Repository navigation
fix(gateway): the injected instruction must name the task's own channel dir - #3610
Conversation
…el dir CHANNEL_DIR is per-instance, but the CONTEXT-FIRST and NOTIFY steps hardcoded 'ag2space'. A dev-homeserver task told the handler to source the wrong credential, and that read returns zero messages rather than an error.
|
Reviewed the diff and the test (comment, not a formal approve — shared-login author). Independent confirmation from a third homeserver, minutes ago. The local-homeserver lane went live tonight ( Header says On the diff: all three sites covered; One follow-up worth a look (not blocking): the same blind-spot class — per-instance values asserted only at their default — is worth a sweep for Checks: all green except |
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 87%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head 456171e835b8. Approving — all five REVIEW.md criteria verified independently, not read off the description.
1. The bug is real on main. Three hardcoded sites, and CHANNEL_DIR is genuinely per-instance:
origin/main:packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py
2432: f"\"$(bash scripts/channel-env.sh ag2space)\"; set +a`) — and read it "
2444: f"\"$(bash scripts/channel-env.sh ag2space)\"; set +a` then python3 "
2445: f"skills/task-progress/scripts/notify.py --source ag2space "
356: CHANNEL_DIR = os.environ.get("REMOTE_TASK_CHANNEL_DIR") or "ag2space"
Worth stating plainly why this class matters: the wrong credential does not raise — it returns an empty room, so the handler concludes "no context" and proceeds. It fails in the direction that looks like success, which is why it survived.
3. The test exercises the failure mode — I mutation-checked it. Reverting only the fix in a clean worktree at the PR head:
reverted 2 channel-env site(s) + 1 notify --source site(s) = 3 total
FAIL a non-default CHANNEL_DIR reaches BOTH env preludes and the notify --source
FAIL both gateway-calling steps name the task's own channel dir, not the default
Exactly the two new assertions fail and nothing else; unmutated, the suite is PASS — all checks green. Credit where due: the comment CHANNEL_DIR defaults to "ag2space", so every assertion above passes even when the hint is hardcoded names the trap that would have made this test vacuous, and the negative assertion "channel-env.sh ag2space)" not in skd is what closes it. That is the difference between a test and a control that cannot fail.
5. Worst-case disruption to existing users — asked and answered: effectively none on default installs. shlex.quote("ag2space") == "ag2space" (verified), so at the default CHANNEL_DIR the emitted block is byte-identical to main. The only instances whose output changes are those with REMOTE_TASK_CHANNEL_DIR set — precisely the broken population. shlex.quote also keeps a hostile value inert: quote("a; rm -rf /") -> "'a; rm -rf /'", which matters because the value lands inside $(bash scripts/channel-env.sh ...) in text a handler pastes into a shell, and channel-env.sh validates only after the shell has parsed.
The one behavior delta worth naming (not a blocker): for an install where REMOTE_TASK_CHANNEL_DIR is set to a dir with no env file, the instruction now names that dir and channel-env.sh fails visibly, where before it named ag2space and could load a real-but-wrong credential. That is silent-wrong becoming visible-fail — the right direction, but it is a change, so it belongs in the record rather than discovered later.
Checks: 20/20 green, diff coverage passes. No blockers, no nits.
… fresh prelude The re-rendered prelude took its lane from the stored source: header — broker-supplied, PROVIDER-defaulted — while _write_task renders from the bridge's own CHANNEL_DIR env. Different provenance, different defaults: under shipped defaults a requeue rewrote the lane from ag2space to remote, re-opening the #3610 class through the #3613 fix. The bridge now passes CHANNEL_DIR through plan_dedup_recovery into build_requeued_task; the header source remains only a last resort for callers with no lane. Both vendored copies synced byte-identical. New cases pin the shipped- defaults divergence (source=remote), a cross-provider header (slack), the no-injection fallback, and both delegation seams. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…copies it (#3616) * fix(gateway): a dedup requeue re-renders the task prelude — it never copies it Closes #3613. build_requeued_task copied the ===SKILL INSTRUCTIONS=== block verbatim, so a requeued task carried the prelude it was BORN with — after any prelude-affecting fix, requeues kept instructing handlers with superseded text (observed live post-#3610: source: dev-ag2space header, channel-env.sh ag2space prelude). The template moves to result_markers.render_skill_prelude (single owner, dependency-light — no bridge import): the gateway's _write_task injects its live CHANNEL_DIR at write time; build_requeued_task strips the stored block and re-renders from the task's own header, where source: names the lane (lane-authoritative stamping, #3609). A prelude-less task gains none. Proof: _write_task output is BYTE-IDENTICAL pre/post across three task shapes (plain, addressed, channel-less) — golden compare at d6ac3a8 vs this head, prompts preserved exactly. New tests 12/12 incl. the observed defect shape, single-block invariant, header-region count placement, and a bridge-holds-no-inline-template delegation pin; control: the test hard-fails against the parent's result_markers. Vendored src/ copy synced byte-identical. Consumer suites green: dedup-recovery-plan, gateway-dedup-recovery, bridge-dedup x3, bridge-marker-no-leak, dm-result-skip-markers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(requeue): drive BOTH vendored copies — the gate scopes src/, the suite drove packages/ The coverage red was the twin-file confound, not missing tests: the suite imported ag2_sparrow.result_markers (out of coverage scope) while the gate measured src/result_markers.py, so 20 fully-tested lines scored zero. The suite now loads each copy by file path and runs every case against both (24 checks), which also pins vendored-copy behavioral parity as a test instead of a manual diff. Verified with CI's own instrument: all 20 previously-missing src/ lines now score. (sys.modules registration before exec_module is required — dataclass creation resolves cls.__module__.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(requeue): the requeuing adapter injects its own lane dir into the fresh prelude The re-rendered prelude took its lane from the stored source: header — broker-supplied, PROVIDER-defaulted — while _write_task renders from the bridge's own CHANNEL_DIR env. Different provenance, different defaults: under shipped defaults a requeue rewrote the lane from ag2space to remote, re-opening the #3610 class through the #3613 fix. The bridge now passes CHANNEL_DIR through plan_dedup_recovery into build_requeued_task; the header source remains only a last resort for callers with no lane. Both vendored copies synced byte-identical. New cases pin the shipped- defaults divergence (source=remote), a cross-provider header (slack), the no-injection fallback, and both delegation seams. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(requeue): injection is the switch — only an injected lane re-renders the prelude Slack and Discord write their own ===SKILL INSTRUCTIONS=== templates and call plan_dedup_recovery without a lane, so the unconditional strip-and- re-render replaced bridge-specific guidance (e.g. Slack's no-history rule) with the gateway template — worse than a wrong lane string. Now build_requeued_task strips and re-renders only when the caller injected channel_dir; without one the stored prelude is carried byte-verbatim (the pre-#3616 behavior, correct for templates the gateway renderer cannot represent). The source: header no longer feeds the lane at all. New pins: bridge-specific prelude survives verbatim, gateway template never rendered without injection, verbatim carry still rewrites id and count. Copies byte-identical; mutation control fails exactly the three new cases src-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
What
The
===SKILL INSTRUCTIONS===block the gateway injects into owner tasks hardcodedag2spacein three places, whileCHANNEL_DIRis per-instance (REMOTE_TASK_CHANNEL_DIR, defaultag2space).A task from the dev homeserver therefore carried a header saying
source: dev-ag2spaceand, two lines later, an instruction to load theag2spacecredential.Why it matters
The wrong credential does not error — it returns an empty room:
CONTEXT-FIRST exists so a handler answers from the reconstructed thread instead of the task body alone. A silent zero-message read defeats it in the failure direction that looks like success — the handler concludes there is no context and proceeds.
Observed live on
!NYKzJYiPgQOXZMyQUv:dev.ag2.spacewhile handling a dev-room task.How
_cdir_q = shlex.quote(CHANNEL_DIR)beside the existing_chan_q, interpolated into both env preludes andnotify.py --source.shlex.quotebecause the value lands inside$(bash scripts/channel-env.sh …)in text a handler pastes into a shell;channel-env.shvalidates the name, but only after the shell has parsed it.Evidence
The default path could not have caught this —
CHANNEL_DIRdefaults to"ag2space", so every pre-existing assertion passes with the bug present. The new control varies the one instance-specific value:Also green:
test_write_task_atomic.py,slack-context-first-ungated.test.py,slack-bridge-write-task.test.py.review-checks.sh --diffon the cumulative diff:PASS (hardcoded-paths + root-artifacts + prose-cap clean).Scope
Two files, +21/-3. No behaviour change for the default instance — the emitted text is byte-identical when
CHANNEL_DIR == "ag2space".🤖 Generated with Claude Code