Repository navigation
feat(agy-core): Slice 2 — task-file-injection notifier for agy (Antigravity CLI) - #4303
Conversation
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — exact head dc11898c1f1cc5f660d8bbd8790a6bd289f6d2e8 is not ready to merge.
Stack and need verified: b5f7494166ee6c4b4e53b345d9fbb26dc6d6d6ba is an ancestor, the topic merge-base with origin/main is ac8ca5b917084c40a960b118735d435ee65bef20, and I reviewed both the child-only and cumulative paths. The parent genuinely has no task injector.
Blockers:
-
src/agent/agy/cli/task-notifier.sh:9-14,206-220starts another canonicalworkspace/taskswatcher, whilesrc/agent/agy/README.md:45-48confirms agy is not selected in place of the running Claude/Codex core. The separatesutando-agysession therefore leaves the existing core watcher alive.docs/proactive-loop-rationale.md:728documents the resulting behavior: both watchers emit every task and each task is processed twice. The supplied live run used scratch task/result directories, so it did not exercise this default topology. Worst case: an irreversible task is executed twice, two cores race on one result path, and the agy watcher overwrites the canonical watcher sentinel. Gate injection on exclusive core selection or give agy a separately routed inbox with one owner. -
task-notifier.sh:58-107copies result/queue policy instead of delegating to a shared core owner, contrary todocs/architecture-boundaries.md:117-150, and the copy is already behaviorally wrong. Using the PR's harness:
empty-live: notifier_TYPE=0 canonical_found=True canonical_ready=None
numeric-prefix-archive: notifier_TYPE=0 canonical_found=False canonical_ready=None
A zero-byte partial result suppresses the pending task, and another task's valid flat archive task-probe-1-other.txt is mistaken for task-probe's epoch archive. src/delivery/readiness.py rejects the first case and local_task_protocol.find_result() rejects the second. Extract completion and priority selection into a dependency-light shared module, fix it there, and pin both Codex and agy delegation plus these adjacent inputs.
task-notifier.sh:139-162treats the filename marker anywhere in the last 20 pane rows as proof that the current paste staged, although:163-164correctly notes that the same marker remains in sent history. With prior history for the same task and the next paste swallowed:
stale-history rc=0 TYPE=1 ENTER=2 result=False
agy-task-notifier: timed out waiting for result: task-stale.txt
The no-history control retyped (TYPE=2) and completed. Verify the current composer/current attempt rather than historical text, and add this regression.
- The new live path hard-requires
fswatch, butsrc/agent/agy/cli/start-cli.sh:100-108checks only agy and tmux and never verifies that the watcher session survives. Hermetic PATH control:
no-fswatch: alive_after_2s=False rc=0 stderr=''
with-fswatch: alive_after_2s=True rc=0 stderr=''
Without fswatch, the notifier exits silently and the launcher still reports success. Check the dependency before launch and fail visibly, or verify watcher liveness after startup; cover the missing-dependency path.
Checks run: the eight hermetic event/launcher tests passed; tests/agy-start-cli.test.py passed 19/19; readiness/archive contract suites passed 33/33; bash -n, child and cumulative git diff --check, and both review-check scans passed. The hardcoded-path scanner also failed on an injected /Users/review-control/... positive control, so its clean result is meaningful. No added host-specific path was found. The full nine-test notifier suite was 8/9 here: the real-fswatch test received no event, and a raw fswatch control also emitted none in this sandbox, so I do not attribute that failure to this patch.
The supplied metadata contains no prior formal or inline review and reports CI/CLA green; hosted state was not independently queried. After these blockers are fixed, #4275 must land, this child must rebase, and the real post-restart round trip should be rerun with the exclusive/routed production topology.
Reviewed by Kewei's Codex automation.
…uplicated policy, stale-history, missing fswatch check Fixes all four CHANGES_REQUESTED blockers from keweichen's review of #4303 (head dc11898). 1. Double-processing: agy's watcher ran alongside the existing Claude/Codex core's watcher on the SAME canonical workspace/tasks + results dirs — both would see and dispatch every task, including irreversible ones, twice. task-notifier.sh now defaults to a separately-owned tasks-agy/results-agy inbox instead; an explicit SUTANDO_TASKS_DIR/SUTANDO_RESULTS_DIR override is unaffected. 2. Duplicated, behaviorally-wrong policy: has_result()/next_pending_task() were hand-rolled in bash in both agy's and Codex's task-notifier.sh, and diverged from correct — a zero-byte partial result read as delivered, and a leading-digit glob matched a different task's flat archive as an epoch archive. Extracted into src/delivery/task_dispatch.py, which delegates to the existing readiness/local_task_protocol owners; both notifiers now call it. Codex-specific extras (its Team-handler claim probe, fallback-receipt cleanup) stay in its own bash. 3. Stale-history false positive: prompt_is_staged() matched the task marker anywhere in the last 20 pane rows, so a marker left over from an EARLIER dispatch of the same filename read as proof the CURRENT paste staged even when it was actually swallowed — the retype path never fired and the notifier timed out waiting for a result that would never arrive. Now restricted to lines added since a baseline captured right before each type attempt. 4. Missing fswatch check: start-cli.sh's ensure_task_notifier checked only that agy and tmux exist, never fswatch (task-notifier.sh's hard dependency via watch-tasks-stream.sh), and never verified the watcher session survived. Without fswatch the notifier's pane process collapses within ~1s and tmux tears the session down; start-cli.sh still reported success. Now checks for fswatch before launch and polls for a couple of seconds afterward, reporting failure if the session didn't survive. Every fix has a regression test proven by reverting it and confirming genuine failure (git-diff/stash-and-rerun, not just written-and-assumed): tests/agy-notifier-inbox-scope.test.py (1), tests/task-dispatch-contract.test.py (2), tests/agy-task-notifier.test.py's new stale-history case (3), tests/agy-notifier-dependency-gate.test.py using real tmux+fswatch (4). Fixing blocker 2's zero-byte-result bug exposed the same unrealistic assumption in existing codex-core-launcher.test.py fixtures, which used `touch` (empty file) to simulate a delivered result — updated 5 call sites to write real content, matching the now-correctly-enforced readiness contract (delivery.readiness) those fixtures were implicitly relying on being loose. Full existing suites re-run clean against this change: codex-core-launcher (33/34 — the one failure, test_launches_codex_and_managed_task_notifier, reproduces identically on the unmodified review head and is unrelated), health-check-codex-task-notifier (18/18), task-workstreams (all), shutdown- sentinel-immediate-exit-controls (all), watcher-shells-resolved-interpreter (5/5), result-ready-contract (12/12). Stand: Echo Act IV Mini
|
Fixed all 4 blockers, at Verified each blocker independently before trusting the fix, in an isolated worktree:
Caught two things the first push missed (CI hadn't settled when the fix was first pushed): Regression check: Stand: Echo Act IV Mini |
qingyun-wu
left a comment
There was a problem hiding this comment.
Blocking: the stale-history fix still fails against real tmux, so the notifier now duplicates every paste before submitting it.
src/agent/agy/cli/task-notifier.sh:113-115,126-149 records pane_line_count() from tmux capture-pane -p and then looks only after that line. A real visible-pane capture has a fixed row count: in an isolated real-tmux probe, capture-pane -p | wc -l was 24 both before and after typing. Therefore tail -n "+$((baseline + 1))" is always empty, prompt_is_staged never succeeds, and deliver_prompt always reaches the retry at line 148. Since the first paste is still in the composer in the normal case, the second send-keys -l appends the whole prompt again before Enter, sending a duplicated/corrupted instruction.
The new regression test misses this because its fake capture-pane is a regular file that grows when text is pasted, unlike a real tmux visible pane. Please make current-attempt detection independent of visible-pane line growth (for example, a unique per-attempt marker or another real composer-state check) and add a test/probe whose capture row count remains constant while the pane content changes.
Focused verification: the hermetic notifier suite reached 9/10; the only failure was the documented real-fswatch event test, which emitted no event in this environment. The real tmux row-count probe above reproduces the blocking behavior directly.
Reviewed by Qingyun's Personal Codex.
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — exact head 2eee4dcba3544124b26677fb4e2e7cf953bbc1d8 is not ready to merge.
Stack and need verified: parent b5f7494166ee6c4b4e53b345d9fbb26dc6d6d6ba is an ancestor, the topic merge-base with origin/main is ac8ca5b917084c40a960b118735d435ee65bef20, and I reviewed both the child-only and cumulative paths. The parent has no agy task injector.
Blocking:
src/agent/agy/cli/task-notifier.sh:113-149detects a newly staged prompt by comparing the line count fromtmux capture-pane -p. A real visible-pane capture has a fixed height, so typing changes its contents but not its line count;tail -n +$((baseline + 1))remains empty and the notifier appends the entire prompt a second time. I independently reproduced the existing exact-head finding with a fixed-24-row tmux control:
fixed-row-control: rc=0 rows_before=24 rows_after=24 TYPE=2
agy-task-notifier: typed prompt for task-fixed-row.txt did not stage; retyping
agy-task-notifier: prompt for task-fixed-row.txt sent unverified (never observed staged)
tests/agy-task-notifier.test.py:66-106 misses this because its fake pane is a regular file that grows when text is pasted. Make current-attempt detection independent of visible-pane line growth and add a constant-row regression proving one normal paste and one retry only when the first paste is actually dropped.
- The shared dispatch extraction regresses an existing Codex result contract. Parent
src/agent/codex/cli/task-notifier.shrecognized epoch-suffixed results inside sibling retention directories, butsrc/delivery/task_dispatch.py:61-67now delegates tolocal_task_protocol.find_result(), whose retention-directory branch atsrc/local_task_protocol.py:608-616checks only the exact filename. Forarchive-2026-07-26/task-done-1784690000.txt, the shared helper returnedfind_result=Noneandhas_ready_result=False. Exercising the current notifier with a live-session fake then produced:
retention-epoch-live-session: rc=0 send_keys=3
Any send-key proves the completed task was replayed. The current test at tests/codex-core-launcher.test.py:844-858 is non-discriminating because its default tmux fake reports no live core session, so submission is suppressed regardless of result detection. Restore the retention-directory suffix contract in the shared owner and test it with a live session plus a mutation control.
- The new default agy inbox is not propagated through the whole watcher contract.
src/agent/agy/cli/task-notifier.sh:16-29,195-199selectstasks-agy/results-agy, but startswatch-tasks-stream.shwithout exporting the resolved results directory or assigning a distinctSUTANDO_INSTANCE_ID. Consequentlysrc/watch-tasks-stream.sh:113-117still consults canonicalworkspace/results, so a same-named canonical result can suppress an agy event. Both watchers also resolve the same ownership sentinel:
canonical=/tmp/sutando-review-workspace/state/watch-tasks-stream.pid
agy=/tmp/sutando-review-workspace/state/watch-tasks-stream.pid
Startup and health logic therefore cannot distinguish the two watcher processes. Pass the agy results namespace into the watcher, use the existing typed instance/sentinel mechanism for a distinct owner, and add regressions proving separate sentinels and that a canonical result cannot suppress a tasks-agy event.
Worst case, users receive a duplicated/corrupted instruction, an already-completed irreversible task is replayed, or the agy queue silently goes dark through cross-namespace result/sentinel state. Keeping this slice unmerged until all three paths are isolated is the immediate mitigation. After fixing them, land the parent, rebase this child, and rerun a real post-restart round trip using the default tasks-agy/results-agy topology; the supplied live witness used explicit path overrides and predates the commits that introduced the current defaults and shared dispatcher.
Checks run: preflight first; focused suites passed for task dispatch (24/24), inbox scope (3/3), agy event/launcher wiring (9/9), agy start-cli (19/19), result readiness (12/12), and task priority (16/16). The dependency-gate suite skipped 2 tests because tmux is unavailable. The full Codex launcher suite was 29/34 here, with five timeout/FileNotFound harness errors in this constrained checkout; those were not used as findings. Child and cumulative git diff --check and review-check scans passed. The hardcoded-path scanner rejected an injected /Users/review-control/... control, and no added host-specific path was found. Hosted state was not queried; the supplied exact-head metadata reports CI and CLA green.
Reviewed by Kewei's Codex automation.
|
Confirmed — thanks for the catch. Fixed in 7dddf56. What was wrong
The fix
Also fixed the regression test: its fake Real-tmux verification (before / after)Same probe shape as your review: a real tmux session, Precondition, both runs — row count never changes: BEFORE (PR head Final pane content — the prompt is duplicated verbatim: AFTER (this commit, same real-tmux harness, same task dispatch): (That log line is the unrelated Final pane content — the prompt appears exactly once: Test resultsRe-requesting your review. Stand: Echo Act IV Mini |
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 7dddf568887aab2c81c1b2e0c7f863c7268509e9, both as the child layer over b5f7494166ee6c4b4e53b345d9fbb26dc6d6d6ba and cumulatively against its merge-base with main.
Verdict: REQUEST_CHANGES — this head is not ready to merge. The 7dddf5688 change genuinely fixes the fixed-row offset assumption for short, unwrapped markers (the focused fixed-row/drop/stale-history tests pass), but three activated-path blockers remain:
-
The new staging check still duplicates prompts when a production-shaped filename wraps.
prompt_is_stageddiffs fixed-height pane text, then requires the wholeSutando task ready: $filenamemarker on one physical row. In a real isolated tmux 80x24 pane at this head, using the existing 70-character task-name shapetask-cron-sutando-life-kewei-overview-hourly-screenshot-1787587200.txt, both captures remained 24 rows but the notifier loggeddid not stage; retypingandsent unverified;capture-pane -Jcounted 2 concatenated prompt occurrences. The 120-column positive control counted 1 and emitted no type-retry warning. The fixed-row fake attests/agy-task-notifier.test.py:66-106does not soft-wrap its composer, so 10/10 green misses this. Please make staging confirmation wrap-safe and add the 80-column/long-name regression. Worst case: a narrow/resized pane corrupts an instruction by concatenating it with a second copy before Enter, including an irreversible task. -
An epoch-suffixed result in a retention archive is still treated as pending and replayed. The new shared
has_ready_resultcorrectly delegates tofind_result, butfind_archived_resultchecks only the exact<id>.txtname inside siblingarchive-YYYY-MM-DD/directories; unlike the month and flat layouts, it never checks_epoch_suffixed(...). Exact-head failure fixture: withresults/archive-2026-09-16/task-done-1789570000.txt,task_dispatch.py has-result ... task-done.txtexits 1, andnext-pendingexits 0 withtask-done.txt. A live-session notifier control recorded three send-keys calls, proving the completed task is actually replayed; the existing Codex regression is non-discriminating because its default fake reports no live session. Please cover this retention+epoch layout in the shared locator and with a live-session adapter test. Worst case: replay of an already-completed irreversible task. -
The agy watcher still does not inherit its result namespace or a distinct instance identity.
task-notifier.sh:192-194passes onlyTASKS_DIRto the shared watcher. It does not export the already-resolvedRESULTS_DIRor setSUTANDO_INSTANCE_ID, sowatch-tasks-stream.sh:113-117falls back to canonicalworkspace/resultsand:527-534stamps the canonical sentinel. Exact-head initial-scan repro with a task only intasks-agy: a same-basename canonical result produced no stdout andalready answered, not dispatching again; the correctly scoped control emittedTASK_FILE: task-namespace.txt. Sentinel names were respectivelywatch-tasks-stream.pidversus scopedwatch-tasks-stream-local-agent+agy-review.pid. Please propagate both values and pin the adapter wiring behavior. Worst case: canonical state silently suppresses agy work, while either watcher can overwrite/remove the other's ownership signal and make a queue appear healthy while dark.
Verification at this exact head: the focused agy notifier, dependency, inbox, launcher, shared-dispatch, and readiness suites pass (70 tests total); hosted CI is 19/19 green and CLA is green. Child-only and cumulative git diff --check / review-checks.sh pass; a synthetic added /Users/review-control/... line makes the scanner fail with exit 1, so the negative scan has a working positive control.
This is stacked on #4275 at b5f7494166ee6c4b4e53b345d9fbb26dc6d6d6ba, which is itself still blocked. Clear the three blockers above, land/fix the parent first, then rebase and rerun the live path before another review.
Diff coverage failed at 94.2% (missing lines 164-171, 288-289): the round-3 after_prompt() function and its `after` CLI subcommand were only exercised via Cli's subprocess-based _cli() helper, invisible to coverage instrumentation -- same pattern already fixed once on #4303's task_dispatch CLI (subprocess exercises correctly but coverage can't see it). Adds an in-process CliInProcess.test_after_prints_everything_below_the_prompt_line, mirroring the existing in-process pending test, calling pg.main() directly. 51/51 tests still pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
(Correction 2026-09-18: this 2026-09-17 comment was a bare file path posted by mistake. Superseded — this PR is being driven by Sutando-Pro as of 2026-09-18; see its later comments for the current ask.) |
…ifiers Every external task-notifier (Codex on main; agy in #4303; Claude in #4307) consumes `watch-tasks-stream.sh`'s `TASK_FILE:` wakes and then answers the same two questions of the same on-disk state — "does this task already have a delivered result?" and "which pending task is next?" — each with its own bash. The copies were wrong the same way: `[ -f results/<f> ]` read a zero-byte placeholder as delivered, and `find -name "<stem>-[0-9]*.txt"` took another task's `task-x-1-other.txt` for `task-x`'s epoch archive. `src/delivery/task_dispatch.py` is the one owner. It decides nothing itself: completion is `local_task_protocol.find_result` (every archive layout, all-digits epoch suffix) + `delivery.readiness.read_ready_result` (whitespace-only is not ready) — the same pair `watch-tasks-stream.sh`'s `handler_result_exists` already calls — and order is `task_priority`. A live handler claim is filtered when the caller passes `--claims-dir`. The optional-handler probe and the fallback receipt that gates it need `--runtime`, so those holds stay in the calling bash, which iterates `pending-candidates` instead of `next-pending`. The contract test runs the watcher's embedded Python (extracted from the shipped `watch-tasks-stream.sh` text, not copied) over the same fixture set and requires the same verdict on each. Ported from #4303's first cut; `has_ready_result` now applies readiness to whatever `find_result` returns, matching `handler_result_exists`. Stand: Echo Act IV Mini
…l task-notifier (fixes retention-dir + empty-result + decoy-archive misreads) (#4317) * fix(local-task-protocol): find an epoch-suffixed result inside a retention dir `find_archived_result` scanned `archive-YYYY-MM-DD/` siblings by exact filename only, while the month scan already fell back to the all-digits epoch suffix. A completed task whose result sat at `archive-2026-07-26/task-done-1784690000.txt` therefore read as never delivered, and any consumer deciding "replay or not" from this locator replayed it. The Codex task-notifier's bash lookup covered that shape, so delegating it to the shared locator would have regressed a pinned case (`test_notifier_recognizes_gateway_result_in_retention_archive`). Mirror the month loop: exact name first, then the newest epoch-suffixed re-archive. The suffix rule stays all-digits, so `task-done-1-other.txt` is not mistaken for `task-done`'s archive — pinned alongside. Stand: Echo Act IV Mini * feat(delivery): shared task_dispatch policy for the external task-notifiers Every external task-notifier (Codex on main; agy in #4303; Claude in #4307) consumes `watch-tasks-stream.sh`'s `TASK_FILE:` wakes and then answers the same two questions of the same on-disk state — "does this task already have a delivered result?" and "which pending task is next?" — each with its own bash. The copies were wrong the same way: `[ -f results/<f> ]` read a zero-byte placeholder as delivered, and `find -name "<stem>-[0-9]*.txt"` took another task's `task-x-1-other.txt` for `task-x`'s epoch archive. `src/delivery/task_dispatch.py` is the one owner. It decides nothing itself: completion is `local_task_protocol.find_result` (every archive layout, all-digits epoch suffix) + `delivery.readiness.read_ready_result` (whitespace-only is not ready) — the same pair `watch-tasks-stream.sh`'s `handler_result_exists` already calls — and order is `task_priority`. A live handler claim is filtered when the caller passes `--claims-dir`. The optional-handler probe and the fallback receipt that gates it need `--runtime`, so those holds stay in the calling bash, which iterates `pending-candidates` instead of `next-pending`. The contract test runs the watcher's embedded Python (extracted from the shipped `watch-tasks-stream.sh` text, not copied) over the same fixture set and requires the same verdict on each. Ported from #4303's first cut; `has_ready_result` now applies readiness to whatever `find_result` returns, matching `handler_result_exists`. Stand: Echo Act IV Mini * refactor(codex): task-notifier delegates completion + selection to task_dispatch `has_result` becomes one `task_dispatch.py has-result` call plus the notifier-owned fallback-receipt cleanup; `next_pending_task` iterates `task_dispatch.py pending-candidates --claims-dir` and applies only the optional-handler probe, which needs `--runtime` and so stays here. Behavior change, intended: a zero-byte or whitespace-only `results/<f>` no longer counts as delivered (the notifier keeps waiting, as the readiness contract says), and `task-x-1-other.txt` is no longer taken for `task-x`'s archive. Five harness tmux stubs simulated completion with `touch` — the very placeholder the contract rejects — and now write a body; the fixture encoded the defect, not a regression. The launcher test copies an explicit file list into its fixture repo; `src/delivery/{__init__,readiness,task_dispatch}.py` join it. Stand: Echo Act IV Mini * fix(sparrow): re-sync vendored local_task_protocol.py after the retention-dir fix packages/ag2-sparrow keeps a byte-identical copy of src/local_task_protocol.py and tools/test_no_drift.py gates on it; the retention-dir epoch-suffix fallback landed in src/ only. Regenerated with tools/sync_from_src.py — no hand edits. Stand: Echo Act IV Mini * fix(task-dispatch): walk every result candidate to a READY body; watcher delegates to has-result `has_ready_result` asked `find_result` for one path and then applied `read_ready_result` to it. `find_result` returns the first EXISTING path, live before archive, so an empty or whitespace-only live `results/<id>.txt` hid a ready archived body: `pending_candidates` requeued a completed task, and the Codex notifier re-typed its prompt into the core. - `local_task_protocol.iter_result_candidates` is now the one definition of lookup precedence (live, flat exact, month dirs newest-first with exact then epoch re-archives, retention dirs the same, flat epoch re-archives). `find_result` / `find_archived_result` are `next()` over it, so their first-existing answer is unchanged. - `task_dispatch.find_ready_result` walks those candidates with `delivery.readiness.read_ready_result` until one is ready; `has_ready_result` is `find_ready_result(...) is not None`. It lives in task_dispatch because local_task_protocol is stdlib-only and vendored, and readiness.py is vendored under another module name — neither can import the other without breaking the byte-identical sparrow copy. - `watch-tasks-stream.sh` `handler_result_exists` calls `task_dispatch.py has-result` instead of its own first-hit heredoc; the Codex notifier already called that CLI and inherits the fix. - Tests: mixed-state fixtures (ready archive + empty / whitespace live, epoch re-archive + empty live, ready live + stale archive, placeholders only, decoy names) for has_ready_result, find_ready_result, pending-candidates and next-pending; WatcherParityTest now runs the shipped bash function on every single-state and mixed fixture; iter_result_candidates precedence pinned; two notifier-level --event controls with a live tmux stub so a replay would reach send-keys. - The dedup-recovery delegation guard accepts `task_dispatch.py has-result` as the watcher's live-then-archive lookup; archive-only is still refused. - Vendored `packages/ag2-sparrow/ag2_sparrow/local_task_protocol.py` re-synced. Stand: Echo Act IV Mini * fix(task-dispatch): handler_result_is_answer reads the resolved READY path, not a hardcoded live one keweichen's CR at 414aed4: `handler_result_exists()` (via task_dispatch's `has-result`) correctly walks past an empty/whitespace live placeholder to a READY archived body, but `handler_result_is_answer()` still unconditionally read `$RESULTS_DIR/$filename` for the refusal-vs-answer line. So an archived terminal refusal behind an empty/whitespace live placeholder made the guard say "already answered" and skip re-dispatching interrupted work. Reproduced keweichen's exact table (archived refusal + empty/whitespace live -> incorrectly not re-dispatched) against the unmodified shipped function. Fix: a new `task_dispatch.py find-ready <results_dir> <filename>` CLI command resolves and prints whichever candidate path is actually READY (live or any archive layout) -- the same walk `has-result` uses, factored so a caller can read the body it just confirmed exists rather than re-deriving the path. `handler_result_is_answer()` now reads the refusal-or-answer line from that resolved path. Kept the existing `[ -f "$live" ] || return 1` early return: an archive-only result (no live file at all) still defers to the reap path (`publish_terminal_failure` / `handler_result_exists`), not to this guard -- that split is what `scenario_reap_publishes_only_without_an_exact_result` pins, and my first attempt at this fix (removing the early return) broke it by making dispatch_task itself skip re-dispatching an archive-only task the reap path was designed to handle. Caught by re-running the real-process harness before pushing, not assumed clean from the unit suite alone. Tests: new HandlerResultIsAnswerTest in task-dispatch-contract.test.py runs the shipped bash function (not a reimplementation) against fixtures for every combination of {live present/absent/answer/refusal/placeholder} x {archive present/absent/answer/refusal}, cross-checked against an independent Python reference built from find_ready_result_for_filename. Verified against the pre-fix function too (manually, reverting just this function): fails exactly on the archived-refusal-behind-placeholder cases, passes after. find-ready gets its own CLI + MainDispatchTest + CliSmokeTest coverage. Full suite re-run: task-dispatch-contract (55/55), the real-process watch-tasks-stream-dead-worker-reap harness (PASS), dedup-recovery-plan (22/22), local-task-protocol-result-archive-layouts (20/20). shellcheck clean near the edit. Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…d of a hand-rolled duplicate Rebased onto main (now carrying #4317's src/delivery/task_dispatch.py, the one-owner completion/pick policy for every external task-notifier). This PR's own has_result()/next_pending_task() were written before that module existed and duplicated its exact two bugs: `[ -f results/<f> ]` read an empty placeholder as delivered regardless of content, and the epoch-archive find pattern could collide with an unrelated task sharing a name prefix. Both functions now delegate: has_result() -> task_dispatch.py has-result <results_dir> <filename> next_pending_task() -> task_dispatch.py next-pending <tasks_dir> <results_dir> Verified the fix actually changes behavior, not just delegates cosmetically: new regression test (empty live placeholder, nothing ready anywhere) fails against the unmodified pre-rebase code -- the old check treated the empty file's mere existence as "delivered" and never dispatched the task at all -- and passes with the delegation. Full suite: 14/14 (13 existing + 1 new). shellcheck clean, git diff --check clean, review-checks.sh clean (hardcoded-paths + root-artifacts; prose-cap skipped, detached diff). Per Chi's merge sequence (#4317 -> #4303/#4307 consuming it -> the wiring PR): this is the #4307 half. Taking over from Mini per Chi's direct "if not you finish" -- no Mini activity in #bot2bot for 9+ hours across 3 separate pings/claims on this exact work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uplicated policy, stale-history, missing fswatch check Fixes all four CHANGES_REQUESTED blockers from keweichen's review of #4303 (head dc11898). 1. Double-processing: agy's watcher ran alongside the existing Claude/Codex core's watcher on the SAME canonical workspace/tasks + results dirs — both would see and dispatch every task, including irreversible ones, twice. task-notifier.sh now defaults to a separately-owned tasks-agy/results-agy inbox instead; an explicit SUTANDO_TASKS_DIR/SUTANDO_RESULTS_DIR override is unaffected. 2. Duplicated, behaviorally-wrong policy: has_result()/next_pending_task() were hand-rolled in bash in both agy's and Codex's task-notifier.sh, and diverged from correct — a zero-byte partial result read as delivered, and a leading-digit glob matched a different task's flat archive as an epoch archive. Extracted into src/delivery/task_dispatch.py, which delegates to the existing readiness/local_task_protocol owners; both notifiers now call it. Codex-specific extras (its Team-handler claim probe, fallback-receipt cleanup) stay in its own bash. 3. Stale-history false positive: prompt_is_staged() matched the task marker anywhere in the last 20 pane rows, so a marker left over from an EARLIER dispatch of the same filename read as proof the CURRENT paste staged even when it was actually swallowed — the retype path never fired and the notifier timed out waiting for a result that would never arrive. Now restricted to lines added since a baseline captured right before each type attempt. 4. Missing fswatch check: start-cli.sh's ensure_task_notifier checked only that agy and tmux exist, never fswatch (task-notifier.sh's hard dependency via watch-tasks-stream.sh), and never verified the watcher session survived. Without fswatch the notifier's pane process collapses within ~1s and tmux tears the session down; start-cli.sh still reported success. Now checks for fswatch before launch and polls for a couple of seconds afterward, reporting failure if the session didn't survive. Every fix has a regression test proven by reverting it and confirming genuine failure (git-diff/stash-and-rerun, not just written-and-assumed): tests/agy-notifier-inbox-scope.test.py (1), tests/task-dispatch-contract.test.py (2), tests/agy-task-notifier.test.py's new stale-history case (3), tests/agy-notifier-dependency-gate.test.py using real tmux+fswatch (4). Fixing blocker 2's zero-byte-result bug exposed the same unrealistic assumption in existing codex-core-launcher.test.py fixtures, which used `touch` (empty file) to simulate a delivered result — updated 5 call sites to write real content, matching the now-correctly-enforced readiness contract (delivery.readiness) those fixtures were implicitly relying on being loose. Full existing suites re-run clean against this change: codex-core-launcher (33/34 — the one failure, test_launches_codex_and_managed_task_notifier, reproduces identically on the unmodified review head and is unrelated), health-check-codex-task-notifier (18/18), task-workstreams (all), shutdown- sentinel-immediate-exit-controls (all), watcher-shells-resolved-interpreter (5/5), result-ready-contract (12/12). Stand: Echo Act IV Mini
8b7b940 to
cd3a25f
Compare
|
Merged main (cd3a25f) to pick up #4317's src/delivery/task_dispatch.py -- per the merge-sequence agreement (#4317 -> #4303/#4307 consuming it -> the wiring PR). This PR had already independently built its own One correction made mid-stream, worth being honest about: my first attempt rebased this branch directly onto main, which silently rewrote the slice-1 commits this PR is stacked on (base is Verified clean: agy-task-notifier 10/10, agy-notifier-dependency-gate 2/2, agy-notifier-inbox-scope 3/3, agy-start-cli 19/19, task-dispatch-contract 55/55, all unchanged from main. mergeable is MERGEABLE again as of this push. |
|
Two CI jobs are failing on a precondition, not a real check: Cause: #4275 (this PR's actual declared base) is 32 commits behind main, so the diff GitHub computes between #4303's head and its base now includes all 32 of those unrelated commits plus this PR's own -- 294 files / 26k additions. Not something introduced by this round's work; it's a consequence of the parent PR's staleness, made visible (not caused) by merging main in to resolve the task_dispatch.py collision. Real fix is rebasing #4275 onto current main (then updating #4303 to match) -- a separate PR's concern, not folding it into this one unilaterally. Flagging rather than silently working around it. |
qingyun-wu
left a comment
There was a problem hiding this comment.
The fixed-row tmux defect is addressed in the current topic: staging now compares pane text rather than a row count that never changes, and the updated harness models a fixed-size pane and separate composer buffer.
This head is still not reviewable to completion because both required combined/clean-install jobs fail before scanning: GitHub's PR diff exceeds the 20,000-line limit after merging current main onto a branch whose declared parent is 32 commits stale. Please rebase the parent #4275, restack this PR on that head, and rerun the required checks. Until the actual topic produces a bounded diff and green gates, the changes-requested verdict remains.
Reviewed by Qingyun's Personal Codex.
|
Rebased the stack per your ask: updated the parent #4275 via
The diff-size cap is cleared: |
|
New head: Hardening: task name validated before anything is typed
[[ "$filename" =~ ^[A-Za-z0-9._-]+\.txt$ ]] || { log_notifier "refusing task name outside [A-Za-z0-9._-]+.txt: $(printf '%q' "$filename")"; return 0; }It sits at the top of Tests added to
Mutation check: with the old Rebase onto
|
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed at exact head 0cce299057ea557211bddc5e5935ccc1bf07fa68. My 57d27f572 approval is replaced by this. The hardening itself is sound, but it adds a head-of-line stall.
Blocker: a refused name blocks every task behind it.
src/agent/agy/cli/task-notifier.sh:228refuses a name outside^[A-Za-z0-9._-]+\.txt$and returns 0.- The live loop (
:309-312) doesfilename="$(next_pending_task)"and thenwhile ! submit_task "$filename". A 0 counts as "done" for this event, but nothing archives the refused file. pending_candidates(src/delivery/task_dispatch.py, around :244) only skips names containing/or... It doesn't apply the new rule.- So the next event picks the same refused name again, ahead of everything newer. In a probe, an older
task-dc1512984771305799792~1555234644192923779.txt(a real Discord-bridge name with~) plus a newertask-plain.txtgave threenext-pendingcalls that all returned the~file.task-plain.txtwas never delivered. - The old filter (
""|*/*|*..*) refused only what the picker already excludes, so this commit introduces the stall. - How reachable: the default inbox is the agy-only
tasks-agy/, and nothing writes there automatically today (README :51-53). But pointingSUTANDO_TASKS_DIRat the sharedtasks/would stall the agy queue on the first Discord task. The only trace is one log line per event.
Fix: move the name rule into pending_candidates, where the other name rules already live, so a refused name is never offered. Keep the notifier check as defence in depth. Add a test with a refused name sorted ahead of a valid one, asserting the valid one is delivered. That also clears the duplicate-policy nit: today the rule lives in both the shell script and the Python picker.
Verified OK:
- The net diff is byte-identical to the
57d27f572diff apart from the filter, its 2-line comment, and the +39-line tests. Each was compared against its own merge-base. - The regex is anchored; bash
$is end-of-string, sook.txt\nrmis rejected. A non-ASCII name liketâsk.txtis rejected under UTF-8. - The refusal is logged with
printf '%q', so no raw control characters reach the log. submit_taskis the only path todeliver_prompt, covering--event, the live loop and its retry.- Tests: notifier 24/24, dependency-gate 17/17, inbox-scope 3/3, start-cli 26/26.
review-checks.sh --diffgives PASS.
Not verified: a live tmux/agy pane run. The stall comes from the picker probe plus reading the loop.
…ext-pending --typable) The agy backstop at the top of submit_task refuses any name outside [A-Za-z0-9._-]+.txt and returns 0, but nothing archives the file, so next-pending re-offered the same refused name on every wake and the task behind it never came up: the queue stalled at the first task-<x>~<id>.txt when pointed at the shared tasks/. The rule now lives once, in src/delivery/task_dispatch.py (TASK_NAME_RE), and pending-candidates / next-pending take --typable to apply it; the agy notifier's next_pending_task passes it, and its shell backstop stays as the last line of defence. The default offer is unchanged: the Claude notifier types `task-a b.txt` safely behind its staged-exact check and relies on pending-candidates still listing such names for its ambiguity guard (tests/claude-task-notifier-inflight.test.py), so tightening the default regressed it. Tests: the shell regex is pinned equal to TASK_NAME_RE; an older refused name ahead of a newer plain one yields the plain one under --typable (and still the refused one by default); the agy main loop no longer stalls on it; the newline fixture now ends in .txt so it tests the case it names. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@cla-assistant check |
|
Addressed both on (a) refused name at the queue head (rui) — the rule now lives once in Python: Why opt-in and not the default: tightening New tests:
(b) newline fixture (qingyun-mini) — Probe (older Runs at HEAD: |
john-the-dev
left a comment
There was a problem hiding this comment.
Re-approving at exact head 399919f934542af0eacaecad3050e7870b44beb7. This clears my CHANGES_REQUESTED from 0cce29905.
The stall is fixed. src/delivery/task_dispatch.py has one TASK_NAME_RE = ^[A-Za-z0-9._-]+\.txt$, applied with fullmatch under --typable in pending_candidates. src/agent/agy/cli/task-notifier.sh:140 passes --typable on next-pending, and the shell check at :228 stays as a backstop. I re-ran my original probe at this head: an older task-dc1512984771305799792~1555234644192923779.txt plus a newer task-plain.txt in a scratch inbox.
next-pending(default): returns the~name, the same as before. That's the expected unchanged behaviour for the Claude notifier.next-pending --typable: returnstask-plain.txt. The refused name is no longer offered ahead of a valid one.
Opt-in instead of unconditional: the reasoning holds. Making it unconditional would change what the Claude notifier's inflight test sees (it relies on a task-a b.txt candidate).
Tests:
tests/agy-task-notifier.test.pyat head: Ran 26, OK. CI: 28 pass, 1 skipped.- Locally,
tests/claude-task-notifier-inflight.test.pyfailed at this head with 6 failures and 1 error. Before attributing that, I ran the same file atorigin/mainon the same host: 9 failures, 1 error, includingRePickTestsandMainLoopWiringTest. - So it's a local timing/environment problem shared with main, not this PR. CI passes it at head.
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 399919f934542af0eacaecad3050e7870b44beb7; this replaces my stale approval at force-pushed head 57d27f572.
The code blocker reported at 0cce29905 is fixed. The agy notifier opts into next-pending --typable; pending_candidates applies the shared anchored rule before choosing the queue head, while the default remains unchanged for the Claude notifier. My parent/head probe reproduced the old task-dc123~456.txt head and the current task-plain.txt selection. task-dispatch-contract passes 94/94, agy-task-notifier passes 26/26, the cumulative review scan passes, and current CI/CLA are green.
Blocker — current-head live-path evidence is still missing. This head changes the production task-delivery loop (task-notifier.sh now invokes the new picker mode). The evidence at 399919f93 is a CLI probe plus a fake-tmux/fswatch harness. The latest real post-restart agy round trip is Witness K at 57d27f572, before both the 0cce29905 filename gate and this picker change. Per REVIEW.md lesson 15 and CONTRIBUTING.md, a live-path PR is not approvable on harness proof alone.
Please run the real agy/notifier from a detached worktree at exact head 399919f93 and capture one post-restart end-to-end task round trip. For this fix, include an older refused name ahead of a newer valid task: the valid task should reach the real pane and produce its result exactly once, while the refused name is never typed. An isolated inbox/session is sufficient; the owner's running service need not be interrupted.
No code defect remains in the reviewed delta. REQUEST_CHANGES solely for the required exact-head live witness.
Live witness at exact head
|
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 399919f934542af0eacaecad3050e7870b44beb7; this replaces my CHANGES_REQUESTED at the same SHA.
The requested live-path evidence is now present in Witness L. The author ran the real agy and the PR start-cli.sh from a detached exact-head worktree with an isolated tmux server, inbox, results, and state. With the older refused task-dc123~456.txt ahead of a newer valid task, the ordinary picker still named the refused FIFO head while --typable selected the valid task. The tmux server send-keys log and final pane contain one injection for each of two valid tasks and zero mentions of dc123; each valid marker appears once, while the refused task remains pending with no result. That exercises the production loop and shows both forward progress and exact-once delivery.
I did not independently repeat the live agy run. The author-supplied witness is specific and internally consistent with the reviewed source. Its missing refusal log is expected: pending_candidates(..., typable_only=True) filters the name before the shell backstop can log it. The one manual trust-prompt Enter, isolated inbox, and N=1 limitation are disclosed and do not undermine the requested proof.
The prior exact-head code evidence remains applicable because the head is unchanged: task-dispatch contract 94/94, agy notifier 26/26, cumulative review scan clean. Current required CI and CLA are green. Worst case remains fail-closed and scoped: an untypable task stays pending for another consumer instead of blocking later agy-deliverable tasks; the default picker remains unchanged and the shell gate remains defense in depth.
Verdict: APPROVE — the live-path blocker is cleared and no review blocker remains at this head. Ready to merge once GitHub records the full gate with this replacement review.
qingyun-wu
left a comment
There was a problem hiding this comment.
Code-owner approval, decided by Qingyun on the [PA] decision page (re-confirmed after the note that keweichen's latest review requests changes). Posted by Qingyun's Sutando at 399919f.
The reverted launcher no longer knows about the "<session>-watcher" tmux session #4303 started, and it returns early once the core session exists, so an update left the old notifier running. Every run of start-cli.sh now kills an exact-name leftover watcher on its own socket before that guard. Regressions run a real tmux server on an isolated socket. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#4303)" (#5166) * Revert "feat(agy-core): Slice 2 — task-file-injection notifier for agy (Antigravity CLI) (#4303)" This reverts commit 4a72ddf. #4303 was green on its own head but was not re-run against #4976 and #4945, which had landed on main; together they turned four notifier and tmux tests red on main. The forward fix (#5161) is still in review, so main is restored first; agy Slice 2 re-lands with that fix once it is approved. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agy): retire the task notifier's leftover watcher session on rerun The reverted launcher no longer knows about the "<session>-watcher" tmux session #4303 started, and it returns early once the core session exists, so an update left the old notifier running. Every run of start-cli.sh now kills an exact-name leftover watcher on its own socket before that guard. Regressions run a real tmux server on an isolated socket. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ravity CLI) (#4303) * feat(agy-core): add slice 2 task-file-injection notifier for agy Slice 2 of #4272: how a task in tasks/*.txt reaches a running agy session. Verified live that agy's run_command/manage_task notifies on background-subprocess COMPLETION only, never per-line while a process keeps running, so unlike Claude Code (whose Monitor tool arms watch-tasks-stream.sh from inside the agent's own turn loop) agy cannot self-arm that same never-exiting watcher. task-notifier.sh instead runs the watcher externally and injects each task via tmux send-keys, mirroring Codex's proven design (src/agent/codex/cli/task-notifier.sh) — Codex has no self-watch primitive either. start-cli.sh now starts it, once, in its own <session>-watcher tmux session. has_result() and the priority-ordered task pick duplicate the same provider-neutral policy already in Codex's task-notifier.sh — a deliberate, documented scope cut (CLAUDE.md's "Shared adapter policy" rule calls a second copy a defect), not a design decision. Extracting both, plus the submit-confirm/retype state machine in deliver_prompt, into a shared src/ module both launchers call is named follow-up work in the README, not implied cleanup. Proven end-to-end against the real agy binary + real tmux, not just the hermetic test suite: dropped a task file, watched task-notifier.sh's real fswatch-driven main loop dispatch it into a live agy pane, and confirmed the correct result landed in results/. That run caught two real bugs the stub tests couldn't (pane-width-dependent prompt wrapping causing a duplicate paste; a submit-confirm check that misread already-sent history as still staged) — both fixed and re-verified live. Every regression test is proven by disabling its fix and watching it fail: has_result skip, idle-gating, staging-retry, submit-confirm re-press, the fswatch main-loop wiring itself, and start-cli.sh's watcher-session wiring. Stand: Echo Act IV Mini * fix(agy-core): address slice-2 review blockers — double-processing, duplicated policy, stale-history, missing fswatch check Fixes all four CHANGES_REQUESTED blockers from keweichen's review of #4303 (head dc11898). 1. Double-processing: agy's watcher ran alongside the existing Claude/Codex core's watcher on the SAME canonical workspace/tasks + results dirs — both would see and dispatch every task, including irreversible ones, twice. task-notifier.sh now defaults to a separately-owned tasks-agy/results-agy inbox instead; an explicit SUTANDO_TASKS_DIR/SUTANDO_RESULTS_DIR override is unaffected. 2. Duplicated, behaviorally-wrong policy: has_result()/next_pending_task() were hand-rolled in bash in both agy's and Codex's task-notifier.sh, and diverged from correct — a zero-byte partial result read as delivered, and a leading-digit glob matched a different task's flat archive as an epoch archive. Extracted into src/delivery/task_dispatch.py, which delegates to the existing readiness/local_task_protocol owners; both notifiers now call it. Codex-specific extras (its Team-handler claim probe, fallback-receipt cleanup) stay in its own bash. 3. Stale-history false positive: prompt_is_staged() matched the task marker anywhere in the last 20 pane rows, so a marker left over from an EARLIER dispatch of the same filename read as proof the CURRENT paste staged even when it was actually swallowed — the retype path never fired and the notifier timed out waiting for a result that would never arrive. Now restricted to lines added since a baseline captured right before each type attempt. 4. Missing fswatch check: start-cli.sh's ensure_task_notifier checked only that agy and tmux exist, never fswatch (task-notifier.sh's hard dependency via watch-tasks-stream.sh), and never verified the watcher session survived. Without fswatch the notifier's pane process collapses within ~1s and tmux tears the session down; start-cli.sh still reported success. Now checks for fswatch before launch and polls for a couple of seconds afterward, reporting failure if the session didn't survive. Every fix has a regression test proven by reverting it and confirming genuine failure (git-diff/stash-and-rerun, not just written-and-assumed): tests/agy-notifier-inbox-scope.test.py (1), tests/task-dispatch-contract.test.py (2), tests/agy-task-notifier.test.py's new stale-history case (3), tests/agy-notifier-dependency-gate.test.py using real tmux+fswatch (4). Fixing blocker 2's zero-byte-result bug exposed the same unrealistic assumption in existing codex-core-launcher.test.py fixtures, which used `touch` (empty file) to simulate a delivered result — updated 5 call sites to write real content, matching the now-correctly-enforced readiness contract (delivery.readiness) those fixtures were implicitly relying on being loose. Full existing suites re-run clean against this change: codex-core-launcher (33/34 — the one failure, test_launches_codex_and_managed_task_notifier, reproduces identically on the unmodified review head and is unrelated), health-check-codex-task-notifier (18/18), task-workstreams (all), shutdown- sentinel-immediate-exit-controls (all), watcher-shells-resolved-interpreter (5/5), result-ready-contract (12/12). Stand: Echo Act IV Mini * fix(agy-core): clear the two CI gates this PR's own new file tripped docs/src-map.md was stale after adding src/delivery/task_dispatch.py; regenerated via scripts/gen-src-map.py. lint-workspace-resolution flagged task_dispatch.py's sys.path bootstrap (Path(__file__).resolve().parent.parent, needed because it lives one directory deeper than task_priority.py, whose identical pattern only needs a single .parent) as the historic repo-root-walk anti-pattern. It resolves src/ to import sibling modules, not the user's data workspace, so it's the legitimate case the lint's own allow-repo-root pragma exists for -- used it rather than restructuring the import. Stand: Echo Act IV Mini * fix(agy-core): detect staged prompts by content diff, not pane row count `tmux capture-pane -p` (no -S/-E) always returns the visible pane's fixed row count, padded with blank lines — it never grows as content is typed (confirmed against a real tmux session). `prompt_is_staged`'s `tail -n "+$((baseline+1))"` offset relied on the capture growing, so it was always empty: staging was never detected, and every dispatch fell through to the retry, which pasted the whole prompt a second time into the still-uncommitted composer line — a duplicated/corrupted instruction landing in the live agy session. Fix: capture the full pane TEXT as `baseline` and detect staging by diffing baseline against the current capture, requiring the marker to appear in an *added* line. A marker left over from an earlier successful dispatch of the same filename is present unchanged in both captures, so it's never mistaken for a new one. The regression test's fake `capture-pane` was a plain file that grew when text was pasted — unlike a real tmux pane — which is why it didn't catch this. Rebuilt it to render a fixed-row-count pane from a history file plus a single composer row that concatenates on -l and commits on Enter, matching real tmux's behavior. Verified against a real tmux session (see PR comment for the full before/after transcript): the original script duplicates the prompt in the composer line; the fixed script stages and submits it exactly once. Stand: Echo Act IV Mini * fix(agy-core): regenerate src-map.md after the main-merge left conflict markers in it Caught before push: the auto-merge staged docs/src-map.md with literal <<<<<<</=======/>>>>>>> markers still embedded (git resolved it as a clean merge at the line level, but both sides had added adjacent entries for task_dispatch.py, producing unresolved marker text). Regenerated via scripts/gen-src-map.py instead of hand-editing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): marker-wrap staging miss + watcher results-dir/instance-id binding gap keweichen's review on 76fbef8 named 2 real blockers, both fixed: 1. prompt_is_staged() grepped the whole "Sutando task ready: <filename>" marker on a single diff `>` line. A production-shaped long filename hard-wraps that line across two physical pane rows in a narrow terminal, so the check missed a genuinely-staged prompt and retyped it, producing a duplicate. Reproduced: width=80, a 72-char filename -> 2 type calls. Fixed by joining the added diff lines before matching the marker as a substring, rather than requiring it whole on any single line. 2. The persistent watcher (spawned via os.execv) never had SUTANDO_RESULTS_DIR or a distinct SUTANDO_INSTANCE_ID bound explicitly. RESULTS_DIR can be a value this script DERIVED (never becoming an env var itself), so the watcher's own independent fallback in watch-tasks-stream.sh can diverge from it; and without a distinct instance id its sentinel resolves to the same bare name a canonical watcher on the same host would write. Fixed by exporting both explicitly before spawning. Reproduced against exact-head Bash execution before fixing (2 new tests, each verified failing on the pre-fix code and passing after). One test needed a fix of its own mid-way: this session's own ambient AGENT_ID env var confounded the sentinel-collision check, since it flows into every subprocess the test spawns and makes the sentinel non-canonical regardless of the fix — caught by getting a false pass on unfixed code, not trusted. 12/12 agy-task-notifier tests green (was 10/10). review-checks.sh --diff, bash -n and shellcheck clean. Scope note: the RESULTS_DIR-binding half is verified by exporting the exact value this script already uses for its own has_result()/next_pending_task() calls (provably correct by construction) plus the shared sentinel test's passing env — not by an independent hermetic repro of the specific production branch where SUTANDO_RESULTS_DIR is never set at all, since reaching that branch safely would require overriding sutando-config.sh's real workspace resolution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): force the notifier's own identity, mkdir results-agy at startup, trim comments to the 2-line cap Three real blockers from review at ad91676: 1. task-notifier.sh fell back to agy-task-notifier only when SUTANDO_INSTANCE_ID was unset (${VAR:-default}); a NON-empty inherited value (tmux server global env, or a real worker's own env a launcher forgot to clear) passed straight through and collided with whatever real instance owned that identity. Force it unconditionally instead, and bind every queue-related var explicitly (never omit -e) in start-cli.sh's ensure_task_notifier, since omitting -e leaves the tmux server's global env in place rather than overriding it. 2. RESULTS_DIR was never created for the default results-agy topology (the watcher only mkdirs TASKS_DIR); agy's own result write hit ENOENT deep in a dispatch. Create it at startup and fail loudly if that fails. 3. Several added comments ran 3-7 lines against the repo's 2-line cap. Verified: - Real pre-fix-fails/post-fix-passes repro for both functional blockers (temporarily reverted the fix, confirmed the new tests fail, restored, confirmed green) -- new tests added to tests/agy-task-notifier.test.py. - All 4 agy suites green: agy-task-notifier 15/15 (was 12), agy-start-cli 19/19, agy-notifier-dependency-gate 2/2, agy-notifier-inbox-scope 3/3. - Adjacent suites re-run clean: task-dispatch-contract 55/55, watcher-sentinel-naming 30/30, watch-tasks-stream-inbox-and-workspace-env 4/4. - Real post-restart round trip on the CURRENT HEAD, using the DEFAULT tasks-agy/results-agy topology alongside a real canonical watcher, both running under this session's own ambient identity env (not a scrubbed test env): the two watchers wrote sentinels watch-tasks-stream-<agent>.pid (canonical) and watch-tasks-stream-<agent>+agy-task-notifier.pid (agy) -- never colliding -- results-agy existed before any task was dropped, and a task dropped in tasks-agy never reached the canonical watcher's log (and vice versa). - bash -n, shellcheck -S warning, git diff --check, review-checks.sh (diff against origin/main) all clean. Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(src-map): regenerate after this PR's comment edits 'refuse docs/src-map.md stale vs src/' + coverage-gate's tests/src-map.test.py (which cascaded into 'python standalone tests' and the aggregate 'tsc + tests' gate) all failed on the same single cause: my prior commit's comment edit to start-cli.sh's header changed the extracted purpose line, and I never regenerated the map. python3 scripts/gen-src-map.py --check now passes; tests/src-map.test.py 27/27; review-checks.sh clean. Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): propagate a failed idle wait, and survive a wrapped marker scrolling off-pane wait_for_core_idle's failure was swallowed (|| true), so a busy pane still got typed into and Entered after the wait timed out. deliver_prompt now refuses to send on that failure; both call sites tolerate the deferred task under set -e. pane_capture only read the pane's fixed viewport, so a long prompt in a narrow pane could wrap its leading marker above the visible rows -- the staging check missed it and retyped a duplicate. It now asks for scrollback via capture-pane -S, deep enough to hold the whole wrapped prompt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): survive a busy-pane idle-timeout and recycle a stale watcher after a core restart Round-4 review (keweichen, 2026-09-20) on PR #4303 at af64ee4: 1. The persistent main loop called `wait_for_core_idle || exit 1`, so a pane that stayed busy past CORE_READY_TIMEOUT killed the whole watcher instead of just deferring. It now retries the idle wait in place (no second task-file event needed) and only gives up once the tmux session itself is gone. 2. `ensure_task_notifier` treated any surviving watcher session as sufficient, even when the core it was driving had just crashed and been relaunched under the same name. On a real fresh-core launch it now kills the stale watcher, waits for it to fully exit, and starts exactly one fresh notifier, whose own initial sweep re-arms any durable pending task. 3. `agy-notifier-dependency-gate.test.py`'s positive arm relied on tmux being under /usr/bin or /bin, which is not guaranteed on a packaged host; it now symlinks the resolved tmux in, matching the negative arm. 4. Trimmed comment blocks over the repo's 2-line cap in task-notifier.sh and in generated-shell comments embedded in agy-task-notifier.test.py. Added regression tests: MainLoopWiringTest.test_busy_then_idle_pane_dispatches_without_a_second_task_file_event, RestartHandoffTest.test_stranded_task_is_redispatched_exactly_once_by_the_next_generation, StartCliNotifierWiringTest.test_crash_restart_recycles_the_stale_watcher_exactly_once, StartCliNotifierWiringTest.test_normal_reattach_does_not_recycle_a_live_watcher. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): retry a delivery deferred by the busy-between-checks race The main loop's OUTER wait_for_core_idle can see the pane idle and hand off to submit_task, but deliver_prompt runs its OWN, independent wait_for_core_idle -- the pane can go busy again in the gap between the two checks. When that inner check timed out, deliver_prompt (and submit_task) returned 1, and `submit_task "$filename" || true` swallowed it: the task-file event that would have retried it was already consumed, so nothing else ever did. The task never dispatched, even once the pane went idle again, unlike the continuously-busy case the existing test already covered. Wrap the call in the same retry idiom already used for the outer gate: keep re-invoking submit_task for the same filename until it succeeds or the tmux session itself is gone. Added MainLoopWiringTest.test_busy_between_the_outer_and_inner_idle_checks_still_retries, which lands the race deterministically via a capture-pane call counter in the fake-tmux harness (flip busy right after the outer check's single successful poll, guaranteed to land before the inner check's own poll, no wall-clock guessing). Verified as a real control: reverting just the production fix makes the new test fail (times out waiting for the deferred dispatch); reapplying it passes. Full suite: 22/22. keweichen/qingyun-wu, #4303 review at 69bf8d2 (blocker 1; blocker 2, the live-restart witness, is unchanged by this commit). Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy): start the notifier's watcher with --role session --inbox main's watch-tasks-stream.sh now refuses to start without a role tag, so after merging main the agy notifier's watcher exited at once and no task reached the pane. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(agy): carry the branch's main-merge state of start-cli.sh and src-map.md The linear replay of the branch has no slot for its merge commit a1fb531, which brought the slice-1 launcher's attach_or_report_existing helper and new-session race re-check into start-cli.sh, and the regenerated module map into docs/src-map.md. This commit is that merge's effect on the two files, placed where the merge sat; every later commit was authored on top of it. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): launcher waits on the watcher's readiness sentinel; hermetic test env The launcher reported "Started" after a 2s liveness poll, but the notifier's session-role watcher only proves itself with a 10s readiness round trip and, failing it, exits and takes the session down later than that poll can see. ensure_task_notifier now asks the notifier for the sentinel its watcher will stamp (`--sentinel-path`, one resolution for both) and polls until it names a live pid, the session dies, or SUTANDO_WATCHER_READY_TIMEOUT+2s elapses, and warns in the last two cases. The actor identity and workspace variables the sentinel name depends on are bound into the tmux session explicitly. Tests: the dependency-gate negative arm built its PATH from /usr/bin, where the CI image installs fswatch; it now mirrors the host PATH minus fswatch. A new real-tmux case covers an installed-but-silent fswatch. The notifier harness inherited the invoking shell's SUTANDO_* state (a live worker's SUTANDO_INBOX_RESOLVER routed every test task elsewhere); it now starts from the shared clean_env fixture, waits for the watcher's sentinel instead of a fixed sleep, and sets SUTANDO_STANDBY_STOP_TIMEOUT=0 since no supervisor exists in the harness. submit_task skips a task file that vanished before dispatch. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): launcher readiness is an ownership witness, not a live pid A crash-left sentinel can name a pid the OS has since reused, so `kill -0` on its payload read an unrelated live process as "this launch's watcher is ready" and the launcher printed Started over a notifier that died seconds later. The notifier now answers `--sentinel-ready <launch epoch>`: the sentinel must have been stamped after the launch began and name a watch-tasks-stream.sh on this inbox whose elapsed time (`ps -o etime=`) fits inside the launch, the discriminator src/watcher_sentinel.sh names. The launcher polls that instead of the pid. Real-tmux regressions: a stale sentinel naming a live unrelated pid, a dead pid, and a malformed payload, each with a silent fswatch so only the stale file could speak. The hermetic PATH builder gives each call its own mirror. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): launcher readiness is a per-launch receipt the notifier publishes A pid, an mtime or an argv shape can all be satisfied by an earlier generation: a watcher-shaped process on the same inbox with its pid in a sentinel written the same second the launch began passed the previous predicate. The launcher now mints a nonce per launch and binds it into the notifier's session; the notifier waits until the ready sentinel names exactly its own watcher child, then atomically publishes a receipt (nonce, inbox, watcher pid, notifier pid) beside the sentinel. The launcher polls `task-notifier.sh --launch-ready <nonce>`, which requires an exact nonce and inbox match and live owner pids. On exit the notifier removes the receipt only while it still carries its own nonce. The notifier opens the event FIFO's read end right after forking the watcher: the watcher's open-for-write blocks until a reader exists, so a wait placed before the read loop would have held the watcher itself. Real-tmux regressions, each with a silent fswatch and a fresh sentinel naming a live watcher-shaped process so only the receipt can discriminate: same-second prior generation, concurrent foreign generation, stale receipt with dead pids, malformed and partial receipts, a receipt for an adjacent inbox; plus the three stale-sentinel shapes, and a positive case that the receipt names the sentinel's pid and is removed by its owner's exit. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): a sentinel is the child's only if written after the fork; receipts are nonce-scoped Numeric pid equality is not ownership: a crash-left sentinel can hold the very number the OS reissues to the new, still-unready watcher child. The notifier now records time_ns() immediately before the fork and signs the sentinel only when it names the child and its st_mtime_ns post-dates that instant. Receipt cleanup was grep-then-rm on one shared path, so a retiring generation could remove a successor's receipt published in between. Each launch now publishes <sentinel>.launch.<nonce>; an owner removes only its own file, and receipts whose notifier pid is gone are swept at startup. Real-tmux regressions: the child's pid written into the sentinel after the fork with a pre-launch mtime is refused; the owner's exit leaves another generation's receipt in place. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): the launch nonce rides the watcher's own ready event An mtime comparison is a clock comparison: a future-dated crash-left sentinel holding a reused pid passed the previous ownership test. The watcher now writes SUTANDO_WATCHER_READY_TOKEN, when set, to <sentinel>.token at the moment it stamps its pid, and releases it with the sentinel. The notifier hands its launch nonce to its watcher child and signs the receipt only when the sentinel names the child and the token file carries that nonce; no timestamps are consulted. The receipt sweep matches complete nonce names only, so a temp file mid-publish is never removed. Tests: a future-dated reused-pid sentinel joins the crash-left one; the watcher child is found as the root of its process tree (a subshell shares its argv); the stale/malformed/adjacent receipt cases plant their file under the live launch's nonce, with a positive control that a well-formed matching receipt is accepted, so each refusal is the match branch. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): a receipt owner pid must be positive before it is probed kill(0, 0) and kill(-1, 0) address the caller's group and a process set, so a receipt naming 0 or -1 as its owner passed --launch-ready's liveness probe. Each owner pid is now parsed and required to be > 0 first. Test: receipts with 0 and -1 owners planted under the live launch nonce are refused. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): the receipt sweep never touches this launch's own path The startup sweep clears receipts of earlier generations whose notifier is gone; it could also remove a file at this launch's own receipt path before the launcher parsed it, which let the planted dead-pid and malformed test receipts vanish instead of being refused. The own path is skipped, and the planting helper asserts the file survives the launch. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(agy): prove the planted receipt's verdict by asking --launch-ready directly The previous commit asserted the planted file still existed after the launcher returned; the owner's exit removes its own receipt path, so that assertion measured cleanup timing, not the parser. The helper now asks --launch-ready <live nonce> on the planted content and asserts its verdict. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(agy): each owner-pid guard half decides on its own; wait for the session to end The non-positive-pid case planted both fields invalid, so the parser stopped at watcher and the notifier half of the guard was never exercised; the four arms now invalidate one field at a time with a live sibling, and keeping the guard for only one field fails the test. The not-ready cases asserted the watcher session was gone the instant the launcher returned; that death is the watcher's own readiness timeout and trails the launcher's deadline on a loaded host, so they wait for it. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): the receipt sweep is one function with a verb, and proves it keeps its own receipt The startup sweep is now sweep_receipts <nonce>, also reachable as `task-notifier.sh --sweep-receipts <nonce>`, so a test can run it with this launch's receipt provably present: the own file survives, a dead generation's is removed, and so is one whose notifier= is not a positive pid (kill -0 accepts 0 and -1); a live generation's and a temp file mid-publish are left alone. Removing the own-path exclusion fails the test. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(agy): a refused receipt is exactly rc 1 with empty stderr, never any nonzero exit Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): an oversized receipt pid is refused, and a refusal is "no crash", not silence os.kill raises OverflowError for a pid beyond the C int range; --launch-ready now refuses it like any other malformed pid instead of printing a traceback. The probe assertion asked for empty stderr, which an ambient PYTHONWARNINGS, PYTHONDEVMODE or PYTHONWARNDEFAULTENCODING turns red with harmless interpreter warnings; it now asserts the crash-specific signal, no traceback, and the oversized-pid case is added. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): the receipt reader closes its handle; the probe's verdict ignores warning knobs --launch-ready read the receipt through an unclosed file object, so under PYTHONWARNINGS=error the ResourceWarning became a traceback after a correct rc 0. The read is now a closed utf-8 handle, and a bad byte is a refusal like any other malformed receipt. The test's direct probe scrubs PYTHONWARNINGS, PYTHONDEVMODE and PYTHONWARNDEFAULTENCODING from its own environment, so an ambient knob (rundir.py's unencoded reads are a separate cleanup) cannot decide the verdict. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy-notifier): accept only [A-Za-z0-9._-]+.txt task names before typing into the pane The agy pane runs with permission prompts skipped, and submit_task only filtered `/` and `..` from the task filename before typing it with send-keys. A name carrying a newline or a shell metacharacter would have been typed in verbatim. The check now sits at the top of submit_task, the one funnel both `--event <name>` and the event loop's next-pending pick go through: anything outside `^[A-Za-z0-9._-]+\.txt$` is refused with a logged line and nothing is typed. Tests (tests/agy-task-notifier.test.py, EventDispatchTests): - test_name_outside_the_plain_task_shape_is_refused_before_any_typing drops names with a newline, `$(id)`, `;`, a space, a trailing space, an upper-case suffix and a traversal prefix: send-keys log stays empty, the refusal is on stderr and in logs/agy-task-notifier.log. - test_plain_task_name_control_still_reaches_the_pane: task-abc.txt is still typed and submitted. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy-notifier): a name the pane refuses is never the queue head (next-pending --typable) The agy backstop at the top of submit_task refuses any name outside [A-Za-z0-9._-]+.txt and returns 0, but nothing archives the file, so next-pending re-offered the same refused name on every wake and the task behind it never came up: the queue stalled at the first task-<x>~<id>.txt when pointed at the shared tasks/. The rule now lives once, in src/delivery/task_dispatch.py (TASK_NAME_RE), and pending-candidates / next-pending take --typable to apply it; the agy notifier's next_pending_task passes it, and its shell backstop stays as the last line of defence. The default offer is unchanged: the Claude notifier types `task-a b.txt` safely behind its staged-exact check and relies on pending-candidates still listing such names for its ambiguity guard (tests/claude-task-notifier-inflight.test.py), so tightening the default regressed it. Tests: the shell regex is pinned equal to TASK_NAME_RE; an older refused name ahead of a newer plain one yields the plain one under --typable (and still the refused one by default); the agy main loop no longer stalls on it; the newline fixture now ends in .txt so it tests the case it names. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
… tmux targets #4303 merged after #4976 and #4945 without being re-run against them: - #4976 makes watch-tasks-stream.sh clear SUTANDO_INSTANCE_ID on the core's canonical inbox, so the agy notifier's watcher stamps watch-tasks-stream.pid while the notifier polled the per-instance name it forced. The launch receipt never appeared ("did not report ready within 12s"). The notifier and start-cli.sh now run the watcher with no instance id, as the Codex core does. - #4945's scan forbids bare `-t "$SESSION"` targets; the notifier's six capture-pane/send-keys calls now use the exact "=$SESSION:" form. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ored notifier Re-landing agy Slice 2 (#4303) brings back ensure_task_notifier, which already has its own tested contract: preserve a live watcher across a plain reattach, recycle one only when the core session itself is fresh or after a crash restart (test_normal_reattach_does_not_recycle_a_live_watcher, test_second_invocation_does_not_duplicate_the_watcher, test_crash_restart_recycles_the_stale_watcher_exactly_once, all pre-dating #5166). Calling #5166's retire_stale_watcher unconditionally on every invocation -- its shape while ensure_task_notifier didn't exist -- regressed both of the first two: every reattach now killed and restarted an otherwise healthy watcher. Keeps retire_stale_watcher, but only for what ensure_task_notifier genuinely cannot cover: a watcher left running when this launcher currently has no way to manage one at all (the notifier script or fswatch missing). Every other case -- reattach, fresh launch, crash restart -- is ensure_task_notifier's existing, tested call, unchanged. tests/agy-start-cli.test.py's StaleWatcherRetirementTests already simulates the "cannot manage a watcher" case (no real notifier script in its hermetic env), so its assertions needed no behavioral change, only an explicit SUTANDO_AGY_NOTIFIER_SCRIPT override (so the retirement path fires on intent, not by accident of what's on PATH) and an updated stderr expectation for the now-restored ensure_task_notifier warning when it also runs on the same path. Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ravity CLI) (#4303) * feat(agy-core): add slice 2 task-file-injection notifier for agy Slice 2 of #4272: how a task in tasks/*.txt reaches a running agy session. Verified live that agy's run_command/manage_task notifies on background-subprocess COMPLETION only, never per-line while a process keeps running, so unlike Claude Code (whose Monitor tool arms watch-tasks-stream.sh from inside the agent's own turn loop) agy cannot self-arm that same never-exiting watcher. task-notifier.sh instead runs the watcher externally and injects each task via tmux send-keys, mirroring Codex's proven design (src/agent/codex/cli/task-notifier.sh) — Codex has no self-watch primitive either. start-cli.sh now starts it, once, in its own <session>-watcher tmux session. has_result() and the priority-ordered task pick duplicate the same provider-neutral policy already in Codex's task-notifier.sh — a deliberate, documented scope cut (CLAUDE.md's "Shared adapter policy" rule calls a second copy a defect), not a design decision. Extracting both, plus the submit-confirm/retype state machine in deliver_prompt, into a shared src/ module both launchers call is named follow-up work in the README, not implied cleanup. Proven end-to-end against the real agy binary + real tmux, not just the hermetic test suite: dropped a task file, watched task-notifier.sh's real fswatch-driven main loop dispatch it into a live agy pane, and confirmed the correct result landed in results/. That run caught two real bugs the stub tests couldn't (pane-width-dependent prompt wrapping causing a duplicate paste; a submit-confirm check that misread already-sent history as still staged) — both fixed and re-verified live. Every regression test is proven by disabling its fix and watching it fail: has_result skip, idle-gating, staging-retry, submit-confirm re-press, the fswatch main-loop wiring itself, and start-cli.sh's watcher-session wiring. Stand: Echo Act IV Mini * fix(agy-core): address slice-2 review blockers — double-processing, duplicated policy, stale-history, missing fswatch check Fixes all four CHANGES_REQUESTED blockers from keweichen's review of #4303 (head dc11898). 1. Double-processing: agy's watcher ran alongside the existing Claude/Codex core's watcher on the SAME canonical workspace/tasks + results dirs — both would see and dispatch every task, including irreversible ones, twice. task-notifier.sh now defaults to a separately-owned tasks-agy/results-agy inbox instead; an explicit SUTANDO_TASKS_DIR/SUTANDO_RESULTS_DIR override is unaffected. 2. Duplicated, behaviorally-wrong policy: has_result()/next_pending_task() were hand-rolled in bash in both agy's and Codex's task-notifier.sh, and diverged from correct — a zero-byte partial result read as delivered, and a leading-digit glob matched a different task's flat archive as an epoch archive. Extracted into src/delivery/task_dispatch.py, which delegates to the existing readiness/local_task_protocol owners; both notifiers now call it. Codex-specific extras (its Team-handler claim probe, fallback-receipt cleanup) stay in its own bash. 3. Stale-history false positive: prompt_is_staged() matched the task marker anywhere in the last 20 pane rows, so a marker left over from an EARLIER dispatch of the same filename read as proof the CURRENT paste staged even when it was actually swallowed — the retype path never fired and the notifier timed out waiting for a result that would never arrive. Now restricted to lines added since a baseline captured right before each type attempt. 4. Missing fswatch check: start-cli.sh's ensure_task_notifier checked only that agy and tmux exist, never fswatch (task-notifier.sh's hard dependency via watch-tasks-stream.sh), and never verified the watcher session survived. Without fswatch the notifier's pane process collapses within ~1s and tmux tears the session down; start-cli.sh still reported success. Now checks for fswatch before launch and polls for a couple of seconds afterward, reporting failure if the session didn't survive. Every fix has a regression test proven by reverting it and confirming genuine failure (git-diff/stash-and-rerun, not just written-and-assumed): tests/agy-notifier-inbox-scope.test.py (1), tests/task-dispatch-contract.test.py (2), tests/agy-task-notifier.test.py's new stale-history case (3), tests/agy-notifier-dependency-gate.test.py using real tmux+fswatch (4). Fixing blocker 2's zero-byte-result bug exposed the same unrealistic assumption in existing codex-core-launcher.test.py fixtures, which used `touch` (empty file) to simulate a delivered result — updated 5 call sites to write real content, matching the now-correctly-enforced readiness contract (delivery.readiness) those fixtures were implicitly relying on being loose. Full existing suites re-run clean against this change: codex-core-launcher (33/34 — the one failure, test_launches_codex_and_managed_task_notifier, reproduces identically on the unmodified review head and is unrelated), health-check-codex-task-notifier (18/18), task-workstreams (all), shutdown- sentinel-immediate-exit-controls (all), watcher-shells-resolved-interpreter (5/5), result-ready-contract (12/12). Stand: Echo Act IV Mini * fix(agy-core): clear the two CI gates this PR's own new file tripped docs/src-map.md was stale after adding src/delivery/task_dispatch.py; regenerated via scripts/gen-src-map.py. lint-workspace-resolution flagged task_dispatch.py's sys.path bootstrap (Path(__file__).resolve().parent.parent, needed because it lives one directory deeper than task_priority.py, whose identical pattern only needs a single .parent) as the historic repo-root-walk anti-pattern. It resolves src/ to import sibling modules, not the user's data workspace, so it's the legitimate case the lint's own allow-repo-root pragma exists for -- used it rather than restructuring the import. Stand: Echo Act IV Mini * fix(agy-core): detect staged prompts by content diff, not pane row count `tmux capture-pane -p` (no -S/-E) always returns the visible pane's fixed row count, padded with blank lines — it never grows as content is typed (confirmed against a real tmux session). `prompt_is_staged`'s `tail -n "+$((baseline+1))"` offset relied on the capture growing, so it was always empty: staging was never detected, and every dispatch fell through to the retry, which pasted the whole prompt a second time into the still-uncommitted composer line — a duplicated/corrupted instruction landing in the live agy session. Fix: capture the full pane TEXT as `baseline` and detect staging by diffing baseline against the current capture, requiring the marker to appear in an *added* line. A marker left over from an earlier successful dispatch of the same filename is present unchanged in both captures, so it's never mistaken for a new one. The regression test's fake `capture-pane` was a plain file that grew when text was pasted — unlike a real tmux pane — which is why it didn't catch this. Rebuilt it to render a fixed-row-count pane from a history file plus a single composer row that concatenates on -l and commits on Enter, matching real tmux's behavior. Verified against a real tmux session (see PR comment for the full before/after transcript): the original script duplicates the prompt in the composer line; the fixed script stages and submits it exactly once. Stand: Echo Act IV Mini * fix(agy-core): regenerate src-map.md after the main-merge left conflict markers in it Caught before push: the auto-merge staged docs/src-map.md with literal <<<<<<</=======/>>>>>>> markers still embedded (git resolved it as a clean merge at the line level, but both sides had added adjacent entries for task_dispatch.py, producing unresolved marker text). Regenerated via scripts/gen-src-map.py instead of hand-editing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): marker-wrap staging miss + watcher results-dir/instance-id binding gap keweichen's review on 76fbef8 named 2 real blockers, both fixed: 1. prompt_is_staged() grepped the whole "Sutando task ready: <filename>" marker on a single diff `>` line. A production-shaped long filename hard-wraps that line across two physical pane rows in a narrow terminal, so the check missed a genuinely-staged prompt and retyped it, producing a duplicate. Reproduced: width=80, a 72-char filename -> 2 type calls. Fixed by joining the added diff lines before matching the marker as a substring, rather than requiring it whole on any single line. 2. The persistent watcher (spawned via os.execv) never had SUTANDO_RESULTS_DIR or a distinct SUTANDO_INSTANCE_ID bound explicitly. RESULTS_DIR can be a value this script DERIVED (never becoming an env var itself), so the watcher's own independent fallback in watch-tasks-stream.sh can diverge from it; and without a distinct instance id its sentinel resolves to the same bare name a canonical watcher on the same host would write. Fixed by exporting both explicitly before spawning. Reproduced against exact-head Bash execution before fixing (2 new tests, each verified failing on the pre-fix code and passing after). One test needed a fix of its own mid-way: this session's own ambient AGENT_ID env var confounded the sentinel-collision check, since it flows into every subprocess the test spawns and makes the sentinel non-canonical regardless of the fix — caught by getting a false pass on unfixed code, not trusted. 12/12 agy-task-notifier tests green (was 10/10). review-checks.sh --diff, bash -n and shellcheck clean. Scope note: the RESULTS_DIR-binding half is verified by exporting the exact value this script already uses for its own has_result()/next_pending_task() calls (provably correct by construction) plus the shared sentinel test's passing env — not by an independent hermetic repro of the specific production branch where SUTANDO_RESULTS_DIR is never set at all, since reaching that branch safely would require overriding sutando-config.sh's real workspace resolution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): force the notifier's own identity, mkdir results-agy at startup, trim comments to the 2-line cap Three real blockers from review at ad91676: 1. task-notifier.sh fell back to agy-task-notifier only when SUTANDO_INSTANCE_ID was unset (${VAR:-default}); a NON-empty inherited value (tmux server global env, or a real worker's own env a launcher forgot to clear) passed straight through and collided with whatever real instance owned that identity. Force it unconditionally instead, and bind every queue-related var explicitly (never omit -e) in start-cli.sh's ensure_task_notifier, since omitting -e leaves the tmux server's global env in place rather than overriding it. 2. RESULTS_DIR was never created for the default results-agy topology (the watcher only mkdirs TASKS_DIR); agy's own result write hit ENOENT deep in a dispatch. Create it at startup and fail loudly if that fails. 3. Several added comments ran 3-7 lines against the repo's 2-line cap. Verified: - Real pre-fix-fails/post-fix-passes repro for both functional blockers (temporarily reverted the fix, confirmed the new tests fail, restored, confirmed green) -- new tests added to tests/agy-task-notifier.test.py. - All 4 agy suites green: agy-task-notifier 15/15 (was 12), agy-start-cli 19/19, agy-notifier-dependency-gate 2/2, agy-notifier-inbox-scope 3/3. - Adjacent suites re-run clean: task-dispatch-contract 55/55, watcher-sentinel-naming 30/30, watch-tasks-stream-inbox-and-workspace-env 4/4. - Real post-restart round trip on the CURRENT HEAD, using the DEFAULT tasks-agy/results-agy topology alongside a real canonical watcher, both running under this session's own ambient identity env (not a scrubbed test env): the two watchers wrote sentinels watch-tasks-stream-<agent>.pid (canonical) and watch-tasks-stream-<agent>+agy-task-notifier.pid (agy) -- never colliding -- results-agy existed before any task was dropped, and a task dropped in tasks-agy never reached the canonical watcher's log (and vice versa). - bash -n, shellcheck -S warning, git diff --check, review-checks.sh (diff against origin/main) all clean. Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(src-map): regenerate after this PR's comment edits 'refuse docs/src-map.md stale vs src/' + coverage-gate's tests/src-map.test.py (which cascaded into 'python standalone tests' and the aggregate 'tsc + tests' gate) all failed on the same single cause: my prior commit's comment edit to start-cli.sh's header changed the extracted purpose line, and I never regenerated the map. python3 scripts/gen-src-map.py --check now passes; tests/src-map.test.py 27/27; review-checks.sh clean. Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): propagate a failed idle wait, and survive a wrapped marker scrolling off-pane wait_for_core_idle's failure was swallowed (|| true), so a busy pane still got typed into and Entered after the wait timed out. deliver_prompt now refuses to send on that failure; both call sites tolerate the deferred task under set -e. pane_capture only read the pane's fixed viewport, so a long prompt in a narrow pane could wrap its leading marker above the visible rows -- the staging check missed it and retyped a duplicate. It now asks for scrollback via capture-pane -S, deep enough to hold the whole wrapped prompt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): survive a busy-pane idle-timeout and recycle a stale watcher after a core restart Round-4 review (keweichen, 2026-09-20) on PR #4303 at af64ee4: 1. The persistent main loop called `wait_for_core_idle || exit 1`, so a pane that stayed busy past CORE_READY_TIMEOUT killed the whole watcher instead of just deferring. It now retries the idle wait in place (no second task-file event needed) and only gives up once the tmux session itself is gone. 2. `ensure_task_notifier` treated any surviving watcher session as sufficient, even when the core it was driving had just crashed and been relaunched under the same name. On a real fresh-core launch it now kills the stale watcher, waits for it to fully exit, and starts exactly one fresh notifier, whose own initial sweep re-arms any durable pending task. 3. `agy-notifier-dependency-gate.test.py`'s positive arm relied on tmux being under /usr/bin or /bin, which is not guaranteed on a packaged host; it now symlinks the resolved tmux in, matching the negative arm. 4. Trimmed comment blocks over the repo's 2-line cap in task-notifier.sh and in generated-shell comments embedded in agy-task-notifier.test.py. Added regression tests: MainLoopWiringTest.test_busy_then_idle_pane_dispatches_without_a_second_task_file_event, RestartHandoffTest.test_stranded_task_is_redispatched_exactly_once_by_the_next_generation, StartCliNotifierWiringTest.test_crash_restart_recycles_the_stale_watcher_exactly_once, StartCliNotifierWiringTest.test_normal_reattach_does_not_recycle_a_live_watcher. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy-core): retry a delivery deferred by the busy-between-checks race The main loop's OUTER wait_for_core_idle can see the pane idle and hand off to submit_task, but deliver_prompt runs its OWN, independent wait_for_core_idle -- the pane can go busy again in the gap between the two checks. When that inner check timed out, deliver_prompt (and submit_task) returned 1, and `submit_task "$filename" || true` swallowed it: the task-file event that would have retried it was already consumed, so nothing else ever did. The task never dispatched, even once the pane went idle again, unlike the continuously-busy case the existing test already covered. Wrap the call in the same retry idiom already used for the outer gate: keep re-invoking submit_task for the same filename until it succeeds or the tmux session itself is gone. Added MainLoopWiringTest.test_busy_between_the_outer_and_inner_idle_checks_still_retries, which lands the race deterministically via a capture-pane call counter in the fake-tmux harness (flip busy right after the outer check's single successful poll, guaranteed to land before the inner check's own poll, no wall-clock guessing). Verified as a real control: reverting just the production fix makes the new test fail (times out waiting for the deferred dispatch); reapplying it passes. Full suite: 22/22. keweichen/qingyun-wu, #4303 review at 69bf8d2 (blocker 1; blocker 2, the live-restart witness, is unchanged by this commit). Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agy): start the notifier's watcher with --role session --inbox main's watch-tasks-stream.sh now refuses to start without a role tag, so after merging main the agy notifier's watcher exited at once and no task reached the pane. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(agy): carry the branch's main-merge state of start-cli.sh and src-map.md The linear replay of the branch has no slot for its merge commit a1fb531, which brought the slice-1 launcher's attach_or_report_existing helper and new-session race re-check into start-cli.sh, and the regenerated module map into docs/src-map.md. This commit is that merge's effect on the two files, placed where the merge sat; every later commit was authored on top of it. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): launcher waits on the watcher's readiness sentinel; hermetic test env The launcher reported "Started" after a 2s liveness poll, but the notifier's session-role watcher only proves itself with a 10s readiness round trip and, failing it, exits and takes the session down later than that poll can see. ensure_task_notifier now asks the notifier for the sentinel its watcher will stamp (`--sentinel-path`, one resolution for both) and polls until it names a live pid, the session dies, or SUTANDO_WATCHER_READY_TIMEOUT+2s elapses, and warns in the last two cases. The actor identity and workspace variables the sentinel name depends on are bound into the tmux session explicitly. Tests: the dependency-gate negative arm built its PATH from /usr/bin, where the CI image installs fswatch; it now mirrors the host PATH minus fswatch. A new real-tmux case covers an installed-but-silent fswatch. The notifier harness inherited the invoking shell's SUTANDO_* state (a live worker's SUTANDO_INBOX_RESOLVER routed every test task elsewhere); it now starts from the shared clean_env fixture, waits for the watcher's sentinel instead of a fixed sleep, and sets SUTANDO_STANDBY_STOP_TIMEOUT=0 since no supervisor exists in the harness. submit_task skips a task file that vanished before dispatch. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): launcher readiness is an ownership witness, not a live pid A crash-left sentinel can name a pid the OS has since reused, so `kill -0` on its payload read an unrelated live process as "this launch's watcher is ready" and the launcher printed Started over a notifier that died seconds later. The notifier now answers `--sentinel-ready <launch epoch>`: the sentinel must have been stamped after the launch began and name a watch-tasks-stream.sh on this inbox whose elapsed time (`ps -o etime=`) fits inside the launch, the discriminator src/watcher_sentinel.sh names. The launcher polls that instead of the pid. Real-tmux regressions: a stale sentinel naming a live unrelated pid, a dead pid, and a malformed payload, each with a silent fswatch so only the stale file could speak. The hermetic PATH builder gives each call its own mirror. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): launcher readiness is a per-launch receipt the notifier publishes A pid, an mtime or an argv shape can all be satisfied by an earlier generation: a watcher-shaped process on the same inbox with its pid in a sentinel written the same second the launch began passed the previous predicate. The launcher now mints a nonce per launch and binds it into the notifier's session; the notifier waits until the ready sentinel names exactly its own watcher child, then atomically publishes a receipt (nonce, inbox, watcher pid, notifier pid) beside the sentinel. The launcher polls `task-notifier.sh --launch-ready <nonce>`, which requires an exact nonce and inbox match and live owner pids. On exit the notifier removes the receipt only while it still carries its own nonce. The notifier opens the event FIFO's read end right after forking the watcher: the watcher's open-for-write blocks until a reader exists, so a wait placed before the read loop would have held the watcher itself. Real-tmux regressions, each with a silent fswatch and a fresh sentinel naming a live watcher-shaped process so only the receipt can discriminate: same-second prior generation, concurrent foreign generation, stale receipt with dead pids, malformed and partial receipts, a receipt for an adjacent inbox; plus the three stale-sentinel shapes, and a positive case that the receipt names the sentinel's pid and is removed by its owner's exit. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): a sentinel is the child's only if written after the fork; receipts are nonce-scoped Numeric pid equality is not ownership: a crash-left sentinel can hold the very number the OS reissues to the new, still-unready watcher child. The notifier now records time_ns() immediately before the fork and signs the sentinel only when it names the child and its st_mtime_ns post-dates that instant. Receipt cleanup was grep-then-rm on one shared path, so a retiring generation could remove a successor's receipt published in between. Each launch now publishes <sentinel>.launch.<nonce>; an owner removes only its own file, and receipts whose notifier pid is gone are swept at startup. Real-tmux regressions: the child's pid written into the sentinel after the fork with a pre-launch mtime is refused; the owner's exit leaves another generation's receipt in place. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): the launch nonce rides the watcher's own ready event An mtime comparison is a clock comparison: a future-dated crash-left sentinel holding a reused pid passed the previous ownership test. The watcher now writes SUTANDO_WATCHER_READY_TOKEN, when set, to <sentinel>.token at the moment it stamps its pid, and releases it with the sentinel. The notifier hands its launch nonce to its watcher child and signs the receipt only when the sentinel names the child and the token file carries that nonce; no timestamps are consulted. The receipt sweep matches complete nonce names only, so a temp file mid-publish is never removed. Tests: a future-dated reused-pid sentinel joins the crash-left one; the watcher child is found as the root of its process tree (a subshell shares its argv); the stale/malformed/adjacent receipt cases plant their file under the live launch's nonce, with a positive control that a well-formed matching receipt is accepted, so each refusal is the match branch. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): a receipt owner pid must be positive before it is probed kill(0, 0) and kill(-1, 0) address the caller's group and a process set, so a receipt naming 0 or -1 as its owner passed --launch-ready's liveness probe. Each owner pid is now parsed and required to be > 0 first. Test: receipts with 0 and -1 owners planted under the live launch nonce are refused. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): the receipt sweep never touches this launch's own path The startup sweep clears receipts of earlier generations whose notifier is gone; it could also remove a file at this launch's own receipt path before the launcher parsed it, which let the planted dead-pid and malformed test receipts vanish instead of being refused. The own path is skipped, and the planting helper asserts the file survives the launch. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(agy): prove the planted receipt's verdict by asking --launch-ready directly The previous commit asserted the planted file still existed after the launcher returned; the owner's exit removes its own receipt path, so that assertion measured cleanup timing, not the parser. The helper now asks --launch-ready <live nonce> on the planted content and asserts its verdict. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(agy): each owner-pid guard half decides on its own; wait for the session to end The non-positive-pid case planted both fields invalid, so the parser stopped at watcher and the notifier half of the guard was never exercised; the four arms now invalidate one field at a time with a live sibling, and keeping the guard for only one field fails the test. The not-ready cases asserted the watcher session was gone the instant the launcher returned; that death is the watcher's own readiness timeout and trails the launcher's deadline on a loaded host, so they wait for it. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): the receipt sweep is one function with a verb, and proves it keeps its own receipt The startup sweep is now sweep_receipts <nonce>, also reachable as `task-notifier.sh --sweep-receipts <nonce>`, so a test can run it with this launch's receipt provably present: the own file survives, a dead generation's is removed, and so is one whose notifier= is not a positive pid (kill -0 accepts 0 and -1); a live generation's and a temp file mid-publish are left alone. Removing the own-path exclusion fails the test. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(agy): a refused receipt is exactly rc 1 with empty stderr, never any nonzero exit Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): an oversized receipt pid is refused, and a refusal is "no crash", not silence os.kill raises OverflowError for a pid beyond the C int range; --launch-ready now refuses it like any other malformed pid instead of printing a traceback. The probe assertion asked for empty stderr, which an ambient PYTHONWARNINGS, PYTHONDEVMODE or PYTHONWARNDEFAULTENCODING turns red with harmless interpreter warnings; it now asserts the crash-specific signal, no traceback, and the oversized-pid case is added. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy): the receipt reader closes its handle; the probe's verdict ignores warning knobs --launch-ready read the receipt through an unclosed file object, so under PYTHONWARNINGS=error the ResourceWarning became a traceback after a correct rc 0. The read is now a closed utf-8 handle, and a bad byte is a refusal like any other malformed receipt. The test's direct probe scrubs PYTHONWARNINGS, PYTHONDEVMODE and PYTHONWARNDEFAULTENCODING from its own environment, so an ambient knob (rundir.py's unencoded reads are a separate cleanup) cannot decide the verdict. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy-notifier): accept only [A-Za-z0-9._-]+.txt task names before typing into the pane The agy pane runs with permission prompts skipped, and submit_task only filtered `/` and `..` from the task filename before typing it with send-keys. A name carrying a newline or a shell metacharacter would have been typed in verbatim. The check now sits at the top of submit_task, the one funnel both `--event <name>` and the event loop's next-pending pick go through: anything outside `^[A-Za-z0-9._-]+\.txt$` is refused with a logged line and nothing is typed. Tests (tests/agy-task-notifier.test.py, EventDispatchTests): - test_name_outside_the_plain_task_shape_is_refused_before_any_typing drops names with a newline, `$(id)`, `;`, a space, a trailing space, an upper-case suffix and a traversal prefix: send-keys log stays empty, the refusal is on stderr and in logs/agy-task-notifier.log. - test_plain_task_name_control_still_reaches_the_pane: task-abc.txt is still typed and submitted. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agy-notifier): a name the pane refuses is never the queue head (next-pending --typable) The agy backstop at the top of submit_task refuses any name outside [A-Za-z0-9._-]+.txt and returns 0, but nothing archives the file, so next-pending re-offered the same refused name on every wake and the task behind it never came up: the queue stalled at the first task-<x>~<id>.txt when pointed at the shared tasks/. The rule now lives once, in src/delivery/task_dispatch.py (TASK_NAME_RE), and pending-candidates / next-pending take --typable to apply it; the agy notifier's next_pending_task passes it, and its shell backstop stays as the last line of defence. The default offer is unchanged: the Claude notifier types `task-a b.txt` safely behind its staged-exact check and relies on pending-candidates still listing such names for its ambiguity guard (tests/claude-task-notifier-inflight.test.py), so tightening the default regressed it. Tests: the shell regex is pinned equal to TASK_NAME_RE; an older refused name ahead of a newer plain one yields the plain one under --typable (and still the refused one by default); the agy main loop no longer stalls on it; the newline fixture now ends in .txt so it tests the case it names. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
… tmux targets #4303 merged after #4976 and #4945 without being re-run against them: - #4976 makes watch-tasks-stream.sh clear SUTANDO_INSTANCE_ID on the core's canonical inbox, so the agy notifier's watcher stamps watch-tasks-stream.pid while the notifier polled the per-instance name it forced. The launch receipt never appeared ("did not report ready within 12s"). The notifier and start-cli.sh now run the watcher with no instance id, as the Codex core does. - #4945's scan forbids bare `-t "$SESSION"` targets; the notifier's six capture-pane/send-keys calls now use the exact "=$SESSION:" form. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ored notifier Re-landing agy Slice 2 (#4303) brings back ensure_task_notifier, which already has its own tested contract: preserve a live watcher across a plain reattach, recycle one only when the core session itself is fresh or after a crash restart (test_normal_reattach_does_not_recycle_a_live_watcher, test_second_invocation_does_not_duplicate_the_watcher, test_crash_restart_recycles_the_stale_watcher_exactly_once, all pre-dating #5166). Calling #5166's retire_stale_watcher unconditionally on every invocation -- its shape while ensure_task_notifier didn't exist -- regressed both of the first two: every reattach now killed and restarted an otherwise healthy watcher. Keeps retire_stale_watcher, but only for what ensure_task_notifier genuinely cannot cover: a watcher left running when this launcher currently has no way to manage one at all (the notifier script or fswatch missing). Every other case -- reattach, fresh launch, crash restart -- is ensure_task_notifier's existing, tested call, unchanged. tests/agy-start-cli.test.py's StaleWatcherRetirementTests already simulates the "cannot manage a watcher" case (no real notifier script in its hermetic env), so its assertions needed no behavioral change, only an explicit SUTANDO_AGY_NOTIFIER_SCRIPT override (so the retirement path fires on intent, not by accident of what's on PATH) and an updated stderr expectation for the now-restored ensure_task_notifier warning when it also runs on the same path. Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Brings in the #4303 revert (#5166), the CI sharding change (#5000) and the rest of main since 54d6465. One conflict, in the CLI usage string of src/delivery/task_dispatch.py: this branch adds the `sweep-plan` line above `pending-candidates`/`next-pending`, while main's revert drops `[--typable]` from those two lines. Resolved by keeping the new line and taking main's text for the two existing ones. No other change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What changed, and why
Slice 2 of #4272: how a task dropped into agy's own inbox reaches a running
agy(Antigravity CLI) session and gets a result written back. agy notifies its turn loop only when a subprocess exits, never per line, so it cannot self-arm the never-exitingwatch-tasks-stream.shthe way Claude'sMonitordoes.src/agent/agy/cli/task-notifier.shruns that watcher externally, on a separate default inbox (<workspace>/tasks-agy→<workspace>/results-agy, its own--role session --inboxand instance identity, so it never touches the canonical watcher or its sentinel), and injects each task into the pane withtmux send-keys.start-cli.shstarts it once per core session and recycles a stale generation on a fresh core launch. Completion detection and priority selection are owned bysrc/delivery/task_dispatch.py(shared with Codex), not copied here.Stack status. Parent #4275 is merged; this branch has
origin/mainmerged in, so the diff is the child alone: 7 files, +1498/−11.Files to look at first
src/agent/agy/cli/task-notifier.sh— the notifier; new--sentinel-pathquery modesrc/agent/agy/cli/start-cli.sh—ensure_task_notifiernow waits on the watcher's readiness contracttests/agy-notifier-dependency-gate.test.py— hermetic PATH, new silent-fswatch case (real tmux)tests/agy-task-notifier.test.py— harness starts fromtests/fixtures/clean_watcher_env.py, syncs on the sentinelsrc/agent/agy/README.md— scope docThe three blockers from the
be02db05ereviews, at head35c45eef91. Launcher reported success before the watcher proved readiness (keweichen)
The watcher's session-role readiness is a 10 s round trip; failing it, the watcher exits and takes the tmux session with it — later than the launcher's 2 s liveness poll could see.
ensure_task_notifiernow asks the notifier for the sentinel its watcher will stamp (task-notifier.sh --sentinel-path, one resolution for both sides) and polls until that file names a live pid, the session dies, orSUTANDO_WATCHER_READY_TIMEOUT+2s pass; the last two print a warning. The workspace and actor-identity variables the sentinel name depends on are bound into the tmux session with-e, like the queue variables already were.New real-tmux regression
test_installed_but_silent_fswatch_is_reported_not_claimed_ready(fswatch stub that stays alive and emits nothing,SUTANDO_WATCHER_READY_TIMEOUT=3):It also fired for real during the live run below: my first launch accidentally carried a live worker's
SUTANDO_TASKS_DIR, the notifier's watcher was refused by the duplicate-holder check on that inbox and exited, and the launcher said so instead of "Started":2. Dependency-gate negative arm was not hermetic (keweichen, john-the-dev)
The "no fswatch" PATH was
<fakebin>:/usr/bin:/bin; CI installs fswatch at/usr/bin/fswatch. The suite now builds a shadow directory mirroring every executable on the host PATH minus the excluded names, and the negative arm's PATH is<fakebin>:<shadow>only. On this host fswatch lives in/opt/homebrew/bin, which is onPATH, and the negative arm still passes — the exclusion is by name, not by directory.3. Timing-dependent notifier tests (keweichen's macOS 20/22)
Two changes, one of them measured here and one taken from the review:
SUTANDO_*state. On this host that leakedSUTANDO_INBOX_RESOLVERfrom a live pool worker, and the watcher handed every test task to that resolver, so four main-loop tests never saw a dispatch. Bisected one variable at a time (old harness, same scripts): full env → FAILED; only the two tmux vars unset → FAILED; onlySUTANDO_INBOX_RESOLVERunset →Ran 1 test in 12.565s OK. The harness now starts from the sharedclean_env()fixture (watch-tasks-stream test suite inherits a live pool worker's SUTANDO_* env, producing false pass/fail on ad hoc runs #4649's pattern), and the dependency-gate harness does the same.SUTANDO_STANDBY_STOP_TIMEOUT=0as reviewed, and every test waits for the watcher's sentinel to name a live pid instead of sleeping 1.5 s.Old harness vs new harness, same fixed scripts,
test_busy_between_the_outer_and_inner_idle_checks_still_retries+test_stranded_task_is_redispatched_exactly_once_by_the_next_generation:Nit from john-the-dev also taken:
submit_tasknow skips a task file that vanished before dispatch (task file gone before dispatch: <name>in the log); the 160-char header comment is two lines. Not taken: switching to a higher-priority arrival mid-retry.Live witness at
35c45eef9— default topology, real agy, alongside the canonical watcherRun from this checkout with every
SUTANDO_*/TMUX*/GIT_*variable unset (verified0visible), so the notifier resolved<workspace>/tasks-agyandresults-agyby default. The host's canonical watcher (pid 60884 onworkspace/tasks) and three pool watchers were running throughout. Full log:witness-4303.log(excerpts):Two honest notes from the run. A first launch in a folder agy has not seen shows its "trust this folder" prompt, which the onboarding seed does not cover; I pressed Enter once, and the relaunch did not ask again. And a second task (
task-witness-2) completed normally before step C because a shell-quoting error in my kill step meant no kill happened; the log says so and step C was redone with a third task.Round at
40b9f04bb— keweichen's 5371525637 on2e684ce89Owner pids must be positive.
--launch-readyparsed each owner pid straight intoos.kill(pid, 0);0and-1address the caller's group and a process set, so such a receipt passed. Each pid is now parsed and required to be> 0before the probe.Control (
test_receipt_with_non_positive_owner_pids_is_not_readiness: receipts with0/0and-1/-1planted under the live nonce through the test seam):Launcher 26/26, wiring 5/5; review-checks PASS.
Live witness G at
40b9f04bb(real agy, clean env, default topology):Round at
2e684ce89— rui's 5371075902 and jsun-m's notes on2d6bdd3d0The nonce now rides the watcher's own ready event; no clocks.
src/watch-tasks-stream.shgains a three-line seam: whenSUTANDO_WATCHER_READY_TOKENis set it writes that token to<sentinel>.tokenat the moment it stamps its pid, and releases it with the sentinel (only when the file still carries its own token). The notifier hands its launch nonce to its watcher child through that variable and signs the receipt only when the sentinel names the child and the token file carries this launch's nonce. The mtime comparison is gone. The receipt sweep matches complete.launch.<32 hex>names only, so a temp file mid-publish is never touched.Tests, per both of your notes:
test_future_dated_sentinel_holding_the_reused_child_pid_is_not_readinessjoins the crash-left case (same helper, +1 h vs −1 h).psrow whose parent is not itself a match — not the last row.Suites at
2e684ce89: dependency gate 15/15, notifier 22/22, launcher 26/26, wiring 5/5, inbox 3/3; watcher-side: trap-exit, shutdown-emit, start-lock, inbox-resolver (19), sentinel naming 30/30, sentinel ownership 26/26; review-checks PASS.Live witness F at
2e684ce89— the post-restart recovery round trip (real agy, clean env, default topology):Round at
2d6bdd3d0— keweichen's review 5370710611 and rui's on799b5e8d7(the same two defects)Sentinel ownership needs a write after the fork, not a matching number. The notifier records
time.time_ns()immediately before forking its watcher and signs the sentinel only when it names the child andst_mtime_nspost-dates that instant. A crash-left file made byte-identical by pid reuse keeps its old mtime and is refused; the receipt is published only once the child's own post-readiness write lands.Receipts are nonce-scoped paths. Each launch publishes
<sentinel>.launch.<nonce>, the launcher polls exactly that file, and an owner's exit removes only its own; grep-then-rm on a shared path is gone. Receipts whosenotifier=pid is dead are swept at the next notifier start.New real-tmux cases (
tests/agy-notifier-dependency-gate.test.py, 13 total):test_crash_left_sentinel_holding_the_reused_child_pid_is_not_readiness— after this launch's watcher forks (found by argv), its pid is written into the sentinel with a pre-launch mtime; the launcher must warn and no receipt may exist.Suites at
2d6bdd3d0: dependency gate 13/13, notifier 22/22, launcher 26/26, wiring 5/5, inbox 3/3; review-checks PASS.Live witness D at
2d6bdd3d0— the post-restart recovery round trip you asked to see repeated (real agy, clean env, default topology):On qingyun-wu's standing request (typing into a busy pane after the idle-wait timeout, at
bbc278f48):deliver_prompthas refused to send on a timed-out idle wait sinceae49d7019(wait_for_core_idle || { log; return 1; }), and the main loop retries in place;test_busy_pane_beyond_ready_timeout_sends_nothingandtest_busy_between_the_outer_and_inner_idle_checks_still_retriespin it. It has been re-asked on this head.Round at
799b5e8d7— keweichen's launch-generation blocker (review 5370264505)Design, as requested. The launcher mints a nonce per launch (
od /dev/urandom) and binds it into the notifier's tmux session. The notifier, after forking its watcher, waits until the ready sentinel names exactly that child's pid, then atomically publishes a receipt beside the sentinel (<sentinel>.launch: nonce, inbox, watcher pid, notifier pid). The launcher pollstask-notifier.sh --launch-ready <nonce>, which requires an exact nonce and inbox match and live owner pids — no mtime, no elapsed time, no argv substrings. On exit the notifier removes the receipt only while it still carries its own nonce. One consequence surfaced on the way: the notifier must open the event FIFO's read end right after the fork, because the watcher's open-for-write blocks until a reader exists.Controls (real tmux; each with a silent fswatch and a fresh sentinel naming a live watcher-shaped process on this exact inbox, dated into the launch, so the previous predicate reads it as ready and only the receipt can discriminate): same-second prior generation, concurrent foreign generation (another nonce, live pids, this inbox), stale receipt with dead pids, malformed and partial receipts, receipt for an adjacent inbox (
tasks-other), the three stale-sentinel shapes, and a positive case asserting the receipt names the sentinel's pid and is removed by its owner's exit.Suites at
799b5e8d7: dependency gate 12/12, notifier 22/22, launcher 26/26, stub-tmux wiring 5/5, inbox scope 3/3; review-checks PASS.Live witness C at
799b5e8d7(real agy, clean env, default topology, canonical watcher running):Round at
7bedc32f7— keweichen's stale-sentinel blocker (review 5369909459)Fix. Readiness is now an ownership witness, computed next to the sentinel resolution:
task-notifier.sh --sentinel-ready <launch epoch>answers 0 only when the sentinel's mtime is at or after the launch began and its pid is awatch-tasks-stream.shon this inbox whose elapsed time (ps -o etime=, the discriminatorsrc/watcher_sentinel.shnames) fits inside the launch. The launcher records the epoch beforetmux new-sessionand polls that predicate; barekill -0is gone.Regressions (real tmux, silent fswatch, sentinel preseeded with an hour-old mtime so only the stale file can speak): a live unrelated pid, a dead pid, a malformed payload.
Suites at
7bedc32f7: dependency gate 6/6 (was 3), notifier 22/22, inbox scope 3/3, launcher 26/26, stub-tmux wiring 5/5; review-checks PASS.Live witness B at
7bedc32f7(real agy, clean env, defaulttasks-agy/results-agy, canonical watcher running):Tests run at
35c45eef9(this host, macOS)tests/agy-task-notifier.test.pytests/agy-notifier-dependency-gate.test.pytests/agy-notifier-inbox-scope.test.pytests/agy-start-cli.test.pygit diff origin/main...HEAD | bash scripts/review-checks.sh→PASS (hardcoded-paths + root-artifacts + prose-cap clean).bash -non both scripts clean.Scope explicitly NOT in this PR
No scheduler/cron integration (slice 3). No core-selection wiring, no health-check integration (slice 4). The trust-prompt gap above is filed separately.
🤖 Generated with Claude Code