Skip to content

Commit f079945

Browse files
feat(#10): Wave C — multi-gate resume, TTY inline confirm, parked-run e2e (#123)
* feat(executor): multi-gate resume and re-park event dedup (#10) 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. * feat(cli): inline TTY confirmation at a human gate during caw run (#10) 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. * test(e2e): real agent run parks at a human gate and reports parked (#10) 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). * fix(cli): commit a TTY gate decline immediately, before prompting the 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). --------- Co-authored-by: haihong.qin <haihongqin@gmail.com>
1 parent 5c8e9fe commit f079945

5 files changed

Lines changed: 344 additions & 7 deletions

File tree

src/caw/cli.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
33
Exit code contract:
44
5-
- 0: success (`caw run` / `caw resume`: the Run succeeded; `caw validate`:
6-
the workflow is valid; `caw graph`: the plan was rendered)
7-
- 1: the Run finished with a failed Node (`caw run`, `caw resume`)
5+
- 0: success (`caw run` / `caw resume`: the Run succeeded, or parked at a
6+
human gate awaiting approval; `caw validate`: the workflow is valid;
7+
`caw graph`: the plan was rendered)
8+
- 1: the Run finished with a failed Node, or a human gate rejected the Run
9+
(`caw run`, `caw resume`)
810
- 2: config error (unreadable file or invalid workflow definition);
911
config errors print exactly one `error:` line. `caw resume` also exits 2
1012
when the run id is unknown or the Run is not resume-eligible (it already
11-
succeeded) — a refusal, with one `error:` line and no re-execution.
13+
succeeded or was rejected) — a refusal, with one `error:` line and no
14+
re-execution.
1215
- 3: infrastructure error (e.g. unwritable runs root, State database
1316
failure) — the Run could not be executed or completed (`caw run`,
1417
`caw resume`)
@@ -27,6 +30,7 @@
2730
import asyncio
2831
import json
2932
import sqlite3
33+
import sys
3034
from collections.abc import Coroutine
3135
from enum import StrEnum
3236
from pathlib import Path
@@ -60,7 +64,14 @@
6064
execute_run,
6165
resume_run,
6266
)
63-
from caw.model import Node, Predicate, Workflow, execution_order, normalize_workflow
67+
from caw.model import (
68+
HumanGateNodeInputs,
69+
Node,
70+
Predicate,
71+
Workflow,
72+
execution_order,
73+
normalize_workflow,
74+
)
6475
from caw.patterns import expander_names, get_expander
6576
from caw.report import GroupReportError, ReportFormat, render_group_report, render_report
6677
from caw.runlayout import run_dir, runs_root
@@ -455,12 +466,55 @@ def _report_and_exit(result: RunResult, workflow_label: str) -> None:
455466
typer.echo(f"run {result.run_id} succeeded")
456467

457468

469+
def _is_attended() -> bool:
470+
"""Whether this is an interactive (TTY) session that can prompt at a human gate (#10)."""
471+
return sys.stdin.isatty() and sys.stdout.isatty()
472+
473+
474+
def _drive_tty_gates(result: RunResult, gate_prompts: dict[str, str | None]) -> RunResult:
475+
"""In an attended session, prompt at each awaiting gate and advance the run (#10).
476+
477+
A parked run in a TTY prompts inline for the awaiting gates — yes approves, no
478+
rejects. Because ANY rejection ends the run (ADR 0010), the FIRST decline commits
479+
immediately and stops prompting: the later gates' decisions can no longer matter,
480+
so a subsequent prompt (or an abort/EOF at one) can never drop a recorded decline.
481+
Approvals are committed once the pass approves every awaiting gate, looping until
482+
the run reaches a terminal. In a non-TTY session the run stays parked for
483+
`caw resume`, so this is a no-op.
484+
"""
485+
while result.parked and _is_attended():
486+
approvals: list[str] = []
487+
declined: str | None = None
488+
for node_id in result.awaiting_node_ids:
489+
prompt = gate_prompts.get(node_id) or f"Approve gate {node_id!r}?"
490+
if typer.confirm(prompt):
491+
approvals.append(node_id)
492+
else:
493+
declined = node_id
494+
break
495+
if declined is not None:
496+
return asyncio.run(resume_run(result.run_id, runs_root(), rejections=(declined,)))
497+
result = asyncio.run(resume_run(result.run_id, runs_root(), approvals=tuple(approvals)))
498+
return result
499+
500+
458501
@app.command()
459502
def run(workflow_file: Path) -> None:
460-
"""Run a workflow file and print a plain-text result."""
503+
"""Run a workflow file and print a plain-text result.
504+
505+
In an attended (TTY) session a human_gate prompts inline (#10): yes approves it and
506+
the run continues, no rejects it and ends the run. In a non-TTY session the run
507+
parks for `caw resume`.
508+
"""
461509
workflow = _load_normalized_workflow(workflow_file)
510+
gate_prompts: dict[str, str | None] = {
511+
node.id: node.inputs.prompt
512+
for node in workflow.nodes
513+
if isinstance(node.inputs, HumanGateNodeInputs)
514+
}
462515
try:
463516
result = asyncio.run(execute_run(workflow, runs_root()))
517+
result = _drive_tty_gates(result, gate_prompts)
464518
except (OSError, sqlite3.Error) as exc:
465519
typer.echo(f"error: {exc}", err=True)
466520
raise typer.Exit(code=3) from exc

