mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
Fixes #8116. A chat conversation could be killed permanently by one early failure: the daemon pinned a backend session id from a run that died during setup, and every later message then failed resuming an id the CLI had never persisted. Fixing the model config did not help — the run died at session/resume, before any model handling — so the only escape was abandoning the conversation and losing its context. Two independent defects had to line up, and both are fixed: - Eight backends (qoder, kiro, grok, hermes, kimi, traecli, reasonix, dim) published a fresh session's id when setup failed before any prompt. The existing clear was gated on opts.ResumeSessionID != "", so it only ever covered resumes. hermes, grok and dim additionally pinned the id to the task row before set_model ran, where FailAgentTask's COALESCE made it unretractable; that pin now goes out once setup has succeeded. - Six backends (qoder, hermes, kimi via session/resume; kiro, traecli, grok via session/load) returned a bare failed Result from the resume RPC, so shouldRetryWithFreshSession read the absent flag as "checked, not a rejection" and never retried. They now share classifyACPResumeFailure, which splits cancellation and timeout out first — a user who cancels mid-resume must not be reported as a rejected session. That second defect is why the blast radius is wider than the report: those six never consulted the classifier on the resume path at all, so any permanent resume failure wedged the conversation, not just a ghost pointer. isACPResumeRejected widens the vocabulary at the resume boundary only, where the RPC's single job is turning an id into a live session. It stays positive evidence rather than treating every resume error as a rejection: session/resume carries mcpServers, so an unreachable MCP server, a handshake crash or an expired credential fail there too and none is cured by starting over. It carries no error-code gate, deliberately — the reported qodercli frame is -32602, but that rests on the reporter's capture and gating on it would make the fix miss its own bug if a runtime picked a code outside the set. No server-side change. The daemon's in-turn retry already retires the pointer through retiredSessionID, so existing wedged conversations recover on their first message after the daemon upgrade, with no backfill. Behaviour change: a fresh setup-stage failure no longer reports a session id, so three set_model tests and dim's config-failure resume test asserted the old contract and were updated. dim's coverage of fail-closed config re-application on resume is preserved, driven from a run that completed a prompt. Reviewed by Steve Jobs over three rounds; the predicate was tightened twice as a result — an over-wide adjacency window that matched auth errors, and a stop-list for request-shaped complaints.
171 lines
9.4 KiB
Go
171 lines
9.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// isACPResumeRejected reports whether an error returned by session/resume or
|
|
// session/load means the runtime refused the session id we asked it to restore.
|
|
//
|
|
// It is deliberately wider than isACPSessionNotFound, and deliberately scoped
|
|
// to that one RPC. session/resume and session/load have exactly one job — turn
|
|
// a recorded id into a live session — so a rejection there is about the id
|
|
// itself far more often than at set_model or prompt time, where the same words
|
|
// could be about the model, the provider or the network. Calling this outside
|
|
// the resume boundary would lose that context and start discarding healthy
|
|
// conversation pointers.
|
|
//
|
|
// It stays positive evidence, not exclusion. Treating EVERY resume error as a
|
|
// rejection is the tempting alternative and it is wrong: session/resume carries
|
|
// mcpServers, so an unreachable MCP server fails it, and so do a runtime
|
|
// handshake crash, an expired provider credential and a missing cwd. None of
|
|
// those is cured by starting over, and shouldRetryWithFreshSession's contract
|
|
// is that a false answer means "checked, and this was not a rejection". What
|
|
// this widens is the vocabulary, not the burden of proof: instead of three
|
|
// literal phrases it accepts any wording that names a session-shaped noun and
|
|
// says, right next to it, that the thing is unusable. That is what GH #8116
|
|
// slipped through — qodercli answers "Invalid session identifier <id>", which
|
|
// no literal in the original set matched, so a conversation pinned to a dead
|
|
// id retried it forever.
|
|
func isACPResumeRejected(err error) bool {
|
|
var rpcErr *acpRPCError
|
|
if !errors.As(err, &rpcErr) {
|
|
return false
|
|
}
|
|
// No isACPSessionErrorCode gate here, unlike isACPSessionNotFound, and that
|
|
// is a deliberate narrowing of what this fix depends on. The reported
|
|
// qodercli frame is invalid_params (-32602) — inside the accepted set — but
|
|
// that rests on the reporter's capture rather than anything we can
|
|
// re-derive: a qodercli without a login answers session/resume with an auth
|
|
// error long before it ever looks the id up, so the rejection path is
|
|
// unreachable locally. Gating on the code would make the whole fix miss its
|
|
// own bug if a runtime picks a code outside the set, and the code buys
|
|
// nothing at this boundary anyway: a runtime that states the session is
|
|
// unusable has said so whatever number it attaches. The wording carries the
|
|
// decision — which is what isACPSessionNotFound's own doc already concedes
|
|
// about the generic -32000 and -32603.
|
|
//
|
|
// Message and Data only. acpRPCError.Method holds "session/resume", whose
|
|
// own "session" would otherwise satisfy the noun half of every error this
|
|
// RPC can produce and quietly turn the predicate into the exclusion rule
|
|
// the doc above rejects.
|
|
text := strings.ToLower(rpcErr.Message + " " + rpcErr.Data)
|
|
// Delete request-shaped complaints before matching rather than returning
|
|
// false on sight of one. A message can carry both — "invalid session
|
|
// request: session not found" — and vetoing the whole string would throw
|
|
// away a real rejection sitting next to a complaint about our own call.
|
|
//
|
|
// This runs BEFORE both wording checks, which is why the isACPSessionNotFound
|
|
// literals are consulted through acpSessionNotFoundWording rather than by
|
|
// calling the helper on the raw error: "unknown session" is one of those
|
|
// literals, so "unknown session capability requested" would otherwise be
|
|
// answered true before the scrub ever ran.
|
|
text = acpSessionRequestComplaintRe.ReplaceAllString(text, " ")
|
|
return acpSessionNotFoundWording(text) || acpSessionUnusableRe.MatchString(text)
|
|
}
|
|
|
|
// acpSessionUnusableRe matches a runtime saying the session id itself is no
|
|
// good. Two shapes, because that is how the wordings actually read:
|
|
//
|
|
// Invalid session identifier "27d8031c-…" (qodercli — verdict, then noun)
|
|
// unknown session <id> (Reasonix — verdict, then noun)
|
|
// Session not found (Hermes — noun, then verdict)
|
|
// session ses_abc does not exist (noun, id, then verdict)
|
|
// No session found with id ses_abc (Kiro — the "no <noun> found" shape)
|
|
//
|
|
// The verdict-first form demands the noun IMMEDIATELY after the verdict, and the
|
|
// noun-first form allows only an "id"/"identifier" word and one id-shaped token
|
|
// in between. That tightness is the whole safety argument, and a looser window
|
|
// is not a detail: the natural English for errors this RPC really does produce
|
|
// puts the same two halves a few words apart while meaning something entirely
|
|
// different — "unknown error while loading session", "invalid token for session
|
|
// abc", "invalid credentials for this session", "cwd does not exist for session
|
|
// abc". Those are auth and infrastructure failures, which Result.ResumeRejected
|
|
// is explicitly documented never to flag, and matching one does not merely waste
|
|
// a retry: it retires a live conversation's pointer and forks it irreversibly.
|
|
// TestIsACPResumeRejected pins every one of them as a negative.
|
|
//
|
|
// The "no <noun> found" alternative is deliberately spelled out here even
|
|
// though isACPSessionNotFound already matches that literal: that helper keeps
|
|
// its error-code gate, so relying on it for this shape would quietly reinstate
|
|
// the code dependency this predicate exists without. Kiro's real frame is
|
|
// -32603 and would pass the gate anyway; the point is that the wording alone
|
|
// decides, for every shape and not just the ones in the regex.
|
|
var acpSessionUnusableRe = regexp.MustCompile(
|
|
`(no such|unknown|invalid|expired|unrecogni[sz]ed|nonexistent|missing)\s+(session|conversation|thread)` +
|
|
`|no\s+(session|conversation|thread)\s+found` +
|
|
`|(session|conversation|thread)(\s+(id|identifier))?(\s+"?[\w-]+"?)?\s+(is\s+)?(not found|does not exist|doesn't exist|no longer exists|expired|invalid|unknown|unrecogni[sz]ed)`)
|
|
|
|
// acpSessionRequestComplaintRe matches the one verdict-first shape that is NOT
|
|
// about the recorded session: a generic noun after it turns the phrase into a
|
|
// complaint about the call we just made. "Invalid session parameters: cwd must
|
|
// be absolute", "invalid session request" and "unknown session capability
|
|
// requested" all say our request was malformed, not that the transcript is
|
|
// gone — and a fresh session would fail identically, after the pointer had
|
|
// already been retired. Reaching one of these needs a client-side bug first,
|
|
// which is why it is a stop-list rather than a redesign.
|
|
var acpSessionRequestComplaintRe = regexp.MustCompile(
|
|
`(no such|unknown|invalid|expired|unrecogni[sz]ed|nonexistent|missing)\s+(session|conversation|thread)\s+(params?|parameters?|request|requests|config|configuration|option|options|capability|capabilities|mode|setup)`)
|
|
|
|
// setupFailureWithholdsSessionID reports whether a run that failed during setup
|
|
// — after session/new, before session/prompt — must report an empty SessionID
|
|
// instead of the id it just created.
|
|
//
|
|
// True exactly when the session is fresh. Such a session has no transcript
|
|
// behind it: no prompt was ever sent, so there is no conversation to continue
|
|
// and withholding the id costs one extra session/new next turn. Publishing it
|
|
// bets that every ACP runtime persists a never-prompted session, and qodercli
|
|
// does not — it exits without writing one, leaving the daemon pinned to an id
|
|
// that does not exist and every later message in that chat failing to resume it
|
|
// forever (GH #8116).
|
|
//
|
|
// A RESUMED session is the opposite case and keeps today's behaviour: its
|
|
// transcript predates this run, so the id stays unless the runtime actively
|
|
// rejected it.
|
|
func setupFailureWithholdsSessionID(opts ExecOptions) bool {
|
|
return opts.ResumeSessionID == ""
|
|
}
|
|
|
|
// classifyACPResumeFailure turns an error from session/resume or session/load
|
|
// into this turn's terminal status, message and resume-rejection flag. Every
|
|
// ACP backend that resumes shares it so the six that used to return a bare
|
|
// failed Result cannot drift apart from each other again.
|
|
//
|
|
// Cancellation and timeout are checked FIRST, and that ordering is deliberate.
|
|
// A user who cancels mid-resume, or a turn that runs out of budget, must never
|
|
// be reported as a rejected session: ResumeRejected licenses the daemon to
|
|
// abandon the recorded conversation and re-run the whole task from a fresh one,
|
|
// which is the opposite of what a cancel asked for. When a real rejection and a
|
|
// cancel race, reading it as a cancel is the recoverable mistake — the pointer
|
|
// survives, and the next turn resumes, gets the same rejection with no cancel
|
|
// in flight, and self-heals then.
|
|
func classifyACPResumeFailure(runCtx context.Context, backend, rpc string, err error, timeout time.Duration, logger *slog.Logger) (status, errText string, rejected bool) {
|
|
switch runCtx.Err() {
|
|
case context.DeadlineExceeded:
|
|
if timeout > 0 {
|
|
return "timeout", fmt.Sprintf("%s timed out after %s during %s", backend, timeout, rpc), false
|
|
}
|
|
return "timeout", fmt.Sprintf("%s timed out during %s", backend, rpc), false
|
|
case context.Canceled:
|
|
return "aborted", "execution cancelled", false
|
|
}
|
|
errText = fmt.Sprintf("%s %s failed: %v", backend, rpc, err)
|
|
if !isACPResumeRejected(err) {
|
|
return "failed", errText, false
|
|
}
|
|
if logger != nil {
|
|
logger.Warn("resumed session rejected by the runtime; the daemon will retry from a fresh session",
|
|
"backend", backend,
|
|
"rpc", rpc,
|
|
"error", err,
|
|
)
|
|
}
|
|
return "failed", errText, true
|
|
}
|