Skip to content

feat(agy-core): Slice 2 — task-file-injection notifier for agy (Antigravity CLI) - #4303

Merged
sonichi merged 28 commits into
mainfrom
feat/agy-core-slice2-task-injection
Oct 6, 2026
Merged

sonichi merged 28 commits into
mainfrom
feat/agy-core-slice2-task-injection

Conversation

@sonichi

@sonichi sonichi commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

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-exiting watch-tasks-stream.sh the way Claude's Monitor does. src/agent/agy/cli/task-notifier.sh runs that watcher externally, on a separate default inbox (<workspace>/tasks-agy → <workspace>/results-agy, its own --role session --inbox and instance identity, so it never touches the canonical watcher or its sentinel), and injects each task into the pane with tmux send-keys. start-cli.sh starts it once per core session and recycles a stale generation on a fresh core launch. Completion detection and priority selection are owned by src/delivery/task_dispatch.py (shared with Codex), not copied here.

Stack status. Parent #4275 is merged; this branch has origin/main merged 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-path query mode
  • src/agent/agy/cli/start-cli.sh — ensure_task_notifier now waits on the watcher's readiness contract
  • tests/agy-notifier-dependency-gate.test.py — hermetic PATH, new silent-fswatch case (real tmux)
  • tests/agy-task-notifier.test.py — harness starts from tests/fixtures/clean_watcher_env.py, syncs on the sentinel
  • src/agent/agy/README.md — scope doc

The three blockers from the be02db05e reviews, at head 35c45eef9

1. 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_notifier now 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, or SUTANDO_WATCHER_READY_TIMEOUT+2 s 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):

# parent launcher (before the fix), same test:
AssertionError: 'did not report ready' not found in 'seeded: .../onboarding.json
Started sutando-agy-depgate-test detached. Attach via: ...'
Ran 1 test in 2.400s — FAILED (failures=1)

# head:
Ran 3 tests in 5.406s — OK

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":

  ⚠ agy task notifier exited: its watcher did not report ready — tasks will not reach this session

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 on PATH, 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:

  • The harness inherited the invoking shell's SUTANDO_* state. On this host that leaked SUTANDO_INBOX_RESOLVER from 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; only SUTANDO_INBOX_RESOLVER unset → Ran 1 test in 12.565s OK. The harness now starts from the shared clean_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.
  • The 15 s standby handoff keweichen measured is not reproducible here (no standby exists in the harness), so the harness sets SUTANDO_STANDBY_STOP_TIMEOUT=0 as 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:

old: FAILED (failures=2)   # "the outer idle check never ran" / "generation 1 never dispatched the task"
new: Ran 4 tests in 61.304s — OK   # those two plus the other two main-loop tests

Nit from john-the-dev also taken: submit_task now 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 watcher

Run from this checkout with every SUTANDO_*/TMUX*/GIT_* variable unset (verified 0 visible), so the notifier resolved <workspace>/tasks-agy and results-agy by default. The host's canonical watcher (pid 60884 on workspace/tasks) and three pool watchers were running throughout. Full log: witness-4303.log (excerpts):

### step A — 17:02:30Z: launch
Started sutando-agy detached. ... rc=0 elapsed=1s
sutando-agy: 1 windows / sutando-agy-watcher: 1 windows
sentinel watch-tasks-stream-local-agent+agy-task-notifier.pid -> pid 2606 alive=yes
  cmd=bash .../src/watch-tasks-stream.sh /.../workspace/tasks-agy ...
default dirs: workspace/results-agy workspace/tasks-agy

### step B — 17:03:18Z: drop task-witness-1.txt into tasks-agy
result after 16s: results-agy/task-witness-1.txt = PONG

### step C — 17:07:39Z: restart handoff
dropped task-witness-3.txt (task: `sleep 45`, then write PONG3)
prompt visible in the agy pane after 6s ... esc to cancel
killing the agy core session mid-task at 17:07:45Z (result file: absent)
sessions now: sutando-agy-watcher only          # gen1 watcher survives the core crash
### step C part 2 — 17:08:18Z: relaunch
gen1 watcher pid: 2606 → Started sutando-agy detached. rc=0 elapsed=1s → gen2 watcher pid: 76802 alive=yes
result after 63s: results-agy/task-witness-3.txt = 'PONG3'
'Sutando task ready: task-witness-3' occurrences in gen2's pane history: 1

### teardown — 17:09:53Z
agy sessions left: 0
canonical watcher 60884 and its sentinel: unchanged; pool watchers unchanged
  (one pool worker re-armed its own inbox watcher at 17:07:20Z, 25 s before my first kill, on an inbox no step touched)

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 on 2e684ce89

Owner pids must be positive. --launch-ready parsed each owner pid straight into os.kill(pid, 0); 0 and -1 address the caller's group and a process set, so such a receipt passed. Each pid is now parsed and required to be > 0 before the probe.

Control (test_receipt_with_non_positive_owner_pids_is_not_readiness: receipts with 0/0 and -1/-1 planted under the live nonce through the test seam):

# notifier at 2e684ce89:  FAIL — 'did not report ready' not found in '...Started sutando-agy-depgate-test detached...'
# notifier at 40b9f04bb:  Ran 16 tests — OK

Launcher 26/26, wiring 5/5; review-checks PASS.

Live witness G at 40b9f04bb (real agy, clean env, default topology):

