feat(subagent): cooperative cancel checkpoint — make cancel actually stop thread-mode subagents - #3261
Conversation
Greptile SummaryThis PR adds cooperative cancellation for subagents. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (5): Last reviewed commit: "fix(tests): parse JSONL properly in canc..." | Re-trigger Greptile |
|
@greptileai review |
|
Addressed two P1 findings from the Greptile review (ea50be2): 1. Cancel status was "failure" instead of "cancelled"
2. Subprocess agents never observed the cancel op
Test suite updated to assert |
80e8322 to
019e8f3
Compare
|
@greptileai review |
|
@greptileai review |
|
Fixed the remaining P1 finding from the latest Greptile review (84216ff):
The other three Greptile findings:
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
All Greptile review findings addressed after 3 rounds. Summary: Fixed (code changes):
Dismissed (design / false positive):
CI: All checks pass; "Test without API keys" was still running at last check. Greptile: 5/5 confidence, all threads resolved. Converged after 3 review rounds. |
Implements the STEP_PRE cancel checkpoint described in issue #3258. Cancel signals written to logdir/control.jsonl are detected before each LLM generation step, allowing subagent_cancel() to actually stop running thread-mode subagents without waiting for natural completion. Changes: - hooks.py: add _subagent_cancel_checkpoint() — fires at STEP_PRE, reads control.jsonl for a cancel op, yields a status message, writes 'cancelled' result via set_subagent_result_if_absent (first-writer-wins), then raises SessionCompleteException to cleanly release the semaphore slot - api.py: add _write_cancel_op() helper (flock-safe JSONL append) and call it from subagent_cancel() in both thread and subprocess modes - __init__.py: register cancel_checkpoint hook at STEP_PRE priority 100 Two O(1) fast-path guards keep per-step overhead minimal: 1. Thread-local check: no-op in parent thread (agent_id is None) 2. stat() on control.jsonl: no-op when file doesn't exist ACP mode retains existing cache-mark-only behavior (no control.jsonl write). Closes #3258
Two bugs found by Greptile code review:
1. `cancelled_result` was `ReturnType("failure", ...)` — so `subagent_cancel()`
stored "failure" before writing the control op, meaning the cooperative
checkpoint's later `set_subagent_result_if_absent("cancelled", ...)` always
lost the first-writer-wins race. Callers saw the cancel as a failure.
Fixed: use `ReturnType("cancelled", ...)` consistently.
2. `get_current_agent_id()` read only the thread-local, which is unset in
subprocess agents (they receive identity via `GPTME_SUBAGENT_AGENT_ID` env).
So the STEP_PRE checkpoint exited early for subprocess agents and they never
observed the cancel op. Fixed: fall back to `os.environ.get(...)`.
Update all tests that asserted the old "failure" cancel status to "cancelled".
…led'
subagent_cancel() now sets ReturnType('cancelled', ...) instead of 'failure'.
Two pre-existing tests still expected 'failure'; update them to match.
subagent_wait_any() skipped cancelled results because 'cancelled' was not in terminal_statuses — the polling loop would keep waiting for another agent to finish instead of returning the cancelled result. Add 'cancelled' alongside 'success', 'failure', 'clarification_needed' and a regression test that verifies wait_any() returns immediately on a cancelled result.
84216ff to
dc4dafb
Compare
|
Rebased onto master to resolve merge conflicts with #3262 (which implemented an overlapping cooperative cancel checkpoint). Conflict resolution notes:
The unique contributions of this PR that weren't in #3262:
|
The test was attempting to parse the entire JSONL file as a single JSON object, but control.jsonl is a JSON Lines format with one record per line. Fixed by splitting on newlines and parsing the first line. This fixes the test failures: - test_cancel_thread_writes_control_file - test_cancel_subprocess_writes_control_file Both were failing with: json.decoder.JSONDecodeError: Extra data: line 2 column 1
|
@greptileai review |
Problem
subagent_cancel()sent aSIGTERM/ set a cancelled flag, but thread-mode subagents had no mechanism to observe it between LLM steps. The running thread would finish its current generation and potentially several more tool calls before the next natural completion point.Solution
Adds a STEP_PRE cooperative checkpoint that fires before every LLM generation step in subagent threads. When a cancel is requested, it writes a
{"op": "cancel"}record tologdir/control.jsonl; the checkpoint reads this file and if found: yields a partial-work note, writescancelledstatus (first-writer-wins, so a natural completion can still win the race), then raisesSessionCompleteExceptionto exitchat()cleanly and release the semaphore slot.Changes
gptme/tools/subagent/hooks.py— new_subagent_cancel_checkpoint()STEP_PRE(before each LLM generation)agent_id is Nonecheck (no-op in parent), and a singlestat()oncontrol.jsonl(no-op when absent)cancelledresult viaset_subagent_result_if_absent(first-writer-wins), raisesSessionCompleteExceptiongptme/tools/subagent/api.py— new_write_cancel_op()+ updatedsubagent_cancel()_write_cancel_op(): flock-safe JSONL append tologdir/control.jsonl(Windows-safe fcntl fallback, same pattern asprompt_queue.py)subagent_cancel()in thread mode: calls_write_cancel_opthen the existing cache-marksubagent_cancel()in subprocess mode: calls_write_cancel_opbeforeSIGTERM(so it works even if the subprocess ignores the signal)gptme/tools/subagent/__init__.py— registerscancel_checkpointhook atSTEP_PREpriority 100tests/test_subagent_unit.py— 10 new testsTestWriteCancelOp(3): JSONL written, appends correctly, creates missing logdirTestSubagentCancelThreadWritesControlFile(2): thread and subprocess cancel modes write control.jsonlTestCancelCheckpointHook(5): no-op outside subagent thread, no-op without file, no-op without cancel op, cancels on cancel op, first-writer-wins on natural completionTest run
All 241 unit tests pass including 10 new cancel-checkpoint tests.
Closes #3258