mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* MUL-7053 fix(daemon): explain the codex retired-compaction 404
Codex fails a turn with:
Error running remote compact task: unexpected status 404 Not Found:
{"detail":"Not Found"}, url: .../codex/responses/compact, cf-ray: ...
and stops there. The line that chose that URL — `remote_compaction_v2 =
false` under `[features]` in ~/.codex/config.toml — appears nowhere in the
text, so the error names no file, no key and no remedy. Codex before 26.901
ignored that value, so a line pasted long ago as a workaround for an
unrelated defect starts failing on upgrade without the user touching
anything. Nor does it resolve on its own: the conversation stays above the
auto-compact threshold, so every following turn retries the same retired
call. The reporter in GH #8000 had to open an issue to learn the fix was
deleting one line.
Recognise the failure and append the remedy to the error text, the same way
the Hermes "no LLM provider configured" hint works — a constant, kept clear
of every phrase taskfailure.Classify and the resume guards match, since it
is persisted in agent_task_queue.error and re-scanned there indefinitely.
The raw error still reaches the user unchanged; the hint rides behind it.
The compaction marker alone does not fire it: a transient 5xx or a rate
limit on the same call would send users to edit a file that was never the
problem. It needs the 404 status, or the legacy `responses/compact` path,
which is evidence of the stale flag whatever status came back.
Text only, deliberately. This does NOT retire the session: unlike the
resume-unsafe failures classified alongside it, the thread is recoverable
and recovers by itself once the flag is gone, so dropping the session
pointer would discard the conversation the one-line fix brings back.
Not doing what PR #8019 proposed — forcing `--enable remote_compaction_v2`
in daemon-managed sessions. That override carries no Multica semantics
(unlike fast_mode, which honours a tier the user paid for), upstream has
stated it will fix the flag, and it would then be permanent dead code that
blocks anyone with a legitimate reason to disable it.
Upstream: openai/codex#42468
Co-authored-by: multica-agent <[email protected]>
* MUL-7053 docs(codex): state when the compaction hint can be deleted
The hint is scoped to a defect upstream intends to fix, so the question it
has to answer for a future reader is what happens then. Under either
upstream remedy — Stage::Removed or a 404 fallback — the predicate stops
matching and nothing has to be unwound: it overrides no config and blocks
nobody from disabling the flag deliberately. Record that, and the contrast
with the override declined in #8019, next to the code rather than in the PR
thread.
Co-authored-by: multica-agent <[email protected]>
* MUL-7053 fix(codex): key the compaction hint on the route, not the status
Two ways the hint could fire on a case its remedy does not fix, both found
in review of #8032.
Requiring only the compaction marker plus a 404 was wrong about which route
failed. Codex wraps v2 compaction failures in the same "Error running remote
compact task" prefix (compact_remote_v2.rs), and a v2 stream can answer 404
too — so a user whose setting was already correct would be told to go turn
it off. Require the retired /responses/compact path instead: it cannot be
produced by the v2 route, and a compaction request reaching it means the
legacy route was selected whatever status came back. The status then stops
mattering, so a non-404 on that path matches too.
That gives up the case where Codex reports the failure without the url tail.
Nothing else in that text identifies the route and the two errors are
otherwise indistinguishable, so the choice was between missing some cases
and misdirecting the users whose config was never the problem. A missed hint
costs what today already costs; a wrong one spends someone's afternoon.
The hint also asserted the shared config file was the source. It is the
common case and the only one the upstream reports mention, but the launch
arguments reach the same state: codexBlockedArgs blocks only --listen, and
the sole feature-conflict stripping here is scoped to fast_mode under an
explicit service tier, so `--disable remote_compaction_v2` and
`-c features.remote_compaction_v2=false` survive from custom args, daemon
extra args, and a custom runtime profile's prefix alike. Anyone in that case
would have followed the hint to edit a line that is not there. The text now
names both sources — it stays a constant, which is load-bearing for the
resume guards, so it cannot narrow itself to the source that actually
applied. TestCodexLaunchArgsCanSelectTheRetiredCompactionRoute pins that the
argument half describes something real, and fails if a later change starts
stripping those spellings.
Also drops the "before 26.901" / "years ago" framing, which mixed the
Desktop build numbers with CLI versions and overstated how long the
workaround had been circulating.
Co-authored-by: multica-agent <[email protected]>
* MUL-7053 fix(daemon): stop the compaction hint contradicting itself
Adding the launch arguments as a second source left the closing line behind.
The hint sent people to check custom_args, a daemon's extra args, or a
profile's fixed args, and then asserted nothing on the Multica side needed
changing — stranding exactly the users that second source was added for,
which is the same dead end the addition was meant to close.
Scope the exclusion to the one target that is genuinely wrong to edit: the
per-task copy, which is rebuilt from the shared config every run, so an edit
there is discarded. Both real sources are simply re-read on the next run, so
that is what the text now says.
Also covers the third argv region the hint names. The prior test exercised
ExtraArgs and CustomArgs, but a custom runtime profile's fixed args reach the
child through the launch prefix, which has its own filters —
FilterLaunchPrefix, the managed-mcp_config override strip, and the priority
tier's fast_mode strip. The setting survives all three; asserting it is what
makes the hint's third source a checked claim rather than a comment.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
174 lines
7.5 KiB
Go
174 lines
7.5 KiB
Go
package daemon
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/pkg/taskfailure"
|
|
)
|
|
|
|
// codexRetiredCompactionError is the failure exactly as GH #8000 reported it.
|
|
const codexRetiredCompactionError = `Error running remote compact task: unexpected status 404 Not Found: ` +
|
|
`{"detail":"Not Found"}, url: https://chatgpt.com/backend-api/codex/responses/compact, ` +
|
|
`cf-ray: a35507973eee3d57-SJC, request id: 9eed88ee-822d-446e-9b73-df49d56fe7e0`
|
|
|
|
func TestAnnotateCodexRetiredCompaction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("explains what codex could not", func(t *testing.T) {
|
|
t.Parallel()
|
|
got := annotateCodexRetiredCompaction(codexRetiredCompactionError, "codex")
|
|
|
|
// The original text has to survive: it is what the runtime actually
|
|
// said, and the hint is additive context, not a replacement.
|
|
if !strings.Contains(got, "Error running remote compact task") {
|
|
t.Errorf("the runtime's own message must be preserved, got: %s", got)
|
|
}
|
|
// The hint has to name what Codex's own error never mentions: the
|
|
// setting, and every place it can be turned off. Being a constant, it
|
|
// cannot know which source applied — so naming only the config file
|
|
// would send anyone who set it in launch arguments to edit a line that
|
|
// is not there, the same dead end this hint exists to prevent.
|
|
for _, want := range []string{
|
|
"remote_compaction_v2",
|
|
"config.toml",
|
|
"--disable remote_compaction_v2",
|
|
"-c features.remote_compaction_v2=false",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("hint must mention %q, got: %s", want, got)
|
|
}
|
|
}
|
|
// Having sent people to look at Multica-side launch arguments, the
|
|
// hint must not then tell them nothing on the Multica side needs
|
|
// changing. The only target it can safely exclude is the generated
|
|
// per-task copy, which is rebuilt from the shared config every run.
|
|
if strings.Contains(strings.ToLower(got), "nothing needs changing") {
|
|
t.Errorf("hint contradicts its own instruction to check launch arguments, got: %s", got)
|
|
}
|
|
if !strings.Contains(got, "per-task codex config") {
|
|
t.Errorf("hint must name the copy that is regenerated, got: %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("leaves unrelated failures alone", func(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
errMsg string
|
|
provider string
|
|
}{
|
|
// Only the codex backend can emit this text; a lookalike from
|
|
// another runtime must not be sent to edit ~/.codex.
|
|
{"other provider", codexRetiredCompactionError, "claude"},
|
|
// Compaction failures on the v2 route. They carry the same
|
|
// "Error running remote compact task" prefix and can carry the
|
|
// same status, so firing here would tell a user whose setting is
|
|
// already correct to go turn it off.
|
|
{
|
|
"v2 route returning the same status",
|
|
`Error running remote compact task: unexpected status 404 Not Found: {"detail":"Not Found"}, ` +
|
|
`url: https://chatgpt.com/backend-api/codex/responses`,
|
|
"codex",
|
|
},
|
|
{
|
|
"v2 route failing some other way",
|
|
`Error running remote compact task: remote compaction v2 stream closed before response.completed`,
|
|
"codex",
|
|
},
|
|
// No url tail, so nothing identifies the route — see
|
|
// CodexRetiredCompactionError for why that stays a false negative.
|
|
{
|
|
"no url tail",
|
|
`Error running remote compact task: unexpected status 404 Not Found: {"detail":"Not Found"}`,
|
|
"codex",
|
|
},
|
|
{"unrelated codex failure", "codex turn/start failed: broken pipe", "codex"},
|
|
{"empty", "", "codex"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := annotateCodexRetiredCompaction(tc.errMsg, tc.provider); got != tc.errMsg {
|
|
t.Errorf("error text must be untouched, got: %s", got)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCodexCompactionAnnotationCannotChangeMachineDecisions is the same guard
|
|
// TestAnnotationCannotChangeMachineDecisions applies to the Hermes hint, and it
|
|
// matters more here: the error this hint rides on already contains "Not Found",
|
|
// so a single word — "model" — anywhere in the prose would flip the failure out
|
|
// of agent_error.unknown into model_not_found_or_unavailable, and an "HTTP 404"
|
|
// spelled out in the advice would do the same.
|
|
//
|
|
// The annotated text does not stop at the daemon: it is persisted in
|
|
// agent_task_queue.error and re-scanned there for the life of the row, by
|
|
// service.ResumeUnsafeFailure on the write path and by the ILIKE/regex guards
|
|
// in GetLastTaskSession / GetLastChatTaskSession on every later resume lookup.
|
|
// Those guards decide whether a session pointer survives — and this failure in
|
|
// particular must keep its pointer, since the thread resumes normally once the
|
|
// user deletes the config line.
|
|
func TestCodexCompactionAnnotationCannotChangeMachineDecisions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Reasons paired with the error texts a real run reports them for.
|
|
cases := []struct {
|
|
name string
|
|
errMsg string
|
|
reason string
|
|
}{
|
|
{"the failure this hint targets", codexRetiredCompactionError, "agent_error.unknown"},
|
|
{"poisoned request", `API error 400: {"type":"invalid_request_error","message":"bad image"}`, "api_invalid_request"},
|
|
{"resume overflow", "codex thread/resume failed: token too long", "codex_resume_oversized"},
|
|
{"auth method unresolved", "codex session/resume failed: Could not resolve authentication method", "agent_error.unknown"},
|
|
{"empty assistant message", `messages.2: assistant message content must not be empty`, "agent_error.unknown"},
|
|
{"context overflow", "API Error: prompt is too long: 250000 tokens > 200000 maximum", "agent_error.context_overflow"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
// Annotate unconditionally — the gate is tested above; here we
|
|
// want the hint attached to every shape to prove it is inert.
|
|
annotated := tc.errMsg + codexRetiredCompactionHint
|
|
|
|
if got, want := taskfailure.Classify(annotated), taskfailure.Classify(tc.errMsg); got != want {
|
|
t.Errorf("hint moved the failure reason: %q -> %q", want, got)
|
|
}
|
|
if got, want := service.ResumeUnsafeFailure(tc.reason, annotated),
|
|
service.ResumeUnsafeFailure(tc.reason, tc.errMsg); got != want {
|
|
t.Errorf("hint changed resume safety: %v -> %v", want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCodexRetiredCompactionKeepsSessionResumable pins the deliberate decision
|
|
// behind this being a text-only change: the thread must stay resumable.
|
|
//
|
|
// The failure looks like the poisoned ones around it — it repeats on every
|
|
// following turn and does not recover on its own — but the cause is one line in
|
|
// a config file, not the transcript. Once that line is gone the same
|
|
// conversation compacts and continues, so retiring the session would destroy
|
|
// the context the fix restores. If someone later adds this text to a resume
|
|
// guard, this test is where they find out that was the wrong lesson.
|
|
func TestCodexRetiredCompactionKeepsSessionResumable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reason := taskfailure.Classify(codexRetiredCompactionError).String()
|
|
annotated := annotateCodexRetiredCompaction(codexRetiredCompactionError, "codex")
|
|
if service.ResumeUnsafeFailure(reason, annotated) {
|
|
t.Errorf("compaction 404 must stay resume-safe, reason %q", reason)
|
|
}
|
|
if _, poisoned := classifyPoisonedError(annotated); poisoned {
|
|
t.Error("compaction 404 must not be classified as a poisoned session")
|
|
}
|
|
if _, unsafe := classifyResumeUnsafeTransport("codex", annotated); unsafe {
|
|
t.Error("compaction 404 must not be classified as a resume-unsafe transport failure")
|
|
}
|
|
}
|