launch rc=0 elapsed=1s; gen1 watcher: 53526; token=ed59284c…; receipts: 1
dropped task-witness-9 at 20:37:01Z
prompt in pane after 6s; footer: esc to cancel
--- killing the agy core mid-task at 20:37:07Z (result: absent)
sessions now: sutando-agy-watcher 
### relaunch at 20:37:10Z
relaunch rc=0 elapsed=1s; gen2 watcher: 56205; gen2 token=c9570d1a…; receipts now: 1
result after 64s: results-agy/task-witness-9.txt = 'PONG9'
'Sutando task ready: task-witness-9' in gen2's pane history: 1
### teardown
agy sessions left: 0; receipts left: 0; token left: 0

Round at 2e684ce89 — rui's 5371075902 and jsun-m's notes on 2d6bdd3d0

The nonce now rides the watcher's own ready event; no clocks. src/watch-tasks-stream.sh gains a three-line seam: when SUTANDO_WATCHER_READY_TOKEN is set it writes that token to <sentinel>.token at 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_readiness joins the crash-left case (same helper, +1 h vs −1 h).
  • The watcher child is found as the root of its process tree — the matching ps row whose parent is not itself a match — not the last row.
  • The stale/dead-pid, malformed/partial and adjacent-inbox receipt cases now plant their file under the live launch's nonce (read from the watcher session's environment while the launcher polls), and a positive control asserts a well-formed matching receipt planted the same way is accepted — so each refusal is the parse/match branch, not a file the launcher never read.
# scripts at 2d6bdd3d0, the two reused-pid cases, run twice:
FAIL: test_future_dated_sentinel_holding_the_reused_child_pid_is_not_readiness   (both runs; crash-left passes there)
# scripts at 2e684ce89, full gate, run twice:
Ran 15 tests — OK   /   Ran 15 tests — OK

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):

launch rc=0 elapsed=1s; gen1 watcher: 1039; token=d3384672…; receipts: 1
dropped task-witness-8 (sleep 45, then PONG8) at 20:02:17Z; prompt in pane after 7s; footer: esc to cancel
killing the agy core mid-task at 20:02:24Z (result: absent)   → sessions now: sutando-agy-watcher
relaunch at 20:02:27Z: rc=0 elapsed=1s; gen2 watcher: 3865; gen2 token=70cd9d44…; receipts now: 1
result after 73s: results-agy/task-witness-8.txt = 'PONG8'
'Sutando task ready: task-witness-8' in gen2's pane history: 1
teardown: agy sessions left: 0; receipts left: 0; token left: 0

Round at 2d6bdd3d0 — keweichen's review 5370710611 and rui's on 799b5e8d7 (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 and st_mtime_ns post-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 whose notifier= 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.
  • the positive case now also seeds another generation's receipt beside the owner's and asserts the owner's exit leaves it in place.
# scripts at 799b5e8d7, these two cases:
FAIL: test_crash_left_sentinel_holding_the_reused_child_pid_is_not_readiness
AssertionError: 'did not report ready' not found in '...Started sutando-agy-depgate-test detached...'
FAIL: test_ready_launch_publishes_a_receipt_for_its_own_watcher_and_removes_it_on_exit  (receipt path scheme)
# scripts at 2d6bdd3d0:
Ran 13 tests in 30.297s — OK

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):

launch rc=0 elapsed=1s; gen1 watcher: 86453; receipts: 1
dropped task-witness-6 (sleep 45, then PONG6) at 19:14:45Z; prompt in pane after 6s; footer: esc to cancel
killing the agy core mid-task at 19:14:51Z (result: absent)   → sessions now: sutando-agy-watcher
relaunch at 19:14:54Z: rc=0 elapsed=2s; gen2 watcher: 89252; receipts now: 1
result after 64s: results-agy/task-witness-6.txt = 'PONG6'
'Sutando task ready: task-witness-6' in gen2's pane history: 1
teardown: agy sessions left: 0; receipts left: 0

On qingyun-wu's standing request (typing into a busy pane after the idle-wait timeout, at bbc278f48): deliver_prompt has refused to send on a timed-out idle wait since ae49d7019 (wait_for_core_idle || { log; return 1; }), and the main loop retries in place; test_busy_pane_beyond_ready_timeout_sends_nothing and test_busy_between_the_outer_and_inner_idle_checks_still_retries pin 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 polls task-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.

# scripts at 7bedc32f7, this test file:
FAIL: test_same_second_prior_generation_is_not_readiness
FAIL: test_concurrent_foreign_generation_receipt_is_not_readiness
FAIL: test_stale_receipt_with_dead_pids_is_not_readiness
FAIL: test_malformed_and_partial_receipts_are_not_readiness
FAIL: test_receipt_for_an_adjacent_inbox_is_not_readiness
FAIL: test_ready_launch_publishes_a_receipt_for_its_own_watcher_and_removes_it_on_exit
Ran 12 tests in 21.408s — FAILED (failures=6)
# scripts at 799b5e8d7:
Ran 12 tests in 26.243s — OK

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):

