Skip to content

feat(#10): Wave B — approve / reject a parked gate via caw resume - #122

Merged
qinhaihong-red merged 3 commits into
mainfrom
feat/10-human-gate-b-approve-reject
Jun 17, 2026
Merged

feat(#10): Wave B — approve / reject a parked gate via caw resume#122
qinhaihong-red merged 3 commits into
mainfrom
feat/10-human-gate-b-approve-reject

Conversation

@qinhaihong-red

Copy link
Copy Markdown
Member

Second of three waves for the Human Gate (#10), per ADR 0010, on top of Wave A (#120, merged). This wave advances or ends a parked run from the CLI. Multi-gate / TTY / parked-run e2e are Wave C.

What's in this wave

Slice 3 — approve (feat(executor))

  • caw resume <run-id> --approve <node-id> (repeatable) advances a parked run: each named gate must be awaiting, is flipped awaiting → succeeded with an {"approved": true} output and a gate_approved event, and the existing seed-satisfied resume path (Failure semantics: retries, timeouts, cancellation, and resume #6) then unlocks its dependents — no new scheduler re-entry.
  • Approving a node that is not an awaiting gate is a config-class refusal (exit 2). A gate left unnamed re-parks.

Slice 4 — reject (feat(executor))

  • caw resume <run-id> --reject <node-id> (repeatable) ends a parked run: each named gate is driven to rejected, the run is recorded rejected (a decided terminal, refused by Resume Eligibility — is_resumable("rejected") == False), and the gated downstream never runs. Rejection takes the deliberate-termination path, NOT the failed-dependency skip rule (ADR 0010 as revised in docs(adr): ADR 0010 — await parking and the human gate (#10 design alignment) #117).
  • Any rejection ends the run, so a co-named --approve does not save it (validation still requires every named node to be an awaiting gate). caw resume --reject exits 1 (a non-success terminal).
  • RunResult now carries rejected_node_ids and reports rejected; succeeded is False for both parked and rejected runs.

Tests / gates

  • New executor-seam tracers: approve resumes and runs downstream (gate succeeded, gate_approved, {"approved": true}); reject ends the run (gate + run rejected, downstream unreached, not resumable, gate_rejected).
  • New CLI-seam tests: resume --approve → exit 0 + downstream runs; resume --reject → exit 1 + rejected, no downstream.
  • ruff check + ruff format --check + mypy --strict clean (60 files), 497 non-e2e pass, real-agent e2e sanity (graph run + CLI) 6 pass.

Acceptance criteria progressed (#10)

  • caw resume <run-id> --approve <node-id> flips the gate to succeeded and resumes; downstream executes
  • caw resume <run-id> --reject <node-id> drives the gate and run to rejected and ends the run; not resumable
  • Approval / rejection recorded as gate_approved / gate_rejected Events
  • multi-gate resume / TTY inline confirm / parked-run report e2e → Wave C

🤖 Generated with Claude Code

`caw resume <run-id> --approve <node-id>` (repeatable) advances a parked run:
each named gate must be awaiting, is flipped awaiting -> succeeded with an
`{"approved": true}` output and a gate_approved event, and the existing
seed-satisfied resume path then unlocks its dependents. Approving a node that is
not an awaiting gate is a config-class refusal (exit 2); a gate left unnamed
re-parks.

Slice 3 of #10 (Wave B). Rejection follows.
`caw resume <run-id> --reject <node-id>` (repeatable) ends a parked run: each
named gate must be awaiting and is driven to `rejected`, the run is recorded
`rejected` (a decided terminal refused by Resume Eligibility), and the gated
downstream never runs. Any rejection ends the run, so a co-named --approve does
not save it. `caw resume --reject` exits 1 (a non-success terminal); RunResult
carries rejected_node_ids and reports `rejected`.

Slice 4 of #10 (Wave B).
@qinhaihong-red

Copy link
Copy Markdown
Member Author

$review: Standards and Spec Review

Reviewed PR #122 against origin/main, issue #10, CONTEXT.md, and ADR 0010.

Standards

  • Hard violation: the changed resume surfaces still describe Resume as only "interrupted or failed" / "re-running incomplete nodes" (src/caw/cli.py, src/caw/executor.py). That is now stale against CONTEXT.md Resume Eligibility and ADR 0010: a parked Run is resumable by approving or declining an awaiting node, not by re-running completed work. Please update the command/docstring language so it names parked gate advancement and rejected-run refusal as part of the Resume contract.
  • Judgement call: the --reject branch finalizes directly before the normal record_run_running / run_resumed / _drive_scheduler path. ADR 0010 says rejection reuses the deliberate-termination path and that single-run Resume gains no new execution path. This may be equivalent for a parked resume with no in-flight work, but structurally it is a new early-finalization branch. Either share the existing termination/finalization seam, or document this as the narrow exception for rejected parked runs.

Spec

  • No missing or partial Wave B requirements found. The diff matches the issue/ADR requirements for --approve, --reject, reject-dominates semantics, non-resumability, and gate_approved / gate_rejected events.
  • No clear scope creep. I did not count multi-gate resume, TTY inline confirmation, or parked-run report e2e as missing because the PR body explicitly defers them to Wave C.
  • Residual coverage gap: the correction line says "Any --reject in a resume ends the run (a co-named --approve does not save it)." That appears implemented, but there is no direct test for mixed --approve gate --reject gate.

Verification run:

  • GitHub CI gates (3.12) and gates (3.13) are green.
  • Local PR checkout at 3cf70b0: ruff check ., ruff format --check ., mypy, targeted executor/CLI seam tests (134 passed), full pytest -m 'not e2e' (497 passed, 13 deselected), and git diff --check origin/main...HEAD.

Summary: 1 Standards hard violation, 1 Standards judgement call, 0 Spec findings. Worst issue: the public/internal Resume wording is now stale relative to parked/rejected Human Gate semantics.

@qinhaihong-red

Copy link
Copy Markdown
Member Author

code-review-and-quality review

Findings first:

  • Required: duplicate decision flags are not validated before mutation. --approve is repeatable, but resume_run(..., approvals=("gate", "gate")) first validates both entries against the pre-mutation awaiting status, then writes the first approval and crashes on the second record_attempt(..., attempt=1) with sqlite3.IntegrityError. Through the CLI this exits 3 with UNIQUE constraint failed: attempt.run_id, attempt.node_id, attempt.attempt, and State is already partially changed: the run remains parked, the gate is succeeded, and the approval attempt exists. Please reject or dedupe duplicate node ids before any State write; duplicate --reject should also avoid duplicate gate_rejected events / duplicated rejected_node_ids.
  • Required: update the resume docstrings/help text so they no longer describe Resume as only interrupted/failed re-execution. After Human gate: await parking and approval #10 Wave B, Resume also advances parked Human Gates and refuses rejected runs; stale command/API docs will mislead both users and future agents.
  • Consider adding direct tests for mixed --approve gate --reject gate and duplicate decision flags. The mixed case is a named ADR behavior, and duplicate flags are where the current non-atomic crash hides.

What looks good:

  • Approval follows the intended seed-satisfied path: the gate is flipped to succeeded, gets {"approved": true}, and downstream executes through the existing resume scheduler.
  • Rejection does not route through failed-dependency skip semantics; downstream remains unreached and the run becomes non-resumable rejected.
  • No security or performance regressions found in the changed paths.

Validation:

  • GitHub CI gates (3.12) and gates (3.13) are green.
  • Local PR checkout at 3cf70b0 passed ruff check ., ruff format --check ., mypy, targeted executor/CLI seam tests (134 passed), full non-e2e pytest (497 passed, 13 deselected), and whitespace diff check.

Overall: request changes.

…review)

Addresses the #122 review (two reviewers):

- Dedupe duplicate decision ids before any State write. A repeated
  `--approve gate --approve gate` previously validated both against the
  pre-mutation `awaiting` status, then wrote the first approval and crashed on the
  second `record_attempt`'s attempt PK (sqlite IntegrityError -> exit 3) with State
  half-mutated. Duplicate `--approve`/`--reject` now collapse to one
  (order-preserving), so a repeat is idempotent -- one approval, one gate_rejected
  event, one rejected_node_ids entry.
- Refresh the stale Resume wording. The `resume` command and `resume_run`
  docstrings said only "interrupted or failed / re-run incomplete nodes"; they now
  name parked human-gate advancement and rejected-run refusal, matching CONTEXT.md
  Resume Eligibility and ADR 0010.
- Document the reject early-finalization as the narrow, deliberate exception: a
  rejected parked run has no nodes to execute or tear down, so it finalizes
  directly to `rejected` rather than routing through `_drive_scheduler` /
  `_finalize_crashed_run`.

Tests: duplicate `--approve` and `--reject` are idempotent; a co-named
`--approve`/`--reject` for one gate ends the run as rejected (reject dominates).
@qinhaihong-red

Copy link
Copy Markdown
Member Author

Thanks — addressed in a381384 (TDD for the bug, doc/comment for the rest).

[Required] Duplicate decision flags crashed non-atomically. Confirmed and fixed — this was the real bug. resume_run(approvals=("gate","gate")) validated both against the pre-mutation awaiting status, wrote the first approval, then crashed on the second record_attempt's attempt PK (UNIQUE constraint failed → exit 3) with State half-mutated. Duplicate --approve/--reject now collapse to one (order-preserving dict.fromkeys) before any write, so a repeat is idempotent: one approval, one gate_rejected event, one rejected_node_ids entry. New tests: test_duplicate_approve_is_idempotent, test_duplicate_reject_is_idempotent.

[Hard] Stale Resume wording. Fixed. The resume command help and the resume_run docstring no longer describe Resume as only "interrupted or failed / re-run incomplete nodes" — they now name parked human-gate advancement and rejected-run refusal, matching CONTEXT.md Resume Eligibility and ADR 0010 (and the exit-code line now covers clean park = 0, rejected = 1).

[Judgement] Reject early-finalization branch. Took the "document the narrow exception" option you offered. The reject branch now carries a comment explaining it is the deliberate narrow exception: a rejected parked run has no nodes to execute and none in flight to tear down, so it finalizes directly to rejected rather than routing through _drive_scheduler (which runs the scheduler) or _finalize_crashed_run (which records errored). Sharing those seams isn't clean since neither fits a no-work rejected terminal.

[Coverage] Mixed --approve gate --reject gate. Added test_reject_dominates_a_co_named_approve — the run ends rejected and the downstream never runs (reject dominates, as the correction line requires).

Gates after the fix: ruff check + ruff format --check + mypy --strict clean (60 files), 500 non-e2e pass. Ready for another look.

@qinhaihong-red
qinhaihong-red merged commit 5c8e9fe 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.

1 participant