Files
multica/server/internal/daemon/poisoned_test.go
Bohan Jiang 30318b79bc MUL-5426: fix(daemon): retire sessions whose history the provider refuses to replay (#6083)
* fix(daemon): retire sessions whose history the provider refuses to replay

A run killed mid-reply (machine shutdown, force-quit, SIGKILL) can leave an
empty assistant message in the agent CLI's transcript. Every later resume
replays it, the provider rejects the request, and the (agent, issue) pair is
bricked with no self-healing and no user-facing recovery.

Multica already has the mechanism for this — poisoned-session classification —
but its detector paired "400" with "invalid_request_error", which is the
Anthropic wire shape. The same defect reported by any other provider carried
neither token, so it classified as agent_error.unknown: resume-safe by
omission. GetLastTaskSession kept handing back the dead session on every
follow-up, manual Rerun resolved it through the same predicate, and the
in-turn fresh-session retry never fired because ResumeRejected is false here
(nothing rejected the resume — the transcript loaded and the provider refused
to replay it).

Add taskfailure.UnresumableHistory, which recognises the defect by what the
provider says is wrong — some content is empty, and here is which message in
the history — rather than by status code or provider name. Both signals are
required, so a tool reporting "field must not be empty" does not match.

Wire it into the four places that decide whether a session survives:

- classifyPoisonedError, so the task is written as api_invalid_request
- shouldRetryWithFreshSession, so the turn recovers on all 17 backends
  instead of the subset whose adapter learned to detect it; the tools == 0
  gate is unchanged, so a run that already used a tool is never re-run
- ResumeUnsafeFailure, covering the manual-Rerun path
- both resume queries, as defense-in-depth for hosts whose daemon predates
  this (self-host daemons upgrade on their own cadence)

Fixes #6066. Also covers the daemon half of #5760.

Co-authored-by: multica-agent <github@multica.ai>

* fix(session): close the Chat and fresh-retry paths that resurrect a poisoned session

Review found the previous commit stopped short in two places, both of which
put the dead transcript back in play.

Chat never consulted the guarded query. The claim handler reads
chat_session.session_id first and only falls back to GetLastChatTaskSession
when it is empty, so a poisoned pointer there bypasses every filter that query
applies. The fail path merely declined to OVERWRITE the pointer, leaving it in
place. It now clears it in the same transaction, matched on session and
runtime so a concurrent turn's newer pointer survives. The promote guard moves
to ResumeUnsafeFailure as well — the reason-only check passed an un-upgraded
daemon's agent_error.unknown row and re-pinned what the clear had just removed.

GetLastChatTaskSession also kept the row-level filter the issue query dropped
in GH #5975: it discarded the newest poisoned row and fell back to an older
completed row carrying the same dead session. It now judges each session by
its latest terminal state, matching GetLastTaskSession.

A recovered turn could not retire anything. A terminal report carried one
session_id, and an empty one meant both "nothing to report" and "forget the
old session", so a fresh-session retry that SUCCEEDED left the id it retried
away from selectable — through an older completed row on the issue, or through
the chat pointer. agent_task_queue.retired_session_id records the abandonment
itself, reported on every terminal path including completed, and both resume
lookups exclude it. This is the contract gap the previous PR deferred; the
fresh-retry path now runs on all backends, so deferring it is not safe.

Also narrows what the cross-backend test claims: it pins the shared decision,
not that all 17 adapters surface the error into Result.Error (#5760 is the
counter-example), and says so.

Co-authored-by: multica-agent <github@multica.ai>

* test(session): require pgx.ErrNoRows in the resume-exclusion assertions

The `if err == nil && prior.SessionID.Valid` form these tests shared is
false-green: any real fault — undefined column, syntax error, dead connection
— makes err non-nil, so the condition is false and the test passes. Run
against a database missing this branch's new column, the exclusion tests
reported PASS on a SQLSTATE 42703, meaning they could not have caught a broken
query.

requireSessionExcluded demands pgx.ErrNoRows specifically and fails loudly on
anything else, so a green run now means the filter worked rather than the
query never ran.

Applied to all nine sites, not just the four this branch added: the other five
guard the same GetLastTaskSession exclusion behaviour that this branch
changes, so leaving them false-green would leave the change under-tested. All
nine pass on a correctly migrated database.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:54:51 +08:00

283 lines
9.9 KiB
Go

package daemon
import (
"strings"
"testing"
"github.com/multica-ai/multica/server/pkg/agent"
)
func TestClassifyPoisonedOutput(t *testing.T) {
cases := []struct {
name string
output string
wantOK bool
wantReason string
}{
{
name: "iteration limit canonical",
output: "I reached the iteration limit and couldn't generate a summary.",
wantOK: true,
wantReason: FailureReasonIterationLimit,
},
{
name: "iteration limit case insensitive",
output: "I REACHED THE ITERATION LIMIT and stopped",
wantOK: true,
wantReason: FailureReasonIterationLimit,
},
{
name: "fallback meta message",
output: "Put your final update inside the content string. Keep it concise.",
wantOK: true,
wantReason: FailureReasonAgentFallbackMsg,
},
{
name: "real conclusion is not poisoned",
output: "Fixed the bug in auth.go and pushed PR #42.",
wantOK: false,
},
{
name: "empty output",
output: "",
wantOK: false,
},
{
name: "mentions iteration but not the marker",
output: "Each iteration of the loop processes one record.",
wantOK: false,
},
{
// Regression guard for the GPT-Boy review on MUL-1630:
// a real review/analysis that quotes both markers must not
// be misclassified. Without the length cap, this entire
// PR's review thread would tank as a poisoned failure.
name: "long review quoting both markers is not poisoned",
output: `Review for the rerun fix.
Detection markers under consideration:
- "I reached the iteration limit and couldn't generate a summary."
- "Put your final update inside the content string. Keep it concise."
The implementation looks correct: the daemon classifies these as
fallback output, persists a dedicated failure_reason, and the SQL
filter excludes them from the resume lookup. Resume-safe auto-retry
still keeps the resume contract, while poisoned sessions are filtered.
Approving with a follow-up note about the matcher being too permissive
on long outputs.`,
wantOK: false,
},
{
name: "marker buried inside a long agent conclusion",
output: strings.Repeat("All checks passed and the bug is fixed. ", 10) + "i reached the iteration limit while debugging earlier.",
wantOK: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reason, ok := classifyPoisonedOutput(tc.output)
if ok != tc.wantOK {
t.Fatalf("classifyPoisonedOutput(%q) ok=%v, want %v", tc.output, ok, tc.wantOK)
}
if ok && reason != tc.wantReason {
t.Fatalf("classifyPoisonedOutput(%q) reason=%q, want %q", tc.output, reason, tc.wantReason)
}
})
}
}
func TestClassifyPoisonedError(t *testing.T) {
cases := []struct {
name string
errMsg string
wantOK bool
wantReason string
}{
{
// MUL-1921 reproducer: a markdown image in the issue
// description was downloaded as a 146-byte CDN auth-error
// XML, then surfaced to the LLM as a base64 PNG. The API
// rejected it and every follow-up task replayed the same
// poisoned conversation.
name: "claude could not process image",
errMsg: `API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"Could not process image"},"request_id":"req_011CarVEtBLj95zD7i8xardY"}`,
wantOK: true,
wantReason: FailureReasonAPIInvalidRequest,
},
{
name: "prompt too long is also poisoning",
errMsg: `API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 213000 tokens > 200000 maximum"}}`,
wantOK: true,
wantReason: FailureReasonAPIInvalidRequest,
},
{
name: "case insensitive",
errMsg: `api error: 400 {"type":"INVALID_REQUEST_ERROR"}`,
wantOK: true,
wantReason: FailureReasonAPIInvalidRequest,
},
{
// Rate-limit must NOT be classified as poisoning — those
// recover on retry and we want session resume to keep the
// in-flight conversation memory.
name: "429 rate limit is transient",
errMsg: `API Error: 429 {"type":"error","error":{"type":"rate_limit_error","message":"Number of request tokens has exceeded your per-minute rate limit"}}`,
wantOK: false,
},
{
name: "5xx overloaded is transient",
errMsg: `API Error: 529 {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`,
wantOK: false,
},
{
// 401/403 mean the daemon's credentials are bad; resuming
// the session won't fix it but the failure is environmental,
// not a poisoned conversation. Out of scope for this
// classifier.
name: "401 auth error",
errMsg: `API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"invalid api key"}}`,
wantOK: false,
},
{
// A tool surfacing a 400 from somewhere unrelated must not
// trigger the classifier — only the combination of 400 +
// invalid_request_error indicates a corrupted body.
name: "tool 400 without invalid_request_error",
errMsg: `agent tool returned status 400: not found`,
wantOK: false,
},
{
name: "empty error message",
errMsg: "",
wantOK: false,
},
{
name: "unrelated execution error",
errMsg: "claude execution timeout after 10m",
wantOK: false,
},
{
// GH #6066: a run killed mid-reply left an empty assistant
// message in the transcript. The provider refuses to replay it
// and words the refusal with neither "invalid_request_error"
// nor a bare "400", so before taskfailure.UnresumableHistory
// this landed in agent_error.unknown — resume-safe by omission
// — and every later task on the issue resumed the dead session.
name: "gh6066 empty assistant message in history",
errMsg: "Invalid request: the message at position 37 with role 'assistant' must not be empty",
wantOK: true,
wantReason: FailureReasonAPIInvalidRequest,
},
{
// GH #5760: the same defect on Kimi/ACP. Has a "400" but no
// "invalid_request_error", so the Anthropic clause missed it too.
name: "gh5760 kimi empty assistant message",
errMsg: "kimi provider error: provider.api_error: 400 the message at position 43 with role 'assistant' must not be empty",
wantOK: true,
wantReason: FailureReasonAPIInvalidRequest,
},
{
// The narrowness guard: an emptiness complaint with no locator
// into the message history is some tool's validation error, and
// discarding a healthy session over it would lose real context.
name: "tool validation emptiness is not poisoning",
errMsg: "validation error: field must not be empty",
wantOK: false,
},
{
// GH #5975: a Kiro resume rejected because the session
// history replays an image over the provider's max pixel
// dimensions. The conversation is unresumable, so it must be
// classified api_invalid_request even though the error is a
// -32603 "Internal error" (no 400 / invalid_request_error).
name: "kiro oversized history image",
errMsg: `kiro session/prompt failed: session/prompt: Internal error (code=-32603, data=Encountered an error in the response stream: messages.14.content.0.image.source.base64.data: At least one of the image dimensions exceed max allowed size: 8000 pixels)`,
wantOK: true,
wantReason: FailureReasonAPIInvalidRequest,
},
{
// A plain -32603 "Internal error" (the transient close
// handshake) shares the code but names neither image marker,
// so it must NOT be classified as poisoning.
name: "plain kiro internal error is not poisoning",
errMsg: `kiro session/prompt failed: session/prompt: Internal error (code=-32603, data=Kiro failed to generate a response)`,
wantOK: false,
},
{
// The dimension phrase alone (without the image-content
// marker) is too weak to classify as a poisoned history.
name: "dimension phrase without image-content marker",
errMsg: `some tool reported: image dimensions exceed max allowed size: 8000 pixels`,
wantOK: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reason, ok := classifyPoisonedError(tc.errMsg)
if ok != tc.wantOK {
t.Fatalf("classifyPoisonedError(%q) ok=%v, want %v", tc.errMsg, ok, tc.wantOK)
}
if ok && reason != tc.wantReason {
t.Fatalf("classifyPoisonedError(%q) reason=%q, want %q", tc.errMsg, reason, tc.wantReason)
}
})
}
}
func TestClassifyResumeUnsafeTimeout(t *testing.T) {
cases := []struct {
name string
provider string
errMsg string
wantOK bool
wantReason string
}{
{
name: "codex semantic inactivity",
provider: "codex",
errMsg: agent.CodexSemanticInactivityMarker + " after 10m0s without agent progress (last activity: tool-result:exec_command)",
wantOK: true,
wantReason: FailureReasonCodexSemanticInactivity,
},
{
name: "codex first turn no progress",
provider: "codex",
errMsg: agent.CodexFirstTurnNoProgressMarker + ` after 30s: received turn start but no item, turn/completed, or error event`,
wantOK: true,
wantReason: FailureReasonCodexSemanticInactivity,
},
{
name: "codex ordinary timeout remains resumable",
provider: "codex",
errMsg: "codex timed out after 30m0s",
wantOK: false,
},
{
name: "other provider same text is not classified",
provider: "claude",
errMsg: agent.CodexSemanticInactivityMarker + " after 10m0s without agent progress",
wantOK: false,
},
{
name: "empty error",
provider: "codex",
errMsg: "",
wantOK: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reason, ok := classifyResumeUnsafeTimeout(tc.provider, tc.errMsg)
if ok != tc.wantOK {
t.Fatalf("classifyResumeUnsafeTimeout(%q, %q) ok=%v, want %v", tc.provider, tc.errMsg, ok, tc.wantOK)
}
if ok && reason != tc.wantReason {
t.Fatalf("classifyResumeUnsafeTimeout(%q, %q) reason=%q, want %q", tc.provider, tc.errMsg, reason, tc.wantReason)
}
})
}
}