Skip to content

feat(#10): Wave C — multi-gate resume, TTY inline confirm, parked-run e2e - #123

Merged
qinhaihong-red merged 4 commits into
mainfrom
feat/10-human-gate-c-multigate-tty-e2e
Jun 17, 2026
Merged

feat(#10): Wave C — multi-gate resume, TTY inline confirm, parked-run e2e#123
qinhaihong-red merged 4 commits into
mainfrom
feat/10-human-gate-c-multigate-tty-e2e

Conversation

@qinhaihong-red

Copy link
Copy Markdown
Member

Final wave of the Human Gate (#10), per ADR 0010, on top of Wave A (#120) and Wave B (#122). With this wave, every #10 acceptance criterion is met.

What's in this wave

Slice 5 — multi-gate resume + re-park event dedup (feat(executor))

  • Two parallel gates both park at the fixpoint; approving one advances its branch and the run re-parks on the rest; approving the rest completes the run. This already fell out of the slice-2/3 machinery — now pinned by a test.
  • A gate that stays awaiting across a resume (unapproved) is a continuation, not a new park: the scheduler is seeded with awaiting_seed (gates already awaiting from the prior run) and re-parks them silently, so gate_awaiting is emitted once on first park, not re-emitted every resume. A gate newly reached during a resume still emits on its first park.

Slice 6 — inline TTY confirmation (feat(cli))

  • In an attended (TTY) session, caw run prompts at each awaiting gate inline (showing the gate's prompt): yes approves and the run continues, no rejects it and ends the run. The CLI loops run → prompt → resume until a terminal. In a non-TTY session the run parks for caw resume (unchanged) — pure CLI orchestration over the existing park/approve/reject primitives, the executor untouched.

Slice 7 — real parked-run e2e (test(e2e), deferred from #90)

  • A real agent Node runs through execute_run, the run parks at a downstream human_gate, and caw report surfaces the parked run + succeeded agent node + awaiting gate from persisted State (JSON + Markdown). Confirms the Reporter renders parked/awaiting with no change (an awaiting gate is not a failure: its error is None).

Tests / gates

  • New: multi-gate approve/re-park; re-park does not re-emit gate_awaiting; TTY approve → success; TTY decline → rejected (exit 1); real-agent parked-run report e2e.
  • ruff check + ruff format --check + mypy --strict clean (61 files), 504 non-e2e pass, and the new parked-run e2e passes with CAW_E2E_AGENT=claude.

#10 acceptance criteria — all met

  • Park at the fixpoint (Wave A)
  • caw resume --approve flips to succeeded and resumes (Wave B)
  • caw resume --reject ends the run as rejected, not resumable (Wave B)
  • One resume may address several awaiting gates; unnamed gates re-park (Wave C)
  • TTY session prompts inline; declining rejects (Wave C)
  • Parking / approval / rejection recorded as gate_awaiting / gate_approved / gate_rejected Events
  • Real parked-run e2e: park a real run, caw report it (markdown + json), asserting parked + awaiting (Wave C)

Closes #10.

🤖 Generated with Claude Code

Two parallel human_gates both park at the fixpoint; approving one advances its
branch and the run re-parks on the rest, and approving the rest completes the
run -- which the slice-2/3 machinery already supported, now pinned by a test.

A gate that stays awaiting across a resume (unapproved) is a CONTINUATION, not a
new park: the scheduler is seeded with the gates already awaiting from the prior
parked run (awaiting_seed) and re-parks them silently, so gate_awaiting is
emitted once on first park rather than re-emitted on every resume. A gate newly
reached during a resume still emits on its first park.

Slice 5 of #10 (Wave C). TTY inline confirmation and the parked-run e2e follow.
In an attended (TTY) session `caw run` now prompts at each awaiting gate inline:
yes approves it and the run continues, no rejects it and ends the run. The CLI
loops run -> prompt -> resume until the run reaches a terminal, showing the gate's
prompt text. In a non-TTY session the run parks for `caw resume` (unchanged), so
the executor is untouched -- this is pure CLI orchestration over the existing
park/approve/reject primitives.

Slice 6 of #10 (Wave C). The real parked-run report e2e follows.
The parked-run e2e deferred from #90: a real agent Node runs through execute_run,
the run then parks at a downstream human_gate, and `caw report` surfaces the
parked run, the succeeded agent node, and the awaiting gate from persisted State
in JSON and Markdown. Confirms the Reporter renders parked/awaiting without change
(an awaiting gate is not a failure: its `error` is None), end to end with a real
agent CLI.

Slice 7 of #10 (Wave C).
@qinhaihong-red

Copy link
Copy Markdown
Member Author

$review: Standards and Spec Review

Fixed point: origin/main
PR head: 4f99591fa0b5c91110d6ba39982cda9ece87683e

Standards

No Standards findings.

The changed files follow the repo's Human Gate / Await vocabulary, keep the executor and CLI changes on the existing seams, preserve the status/event ownership pattern, and match the surrounding seam/e2e test style.

Spec

  • [P1] Persist a TTY decline before prompting any remaining gates. src/caw/cli.py:482

    _drive_tty_gates prompts every awaiting_node_id first, then calls resume_run(...) only after the loop. That means a multi-gate parked run can lose a decline: with two parallel gates, entering n for the first prompt and then hitting EOF/abort at the second prompt exits with Aborted, but the run remains parked, both gates remain awaiting, and no gate_rejected event is written. This violates Human gate: await parking and approval #10 / ADR 0010: in a TTY session, declining drives the run to terminal rejected, and any reject ends the run. The CLI should stop prompting and commit the rejection as soon as a prompt returns false, or otherwise avoid any later prompt being able to prevent that rejection from being persisted. Please add a regression test for two simultaneous TTY gates where the first answer is n.

Verification

  • uv --cache-dir /private/tmp/caw-uv-cache run ruff check .
  • uv --cache-dir /private/tmp/caw-uv-cache run ruff format --check .
  • uv --cache-dir /private/tmp/caw-uv-cache run mypy
  • uv --cache-dir /private/tmp/caw-uv-cache run pytest tests/test_executor_seam.py tests/test_cli_seam.py tests/test_report_seam.py tests/test_e2e_harness.py tests/e2e/test_human_gate_parked_run.py -m "not e2e"
  • uv --cache-dir /private/tmp/caw-uv-cache run pytest -m "not e2e"
  • uv --cache-dir /private/tmp/caw-uv-cache run pytest tests/e2e/test_human_gate_parked_run.py -m e2e
  • git diff --check origin/main...HEAD

Verdict: request changes for the TTY multi-gate decline path.

@qinhaihong-red

Copy link
Copy Markdown
Member Author

code-review-and-quality review

Findings

  • Required - Correctness: a declined TTY gate can be dropped when several gates are awaiting. src/caw/cli.py:482

    The implementation batches all TTY answers before calling resume_run. In a two-gate run, if the user answers n to the first gate and the second prompt aborts/EOFs, the command exits 1 with Aborted but never records the rejection. I reproduced the persisted state afterward: run status stayed parked, both gates stayed awaiting, and no gate_rejected event was written. Because ADR 0010 says any rejection ends the run, the first n should be durable immediately and the CLI should not require answers to gates whose decision can no longer matter. A focused test should cover two simultaneous TTY gates with input n\n.

Other Axes

  • Readability: the added code is small and follows the surrounding CLI/executor naming style.
  • Architecture: the executor change stays on the existing resume/scheduler seam; no new execution path or dependency was introduced.
  • Security: no new secrets, dependency, SQL construction, or external-input trust issue found in this diff.
  • Performance: no material performance concern; the new set membership and prompt loop are bounded by awaiting gates in one run.

Verification

  • Static gates passed: ruff check, ruff format --check, mypy.
  • Tests passed: targeted non-e2e subset, full pytest -m "not e2e" (504 passed, 14 deselected), and the new real e2e (tests/e2e/test_human_gate_parked_run.py -m e2e, 1 passed).
  • Manual repro performed for the multi-gate TTY decline bug above.

Verdict: request changes.

… rest (#10 review)

Addresses the #123 review (two reviewers, one correctness finding): a declined TTY
gate could be lost when several gates are awaiting. `_drive_tty_gates` prompted every
awaiting gate first, then resumed only after the loop -- so answering `n` to the first
of two parallel gates and then EOF/aborting at the second exited "Aborted" with the run
still `parked`, both gates `awaiting`, and no gate_rejected written.

Because ANY rejection ends the run (ADR 0010), the FIRST decline now commits
immediately and stops prompting: the later gates' decisions can no longer matter, so no
subsequent prompt or abort can drop a recorded decline. Approvals still batch until the
pass approves every gate.

Also completes the module exit-code contract for the gate semantics (parked = 0,
rejected = 1). Regression test: two parallel TTY gates with input `n\n` ends the run
rejected and persists a gate_rejected event (one answer suffices -- the second gate is
never prompted).
@qinhaihong-red

Copy link
Copy Markdown
Member Author

Thanks — the TTY multi-gate decline finding was a real correctness bug. Fixed in 4d87481.

Root cause confirmed. _drive_tty_gates prompted every awaiting gate first and resumed only after the loop, so with two parallel gates, answering n to the first and then EOF/aborting at the second exited "Aborted" with the run still parked, both gates awaiting, and no gate_rejected written — the decline was lost.

Fix. Because any rejection ends the run (ADR 0010), the first decline now commits immediately and stops prompting — the later gates' decisions can no longer matter, so no subsequent prompt or abort can drop a recorded decline. Approvals still batch until the pass approves every gate (an approval being deferred is benign: the gate stays awaiting and is resumable).

Regression test (as requested): test_run_in_a_tty_declines_the_first_of_two_gates_and_ends_the_run — two parallel TTY gates, input n\n (one answer). The run ends rejected (exit 1), parked never appears, and a gate_rejected event is persisted. The single answer is the proof: if the CLI still prompted both gates first, the second prompt would EOF/abort instead of the run ending.

Also completed the module-level exit-code contract for the gate semantics (parked = 0, rejected = 1), which had drifted.

Gates: ruff check + ruff format --check + mypy --strict clean (61 files), 505 non-e2e pass, parked-run e2e still passes. Ready for another look.

@qinhaihong-red
qinhaihong-red merged commit f079945 into main Jun 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Human gate: await parking and approval

1 participant