src/caw/executor.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ def __init__(
584584
satisfied_seed: Mapping[str, str] | None = None,
585585
attempt_seed: Mapping[str, int] | None = None,
586586
started_seed: set[str] | None = None,
587+
awaiting_seed: set[str] | None = None,
587588
) -> None:
588589
self._state = state
589590
self._events = events
@@ -619,6 +620,11 @@ def __init__(
619620
# so the Run reaches a fixpoint and parks rather than running past the gate
620621
# (#10, ADR 0010).
621622
self._awaiting: set[str] = set()
623+
# Gates already `awaiting` from a prior parked Run (seeded on resume): a gate
624+
# in this set that re-parks is a CONTINUATION, not a new park, so it re-records
625+
# `awaiting` silently and does NOT re-emit gate_awaiting — the trace marks the
626+
# entry into awaiting once, not on every resume (#10).
627+
self._awaiting_already: set[str] = set(awaiting_seed or ())
622628
# Per-Node Attempt bookkeeping for the in-run retry loop (#6). ``_attempt``
623629
# is the Attempt NUMBER the next launch of a Node uses, so re-launched
624630
# Nodes write distinct ``attempt`` rows ((run_id, node_id, attempt) is the
@@ -753,7 +759,11 @@ def _park_gate(self, node: Node) -> None:
753759
return
754760
self._state.record_node_awaiting(run_id=self._run_id, node_id=node.id)
755761
self._awaiting.add(node.id)
756-
self._events.append("gate_awaiting", {"node_id": node.id})
762+
# Emit gate_awaiting only on the FIRST park — a fresh run, or a gate newly
763+
# reached during this resume. A gate already awaiting from a prior parked Run
764+
# re-parks silently: it never left awaiting, so the trace marks the entry once.
765+
if node.id not in self._awaiting_already:
766+
self._events.append("gate_awaiting", {"node_id": node.id})
757767

758768
def _record_attempt(self, node: Node, result: NodeResult) -> None:
759769
"""Record one Attempt's outcome in State and the Event trace.
@@ -1306,6 +1316,9 @@ async def resume_run(
13061316
f"not forward-compatible with this version"
13071317
)
13081318
node_statuses = state.node_statuses(run_id)
1319+
# Gates already awaiting before this resume: an unapproved one re-parks as a
1320+
# CONTINUATION, so the scheduler must not re-emit gate_awaiting for it (#10).
1321+
awaiting_before = {nid for nid, status in node_statuses.items() if status == AWAITING}
13091322
# Duplicate decision ids collapse to one, order-preserving, so a repeated
13101323
# --approve/--reject for a gate is idempotent rather than crashing on the
13111324
# attempt PK or double-recording the rejection (#10 review).
@@ -1380,5 +1393,6 @@ async def resume_run(
13801393
satisfied_seed=satisfied,
13811394
attempt_seed=attempt_seed,
13821395
started_seed=started_seed,
1396+
awaiting_seed=awaiting_before,
13831397
)
13841398
return await _drive_scheduler(scheduler, state, events, run_id)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Real-agent-CLI e2e: a real run parks at a human_gate and `caw report` renders it (#10).
2+
3+
Deferred here from #90: the Reporter renders parked/awaiting status-agnostically (covered
4+
offline by the report-seam suite), so what this e2e adds is the REAL flow — a real agent
5+
Node runs through ``execute_run``, the run then parks at a downstream ``human_gate`` (ADR
6+
0010), and the report surfaces the parked run and the awaiting gate from persisted State.
7+
The agent is selected by ``CAW_E2E_AGENT`` (default ``claude``); the suite FAILS (never
8+
skips) when the selected CLI is absent.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
from pathlib import Path
15+
from typing import Any
16+
17+
import pytest
18+
19+
from caw.adapter import AdapterRegistry
20+
from caw.executor import RunResult, execute_run
21+
from caw.model import Workflow, normalize_workflow
22+
from caw.report import ReportFormat, render_report
23+
from e2e import harness
24+
25+
# A generous per-Node wall-clock budget so ordinary model latency never trips the
26+
# kernel's timeout; a genuine hang still fails rather than blocking forever.
27+
_NODE_TIMEOUT_S = 300.0
28+
_AGENT_ID = "agent"
29+
_GATE_ID = "gate"
30+
31+
32+
def _gated_agent_workflow(agent: str) -> Workflow:
33+
"""A real agent Node followed by a human_gate: agent -> gate (deploy is gated)."""
34+
inputs: dict[str, Any] = {
35+
"adapter": harness.adapter_for_agent(agent),
36+
"prompt": "Reply with a one-word greeting.",
37+
"env": list(harness.agent_env_names()),
38+
}
39+
run_args = harness.agent_run_args(agent)
40+
if run_args:
41+
inputs["args"] = list(run_args)
42+
raw = {
43+
"name": "e2e-gated",
44+
"version": 1,
45+
"nodes": [
46+
{"id": _AGENT_ID, "kind": "agent", "timeout": _NODE_TIMEOUT_S, "inputs": inputs},
47+
{
48+
"id": _GATE_ID,
49+
"kind": "human_gate",
50+
"needs": [_AGENT_ID],
51+
"inputs": {"prompt": "Approve the deploy?"},
52+
},
53+
],
54+
}
55+
return normalize_workflow(raw, source="<e2e>")
56+
57+
58+
def _why(result: RunResult) -> str:
59+
"""A debuggable reason string surfacing failed Nodes' stderr in an assertion."""
60+
return "; ".join(
61+
f"{node.node_id}: {node.status}: {node.stderr.strip()}"
62+
for node in result.node_results
63+
if not node.succeeded
64+
)
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_a_real_agent_run_parks_at_a_human_gate_and_reports_parked(
69+
agent: str, tmp_path: Path
70+
) -> None:
71+
# A real agent Node runs, then the run parks at the downstream human_gate: the run
72+
# is `parked`, the agent node `succeeded`, the gate `awaiting`, and `caw report`
73+
# surfaces all of that from persisted State in JSON and Markdown.
74+
harness.require_agent_cli(agent) # FAIL (not skip) when the selected CLI is absent
75+
workflow = _gated_agent_workflow(agent)
76+
runs_root = tmp_path / "runs"
77+
78+
async def do_run() -> RunResult:
79+
return await execute_run(workflow, runs_root, registry=AdapterRegistry())
80+
81+
result = await harness.run_with_transient_retry(do_run)
82+
83+
assert result.status == "parked", f"expected a parked run: {_why(result)}"
84+
assert result.awaiting_node_ids == (_GATE_ID,)
85+
86+
run_dir = runs_root / result.run_id
87+
report: dict[str, Any] = json.loads(render_report(run_dir, ReportFormat.json))
88+
89+
assert report["status"] == "parked"
90+
agent_node = next(item for item in report["nodes"] if item["id"] == _AGENT_ID)
91+
assert agent_node["status"] == "succeeded", "the real agent node ran before the gate"
92+
gate_node = next(item for item in report["nodes"] if item["id"] == _GATE_ID)
93+
assert gate_node["status"] == "awaiting"
94+
assert gate_node["error"] is None, "an awaiting gate is not a failure"
95+
assert any(
96+
event["type"] == "gate_awaiting" and event["data"]["node_id"] == _GATE_ID
97+
for event in report["trace"]
98+
)
99+
100+
# Markdown renders the same parked run without error: the awaiting gate is visible.
101+
markdown = render_report(run_dir, ReportFormat.markdown)
102+
assert f"# Run {result.run_id}" in markdown
103+
assert _GATE_ID in markdown
104+
assert "awaiting" in markdown

tests/test_cli_seam.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,3 +1515,93 @@ def test_resume_reject_ends_the_run(
15151515
assert "rejected" in rejected.output
15161516
assert "deploy" not in rejected.output, "a rejected run never runs the gated downstream"
15171517
assert "succeeded" not in rejected.output
1518+
1519+
1520+
def test_run_in_a_tty_prompts_and_approves_inline(
1521+
write_workflow_data: Callable[[dict[str, Any]], Path],
1522+
tmp_path: Path,
1523+
monkeypatch: pytest.MonkeyPatch,
1524+
) -> None:
1525+
# In an attended (TTY) session, `caw run` prompts at the gate inline; answering
1526+
# yes approves it and the run continues to success without a separate
1527+
# `caw resume` (#10, ADR 0010).
1528+
workflow_file = write_workflow_data(_gated_workflow_data())
1529+
monkeypatch.chdir(tmp_path)
1530+
monkeypatch.setattr("caw.cli._is_attended", lambda: True)
1531+
1532+
result = runner.invoke(app, ["run", str(workflow_file)], input="y\n")
1533+
1534+
assert result.exit_code == 0, result.output
1535+
assert "succeeded" in result.output
1536+
assert "node deploy attempt 1 exited 0" in result.output
1537+
1538+
1539+
def test_run_in_a_tty_declines_and_rejects_inline(
1540+
write_workflow_data: Callable[[dict[str, Any]], Path],
1541+
tmp_path: Path,
1542+
monkeypatch: pytest.MonkeyPatch,
1543+
) -> None:
1544+
# Declining at the inline prompt rejects the gate and ends the run (exit 1).
1545+
workflow_file = write_workflow_data(_gated_workflow_data())
1546+
monkeypatch.chdir(tmp_path)
1547+
monkeypatch.setattr("caw.cli._is_attended", lambda: True)
1548+
1549+
result = runner.invoke(app, ["run", str(workflow_file)], input="n\n")
1550+
1551+
assert result.exit_code == 1, result.output
1552+
assert "rejected" in result.output
1553+
assert "deploy" not in result.output
1554+
1555+
1556+
def _multi_gated_workflow_data() -> dict[str, Any]:
1557+
"""Two parallel gated branches: build -> gate{A,B} -> deploy{A,B}."""
1558+
return {
1559+
"name": "multi-gated",
1560+
"version": 1,
1561+
"nodes": [
1562+
{"id": "build", "kind": "shell", "inputs": {"command": "echo built"}},
1563+
{"id": "gateA", "kind": "human_gate", "needs": ["build"], "inputs": {"prompt": "A?"}},
1564+
{"id": "gateB", "kind": "human_gate", "needs": ["build"], "inputs": {"prompt": "B?"}},
1565+
{
1566+
"id": "deployA",
1567+
"kind": "shell",
1568+
"needs": ["gateA"],
1569+
"inputs": {"command": "echo a"},
1570+
},
1571+
{
1572+
"id": "deployB",
1573+
"kind": "shell",
1574+
"needs": ["gateB"],
1575+
"inputs": {"command": "echo b"},
1576+
},
1577+
],
1578+
}
1579+
1580+
1581+
def test_run_in_a_tty_declines_the_first_of_two_gates_and_ends_the_run(
1582+
write_workflow_data: Callable[[dict[str, Any]], Path],
1583+
tmp_path: Path,
1584+
monkeypatch: pytest.MonkeyPatch,
1585+
) -> None:
1586+
# With two parallel TTY gates, declining the FIRST ends the run immediately — the
1587+
# decline is committed before the second gate is ever prompted, so no later prompt
1588+
# or abort can drop it (#10, ADR 0010 review). Only ONE answer is supplied: if the
1589+
# CLI prompted both gates first, the second prompt would EOF/abort instead.
1590+
workflow_file = write_workflow_data(_multi_gated_workflow_data())
1591+
monkeypatch.chdir(tmp_path)
1592+
monkeypatch.setattr("caw.cli._is_attended", lambda: True)
1593+
1594+
result = runner.invoke(app, ["run", str(workflow_file)], input="n\n")
1595+
1596+
assert result.exit_code == 1, result.output
1597+
assert "rejected" in result.output
1598+
assert "parked" not in result.output, "the decline ended the run, not re-parked it"
1599+
1600+
run_dir = next((tmp_path / ".caw" / "runs").iterdir())
1601+
events = [
1602+
json.loads(line)
1603+
for line in (run_dir / "events.jsonl").read_text(encoding="utf-8").splitlines()
1604+
]
1605+
assert any(event["type"] == "gate_rejected" for event in events), (
1606+
"the decline was persisted as a gate_rejected event"
1607+
)

0 commit comments

Comments
 (0)