From 54c9fa9109719902c049f3de75d1cc67ef49238e Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 17 Oct 2026 19:29:36 +0800 Subject: [PATCH 01/11] fix(session): require explicit work orders for auto tasks --- internal/manual/chat/tasks.md | 22 +++++++++---- internal/session/route_judge.go | 33 ++++++++++++++++++++ internal/session/route_judge_test.go | 46 ++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index fd7ad5a2f1..bc2fcf8202 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -624,10 +624,17 @@ what you asked and the first two lines of the reply, and decides one thing: shou been work? The same judge also starts reading your message **the moment you send it**, alongside the reply rather than in front of it, which is its own section below. +**A task starts only on a direct work order, not because the subject sounds important.** +Every question is answered in the conversation, including "can we get screenshots headless?" +Suggestions, product direction, goals and plans are discussion unless you directly instruct +codeaf to act on them. "Please build the headless screenshot flow" is an instruction; "we +should have headless screenshots" is not. + **A yes is asked twice.** The cheap model only screens — it reads every substantial message and every wordy turn, which is why it is cheap — and it cannot start anything on its own. Before a task exists, the same question is put once more to your **planner** model, in the same words, with none of the -first answer in front of it. Only if both say yes does the work start, and then one dim line +first answer in front of it. Both must recognize a direct, non-question instruction, and +both must agree the work belongs on a task. Only then does the work start, and one dim line goes into the transcript: ``` @@ -680,6 +687,9 @@ which works at once when it is the only running task, and needs `alt+t` to walk first when there are others — or `Stop` on the pointer. Nothing about it is special from the moment it exists. +If a stopped task's result wakes the conversation, answering that result does not start the +same request again. A new instruction you type is a fresh request and can be judged normally. + **There is no setting that turns this off.** No `/settings` row switches it, and nothing you type disarms it for the session. What bounds it is the list above, and the stop. @@ -710,11 +720,11 @@ four jobs, and reading the request cannot tell them apart. The answer doing the this moment now only decides **how soon the answer is told what it has run up**, and the answer decides for itself from there. -**What the judge is looking for is a request whose fastest correct answer is not a -conversation:** several independent deliverables in one message, a sweep over many files or -many sources, or an answer you would otherwise sit and watch a spinner for. A question, a -discussion, one obvious edit and a few tool calls are all answered here, however large the -subject sounds. +**The judge needs both authorization and work worth handing off.** The fastest correct answer +must not be a conversation: several independent deliverables in one message, a sweep over many +files or sources, or an answer you would otherwise sit and watch a spinner for. A question, +discussion, suggestion or product direction is answered here, however large the subject +sounds. An explicit instruction to do a small edit or a few tool calls stays here too. **Everything that bounds the auto-start above bounds this too** — one start every three turns across both moments, six words, two models agreeing, silence when either cannot diff --git a/internal/session/route_judge.go b/internal/session/route_judge.go index 9b13aec171..1dfa557ba2 100644 --- a/internal/session/route_judge.go +++ b/internal/session/route_judge.go @@ -309,6 +309,11 @@ or {"work": true, "wide": true, "goal": "...", "acceptance": "...", "checks": ["..."], "why": "..."} + work true only when the person gave a direct instruction to do work, in a + non-question. Every question is false, including "can we..." and + "could you...". Product direction, suggestions, goals and plans are + false unless the person directly instructs you to act on them. Judge + authorization before effort: a useful or lengthy job is not permission. wide true when the work is BROAD — many files, many sources, several independent parts — so the one worker that starts on it is allowed to hand the parts out once it has opened the material. Leave it out for @@ -418,6 +423,11 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools } asked = steward.Ask() } + // A stopped task's result wakes a turn so the session can report what + // happened. That reply is not a fresh instruction to start the same work. + if user.wake && a.stoppedTaskResultFor(asked) { + return judgeRuling{} + } // THE FLOOR IS THE ASK, and it is cheaper than a model call. A commit, // an undo, a one-line edit or a single read is answered here; paying a // judge to be told it is work is how F26's commit became a task. @@ -469,6 +479,29 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools return judgeRuling{start: true, verdict: verdict, turn: turn, ahead: ahead} } +// stoppedTaskResultFor reports whether this turn is answering the result of a +// stopped task that was started from the same request. A new message from the +// person is a new instruction and remains eligible for the ordinary judge. +func (a *Agent) stoppedTaskResultFor(asked string) bool { + a.mu.Lock() + results := append([]uint64(nil), a.turnResults...) + a.mu.Unlock() + if len(results) == 0 { + return false + } + graph := a.tasker() + if graph == nil { + return false + } + for _, id := range results { + node := graph.node(id) + if node != nil && node.wasStopped() && strings.TrimSpace(node.request()) == strings.TrimSpace(asked) { + return true + } + } + return false +} + // applyRouteJudge is the other half of [Agent.readRouteJudge]: the decision, // spent. The TURN calls it, and it calls it at the one moment it knows the turn // is really over — after the reader that may re-open the turn has said it will diff --git a/internal/session/route_judge_test.go b/internal/session/route_judge_test.go index cef231432d..3394355dc7 100644 --- a/internal/session/route_judge_test.go +++ b/internal/session/route_judge_test.go @@ -1902,6 +1902,52 @@ func TestBothBriefsAskForOneObjectAndOnlyOneOfThemMentionsAnAnswer(t *testing.T) } } +func TestRouteJudgesRequireDirectNonQuestionWorkOrders(t *testing.T) { + for _, want := range []string{ + "direct instruction to do work", + "in a non-question", + "Every question is false", + "Product direction, suggestions, goals and plans are false", + "authorization before effort", + } { + if !strings.Contains(routeVerdictContract, want) { + t.Errorf("the shared route contract does not require %q", want) + } + } + if !strings.Contains(routeAheadBrief, routeVerdictContract) || !strings.Contains(routeJudgeBrief, routeVerdictContract) { + t.Fatal("one route judge can start work without the authorization rule") + } +} + +func TestAStoppedTaskResultCannotAutoStartItsRequestAgain(t *testing.T) { + completer := &routeCompleter{answer: "I can report what happened.", verdict: routeYes} + agent, _, _ := routeAgent(t, completer) + graph := agent.graph() + id := graph.reserve() + graph.admit(id, taskSpec{title: "the requested audit", request: routeAsk, brief: "audit", acceptance: "done"}) + node := graph.node(id) + node.markStopped() + agent.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + agent.mu.Lock() + agent.turnResults = []uint64{id} + agent.mu.Unlock() + + ruling := agent.readRouteJudge(context.Background(), userMessage{wake: true}, false, completer.answer) + if ruling.start { + t.Fatal("a stopped task result started the same request again") + } + if completer.asked() != 0 || completer.confirms() != 0 { + t.Fatalf("the route judge spent %d screen calls and %d confirms on a stopped result", completer.asked(), completer.confirms()) + } + + // The same words in a new human turn are a fresh instruction. + ruling = agent.readRouteJudge(context.Background(), userText(routeAsk), false, completer.answer) + if !ruling.start { + t.Fatal("a new human instruction was suppressed by an earlier stopped result") + } + ruling.release() +} + // ── the order of the two readers at a turn's end ──────────────────────────── // A JUDGE THAT HAS ALREADY ANSWERED HAS STILL STARTED NOTHING. From 4bc7dae6828245b766ef16475644c10318295977 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 17 Oct 2026 19:43:08 +0800 Subject: [PATCH 02/11] docs(changelog): add entry for PR 1845 --- .../unreleased/1845-explicit-work-orders.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/changes/unreleased/1845-explicit-work-orders.md diff --git a/docs/changes/unreleased/1845-explicit-work-orders.md b/docs/changes/unreleased/1845-explicit-work-orders.md new file mode 100644 index 0000000000..a600aa0157 --- /dev/null +++ b/docs/changes/unreleased/1845-explicit-work-orders.md @@ -0,0 +1,15 @@ +--- +kind: fixed +title: automatic task starts require a direct work order +pr: 1845 +surface: [chat, engine] +invalidates: + - >- + Questions and product direction could be mistaken for permission to start a + task. Both route judges now require a direct non-question work order, and a + stopped task result cannot restart the same request. +--- + +Questions and suggestions stay in the conversation unless the person directly +asks codeaf to do work. A stopped task's result can be reported without starting +the same request again. From b407d2120729980c7a2f4852ac0a27de48b582b4 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 17 Oct 2026 20:15:15 +0800 Subject: [PATCH 03/11] test(session): tolerate prompt line wrapping --- internal/session/route_judge_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/session/route_judge_test.go b/internal/session/route_judge_test.go index 3394355dc7..a42d3d1dd3 100644 --- a/internal/session/route_judge_test.go +++ b/internal/session/route_judge_test.go @@ -1903,6 +1903,7 @@ func TestBothBriefsAskForOneObjectAndOnlyOneOfThemMentionsAnAnswer(t *testing.T) } func TestRouteJudgesRequireDirectNonQuestionWorkOrders(t *testing.T) { + contract := strings.Join(strings.Fields(routeVerdictContract), " ") for _, want := range []string{ "direct instruction to do work", "in a non-question", @@ -1910,7 +1911,7 @@ func TestRouteJudgesRequireDirectNonQuestionWorkOrders(t *testing.T) { "Product direction, suggestions, goals and plans are false", "authorization before effort", } { - if !strings.Contains(routeVerdictContract, want) { + if !strings.Contains(contract, strings.Join(strings.Fields(want), " ")) { t.Errorf("the shared route contract does not require %q", want) } } From 41a2e41d74a20097d5f3c16a77287796597ef5c5 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 19 Oct 2026 16:56:51 +0800 Subject: [PATCH 04/11] fix(session): distinguish polite work requests from questions --- .../unreleased/1845-explicit-work-orders.md | 14 +++--- internal/manual/chat/tasks.md | 22 +++++----- internal/session/route_judge.go | 21 ++++++--- internal/session/route_judge_test.go | 43 ++++++++++++++++--- 4 files changed, 71 insertions(+), 29 deletions(-) diff --git a/docs/changes/unreleased/1845-explicit-work-orders.md b/docs/changes/unreleased/1845-explicit-work-orders.md index a600aa0157..f47edb7f66 100644 --- a/docs/changes/unreleased/1845-explicit-work-orders.md +++ b/docs/changes/unreleased/1845-explicit-work-orders.md @@ -1,15 +1,17 @@ --- kind: fixed -title: automatic task starts require a direct work order +title: automatic task starts distinguish requests from questions pr: 1845 surface: [chat, engine] invalidates: - >- Questions and product direction could be mistaken for permission to start a - task. Both route judges now require a direct non-question work order, and a - stopped task result cannot restart the same request. + task. Both route judges now distinguish clear polite requests from + feasibility questions, ambiguous questions and future direction; a stopped + task result cannot restart the same request. --- -Questions and suggestions stay in the conversation unless the person directly -asks codeaf to do work. A stopped task's result can be reported without starting -the same request again. +Clear polite requests can start work even when phrased as questions. Questions +about feasibility, ambiguous questions and future direction stay in the +conversation. A stopped task's result can be reported without starting the same +request again. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index bc2fcf8202..d4d0533618 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -624,17 +624,19 @@ what you asked and the first two lines of the reply, and decides one thing: shou been work? The same judge also starts reading your message **the moment you send it**, alongside the reply rather than in front of it, which is its own section below. -**A task starts only on a direct work order, not because the subject sounds important.** -Every question is answered in the conversation, including "can we get screenshots headless?" -Suggestions, product direction, goals and plans are discussion unless you directly instruct -codeaf to act on them. "Please build the headless screenshot flow" is an instruction; "we -should have headless screenshots" is not. +**A task starts only when you clearly ask codeaf to do work now.** That can be an imperative +or a polite question whose requested action is clear: "Could you fix the failing tests?" and +"Can you implement headless screenshots and add tests?" both ask for work. A question about +feasibility, like "How would headless screenshots work?", stays in the conversation. "Can we +get screenshots without a display?" is ambiguous without context, so codeaf answers or asks +what you mean before starting. Suggestions and future direction, like "we should support +screenshots eventually", are discussion until you ask codeaf to act. **A yes is asked twice.** The cheap model only screens — it reads every substantial message and every wordy turn, which is why it is cheap — and it cannot start anything on its own. Before a task exists, the same question is put once more to your **planner** model, in the same words, with none of the -first answer in front of it. Both must recognize a direct, non-question instruction, and -both must agree the work belongs on a task. Only then does the work start, and one dim line +first answer in front of it. Both must recognize a clear request to do work now, and both +must agree the work belongs on a task. Only then does the work start, and one dim line goes into the transcript: ``` @@ -722,9 +724,9 @@ answer decides for itself from there. **The judge needs both authorization and work worth handing off.** The fastest correct answer must not be a conversation: several independent deliverables in one message, a sweep over many -files or sources, or an answer you would otherwise sit and watch a spinner for. A question, -discussion, suggestion or product direction is answered here, however large the subject -sounds. An explicit instruction to do a small edit or a few tool calls stays here too. +files or sources, or an answer you would otherwise sit and watch a spinner for. Feasibility +questions, discussion, suggestions and product direction are answered here, however large the +subject sounds. A clear request whose fastest answer takes only a few tool calls stays here too. **Everything that bounds the auto-start above bounds this too** — one start every three turns across both moments, six words, two models agreeing, silence when either cannot diff --git a/internal/session/route_judge.go b/internal/session/route_judge.go index 1dfa557ba2..406af13b1b 100644 --- a/internal/session/route_judge.go +++ b/internal/session/route_judge.go @@ -283,7 +283,7 @@ type routeVerdict struct { // is a condition nobody can check (see [routeVerdict.Acceptance]). const routeJudgeBrief = `You judge ONE turn of a coding assistant, after the fact. The assistant answered the person in WORDS ALONE — it called no tool. You decide one thing: should that turn have been WORK? -WORDS are a question, a discussion, advice, an opinion, a fact, an explanation, a plan somebody asked to read. SMALL WORK is words too, for this purpose: a few tool calls, one obvious edit, a file read and an answer. Handing small work off is slower than doing it, so it is not work. +WORDS include questions about feasibility, discussion, advice, opinions, facts, explanations and plans somebody asked to read. A polite question that clearly asks you to act now can still authorize work, as the work rule below explains. SMALL WORK is words too, for this purpose: a few tool calls, one obvious edit, a file read and an answer. Handing small work off is slower than doing it, so it is not work. WORK is research across several sources, changes across several files, a goal with several independent parts, or anything the person would otherwise watch a spinner for. @@ -309,11 +309,18 @@ or {"work": true, "wide": true, "goal": "...", "acceptance": "...", "checks": ["..."], "why": "..."} - work true only when the person gave a direct instruction to do work, in a - non-question. Every question is false, including "can we..." and - "could you...". Product direction, suggestions, goals and plans are - false unless the person directly instructs you to act on them. Judge - authorization before effort: a useful or lengthy job is not permission. + work true only when the person clearly asks codeaf to do work now. This can + be an imperative or a polite question whose intended action is clear: + "Could you fix the failing tests?" and "Can you implement headless + screenshots and add tests?" both authorize work. A question about + feasibility or explanation, such as "How would headless screenshots + work?", does not. "Can we get screenshots without a display?" is + ambiguous without context, so answer or clarify; do not start work. + "We should support screenshots eventually" is future direction, not + authorization. Judge intended action and timing, not grammar. Product + direction, suggestions, goals and plans do not authorize work unless + the person clearly asks you to act now. Judge authorization before + effort: a useful or lengthy job is not permission. wide true when the work is BROAD — many files, many sources, several independent parts — so the one worker that starts on it is allowed to hand the parts out once it has opened the material. Leave it out for @@ -799,7 +806,7 @@ func routeJudgeQuestion(asked, answered string) string { // to surface features is a keyword rule with a bill attached. const routeAheadBrief = `You judge ONE message a person has just typed to a coding assistant, BEFORE it is answered. Nothing has been done yet: you are reading the REQUEST, not a reply. You decide one thing: is this WORK? -WORDS are a question, a discussion, advice, an opinion, a fact, an explanation, a plan somebody asked to read. SMALL WORK is words too, for this purpose: a few tool calls, one obvious edit, a file read and an answer. Handing small work off is slower than doing it, so it is not work. +WORDS include questions about feasibility, discussion, advice, opinions, facts, explanations and plans somebody asked to read. A polite question that clearly asks you to act now can still authorize work, as the work rule below explains. SMALL WORK is words too, for this purpose: a few tool calls, one obvious edit, a file read and an answer. Handing small work off is slower than doing it, so it is not work. WORK is research across several sources, changes across several files, a goal with several independent parts, or anything the person would otherwise watch a spinner for. Three shapes of request are almost always work: one message that asks for SEVERAL INDEPENDENT DELIVERABLES, a SWEEP across many files or many sources, and anything whose answer is MINUTES OF TOOL CALLS the person can only sit and watch. diff --git a/internal/session/route_judge_test.go b/internal/session/route_judge_test.go index a42d3d1dd3..2532bc5d8e 100644 --- a/internal/session/route_judge_test.go +++ b/internal/session/route_judge_test.go @@ -1902,17 +1902,33 @@ func TestBothBriefsAskForOneObjectAndOnlyOneOfThemMentionsAnAnswer(t *testing.T) } } -func TestRouteJudgesRequireDirectNonQuestionWorkOrders(t *testing.T) { +func TestRouteJudgesDistinguishWorkOrdersFromQuestions(t *testing.T) { contract := strings.Join(strings.Fields(routeVerdictContract), " ") + for _, example := range routeIntentExamples { + text := strings.Join(strings.Fields(example.request), " ") + if got := strings.Contains(example.rule, "authorize work"); got != example.wantWork { + t.Fatalf("evaluation case %s has rule %q for work=%t", example.request, example.rule, example.wantWork) + } + if !strings.Contains(contract, text) { + t.Errorf("the shared route contract omits %s", example.request) + } + if !strings.Contains(contract, example.rule) { + t.Errorf("the shared route contract does not explain why %s is work=%t: missing %q", example.request, example.wantWork, example.rule) + } + for name, brief := range map[string]string{"pre-turn": routeAheadBrief, "post-turn": routeJudgeBrief} { + brief = strings.Join(strings.Fields(brief), " ") + if !strings.Contains(brief, text) || !strings.Contains(brief, example.rule) { + t.Errorf("the %s route prompt does not carry the %s classification", name, example.request) + } + } + } for _, want := range []string{ - "direct instruction to do work", - "in a non-question", - "Every question is false", - "Product direction, suggestions, goals and plans are false", + "clearly asks codeaf to do work now", + "Judge intended action and timing, not grammar", "authorization before effort", } { if !strings.Contains(contract, strings.Join(strings.Fields(want), " ")) { - t.Errorf("the shared route contract does not require %q", want) + t.Errorf("the shared route contract does not state %q", want) } } if !strings.Contains(routeAheadBrief, routeVerdictContract) || !strings.Contains(routeJudgeBrief, routeVerdictContract) { @@ -1920,6 +1936,21 @@ func TestRouteJudgesRequireDirectNonQuestionWorkOrders(t *testing.T) { } } +// routeIntentExamples is the small, reviewable evaluation set for the prompt +// change. The production judge remains model-backed; these labels are the +// expected contract that both independent readers must follow. +var routeIntentExamples = []struct { + request string + wantWork bool + rule string +}{ + {`"Could you fix the failing tests?"`, true, "both authorize work"}, + {`"Can you implement headless screenshots and add tests?"`, true, "both authorize work"}, + {`"How would headless screenshots work?"`, false, "does not"}, + {`"Can we get screenshots without a display?"`, false, "ambiguous without context"}, + {`"We should support screenshots eventually"`, false, "future direction, not authorization"}, +} + func TestAStoppedTaskResultCannotAutoStartItsRequestAgain(t *testing.T) { completer := &routeCompleter{answer: "I can report what happened.", verdict: routeYes} agent, _, _ := routeAgent(t, completer) From 8730f0cddc1f99111e457684e5290f5ddec652ca Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 08:50:55 -0400 Subject: [PATCH 05/11] test(session): cover routing authorization and stopped-result scope Separate feasibility questions from explanation requests in the shared route contract and the searchable chat manual. Pin examples with their complete classification clauses, and prove stopped-result suppression does not block fresh requests or normal remediation. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1845-explicit-work-orders.md | 2 +- internal/manual/chat/tasks.md | 29 +++-- internal/session/route_judge.go | 7 +- internal/session/route_judge_test.go | 107 ++++++++++++------ 4 files changed, 97 insertions(+), 48 deletions(-) diff --git a/docs/changes/unreleased/1845-explicit-work-orders.md b/docs/changes/unreleased/1845-explicit-work-orders.md index f47edb7f66..30b50307f8 100644 --- a/docs/changes/unreleased/1845-explicit-work-orders.md +++ b/docs/changes/unreleased/1845-explicit-work-orders.md @@ -1,6 +1,6 @@ --- kind: fixed -title: automatic task starts distinguish requests from questions +title: automatic task starts require a clear request to act now pr: 1845 surface: [chat, engine] invalidates: diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index d4d0533618..dcb6569c82 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -616,22 +616,33 @@ partial block disappears because that attempt's half-arrived call was discarded; proposal fragment starts a new block. If the call is refused before it ever becomes a question, it settles as `not started · the call was refused` instead of moving forever. +## Will a polite request start a task — feasibility, explanation, suggestions and future direction + +**Automatic task routing needs a clear request to do work now.** An imperative or a +polite question can authorize work: "Could you fix the failing tests?" and "Can you +implement headless screenshots and add tests?" both ask codeaf to act. They remain +eligible for normal routing; a small job that takes only a few tool calls stays here. + +A feasibility question, such as "Is headless screenshot capture possible on CI?", or an +explanation request, such as "How would headless screenshots work?", stays in chat. +"Can we get screenshots without a display?" is ambiguous without context, so codeaf +answers or asks what you mean before starting. "We should support screenshots eventually" +is future direction, not authorization. Suggestions, goals and plans remain discussion +until you clearly ask codeaf to act now. The intended action and timing matter, not +whether the sentence ends in a question mark. + +Both route readings follow this rule: the one alongside your message and the one after +a reply in words alone. Both also require the work to be worth handing off, a substantial +message of at least six words, and two models agreeing. Ambiguous messages stay in chat. + ## Why a task started on its own — codeaf started work I did not ask for, this looked like work, so task N started -**Sometimes work starts without you asking for it, and you are told after.** After a turn +**Sometimes codeaf starts a task without you explicitly asking for a task, and tells you after.** After a turn that answered a substantial message in **words alone** — no tool call — a cheap model reads what you asked and the first two lines of the reply, and decides one thing: should that have been work? The same judge also starts reading your message **the moment you send it**, alongside the reply rather than in front of it, which is its own section below. -**A task starts only when you clearly ask codeaf to do work now.** That can be an imperative -or a polite question whose requested action is clear: "Could you fix the failing tests?" and -"Can you implement headless screenshots and add tests?" both ask for work. A question about -feasibility, like "How would headless screenshots work?", stays in the conversation. "Can we -get screenshots without a display?" is ambiguous without context, so codeaf answers or asks -what you mean before starting. Suggestions and future direction, like "we should support -screenshots eventually", are discussion until you ask codeaf to act. - **A yes is asked twice.** The cheap model only screens — it reads every substantial message and every wordy turn, which is why it is cheap — and it cannot start anything on its own. Before a task exists, the same question is put once more to your **planner** model, in the same words, with none of the diff --git a/internal/session/route_judge.go b/internal/session/route_judge.go index 406af13b1b..37f04bc996 100644 --- a/internal/session/route_judge.go +++ b/internal/session/route_judge.go @@ -312,9 +312,10 @@ or work true only when the person clearly asks codeaf to do work now. This can be an imperative or a polite question whose intended action is clear: "Could you fix the failing tests?" and "Can you implement headless - screenshots and add tests?" both authorize work. A question about - feasibility or explanation, such as "How would headless screenshots - work?", does not. "Can we get screenshots without a display?" is + screenshots and add tests?" both authorize work. A feasibility question, + such as "Is headless screenshot capture possible on CI?", or an + explanation request, such as "How would headless screenshots work?", + does not authorize work. "Can we get screenshots without a display?" is ambiguous without context, so answer or clarify; do not start work. "We should support screenshots eventually" is future direction, not authorization. Judge intended action and timing, not grammar. Product diff --git a/internal/session/route_judge_test.go b/internal/session/route_judge_test.go index 2532bc5d8e..2a33b5cdf2 100644 --- a/internal/session/route_judge_test.go +++ b/internal/session/route_judge_test.go @@ -10,6 +10,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/manual" "github.com/Agent-Field/codeaf/internal/roles" "github.com/Agent-Field/codeaf/internal/splitgate" ) @@ -1904,10 +1905,11 @@ func TestBothBriefsAskForOneObjectAndOnlyOneOfThemMentionsAnAnswer(t *testing.T) func TestRouteJudgesDistinguishWorkOrdersFromQuestions(t *testing.T) { contract := strings.Join(strings.Fields(routeVerdictContract), " ") + page := strings.Join(strings.Fields(manual.Chat().Context("will a polite request start a task", manual.DefaultResults)), " ") for _, example := range routeIntentExamples { text := strings.Join(strings.Fields(example.request), " ") - if got := strings.Contains(example.rule, "authorize work"); got != example.wantWork { - t.Fatalf("evaluation case %s has rule %q for work=%t", example.request, example.rule, example.wantWork) + if !strings.Contains(page, text) { + t.Errorf("the retrieved task-routing manual omits %s", example.request) } if !strings.Contains(contract, text) { t.Errorf("the shared route contract omits %s", example.request) @@ -1936,48 +1938,83 @@ func TestRouteJudgesDistinguishWorkOrdersFromQuestions(t *testing.T) { } } -// routeIntentExamples is the small, reviewable evaluation set for the prompt -// change. The production judge remains model-backed; these labels are the -// expected contract that both independent readers must follow. +// routeIntentExamples pins each example together with its classification in +// both prompts. This is a prompt-text regression, not a model evaluation: there +// is no deterministic intent parser whose semantic accuracy it could assert. var routeIntentExamples = []struct { request string wantWork bool rule string }{ - {`"Could you fix the failing tests?"`, true, "both authorize work"}, - {`"Can you implement headless screenshots and add tests?"`, true, "both authorize work"}, - {`"How would headless screenshots work?"`, false, "does not"}, - {`"Can we get screenshots without a display?"`, false, "ambiguous without context"}, - {`"We should support screenshots eventually"`, false, "future direction, not authorization"}, + {`"Could you fix the failing tests?"`, true, `"Could you fix the failing tests?" and "Can you implement headless screenshots and add tests?" both authorize work.`}, + {`"Can you implement headless screenshots and add tests?"`, true, `"Could you fix the failing tests?" and "Can you implement headless screenshots and add tests?" both authorize work.`}, + {`"Is headless screenshot capture possible on CI?"`, false, `A feasibility question, such as "Is headless screenshot capture possible on CI?", or an explanation request, such as "How would headless screenshots work?", does not authorize work.`}, + {`"How would headless screenshots work?"`, false, `A feasibility question, such as "Is headless screenshot capture possible on CI?", or an explanation request, such as "How would headless screenshots work?", does not authorize work.`}, + {`"Can we get screenshots without a display?"`, false, `"Can we get screenshots without a display?" is ambiguous without context, so answer or clarify; do not start work.`}, + {`"We should support screenshots eventually"`, false, `"We should support screenshots eventually" is future direction, not authorization.`}, } func TestAStoppedTaskResultCannotAutoStartItsRequestAgain(t *testing.T) { - completer := &routeCompleter{answer: "I can report what happened.", verdict: routeYes} - agent, _, _ := routeAgent(t, completer) - graph := agent.graph() - id := graph.reserve() - graph.admit(id, taskSpec{title: "the requested audit", request: routeAsk, brief: "audit", acceptance: "done"}) - node := graph.node(id) - node.markStopped() - agent.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) - agent.mu.Lock() - agent.turnResults = []uint64{id} - agent.mu.Unlock() - - ruling := agent.readRouteJudge(context.Background(), userMessage{wake: true}, false, completer.answer) - if ruling.start { - t.Fatal("a stopped task result started the same request again") - } - if completer.asked() != 0 || completer.confirms() != 0 { - t.Fatalf("the route judge spent %d screen calls and %d confirms on a stopped result", completer.asked(), completer.confirms()) - } - - // The same words in a new human turn are a fresh instruction. - ruling = agent.readRouteJudge(context.Background(), userText(routeAsk), false, completer.answer) - if !ruling.start { - t.Fatal("a new human instruction was suppressed by an earlier stopped result") + for _, tc := range []struct { + name string + request string + stopped bool + arrived bool + wake bool + want bool + }{ + {"stopped result", routeAsk, true, true, true, false}, + {"stopped result with surrounding whitespace", " " + routeAsk + "\n", true, true, true, false}, + {"fresh human request", routeAsk, true, true, false, true}, + {"failed result can still start a repair", routeAsk, false, true, true, true}, + {"unrelated stopped result", "implement headless screenshots across the app and add tests", true, true, true, true}, + {"stopped task did not arrive this turn", routeAsk, true, false, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + completer := &routeCompleter{answer: "I can report what happened.", verdict: routeYes} + agent, _, nodes := routeAgent(t, completer) + graph := agent.graph() + id := graph.reserve() + graph.admit(id, taskSpec{title: "the requested audit", request: tc.request, brief: "audit", acceptance: "done"}) + if tc.stopped { + graph.node(id).markStopped() + } + agent.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + if tc.arrived { + agent.mu.Lock() + agent.turnResults = []uint64{id} + agent.mu.Unlock() + } + user := userText(routeAsk) + if tc.wake { + user = userMessage{wake: true} + } + ruling := agent.readRouteJudge(context.Background(), user, false, completer.answer) + defer ruling.release() + if ruling.start != tc.want { + t.Fatalf("start = %t, want %t", ruling.start, tc.want) + } + wantCalls := 0 + if tc.want { + wantCalls = 1 + } + if completer.asked() != wantCalls || completer.confirms() != wantCalls { + t.Fatalf("screen calls = %d, confirms = %d, want %d each", completer.asked(), completer.confirms(), wantCalls) + } + if !tc.want { + agent.applyRouteJudge(newEventHub(), ruling) + if nodes.count() != 0 || admitted(graph) != 1 { + t.Fatal("reporting a stopped result admitted or ran another task") + } + // A person can repeat the same request after the stopped wake. + fresh := agent.readRouteJudge(context.Background(), userText(routeAsk), false, completer.answer) + defer fresh.release() + if !fresh.start || completer.asked() != 1 || completer.confirms() != 1 { + t.Fatal("a fresh human request was suppressed after the stopped result") + } + } + }) } - ruling.release() } // ── the order of the two readers at a turn's end ──────────────────────────── From 01b47012db570b637a9a32d76b2fc77f7c789722 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 09:43:52 -0400 Subject: [PATCH 06/11] fix(manual): make stopped-result routing reachable through search Give the stopped-result rule its own section so the manual context includes both the restart guard and the fresh-human-request exception. Pin the actual retrieved answer, since merely finding the long tasks page hid the rule beyond the section limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1845-explicit-work-orders.md | 3 +++ internal/manual/chat/tasks.md | 11 ++++++++--- internal/manual/chat_test.go | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/changes/unreleased/1845-explicit-work-orders.md b/docs/changes/unreleased/1845-explicit-work-orders.md index 30b50307f8..f7d2fbc017 100644 --- a/docs/changes/unreleased/1845-explicit-work-orders.md +++ b/docs/changes/unreleased/1845-explicit-work-orders.md @@ -15,3 +15,6 @@ Clear polite requests can start work even when phrased as questions. Questions about feasibility, ambiguous questions and future direction stay in the conversation. A stopped task's result can be reported without starting the same request again. + +The authorization and stopped-result rules each have their own searchable manual +section, so retrieval returns the rule instead of truncating it inside a longer topic. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index dcb6569c82..b9ac4b4c9e 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -700,15 +700,20 @@ which works at once when it is the only running task, and needs `alt+t` to walk first when there are others — or `Stop` on the pointer. Nothing about it is special from the moment it exists. -If a stopped task's result wakes the conversation, answering that result does not start the -same request again. A new instruction you type is a fresh request and can be judged normally. - **There is no setting that turns this off.** No `/settings` row switches it, and nothing you type disarms it for the session. What bounds it is the list above, and the stop. Nodes cut by an adaptive run also appear as rows under the run's own row — see *Adaptive runs*, which explains what those rows can and cannot do. +## Can a stopped task start again — a result wake and a fresh human request + +If a stopped task's result wakes the conversation, answering that result does not start the +same request again. A new instruction you type is a fresh request and can be judged normally. +That includes a new request that repeats the same words. The normal task rules still +apply: work worth handing off, at least six words, two models agreeing, and at most one start +every three turns. + ## A message that reads like work makes codeaf look sooner — nothing else happens **The judge that starts work on its own looks at two moments.** One is after a turn that diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c0524e43d1..f02682f8ea 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4116,3 +4116,18 @@ func TestChangedInputQuestionReachesRetainedHistory(t *testing.T) { t.Errorf("%q does not reach what-i-remember · %q; it reached %v", question, title, where) } } + +// A rule beyond the section cap is absent from the answer a model actually reads, +// even when searching finds the correct page. Stopped-result handling therefore +// needs its own searchable section and both halves of the rule in the context. +func TestTheChatManualExplainsStoppedResultRouting(t *testing.T) { + text := strings.Join(strings.Fields(Chat().Context("can a stopped task's result start the same request again", DefaultResults)), " ") + for _, want := range []string{ + "answering that result does not start the same request again", + "A new instruction you type is a fresh request and can be judged normally", + } { + if !strings.Contains(text, want) { + t.Errorf("the retrieved stopped-result answer omits %q", want) + } + } +} From 408dd05e83dcc2238de09f2dd49ea82d1c150cbe Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:05:36 -0400 Subject: [PATCH 07/11] fix(session): judge human steering during stopped-result wakes Read current-turn person provenance and judge the newly consumed instruction, including a verbatim repeat of the stopped request. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1845-explicit-work-orders.md | 3 + internal/manual/chat/tasks.md | 3 + internal/session/route_judge.go | 10 +- .../route_judge_stopped_result_test.go | 105 ++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 internal/session/route_judge_stopped_result_test.go diff --git a/docs/changes/unreleased/1845-explicit-work-orders.md b/docs/changes/unreleased/1845-explicit-work-orders.md index f7d2fbc017..d940ecf858 100644 --- a/docs/changes/unreleased/1845-explicit-work-orders.md +++ b/docs/changes/unreleased/1845-explicit-work-orders.md @@ -16,5 +16,8 @@ about feasibility, ambiguous questions and future direction stay in the conversation. A stopped task's result can be reported without starting the same request again. +A fresh human instruction remains eligible even when it arrives while the stopped +result is being answered. + The authorization and stopped-result rules each have their own searchable manual section, so retrieval returns the rule instead of truncating it inside a longer topic. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index b9ac4b4c9e..9c9402ab68 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -714,6 +714,9 @@ That includes a new request that repeats the same words. The normal task rules s apply: work worth handing off, at least six words, two models agreeing, and at most one start every three turns. +You can also type a new instruction while codeaf is answering the stopped result. Once +that instruction reaches the conversation, its own words can be judged normally in that turn. + ## A message that reads like work makes codeaf look sooner — nothing else happens **The judge that starts work on its own looks at two moments.** One is after a turn that diff --git a/internal/session/route_judge.go b/internal/session/route_judge.go index 37f04bc996..a4f7441e3c 100644 --- a/internal/session/route_judge.go +++ b/internal/session/route_judge.go @@ -392,6 +392,8 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools a.mu.Lock() turn, offered := a.routeTurns, a.routeOffered model, closed := a.model, a.closed + freshPerson := a.personHeard != 0 && a.personHeard == a.turnSeq + personAsk := a.personAsk a.mu.Unlock() if closed || usedTools { return judgeRuling{} @@ -424,7 +426,11 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools // floor, the gap, the cheap screen, the mastermind confirm, and both of them // having to say yes. asked := user.text() - if user.empty() || user.wake || user.authored { + if user.wake && freshPerson { + // The opening note stays a wake after steering is consumed. Judge the + // person's new words, even when they repeat the cancelled request. + asked = personAsk + } else if user.empty() || user.wake || user.authored { steward := a.steward() if steward == nil { return judgeRuling{} @@ -433,7 +439,7 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools } // A stopped task's result wakes a turn so the session can report what // happened. That reply is not a fresh instruction to start the same work. - if user.wake && a.stoppedTaskResultFor(asked) { + if user.wake && !freshPerson && a.stoppedTaskResultFor(asked) { return judgeRuling{} } // THE FLOOR IS THE ASK, and it is cheaper than a model call. A commit, diff --git a/internal/session/route_judge_stopped_result_test.go b/internal/session/route_judge_stopped_result_test.go new file mode 100644 index 0000000000..b91810fcf2 --- /dev/null +++ b/internal/session/route_judge_stopped_result_test.go @@ -0,0 +1,105 @@ +package session + +import ( + "context" + "strings" + "testing" + "time" +) + +func reviewResultNode(t *testing.T, a *Agent, stopped bool, title string) *TaskNode { + t.Helper() + g := a.graph() + id := g.reserve() + g.admit(id, taskSpec{title: title, request: routeAsk, brief: title, acceptance: "done"}) + n := g.node(id) + g.mu.Lock() + n.state = TaskFailed + n.stopped = stopped + if stopped { + n.ending = TaskEndingStopped + } + g.mu.Unlock() + return n +} + +func reviewWake(t *testing.T, a *Agent, tags []TaskReplyTag) { + t.Helper() + wakes, leave := a.WatchWakes() + defer leave() + note := wakeNote("The stopped audit was cancelled. A different audit failed and needs a repair.") + note.replyTags = tags + if !a.enqueueNote(note) { + t.Fatal("wake not accepted") + } + select { + case stream := <-wakes: + collect(t, stream) + case <-time.After(10 * time.Second): + t.Fatal("no wake") + } +} + +func TestReviewFreshHumanRepeatDuringStoppedWake(t *testing.T) { + c := &routeCompleter{answer: "I will fix the audit now.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + a.personAsk = routeAsk + stopped := reviewResultNode(t, a, true, "cancelled old audit") + c.onAnswer = func(round int) { + if round != 1 { + return + } + ch, err := a.Submit(context.Background(), routeAsk) + if err != nil { + t.Error(err) + return + } + go func() { + for range ch { + } + }() + } + reviewWake(t, a, []TaskReplyTag{stopped.resultTag()}) + a.mu.Lock() + heard, turn := a.personHeard, a.turnSeq + a.mu.Unlock() + t.Logf("fresh human message consumed in turn=%d; current turn=%d; screen=%d confirm=%d", heard, turn, c.asked(), c.confirms()) + if heard != turn { + t.Fatal("fixture did not consume the human message in the result turn") + } + if c.asked() == 0 { + t.Fatal("fresh human repeat was consumed but the stopped-result guard suppressed routing") + } +} + +func TestFreshHumanSteeringSuppliesTheRouteAsk(t *testing.T) { + c := &routeCompleter{answer: "I will implement the screenshots now.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + stopped := reviewResultNode(t, a, true, "cancelled old audit") + const fresh = "implement headless screenshots across the app and add tests" + c.onAnswer = func(round int) { + if round != 1 { + return + } + ch, err := a.Submit(context.Background(), fresh) + if err != nil { + t.Error(err) + return + } + go func() { + for range ch { + } + }() + } + reviewWake(t, a, []TaskReplyTag{stopped.resultTag()}) + c.mu.Lock() + question, confirm := c.question, c.confirmQ + c.mu.Unlock() + for _, got := range []string{question, confirm} { + if !strings.Contains(got, fresh) || strings.Contains(got, routeAsk) { + t.Fatalf("routing did not read the new human instruction: %q", got) + } + } +} From 93c08ce139f5edc28cadb160e37fe3c4568f33d2 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:08:55 -0400 Subject: [PATCH 08/11] fix(session): scope stopped-result routing to each delivered task Keep attempt snapshots and effective obligations distinct even when sibling tasks inherit the same request. Skip result-only cancelled work before classification, and show both readers only the independent eligible work with explicit cancellation boundaries. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1845-explicit-work-orders.md | 2 + internal/manual/chat/tasks.md | 4 + internal/session/route_judge.go | 71 ++++++++--- .../session/route_judge_mixed_results_test.go | 119 ++++++++++++++++++ internal/session/session.go | 10 ++ internal/session/wakecause.go | 14 +++ 6 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 internal/session/route_judge_mixed_results_test.go diff --git a/docs/changes/unreleased/1845-explicit-work-orders.md b/docs/changes/unreleased/1845-explicit-work-orders.md index d940ecf858..0dc1b2965e 100644 --- a/docs/changes/unreleased/1845-explicit-work-orders.md +++ b/docs/changes/unreleased/1845-explicit-work-orders.md @@ -18,6 +18,8 @@ request again. A fresh human instruction remains eligible even when it arrives while the stopped result is being answered. +An independent non-stopped result in the same wake also remains eligible; sharing +the original request with a stopped task does not cancel the sibling's work. The authorization and stopped-result rules each have their own searchable manual section, so retrieval returns the rule instead of truncating it inside a longer topic. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 9c9402ab68..a2757318fe 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -717,6 +717,10 @@ every three turns. You can also type a new instruction while codeaf is answering the stopped result. Once that instruction reaches the conversation, its own words can be judged normally in that turn. +If the same wake also reports an independent task that was not stopped, that task's +remaining work can still be judged normally, even when both tasks came from the same +original request. The stopped task stays excluded from that work. + ## A message that reads like work makes codeaf look sooner — nothing else happens **The judge that starts work on its own looks at two moments.** One is after a turn that diff --git a/internal/session/route_judge.go b/internal/session/route_judge.go index a4f7441e3c..755143183f 100644 --- a/internal/session/route_judge.go +++ b/internal/session/route_judge.go @@ -122,6 +122,7 @@ package session import ( "context" "encoding/json" + "fmt" "strconv" "strings" "time" @@ -437,11 +438,6 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools } asked = steward.Ask() } - // A stopped task's result wakes a turn so the session can report what - // happened. That reply is not a fresh instruction to start the same work. - if user.wake && !freshPerson && a.stoppedTaskResultFor(asked) { - return judgeRuling{} - } // THE FLOOR IS THE ASK, and it is cheaper than a model call. A commit, // an undo, a one-line edit or a single read is answered here; paying a // judge to be told it is work is how F26's commit became a task. @@ -454,6 +450,15 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools if offered > 0 && turn-offered < routeJudgeGap { return judgeRuling{} } + // A stopped task's result is news to report, not a fresh instruction. + // Independent results in the same wake retain only their own work. + if user.wake && !freshPerson { + var blocked bool + asked, blocked = a.routeResultAsk(asked) + if blocked { + return judgeRuling{} + } + } // The judge is a ROLE, so an install with no tiers configured resolves it to // the conversation's own model rather than refusing — and an install with // nothing anywhere gets no judge at all, which is this feature absent rather @@ -493,27 +498,59 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools return judgeRuling{start: true, verdict: verdict, turn: turn, ahead: ahead} } -// stoppedTaskResultFor reports whether this turn is answering the result of a -// stopped task that was started from the same request. A new message from the -// person is a new instruction and remains eligible for the ordinary judge. -func (a *Agent) stoppedTaskResultFor(asked string) bool { +// routeResultAsk excludes cancelled work without vetoing an independent result +// that inherited the same original request. Only a result-only wake uses this; +// the person's fresh instructions take precedence in readRouteJudge. +func (a *Agent) routeResultAsk(asked string) (string, bool) { a.mu.Lock() results := append([]uint64(nil), a.turnResults...) + tags := append([]TaskReplyTag(nil), a.turnResultTags...) a.mu.Unlock() if len(results) == 0 { - return false + return asked, false } graph := a.tasker() - if graph == nil { - return false + // Older callers carry only ids, and older persisted tags have no state. + // Current deliveries use their frozen snapshots, never the live attempt. + if len(tags) == 0 { + for _, id := range results { + tags = append(tags, TaskReplyTag{ID: id}) + } } - for _, id := range results { - node := graph.node(id) - if node != nil && node.wasStopped() && strings.TrimSpace(node.request()) == strings.TrimSpace(asked) { - return true + var eligible, cancelled []TaskReplyTag + stopped := false + for _, tag := range tags { + if tag.State == "" && graph != nil { + if node := graph.node(tag.ID); node != nil { + tag = node.resultTag() + } } + if tag.Stopped { + cancelled = append(cancelled, tag) + if strings.TrimSpace(tag.Request) == strings.TrimSpace(asked) { + stopped = true + } + } else if tag.State != "" && strings.TrimSpace(tag.owed()) != "" { + eligible = append(eligible, tag) + } + } + if !stopped { + return asked, false + } + if len(eligible) == 0 { + return "", true + } + // The shared request is context for each part, not authority to revive the + // cancelled sibling. Both readers receive the same restricted question. + var out strings.Builder + out.WriteString("Only the non-stopped task results below remain eligible for work. Do not restart stopped tasks or repeat the whole original request.\n") + for _, tag := range eligible { + fmt.Fprintf(&out, "\nTask %d (attempt %d), %s\nIts own target: %s\n", tag.ID, tag.Attempt, tag.Title, tag.owed()) + } + for _, tag := range cancelled { + fmt.Fprintf(&out, "\nStopped task %d, report only: %s\n", tag.ID, tag.Title) } - return false + return out.String(), false } // applyRouteJudge is the other half of [Agent.readRouteJudge]: the decision, diff --git a/internal/session/route_judge_mixed_results_test.go b/internal/session/route_judge_mixed_results_test.go new file mode 100644 index 0000000000..42be105543 --- /dev/null +++ b/internal/session/route_judge_mixed_results_test.go @@ -0,0 +1,119 @@ +package session + +import ( + "context" + "fmt" + "strings" + "testing" + "time" +) + +func TestReviewMixedResultsKeepIndependentRepairEligible(t *testing.T) { + c := &routeCompleter{answer: "The cancelled UI audit stays cancelled. I will complete the independent storage audit.", verdict: `{"work":true,"goal":"Complete the independent pricing storage audit; leave the cancelled UI audit stopped","acceptance":"Storage audit report exists","why":"finish the still-authorized independent audit"}`} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + a.personAsk = routeAsk + stopped := reviewResultNode(t, a, true, "cancelled pricing UI audit") + failed := reviewResultNode(t, a, false, "failed pricing storage audit") + reviewWake(t, a, []TaskReplyTag{stopped.resultTag(), failed.resultTag()}) + a.mu.Lock() + results := append([]uint64(nil), a.turnResults...) + a.mu.Unlock() + t.Logf("result IDs=%v; screen=%d confirm=%d", results, c.asked(), c.confirms()) + if len(results) != 2 { + t.Fatal("fixture did not deliver both results") + } + if c.asked() == 0 { + t.Fatal("a different failed task's repair was suppressed by the stopped sibling's inherited request") + } + c.mu.Lock() + question, confirm := c.question, c.confirmQ + c.mu.Unlock() + if question != confirm { + t.Fatalf("screen and confirm read different obligations:\n%s\n%s", question, confirm) + } + for _, want := range []string{"Only the non-stopped task results", "Do not restart stopped tasks", "Task 2 (attempt 0), failed pricing storage audit", "Stopped task 1, report only: cancelled pricing UI audit"} { + if !strings.Contains(question, want) { + t.Errorf("the mixed-result question omits %q:\n%s", want, question) + } + } +} + +func TestStoppedResultRoutingUsesTheDeliveredAttempt(t *testing.T) { + for _, stoppedAtDelivery := range []bool{true, false} { + t.Run(fmt.Sprintf("stopped_at_delivery_%t", stoppedAtDelivery), func(t *testing.T) { + c := &routeCompleter{answer: "Here is what happened.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + node := reviewResultNode(t, a, stoppedAtDelivery, "delayed audit") + tag := node.resultTag() + g := a.graph() + g.mu.Lock() + // Another attempt changes the live node before this result is consumed. + node.attempt++ + node.stopped = !stoppedAtDelivery + g.mu.Unlock() + a.mu.Lock() + note := wakeNote("the old attempt arrived") + note.replyTags = []TaskReplyTag{tag} + a.rememberOwedLocked(note) + a.mu.Unlock() + ruling := a.readRouteJudge(context.Background(), note, false, c.answer) + defer ruling.release() + if ruling.start == stoppedAtDelivery { + t.Fatalf("start=%t followed the live node instead of delivered stopped=%t", ruling.start, stoppedAtDelivery) + } + wantCalls := 1 + if stoppedAtDelivery { + wantCalls = 0 + } + if c.asked() != wantCalls || c.confirms() != wantCalls { + t.Fatalf("screen=%d confirm=%d, want %d each", c.asked(), c.confirms(), wantCalls) + } + // The next wake cannot inherit this attempt's cancellation snapshot. + a.mu.Lock() + a.forgetOwedLocked() + a.mu.Unlock() + fresh := a.readRouteJudge(context.Background(), wakeNote("new event"), false, c.answer) + defer fresh.release() + if !fresh.start { + t.Fatal("a previous turn's stopped result suppressed a later wake") + } + }) + } +} + +func TestMixedResultRoutingRetainsEachTasksEffectiveTarget(t *testing.T) { + for _, reverse := range []bool{false, true} { + t.Run(fmt.Sprintf("reversed_%t", reverse), func(t *testing.T) { + c := &routeCompleter{answer: "Only the storage audit needs work.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + stopped := reviewResultNode(t, a, true, "cancelled UI audit") + failed := reviewResultNode(t, a, false, "storage audit") + remaining := failed.resultTag() + const target = "audit storage transactions only and produce the storage report" + remaining.Obligation, remaining.Revision = target, 2 + tags := []TaskReplyTag{stopped.resultTag(), remaining} + if reverse { + tags[0], tags[1] = tags[1], tags[0] + } + note := wakeNote("the two results arrived") + note.replyTags = tags + a.mu.Lock() + a.rememberOwedLocked(note) + a.mu.Unlock() + ruling := a.readRouteJudge(context.Background(), note, false, c.answer) + defer ruling.release() + if !ruling.start { + t.Fatal("the independent result was suppressed") + } + c.mu.Lock() + question, confirm := c.question, c.confirmQ + c.mu.Unlock() + if question != confirm || !strings.Contains(question, target) || strings.Contains(question, "Its own target: "+routeAsk) { + t.Fatalf("routing lost the eligible task's own effective target:\n%s\n%s", question, confirm) + } + }) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index dfc0666a25..9a9aefc465 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -664,6 +664,12 @@ type TaskReplyTag struct { // neither and is read exactly as it always was. Obligation string `json:"obligation,omitempty"` Revision uint64 `json:"revision,omitempty"` + // State, Stopped and Attempt belong to the result's own attempt. A delayed + // delivery must not borrow a re-armed node's new cancellation state. Empty + // State identifies older tags that still need the node as a fallback. + State TaskState `json:"state,omitempty"` + Stopped bool `json:"stopped,omitempty"` + Attempt int `json:"attempt,omitempty"` } // Event is one observable thing in a turn. A Submit returns a channel of @@ -2827,6 +2833,10 @@ type Agent struct { // [Agent.deliveringOwnedResult]), and that question has to survive a result // whose words were empty. turnResults []uint64 + // turnResultTags keeps each arrival's target and cancellation snapshot for + // routing. Unlike owedAsks, siblings with the same request stay distinct. + // It has the same turn lifetime as turnResults. + turnResultTags []TaskReplyTag // personTurns is the same answer for the turns BEFORE the newest one, kept // for the same reason and bounded (admission_compile.go). personAsk answers // "what is the current ask"; this answers "what else have they told us", diff --git a/internal/session/wakecause.go b/internal/session/wakecause.go index 73d84d6115..49360cabd0 100644 --- a/internal/session/wakecause.go +++ b/internal/session/wakecause.go @@ -102,9 +102,21 @@ func (a *Agent) rememberOwedLocked(user userMessage) { // text and the write seam needs the node rather than the sentence // (writeseam.go's [Agent.deliveringOwnedResult]). a.arrivedLocked(tag.ID) + a.rememberResultTagLocked(tag) } } +// rememberResultTagLocked retains the first snapshot of each delivered attempt, +// even when another task inherited the same request. The caller holds a.mu. +func (a *Agent) rememberResultTagLocked(tag TaskReplyTag) { + for _, already := range a.turnResultTags { + if already.ID == tag.ID && already.Attempt == tag.Attempt { + return + } + } + a.turnResultTags = append(a.turnResultTags, tag) +} + // arrivedLocked records one result that landed in this turn, once. The caller // holds a.mu. func (a *Agent) arrivedLocked(id uint64) { @@ -152,6 +164,7 @@ func (a *Agent) oweLocked(ask owedAsk) { // arrived with. Called once, where a turn opens. func (a *Agent) forgetOwedLocked() { a.owedAsks, a.landingOutcomes, a.turnResults, a.programOutcomeNow, a.personCardAnswers = nil, nil, nil, nil, nil + a.turnResultTags = nil } // turnAsk is the ask this turn's endings are read against. @@ -236,6 +249,7 @@ func (n *TaskNode) resultTagLocked() TaskReplyTag { return TaskReplyTag{ ID: n.id, Title: n.spec.title, Request: n.spec.request, Obligation: obligation, Revision: revision, + State: n.state, Stopped: n.stopped, Attempt: n.attempt, } } From d3da2a0a86c6bf3201f1d29772511b91dcf1069e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:09:23 -0400 Subject: [PATCH 09/11] fix(session): restore task cancellation from its saved ending Derive the stopped runtime flag from the persisted stopped ending. Keep restored cancelled results out of routing while ordinary failed results retain repair eligibility. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1845-explicit-work-orders.md | 1 + internal/manual/chat/tasks.md | 1 + .../route_judge_restored_result_test.go | 61 +++++++++++++++++++ internal/session/task_store.go | 49 ++++++++------- 4 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 internal/session/route_judge_restored_result_test.go diff --git a/docs/changes/unreleased/1845-explicit-work-orders.md b/docs/changes/unreleased/1845-explicit-work-orders.md index 0dc1b2965e..0fceeaabea 100644 --- a/docs/changes/unreleased/1845-explicit-work-orders.md +++ b/docs/changes/unreleased/1845-explicit-work-orders.md @@ -20,6 +20,7 @@ A fresh human instruction remains eligible even when it arrives while the stoppe result is being answered. An independent non-stopped result in the same wake also remains eligible; sharing the original request with a stopped task does not cancel the sibling's work. +Restoring a saved task preserves whether it was stopped by a person. The authorization and stopped-result rules each have their own searchable manual section, so retrieval returns the rule instead of truncating it inside a longer topic. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index a2757318fe..8d510ba3aa 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -720,6 +720,7 @@ that instruction reaches the conversation, its own words can be judged normally If the same wake also reports an independent task that was not stopped, that task's remaining work can still be judged normally, even when both tasks came from the same original request. The stopped task stays excluded from that work. +This stop protection also applies to stopped tasks restored from a saved session. ## A message that reads like work makes codeaf look sooner — nothing else happens diff --git a/internal/session/route_judge_restored_result_test.go b/internal/session/route_judge_restored_result_test.go new file mode 100644 index 0000000000..c813f2809c --- /dev/null +++ b/internal/session/route_judge_restored_result_test.go @@ -0,0 +1,61 @@ +package session + +import ( + "context" + "testing" + "time" +) + +func TestReviewRestoredStoppedResultCannotRestart(t *testing.T) { + c := &routeCompleter{answer: "The audit was stopped.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + a.personAsk = routeAsk + stopped := reviewResultNode(t, a, true, "cancelled audit") + g := a.graph() + g.mu.Lock() + record := stopped.recordLocked() + g.mu.Unlock() + restored := restoreNode(g, record) + g.mu.Lock() + g.nodes[stopped.id] = restored + g.mu.Unlock() + t.Logf("checkpoint ending=%q, restored stopped=%t", record.Ending, restored.wasStopped()) + reviewWake(t, a, []TaskReplyTag{restored.resultTag()}) + if c.asked() != 0 { + t.Fatal("a restored stopped result reached the classifier and can restart the stopped request") + } +} + +func TestRestoredNonStoppedResultsRemainEligible(t *testing.T) { + for _, ending := range []TaskEnding{"", TaskEndingInterrupted, TaskEndingWire, TaskEndingRefused} { + t.Run(string(ending), func(t *testing.T) { + c := &routeCompleter{answer: "The audit needs a repair.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + node := reviewResultNode(t, a, false, "failed audit") + g := a.graph() + g.mu.Lock() + node.ending = ending + record := node.recordLocked() + g.mu.Unlock() + restored := restoreNode(g, record) + if restored.wasStopped() { + t.Fatalf("ending %q restored as a person's cancellation", ending) + } + g.mu.Lock() + g.nodes[node.id] = restored + g.mu.Unlock() + note := wakeNote("the failed result arrived") + note.replyTags = []TaskReplyTag{restored.resultTag()} + a.mu.Lock() + a.rememberOwedLocked(note) + a.mu.Unlock() + ruling := a.readRouteJudge(context.Background(), note, false, c.answer) + defer ruling.release() + if !ruling.start || c.asked() != 1 || c.confirms() != 1 { + t.Fatalf("restored non-stopped result was suppressed: start=%t screen=%d confirm=%d", ruling.start, c.asked(), c.confirms()) + } + }) + } +} diff --git a/internal/session/task_store.go b/internal/session/task_store.go index cc4de10f80..7b963ba845 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -2175,29 +2175,32 @@ func restoreNode(graph *TaskGraph, record taskRecord) *TaskNode { landed: record.landedHalf(), late: append([]string(nil), record.Late...), ending: record.Ending, - kind: record.Kind, - claim: record.Claim, - checkedOn: record.CheckedOn, - produced: resultFromRecord(record.Result), - changed: record.Changed, - wrote: record.Wrote, - branch: record.Branch, - worktree: record.Worktree, - merge: record.Merge, - journal: record.Journal, - elapsed: time.Duration(record.ElapsedMS) * time.Millisecond, - started: record.StartedAt, - ended: record.EndedAt, - cost: record.CostUSD, - input: record.Input, - output: record.Output, - cacheRead: record.CacheRead, - cacheWrite: record.CacheWrite, - noted: record.Noted, - notedRead: record.Noted, - notedState: record.NotedState, - attempt: record.Attempt, - interrupted: record.Interrupted, + // The ending already persists a person's cancellation. Rebuild the + // runtime flag so restored results retain the same routing protection. + stopped: record.Ending == TaskEndingStopped, + kind: record.Kind, + claim: record.Claim, + checkedOn: record.CheckedOn, + produced: resultFromRecord(record.Result), + changed: record.Changed, + wrote: record.Wrote, + branch: record.Branch, + worktree: record.Worktree, + merge: record.Merge, + journal: record.Journal, + elapsed: time.Duration(record.ElapsedMS) * time.Millisecond, + started: record.StartedAt, + ended: record.EndedAt, + cost: record.CostUSD, + input: record.Input, + output: record.Output, + cacheRead: record.CacheRead, + cacheWrite: record.CacheWrite, + noted: record.Noted, + notedRead: record.Noted, + notedState: record.NotedState, + attempt: record.Attempt, + interrupted: record.Interrupted, // AND WHO WAS HOLDING ITS DECISION, which is read back exactly as it was // written and then put right by the floor a few lines above this node's // arrival ([TaskGraph.handBackOnLoad], called out of [TaskGraph.rehydrate]). From 923d226a9bed83fcabc2be8318db7bac8a13bff1 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:42:03 -0400 Subject: [PATCH 10/11] fix(session): limit stopped-result guard to unambiguous result-only wakes Keep dev's routing inputs, floors and rulings whenever a human speaks, results are mixed, or provenance is incomplete. Skip both classifiers only when every delivered result is stopped for the current request in a result-only wake. Remove scoped eligible-work questions, attempt tags and snapshot deduplication. Retain frozen stop provenance and restored cancellation for the stop-only case. Preserve untagged news through batching without changing existing owed asks. Compare production wake, Submit, retry, cancel and recovery probes against dev, and narrow the manual and change entry to the conservative guarantee. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1845-explicit-work-orders.md | 21 +- internal/manual/chat/tasks.md | 29 +- internal/manual/chat_test.go | 4 + internal/session/agent.go | 6 + internal/session/route_judge.go | 83 ++--- internal/session/route_judge_dev_test.go | 297 ++++++++++++++++++ .../session/route_judge_mixed_results_test.go | 22 +- .../route_judge_restored_result_test.go | 2 +- .../route_judge_stopped_result_test.go | 34 +- internal/session/route_judge_test.go | 7 +- internal/session/session.go | 14 +- internal/session/wakecause.go | 19 +- 12 files changed, 399 insertions(+), 139 deletions(-) create mode 100644 internal/session/route_judge_dev_test.go diff --git a/docs/changes/unreleased/1845-explicit-work-orders.md b/docs/changes/unreleased/1845-explicit-work-orders.md index 0fceeaabea..b4e2a94848 100644 --- a/docs/changes/unreleased/1845-explicit-work-orders.md +++ b/docs/changes/unreleased/1845-explicit-work-orders.md @@ -7,20 +7,23 @@ invalidates: - >- Questions and product direction could be mistaken for permission to start a task. Both route judges now distinguish clear polite requests from - feasibility questions, ambiguous questions and future direction; a stopped - task result cannot restart the same request. + feasibility questions, ambiguous questions and future direction. Only a + wake exclusively reporting known stopped results for the current request, + without fresh human input, skips automatic task routing. --- Clear polite requests can start work even when phrased as questions. Questions about feasibility, ambiguous questions and future direction stay in the -conversation. A stopped task's result can be reported without starting the same -request again. +conversation. The stopped-result guard applies only to an exclusively result-driven +wake where every result is known to be stopped for the current request and no fresh +human message was consumed. It makes no classifier calls and admits nothing. -A fresh human instruction remains eligible even when it arrives while the stopped -result is being answered. -An independent non-stopped result in the same wake also remains eligible; sharing -the original request with a stopped task does not cancel the sibling's work. -Restoring a saved task preserves whether it was stopped by a person. +Every other case keeps normal routing inputs, limits and rulings: fresh human messages +(including acknowledgements during a wake), mixed results or attempts, different +requests, background news and missing provenance. Mixed wakes have no new guarantee +that stopped work will stay excluded. Restoring a saved task preserves its stopped +flag; the guard still requires provenance on the delivered result. Ordinary recovery +notices without that provenance follow normal routing. The authorization and stopped-result rules each have their own searchable manual section, so retrieval returns the rule instead of truncating it inside a longer topic. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 8d510ba3aa..a5a71be262 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -708,19 +708,22 @@ runs*, which explains what those rows can and cannot do. ## Can a stopped task start again — a result wake and a fresh human request -If a stopped task's result wakes the conversation, answering that result does not start the -same request again. A new instruction you type is a fresh request and can be judged normally. -That includes a new request that repeats the same words. The normal task rules still -apply: work worth handing off, at least six words, two models agreeing, and at most one start -every three turns. - -You can also type a new instruction while codeaf is answering the stopped result. Once -that instruction reaches the conversation, its own words can be judged normally in that turn. - -If the same wake also reports an independent task that was not stopped, that task's -remaining work can still be judged normally, even when both tasks came from the same -original request. The stopped task stays excluded from that work. -This stop protection also applies to stopped tasks restored from a saved session. +When a conversation wakes exclusively to report task results, every result is known to +be stopped for the current request, and no fresh human message was consumed in that turn, +answering that result does not start the same request again. Neither route reading runs. +This includes a stopped task restored from a saved session when its delivered result carries +that provenance. Recovery notices without result provenance follow normal routing. + +A new instruction you type is a fresh request and can be judged normally. That includes +a new request that repeats the same words, or a message consumed while the stopped result +is being answered. Mixed stopped and non-stopped results, different requests, multiple +attempts of the same task, other background news and uncertain provenance also follow +normal routing. These cases have no extra protection against restarting stopped work. + +The usual routing inputs and limits still apply. An unattended session uses its original +goal as the routing request, including when a human message arrives during a result wake. +Work must be worth handing off, with at least six words, two models agreeing, and at most +one start every three turns. ## A message that reads like work makes codeaf look sooner — nothing else happens diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index f02682f8ea..8ec1caede6 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4125,6 +4125,10 @@ func TestTheChatManualExplainsStoppedResultRouting(t *testing.T) { for _, want := range []string{ "answering that result does not start the same request again", "A new instruction you type is a fresh request and can be judged normally", + "every result is known to be stopped for the current request", + "no fresh human message was consumed", + "Recovery notices without result provenance follow normal routing", + "These cases have no extra protection against restarting stopped work", } { if !strings.Contains(text, want) { t.Errorf("the retrieved stopped-result answer omits %q", want) diff --git a/internal/session/agent.go b/internal/session/agent.go index 9f020618dc..b58e81e694 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1191,6 +1191,9 @@ type userMessage struct { // otherResults preserves untagged background outcomes when a batch also // carries task results. Their reply obligation must not be lost in folding. otherResults bool + // nonTaskNews preserves untagged news inside a batch for the conservative + // stopped-result routing guard, without changing the turn's owed asks. + nonTaskNews bool // wake marks a note the model OWES AN ANSWER FOR: a task's completion // (task_run.go's reportTaskNode), a background job's exit (jobs.go's reap), @@ -3378,12 +3381,14 @@ func batchSessionNotes(notes []userMessage) userMessage { var ( wake bool otherResults bool + nonTaskNews bool tags []TaskReplyTag delivered []durableDelivery ) for _, note := range notes { wake = wake || note.wake otherResults = otherResults || note.otherResults + nonTaskNews = nonTaskNews || note.nonTaskNews || len(note.replyTags) == 0 tags = append(tags, note.replyTags...) // The batch is the record these notes end up in, so it carries what // settles each of them ([durableDelivery]); dropped here, every landing in @@ -3428,6 +3433,7 @@ func batchSessionNotes(notes []userMessage) userMessage { message: textMessage("user", text), replyTags: tags, otherResults: otherResults, + nonTaskNews: nonTaskNews, delivered: delivered, wake: wake, authored: true, diff --git a/internal/session/route_judge.go b/internal/session/route_judge.go index 755143183f..eee1601855 100644 --- a/internal/session/route_judge.go +++ b/internal/session/route_judge.go @@ -122,7 +122,6 @@ package session import ( "context" "encoding/json" - "fmt" "strconv" "strings" "time" @@ -394,7 +393,6 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools turn, offered := a.routeTurns, a.routeOffered model, closed := a.model, a.closed freshPerson := a.personHeard != 0 && a.personHeard == a.turnSeq - personAsk := a.personAsk a.mu.Unlock() if closed || usedTools { return judgeRuling{} @@ -427,11 +425,7 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools // floor, the gap, the cheap screen, the mastermind confirm, and both of them // having to say yes. asked := user.text() - if user.wake && freshPerson { - // The opening note stays a wake after steering is consumed. Judge the - // person's new words, even when they repeat the cancelled request. - asked = personAsk - } else if user.empty() || user.wake || user.authored { + if user.empty() || user.wake || user.authored { steward := a.steward() if steward == nil { return judgeRuling{} @@ -450,14 +444,10 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools if offered > 0 && turn-offered < routeJudgeGap { return judgeRuling{} } - // A stopped task's result is news to report, not a fresh instruction. - // Independent results in the same wake retain only their own work. - if user.wake && !freshPerson { - var blocked bool - asked, blocked = a.routeResultAsk(asked) - if blocked { - return judgeRuling{} - } + // Only an exclusively stopped-result wake is report-only. Every other + // turn keeps the ordinary ask, floors and readings, including human steering. + if user.wake && !user.answersPerson && !freshPerson && a.onlyStoppedResults(asked) { + return judgeRuling{} } // The judge is a ROLE, so an install with no tiers configured resolves it to // the conversation's own model rather than refusing — and an install with @@ -498,59 +488,24 @@ func (a *Agent) readRouteJudge(ctx context.Context, user userMessage, usedTools return judgeRuling{start: true, verdict: verdict, turn: turn, ahead: ahead} } -// routeResultAsk excludes cancelled work without vetoing an independent result -// that inherited the same original request. Only a result-only wake uses this; -// the person's fresh instructions take precedence in readRouteJudge. -func (a *Agent) routeResultAsk(asked string) (string, bool) { +// onlyStoppedResults requires positive provenance for every arrival. Missing +// metadata, other news and repeated task identities leave normal routing alone; +// in particular, two attempts of one task are not a stopped-only batch. +func (a *Agent) onlyStoppedResults(asked string) bool { a.mu.Lock() - results := append([]uint64(nil), a.turnResults...) - tags := append([]TaskReplyTag(nil), a.turnResultTags...) - a.mu.Unlock() - if len(results) == 0 { - return asked, false - } - graph := a.tasker() - // Older callers carry only ids, and older persisted tags have no state. - // Current deliveries use their frozen snapshots, never the live attempt. - if len(tags) == 0 { - for _, id := range results { - tags = append(tags, TaskReplyTag{ID: id}) - } + defer a.mu.Unlock() + if a.turnNonTaskNews || len(a.turnResultTags) == 0 { + return false } - var eligible, cancelled []TaskReplyTag - stopped := false - for _, tag := range tags { - if tag.State == "" && graph != nil { - if node := graph.node(tag.ID); node != nil { - tag = node.resultTag() - } + seen := make(map[uint64]bool, len(a.turnResultTags)) + for _, tag := range a.turnResultTags { + if tag.ID == 0 || seen[tag.ID] || !tag.State.settled() || !tag.Stopped || + strings.TrimSpace(tag.Request) != strings.TrimSpace(asked) { + return false } - if tag.Stopped { - cancelled = append(cancelled, tag) - if strings.TrimSpace(tag.Request) == strings.TrimSpace(asked) { - stopped = true - } - } else if tag.State != "" && strings.TrimSpace(tag.owed()) != "" { - eligible = append(eligible, tag) - } - } - if !stopped { - return asked, false - } - if len(eligible) == 0 { - return "", true - } - // The shared request is context for each part, not authority to revive the - // cancelled sibling. Both readers receive the same restricted question. - var out strings.Builder - out.WriteString("Only the non-stopped task results below remain eligible for work. Do not restart stopped tasks or repeat the whole original request.\n") - for _, tag := range eligible { - fmt.Fprintf(&out, "\nTask %d (attempt %d), %s\nIts own target: %s\n", tag.ID, tag.Attempt, tag.Title, tag.owed()) - } - for _, tag := range cancelled { - fmt.Fprintf(&out, "\nStopped task %d, report only: %s\n", tag.ID, tag.Title) + seen[tag.ID] = true } - return out.String(), false + return true } // applyRouteJudge is the other half of [Agent.readRouteJudge]: the decision, diff --git a/internal/session/route_judge_dev_test.go b/internal/session/route_judge_dev_test.go new file mode 100644 index 0000000000..03befca9a4 --- /dev/null +++ b/internal/session/route_judge_dev_test.go @@ -0,0 +1,297 @@ +package session + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/roles" +) + +// Keep these probes source-compatible with current dev: no new tag fields or helpers. +func r1845Node(t *testing.T, a *Agent, title string) *TaskNode { + t.Helper() + g := a.graph() + id := g.reserve() + g.admit(id, taskSpec{title: title, request: routeAsk, brief: title, acceptance: "done"}) + return g.node(id) +} + +func TestOldFailedAttemptAndNewStopKeepDevRouting(t *testing.T) { + c := &routeCompleter{answer: "The audit was stopped.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + a.mu.Lock() + a.opened = false // Queue both real deliveries before any wake can consume either. + a.personAsk = routeAsk + a.mu.Unlock() + n := r1845Node(t, a, "pricing audit") + g := a.graph() + g.mu.Lock() + n.state, n.report = TaskFailed, "failed audit" + g.mu.Unlock() + a.reportTaskNode(n) // Real resultOf -> deliverTaskNote -> postTaskMessage. + if err := a.RetryTask(n.id); err != nil { + t.Fatal(err) + } + if _, err := a.Cancel(fmt.Sprint(n.id)); err != nil { + t.Fatal(err) + } + if !n.wasStopped() { + t.Fatal("fixture did not stop the retry") + } + a.drainSteering(nil) + a.mu.Lock() + ids := append([]uint64(nil), a.turnResults...) + a.mu.Unlock() + if len(ids) != 1 { + t.Fatalf("unexpected task ids: %v", ids) + } + r := a.readRouteJudge(context.Background(), wakeNote("the results arrived"), false, c.answer) + defer r.release() + t.Logf("start=%t screen=%d confirm=%d question=%s", r.start, c.asked(), c.confirms(), c.sawQuestion()) + if r.start { + a.applyRouteJudge(newEventHub(), r) + t.Logf("admitted tasks after applying ruling=%d", admitted(g)) + if admitted(g) != 2 { + t.Fatal("mixed attempts changed dev's admission") + } + } + if !r.start { + t.Fatal("mixed attempts changed dev's ruling") + } + assertDevRouteReadings(t, c, 1) +} + +func TestRecoveryWithoutResultProvenanceKeepsDevRouting(t *testing.T) { + journal, checkpoint := journalIn(t) + writeCheckpoint(t, checkpoint, taskDocument{ + Type: taskDocumentType, Version: taskFileVersion, Seq: 1, + Nodes: []taskRecord{{ID: 1, Title: "pricing audit", Request: routeAsk, Brief: "audit", Acceptance: "done", State: TaskFailed, Ending: TaskEndingStopped, Report: "stopped", Noted: false}}, + }) + c := &routeCompleter{answer: "The audit was stopped.", verdict: routeYes} + a, _ := newTestAgent(t, c, func(config *Config) { + config.SessionFile = journal + config.AskConsent = true + config.RolesSource = tierSettings(map[string]string{roles.TierKey(roles.TierLow): routeScreenModel, roles.TierKey(roles.TierMastermind): routeConfirmModel}) + }) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + a.mu.Lock() + a.personAsk = routeAsk + a.mu.Unlock() + stubbedGraph(a, func(*TaskNode) {}) + a.drainSteering(nil) // Consume the actual non-waking recovery notice. + // This is the same boundary drain used by a wake. + a.mu.Lock() + ids := append([]uint64(nil), a.turnResults...) + a.mu.Unlock() + t.Logf("restored stopped=%t, remembered result IDs=%v", a.graph().node(1).wasStopped(), ids) + r := a.readRouteJudge(context.Background(), wakeNote("a background event wakes this session"), false, c.answer) + defer r.release() + t.Logf("start=%t screen=%d confirm=%d", r.start, c.asked(), c.confirms()) + if !r.start || len(ids) != 0 { + t.Fatal("recovery fixture or dev ruling changed") + } + assertDevRouteReadings(t, c, 1) +} + +func TestUnrevisedMixedResultsKeepDevRouteAsk(t *testing.T) { + c := &routeCompleter{answer: "The UI audit stays stopped; finish the storage audit.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + ui := r1845Node(t, a, "UI audit") + storage := r1845Node(t, a, "storage audit") + g := a.graph() + g.mu.Lock() + ui.state, ui.stopped = TaskFailed, true + ui.spec.brief = "Audit only pricing UI views and produce UI-ONLY-FINDINGS.md" + storage.state = TaskFailed + storage.spec.brief = "Audit only pricing storage transactions and produce STORAGE-ONLY-FINDINGS.md" + storage.spec.acceptance = "STORAGE-ONLY-FINDINGS.md exists" + g.mu.Unlock() + note := wakeNote("one audit stopped; the other failed") + note.replyTags = []TaskReplyTag{ui.resultTag(), storage.resultTag()} + a.mu.Lock() + a.rememberOwedLocked(note) + a.mu.Unlock() + r := a.readRouteJudge(context.Background(), note, false, c.answer) + defer r.release() + if !r.start { + t.Fatal("mixed results changed dev's ruling") + } + assertDevRouteReadings(t, c, 1) +} + +func TestFreshThanksAndMixedResultsKeepDevRouting(t *testing.T) { + c := &routeCompleter{answer: "The cancelled UI audit stays cancelled. The failed storage audit needs repair.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + ui := r1845Node(t, a, "cancelled UI audit") + storage := r1845Node(t, a, "failed storage audit") + g := a.graph() + g.mu.Lock() + ui.state, ui.stopped = TaskFailed, true + storage.state = TaskFailed + g.mu.Unlock() + c.onAnswer = func(round int) { + if round != 1 { + return + } + ch, err := a.Submit(context.Background(), "thanks") + if err != nil { + t.Error(err) + return + } + // Finish the production boundary drain before this scripted answer + // returns, so a discarded pre-steer reading cannot satisfy the counter. + a.drainSteering(nil) + go func() { + for range ch { + } + }() + } + wakes, leave := a.WatchWakes() + defer leave() + note := wakeNote("The UI audit stopped; the storage audit failed.") + note.replyTags = []TaskReplyTag{ui.resultTag(), storage.resultTag()} + if !a.enqueueNote(note) { + t.Fatal("wake refused") + } + select { + case stream := <-wakes: + collect(t, stream) + case <-time.After(10 * time.Second): + t.Fatal("no wake") + } + a.mu.Lock() + heard, turn := a.personHeard, a.turnSeq + a.mu.Unlock() + t.Logf("personHeard=%d turn=%d screen=%d confirm=%d", heard, turn, c.asked(), c.confirms()) + if heard != turn { + t.Fatal("fixture did not consume the human message") + } + assertDevRouteReadings(t, c, 2) +} + +// The expected calls and complete reader input are measured on dev a4dff1168 +// using these same production wake, Submit, retry, cancel and recovery paths. +// Compatibility controls deliberately pass on both versions; stop-only tests +// separately prove the guard changes behavior. +func assertDevRouteReadings(t *testing.T, c *routeCompleter, calls int) { + t.Helper() + c.mu.Lock() + screen, confirm, question, confirmQ := c.judged, c.confirmed, c.question, c.confirmQ + c.mu.Unlock() + want := routeJudgeQuestion(routeAsk, c.answer) + if screen != calls || confirm != calls || question != want || confirmQ != want { + t.Fatalf("dev compatibility: screen=%d confirm=%d, want %d each\nquestion=%q\nconfirm=%q\nwant=%q", screen, confirm, calls, question, confirmQ, want) + } + t.Logf("dev compatibility: screen=%d confirm=%d ask=%q", screen, confirm, routeAsk) +} + +func TestAmbiguousResultWakesKeepDevRouting(t *testing.T) { + for _, scenario := range []string{ + "no provenance", "legacy tag", "unknown task", "background outcome", + "untagged news batched first", "untagged news batched last", "nested batch", + "untagged news consumed later", "other request", "mixed requests", + "two stopped attempts", "non-settled tag", "answers earlier human", + } { + t.Run(scenario, func(t *testing.T) { + c := &routeCompleter{answer: "Here is what happened.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + n := reviewResultNode(t, a, true, "stopped audit") + note := wakeNote("the result arrived") + note.replyTags = []TaskReplyTag{n.resultTag()} + switch scenario { + case "no provenance": + note.replyTags = nil + case "legacy tag": + note.replyTags = []TaskReplyTag{{ID: n.id, Request: routeAsk}} + case "unknown task": + note.replyTags = append(note.replyTags, TaskReplyTag{ID: 999, Request: routeAsk}) + case "background outcome": + note.otherResults = true + case "answers earlier human": + note.answersPerson = true + case "untagged news batched first": + note = batchSessionNotes([]userMessage{wakeNote("other news"), note}) + case "untagged news batched last": + note = batchSessionNotes([]userMessage{note, wakeNote("other news")}) + case "nested batch": + note = batchSessionNotes([]userMessage{batchSessionNotes([]userMessage{note, wakeNote("other news")})}) + case "other request", "mixed requests": + g := a.graph() + g.mu.Lock() + n.spec.request = "implement headless screenshots across the app and add tests" + g.mu.Unlock() + if scenario == "other request" { + note.replyTags = nil + } + note.replyTags = append(note.replyTags, n.resultTag()) + case "two stopped attempts": + g := a.graph() + g.mu.Lock() + n.attempt++ + g.mu.Unlock() + note.replyTags = append(note.replyTags, n.resultTag()) + case "non-settled tag": + g := a.graph() + g.mu.Lock() + n.state = TaskRunning + g.mu.Unlock() + note.replyTags = []TaskReplyTag{n.resultTag()} + } + a.mu.Lock() + a.rememberOwedLocked(note) + if scenario == "untagged news consumed later" { + a.rememberOwedLocked(wakeNote("other news")) + } + a.mu.Unlock() + r := a.readRouteJudge(context.Background(), note, false, c.answer) + defer r.release() + if !r.start { + t.Fatal("ambiguous wake changed dev's ruling") + } + assertDevRouteReadings(t, c, 1) + }) + } +} + +func TestFreshHumanWakesKeepDevFloors(t *testing.T) { + for _, scenario := range []string{"Person", "short Steward ask", "trivial Steward ask"} { + t.Run(scenario, func(t *testing.T) { + c := &routeCompleter{answer: "I will report the result.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + if scenario == "short Steward ask" { + a.principal = NewSteward("thanks", Budget{Wall: time.Hour}, nil) + } else if scenario == "trivial Steward ask" { + a.principal = NewSteward("please commit all of the changes now", Budget{Wall: time.Hour}, nil) + } + n := reviewResultNode(t, a, true, "stopped audit") + c.onAnswer = func(round int) { + if round != 1 { + return + } + stream, err := a.Submit(context.Background(), routeAsk) + if err != nil { + t.Error(err) + return + } + a.drainSteering(nil) + go func() { + for range stream { + } + }() + } + reviewWake(t, a, []TaskReplyTag{n.resultTag()}) + a.mu.Lock() + heard, turn := a.personHeard, a.turnSeq + a.mu.Unlock() + if heard == 0 || heard != turn || c.asked() != 0 || c.confirms() != 0 { + t.Fatalf("dev floor changed: heard=%d turn=%d screen=%d confirm=%d", heard, turn, c.asked(), c.confirms()) + } + }) + } +} diff --git a/internal/session/route_judge_mixed_results_test.go b/internal/session/route_judge_mixed_results_test.go index 42be105543..44089f5311 100644 --- a/internal/session/route_judge_mixed_results_test.go +++ b/internal/session/route_judge_mixed_results_test.go @@ -3,7 +3,6 @@ package session import ( "context" "fmt" - "strings" "testing" "time" ) @@ -26,17 +25,7 @@ func TestReviewMixedResultsKeepIndependentRepairEligible(t *testing.T) { if c.asked() == 0 { t.Fatal("a different failed task's repair was suppressed by the stopped sibling's inherited request") } - c.mu.Lock() - question, confirm := c.question, c.confirmQ - c.mu.Unlock() - if question != confirm { - t.Fatalf("screen and confirm read different obligations:\n%s\n%s", question, confirm) - } - for _, want := range []string{"Only the non-stopped task results", "Do not restart stopped tasks", "Task 2 (attempt 0), failed pricing storage audit", "Stopped task 1, report only: cancelled pricing UI audit"} { - if !strings.Contains(question, want) { - t.Errorf("the mixed-result question omits %q:\n%s", want, question) - } - } + assertDevRouteReadings(t, c, 2) } func TestStoppedResultRoutingUsesTheDeliveredAttempt(t *testing.T) { @@ -83,7 +72,7 @@ func TestStoppedResultRoutingUsesTheDeliveredAttempt(t *testing.T) { } } -func TestMixedResultRoutingRetainsEachTasksEffectiveTarget(t *testing.T) { +func TestMixedResultRoutingKeepsDevAskWithRevisedTargets(t *testing.T) { for _, reverse := range []bool{false, true} { t.Run(fmt.Sprintf("reversed_%t", reverse), func(t *testing.T) { c := &routeCompleter{answer: "Only the storage audit needs work.", verdict: routeYes} @@ -108,12 +97,7 @@ func TestMixedResultRoutingRetainsEachTasksEffectiveTarget(t *testing.T) { if !ruling.start { t.Fatal("the independent result was suppressed") } - c.mu.Lock() - question, confirm := c.question, c.confirmQ - c.mu.Unlock() - if question != confirm || !strings.Contains(question, target) || strings.Contains(question, "Its own target: "+routeAsk) { - t.Fatalf("routing lost the eligible task's own effective target:\n%s\n%s", question, confirm) - } + assertDevRouteReadings(t, c, 1) }) } } diff --git a/internal/session/route_judge_restored_result_test.go b/internal/session/route_judge_restored_result_test.go index c813f2809c..57b57c433c 100644 --- a/internal/session/route_judge_restored_result_test.go +++ b/internal/session/route_judge_restored_result_test.go @@ -22,7 +22,7 @@ func TestReviewRestoredStoppedResultCannotRestart(t *testing.T) { g.mu.Unlock() t.Logf("checkpoint ending=%q, restored stopped=%t", record.Ending, restored.wasStopped()) reviewWake(t, a, []TaskReplyTag{restored.resultTag()}) - if c.asked() != 0 { + if c.asked() != 0 || c.confirms() != 0 || admitted(g) != 1 { t.Fatal("a restored stopped result reached the classifier and can restart the stopped request") } } diff --git a/internal/session/route_judge_stopped_result_test.go b/internal/session/route_judge_stopped_result_test.go index b91810fcf2..78d53ebd37 100644 --- a/internal/session/route_judge_stopped_result_test.go +++ b/internal/session/route_judge_stopped_result_test.go @@ -2,7 +2,7 @@ package session import ( "context" - "strings" + "fmt" "testing" "time" ) @@ -68,12 +68,10 @@ func TestReviewFreshHumanRepeatDuringStoppedWake(t *testing.T) { if heard != turn { t.Fatal("fixture did not consume the human message in the result turn") } - if c.asked() == 0 { - t.Fatal("fresh human repeat was consumed but the stopped-result guard suppressed routing") - } + assertDevRouteReadings(t, c, 2) } -func TestFreshHumanSteeringSuppliesTheRouteAsk(t *testing.T) { +func TestFreshHumanSteeringKeepsDevRouteAsk(t *testing.T) { c := &routeCompleter{answer: "I will implement the screenshots now.", verdict: routeYes} a, _, _ := routeAgent(t, c) a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) @@ -94,12 +92,24 @@ func TestFreshHumanSteeringSuppliesTheRouteAsk(t *testing.T) { }() } reviewWake(t, a, []TaskReplyTag{stopped.resultTag()}) - c.mu.Lock() - question, confirm := c.question, c.confirmQ - c.mu.Unlock() - for _, got := range []string{question, confirm} { - if !strings.Contains(got, fresh) || strings.Contains(got, routeAsk) { - t.Fatalf("routing did not read the new human instruction: %q", got) - } + assertDevRouteReadings(t, c, 2) +} + +func TestStoppedOnlyResultWakeAdmitsNothing(t *testing.T) { + for _, multiple := range []bool{false, true} { + t.Run(fmt.Sprint(multiple), func(t *testing.T) { + c := &routeCompleter{answer: "The audit was stopped.", verdict: routeYes} + a, _, _ := routeAgent(t, c) + a.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) + stopped := reviewResultNode(t, a, true, "stopped audit") + tags := []TaskReplyTag{stopped.resultTag()} + if multiple { + tags = append(tags, reviewResultNode(t, a, true, "another stopped audit").resultTag()) + } + reviewWake(t, a, tags) + if c.asked() != 0 || c.confirms() != 0 || admitted(a.graph()) != len(tags) { + t.Fatalf("stopped-only wake: screen=%d confirm=%d admitted=%d", c.asked(), c.confirms(), admitted(a.graph())) + } + }) } } diff --git a/internal/session/route_judge_test.go b/internal/session/route_judge_test.go index 2a33b5cdf2..78f1bce7f4 100644 --- a/internal/session/route_judge_test.go +++ b/internal/session/route_judge_test.go @@ -1977,12 +1977,17 @@ func TestAStoppedTaskResultCannotAutoStartItsRequestAgain(t *testing.T) { id := graph.reserve() graph.admit(id, taskSpec{title: "the requested audit", request: tc.request, brief: "audit", acceptance: "done"}) if tc.stopped { + graph.mu.Lock() + graph.nodes[id].state = TaskFailed + graph.mu.Unlock() graph.node(id).markStopped() } agent.principal = NewSteward(routeAsk, Budget{Wall: time.Hour}, nil) if tc.arrived { agent.mu.Lock() - agent.turnResults = []uint64{id} + note := wakeNote("the task result arrived") + note.replyTags = []TaskReplyTag{graph.node(id).resultTag()} + agent.rememberOwedLocked(note) agent.mu.Unlock() } user := userText(routeAsk) diff --git a/internal/session/session.go b/internal/session/session.go index 9a9aefc465..b59a6fc377 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -664,12 +664,11 @@ type TaskReplyTag struct { // neither and is read exactly as it always was. Obligation string `json:"obligation,omitempty"` Revision uint64 `json:"revision,omitempty"` - // State, Stopped and Attempt belong to the result's own attempt. A delayed + // State and Stopped belong to the result's own attempt. A delayed // delivery must not borrow a re-armed node's new cancellation state. Empty - // State identifies older tags that still need the node as a fallback. + // State identifies older tags without positive stop provenance. State TaskState `json:"state,omitempty"` Stopped bool `json:"stopped,omitempty"` - Attempt int `json:"attempt,omitempty"` } // Event is one observable thing in a turn. A Submit returns a channel of @@ -2833,10 +2832,11 @@ type Agent struct { // [Agent.deliveringOwnedResult]), and that question has to survive a result // whose words were empty. turnResults []uint64 - // turnResultTags keeps each arrival's target and cancellation snapshot for - // routing. Unlike owedAsks, siblings with the same request stay distinct. - // It has the same turn lifetime as turnResults. - turnResultTags []TaskReplyTag + // turnResultTags keeps cancellation provenance for the stopped-only guard. + // It retains every arrival, so mixed attempts cannot look stopped-only. + // turnNonTaskNews marks arrivals without exclusive task-result provenance. + turnResultTags []TaskReplyTag + turnNonTaskNews bool // personTurns is the same answer for the turns BEFORE the newest one, kept // for the same reason and bounded (admission_compile.go). personAsk answers // "what is the current ask"; this answers "what else have they told us", diff --git a/internal/session/wakecause.go b/internal/session/wakecause.go index 49360cabd0..779a119e4e 100644 --- a/internal/session/wakecause.go +++ b/internal/session/wakecause.go @@ -71,6 +71,9 @@ const ( // places a message reaches the transcript — the turn's opening and the steering // drain — so a landing that arrives mid-turn is owed by the turn it lands in. func (a *Agent) rememberOwedLocked(user userMessage) { + // Untagged news cannot establish a stopped-only wake. The empty opening + // envelope contributes no news; its actual notes arrive in the first drain. + a.turnNonTaskNews = a.turnNonTaskNews || user.nonTaskNews || user.otherResults || len(user.replyTags) == 0 && !user.empty() a.rememberProgramOutcomeLocked(user) if question := strings.TrimSpace(user.landingQuestion); question != "" { a.oweLocked(owedAsk{text: question, from: owedByPerson}) @@ -102,21 +105,10 @@ func (a *Agent) rememberOwedLocked(user userMessage) { // text and the write seam needs the node rather than the sentence // (writeseam.go's [Agent.deliveringOwnedResult]). a.arrivedLocked(tag.ID) - a.rememberResultTagLocked(tag) + a.turnResultTags = append(a.turnResultTags, tag) } } -// rememberResultTagLocked retains the first snapshot of each delivered attempt, -// even when another task inherited the same request. The caller holds a.mu. -func (a *Agent) rememberResultTagLocked(tag TaskReplyTag) { - for _, already := range a.turnResultTags { - if already.ID == tag.ID && already.Attempt == tag.Attempt { - return - } - } - a.turnResultTags = append(a.turnResultTags, tag) -} - // arrivedLocked records one result that landed in this turn, once. The caller // holds a.mu. func (a *Agent) arrivedLocked(id uint64) { @@ -165,6 +157,7 @@ func (a *Agent) oweLocked(ask owedAsk) { func (a *Agent) forgetOwedLocked() { a.owedAsks, a.landingOutcomes, a.turnResults, a.programOutcomeNow, a.personCardAnswers = nil, nil, nil, nil, nil a.turnResultTags = nil + a.turnNonTaskNews = false } // turnAsk is the ask this turn's endings are read against. @@ -249,7 +242,7 @@ func (n *TaskNode) resultTagLocked() TaskReplyTag { return TaskReplyTag{ ID: n.id, Title: n.spec.title, Request: n.spec.request, Obligation: obligation, Revision: revision, - State: n.state, Stopped: n.stopped, Attempt: n.attempt, + State: n.state, Stopped: n.stopped, } } From 2980704dc30611c360fed4868cbfe63a9234fd4b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 13:52:22 -0400 Subject: [PATCH 11/11] test(session): drain steering before reading routes in stopped-wake tests Both fresh-human stopped-wake tests submitted a steer from the answer hook but let the turn race the boundary drain, so CI could count one route reading instead of two. Drain inside the hook, as the mixed-result steering test already does. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/session/route_judge_stopped_result_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/session/route_judge_stopped_result_test.go b/internal/session/route_judge_stopped_result_test.go index 78d53ebd37..efea8e1ce6 100644 --- a/internal/session/route_judge_stopped_result_test.go +++ b/internal/session/route_judge_stopped_result_test.go @@ -55,6 +55,9 @@ func TestReviewFreshHumanRepeatDuringStoppedWake(t *testing.T) { t.Error(err) return } + // Finish the production boundary drain before this scripted answer + // returns, so the steer is consumed in this turn deterministically. + a.drainSteering(nil) go func() { for range ch { } @@ -86,6 +89,9 @@ func TestFreshHumanSteeringKeepsDevRouteAsk(t *testing.T) { t.Error(err) return } + // Finish the production boundary drain before this scripted answer + // returns, so the steer is consumed in this turn deterministically. + a.drainSteering(nil) go func() { for range ch { }