Whitelist entries now admit hooks via <event>.* (every hook of that
event), *.<name> (that hook name on every event), and * / *.*
(everything), alongside the unchanged exact <event>.<name> form.
Partial globs like tool.st* are not wildcards: they admit nothing and
still land in the unknown-entry debug diagnostics, while <event>.*
entries are exempt from them. Escalation events keep resolving ungated
regardless of whitelist content.
- strip interior NUL bytes from hook env values in fire_resolved so
agent-authored text (e.g. escalation questions) cannot suppress a hook
via a failed spawn; raise the spawn-failure log to warn!
- make Supervisor::has_active_tasks recurse into child supervisors,
mirroring cancel_recursive, so a prompt ctrl-c that cancels only a
grandchild still latches as an interruption
- document the signal-first vs error-first classification asymmetry at
top_level_agent_finished / SpawnResultHooks::fire and the deliberate
REPL teardown cancel; pin ctrlc-with-no-error => agent.interrupted
- probe Scenario J: pty-driven ctrl-c during a live headless --agent run
fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR
Ctrl-C at the reedline prompt no longer latches the abort signal
unconditionally: latch_prompt_interrupt only marks an interruption when
the supervisor had live agents/jobs to cancel (new
Supervisor::has_active_tasks), so the advertised exit path (ctrl-c at an
idle prompt, then ctrl-d) fires agent.completed at teardown instead of
agent.interrupted. Mid-turn ctrl-c still latches directly via
wait_user_interrupt/poll_abort_signal, preserving the
interrupted-then-exit classification.
Also: probe-lane tests for escalation.answered single-fire,
escalation question env truncation, agent.failed inverse gate, and
hooks probe Scenario I (session.started vs session.resumed); comment
notes on escalation.answered semantics and the agent__cancel blind
spot.
- escalation.raised fires in the child ctx after queue submit; escalation.answered
fires in the parent ctx from the taken request, so both agree on origin envs
- escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist
never applies to these root-level events
- session.resumed fires on the use_session resume leg, inverse of session.started
- agent.interrupted replaces agent.completed/failed when the abort signal was
ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm
(agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal;
exit_agent and the shell-exit path stay non-interrupted by passing no signal
- document the deliberate non-fire paths (direct prompts, escalation timeout and
parent-drop, graph approval nodes)
Two conformance gaps were tests-only:
- install_builtin_hooks warning: a #[serial] test proves the canonicalized
compare stays quiet when COYOTE_HOOKS_DIR merely respells the default
(via a '..' hop and, on unix, a symlink), and a second test proves a
genuine override still warns on a fresh install where the default hooks
dir does not exist (fallback compare).
- run_hook payload cleanup: a dispatch test fires a hook with a payload
and an unspawnable cwd, anchors on the spawn-failure debug log (and the
absence of a payload-write failure) so the assertion cannot pass
vacuously, then polls with a bounded deadline until the payload file is
unlinked.
Both tests were verified red-capable: reverting the canonicalization or
skipping the unlink on the error branch makes them fail.
Extends the .gitattributes eol=lf guard to assets/agents/** (33 embedded
shell scripts share the CRLF failure mode the hooks rules fix) and
scripts/*.sh. Adopts the probe-authored Scenario H: black-box checks of
the installed log-events.sh (XDG default, 0600, UTC header, secret
filter, symlink refusal) and notify.sh (control-byte-stripped fallback),
plus non-executable .builtin-manifest.
- one shared exec-bit predicate (Language-recognized extensions => 0755) used by
every hook/agent installer; non-script files in hooks dirs stay non-executable
- mixin discovery scans the global hooks dir and each agent's hooks dir;
.builtin-manifest is never scanned
- NTFS 8.3 short-name aliases rejected as builtin-manifest entries
- hook-script install announcements restricted to real hook locations
- COYOTE_HOOKS_DIR override warning compares canonicalized paths
- embedded assets decode via checked from_utf8 instead of from_utf8_unchecked
- log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log
keeping 0600/symlink-refusal/secret-filter hardening; notify.sh tty fallback strips
control bytes; both use UTC ISO-8601 timestamps
- payload files orphaned by a write failure are unlinked; stale coyote-hook-*.json
payloads swept best-effort at startup
- sweep_removed_agent_hooks never follows symlinked agent dirs
- usage-probe-hooks.sh replaces bare sleeps with a bounded poll, re-verifies 0755 on
restored scripts, and covers roles/agent hook locations
- TestConfigDirGuard hoisted into crate::testing
- .gitattributes pins LF line endings for bundled scripts
hooks is now a reserved bundle name.
- Thread an explicit InstallMode (Skip/Prompt/Force) through the hook
installers: startup install_builtins() is pinned to Skip and never
prompts (test-pinned on both mocked-TTY and TTY-less paths);
--install-builtins hooks runs Prompt mode across all three hook
locations (global hooks dir, roles/hooks, agents/<name>/hooks) under
one shared keep-all/replace-all sticky scope; force paths keep
unconditional overwrite semantics.
- Hoist the bundle resolve_conflict/StickyMode/ConflictAction prompt
into shared config::conflict with a NonInteractive policy: bundle
installs keep their non-TTY bail, builtin hook refreshes resolve
non-TTY conflicts to keep. Bundle UX preserved byte-for-byte,
pinned by tests.
- Extend .builtin-manifest reconcile to the resolved top-level hooks
dir (shipped set from HookAssets::iter()); malformed-manifest
fail-safe (zero deletions) preserved and test-pinned.
- Sweep removed builtin agents' hooks: agent dirs absent from the
embed with a hooks/.builtin-manifest reconcile against an empty
shipped set; empty hooks dirs are removed, user files and
manifest-less agent dirs are never touched.
- Update usage-probe-hooks.sh Scenario C to the new non-interactive
keep contract and add black-box Scenarios E-G covering role/agent
hook reconcile, top-level manifest fail-safety, and the
removed-agent sweep.
The look-don't-touch bypass was keyed on load_with_interpolation's
pre-existing bool, which mutating early paths (--authenticate, --auth-mcp,
mcp manage, vault flags) already passed as true meaning only 'skip vault
interpolation' — so they silently lost their first-run config creation.
Split the parameter: skip_interpolation keeps the historical meaning,
a distinct inspection flag alone gates the missing-config default/wizard
bypass.
inspection_only is now computed from an explicit read-only whitelist:
--sync-models is excluded (it downloads and writes models-override.yaml)
and returns to the bootstrap/strict path, keeping only its historical
interpolation-skip and vault leniency. mcp/vault inspection sub-conditions
are hoisted and shared so mutating flag combos never inherit the bypass.
Coverage: sandbox vault contract tests, a sync-models bootstrap-path pin,
a --sync-models scenario in usage-probe-inspection.sh, and a PTY wizard
regression probe (scripts/usage-probe-wizard.sh).
- Skip the builtins bootstrap, function-binary build, and tool-scope
rebuild for inspection-only runs (--info/--list-*/--mcp-list/
--list-secrets without --agent/--role): a pristine COYOTE_CONFIG_DIR
now stays byte-empty (was 269 files written before dispatch)
- Tolerate a model-less default config on the inspection surface via
AppConfig::from_config_lenient + Model::default() in bootstrap;
strict 'No available model' behavior unchanged for the REPL path and
all mutating mcp/vault flags
- --list-secrets degrades to an rc=0 informational message in sandbox
mode (mutating vault flags still fail there), pinned by test
- Add the 041.7-4 regression test: a malformed COYOTE_<NAME>_GLOBAL_TOOLS
env override logs the debug message and leaves global_tools untouched
- Adopt scripts/usage-probe-inspection.sh (10 flags: rc=0 + zero writes,
output required only for --info) and add binary-level
tests/inspection_flags.rs, incl. the agent+--info still-bootstraps pin
- unify hook base-env assembly on hooks::base_envs_parts (drop the
hand-copied hook_base_envs in function/agents.rs; graph executor and
spawn-result hooks now share the single construction site, test-pinned)
- factor the duplicated job.started dispatch in jobs.rs handle_start
into one fire_job_started helper
- COYOTE_LLM_ERROR now carries the immediate error (to_string()),
matching COYOTE_TOOL_ERROR; test pins the new format
- remove all stale #[allow(dead_code)] markers in src/hooks.rs
- test infra: rename install_warn_collector -> install_log_collector,
add poison-symmetric warn_snapshot/debug_snapshot helpers, hoist a
shared EnvVarGuard (TestConfigDirGuard now builds on it), add a
same-fire payload-uniqueness test, and give global_tools env
overrides the same malformed-JSON debug diagnostic as global_hooks
- inspection-only flags (--info et al) bypass the first-run wizard:
missing config + info_flag returns Config::default() without writing
anything, in both the sandbox and interactive arms (tests included)
- bound the shim-relocation test's exec in an ETXTBSY retry (10x50ms)
- document the intended 'roles' reserved-name asymmetry at
resolve_bundle_name's gate
The .compress-session success test polls through the whole summarization
call chain; those nested poll frames overflow the default test-thread
stack on Windows debug builds even with the outer future boxed. Drive
all four deep-compress tests through the same big-stack run_async
harness the macro tests already use.
Bundles: RemoteLayout.hooks + scan/is_empty/plan routing into hooks_dir,
a functional --filter hooks arm replacing the inert interim arm,
TopCategory::Hooks provenance rows, and per-file 'bundle defines hook
script' summary lines (visibility only, no approval prompt).
Reconciliation: new builtin_manifest module — a hidden per-directory
.builtin-manifest records the files the builtin installer actually owns
(written this run, previously owned, or pending failed deletions);
deletion is strictly manifest-minus-embed so user-created files in the
same directory are never touched. Wired into install_builtin_agents for
agents/<name>/hooks/ and a greenfield minimal Role::install_builtin_role_hooks
for roles/hooks/.
Hardening: bundles are barred from shipping any path component that
aliases .builtin-manifest (Unicode case fold + trailing dot/space,
directories included); manifest entries reuse the bundle store's
is_safe_component rules; malformed manifests fail safe toward keeping
files; role-side reconcile failures degrade to a warning like the
agent side.
Black-box scenarios against a built binary with dry_run configs: hook
cadence + env scoping, runtime inertness of failing hooks, --info firing
zero agent.* events, --install-builtins hooks install/force/cold-start
semantics, and end-to-end firing of the installed log-events.sh via the
documented relative-path wiring.
- AssetCategory::Hooks + InstallFilter::Hooks (NAMES/parse/clap), reserving
the 'hooks' bundle name via the existing AssetCategory::parse check
- HookAssets embed + install_builtin_hooks(force): installs the bundled
example scripts 0755 into paths::hooks_dir() via write_file_atomic, wired
into install_builtins() and install_assets(); warns once when
COYOTE_HOOKS_DIR diverges from the config-dir default
- assets/hooks/notify.sh: notify-send -> osascript -> terminal fallback,
with AppleScript-literal sanitization of model-influenced values
- assets/hooks/log-events.sh: appends a COYOTE_* env snapshot (secrets
excluded, 0600 log, symlink-refusing) to ${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}
- sbx-mixin discovery now scans roles/*.sbx-mixin.yaml and
roles/hooks/*.sbx-mixin.yaml (mirroring the role-hook cwd)
- tests: install+0755+force, install_assets(Hooks) end-to-end, discover()
roles scanning, reserved bundle name, no-ARGC-BUILD embed content, script
content order + real execution (secret exclusion, tty-less fallback),
CLI parser round-trips; hooks_dir env test made #[serial]
- Open the top-level agent bracket only at committed dispatch sites so
inspection-flag early returns (--list-sessions, --info, --macro,
--rebuild-rag, --init-*, --build-tools) never leak agent.started
- Close the agent bracket ahead of process::exit in shell_execute's
execute branch, where the dispatch wrapper's close is unreachable
- Extract the ask-loop auto-compression seam and cover the
session.compressed success path at both call sites with sink tests
- Prove Session.role_hooks never serializes and that injected
role_hooks YAML is ignored on deserialization
Turn bracket (repl::TurnBracket): turn.started on entry and exactly one of
turn.{completed,interrupted,failed} on exit, with the interrupted-vs-failed
discrimination (abort_signal.aborted_ctrlc()) in one place. Wired at the
three outer boundaries: ask(), start_directive, and shell_execute — never at
the per-LLM-round before/after_chat_completion seams.
Session seams: session.started fires for newly created sessions only
(use_session's new_from_ctx paths; resume fires nothing), session.ended in
exit_session while the session is still attached, session.compressed on the
success path of both compress_session call sites (auto-compression and the
.compress command).
Session-held role hooks: Session snapshots the role's HooksMap on set_role,
clears it on clear_role/sync_agent, and re-reads it from the role file on
load; resolved_hooks gains a session leg so role hooks keep firing when the
role lives in the session (fresh set_role and resumed sessions alike).
Top-level agent bracket: agent.started/completed/failed once per agent RUN —
headless --agent dispatch and the interactive agent-context lifetime (enter
via .agent or startup; exit via switch-away or REPL teardown), never per
message. COYOTE_AGENT_ID is synthesized as top-<agent-name>-<nonce>.
Exit-path seams call hooks::drain_pending with a 2s bound so exit hooks
finish spawning before process teardown; interactive mid-session events
never drain.
Wire the hook engine into the three runtime seams:
- tool.*: ToolCall::eval fires tool.started once the name and arguments
are resolved, then exactly one of tool.completed/tool.failed,
discriminated on both the Err path and tool_call_error-shaped outputs.
The arguments JSON is handed to the engine as the payload; eval itself
performs no payload-file I/O.
- llm.request.*: the three transport free functions
(call_chat_completions, call_chat_completions_streaming, and the quiet
graph-node variant) fire started/completed/failed for every provider
and caller, classifying failures as aborted, api_error (with
COYOTE_LLM_STATUS), or other.
- job.*: handle_job_tool fires job.started before either spawn and the
terminal event inside the detached closures via a pre-resolved
fire_resolved snapshot, so job__start's return is never delayed.
The test sink now captures the payload and cwd of each dispatch
(sink-only change), and seam tests pin event cadence, env contracts, and
byte-identical tool results and errors.
Two test gaps: no test fired a genuine graph.node.failed and inspected
its payload (the only node-failure test asserted the hook's absence
because a fallback absorbed the error), and no test drove a malformed
hook definition through the new graph seams. The first test fails an
agent node with no fallback and asserts the captured envs carry the
node id, node type, a numeric duration, and a non-empty error while
the failure still propagates out of the executor. The second routes a
blank-command hook from graph.yaml's top level through the executor
seam and proves it degrades safely: the node completes, the malformed
hook is skipped with a debug log, and a well-formed sibling on the
same event still fires.
Fire agent.started before the supervisor spawn and agent.completed/failed
from the spawn task's result arms, resolved against the child context so
global hooks stay gated by the child's own whitelist. Graph AgentNode
failures fire agent.failed at the point of failure, before a declared
fallback converts them into a successful route. The graph executor fires
graph.node.started before each branch spawn and graph.node.completed/failed
beside the narrate calls, with per-branch pre-resolved snapshots and
metadata so parallel super-step branches never cross-contaminate. Base
envs for detached dispatches are rebuilt at fire time via a shared
hook_base_envs helper.
Introduce src/hooks.rs as a types-only module (HookDef, HooksMap) and
plumb hooks:/global_hooks: config fields through every scope:
- Config + AppConfig hooks field, both Default impls, the manual
from_config copy, and a COYOTE_HOOKS env override
- Role frontmatter: hand-parsed hooks arm, private field, getter, and
JSON-inline re-serialization in Role::export (malformed maps degrade
to None)
- AgentConfig + Graph: hooks + global_hooks on both structs, copied in
AgentConfig::from_graph, with a per-agent global_hooks env override
- paths::hooks_dir() = <config_dir>/hooks with COYOTE_HOOKS_DIR override
- Commented hooks: block in the global config template (global scope
only; no global_hooks key)
No engine or dispatch logic yet; existing configs parse unchanged.
- ClaudeConfig gains an oauth: block (custom endpoints, e.g. a private
gateway); without it, auth: oauth keeps the stock Claude Pro/Max provider
- Claude-Code system-prefix spoof + anthropic-beta header are only sent on
the stock provider path
- OAuth tokens are stamped with an issuer fingerprint (token_url|client_id)
so switching endpoints never replays a token against the wrong issuer
- All auth flows refresh the in-memory access-token cache on save, so
.authenticate mid-session takes effect immediately
- A custom oauth: block now requires api_base: refusing to silently fall
back to api.anthropic.com prevents sending a gateway token to the wrong
host
SystemTime nanos alone collide on coarse clocks, so concurrent tests shared
a scratch dir and read each other's generated sbx mixins (flaky
generated_sbx_mixin_* failures in CI).
verdict_gate.py now names every incomplete review on a machine line the
done template renders immediately after the verdict:
`GAUNTLET_REVIEW_INCOMPLETE: <name>[, <name>]` (sorted). Names are the
lanes retry_gate gave up on (`review_incomplete`) plus the `pipeline`
pseudo-lane whenever `signals_error` carries a `PIPELINE-FAULT:` —
parse death, builder / retry-gate / default-lanes crash guards — and the
gate's own crash guard emits the `pipeline` line too. The report gains a
`## Review incomplete` section (after the signals_error blockquote,
immediately before `## Full lane reports`) and the three PIPELINE-FAULT
lane rows carry ` ×N` when the lane was re-run (N = 1 texts unchanged).
retry_gate.py stands in a synthetic `PIPELINE-FAULT: <lane> lane produced
no report` marker for a lane that ran but collected nothing, so it is
stashed, classified FAULTED and never renders SKIPPED downstream.
MAX_RETRY_ELAPSED_SECS trimmed 45600 → 45000 for margin slack.
graph.yaml: `gauntlet_incomplete_line` seeded in initial_state and
spliced into the done output. README + mermaid document retry_gate, the
dotted _next back-edge, the 3-attempt / wall-clock budget, that retry
never narrows selection, the crash-guard append semantics, and the
machine-line contract (present whenever any lane or the pipeline was
incomplete; it can accompany real findings).
Tests: ×N on all three branches (N>1, N=1 byte-identical, no leak onto
non-fault rows), exact incomplete line / section placement, pipeline
pseudo-lane alone and alongside real lanes (via default_lanes.py's real
crash guard), mixed finding+fault, crash-guard chaining from
build_items / retry_gate / parse_fault / verdict_gate, synthetic
no-report marker, done-template wiring, README pins (positive + stale
negative).
Give the review gauntlet a deterministic retry loop for lanes that FAULT
(PIPELINE-FAULT marker, missing verdict sentinel, or a nested graph's own
degraded-run wording) without ever re-running a lane that reached a real
verdict.
- graph.yaml: the four lane maps route to a new `retry_gate` script node
(static `next: verdict_gate`; emits `_next: build_items` to retry).
initial_state gains lane_attempts / kept_results / retry_lanes /
review_incomplete / gauntlet_started_at. settings.timeout 24000 -> 69600
(3 passes x 2 attempts x 11400 + 1200 margin).
- scripts/retry_gate.py (new): stashes every lane report that ran
(completed and faulted alike) and restores reports a re-run pass
overwrote with []; classifies with verdict_gate.py's sentinel regexes
verbatim; counts attempts; retries while attempts < 3 and inside a
45600 s wall-clock budget; otherwise records the lane in
review_incomplete and falls through. Crash guard appends to
signals_error and never retries.
- scripts/signals.py: stamps gauntlet_started_at before the git work so a
failed diff still starts the clock.
- scripts/build_items.py: retry pass emits items only for retry_lanes
(with attempt numbers), adds a `retried:` note to lanes_summary, resets
retry_lanes; falsy-guarded stamp fallback; crash guard appends to
signals_error and preserves lanes_summary.
- Tests: hermetic python3-guarded script tests for retry_gate /
build_items / signals, graph wiring + initial_state pins, sentinel and
LANE_KEYS cross-pins, retry-budget envelope vs settings.timeout, a
first-pass golden payload, and an engine-level executor test proving a
script `_next` back-edge re-fires a static fan-out and an empty map
re-run clears its collect_into.
Plan: PLAN-review-retry section A items 1-3 and 5. Verdict/report surface
(section A items 4 and 6) is TASK-032.
Child escalations (coder plan approvals, deviation gates, user__* calls
from subagents) previously expired after the escalating agent's
escalation_timeout, defaulting to 300 s, and 0 timed out instantly. In
autonomous runs the orchestrator often could not reply within that
window, so approvals silently fell through to the best-judgment
fallback.
- Extract await_escalation_reply as the wait seam: 0 awaits the oneshot
with no deadline; nonzero keeps tokio::time::timeout with the same
timeout message. A dropped sender still resolves to the existing
cancelled reply either way.
- DEFAULT_ESCALATION_TIMEOUT_SECS and default_escalation_timeout() -> 0,
so graph agents (which inherit the default via from_graph) are covered.
- Document the new semantics in config.agent.example.yaml and drop the
5-minute-timeout claim from the default spawn instructions.
- Tests: paused-clock unlimited wait (3600 s), nonzero expiry message,
dropped-sender completion, default pin, and a grep pin that the stale
timeout wording is gone from src/, assets/, and the example config.
Plan: PLAN-review-retry §C (D3).
verdict.py: a recorded verification run with exit≠0 (including the -1
TIMEOUT sentinel) or a skipped record now forces DIVERGES with a numbered
complaint naming the command — previously a [FAIL] line sat in the report
appendix while the sentinel read CONFORMS, and callers route on the
sentinel alone. Fail-closed by the same rule: an ENVIRONMENT runner-error
marker when commands WERE declared is one red complaint (the declared
commands never ran, so the run is unproven); the "none declared" marker
and an empty declaration stay non-red. Headers name the red-run count.
run_checks.py: decode command output with encoding=utf-8/errors=replace
(a single non-UTF-8 byte used to raise inside communicate() and collapse
the whole evidence record to an ENVIRONMENT marker); bound the post-kill
pipe drain by DRAIN_TIMEOUT_SECS so a session-escaped grandchild holding
the pipe cannot turn a TIMEOUT record into a node kill.
graph.yaml: settings.timeout 5400 → 9000 = 3600 (run_checks node timeout)
+ 5400 (the previous whole-graph budget, kept for every other stage); the
old value predated run_checks and a graph-level timeout kills from
outside, losing the DIVERGES sentinel. Descriptions and README doctrine
updated (CONFORMS = all MET + zero holistic complaints + zero pipeline
faults + zero red runs).
Tests: envelope pin (settings >= run_checks + 5400), gauntlet lane
timeouts > child graph timeouts and gauntlet >= max_attempts × lane +
margin over every agent lane, non-UTF-8 output, bounded drain (unix),
red/skipped/timeout/none-declared/ENVIRONMENT×declaration matrix,
numbering continuity, header punctuation, wording pins on graph.yaml,
run_checks.py and both READMEs. The deadline test's hung command is now
python3-based instead of GNU sleep (cmd.exe on the Windows CI leg).
code-reviewer verdict.py: finding-verifier now completes "successfully"
with fault markers INSIDE its FINDING_VERIFIER_RESULTS payload
(parse_fault's `pipeline-fault` sentinel id, verify_fault / verdict_gate
crash-guard notes prefixed PIPELINE-FAULT:), which the old
"Agent node failed:" prefix check never saw — a dead parse degraded
silently to "unverified" with no fault recorded. Detect them, anchored on
the sentinel id / note prefix only (never a substring over the report),
counted over the raw entry list so duplicate crash-guard ids are not
collapsed. Faults lead the attention list uncapped; the display cap of 5
applies to non-fault entries; the "always-human trigger(s) fired" reason
fires only when non-fault triggers exist.
code-reviewer render.py: CONTRACT comments at the verdict and summary
lines review-gauntlet's gate parses; every LLM-influenced value rendered
after the summary line (dropped_titles, rigor, bar_provenance,
resolved_surfaces, reason) is newline-sanitised so none can forge a
second summary line.
finding-verifier verdict_gate.py: the outer crash-guard note carries the
PIPELINE-FAULT: prefix (the after-retry UNVERIFIABLE path stays
unprefixed — model misbehaviour, not infrastructure); README names all
three emitters.
deep-research: vet_fault's UNVETTED note tells the critic not to request
revision solely because the assessment is unavailable (it matched
critique's PREPRINT/UNVERIFIED REVISE trigger verbatim, burning the
revision budget on an infra fault); vet_fault and critique_fault dedupe
repeated faults and preserve accumulated faults on crash via
safe_faults(); question_fault keeps its plan-pinned prefix and appends the
lane's question identity so distinct dead lanes no longer collapse in
combine_findings' dedupe.
verdict_gate.py: the code-review critical count is read from render.py's
deterministic summary line (full-shape regex, LAST match wins — a 🔴
quoted inside a finding body or Changes-table row is not a finding); the
raw 🔴 count remains the fail-closed fallback when the line is absent. A
code-review lane whose own rendered verdict line records a pipeline
fault (degraded nested run) now BLOCKS instead of passing as GREEN
(attention), and fault + reds append both blockers. A non-string lane
result item is BLOCKED as malformed, never routed to GREEN.
default_lanes.py: the crash handler now emits the WIDEST safe selection
(code-review ∪ adversary ∪ security) and records a PIPELINE-FAULT in
signals_error so the gate blocks — previously it repaired malformed state
into a NARROWER passable selection with no fault. Probe is added only
when consumer_surface AND a probe_context (local-run recipe) are present,
mirroring build_items' guard, so degraded selection cannot spawn an
INCONCLUSIVE-by-construction probe.
graph.yaml: re-derive the timeout envelope. Agent-node retries each get a
fresh per-attempt budget (agent.rs retry_transient/bounded_attempt) and
the child's "Graph … timed out" matches the transient anchor, so lanes
exceed their child graph's settings.timeout (code-review 11400 > 10800,
adversary 9600 > 9000) and the gauntlet's own timeout covers
max_attempts × lane timeout + margin (2 × 11400 + 1200 = 24000). The
child graphs' internal retry envelopes are deliberately not derived here
(tracked as a follow-up). README/descriptions updated and pinned.
Tests: summary-count/quoted-🔴/fallback/last-match/nested-fault/both-
blockers/malformed-lane, crash-widens-and-records-fault, probe guard,
ALIASES parity with build_items, and e2e render.py → verdict_gate runs.
is_transient_error: a typed crate::client::ApiStatusError anywhere in the
chain is now authoritative (positive and negative) — catch_error renders
429s as "… (type: rate_limit_error)" / "… (status: 429)", which none of
the old string anchors matched, so provider rate limits were never
retried, while a 400 whose body mentioned "rate limit" was. Transient
statuses: 429, 500, 502, 503, 504, 529; message text only classifies
untyped errors, and those anchors are now case-insensitive.
validator: warn for an llm/agent node whose output_schema lacks a
non-empty object `properties` map (auto-merge writes nothing since
402d25a) unless the node lifts keys via a non-empty state_updates; the
same absent-or-empty predicate now also gates validate_fallback_capture.
Bundled and graph.example.yaml warning baselines are unchanged.
Tests build the 429 fixture through real catch_error output and a
.context() chain, cover the status table, typed-overrides-text, and all
validator branches.
Adversary re-review follow-ups to 665fee9/402d25a:
- architect/sisyphus spawn templates: the pinned cargo commands are only an
EXAMPLE in a language-agnostic orchestrator — a literal follower in a
non-Rust repo would record 'cargo: command not found' and yield a false
DIVERGES. Each --variables line now carries a substitution cue (pinned).
- run_checks.py: a non-list prior pipeline_faults value is no longer iterated
character by character (test case added).
- Pins: both graphs' verification_commands description wording; the
run_adversary prompt states where the commands come from; the replacement
sentence sits on one physical line.
state_updates::apply merged EVERY top-level key of an llm/agent node's JSON
output into graph state whenever the node had an output_schema, and
structured::extract never validated the output against that schema. Any
prompt text able to steer the model — including plan/diff text pasted from a
repo under review — could therefore plant an undeclared key (e.g.
verification_commands) into state and overwrite a caller-declared value
before a downstream script consumed it.
- apply() now takes the node's output_schema and merges only keys present in
schema.properties; undeclared keys are dropped. No schema, or a schema
without an object properties map, merges nothing.
- Nodes without output_schema are unchanged (None == the old false path);
explicit state_updates are unchanged and still see the full output via
{{output.<key>}}, which remains the author-controlled way to lift a key.
- Callers: llm.rs, agent.rs (x2), rag.rs (None).
- Sweep test bundled_graph_output_schemas_declare_every_key_relied_on: for
every bundled graph, every consumed state key has a declared writer
(initial_state, variable, state_updates target, map key, script-emitted key,
or an output_schema property), and no schema node's field list names a key
its schema omits — so this restriction cannot silently break a shipped
graph. It passes with no graph.yaml changes.
- graph.example.yaml documents the declared-keys-only merge; the residual
disclosed in 665fee9's README/docstring notes is now recorded as closed.
verification_commands — the shell commands the adversary's run_checks stage
executes — were extracted by the LLM parse node from {{initial_prompt}}, a
flat prompt that also carries untrusted plan/diff text pasted from the repo
under review. A planted '## VERIFICATION' block was indistinguishable from the
caller's declaration: prompt injection -> arbitrary command execution.
- adversary + review-gauntlet graphs declare verification_commands as a graph
variable (default '[]'); the key leaves initial_state (or_insert seeding
would otherwise shadow it) and leaves both parse nodes' instructions,
output_schema.properties and required.
- review-gauntlet forwards it to run_adversary via a structured inputs:
passthrough (lone {{verification_commands}} template), not lane-prompt prose.
- run_checks.py accepts a JSON list or a JSON-encoded string of one (variables
arrive as strings); '[]'/[]/missing = none declared; any other shape fails
closed with a PIPELINE-FAULT (nothing executed) that verdict.py turns into
DIVERGES complaint #1.
- architect/sisyphus spawn templates and both READMEs document the
--variables / --agent-variable form and the trust boundary. Known residual
(llm-node auto-merge of undeclared output keys) is disclosed in the docs.
- Tests: list/JSON-string acceptance, fail-closed no-execution proof,
prompt-prose injection proof, verdict wiring, asset pins for both graphs and
orchestrator configs, dispatch-inputs > variable-override > default
precedence.
Graph llm nodes (and the structured-output extractor they share with agent
nodes) now obtain the model reply through a quiet streaming transport,
`call_chat_completions_streaming_quiet`, instead of the non-streaming call.
Because chunks arrive as they are generated, reqwest's client-level
`read_timeout` (default 300s) bounds only a genuine stall between chunks and
the node's own `timeout` becomes the effective bound on total generation.
Previously zero bytes arrived until the whole reply finished, so a 300s
read_timeout silently capped every llm node regardless of its `timeout`
(seen live: code-reviewer's `synthesize` node dying on a 61-file review).
The quiet transport renders nothing (silent SseHandler, no spinner), keeps
tool-call handling identical to the non-streaming path (call-loop detection
off), falls back to the non-streaming request for catalog `no_stream` models
(raced against the abort), and never returns partial output as success.
Supporting changes:
- `sse_stream`/`json_stream` keep the typed `reqwest::Error` in the anyhow
chain so `is_transient_error` still classifies read_timeout stalls as
transient and `max_attempts` retries them (previously stringified).
- `structured::extract` takes the graph/session AbortSignal and refuses to
issue an extraction request for an already-aborted graph.
- `run_chat_loop` gains an injectable CompletionRunner seam (owning client
construction + the before-hook) so the timeout/retry/abort behaviour is
unit-testable over a paused clock; a fake `impl Client` drives the real
trait-default streaming for 600s of virtual time; real-socket tests pin
that read_timeout fires on a stall but not on a slow continuous stream.
run_checks.py SIGKILLs the timed-out command's whole process group so a
backgrounded grandchild cannot outlive the record; nothing proved it.
Add a unix-only test that backgrounds `sleep 30`, records its pid, and
probes `kill -0` after the 1s deadline fires.
TOTAL_DEADLINE_SECS (run_checks.py) and the run_checks node's `timeout:`
(graph.yaml) live in different files and could drift until the node
executor kills the script before its in-script degradation runs. Parse
the script's default and assert it stays strictly below the node
timeout, without hardcoding either value.
Sweep every agent node in the six bundled graphs and assert max_attempts
is exactly 2 (step-runner/implement stays 1) against a pinned node list,
so a dropped `max_attempts:` line cannot silently revert a lane to
one-shot (the serde default is 1). Sweep every llm/agent node for a
declared, existing fallback and pin the specific fault routes in
review-gauntlet, code-reviewer, and adversary.
Pin check_criterion's tool list to fs_read/fs_cat/fs_grep/ast_grep and
its 'judged ONLY against the recorded verification runs' instruction,
so the criterion branch can never grow an execution tool unnoticed.
agent.rs: the retry-after-timeout test ran its second attempt with no
timeout bound, so it passed whether or not the retry owned a fresh
budget. Attempt 2 now runs under the same 1s bounded_attempt and sleeps
900ms under the paused clock; it only succeeds if the deadline is
per-attempt rather than shared with the attempt that already used it.
llm.rs: the `LLM node structured-extraction failed: {e:#}` branch had no
test because `run` cannot succeed without a model. Split the post-run
half of execute into `finish(node, state, ctx, result)` (byte-identical
body, mirrors agent.rs's outcome_from seam) and add tests feeding
Ok("not json") through it, with and without a fallback, asserting the
captured text prefix and the extractor's full context chain.
Node B is the graph's `facts` node (script diff_facts.py), not a node
named diff_facts. criterion_fault has no `next`; it reaches holistic
through the map join, so draw it dashed like the other branch-terminal
edges instead of as a solid successor edge.
Rust test comments/assert messages and deep-research script docstrings
cited numbered requirement labels (R3/R5) and task labels (TASK-016,
TASK-021) that are defined only in an external plan document. Reword
each to say what it means on its own: fail-safe crash guards, the
validator's fallback-capture warning, task-queue anchors.
FIX A: run_checks.py passed no env= to Popen, so verification commands
inherited the live GRAPH_STATE/GRAPH_STATE_FILE. Every suite script's
load_state() prefers GRAPH_STATE_FILE, so when the adversary reviews a repo
whose own tests exec these scripts (this repo's R5 tests) the leaked file
silently overrode the test state -> false failures. Popen now gets an env
with all GRAPH_STATE* keys removed. The five sibling script-test helpers
(review-gauntlet, code-reviewer, finding-verifier, step-runner,
deep-research) gain .env_remove("GRAPH_STATE_FILE") for hermeticity. New
R5 test loads state via GRAPH_STATE_FILE and asserts a shell-neutral
python3 -c probe sees both vars UNSET.
FIX B: the depth check compares the child's absolute depth against the
SPAWNER's own max_agent_depth (root = 0), so under
architect(0)->sisyphus(1)->review-gauntlet(2)->code-reviewer(3) the
code-review lane faulted "Max agent depth exceeded (4/3)". Limits are now
child depth + 1 headroom along the documented chains, with the chain in a
comment next to each key: code-reviewer 3->5, domain-reviewer 2->6,
architecture-reviewer 2->4, step-runner default->4 (explicit),
review-gauntlet stays 4. domain-reviewer's file-reviewer hop is documented
but inert (can_spawn_agents unset) - left unwired by decision; noted in
config and pinned via a DOCUMENTED_BUT_UNWIRED exemption in the new
bundled-config test, which pins every value and asserts per-hop headroom +
reachability along four chains.
The crash-path assertion checked only "PIPELINE-FAULT:"; verdict.py's died() keys on "PIPELINE-FAULT: criterion ", so a regression there would have passed the test yet stopped the crashed marker from rendering as DIED.
A live code-reviewer run degraded through synthesize's fallback; the
graph already retries once (max_attempts: 2) and falls back to verdict
with synth_failure captured, but nothing pinned that wiring. Add the
wiring test so a future edit cannot silently drop the retry or route.
A live run died at map_criteria: one heavy criterion exhausted
max_iterations (10) with no fallback and sank the whole 17-criterion
review. check_criterion now has a 30-iteration budget, a tool-budget
instruction, and falls back to criterion_fault, which records a
schema-shaped UNMET verdict with a PIPELINE-FAULT evidence marker for
that one criterion; verdict.py renders it as DIED rather than judged.
A live code-reviewer run reported "LLM node failed: Failed to call
chat-completions api" with the root cause invisible, because the
state_updates fault text used `{e}` (top context only). Format with
`{e:#}` so fault scripts and warn! logs see the whole anyhow chain.
R3 guards: parse_request (crash → ask_topic), bootstrap_research,
combine_findings (crash → fault-text findings; also lifts branch-local
PIPELINE-FAULT findings into pipeline_faults, deduped), reflexion_gate
(crash fails forward to synthesize), incorporate_feedback (crash still
loops with a generic feedback note), verify_sources (top-level guard +
pipeline_notes fold).
Fallbacks per the plan's naming pins: plan → plan_fault → end_fault
(fail-closed; gains state_updates plan_failure per the TASK-016 rule);
research_one_question → branch-local question_fault (map collects the
fault finding); vet_sources/critique → degrade-visibly markers that
continue to the stage each node's own next pointed to; synthesize →
synth_fault → end_fault. end_fault renders
'DEEP_RESEARCH FAILED — PIPELINE-FAULT: <fault text>'.
Accepted-path visibility: verify_sources folds pipeline_faults into
pipeline_notes ('' when fault-free), appended to end_accepted's output
and the approval question — happy-path output stays byte-identical.
initial_state seeds questions: [] so a dying plan inside the static
fan-out leaves the sibling knowledge_lookup lane mapping over zero
items while end_fault renders.
R5: 19 python3-guarded regression tests + GraphParser wiring test;
bundled warning baseline unchanged.
- prompts.rs DEFAULT_SPAWN_INSTRUCTIONS: example IDs task_1/task_2 -> "1"/"2"
(real numeric-string IDs; old format silently produced dep_error + unblocked
task), plus task-queue rules: root tasks with agent are never auto-dispatched,
task_complete is what dispatches dependents, auto-dispatched agents must be
collected, failed tasks permanently block dependents.
- architect config: optional task-queue mirroring guidance for parallel mode
(disk task files stay the durable store; queue is in-memory).
- sisyphus config: short note on encoding dependency-shaped bursts as
agent__task_create chains; todos remain the tracking source of truth.
The three fallback-capture keys (orient_failure, sweep_failure,
handoff_failure) were write-only dead state: captured on LLM-node failure
but interpolated nowhere, so end_failure rendered an empty STEP_FAILED
shell and write_handoff carried no trace a dead sweep.
- new crash-proof script node note_llm_fault distills orient/handoff
'LLM node...' failure text into fault_note (prefix-anchored; empty when
neither key holds a fault); orient and write_handoff fallbacks now route
through it before end_failure
- end_failure renders 'Pipeline fault (if any): {{fault_note}}' (sentinel
and all existing fields preserved; fault_note defaulted in initial_state)
- write_handoff prompt interpolates {{sweep_failure}} with a
PIPELINE-FAULT VISIBILITY instruction mirroring the review_report
treatment (fault -> Deviations entry + step_summary; success/empty
ignored)
- tests: note_llm_fault fault/clean/crash/truncation paths via the
GRAPH_STATE harness; wiring test pins the retargeted fallbacks,
note_llm_fault routing, {{fault_note}} in end_failure, and the
sweep-fault prompt/instruction anchors; warning baseline unchanged
finding-verifier: parse gains max_attempts 2 + parse_failure capture +
fallback to a new parse_fault marker that rewrites verdicts to a single
UNVERIFIABLE PIPELINE-FAULT entry and continues to done (sentinel
preserved — the caller's verify lane degrades instead of dying);
verify_one gains a branch-local verify_fault marker normalizing the
engine failure text into a schema-conformant UNVERIFIABLE verdict.
step-runner: implement gains fallback end_failure (no retry — reruns
against a mutated tree are unsafe; STEP_FAILED renders the failure via
coder_result); independent_review gains fallback write_handoff with the
handoff instructions and route_review treating an "Agent node failed:"
review_report as review-did-not-run (fail-visible, guard ahead of the
🔴 grep); orient/edge_case_sweep/write_handoff capture their failure
text via state_updates, shrinking the bundled fallback-capture warning
baseline back to zero.
R5 tests: fault-marker normalization + crash paths (raw non-JSON
GRAPH_STATE), route_review.sh fault/critical/clean routing via bash+jq,
and structural wiring tests pinning fallbacks, sentinels, and
end_failure's initial_state-backed template keys.