Repository navigation
fix(waitcause): a terminal tool call ends the thread, so the keep-alive sends no pings - #859
Conversation
…ve sends no pings (#857) A response whose last tool_use is a terminal tool (default: StructuredOutput) means the agent has finished. No tool result comes back. - waitcause: new waiting_for value "done". ClassifyWaitingForTerminal takes the list. - waitcause: SubagentHandback reads like end_turn with nothing open. - keepalive: pingable() is false for a done thread. No pings are planned. - config: cache.keepalive_terminal_tools. [] turns the rule off. Closes #857 Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
5ad857f to
affb47a
Compare
amiddavid
left a comment
There was a problem hiding this comment.
| Reviewer | Kind | Started by | Context | Review | Round | Head | Verdict |
|---|---|---|---|---|---|---|---|
| Reviewer 1 (glm, user of @amiddavid) | GLM rits/zai-org/glm-5-3 via Claude Code 2.1.316 | offload-review skill, @amiddavid's session | fresh | 1 of 2 | 1 of 2 | affb47a60bfd |
blocking |
Verdict: blocking
The change is sound in its core. The classifier, the keeper gate, and the tests are careful.
Two gaps remain: the off switch does not work for hosted tenants, and a truncated response
window can mark a live thread as done.
Findings
-
config/config.go:173 — a hosted tenant's
keepalive_terminal_toolsis parsed, then silently dropped- What fails: hosted mode, a tenant document sets
cache: keepalive_terminal_tools: []to turn
the rule off.buildTenantConfigmaps the tenant'scache:block throughcachePolicy
(cmd/context-guru-proxy/main.go:1124).proxy.CachePolicyhas no terminal field, so the value
is lost.proxy.Options.TerminalTools(main.go:690) holds only the host process config. The
tenant keeps the #857 behavior, cannot turn it off, and gets no warning. The PR body itself
says the change affects "Hosted and local" users, and namescache.keepalive_terminal_tools: []
as the off switch — an instruction that does nothing for half its audience. - Proof:
grep KeepAliveTerminalToolsover the repo: read only at main.go:690 (the host's own
config).cachePolicy(main.go:1117-1118) is the only path a tenant document'scache:block
takes, and it drops the field. docs/how-to/cache-keepalive.md:74 says "Set
keepalive_terminal_tools: []to turn this off" with no host-only caveat. The key also
survives a settings-page save (ApplyForm writes only its own tuning keys), so a tenant can
carry it indefinitely without effect. - Fix: pick one. (a) State in docs/how-to that the key is host-config only, and that hosted
tenants cannot change it. (b) Carry the list per tenant, the waycachePolicycarries the
rest of thecache:block.
- What fails: hosted mode, a tenant document sets
-
keepalive/keeper.go:310 — a spliced sniff window can mark a live thread
done, so it gets no pings- What fails: a streamed response longer than the sniffer's window (64 KiB head + 64 KiB tail,
proxy/usage.go:681). The response callsStructuredOutputwith over 64 KiB of arguments, then
one more tool with over 64 KiB of arguments. The second call'scontent_block_startevent
falls in the dropped middle, soresponseToolUseNamesnever sees it. The last visible name is
StructuredOutput, soIsDoneanswers true. The thread really waits on the second tool's
result, but the keeper plans no pings. When the result arrives, the thread pays a cold cache
rewrite. The dashboard row also readswaiting_for = done, which is wrong. - Proof: proxy/usage.go:603-606 documents
ToolUseNamesas best-effort over the bounded window,
"not exhaustive".responseToolUseNames(proxy/usage.go:606) reads names only from events
insidesniffer.bytes(). Before this PR the same truncation could only mislabelwait_tool;
IsDoneturns the last visible name into a terminal verdict. The PR's own test
"StructuredOutput NOT last stays own_tool" states the intent that only the true last call
counts; the window breaks that intent silently. - Bound: money only, at most the strategy's ping value per thread. No data loss, no fail-open
break. The scenario is rare (it needs two tool calls of over 64 KiB each in one response). - Fix:
responseUsageWhyalready knows thesplicedflag. Carry it next toToolUseNames
(a field or a sentinel), and makeIsDoneanswer false for a response whose name list may be
incomplete. A spliced response then keeps today's ping behavior.
- What fails: a streamed response longer than the sniffer's window (64 KiB head + 64 KiB tail,
Non-blocking
- keepalive/keeper.go:944 — a thread refused because it is done is counted with reason
ReasonBelowLimits. An operator reading the refuse stats sees "below limits" for a thread that
was fine. A dedicated reason would read true. - internal/waitcause/waitcause.go:81-85 —
IsDone'srecover()cannot fire; nothing in the
function can panic.terminalSetalso builds a map on every call, now on the record path of
every request. A package-level set for the default list drops both. - internal/waitcause/waitcause.go:151 —
donewins over open background and teammate counts, and
no test coversStructuredOutputwithopenBackground > 0. If the 226-of-226 measurement
included threads with open background tasks, say so in the PR. If it did not, background should
win, as it already does forSubagentHandback. - proxy/proxy.go:2032-2039 — on the streamed path, when the final expand round's usage does not
parse,ToolUseNameskeeps the earlier round's list whileStopReasonis refreshed. The shape
predates this PR, butIsDonenow acts on the stale list. The usual failure direction is
missed-done (pings sent), which is safe. - The wiring config →
proxy.Options.TerminalTools→ keeper and capture has no test. The library
behavior is tested; the three assignments are not.
What I checked
- The full diff, and the code around each change:
ClassifyWaitingForTerminal's order of checks,
IsDone,pingable,Record's refuse path, the sweep's held answer, the capture closures. - Fail open:
IsDoneis pure and cannot panic; a false answer only skips pings. The request path
is untouched. - The off switch at each layer:
nilvs[]vs a custom list, in the classifier tests, the keeper
tests, andterminalSet. - Reversibility and data dependence: tool names only; the data-dependence row is updated and
matches the code. - The config round trip:
CacheConfigis never marshaled, sonilcannot become[]behind an
operator's back. A form save writes only its own tuning keys, so a stored
keepalive_terminal_toolssurvives. - The dash side:
waiting_foris a plain TEXT column with no enum; no UI or query code enumerates
its values, sodonelands as its own bucket. ClassifyWaitingFor(old signature) has no production caller left; both live callers pass the
configured list.- main.go's two config paths (single-tenant host config vs hosted tenant documents), which is
finding 1. - The PR body: it names the behavior change, the off switch, and the data dependence, as the repo
rules require.
amiddavid
left a comment
There was a problem hiding this comment.
| Reviewer | Kind | Started by | Context | Review | Round | Head | Verdict |
|---|---|---|---|---|---|---|---|
| Reviewer 2 (glm, user of @amiddavid) | GLM rits/zai-org/glm-5-3 via Claude Code 2.1.316 | offload-review skill, @amiddavid's session | fresh | 2 of 2 | 1 of 2 | affb47a60bfd |
blocking |
Verdict: blocking
Findings
-
config/config.go:173 — a tenant document accepts
keepalive_terminal_toolsand silently ignores it, so the documented off switch does not work in hosted mode- What fails: a hosted-mode tenant (or an operator editing a tenant document, which is where hosted
cache:config lives —buildTenantConfig, cmd/context-guru-proxy/main.go:1117) setskeepalive_terminal_tools: []to get the pre-#857 pings back. The strict loader accepts the key, becauseCacheConfigis shared by tenant documents.cachePolicy(main.go:1124) maps the block ontoproxy.CachePolicyand drops the field, andproxy.Options.TerminalToolscomes only from the host's own top-level config (main.go:690). The tenant's setting has no effect and no error is shown.docs/how-to/cache-keepalive.md:74says "Setkeepalive_terminal_tools: []to turn this off" without saying the key is host-level only; the PR body does say "process-wide, also in hosted mode", but the operator doc does not. - Proof:
grepshowsKeepAliveTerminalToolsis read in exactly one place (main.go:690, from the host'scfg.Cache);buildTenantConfig→cachePolicy(cfg.Cache)never touches it; the tenant document goes throughconfig.LoadBytes, which knows the key. So the key is valid in a tenant document and dead there. - Fix: say in
docs/how-to/cache-keepalive.mdthat the key is host-level config only and is ignored in a tenant document (the operator sets it in the main config file). Better: reject the key in tenant documents, so a silently-ignored setting cannot be stored at all. This is a doc/config decision — it needs the owner's word, not mine.
- What fails: a hosted-mode tenant (or an operator editing a tenant document, which is where hosted
-
config/config.go:173 — no test pins the nil-versus-
[]semantics of the new YAML key- What fails: the off switch rests on one distinction: an absent key loads as
nil(default list on) andkeepalive_terminal_tools: []loads as a non-nil empty list (rule off). No test loads a YAML document and asserts either value. The keeper tests (keepalive/terminal_test.go:53) build[]string{}by hand, so a later change in the loader, the form round-trip, or a normalization that turns[]intonilwould flip every "off" deployment back to the default rule with no failing test. - Proof:
grep -rn "TerminalTools\|terminal_tools" config/*_test.go tenant/*_test.go cmd/ proxy/*_test.gomatches onlymain.go:690— no test anywhere loads the key from YAML. - Fix: add a config test: load a document with the key absent and assert
cfg.Cache.KeepAliveTerminalTools == nil; load one withkeepalive_terminal_tools: []and assert it is non-nil and empty; load[Finish]and assert the value. One test function, three cases.
- What fails: the off switch rests on one distinction: an absent key loads as
Non-blocking
internal/waitcause/waitcause.go:80— therecoverinIsDoneis dead code: a length check and a map read cannot panic. Harmless, but it suggests a panic risk that is not there.internal/waitcause/waitcause.go:54—DefaultTerminalToolsis an exported mutable var. Any caller could mutate it and change process behavior. A function or an unexported var would be safer.internal/waitcause/waitcause.go:138—ClassifyWaitingForkept its signature but changed behavior (it now applies the default terminal list, so a lastStructuredOutputreadsdone). Its doc comment does not say so.- A configured list of only empty strings (
[""]) turns the terminal match off but leaves theSubagentHandbackrelabel on, while[]turns both off. An edge case of a malformed list;terminalSetdrops empty names butlen(terminal) > 0still gates the handback rule. scripts/analysis/wait_causes.py:58—WAITING_FOR_VALUESdoes not knowdone. The PR says the script is unchanged on purpose; fine, noted for whoever runs it on post-#857 transcripts.- The default list applies to every provider and every host, though the 226-of-226 measurement is hosted Claude traffic. On OpenAI a function named
StructuredOutputwould also end the pings. The failure direction is the cheap one (a forgone ping, not a wrong one), and the PR names the behavior change at the top, so this is only a note. - The proxy-layer wiring (dashcapture's
terminal,newKeeperreadingh.opts.TerminalTools) has no test of its own. The behavior it carries is tested one layer down, so I did not make it a finding.
What I checked
- Read
CLAUDE.mdanddocs/design.md(pipeline, fail-open, keep-alive rules, data dependence). - Read the full diff
c2a06dd5..HEADand the code around every hunk:internal/waitcause/waitcause.go(whole file),keepalive/keeper.go(Record, pingable, Sweep, Options, entry fields),keepalive/substitute.go,keepalive/terminal_test.goplus its helpers (testKeeperWith,kaPolicy,fakeDispatcher.hold/holds/n),proxy/dashcapture.go(newCapture, finish closures),proxy/keepalive.go(newKeeper/newKeeperWith and the strategy-rebuild path),proxy/proxy.go,cmd/context-guru-proxy/main.go(Options wiring,buildTenantConfig,cachePolicy),config/config.go,config/form.go(ApplyForm merge — a settings save preserves the new key in a stored document), both changed tests and why they had to change (CacheConfigbecame non-comparable; I grepped for other==/!=onCache/Configand found none). - Traced the classification logic by hand: terminal-last →
done; terminal not last →own_tool; handback-last → end_turn-like path withtoolUseNamesnil andusageOKforced true; empty list skips both rules;failedwins overdone. Confirmed no consumer switches onWaitingForvalues (dash schema/store/query store the string as-is; no UI file reads it). - Confirmed a done thread never reaches a ping:
pingable()is false at Record, so no entry (and no credential hold) is created; Sweep, Plans and the extra-slot path all sit behind the same gate. A done thread that resumes is tracked again on its next response, becauseturnsoutlives the entry. - Checked lock discipline:
pingableruns underk.muin Sweep/Plans;sweepWaitCausecopies the entry fields under the lock;capture.terminalis written at capture creation and read on the writer goroutine after a channel handoff. - Checked the PR body (description only, no comments): the behavior change is named at the top with who it affects, old and new behavior, and the off switch, per
RELEASING.md. - Did not run builds or tests (not approved; Go is not on this machine).
|
Behavior change: approved by the owner (@amiddavid), 2026-11-02, in the session that started this PR. The approved change:
The owner gave this yes in words, in the session. This comment records it on the PR so the integrator can read it. The round-2 review fixes do not widen the change: they make the rule fire in fewer cases (a cut-off response is never |
- A spliced sniff window never reads "done": Usage.ToolUseIncomplete and Record.ToolUseIncomplete switch the rule off for that response. - A tenant document that sets keepalive_terminal_tools is refused. The key is host config only. Docs say so. - New refuse reason "done" for a finished thread (dash, band, mod, cg.py). - IsDone: no dead recover, default set built once, DefaultTerminalTools is now a copy-returning function, empty names act like []. - Tests: YAML load of the key, done beats open background, empty-names list, incomplete names, refuse reason, tenant refusal. Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Round 1 answers. All fixes are in Blocking
Non-blocking
|
amiddavid
left a comment
There was a problem hiding this comment.
| Reviewer | Kind | Started by | Context | Review | Round | Head | Verdict |
|---|---|---|---|---|---|---|---|
| Reviewer 1 (glm, user of @amiddavid) | GLM rits/zai-org/glm-5-3 via Claude Code 2.1.316 | offload-review skill, @amiddavid's session | resumed | 1 of 2 | 2 of 2 | 4cb67cfaf4d9 |
blocking |
Verdict: blocking
Round 1 asked for two fixes. Both were addressed, and the spliced-window fix is correct end to
end. But the tenant-key fix landed in the wrong layer: buildTenantConfig also rebuilds the
single-tenant host's own document, so the new refusal breaks /api/adapt there, and it fires
too late (request time, as a 401) for a hosted document that does carry the key.
Findings
- cmd/context-guru-proxy/main.go:1116 — the tenant-key refusal breaks
/api/adapton a single-tenant proxy, and misses the hosted save path- What fails, single-tenant: the operator sets
cache.keepalive_terminal_toolsin the proxy's
config file — the documented host use (docs/how-to/cache-keepalive.md:74 says "host
configuration only", and main.go:692 reads it from that same file). main.go:609-612 makes
that file theBaseConfig, and main.go:652 wiresBuildConfig: buildTenantConfigwith no
hosted-mode gate. Every/api/adaptapply or revert (proxy/adaptapply.go:321, 345) edits
the BaseConfig and rebuilds it throughbuildTenantConfig, which now refuses the key. So
every apply on that proxy answers 400: "cache.keepalive_terminal_tools is host
configuration; a tenant document cannot set it" — for the host's OWN document. The plugin's
/context-guru:save-moreflow is such an apply. A saved override also stops reapplying at
startup (adaptapply.go:378 warns and skips). Before this commit, all of that worked. - What fails, hosted: the refusal sits at build time, not save time.
tenant.Registry.Update
validates a stored document withconfig.Validate(tenant/tenant.go:703-706), which accepts
the key. The account editor takes a raw document. A manager who pastes one with the key
passes save validation and the document is stored. The tenant's every request then fails in
TenantSource.tenancy, andstatusOfmaps the plain error to 401 unauthorized
(proxy/tenancy.go:417-420): the tenant is bricked behind a misleading auth error. The
commit's premise "the key is new, so no stored document has it" holds only until a manager
pastes one. - Proof:
BaseConfig= the config file's bytes (main.go:609-612);BuildConfigis
buildTenantConfigunconditionally (main.go:651-652), also whentenants == nil;
reloadLocalcallsh.opts.BuildConfigon every local apply and revert
(adaptapply.go:290, 321, 345).config.Validate(config/config.go:381-390) runs
LoadBytes+Buildonly — nothing refuses the key before storage. - Fix: enforce the restriction where a TENANT document is saved, not where any document is
built. Wrap theValidatehanded totenant.Open(main.go:519) to also refuse the key, and
letbuildTenantConfigaccept it again (or pass a tenant/local flag). A manager then gets a
400 on the settings page at save time, and the local adapt path — which rebuilds the host's
own document — keeps working.
- What fails, single-tenant: the operator sets
Round 1 findings: status
- R1-1 (tenant key ignored): addressed, but the fix is finding 1. The refusal is real and
tested (TestTenantDocumentRefusesTerminalTools), and the docs now say host-only. The layer
is wrong. - R1-2 (spliced window can read
done): fixed, verified end to end.
responseUsageWhysetsUsage.ToolUseIncompletefrom the sniffer'ssplicedflag
(proxy/usage.go:373). It travels intokeepalive.Record.ToolUseIncompleteand the capture
(proxy/keepalive.go:392, proxy/dashcapture.go:509-511), andterminalForempties the list for
that response (keepalive/keeper.go:272-277). Both tests cover it. The flag and the name list
always come from the same round, including the sticky-usage multi-round paths. The direction
is conservative: any stream over the 64 KiB head window keeps today's pings. - Own refuse reason: fixed.
ReasonDone = "done"(keepalive/manualhold.go:62), set in
Record's refuse path, and added to the dash comment, the band and mod text (model.tsand its
test), and bothcg.pycopies. The strings match the keeper's. - Dead
recover, map per call, exported mutable list: fixed.IsDoneis plain; the default
set is built once;DefaultTerminalTools()returns a copy, with a test. donevs open background: kept, with a reason and a test. The reason — a finished
subagent gets no later notification on its own thread — is sound, and
"done beats an open background task" now pins it. I accept it.- Stale
ToolUseNamesafter an expand round: left, safe direction. Accepted. - Wiring test: declined with a reason. The ends (YAML semantics, tenant refusal) are now
tested; the three assignments sit insidemain's setup. Accepted.
Reviewer 2, round 1
- R2-1 (tenant key ignored): same as my R1-1. Addressed; see finding 1 for the defect the
fix adds. - R2-2 (no YAML test for nil vs
[]): fixed.config/terminal_tools_test.goloads absent
(nil),[](non-nil, empty) and[Finish], and asserts each. I checked the cases and the
loader path; they are right. - Reviewer 2's non-blocking notes are all addressed or answered above, except the OpenAI
cross-provider note (a function namedStructuredOutputon OpenAI also ends pings). That
stands unanswered; the failure direction is a forgone ping, so it stays a note.
Non-blocking
terminalFor(keepalive/keeper.go:273) allocates a fresh empty slice per recorded response
with an incomplete name list. One small allocation on a rare path; a package-level empty slice
would drop it.- The owner's approval of the behavior change is recorded on the PR (comment, 2026-11-02
15:55:52Z), with who it affects and the off switch. That satisfies the repo's rule.
What I checked
- Last review: round 1, affb47a, 2026-11-02T15:52:03Z.
- New commits read: 1 —
4cb67cfa"fix(waitcause): round 1 review fixes for the terminal-tool
rule (#857)". - Read in full: 2 reviews, 2 comments, 0 inline comments (from the three files
.review-in-reviews.txt,.review-in-comments.txt,.review-in-inline.txt). - New items since my last review, and what I did about each:
- Reviewer 2's round-1 review (15:52:52Z): read in full; both findings checked — one shared
with mine, one (the YAML test) verified as fixed. - Owner's approval comment (15:55:52Z): read; checked it names the change, the audience and
the off switch. - Author's round-1 answers (16:16:29Z): read; each claim re-verified in the code (see
"Round 1 findings: status"). - Commit
4cb67cfa: full diff read, plus the code around every hunk.
- Reviewer 2's round-1 review (15:52:52Z): read in full; both findings checked — one shared
- The refusal's blast radius: every caller of
buildTenantConfig(main.go:526 tenant source,
main.go:652BuildConfig), the whole/api/adaptlocal path (adaptWriteLocal,
adaptRevertLocal, the startup reapply), the hosted save path (tenant.Registry.Update→
config.Validate), and how a build error reaches the tenant (statusOf→ 401). This is
finding 1. - The spliced fix at every
responseUsageWhycall site (sniffed stream, buffered, SSE splicer),
and the pairing ofToolUseNameswithToolUseIncompleteacross the sticky multi-round
usage merge. - The waitcause rewrite by hand: nil → shipped set,
[]and[""]→ rule off (done and
handback both), custom list → replaced, handback still gated on the cleaned set. No stale
reference to the oldDefaultTerminalToolsvar remains. - The new tests' premises:
stateOfexists (keepalive/manualhold_test.go:30),preset: offis
a real preset (config/config.go:486),components.NopEmitter{}is the same emitter
config.Validateuses. - Did not run builds or tests (not approved; Go is not on this machine).
amiddavid
left a comment
There was a problem hiding this comment.
| Reviewer | Kind | Started by | Context | Review | Round | Head | Verdict |
|---|---|---|---|---|---|---|---|
| Reviewer 2 (glm, user of @amiddavid) | GLM rits/zai-org/glm-5-3 via Claude Code 2.1.316 | offload-review skill, @amiddavid's session | resumed | 2 of 2 | 2 of 2 | 4cb67cfaf4d9 |
blocking |
Verdict: blocking
Round 1 is answered well: the spliced-window guard is right, the YAML semantics are pinned, the
smaller items are all fixed. But the tenant-refusal fix has the wrong blast radius: it also fires
on the single-tenant /api/adapt path, which rebuilds the host's own config document — the one
place the key is legitimate.
Findings
-
cmd/context-guru-proxy/main.go:1116 — the tenant-document refusal breaks every
/api/adaptwrite on a single-tenant proxy whose config file setskeepalive_terminal_tools- What fails: an operator follows the new docs and puts
keepalive_terminal_tools: [Foo]in the config file passed with--config. Startup is fine (loadConfigreads it,proxy.Options.TerminalToolsis set). Then any/api/adapt/*write — settings, compaction, tools, the plugin's save-more — callsadaptWrite→adaptWriteLocal(proxy/adaptapply.go:131), which starts fromh.opts.BaseConfig(the same file, main.go:608-613), edits it (the edits preserve unknown keys), and rebuilds it throughBuildConfig=buildTenantConfig(adaptapply.go:290-292).buildTenantConfignow refuses any document that sets the key, so the whole apply fails with HTTP 400, and the error message is wrong for this case: the document is not "a tenant document", it is the host's own config, where the key is valid. The operator cannot apply any adapt change until they remove the key from the file and restart. - Proof: main.go:652 wires
BuildConfig: buildTenantConfigfor the single-tenant host too, and main.go:608-613 puts the--configfile's bytes intoBaseConfigwhencfgPathis set.reloadLocal(adaptapply.go:287-292) is the only consumer ofBuildConfigon that path and passes it the editedBaseConfig. The refusal at main.go:1113-1117 checks onlycfg.Cache.KeepAliveTerminalTools != nil, with no way to tell a tenant registry rebuild from a local host rebuild. - Fix: scope the refusal to tenant documents only. The tenant registry path is
proxy.NewTenantSource(reg, emitter, buildTenantConfig, ...)(main.go:525) — wrap the check there (a smallfunc(doc []byte, e components.Emitter)that loads, refuses on the key, then callsbuildTenantConfig), and leaveBuildConfigpermissive, since it rebuilds the host's own document. A test should cover both: a tenant doc with the key is refused; a local adapt rebuild of a doc with the key succeeds.
- What fails: an operator follows the new docs and puts
-
docs/dashboard.md:143 — the
ping_reasonvalue list does not includedone- What fails: the round-2 commit added the new refuse reason everywhere it enumerated the values (dash/cachestate.go comment,
model.tslong and short forms plus its test, bothcg.pycopies) but missed this doc.docs/dashboard.mdsays "ping_reasonis one of:" and lists ten values;doneis a real, reachable value (ReasonDoneis set byRecord's refuse path and read back through/api/cachestate), so the doc's exhaustive list is now wrong by one. - Proof: keepalive/manualhold.go adds
ReasonDone = "done"; keeper.go:963-965 sets it viasetReasonLocked; the reasons map is what the Ping button's explanation and/api/cachestateread (manualhold.go's own doc). The commit updated four of the six places that enumerate the values;docs/dashboard.md:143-153and the plugin Go test below are the two it missed. - Fix: add a
doneline to the list indocs/dashboard.md(one sentence: the thread's last response ended with a terminal tool, so nothing is pinged).
- What fails: the round-2 commit added the new refuse reason everywhere it enumerated the values (dash/cachestate.go comment,
Non-blocking
- context-guru-plugin/ping_test.go:236 —
TestPingReasonWordsverifies words for everyping_reasonbut its table does not includedone. The words exist (bothcg.pycopies,model.ts); only the test's table misses the new value. - With
ToolUseIncompletetrue, the whole #857 rule set is off for that response, including theSubagentHandbackrelabel: a spliced response ending in a handback readsown_tool, nothuman. That is the pre-#857 label, the safe direction, and consistent between the keeper and the dash row. Note only.
Earlier findings, and the answers
- R2-1 / R1-1 (tenant key silently ignored): fixed in intent, but the fix is finding 1. The refusal plus the doc line answer the original gap for hosted tenant documents. The author's reason "the key is new, so no stored document has it" is right for stored tenant docs — but the adapt path reads the host's live config file, not a stored tenant doc, which is exactly the case the refusal breaks.
- R1-2 (spliced window can mark a live thread done): fixed, correctly. I verified the flag on every path:
responseUsageWhysetsToolUseIncomplete = spliced; the sniffed window passessn.spliced()(proxy.go:2463), the full-body paths passfalse(proxy.go:2132, andresponseUsageis test-only now). The streamed path takes its wholeUsage— names and flag together — from the window call (proxy.go:2021-2035), so a long stream whose middle is dropped is also covered.terminalForturns the flag into an empty list for that response only, in both the keeper and the dash capture. Tests cover both layers. - R2-2 (YAML nil-vs-
[]test): fixed.config/terminal_tools_test.gopins absent → nil,[]→ non-nil empty,[Finish]→ the list. - Reviewer 1, "done vs open background" (kept, with a reason): the reason is right, I accept it. A thread that ended with
StructuredOutputnever gets another request — a background task's notification lands on the main agent's thread, not the finished subagent's — sodoneis the truer label and the no-ping decision is unchanged either way. The new test case (openBackground 2, openTeammate 1 → done) pins it. - Reviewer 1, stale
ToolUseNamesafter an unparsed expand round (left): accepted. I traced it: the stale list is the previous round's, whose last name is the expand tool itself, not a terminal tool, so a falsedoneneeds a shape the continuation loop does not produce. The realistic direction is a misseddone(one ping sent), which only spends money. - Reviewer 1, wiring test not added: accepted. The ends are covered (YAML load, tenant refusal); the middle is three assignments inside
main's setup. - My round-1 non-blocking items: all fixed (dead
recoverremoved, default set built once,DefaultTerminalTools()returns a copy,ClassifyWaitingFordoc updated,[""]now acts like[]with a test). - Owner approval: recorded on the PR (comment at 15:55:52Z), naming who it affects, the old and new behavior, and the off switch. The round-2 fixes do not widen the approved change — the refusal in finding 1 is a config-validation change of its own, which is why it needs the owner's look, not just a code fix.
What I checked
- Last review: round 1,
affb47a60bfd872a9e60c1fd2140cbf4b5aa0b88, 2026-11-02T15:52:52Z. - New commits read: 1 —
4cb67cfa"fix(waitcause): round 1 review fixes for the terminal-tool rule (#857)". - Read in full: 2 reviews, 2 comments, 0 inline comments (from
.review-in-reviews.txt,.review-in-comments.txt,.review-in-inline.txt; the inline file is empty). - New items since my last review: the fix commit (full diff read, every hunk); the owner's approval comment (checked it names the change and the off switch); the author's round-1 answers comment (checked each claim against the code — all verified except the tenant-refusal blast radius, finding 1).
- Verified the spliced fix end to end:
Usage.ToolUseIncompleteinresponseUsageWhy, its three production callers and theirsplicedarguments, the streamed path's usage merge (proxy.go:2021-2135),Record.ToolUseIncomplete→terminalFor→kaEntry.terminal, and the dash capture's override. - Verified the refusal's reach:
BuildConfigvsBaseConfig(proxy/proxy.go:207-212),adaptWrite→adaptWriteLocal→reloadLocal→buildTenantConfig, andNewTenantSource's use of the same builder — which is finding 1. - Verified the reason plumbing:
ReasonDone, the refuse-path ordering (over budget beats done; done beats below limits),stateOftest helper, and every place that enumeratesping_reasonvalues (grep "pings used"across Go, TS, Python and docs) — which is finding 2 and the non-blocking test gap. - Checked
resolveTerminalsemantics by hand: nil → shipped set (built once),[]and[""]→ empty set → both rules off, custom list → fresh set;DefaultTerminalTools()returns a copy (test covers it). - Did not run builds or tests (not approved; Go is not on this machine).
…#857) buildTenantConfig also builds the host's own config on a single-tenant proxy (adapt apply and revert), so it must accept the key. - Tenant save: tenant.Open gets validateTenantDoc (config.Validate plus the host-only key check), so a manager gets a 400. - Tenant build: NewTenantSource gets buildHostedTenantConfig, which refuses the key and then calls buildTenantConfig. - docs/dashboard.md and TestPingReasonWords list the "done" ping_reason. Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Round 2 answers. All fixes are in
|
amiddavid
left a comment
There was a problem hiding this comment.
| Reviewer | Kind | Started by | Context | Review | Round | Head | Verdict |
|---|---|---|---|---|---|---|---|
| Reviewer 1 (glm, user of @amiddavid) | GLM rits/zai-org/glm-5-3 via Claude Code 2.1.316 | offload-review skill, @amiddavid's session | resumed | 1 of 2 | 3 of 3 | e734c87f37ef |
approve |
Verdict: approve
The round-2 finding is fixed at the right layer, and the fix holds on every path I traced. No
findings this round.
Round 2 findings: status
- My finding 1 (tenant-key refusal breaks single-tenant
/api/adapt, misses the hosted save
path): fixed.buildTenantConfigaccepts the key again, so the local adapt apply and revert
rebuild the host's own document without refusal —TestHostConfigWithTerminalToolsStillBuilds
pins it. The restriction moved to the two places a TENANT document passes through:- Save time:
tenant.OpengetsvalidateTenantDoc(main.go:519), which isconfig.Validate
plus the key check.tenant.Registry.Update(tenant/tenant.go:703-706) runs it on every
stored-document write, so a manager who pastes a document with the key gets a 400 on the
settings page, not a stored brick. - Build time:
NewTenantSourcegetsbuildHostedTenantConfig(main.go:526), which refuses the
key and then callsbuildTenantConfig— defense in depth for the per-request rebuild.
I verified the dispatch that makes this layering sound:adaptWritesendswho.Localto
adaptWriteLocal(the onlyBuildConfig/reloadLocalconsumer), andwho.Localis set only
whenreg == nil, that is single-tenant (proxy/adaptapply.go:46-49, 131). Hosted adapt writes
go throughreg.Update, which now validates.refuseTenantHostKeysreturns nil on a parse
error by design, and both its callers report the parse error themselves first.
- Save time:
- Reviewer 2, round-2 finding 1 (same blast-radius defect): fixed the same way. Checked
myself; nothing to add. - Reviewer 2, round-2 finding 2 (
docs/dashboard.mdmissingdone): fixed. The
ping_reasonlist has thedoneline (docs/dashboard.md:148). - Reviewer 2, round-2 non-blocking (
TestPingReasonWordstable): fixed. Thedonerow is
in (context-guru-plugin/ping_test.go:237).
Findings
None.
Non-blocking
errTerminalToolsHostOnly(main.go:1123) is a plain error. IfbuildHostedTenantConfigever
fired at request time,statusOfwould map it to a bare 401 (proxy/tenancy.go:417-420). It is
unreachable now — the save path refuses the key first — so this is only a latent sharp edge.
Aproxy.StatusErrorwould make the message reach the caller if that ever changes.
What I checked
- Last review: round 2, 4cb67cf, 2026-11-02T16:24:23Z.
- New commits read: 1 —
e734c87f"fix(proxy): refuse keepalive_terminal_tools in a tenant
document only (#857)". - Read in full: 3 reviews, 3 comments, 0 inline comments (from the three files
.review-in-reviews.txt,.review-in-comments.txt,.review-in-inline.txt; the inline file
is empty). - New items since my last review, and what I did about each:
- Reviewer 2's round-2 review (16:24:47Z): read in full. Finding 1 matched my round-2 finding;
finding 2 (docs/dashboard.md) and the test-table note I checked myself — both fixed in
e734c87f. - Author's round-2 answers (16:50:04Z): read; every claim re-verified in the code (see
"Round 2 findings: status"). The declined request-time test is fine: the save path now
refuses the key, so no stored document can carry it. - Commit
e734c87f: full diff read, plus the code around every hunk.
- Reviewer 2's round-2 review (16:24:47Z): read in full. Finding 1 matched my round-2 finding;
- The new layering on every path a document takes: single-tenant startup (
loadConfig→
proxy.Options.TerminalTools), single-tenant adapt (adaptWrite→who.Local→
adaptWriteLocal→reloadLocal→BuildConfig=buildTenantConfig, key accepted),
hosted save (registry.Update→validateTenantDoc), hosted per-request build
(TenantSource.build→buildHostedTenantConfig), and the migration hook
(tenant.Open's Validate, used byfixDeprecatedExtractLLMConfigs— no stored document can
have the new key). - The tests added in this commit:
TestTenantDocumentRefusesTerminalToolsnow covers both the
save-time check and the hosted build;TestHostConfigWithTerminalToolsStillBuildscovers the
local rebuild. Their fixtures usepreset: off, a real preset. - The
ping_reasondocumentation set is now complete: dash/cachestate.go's comment,model.ts
long and short forms plus its test, bothcg.pycopies,docs/dashboard.md, and the plugin's
TestPingReasonWords. - Did not run builds or tests (not approved; Go is not on this machine).
amiddavid
left a comment
There was a problem hiding this comment.
| Reviewer | Kind | Started by | Context | Review | Round | Head | Verdict |
|---|---|---|---|---|---|---|---|
| Reviewer 2 (glm, user of @amiddavid) | GLM rits/zai-org/glm-5-3 via Claude Code 2.1.316 | offload-review skill, @amiddavid's session | resumed | 2 of 2 | 3 of 3 | e734c87f37ef |
approve |
Verdict: approve
Both round-2 findings are fixed at the right layer, with tests on both sides of the split. I found
no new defect in e734c87f.
Findings
None.
Round-2 findings: status
- Tenant-key refusal broke
/api/adapton a single-tenant host (my finding 1; also reviewer 1, round 2, finding 1, single-tenant half): fixed.buildTenantConfigaccepts the key again (main.go:1110-1122, the refusal is gone), so the
local adapt apply and revert rebuild the host's own document as before.BuildConfigat
main.go:652 is stillbuildTenantConfig, andTestHostConfigWithTerminalToolsStillBuilds
pins the case I asked for: a local rebuild of a host config with the key succeeds.
- Hosted save path let the key through to request time (reviewer 1, round 2, finding 1, hosted half — I checked it myself): fixed.
tenant.Opennow getsvalidateTenantDoc(main.go:519), which runsconfig.Validateplus
refuseTenantHostKeys.Registry.Updatevalidates everyConfigYAMLpatch with the
injected function (tenant/tenant.go:703-706), and both tenant-doc write paths go through
Update: the account editor, and the hosted/api/adaptwrite (proxy/adaptapply.go:146).
A manager pasting a document with the key now gets a 400 naming the key at save time, not a
401 at request time.NewTenantSourcealso getsbuildHostedTenantConfig, which refuses
the key at build — belt and braces for documents that predate the save check, which cannot
exist since the key is new.- I agree with reviewer 1's round-2 analysis of the 401 shape; the fix removes the reachable
path to it. A stored document with the key is now possible only through a direct database
edit, which is not a supported path.
docs/dashboard.mdmissingdone(my finding 2): fixed. Theping_reasonlist has a
doneline (dashboard.md:148) with wording that matches the code's meaning.TestPingReasonWordsmissingdone(my non-blocking): fixed. The table has adonerow;
the expected words matchcg.py's "this agent has finished".
Non-blocking
validateTenantDocparses the document twice (config.ValidaterunsLoadBytes+Build, then
refuseTenantHostKeysrunsLoadBytesagain). It runs only on a tenant-document save, so the
cost is nothing. A note only.buildHostedTenantConfig's build-time refusal still surfaces as a 401 throughstatusOfif a
stored document ever carries the key. Unreachable through every supported write path; noted so
nobody mistakes it for a live hole.
What I checked
- Last review: round 2,
4cb67cfaf4d9dec3a642cbe05d2af8a242bd64ea, 2026-11-02T16:24:47Z. - New commits read: 1 —
e734c87f"fix(proxy): refuse keepalive_terminal_tools in a tenant
document only (#857)". - Read in full: 4 reviews, 3 comments, 0 inline comments (from
.review-in-reviews.txt,
.review-in-comments.txt,.review-in-inline.txt; the inline file is empty). - New items since my last review, and what I did about each:
- Commit
e734c87f: full diff read, plus the code around every hunk. - Reviewer 1's round-2 review (16:24:23Z): read in full; its hosted-save half (the 401 bricking)
checked in the code —Registry.Update's validation, the account editor's write path, and
the hosted adapt write path all go through the injectedValidate. - Author's round-2 answers (16:50:04Z): read; every claim re-verified in the code. The declined
request-time test is fine: the save path refuses the key, so no stored document can carry it,
and the build-side refusal is tested directly.
- Commit
- The refusal's new blast radius:
buildTenantConfig's callers (main.go:652BuildConfigfor
the local adapt path,buildHostedTenantConfigfor the tenant source),tenant.Open's
Validateoption and its two consumers (Registry.Update, the startup migration — the latter
fail-open and only touches pre-#118 documents, which cannot carry the new key), and the hosted
adapt write (adaptapply.go:139-146, stores throughUpdate). - The two new tests' premises:
validateTenantDocandbuildHostedTenantConfigexist inmain's
package;buildTenantConfigaccepting the key is the same functionreloadLocalcalls. - The doc and test strings:
donein dashboard.md matchesReasonDone;TestPingReasonWords'
expected words match bothcg.pycopies. - Did not run builds or tests (not approved; Go is not on this machine).
…0.2.9) (#863) Claude Code plugin 0.6.4 -> 0.6.5: - #850: 12 display-only skills get disable-model-invocation: true. - #738: an optional beep when Claude finishes or needs input. - #848: settings.py still writes head_ttl_min_tokens, so an older proxy loads its config. - #859: the band and cg.py show "finished" for an agent that ended with a terminal tool call. Codex plugin 0.2.8 -> 0.2.9: - #850: 9 display-only skills get allow_implicit_invocation: false. - #852: the session skill's front matter is valid YAML, so Codex loads it. Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Behavior change (needs a human developer's yes)
StructuredOutputcall. Hosted and local.SubagentHandbackcall now readswaiting_for = human(wasown_tool). It only changes the label. It does not change pings.cache.keepalive_terminal_tools: []in the config. The old behavior returns. The default list is[StructuredOutput].Summary
StructuredOutputalways ended the thread: 226 of 226 on the hosted proxy in 7 days (waitcause: a subagent's StructuredOutput call is its final answer, not a tool wait #857).waiting_forvalue,done, marks such a thread.donethread.What changed
internal/waitcause:donevalue,DefaultTerminalTools,IsDone,ClassifyWaitingForTerminal.ClassifyWaitingForkeeps its signature and uses the default list.tool_usecounts. A terminal tool followed by another call staysown_tool.SubagentHandback(last call) is classified likeend_turnwith nothing open.keepalive:Options.TerminalTools.pingable()returns false for a done thread. The sweep's wait cause uses the same list.config:cache.keepalive_terminal_tools.nilis the default list.[]is off.proxy.Options.TerminalToolscarries it. It is process-wide, also in hosted mode.docs/how-to/cache-keepalive.md,docs/data-dependence.md. Comment indash/event.go.CacheConfignow holds a slice, so two tests that compared it with==now usereflect.DeepEqual.How it was tested
On the eval box, with
-race:go vet ./...: clean../internal/waitcause ./keepalive ./components ./config ./tenant ./cmd ./apply: pass../dash/...: pass../proxy/...: pass.IsDone. 4 tests failed. Restored: they pass.Data dependence
Metadata only. The rule reads the tool name of the last
tool_useblock. The proxy already reads it. It works at every storage level (off, metadata only, everything).docs/data-dependence.mdrow "Wait-cause and lifecycle signals" is updated.Other readers of
waiting_fordash(schema, store, query, breakdown dimension): plain text column. A new value shows as its own bucketdone. No change needed.context-guru-plugin/,codex-marketplace): no reader ofwaiting_for.extract_llm_sweepheld answer and shadow log: store the string as-is.scripts/analysis/wait_causes.py: has its own classifier on transcripts. Not changed here.Closes #857
🤖 Generated with Claude Code