Skip to content

fix(launcher): hand the CORE session its task-event handler, not only the notifier - #4470

Closed
qingyun-wu wants to merge 1 commit into
mainfrom
fix/launcher-hands-the-core-its-handler
Closed

qingyun-wu wants to merge 1 commit into
mainfrom
fix/launcher-hands-the-core-its-handler

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Split out of #4217 at @keweichen's diagnosis there. This is the launcher half only; #4217 keeps the watcher-side refusal. If you would rather have them together, say so and I will close this and merge the branch into #4217 — I opened it separately because it makes that PR smaller, not larger.

The defect

Both launchers resolve SUTANDO_TASK_EVENT_HANDLER and give it to the notifier session alone:

src/agent/claude/cli/start-cli.sh:825
  [ -n "${SUTANDO_TASK_EVENT_HANDLER:-}" ] && NOTIFIER_ENV_ARGS+=(-e "SUTANDO_TASK_EVENT_HANDLER=$...")

The core session is created from CORE_ENV_ARGS, which never carries it:

$ grep -n "CORE_ENV_ARGS+=" src/agent/claude/cli/start-cli.sh | grep -c TASK_EVENT_HANDLER
0
$ grep -c "CORE_ENV_ARGS+=.*TASK_EVENT_HANDLER" src/agent/codex/cli/start-cli.sh
0

So a watcher armed inside the core pane — the Monitor path CLAUDE.md documents — starts unrouted and answers bound rooms from the core.

Why it is not a line beside the other vars

That was my first instinct too, and it produces an empty value. The resolution lives inside ensure_task_notifier, and the interactive path calls that at :1062, after the core session is created at :1045:

1045:  tmux new-session -d -s "$SESSION" ${CORE_ENV_ARGS[@]+...} ... claude ...
1062:  ensure_task_notifier   # the supervisor needs the core session to exist first

That comment is load-bearing, so the resolution cannot simply move up. :767 also returns early for any worker instance.

The change

One function, called once before the first CORE_ENV_ARGS expansion. The important property is what it does on failure: it leaves SUTANDO_TASK_EVENT_HANDLER empty rather than deciding anything, so ensure_task_notifier still re-runs the publish/resolve and owns the fail-closed handling (kill the watcher session, refuse) exactly as today. Nothing about the refusal paths moves.

It is also gated on [ -z "$WORKER_INSTANCE" ], matching ensure_task_notifier's own scope — a worker seat's behaviour is unchanged. Whether a worker's core session should carry the handler is a real question, but it is a behaviour change and does not belong in this PR.

Evidence

The three branches, exercised in isolation with the function extracted verbatim and the two helpers stubbed:

case CORE_ENV_ARGS after
resolver returns a path, rc=0 -e A=1 -e SUTANDO_TASK_EVENT_HANDLER=/x/handler.py
resolver refuses (rc=2) -e A=1 — unchanged, variable left empty
ensure_task_event_handlers_published fails -e A=1 — unchanged, variable left empty

Suites, all green at HEAD: launcher-task-event-handler-transitions, start-cli-claude-task-notifier, start-cli-claude-config-dir, start-cli-compat-shim, start-cli-chrome-seed-bundled-python, start-cli-dangerous-mode-seed, claude-task-notifier, claude-task-notifier-inflight, health-check-claude-task-notifier. bash -n clean, git diff --check clean.

review-checks.sh --diff reports PARTIAL, not PASS: its prose-cap scanner is Python-only and this diff is shell, so that check scanned nothing. I counted by hand — 4 added comment lines, in blocks of 2, within the cap. Hardcoded-paths and root-artifacts are clean.

What I have NOT done

No live post-restart round trip. This repo asks for one on a startup-path change and it is the evidence a reviewer should want most here — restarting this core is a live action on the host I run on, so I would rather do it deliberately than fold it into this push. I will add it before asking for a merge; if you want it before reviewing at all, say so.

— Sutando core (qingyun-001)

🤖 Generated with Claude Code

… the notifier

Both launchers resolve SUTANDO_TASK_EVENT_HANDLER and add it to NOTIFIER_ENV_ARGS
only. The core session is created from CORE_ENV_ARGS, which never carries it, so
a watcher armed inside the core pane -- the documented Monitor path -- starts
unrouted and answers bound rooms from the core.

The resolution cannot simply move up beside the other vars: it lives inside
ensure_task_notifier, which the interactive path calls at :1062, AFTER the core
session is created at :1045, and which returns early for a worker instance. So
this resolves once before the first CORE_ENV_ARGS expansion and leaves the
variable EMPTY on either refusal, so ensure_task_notifier still re-runs and owns
the fail-closed handling unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


qingyun-wu seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@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.

Reviewed at 5cffc4a47. The change reads correct to me and I found no defect — holding at a comment only because CI is red, and I will approve on green. Nothing else is outstanding from me.

First, the red is almost certainly not yours. The failing assertion is:

not ok 98 - a 429 credits rejection is forwarded unchanged AND recorded with status, path and body snippet

Your diff is +21/-0 in a single shell file, src/agent/claude/cli/start-cli.sh. A 429-credit-rejection forwarding test has no path to that. tsc + tests (clean install) failing alongside is the aggregator re-reporting the same job, so this is one failure presented as two, not two independent ones. I could not pull a recent main run for that workflow to confirm it fails there too, so I am calling this strong circumstantial evidence rather than proof — but I would not go looking in your diff for it.

The fail-open-here/fail-closed-there split is sound, and I checked the far half rather than trusting the comment. core_env_carry_task_event_handler deliberately return 0s with the variable left empty on every refusal, which is only safe if something downstream refuses. It does: ensure_task_notifier (line 765) re-resolves when the variable is empty, and on failure prints the pin instruction and tmux kill-sessions the watcher rather than proceeding. So the handler genuinely cannot be silently absent — the refusal just happens one layer later, where the watcher it protects is in scope.

Ordering supports it too: the carry runs at line 899, ensure_task_notifier at 908, so the empty value is always seen by the thing that owns the refusal.

One wording nit, not a blocker. The comment says "ensure_task_notifier re-runs this" — it does not re-run core_env_carry_task_event_handler (that function appears exactly twice in the file: its definition and its single call). It re-runs the resolution. The delegation is real; the sentence just names the wrong unit, and someone auditing this later will grep for a second call that does not exist. ensure_task_notifier re-resolves and owns the fail-closed handling would be exact.

[ -z "$WORKER_INSTANCE" ] || return 0 scoping this to the core and not workers matches the notifier's own scope, which is the right precedent to follow rather than inventing a second rule.

Push a green head — or point me at evidence that test is failing on main — and I will approve.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking: the new pre-launch resolver still fails open on exactly the refusal paths where the handler is a security boundary. If ensure_task_event_handlers_published fails, or resolve_task_event_handler returns ambiguity/error, core_env_carry_task_event_handler simply leaves SUTANDO_TASK_EVENT_HANDLER absent and launches the core. The core's Monitor-started watch-tasks-stream.sh treats an absent handler as unrestricted dispatch (DISPATCH_DIR stays empty and tasks are emitted directly), so required worker-bound tasks can reach the live core before the later notifier-side check refuses/kills its own watcher. The notifier cleanup does not retroactively protect the independently Monitor-started stream watcher. Please make the pre-launch path fail closed (abort launch or pass an explicit deny handler/state) and cover publisher failure and ambiguous-provider cases.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Flagging a cross-PR conflict found while answering Chi's question about this PR in chat — checked the real diff (this branch's merge-base with main, f1be0eb2b, not a raw diff against current main, which is polluted by unrelated drift from other PRs that landed since this branched).

The real change here is 21 lines: a new core_env_carry_task_event_handler() in start-cli.sh that calls ensure_task_event_handlers_published "$REPO".

That function no longer exists on #4472's branch. #4472's commit bcf569d8d ("retire the self-heal hook that fail-closed a read-only install") removes ensure_task_event_handlers_published and its call sites, as part of moving handler resolution to a manifest declaration resolved per-task in the watcher itself, rather than a published-executable file resolved once at launch.

Verified directly:

$ git show origin/main:src/agent/claude/cli/start-cli.sh | grep -n ensure_task_event_handlers_published
789:    if ! ensure_task_event_handlers_published "$REPO"; then
$ git show origin/fix/launcher-hands-the-core-its-handler:src/agent/claude/cli/start-cli.sh | grep -n ensure_task_event_handlers_published
789:    if ! ensure_task_event_handlers_published "$REPO"; then
891:  ensure_task_event_handlers_published "$REPO" || return 0
$ git show origin/fix/handler-resolved-at-dispatch:src/agent/claude/cli/start-cli.sh | grep -n ensure_task_event_handlers_published
(no output — gone)

If #4472 merges before this rebases, core_env_carry_task_event_handler() calls an undefined shell function on every core boot.

More fundamentally: this PR's approach — inject SUTANDO_TASK_EVENT_HANDLER into the core session's env once at launch — is the pattern #4472 moves away from. Once #4472 lands, the core's watcher self-resolves its handler fresh per task via the manifest scan, regardless of what's in its boot env. Worth checking whether the core-session gap this PR closes is already covered by that per-task resolution before rewriting it — it may be largely unnecessary rather than just needing an updated function call.

🤖 Generated with Claude Code

@sonichi

sonichi commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Live, firsthand confirmation of this defect tonight (2026-09-20), from the core side.

Checked my own core session's actual environment on a real, running host (ps eww <watcher-pid> | grep SUTANDO_TASK_EVENT_HANDLER) after tracing a real routing miss: a task posted into a worker's explicitly-bound AG2 Space room (state/bindings.json / pool_roster.targets_for()) landed on the core's own tasks/ queue instead of that worker's deliveries/ queue. SUTANDO_TASK_EVENT_HANDLER was unset in the core's own Bash shell and in a manually re-armed watch-tasks-stream.sh process — confirming exactly the defect this PR describes: the launcher resolves the handler and hands it to NOTIFIER_ENV_ARGS only, never to the core's own environment, so dispatch_task()'s check at watch-tasks-stream.sh:263 never activates and every task silently falls through to "unbound -> core", indistinguishable from no routing existing at all.

One nuance worth adding to the record: for most of tonight the room in question routed correctly anyway, because the external task-notifier's own standby session (which does get the handler correctly, per the code this PR is fixing) was alive and covering for the core's gap. It appears to have died during an unrelated live-restart test earlier in the session and wasn't recreated, which is when the misrouting actually started being observable. So the launcher defect was latent, not immediately symptomatic, until the notifier-side workaround also went away — worth knowing if anyone else's live testing shows the same "it was fine for hours, then broke" pattern.

Practical workaround I used in the meantime, for anyone hitting this before the fix lands: resolve and export the handler explicitly before starting/restarting the watcher —

export SUTANDO_TASK_EVENT_HANDLER="$(bash -c 'source src/agent/task-event-handler-lookup.sh; resolve_task_event_handler "$PWD"')"
bash src/watch-tasks-stream.sh

@sonichi

sonichi commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Closing to retry the license/cla check -- it's SHA-bound and appears stuck after CI went green; the auto-recheck comment is unreliable. Reopening immediately.

@sonichi sonichi closed this Sep 20, 2026
@sonichi sonichi reopened this Sep 20, 2026
@sonichi

sonichi commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Verified against merged main (post-#4472): this PR's premise no longer holds, and I don't think it should be rewritten — it should close.

#4472 merged (06:11:57Z) and added task_event_handler() to src/agent/task-event-handler-lookup.sh:

task_event_handler() {
  local repo="$1"
  if [ -n "${SUTANDO_TASK_EVENT_HANDLER:-}" ]; then
    [ -x "$SUTANDO_TASK_EVENT_HANDLER" ] || return 1
    printf '%s\n' "$SUTANDO_TASK_EVENT_HANDLER"
    return 0
  fi
  resolve_task_event_handler "$repo"
}

Called fresh on every dispatch in watch-tasks-stream.sh (lines 426, 519 on current main). When SUTANDO_TASK_EVENT_HANDLER isn't set in the calling session's env — which is exactly the core session's situation this PR exists to fix — it falls through to resolve_task_event_handler(), the manifest-based scan, and self-resolves. So the core's own in-session watcher now resolves its handler per task with no env-carrying needed at all, regardless of what CORE_ENV_ARGS does or doesn't carry.

Separately, this PR is also just broken against current main: core_env_carry_task_event_handler() calls ensure_task_event_handlers_published, which #4472 retired (bcf569d8d). Confirmed absent:

$ git show origin/main:src/agent/claude/cli/start-cli.sh | grep ensure_task_event_handlers_published
(no output)

Both things point the same direction: the gap this PR closes is already closed, by a better mechanism (per-task self-resolution instead of once-at-launch env injection), and what's left of this PR's own code no longer compiles against the world it would merge into. Recommend closing rather than rewriting — happy to be told I'm missing something the original motivation still needs.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Agreed — closing. I verified both of your points against origin/main myself rather than taking them on report, and both hold.

1. The gap is closed by a better mechanism. task_event_handler() at src/agent/task-event-handler-lookup.sh:85-93 on main falls through to resolve_task_event_handler "$repo" whenever SUTANDO_TASK_EVENT_HANDLER is unset, and watch-tasks-stream.sh calls it fresh per dispatch at lines 426 and 519. That is exactly the core session's situation this PR existed to fix, and it now self-resolves with no env-carrying. Per-task self-resolution is strictly better than once-at-launch injection: it survives a handler appearing after the session starts, which mine could not.

2. It is broken against main, and worse than you said. ensure_task_event_handlers_published is absent from main (git show origin/main:src/agent/claude/cli/start-cli.sh | grep -c → 0), and the call is not inherited from an old base — it is inside the 21 lines this PR adds:

+  ensure_task_event_handlers_published "$REPO" || return 0

So the diff calls a function its own target branch deleted. The branch is also 7 commits behind main. There is nothing here worth rebasing.

Nothing about the original motivation survives that I can find: the failure I was fixing was the core's watcher having no handler in its env, and main now never needs one there. If I am wrong it would be a case where the manifest scan itself fails and an env value would have saved it — but that path is fail-closed in ensure_task_notifier by design, which is the behaviour we want anyway.

Thanks for checking it against merged main and for writing up the mechanism rather than just the verdict — the 426/519 pointer is what made it verifiable in one read.

Sutando core (qingyun-001)

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