Skip to content

feat(#10): Wave A — human_gate node kind + await parking - #120

Merged
qinhaihong-red merged 3 commits into
mainfrom
feat/10-human-gate-a-park
Jun 17, 2026
Merged

feat(#10): Wave A — human_gate node kind + await parking#120
qinhaihong-red merged 3 commits into
mainfrom
feat/10-human-gate-a-park

Conversation

@qinhaihong-red

Copy link
Copy Markdown
Member

First of three sequential waves implementing the Human Gate (#10), per ADR 0010. This wave makes a run park at a gate; approval/rejection (Wave B) and multi-gate/TTY/e2e (Wave C) follow.

What's in this wave

Slice 1 — human_gate node kind (feat(model))

  • Third node kind alongside shell/agent: HumanGateNodeInputs with an optional prompt, registered in the kind→inputs and kind→producible maps and the node-kind Literal.
  • A gate emits no when-producible field, so any when ref to a gate is a config error (ADR 0010: approved is not a branch source in v0.1).

Slice 2 — await parking (non-TTY) (feat(executor))

  • A gate parks the run at the scheduler fixpoint (no node runnable, none in flight, ≥1 awaiting) instead of executing: it consumes no concurrency slot and launches no task.
  • The gate is recorded awaiting, the run parked (no finished_at, no run_finished — a parked run is not finished), downstream stays unreached, and a gate_awaiting event marks the park.
  • caw run reports the parked run + awaiting gate and exits 0 (a park is not a failure) in a non-TTY session.
  • Extends the Own run/node status, event types, and attempt numbers in one place #30 vocabulary owners in one place: statuses parked/awaiting/rejected (status.py) and events gate_awaiting/gate_approved/gate_rejected (events.py). rejected and the approve/reject events are defined here but consumed in Wave B.
  • RunResult carries awaiting_node_ids and reports parked; a human_gate reaching the node dispatch is a guarded invariant breach.

Tests / gates

  • New: executor-seam park tracer (run parks: gate awaiting, run parked, downstream unreached, gate_awaiting event, no run_finished); CLI-seam parked-exit (exit 0 + "parked", not "succeeded").
  • ruff + mypy --strict clean (60 files), 489 non-e2e pass, and a real-agent e2e sanity (CAW_E2E_AGENT=claude: agent graph run + CLI) 6 pass — the central scheduler change does not regress real runs.

Acceptance criteria progressed (#10)

  • A run reaching a human_gate parks at the fixpoint: node awaiting, run parked, State persisted, process exits cleanly in a non-TTY session
  • Parking recorded as a gate_awaiting Event
  • approve / reject / multi-gate / TTY / parked-run e2e → Waves B & C

🤖 Generated with Claude Code

The third node kind (ADR 0010): a human_gate parks the Run for approval. Its
only input is an optional prompt for the TTY confirmation; it carries none of
the shell/agent subprocess fields and emits no `when`-producible field, so any
`when` ref to a gate is a config error. Registered in the kind->inputs and
kind->producible maps and the node-kind Literal.

Slice 1 of #10 (Wave A). Parking behavior follows.
A human_gate node now parks the run at the scheduler fixpoint instead of
executing: the gate is recorded `awaiting`, the run `parked` (no finished_at and
no run_finished -- it is not finished), downstream stays unreached, and a
gate_awaiting event marks the park. `caw run` reports the parked run and its
awaiting gate and exits 0 (a park is not a failure) in a non-TTY session.

Extends the #30 vocabulary owners with the gate statuses (parked/awaiting/
rejected) and events (gate_*). RunResult carries awaiting_node_ids and reports
`parked`; a human_gate reaching the node dispatch is a guarded invariant breach.

Slice 2 of #10 (Wave A). Approval/rejection follow in Wave B.
@qinhaihong-red
qinhaihong-red force-pushed the feat/10-human-gate-a-park branch from 3aebbea to 092e2e5 Compare June 17, 2026 07:15
@qinhaihong-red

Copy link
Copy Markdown
Member Author

$review: Standards and Spec Review

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

Standards

  • Hard violation: src/caw/model.py widens Node.kind to include human_gate, but the shared Node fields still allow top-level retries and timeout on that gate. ADR 0010's Human Gate shape says subprocess-shaped fields, including timeout and retries, "do not apply" because a gate spawns no process. HumanGateNodeInputs forbids extra input fields, but a workflow can still author kind: human_gate with top-level timeout/retries and have those ignored.
  • Hard violation: RunResult.succeeded stays True whenever all attempted nodes succeeded, even when awaiting_node_ids is non-empty and status == "parked". That conflicts with the domain/ADR language that a parked Run has status parked and stays parked until awaiting nodes resolve. It also risks existing controller code treating a parked iteration as a successful terminal iteration because controllers record and branch on result.succeeded.
  • Hard violation: src/caw/status.py adds rejected as a Run status, but src/caw/executor.py still defines only succeeded as non-resumable. ADR 0010 and CONTEXT.md both require rejected to be refused alongside succeeded under Resume Eligibility.

Spec

  • Missing/partial: Human gates still accept process controls. Spec quote from ADR 0010: "The subprocess-shaped fields (timeout, retries, env, cwd, artifacts, output_schema) do not apply: a gate spawns no process." The new inputs model handles env/cwd/artifacts/output_schema, but generic Node.retries and Node.timeout still validate for human_gate.
  • Missing/partial: rejected resume eligibility is wrong if this PR lands the vocabulary now. Spec quote from ADR 0010: "rejected joins the refused set alongside succeeded." The diff adds REJECTED to the owned run-status vocabulary, but _NON_RESUMABLE_RUN_STATUSES remains {SUCCEEDED}.
  • No clear scope creep beyond predeclaring later-wave vocabulary, which ADR 0010 itself asks to centralize. Approve/reject, TTY prompting, multi-gate resume, and parked-run report e2e are intentionally deferred by the PR body to later waves.

Verification run:

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

Summary: 3 Standards findings, 2 Spec findings. Worst issue: the PR introduces Human Gate status/control vocabulary but leaves resume eligibility and gate shape validation inconsistent with ADR 0010.

@qinhaihong-red

Copy link
Copy Markdown
Member Author

code-review-and-quality review

Findings first:

  • Required: reject timeout and retries on human_gate nodes. ADR 0010 says subprocess-shaped fields do not apply because a gate spawns no process, but Node.retries and Node.timeout are still accepted for every kind. I verified this locally: normalize_workflow(...) accepts a human_gate with retries: 3 and timeout: 1, then stores those ignored values on the node. That is a correctness/readability issue because the authored config appears meaningful but cannot affect a parked gate.
  • Required: make rejected non-resumable in the same PR that adds it to the Run status vocabulary. ADR 0010 and CONTEXT.md both say rejected runs are refused like succeeded runs, but is_resumable("rejected") currently returns True because _NON_RESUMABLE_RUN_STATUSES is still only {SUCCEEDED}. Even if reject execution lands in Wave B, the vocabulary and eligibility rule should not disagree.
  • Required: do not let a parked run report RunResult.succeeded == True, or guard all existing result.succeeded consumers before they can see parked runs. A parked run is not failed, but it is also not a successful terminal run. Today a simple parked run returns status == "parked" while succeeded == True; controller code records and branches on result.succeeded, so a Human Gate inside a controller-run workflow can be misclassified as a successful iteration instead of a parked one.

What looks good:

  • The scheduler shape is close to ADR 0010: a gate launches no task, consumes no concurrency slot, does not satisfy dependents, records awaiting, appends gate_awaiting, and only parks once no in-flight work remains.
  • Event/status vocabulary is centralized in the right modules.
  • I did not find security or performance regressions in the diff.

Validation:

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

Overall: request changes.

…eligibility with ADR 0010 (#10 review)

Addresses the #120 review (two reviewers, three converging findings):

- Reject `retries`/`timeout` on a human_gate node. ADR 0010 says the
  subprocess-shaped fields do not apply (a gate runs no process); the
  inputs-level ones were already forbidden, but the node-level `retries`/`timeout`
  still validated and were silently ignored. They are now a config error.
- A parked RunResult is no longer `succeeded`. `succeeded` returned True whenever
  the run-down nodes succeeded, even when parked -- risking a Pattern Controller
  that branches on `result.succeeded` misclassifying a parked iteration as a
  finished success. A parked run is now `succeeded == False` (status stays
  `parked`).
- `rejected` is non-resumable. The status was added to the vocabulary but
  `_NON_RESUMABLE_RUN_STATUSES` still held only `succeeded`; ADR 0010 and
  CONTEXT.md refuse a rejected run alongside succeeded. `parked` stays resumable.

Tests: a gate with retries/timeout is a config error; a parked run reports
succeeded=False; is_resumable refuses rejected/succeeded and admits parked/failed.
@qinhaihong-red

Copy link
Copy Markdown
Member Author

Thanks — all three Standards/Spec findings were right and are fixed in 1721612 (TDD: a failing test each, then the fix).

1. timeout/retries accepted on a human_gate. Correct — those are node-level fields, so HumanGateNodeInputs' extra="forbid" (which covers the inputs-level env/cwd/artifacts/output_schema) never saw them. Added a Node after-validator: a human_gate with a non-default retries or timeout is now a config error ("a human_gate runs no process, so retries/timeout does not apply") rather than a silently-ignored control.

2. Parked run reported succeeded == True. Correct and the controller-misclassification risk is real. RunResult.succeeded now returns False when the run is parked (status stays parked), so a consumer branching on result.succeeded — a Pattern Controller iteration — can't read a parked run as a finished success.

3. rejected resumable while in the vocabulary. Correct — the vocabulary and the eligibility rule must not disagree even though reject execution lands in Wave B. _NON_RESUMABLE_RUN_STATUSES is now {succeeded, rejected}, matching ADR 0010 / CONTEXT.md; parked stays resumable (advanced by approve/reject).

New tests: a gate with retries/timeout is a config error; a parked run reports succeeded=False while its run-down nodes succeeded; is_resumable refuses rejected/succeeded and admits parked/failed.

Gates after the fix: ruff check + ruff format --check + mypy --strict clean (60 files), 493 non-e2e pass, real-agent e2e sanity (graph run) pass. Ready for another look.

@qinhaihong-red
qinhaihong-red merged commit 1270774 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