Started sutando-agy detached. ... rc=0 elapsed=1s
sentinel -> 65985
receipt: nonce=525a5c38…  inbox=…/workspace/tasks-agy  watcher=65985  notifier=65882
--launch-ready with the session's nonce: ready; with a foreign nonce: not-ready
result after 16s: results-agy/task-witness-5.txt = 'PONG5'
agy sessions left: 0; receipt after the owner's exit: removed

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 a watch-tasks-stream.sh on this inbox whose elapsed time (ps -o etime=, the discriminator src/watcher_sentinel.sh names) fits inside the launch. The launcher records the epoch before tmux new-session and polls that predicate; bare kill -0 is 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.

# scripts at 35c45eef9, same three tests:
FAIL: test_stale_sentinel_naming_a_live_unrelated_pid_is_not_readiness
AssertionError: 'did not report ready' not found in 'seeded: ...\nStarted sutando-agy-depgate-test detached. Attach via: ...'
Ran 3 tests in 9.125s — FAILED (failures=1)      # dead/malformed already failed safe
# scripts at 7bedc32f7:
Ran 3 tests in 13.003s — OK

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, default tasks-agy/results-agy, canonical watcher running):

Started sutando-agy detached. ... rc=0 elapsed=1s
sentinel -> pid 91042  etime=00:01  cmd=bash .../src/watch-tasks-stream.sh ...
ownership witness now: ready
same witness with a launch epoch 1h in the future (must refuse): not-ready
result after 18s: results-agy/task-witness-4.txt = 'PONG4'
agy sessions left: 0

Tests run at 35c45eef9 (this host, macOS)

suite result
tests/agy-task-notifier.test.py 22 tests, OK (91 s)
tests/agy-notifier-dependency-gate.test.py 3 tests, OK (real tmux; 2 before)
tests/agy-notifier-inbox-scope.test.py 3 tests, OK
tests/agy-start-cli.test.py 26 tests, OK

git diff origin/main...HEAD | bash scripts/review-checks.sh → PASS (hardcoded-paths + root-artifacts + prose-cap clean). bash -n on 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

@sonichi

sonichi commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

@sonichi flagging — Slice 2 of #4272, stacked on #4275 (parent still has open review blockers, noted accurately in the PR body). Real end-to-end path proven against the real agy binary; details and evidence in the PR body.

@sonichi
sonichi requested a review from keweichen September 16, 2026 02:22
@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/delivery/task_dispatch.py (100%)

Summary

  • Total: 8 lines
  • Missing: 0 lines
  • Coverage: 100%

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. src/agent/agy/cli/task-notifier.sh:9-14,206-220 starts another canonical workspace/tasks watcher, while src/agent/agy/README.md:45-48 confirms agy is not selected in place of the running Claude/Codex core. The separate sutando-agy session therefore leaves the existing core watcher alive. docs/proactive-loop-rationale.md:728 documents 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.

  2. task-notifier.sh:58-107 copies result/queue policy instead of delegating to a shared core owner, contrary to docs/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.

  1. task-notifier.sh:139-162 treats the filename marker anywhere in the last 20 pane rows as proof that the current paste staged, although :163-164 correctly 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.

  1. The new live path hard-requires fswatch, but src/agent/agy/cli/start-cli.sh:100-108 checks 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.

sonichi added a commit that referenced this pull request Sep 16, 2026
…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
@sonichi

sonichi commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

Fixed all 4 blockers, at 5d598bf953dd (two commits: 60b85be5a for the blockers themselves, 5d598bf95 for two CI gates that commit's own new file tripped — see below).

Verified each blocker independently before trusting the fix, in an isolated worktree:

  • Add demo video to README #1 (double-processing): confirmed — src/agent/agy/README.md itself states agy isn't wired into core selection. Fixed: task-notifier.sh now defaults to a separately-owned tasks-agy/results-agy inbox instead of the canonical dirs.
  • temp: demo video upload #2 (duplicated, wrong policy): confirmed by direct repro — manually exercised the new src/delivery/task_dispatch.py against a zero-byte result and a misleadingly-named archive file (task-probe-1-other.txt vs task-probe.txt); both now correctly resolve to "not ready" / "no match". Both agy's and Codex's notifiers now delegate to this one shared module (local_task_protocol.find_result + delivery.readiness.read_ready_result, not reimplemented).
  • Inline demo video in README #3 (stale-history false positive): prompt_is_staged() now captures a baseline line-count immediately before each type attempt and only matches lines added since.
  • README: inline demo video #4 (missing fswatch check): start-cli.sh now checks for fswatch before launch and polls ~2s after to confirm the watcher session survived.

Caught two things the first push missed (CI hadn't settled when the fix was first pushed): docs/src-map.md was stale after adding task_dispatch.py (regenerated), and lint-workspace-resolution flagged the new file's sys.path bootstrap — legitimate (it reaches src/ for sibling imports, not the user's data workspace; needs .parent.parent since it's one directory deeper than task_priority.py, whose identical pattern only needs .parent), so used the lint's own allow-repo-root pragma rather than restructuring.

Regression check: tests/codex-core-launcher.test.py is 33/34 — the one failure reproduces identically on the unmodified pre-fix head (confirmed via a separate worktree at dc11898c1), so it's pre-existing and unrelated to this change. All 4 new/updated test files (agy-notifier-inbox-scope, task-dispatch-contract, agy-task-notifier, agy-notifier-dependency-gate) genuinely green. review-checks.sh --diff clean.

Stand: Echo Act IV Mini

@sonichi
sonichi requested a review from keweichen September 16, 2026 04:27

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. src/agent/agy/cli/task-notifier.sh:113-149 detects a newly staged prompt by comparing the line count from tmux 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.

  1. The shared dispatch extraction regresses an existing Codex result contract. Parent src/agent/codex/cli/task-notifier.sh recognized epoch-suffixed results inside sibling retention directories, but src/delivery/task_dispatch.py:61-67 now delegates to local_task_protocol.find_result(), whose retention-directory branch at src/local_task_protocol.py:608-616 checks only the exact filename. For archive-2026-07-26/task-done-1784690000.txt, the shared helper returned find_result=None and has_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.

  1. The new default agy inbox is not propagated through the whole watcher contract. src/agent/agy/cli/task-notifier.sh:16-29,195-199 selects tasks-agy/results-agy, but starts watch-tasks-stream.sh without exporting the resolved results directory or assigning a distinct SUTANDO_INSTANCE_ID. Consequently src/watch-tasks-stream.sh:113-117 still consults canonical workspace/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.

@sonichi

sonichi commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

Confirmed — thanks for the catch. Fixed in 7dddf56.

What was wrong

prompt_is_staged sliced the capture with tail -n "+$((baseline+1))", where baseline was pane_line_count() — the row count of tmux capture-pane -p. A real visible-pane capture (no -S/-E) always returns the pane's fixed row count, padded with blank lines, regardless of how much is typed. So the offset always pointed past the end of the capture, prompt_is_staged never returned true, and deliver_prompt always fell through to the retry — pasting the whole prompt a second time into the still-uncommitted composer line.

The fix

prompt_is_staged now takes the full pane TEXT as baseline (not a line count) and detects staging by diffing baseline against the current capture, requiring the marker to appear on a line diff reports as added. 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 — same guarantee the line-count offset was trying (and failing) to provide.

Also fixed the regression test: its fake capture-pane was a plain file that grew when text was typed, unlike a real tmux pane, which is exactly why it missed this. Rebuilt it to render a fixed-row-count pane (history file + a single composer row that concatenates on -l and commits on Enter), matching real tmux's behavior. All 10 notifier tests still pass, including the two most relevant to this bug (test_dropped_paste_is_retyped, test_stale_history_marker_does_not_count_as_staged).

Real-tmux verification (before / after)

Same probe shape as your review: a real tmux session, capture-pane -p before and after typing.

Precondition, both runs — row count never changes:

$ tmux new-session -d -s probe -x 80 -y 24
$ tmux capture-pane -t probe -p | wc -l   # 24
$ tmux send-keys -t probe -l "hello world this is a test line"
$ tmux capture-pane -t probe -p | wc -l   # 24, unchanged

BEFORE (PR head 2eee4dcba, unmodified task-notifier.sh --event, real tmux session, real dispatch of a task file):

agy-task-notifier: typed prompt for task-orig1.txt did not stage; retyping
agy-task-notifier: prompt for task-orig1.txt sent unverified (never observed staged)

Final pane content — the prompt is duplicated verbatim:

agy-fake> Sutando task ready: task-orig1.txt. Read /tmp/agy-real-probe2/tasks/ta
sk-orig1.txt, follow AGENTS.md, complete the task, and write the result to /tmp/
agy-real-probe2/results/task-orig1.txt.Sutando task ready: task-orig1.txt. Read
/tmp/agy-real-probe2/tasks/task-orig1.txt, follow AGENTS.md, complete the task,
and write the result to /tmp/agy-real-probe2/results/task-orig1.txt.
zsh: command not found: Sutando

AFTER (this commit, same real-tmux harness, same task dispatch):

agy-task-notifier: prompt not yet submitted after Enter for task-real1.txt; re-pressing once

(That log line is the unrelated SUBMIT_CONFIRM_TIMEOUT re-press — the fake shell in the probe never goes "busy" or writes a result promptly, so the notifier re-presses Enter once. It is not a staging failure — no "did not stage; retyping" line appears.)

Final pane content — the prompt appears exactly once:

agy-fake> Sutando task ready: task-real1.txt. Read /tmp/agy-real-probe/tasks/tas
k-real1.txt, follow AGENTS.md, complete the task, and write the result to /tmp/a
gy-real-probe/results/task-real1.txt.
zsh: command not found: Sutando

Test results

$ python3 tests/agy-task-notifier.test.py -v
...
Ran 10 tests in 24.857s
OK
$ python3 tests/agy-notifier-dependency-gate.test.py -v   # 2/2 OK
$ python3 tests/agy-notifier-inbox-scope.test.py -v       # 3/3 OK
$ python3 tests/agy-start-cli.test.py -v                  # 19/19 OK
$ python3 tests/task-dispatch-contract.test.py -v         # 24/24 OK
$ git diff | bash scripts/review-checks.sh                # PASS

Re-requesting your review.

Stand: Echo Act IV Mini

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The new staging check still duplicates prompts when a production-shaped filename wraps. prompt_is_staged diffs fixed-height pane text, then requires the whole Sutando task ready: $filename marker on one physical row. In a real isolated tmux 80x24 pane at this head, using the existing 70-character task-name shape task-cron-sutando-life-kewei-overview-hourly-screenshot-1787587200.txt, both captures remained 24 rows but the notifier logged did not stage; retyping and sent unverified; capture-pane -J counted 2 concatenated prompt occurrences. The 120-column positive control counted 1 and emitted no type-retry warning. The fixed-row fake at tests/agy-task-notifier.test.py:66-106 does 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.

  2. An epoch-suffixed result in a retention archive is still treated as pending and replayed. The new shared has_ready_result correctly delegates to find_result, but find_archived_result checks only the exact <id>.txt name inside sibling archive-YYYY-MM-DD/ directories; unlike the month and flat layouts, it never checks _epoch_suffixed(...). Exact-head failure fixture: with results/archive-2026-09-16/task-done-1789570000.txt, task_dispatch.py has-result ... task-done.txt exits 1, and next-pending exits 0 with task-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.

  3. The agy watcher still does not inherit its result namespace or a distinct instance identity. task-notifier.sh:192-194 passes only TASKS_DIR to the shared watcher. It does not export the already-resolved RESULTS_DIR or set SUTANDO_INSTANCE_ID, so watch-tasks-stream.sh:113-117 falls back to canonical workspace/results and :527-534 stamps the canonical sentinel. Exact-head initial-scan repro with a task only in tasks-agy: a same-basename canonical result produced no stdout and already answered, not dispatching again; the correctly scoped control emitted TASK_FILE: task-namespace.txt. Sentinel names were respectively watch-tasks-stream.pid versus scoped watch-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.

sonichi added a commit that referenced this pull request Sep 17, 2026
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>
@sonichi

sonichi commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner Author

(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.)

sonichi added a commit that referenced this pull request Sep 18, 2026
…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
sonichi added a commit that referenced this pull request Sep 18, 2026
…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>
sonichi added a commit that referenced this pull request Sep 18, 2026
…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>
sonichi added a commit that referenced this pull request Sep 18, 2026
…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
@sonichi
sonichi force-pushed the feat/agy-core-slice2-task-injection branch 2 times, most recently from 8b7b940 to cd3a25f Compare September 18, 2026 04:04
@sonichi

sonichi commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

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 src/delivery/task_dispatch.py (a real, genuine collision -- same defect, same fix, discovered twice). #4317's version on main is the more thorough one (extra find_ready_result_for_filename, --claims-dir support, more careful epoch-suffix handling via iter_result_candidates) and is already 2-maintainer-approved, so I kept main's version and dropped the two commits here that only existed to build/patch the now-superseded local copy (60b85be5a's task_dispatch.py-specific half, 5d598bf95, 2eee4dcba -- their non-duplicate fixes, e.g. the double-processing/stale-history/fswatch-check fixes in the first one, are kept). agy's own task-notifier.sh already called the shared module's CLI (has-result/next-pending) correctly -- since main's version is interface-compatible, no changes were needed there at all once the module itself was resolved.

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 feat/agy-core-slice1-launcher, not main) and broke mergeability against the real base -- caught via mergeStateStatus: CONFLICTING after pushing, reverted, and redid it as a merge instead (preserves #4275's commits byte-identical, brings in main's history alongside rather than replacing it). Also caught a merge artifact before the second push: docs/src-map.md auto-merged with literal unresolved conflict markers still in the file (git resolved it at the line level even though both sides added adjacent entries) -- regenerated via scripts/gen-src-map.py rather than trusting the auto-merge.

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. codex-core-launcher.test.py has 1 pre-existing host-timing flake (test_launches_codex_and_managed_task_notifier) -- confirmed the file is byte-identical to main, so this isn't something introduced here; matches the same flake keweichen's own review of #4317 already documented on that PR. shellcheck clean, git diff --check clean, review-checks.sh clean, no stray merge-conflict markers anywhere in the tree (checked).

mergeable is MERGEABLE again as of this push.

@sonichi

sonichi commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Two CI jobs are failing on a precondition, not a real check: tsc + tests (clean install) and typecheck + node & shell tests both fail at "Fetch PR diff (precondition)" with HTTP 406: Sorry, the diff exceeded the maximum number of lines (20000) -- explicitly logged as "This is NOT a review-checks finding — nothing was scanned."

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonichi

sonichi commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Rebased the stack per your ask: updated the parent #4275 via gh pr update-branch (a merge, not a rewrite — its own 4 real commits stay intact, verified ca0cbbc47 remains 4 ahead / 0 behind main), then merged that into this branch (76fbef8b5).

gh pr update-branch itself reported a conflict on this PR, but a local git merge origin/feat/agy-core-slice1-launcher went through clean with an empty tree diff — this branch had already picked up the same main content via its own earlier merge (25fd304ec), so the new commit only records the ancestry link to #4275's updated tip. Verified git merge-base --is-ancestor for both #4275's new head and main before pushing, and swept the tree for stray conflict markers (none).

The diff-size cap is cleared: gh pr diff 4303 now returns 1093 lines (was >20000). CI is re-running fresh against the new head with no precondition failures — all jobs currently pending, none fail.

@sonichi

sonichi commented Oct 1, 2026

Copy link
Copy Markdown
Owner Author

New head: 0cce299057ea557211bddc5e5935ccc1bf07fa68 (was 57d27f572; force-pushed with lease, rebase required it).

Hardening: task name validated before anything is typed

submit_task in src/agent/agy/cli/task-notifier.sh previously filtered only / and .. before the filename was typed into the agy pane (which runs with --dangerously-skip-permissions). It now accepts only names matching ^[A-Za-z0-9._-]+\.txt$; anything else is refused with a logged line and nothing is sent:

[[ "$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 submit_task, the single funnel both --event <name> and the event loop's next_pending_task pick go through, so the check precedes every use of the name (has_result, the -f probe, the prompt string, send-keys). Behaviour is otherwise unchanged.

Tests added to tests/agy-task-notifier.test.py (EventDispatchTests, same clean_env() fixture as the other cases):

  • 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 ../ prefix; asserts the send-keys log stays empty and the refusal appears 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.

Mutation check: with the old case filter restored, the first test fails (the newline name was typed into the pane); with the fix it passes.

Rebase onto origin/main (1be758ba5)

The branch is stacked on slice 1 (squash-merged as #4275) and carried 7 merge commits, so a plain git rebase origin/main replayed slice-1 and main history and add/add-conflicted. Replayed instead the 26 slice-2-only commits in order onto origin/main. Conflicts and how they were resolved:

  • Files already in main through other PRs (src/delivery/task_dispatch.py, src/agent/codex/cli/task-notifier.sh, tests/task-dispatch-contract.test.py, tests/codex-core-launcher.test.py): main's version (their net change in this PR is empty). One commit (2eee4dcba, task_dispatch tests) became empty and was dropped.
  • src/agent/agy/cli/start-cli.sh at the first slice-2 commit: ensure_task_notifier placed before main's attach_or_report_existing.
  • The branch's merge commit a1fb5312d had no slot in a linear history; its effect on start-cli.sh (the attach_or_report_existing helper + new-session race re-check) and docs/src-map.md is carried as one commit (7db27eee1) at the point the merge sat, so every later commit applies on what it was authored against.

Verification: git diff origin/main...HEAD before and after the rebase is byte-identical apart from the hardening commit (0 delta lines, same 9 files). #4976 is still open, so src/watch-tasks-stream.sh on main is unchanged relative to this branch's seam.

Test summaries at 0cce29905 (scrubbed env)

=== tests/agy-task-notifier.test.py
Ran 24 tests in 95.276s
OK
=== tests/agy-notifier-dependency-gate.test.py
Ran 17 tests in 67.822s
OK
=== tests/agy-notifier-inbox-scope.test.py
Ran 3 tests in 0.026s
OK
=== tests/agy-start-cli.test.py
Ran 26 tests in 18.278s
OK
=== git diff origin/main...HEAD | bash scripts/review-checks.sh
prose-cap: PASS (comment blocks within cap 2, exts .py)
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:228 refuses a name outside ^[A-Za-z0-9._-]+\.txt$ and returns 0.
  • The live loop (:309-312) does filename="$(next_pending_task)" and then while ! 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 newer task-plain.txt gave three next-pending calls that all returned the ~ file. task-plain.txt was 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 pointing SUTANDO_TASKS_DIR at the shared tasks/ 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 57d27f572 diff 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, so ok.txt\nrm is rejected. A non-ASCII name like tâsk.txt is rejected under UTF-8.
  • The refusal is logged with printf '%q', so no raw control characters reach the log.
  • submit_task is the only path to deliver_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 --diff gives 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>
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner Author

Addressed both on 399919f934542af0eacaecad3050e7870b44beb7.

(a) refused name at the queue head (rui) — the rule now lives once in Python: TASK_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+\.txt$") in src/delivery/task_dispatch.py, applied in pending_candidates / next_pending_task under a new --typable / typable_only=True, which the agy notifier's next_pending_task() passes. The shell backstop at the top of submit_task is unchanged and is pinned equal to the Python pattern by NameRuleIsSharedTest.test_backstop_regex_is_the_dispatch_policy_regex (tests/agy-task-notifier.test.py).

Why opt-in and not the default: tightening pending_candidates unconditionally regressed the Claude notifier — tests/claude-task-notifier-inflight.test.py::test_a_staged_prompt_two_pending_tasks_could_own_is_not_resumed writes task-a b.txt and needs pending-candidates to still list it for staged_prompt_is_ambiguous (passes at the parent, timed out with the unconditional filter, passes again with the flag). The Claude notifier types such names safely behind its staged-exact check; the agy pane does not, so only agy asks for the filter.

New tests:

  • tests/task-dispatch-contract.test.py::PendingCandidatesTest.test_a_refused_name_sorting_ahead_of_a_valid_one_is_never_offered_under_typable (~, space, $(id), newline names; default offer unchanged)
  • tests/task-dispatch-contract.test.py::MainDispatchTest.test_next_pending_typable_skips_an_older_refused_name_for_the_newer_plain_one (CLI; --typable twice is a usage error)
  • tests/agy-task-notifier.test.py::MainLoopWiringTest.test_an_older_refused_name_does_not_stall_the_newer_plain_task (real fswatch loop) — fails on the parent 0cce29905 (AssertionError: the refused name at the queue head stalled the plain task behind it), passes at HEAD.

(b) newline fixture (qingyun-mini) — "task-a.txt\nrm -rf x" → "task-a\nrm -rf x.txt": ends in .txt, still carries the newline, still refused.

Probe (older task-dc123~456.txt with mtime 2026-09-01, newer task-plain.txt):

# parent 0cce29905
$ task_dispatch.py next-pending tasks results
task-dc123~456.txt
rc=0

# HEAD 399919f93, what the agy notifier now calls
$ task_dispatch.py next-pending tasks results --typable
task-plain.txt
rc=0
# HEAD, default (Claude notifier path) — unchanged
$ task_dispatch.py next-pending tasks results
task-dc123~456.txt
rc=0

Runs at HEAD: task-dispatch-contract 94 OK · agy-task-notifier 26 OK · agy-notifier-dependency-gate 17 OK · agy-notifier-inbox-scope 3 OK · agy-start-cli 26 OK · codex-task-notifier-worker-holds 4 OK · ruff check clean · scripts/review-checks.sh PASS. claude-task-notifier-inflight is red on this host at the parent as well (test_filenames_differing_only_in_whitespace_are_distinct_tasks, 0 != 2 at both parent and HEAD with SUTANDO_INBOX_RESOLVER unset) — not attributable to this change; the ambiguity test named above passes at both.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: returns task-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.py at head: Ran 26, OK. CI: 28 pass, 1 skipped.
  • Locally, tests/claude-task-notifier-inflight.test.py failed at this head with 6 failures and 1 error. Before attributing that, I ran the same file at origin/main on the same host: 9 failures, 1 error, including RePickTests and MainLoopWiringTest.
  • So it's a local timing/environment problem shared with main, not this PR. CI passes it at head.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonichi

sonichi commented Oct 1, 2026

Copy link
Copy Markdown
Owner Author

Live witness at exact head 399919f934542af0eacaecad3050e7870b44beb7 (refused name ahead of a valid task)

This answers the current-head live-path request: the real agy and the real notifier ran from a detached worktree at 399919f93. I captured a post-restart round trip in which an older task with a refused name sat ahead of a newer valid one. The run was 2026-10-01 17:24:43–17:26:54Z. The inbox, results, state and tmux server were isolated. The owner's running services were not touched or restarted.

head:     399919f934542af0eacaecad3050e7870b44beb7   (git rev-parse HEAD in the detached worktree)
agy:      1.2.14, Gemini 3.1 Pro (Low), launched by the PR's own start-cli.sh with production flags (--dangerously-skip-permissions)
tmux:     own server on /tmp/sut-wit-4303k.sock, started with `tmux -v`, so its server log records every command a client sends (independent send-keys log)
env:      every inherited SUTANDO_* var unset, plus TMUX/TMUX_PANE/AGENT_ID/AGENT_MXID; only the four overrides below were set

Commands (<scratch> = a scratch dir; <wt> = the detached worktree inside it)

git worktree add --detach <wt> 399919f934542af0eacaecad3050e7870b44beb7
mkdir -p <scratch>/{tasks,results,state,logs}
cleanenv tmux -v -S /tmp/sut-wit-4303k.sock new-session -d -s witkeep 'sleep 7200'   # verbose server, keepalive session
cd <wt> && cleanenv SUTANDO_AGY_TMUX_SOCKET=/tmp/sut-wit-4303k.sock SUTANDO_AGY_TMUX_SESSION=wit4303agy \
  SUTANDO_TASKS_DIR=<scratch>/tasks SUTANDO_RESULTS_DIR=<scratch>/results \
  bash src/agent/agy/cli/start-cli.sh

cleanenv is env -u <every SUTANDO_* var> -u TMUX -u TMUX_PANE -u AGENT_ID -u AGENT_MXID -u AGENT_DISPLAY_NAME "$@". The notifier was not started by hand. start-cli.sh started it in wit4303agy-watcher with its launch nonce, and returned only after --launch-ready accepted this launch's receipt.

Timeline

17:24:43Z  worktree HEAD=399919f934542af0eacaecad3050e7870b44beb7
17:24:44Z  RESTART: fresh tmux server + start-cli.sh from the worktree (agy session + notifier session)
17:24:45Z  start-cli.sh rc=0   "already seeded: …/onboarding.json"  "Started wit4303agy detached."
           state/: watch-tasks-stream-…+agy-task-notifier.pid, .pid.token, .pid.launch.<32-hex nonce>   (receipt published)
17:24:54Z  agy showed the first-launch "Do you trust the contents of this project?" prompt for the scratch worktree (#4962); accepted with Enter
           pane then idle: "? for shortcuts"
17:25:13Z  wrote OLDER  tasks/task-dc123~456.txt        (refused name: `~` is outside [A-Za-z0-9._-])
17:25:15Z  wrote NEWER  tasks/task-plain-a130c5b7.txt   (asks for marker WITNESS-4303-06bca38693f952e6 in results/task-plain-a130c5b7.txt)
17:25:15Z  read-only probe on the same inbox at that moment:
             task_dispatch.py next-pending <tasks> <results>            -> task-dc123~456.txt   (the refused name IS the FIFO head)
             task_dispatch.py next-pending <tasks> <results> --typable  -> task-plain-a130c5b7.txt
17:25:15.812Z  send-keys -l -t wit4303agy "Sutando task ready: task-plain-a130c5b7.txt. Read <scratch>/tasks/task-plain-a130c5b7.txt, follow AGENTS.md, …"
17:25:16.407Z  send-keys -t wit4303agy Enter
17:25:32Z  results/task-plain-a130c5b7.txt written (30 bytes)   -> 17 s from task write to result
17:25:55Z  wrote second valid task tasks/task-plain-f563441b.txt (marker WITNESS-4303-82fa2c5e528705f2); the refused task is still pending in tasks/
17:25:56.046Z  send-keys -l -t wit4303agy "Sutando task ready: task-plain-f563441b.txt. Read …"
17:25:56.587Z  send-keys -t wit4303agy Enter
17:26:10Z  results/task-plain-f563441b.txt written (30 bytes)   -> 15 s from task write to result
17:26:40Z  cleanup started

The send-keys lines come from the tmux server's own verbose log (message: <client> command: send-keys …). Here is every send-keys the server received during the run:

17:24:54.926Z  command: send-keys -t wit4303agy Enter                                  <- mine: the trust prompt
17:25:15.812Z  command: send-keys -l -t wit4303agy "Sutando task ready: task-plain-a130c5b7.txt. Read /…
17:25:16.407Z  command: send-keys -t wit4303agy Enter
17:25:56.046Z  command: send-keys -l -t wit4303agy "Sutando task ready: task-plain-f563441b.txt. Read /…
17:25:56.587Z  command: send-keys -t wit4303agy Enter

Results and counts

results/task-plain-a130c5b7.txt:  WITNESS-4303-06bca38693f952e6   (marker count 1)
results/task-plain-f563441b.txt:  WITNESS-4303-82fa2c5e528705f2   (marker count 1)
results/ has no task-dc123~456.txt; tasks/task-dc123~456.txt is still pending, untyped

send-keys lines naming task-plain-a130c5b7.txt: 1    pane "Sutando task ready: task-plain-a130c5b7.txt": 1
send-keys lines naming task-plain-f563441b.txt: 1    pane "Sutando task ready: task-plain-f563441b.txt": 1
"dc123" anywhere in the tmux server log (all commands, all clients): 0
"dc123" anywhere in the final pane capture (500 lines of scrollback):   0

The pane after the second task (path wraps trimmed):

> Sutando task ready: task-plain-a130c5b7.txt. Read <scratch>/tasks/task-plain-a130c5b7.txt,
  follow AGENTS.md, complete the task, and write the result to <scratch>/results/task-plain-a130c5b7.txt.
● Read(…/task-plain-a130c5b7.txt)
● Create(…task-plain-a130c5b7.txt)
  I have completed the task and written the witness marker to the result file …
> Sutando task ready: task-plain-f563441b.txt. Read <scratch>/tasks/task-plain-f563441b.txt, …
● Read(…/task-plain-f563441b.txt)
● Bash(_out="<scratch>/results/…)
  I have completed the task and written the second witness marker to the result file …
? for shortcuts                                             Gemini 3.1 Pro · low

The old picker would have returned the refused task as the queue head. With --typable the notifier never took it as head: the valid task behind it was typed into the real pane 0.6 s after it landed, and it produced its result exactly once. The second valid task then advanced the same way, with the refused task still sitting in the inbox.

Cleanup

kill-session -t wit4303agy-watcher stopped the notifier, and its trap stopped its watcher; the sentinel, token and launch receipt were removed and only state/task-queue.json remained. kill-server on the own socket then stopped agy and its MCP children. Checked by PID: none of the nine processes in the recorded tree survived. ps found no match for the socket, session or worktree path. The socket file and the worktree were removed (git worktree remove, no --force, so the run left no files in the worktree). The owner's Chrome and every process I did not start were left alone. agy started no browser, and nothing listened on the MCP's browser port.

Gaps, stated plainly

  • No notifier log line for the refusal. logs/agy-task-notifier.log was never created in this run: the notifier logged nothing at all, so there were no retries, no "not staged" and no "refusing" lines. That is what this head is designed to do. next-pending --typable drops the refused name inside the picker, so submit_task's regex backstop (the only code that logs refusing task name …) is never handed it. The evidence for the skip is the same-moment picker probe above plus the absence of the name from every send-keys command and from the pane. The watcher's own TASK_FILE: event stream goes to the notifier's FIFO and is not logged, so I cannot show the event for the refused file arriving.
  • The trust prompt (agy: first launch in a new folder stops at the 'trust this folder' prompt, which the onboarding seed does not cover #4962) needed one manual Enter. It is visible as the first send-keys line above. Accepting it made agy append the scratch worktree to ~/.gemini/antigravity-cli/settings.json trustedWorkspaces. I restored that file byte-for-byte to its pre-run content (same sha256) after cleanup.
  • The isolated inbox means --role session ran against a scratch inbox, and SUTANDO_WORKSPACE_DIR was empty, so the workspace was derived from the inbox, as start-cli.sh passes it. This is the shape the review allowed. The live core's own tasks-agy/ was not exercised.
  • N = 1 run.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonichi
sonichi merged commit 4a72ddf into main Oct 6, 2026
29 checks passed
@sonichi
sonichi deleted the feat/agy-core-slice2-task-injection branch October 6, 2026 14:59
sonichi added a commit that referenced this pull request Oct 6, 2026
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>
sonichi added a commit that referenced this pull request Oct 6, 2026
#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>
sonichi added a commit that referenced this pull request Oct 6, 2026
…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>
sonichi added a commit that referenced this pull request Oct 6, 2026
… 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>
sonichi added a commit that referenced this pull request Oct 6, 2026
…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>
sonichi added a commit that referenced this pull request Oct 6, 2026
…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>
sonichi added a commit that referenced this pull request Oct 6, 2026
… 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>
sonichi added a commit that referenced this pull request Oct 6, 2026
…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>
qingyun-wu added a commit that referenced this pull request Oct 7, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants