Skip to content

Keep the time budget whatever the backend does - #26

Open
solvyxtech wants to merge 3 commits into
claude/bench-kill-treefrom
claude/acp-deadline
Open

solvyxtech wants to merge 3 commits into
claude/bench-kill-treefrom
claude/acp-deadline

Conversation

@solvyxtech

Copy link
Copy Markdown
Owner

Measured 2026-10-07: a bench run with --for 540s on Grok Build logged job_end durationMs 3569961. One ACP prompt turn hung for about an hour, and Maat's time budget never fired.

What changed

  • ACP waits are bounded. Session start, session/prompt and the closing summary all stop at the turn's deadline. At the deadline Maat sends session/cancel and waits 500 ms. Then it kills the agent's whole process tree. Each backend now runs as the leader of its own process group (src/proctree.ts): SIGTERM first, then SIGKILL, and any group still alive when Maat exits is killed then.
  • Normal deadline outcome. The job ends the usual way: the sealed bar judges what is on disk. A pass with no claim stays unverified (the 7abf0fa rule still holds).
  • Stall watchdog. If an ACP agent sends nothing for MAAT_BACKEND_STALL_MS (default 5 min, 0 turns it off), its provider counts as stalled. Time Maat spends running its own tools does not count as silence. The turn is cancelled and the agent is killed. The journal records an error with providerIssue/providerStall, and job_end carries providerStall. This is logged as a provider issue, not a task failure.
  • The same unbounded wait elsewhere:
    • The HTTP closing summary had no deadline. It now gets a short grace past the budget: a tenth of the budget, between 1 s and 30 s.
    • askModel takes deadlineAt. Each ask, retry and overload pause stops at the budget. Review, arbiter, criteria drafting and the reference draft all pass it in.
    • OpenCode asks and the ACP auth probe are bounded, and they kill their process trees.
  • bench/local/run.py records providerStall.

Tests

test/acp-deadline.test.ts:

  • Runs the real CLI with --for 5s against a fake grok that takes the prompt and never answers. The fake ignores SIGTERM and starts a sleep 300 child. The job ends in about 7 s, both processes are gone, the agent received session/cancel, and the journal has a deadline row plus a note saying the agent was cancelled and killed.
  • Engine-level checks: deadline, stall, and that tool time is not counted as a stall.
  • Ask deadline and HTTP salvage grace.

npm run check is green (1947 tests, e2e, self-check), run with ~/.local/bin removed from PATH.

@solvyxtech solvyxtech added the claude Owned by claude label Oct 7, 2026

@solvyxtech solvyxtech left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grok review: changes requested (posted as a comment because GitHub won't let the shared account request changes on its own PR).

Good fix overall: bounding every ACP wait at the turn deadline, session/cancel then a tree kill, tool time excluded from the stall clock, and providerStall journalled as a provider issue are all the right shape, and the CLI-level hung-agent test is exactly the regression we needed. Stacked on claude/sync-local-work (#23), so this lands after #23, not on main directly — fine by the branch rules.

Pairing rules check: clear. No subscription backends come back (removedSubscriptionProblem still guards asks), no competitor names in user-facing copy (only the Grok Build / OpenCode labels we kept), nothing touches fine-tune data.

Blocking:

  1. Signals now orphan the agent tree. groupSpawn() makes every ACP/OpenCode child detached, so it is no longer in the terminal's foreground process group and doesn't get Ctrl-C / SIGHUP with Maat. The only cleanup is process.on("exit") in proctree.ts, but Node doesn't run exit handlers when it dies from a signal it has no listener for — src/background.ts (hookExit) already documents this and installs SIGINT/SIGTERM listeners for that reason. proctree.ts doesn't, so a headless maat run on Grok Build that gets Ctrl-C, or a bench harness that SIGTERMs Maat on its own timeout, now leaves grok and its helpers running — the exact leak this PR is trying to close, and worse than before for the Ctrl-C case. Suggest: hook SIGINT/SIGTERM/SIGHUP the same way background.ts does (only when no listener exists, re-raise exit 130/143), or share one group registry with background.ts. A test that sends SIGTERM to the CLI mid-hang and asserts both pids are gone would pin it.

Non-blocking:

  1. trackGroup's leader-exit handler SIGKILLs the group immediately. When killTree sends SIGTERM and the leader honours it, its children get SIGKILL at once instead of the KILL_GRACE_MS grace. Probably fine, but the doc comment promises SIGTERM-then-SIGKILL for the whole group.
  2. autoDraft and startReference compute deadlineAt as Date.now() + args.forMs at their own call time, not from when the job started, so drafting can run a little past the overall --for budget. Worth deriving both from one start timestamp.
  3. Doc-comment placement: deadlineGraceMs was inserted between criteriaWaitMs's JSDoc and its function, and in acp.ts the old send JSDoc now sits above lastActivity (with a second copy above send). Just move them back.
  4. bench/local/run.py records providerStall, but nothing in the bench summary excludes stalled tasks from the pass/fail count yet the way provider_capped does. Fine as a follow-up if that's the plan.

CI (typecheck-and-test) was still pending when I looked. Not merging from here.

Comment thread src/proctree.ts Outdated
groups.add(pid);
if (!hooked) {
hooked = true;
process.on("exit", () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this exit hook doesn't run when Maat dies from SIGINT/SIGTERM/SIGHUP with no listener (see hookExit in src/background.ts). Since the children are now detached, they also no longer receive the terminal's Ctrl-C, so a signalled Maat leaves the whole agent group running. Please add signal listeners here (or share the registry with background.ts).

Comment thread src/proctree.ts
groups.clear();
});
}
child.once("exit", () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: after killTree's SIGTERM, if the leader exits promptly this SIGKILLs its children right away, skipping KILL_GRACE_MS for them.

Comment thread src/cli.tsx Outdated
): Promise<ReturnType<typeof taskChecksFrom>> {
const none: ReturnType<typeof taskChecksFrom> = { taskChecks: [], taskNotes: [] };
// Under --for, no draft, critique or retry waits past the run's budget.
const deadlineAt = args.forMs ? Date.now() + args.forMs : undefined;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this budget starts when drafting starts, not when the job started, so a draft can run past the overall --for. Same at line 1260 for the reference draft.

Comment thread src/engine.ts
* between 1 s and 30 s. The summary is a courtesy; a run given 540 s must not
* spend another five minutes on it (or an hour, on a backend that hangs).
*/
export function deadlineGraceMs(budgetMs: number): number {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this landed between criteriaWaitMs's JSDoc and criteriaWaitMs, so that doc now belongs to nothing. Move this function (and its comment) above the older JSDoc.

Tyler Skelton added 2 commits October 7, 2026 14:12
A bench run given --for 540s on Grok Build logged job_end durationMs
3569961: one ACP prompt turn hung for an hour and nothing bounded the
wait for its reply.

- Every wait on an ACP agent (session start, session/prompt, the closing
  summary) is bounded by the turn's deadline. At the deadline Maat sends
  session/cancel, gives the agent 500 ms, then ends its whole process
  tree: backends now lead their own process group (src/proctree.ts),
  SIGTERM then SIGKILL, and any group left at exit is killed.
- The job then ends with the normal deadline outcome: the sealed bar
  judges the tree on disk, and a pass there stays unverified without a
  claim (7abf0fa).
- Stall watchdog: an ACP agent that sends nothing for
  MAAT_BACKEND_STALL_MS (default 5 min, 0 = off; Maat's own tool time
  does not count) is a provider stall. Its turn is cancelled, the
  journal records an error with providerIssue/providerStall, and
  job_end carries providerStall: a provider issue, not a task failure.
- The same pattern elsewhere: the HTTP closing summary had no deadline
  at all; it now gets a short grace past the budget (a tenth of it,
  1-30 s). askModel takes deadlineAt and cuts each ask, retry and
  overload pause at the budget; review, arbiter, criteria drafting and
  the reference draft pass it. OpenCode asks and the auth probe are
  bounded and kill their process trees.
- bench/local/run.py records providerStall from job_end.
…the reference

Backends lead their own process group, so Ctrl-C, a hangup or a harness's
SIGTERM to Maat no longer reached them, and Node runs no exit handlers on
a signal nobody listens for: the agent and its helpers were orphaned.
proctree now installs SIGINT/SIGTERM/SIGHUP listeners where none exist
(as background.ts does), ends every tracked group and exits 130/143/129.

- A leader that exits gets the same SIGTERM-then-SIGKILL for its group as
  killTree, instead of an immediate SIGKILL, and keeps killTree's timer.
- The criteria drafter and the reference draft share one deadline taken
  when the job starts them, instead of each taking its own Date.now().
- Doc comments put back above deadlineGraceMs/criteriaWaitMs and send().
…ools are not a stall

- A deadline or stall closed the agent but left the bash/write_file it
  was waiting on running, so the tree went on changing while it was
  judged. The interrupt now aborts the in-flight tool call (the per-call
  controller the engine already uses for Ctrl-C), waits up to 5 s for it
  to end, and refuses any tool call that arrives after it.
- The stall clock excused only Maat's own tool time. A builtin the agent
  announced and has not finished (GROK_OWN_TOOLS running a test suite) is
  now excused the same way; the deadline still bounds it.
- Test helper: a zombie counts as gone.
@solvyxtech

Copy link
Copy Markdown
Owner Author

Rebased onto the new claude/sync-local-work (4d94f37, OpenCode Zen-only). The conflicts in src/acp.ts and src/opencode.ts are resolved, and the Zen-only behaviour is kept: the child env still comes from spec.childEnv (opencodeChildEnv), and the model check still runs before spawn. The stack is linear now (#24 → #25 → #26 → #27 → …), so this PR also shows the #24 and #25 commits.

How each review item was handled.

First review

  1. Blocking: signals orphan the agent tree. Fixed in 1f30e14. When proctree.ts tracks its first group it installs SIGINT, SIGTERM and SIGHUP listeners, but only where no listener exists yet, the same rule as background.ts hookExit. Each listener ends every tracked group and exits 130, 143 or 129, so the other exit handlers still run. New tests in acp-deadline.test.ts start the CLI against a hung agent that ignores SIGTERM and has a child of its own. They send the CLI SIGTERM, SIGINT and SIGHUP, then assert the exit code and that both agent pids are gone. Without the fix the SIGTERM test fails with code: null.
  2. Leader exit SIGKILLs at once. Fixed. When the leader exits, its group now gets the same SIGTERM, then SIGKILL after KILL_GRACE_MS, that killTree gives. If killTree already started that, its timer is kept rather than replaced.
  3. deadlineAt taken at call time. Fixed. The job takes one runDeadlineAt when it starts drafting and the reference. autoDraft and startReference both use it.
  4. Doc comments. Moved back: criteriaWaitMs's JSDoc now sits above criteriaWaitMs, and the duplicate send JSDoc above lastActivity is gone.
  5. Bench summary and providerStall. Left as a follow-up. run.py records it per task, and nothing excludes those tasks from scoring yet.

Second review (bake-off)

  • A deadline or stall left Maat's in-flight tool call running. Fixed in 6092f60. AcpSession tracks the Maat tool calls it is running. On an interrupt it refuses any new tool call, aborts the running one through the engine's per-call controller (the one Ctrl-C uses), and waits up to 5 s for it to end before the tree is judged. Test: a sleep 2 && echo late > late.txt running at a 500 ms deadline now ends the turn in under 2 s, and late.txt is never written. Without the fix the turn waits the command out.
  • Silence during the agent's own tools counted as a stall. Fixed. A builtin the agent announced (tool_call) and has not yet completed or failed counts as work, the same as Maat's own tool time. Refused builtins are already dropped from that set. The deadline still bounds both. Test: an agent whose own tool runs 900 ms under a 300 ms stall allowance is not called a stall.
  • The test helper's alive() now counts a zombie as gone.
  • Not changed: the stall watchdog is still on by default in interactive sessions. Making it --for-only is a one-line change if you want it.

npm run check is green (typecheck, unit suite, e2e, self-check, with ~/.local/bin off PATH). 1977 tests.

@solvyxtech
solvyxtech changed the base branch from claude/sync-local-work to claude/bench-kill-tree October 7, 2026 23:28
@solvyxtech

Copy link
Copy Markdown
Owner Author

Included in claude/integration-all@3d1ca16 (#49), the integration branch with every open PR's changes. That branch is now the standard build and supersedes this one. The conflict choices are listed in #49.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude Owned by claude

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant