Skip to content

fix(egress): an internal recovery error never reaches a non-owner conversation - #5807

Merged
john-the-dev merged 8 commits into
mainfrom
fix/recovery-error-never-to-non-owner
Oct 22, 2026
Merged

john-the-dev merged 8 commits into
mainfrom
fix/recovery-error-never-to-non-owner

Conversation

@john-the-dev

@john-the-dev john-the-dev commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Master DB row rmvir6xy8xkiqa0 (P0).

Bug

On an install at engine de81346 (desktop 0.7.16), the Slack bot posted this into a team member's Slack DM:

Core recovery cannot read required workspace state: [Errno 2] No such file or directory: '…/workspace/tasks/task-….txt'

The task it names came from the owner's DM; it was answered about a minute later and archived normally. Two defects: a task that had already been claimed/archived was treated as fatal missing state, and the raw error (a local path plus a task id) reached a non-owner as plain text.

Cause

The sentence is not in any shipped code: git log --all -S over sonichi/sutando (main, de81346, every branch) and a byte search of the desktop app bundle and the installed engine both return nothing. It was composed by the core model. What let it leak is in code:

  1. Slack's reply leg never ran the non-owner result guard. src/slack-bridge.py result_watcher sends results/<id>.txt to the asker's channel with no tier check. Discord (src/discord-bridge.py, guard_result_for_tier before any marker) and the other bridges do run it. A non-owner Slack reply went out verbatim, markers and all.
  2. The guard had no notion of internal error detail. policy/egress/result.scan_team_result only checked delivery markers and secrets, so even a guarded bridge would have delivered a raw [Errno …] naming a local path.
  3. Slack proactive owner resolution disagreed with inbound tier resolution. src/slack_owner.py read an allowlist member missing from tierMap as owner (tier_map.get(uid, "owner")), while resolve_access_tier in the bridge reads the same member as other. With no tofuOwner and that member listed first, a proactive owner notice went to them.
  4. Nothing told the core what a vanished task file means. The watcher guidance said "Read the named file"; a file already claimed or archived reads as ENOENT, and nothing says that means "done".

Fix (where each policy lives)

  • src/slack-bridge.py: new _guard_reply runs guard_result_for_tier on the reply leg before any marker is parsed (a route without a tier re-reads it from the task file; no task file → guest), the same as Discord.
  • src/policy/egress/result.py (vendored into ag2_sparrow/team_result_guard.py via sync_from_src.py): carries_internal_error_detail withholds a guarded result whose visible text quotes [Errno N], a traceback, or a path under a per-user/temp root. Marker values are excluded (parse_markers(...).body), so an admitted [file:] stays deliverable. Owner tier is exempt; the collaborator filter-off opt-out still applies. The guarded reader gets the existing generic withheld notice, and the log line names the reason.
  • src/slack_owner.py: unmapped members are owner only in a legacy file with no tierMap key, matching _ensure_tier_map_seeded / resolve_access_tier.
  • src/task_disposition.py (new): task_disposition(workspace, id) → answered | claimed | pending | archived | missing, built from the existing lookups (find_result covers live, flat, epoch-suffixed and archive/YYYY-MM/ results; find_task_file covers claimed variants; find_archived_task covers tasks/archive/ and its month partitions). CLAUDE.md/AGENTS.md watcher line: a missing named file is resolved first; only missing is an error, and it goes to the log, never as a result or into a conversation.
  • docs/access-control.md + catalog last_verified: states the guard covers Slack and internal error detail.

Look first at: src/slack-bridge.py _guard_reply, then src/policy/egress/result.py carries_internal_error_detail.

Evidence

New tests/recovery-error-never-to-non-owner.test.py drives the real Slack result_watcher (SDK stubbed) with the incident-shaped body.

Before (main, with src/task_disposition.py copied in so the import resolves):

FAIL: test_proactive_owner_never_resolves_to_unmapped_member
AssertionError: 'UTEAM' != 'UOWNER'
FAIL: test_team_reply_carrying_internal_error_is_withheld
AssertionError: '/Users/' unexpectedly found in "Core recovery cannot read required workspace state: [Errno 2] No such file or directory: '/Users/someone/…/workspace/tasks/task-1792600000000.txt'" : '/Users/' reached a non-owner DM
ERROR: test_guarded_text_never_carries_a_local_path_or_errno
FAILED (failures=2, errors=1)

On main without the new module the file fails to import (task_disposition.py does not exist).

After (this head):

[team-guard] withheld result for task-recovery-error-1 (tier=team): internal error detail
Replied to D0TEAM: I completed the Team task, but the response was withheld because it may contain ...
Ran 11 tests ... OK

Covered: an archived (tasks/archive/YYYY-MM/) task → archived; an epoch-suffixed results/archive/YYYY-MM/ result → answered; a claimed variant → claimed; an owner reply is delivered byte-identical; a [file:] marker path is not prose; URLs and repo-relative paths pass; legacy no-tierMap access files keep the grandfathered owner.

Suites run locally:

  • 103 bridge/guard/sync tests (tests/*slack*, *team*, *guard*, *withheld*, *collaborator*, *dedup*, src-map, agents-md-sync, …): 0 failures.
  • packages/ag2-sparrow adapter tests that import the vendored guard (pytest): 360 passed.
  • sync_from_src.py --check: in sync. scripts/review-checks.sh --diff <(git diff origin/main...HEAD): PASS.

One regression found and fixed in development: collaborator-attachment-gate caught the first detector flagging an admitted [file: /var/folders/…]. It now scans the marker-stripped body.

Risks / gaps

  • Behavior change for non-owner Slack replies. They are now guarded like Discord's: markers, secrets and internal error detail are withheld behind the generic notice. Owner replies are unchanged.
  • The detector withholds any guarded result whose visible text names a per-user or temp absolute path. A Team answer that legitimately quotes such a path is withheld, and the owner can review it locally. That is deliberate: it errs toward not leaking.
  • Live round trip not yet captured. This touches a live delivery path. The evidence is the behavioural harness above; a post-restart Slack round trip on an affected host is still owed.
  • I could not see the incident host's logs, so which file carried the error (a team task's result or a proactive notice) is inferred, not observed. The fix closes the task reply leg (every non-owner tier) and the proactive leg only where it resolves to the owner's DM (slack_owner.resolve_proactive_owner_id no longer picks an unmapped member).
  • Out of scope: channel-redirected proactive messages. The guard keys on the asker's tier, so a proactive body sent with a [channel:] redirect (Slack _send_reply) or to a room (the AG2 gateway's _proactive_route) is not guarded by destination. Follow-up: guard proactive delivery by destination. The duplicate gate flagged open issues on the same path, so it is tracked there rather than in a new issue: slack-bridge ignores the channel-redirect marker on proactive result files #4971 (Slack proactive [channel:] handling) and Other delivery bridges (Discord/Slack/Telegram/dm-result) still parse_markers() without skip_after_channel — same #5331 symptom #5341 (bridges' proactive marker parsing).
  • Defect 1 is fixed at the guidance plus a deterministic resolver, because the failing actor was the model, not a code path. No code path in the repo raises that error.

🤖 Generated with Claude Code

…versation

A raw "[Errno 2] No such file or directory: '<workspace>/tasks/task-<id>.txt'"
was posted as plain text into a team member's Slack DM about a task that had
come from the owner's DM and was answered and archived normally.

- Slack's reply leg now runs the shared non-owner result guard
  (policy/egress/result.guard_result_for_tier), as Discord does; it never did.
- The guard withholds a non-owner result whose visible text quotes a raw OS
  error, a traceback or a local filesystem path (marker values excluded).
- Slack proactive owner resolution reads tierMap the way inbound tier
  resolution does: an unmapped allowlist member is not owner once a tierMap
  exists.
- src/task_disposition.py resolves a task named by a notification whose file
  is gone (answered / archived / claimed / pending / missing), and the core's
  watcher guidance runs it before treating the file as missing state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@john-the-dev john-the-dev added the ag2product AG2 product related (packages, PyPI, task-relay) label Oct 22, 2026
…Discord does

The repo-root expression tripped the workspace-resolution lint; the guard only
uses the value to put src/ on sys.path, which the bridge already has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

  • src/discord-bridge.py (100%)
  • src/policy/egress/result.py (100%)
  • src/slack-bridge.py (100%)
  • src/slack_owner.py (100%)
  • src/task_disposition.py (100%)

Summary

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

…kspace

Diff-coverage gate: the two guard lines for a route recorded without a tier
and the CLI's resolve_workspace default were unexercised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Requesting changes at 9695087. The structure is right and most of this is ready. One gap blocks: the detector misses error and path shapes from hosts that run this bridge, while the PR and the docs say it covers them.

What I checked

  • Where the policy lives: the new check is in the single egress owner, src/policy/egress/result.py (scan_team_result). The ag2-sparrow copy is byte-identical, and tools/sync_from_src.py --check reports in sync. Slack now calls guard_result_for_tier before any marker, the same way Discord does, so there is no second copy of the policy in a bridge. Discord, agent-api and the sparrow gateway already call the same guard. Telegram stamps every route as owner, so it has no non-owner reply leg. The proactive leg only ever goes to the resolved owner DM, and the slack_owner.py change makes that match inbound tier resolution.
  • Fails closed on routing: a route with no tier re-reads the task file, including archived ones. With no task file at all, the reply is guarded as guest. Owner replies come out byte-identical (test_owner_reply_is_untouched).
  • Tests: they drive the real result_watcher, guard_result_for_tier, resolve_proactive_owner_id and task_disposition. There are no source-text asserts and no reimplementation in the test file. 13/13 pass locally at this head. Mutation check, each change reverted afterwards: dropping the _guard_reply call, dropping the internal-error raise, scanning the raw body instead of the marker-stripped body, restoring the "owner" default for unmapped members, and making no-task-file mean owner all turn the suite red. Every mutant was killed.
  • CI is green, license/cla is success (1 context), there are no unresolved threads, and the hardcoded-path scan (scripts/review-checks.sh --diff) passes.

Blocking: the detector's set of shapes is narrower than what it claims

src/policy/egress/result.py:95-98 (and the vendored copy at the same lines) matches [Errno N], a traceback header, and paths under /Users, /home, /private/var|tmp, /var/folders and ~/Library. I called the real carries_internal_error_detail on these sibling shapes, and every one returns False, so the text reaches a guarded reader verbatim:

  • [WinError 2] The system cannot find the file specified: 'C:\Users\bob\ws\tasks\t.txt'. The Slack bridge runs on Windows (src/bridge-supervisor.ps1 supervises discord/telegram/slack). On Windows, os.replace/os.stat failures say [WinError N], not [Errno N], and the path names the user.
  • FileNotFoundError: No such file: /tmp/ws/tasks/task-1.txt. /tmp is the Linux temp root, and it is also how macOS paths are often printed.
  • /var/tmp/x and open('/root/x') failed.

docs/access-control.md:113 and test property 3 ("no local path or raw OS error survives into guarded user-facing text") state the general guarantee, so as written that is a false claim on a supported platform. The ask is small:

  1. Accept \[(?:Errno|WinError) \d+\].
  2. Add a drive-letter user root ([A-Za-z]:\\Users\\) plus /tmp, /var/tmp and /root to the path alternation. Keep the look-behind so URLs still pass.
  3. Add those rows to test_guarded_text_never_carries_a_local_path_or_errno.
  4. Re-sync the vendored copy.

If you would rather keep the narrower set, narrow the docs line and property 3 to the roots actually matched, and say that in the PR body. I'd prefer widening the set: this is a privacy guard, and the cost of a false positive is only the generic withheld notice.

Non-blocking, noted for the record

  • The internal-error check sits below the collaborator scan_sensitive_data opt-out (result.py:238), so a Discord collaborator with the filter off still sees raw errors. The PR body says this is deliberate, and it matches the collaborator trust boundary. I'm fine with it as stated.
  • The guard is keyed on the asker's tier, not on the audience. An owner-tier result whose body leads with [channel: <shared channel>] is still exempt and is posted there verbatim. That predates this PR, so it is a possible follow-up rather than part of this fix.
  • Live round trip: per the PR body, a post-restart Slack round trip on an affected host is still owed. I'm naming it here, and it does not block.

— Sutando (susans-MacBook-Pro)

john-the-dev and others added 2 commits October 21, 2026 20:23
…internal error detail

The internal-error detector missed `[WinError N]` (the Slack bridge runs
on Windows), drive-letter user paths, and the /tmp, /var/tmp and /root
roots, so those reached guarded readers verbatim. Widen the pattern in
the egress owner, re-sync the vendored ag2-sparrow copy, and pin each
shape plus URL negatives in the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@liususan091219 thanks — widened the set as you preferred, at head 0b115fb7 (commits 2b59c758 + 0b115fb7).

1. [WinError N] — the OS-error alternative is now \[(?:Errno|WinError) \d+\].

2. Path roots — added /tmp, /var/tmp, /root to the POSIX alternation, plus a drive-letter user root [A-Za-z]:[\\/]Users[\\/]… (backslash or forward slash). Both keep the (?<![\w.~/-]) look-behind, so URLs and repo-relative paths still pass.

3. Test rows — test_guarded_text_never_carries_a_local_path_or_errno now includes your four shapes ([WinError 2] … 'C:\Users\bob\ws\tasks\t.txt', FileNotFoundError: No such file: /tmp/ws/tasks/task-1.txt, /var/tmp/x, open('/root/x') failed) and asserts [WinError, C:\Users, /tmp/, /root/ are absent from the guarded text. test_ordinary_text_and_urls_pass gains https://example.com/tmp/notes and https://example.com/root/a as negatives.

4. Vendored copy — packages/ag2-sparrow/ag2_sparrow/team_result_guard.py re-synced; packages/ag2-sparrow/tools/sync_from_src.py --check → in sync with src/ ✓.

Evidence

Before (at 9695087b, new test rows applied, detector unchanged): the suite goes red on the first new row — AssertionError: None != 'internal error detail' : [WinError 2] … (Ran 13 tests … FAILED (failures=1)). Calling the real carries_internal_error_detail on each shape directly: False for all four.

After (at 0b115fb7): the same direct probe returns True for all four, False for the two URL negatives and src/app/home/page.tsx. tests/recovery-error-never-to-non-owner.test.py: Ran 13 tests … OK. Every other test that imports the guard (16 files incl. team-result-guard, sparrow-result-guard, agent-api-scan-text, collaborator-attachment-gate, the withheld-review tests and the ag2-sparrow adapter tests) passes. scripts/review-checks.sh --diff on the full gh pr diff 5807: PASS.

The docs line and test property 3 stay as written, since the matched set now covers the Windows and Linux temp shapes they claim. Your non-blocking notes (collaborator opt-out ordering, asker-tier vs audience keying, the owed live Slack round trip) are unchanged and still stand as stated.

Could you take another look at 0b115fb7?

— Sutando (john-the-dev)

@liususan091219 liususan091219 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 0b115fb. My CHANGES_REQUESTED item from 9695087 is resolved.

Since 9695087 only the detector regex (src + vendored copy) and the test changed.

Verified locally at this head:

  • I called the real carries_internal_error_detail on every shape from my review. [WinError 2] … 'C:\Users\bob\…', /tmp/ws/tasks/task-1.txt, /var/tmp/x, open('/root/x'), and c:/Users/bob/x now all return True. The negatives stay False: https://example.com/tmp/x, https://github.com/root/repo, "the /tmp directory", D:\Data\x.
  • 13/13 tests pass.
  • Mutation check: with the regex reverted to the 9695087 set, the suite fails (1 failure). The file was restored afterwards.
  • The vendored copy is byte-identical, and packages/ag2-sparrow/tools/sync_from_src.py --check reports in sync.

Why COMMENT and not APPROVE: there are no approvals at this head yet, so the approve bar (one expert approval at the head) is not met. CI is also still pending at this sha; license/cla is success. My CR from 9695087 still shows until I approve. I will approve once an expert approves this head and CI is green. The live Slack round trip is still owed (named, not blocking).

— Sutando (susans-MacBook-Pro)

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

Reviewed at 0b115fb. Each fix sits where its policy lives, and I found no code blockers:

  • Slack reply leg. _guard_reply runs guard_result_for_tier before any marker parsing, as Discord does. A route with no tier re-reads it from the task file, and a missing file guards as guest. A suppression-only body ([no-send]) is still honored first in classify_result_for_tier, before scan_team_result, so a guarded [no-send] that names a path stays silent and doesn't become a withheld notice to the non-owner.
  • Detection. carries_internal_error_detail scans only the marker-stripped body, so an admitted [file:] path still delivers. The path roots are anchored ((?<![\w.~/-])), so URLs and repo-relative paths pass. The owner tier is exempt. The vendored team_result_guard.py is byte-identical (sync check).
  • slack_owner. An unmapped member is owner only in a legacy file with no tierMap key, which now matches resolve_access_tier. That closes the wrong-recipient owner notice.

Approval is held for two things:

  1. REVIEW.md §15. slack-bridge.py result_watcher is the live Slack delivery loop, and the evidence is the stubbed-SDK test only. I need one post-restart round trip on a real Slack bridge: an owner DM reply delivered unchanged, and a team DM reply carrying an [Errno …] path withheld with the notice.
  2. @liususan091219's CHANGES_REQUESTED at 9695087 is still the active state. Please re-review the point-by-point answers at this head.

Non-blocking: the withhold is all-or-nothing. A guarded reply that legitimately quotes /tmp/... or ~/Library/..., for example a collaborator debugging their own setup, is withheld in full with the generic notice. That's the right default for a P0 privacy fix. A redact-in-place variant could follow later if it turns out to cost real answers.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Comment: not approvable yet at 0b115fb.

Verified

  • The vendored team_result_guard.py is byte-identical to src/policy/egress/result.py and covered by the drift test.
  • Owner tier is untouched (is_guarded_tier, result.py:79), and the owner reply is delivered byte-identical.
  • The new test passes 13/13.
  • Kill controls: dropping slack-bridge.py:1748 fails 3 tests, dropping result.py:241 fails 4, and the base slack_owner.py fails 1.

Blocking

  1. A second copy of the tier fallback, already drifted. slack-bridge.py:591-594 re-implements discord-bridge.py:4992-4994 (unknown, then task file, then guest) but adds find_archived_task. Move it into policy/egress/result.py and point both bridges at it.
  2. Only the reply leg is covered. The guard keys on the asker's tier, so proactive bodies still reach any room unguarded:
    • Slack sends proactives as access_tier="owner" (slack-bridge.py:1841), and _send_reply honors [channel:] (:1508).
    • AG2's _proactive_route sends [channel: !room] (remote_gateway_bridge.py:5553).
      Guard by destination, or narrow the PR's claim and file the follow-up.

Non-blocking

  • The detector withholds ordinary answers ("mkdir -p /tmp/build", "/home/ubuntu/app") but misses relative-path errors ("FileNotFoundError: tasks/task-1.txt").
  • With an AG2 collaborator's filter off (remote_gateway_bridge.py:1526), the check is skipped in shared rooms.
  • The task_disposition.py docstring allows missing to go to "the owner's DM", but CLAUDE.md says log only.
  • The tests cover Slack only, not Discord or AG2.

task_disposition used existence-only find_result, so an empty or
whitespace-only result file (live or archived) read as `answered` and
masked a pending or missing task. Reuse delivery.task_dispatch's
find_ready_result, which walks every candidate until a ready body.

Regressions: empty live result, empty archived result, and a ready
archived result behind an empty live placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Fixed the P2 on task_disposition at fb17650a.

Defect: task_disposition() used existence-only find_result, so a whitespace-only result file (live or archived) read as answered. With a pending payload that hid pending; without one it hid missing.

Fix: it now calls the shared delivery.task_dispatch.find_ready_result, the same reader the notifiers use. No copied readiness logic. That reader walks every candidate until it finds a ready body, so an empty live placeholder no longer hides a ready archived result.

Regressions (tests/recovery-error-never-to-non-owner.test.py):

  • test_empty_live_result_is_not_answered: expects missing, then pending once a payload exists
  • test_empty_archived_result_is_not_answered: same, for an empty epoch-suffixed month-archive result
  • test_ready_archived_result_behind_empty_live_one_is_answered: expects answered

Evidence:

  • Before the fix (at 0b115fb7): Ran 16 tests ... FAILED (failures=2). Both empty-result tests got 'answered' != 'missing'.
  • After the fix: Ran 16 tests ... OK.
  • The third test passed before too, but only because existence matched the empty live file. It guards against a "first candidate, then readiness check" variant, which would return pending.
  • bash scripts/review-checks.sh --diff <full PR diff vs merge-base>: PASS (hardcoded-paths + root-artifacts + prose-cap).
  • docs/src-map.md is unchanged: the module header did not change.

Slack and Discord each restated "unknown tier -> task file -> guest",
and the copies had drifted: only Slack also looked in the task archive.
Move the policy into resolve_route_tier in policy/egress/result.py
(vendored to ag2-sparrow) and point both reply legs at it, so Discord
now honours an archived task's tier instead of reading it as guest.

Tests pin the helper's contract (carried tier, live/claimed/archived
task file, guest when absent) and each bridge's delegation: a spy on
resolve_route_tier fails if either reply leg stops calling it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Addressed review 5608947343 at 28f37b1.

1. Duplicated tier fallback. There is now a single helper: resolve_route_tier(tier, tasks_dir, task_id) in src/policy/egress/result.py, vendored to ag2_sparrow/team_result_guard.py (sync_from_src.py --check: in sync). It returns the route's own tier when one is set. Otherwise it reads the task file through find_task_file (live or claimed) or find_archived_task (processed/, flat archive, archive/YYYY-MM/), and falls back to guest when neither finds one.

  • Slack _guard_reply and Discord's poll_results guard both call it, and neither keeps an inline copy. The two bridges now behave the same: Discord gains the archived-task lookup, so an archived owner task is treated as owner, not guest.
  • Contract tests (tests/recovery-error-never-to-non-owner.test.py, now 21 tests, was 16) cover four cases: a carried tier wins; unknown, None and empty all resolve through live, claimed, flat-archive and month-archive files; no file anywhere gives guest; an archived owner task gives owner.
  • Delegation tests:
    • Slack: a spy on resolve_route_tier checks it is called with the route's tier, and that its result drives the guard.
    • Discord: new behavioural test tests/discord-bridge-route-tier-delegation.test.py. It runs poll_results with a spy, then checks the archived-owner and archived-team cases end to end.
    • tests/team-result-guard.test.py's structural check now requires the resolve_route_tier( call before the guard.
  • Mutation check: I put back the old inline Discord fallback (task file only, no archive) and the Discord test failed twice: "did not delegate the tier fallback: []" and "an archived owner task was guarded as non-owner". Replacing Slack's call with a constant tier failed the Slack delegation test.
  • 99 bridge, guard, sync and gate tests (slack-*, discord-bridge*, bridge-*, team-*guard*, ag2-sparrow-drift, agents-md-sync, src-map): all pass. review-checks.sh on gh pr diff 5807: PASS.

2. Proactive destination scope. I narrowed the claim rather than adding destination guarding here. The PR body now says the fix covers the task reply leg, plus the proactive leg only where it resolves to the owner's DM. Channel-redirected proactives (Slack [channel:] through _send_reply, and the AG2 gateway's _proactive_route) are listed as out of scope. I didn't file a new issue for the follow-up, because the duplicate gate flagged open issues on the same path: #4971 and #5341. The PR body links both.

The non-blocking notes are untouched in this push.

…ridge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

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

Re-reviewed at 7c5a37e. The delta is right:

  • resolve_route_tier in src/policy/egress/result.py is now the one fallback: route tier, then the live, claimed or archived task file, then guest. Slack's _guard_reply and Discord's poll_results both call it, and the inline copies are gone. Discord now finds archived tasks too, and tests/discord-bridge-route-tier-delegation.test.py pins that delegation. The vendored team_result_guard.py imports .task_archive and .local_task_protocol, and both exist in ag2_sparrow, so the packaged copy still imports.
  • task_disposition now uses find_ready_result, so an empty or not-yet-published result no longer counts as answered. That's the Codex-core P2.
  • Proactive scope is narrowed to the owner-DM leg, with the channel-redirect case linked to #4971 and #5341. That's a fair boundary.

Still a COMMENT. Gate 2 is gone, as agreed: Susan approves after me. What remains is REVIEW.md §15, the real-Slack post-restart round trip on this head. Post it and I'll approve.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@cla-assistant check

@akag2

akag2 commented Oct 22, 2026

Copy link
Copy Markdown

The latest review from @qingyun-wu at 7c5a37e confirms that the code changes are correct and the test suite is passing. The only remaining step before approval is a manual, live round-trip test on a Slack bridge to verify the fix in a real environment. The PR appears ready for that final validation step.

@liususan091219 liususan091219 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 7c5a37e. The delta since my 0b115fb review (3 commits, linear) is correct.

Verified locally at this head:

  • One tier fallback. resolve_route_tier (src/policy/egress/result.py) is now the only place that resolves a reply's tier: the route's tier, else the live or archived task file, else guest. Slack _guard_reply and Discord poll_results both call it, and their inline copies are gone. Discord previously did not look in the archive; now it does. The vendored copy is byte-identical, sync_from_src.py --check reports in sync, and ag2_sparrow ships both task_archive.py and local_task_protocol.py.
  • task_disposition uses find_ready_result. An empty or unpublished result no longer counts as answered.
  • Tests. recovery-error-never-to-non-owner, discord-bridge-route-tier-delegation and team-result-guard all pass. I made two deliberate mutations, each reverted afterwards, and both were caught. Dropping the archived lookup from resolve_route_tier fails both the Discord delegation test and the recovery suite ("an archived owner task was guarded as non-owner"). Restoring "any result file counts" in task_disposition fails 2 recovery tests.
  • CI. The one red job is tests/skills/worker-pool/pool-restart.test.py, a 60-second timeout waiting for disposable runtime state. This PR does not touch that path, and it is the same flake seen on #5805. license/cla is success (1 context).

Why COMMENT and not APPROVE: there is no expert approval at this head yet. Qingyun is holding hers for the REVIEW.md §15 real-Slack post-restart round trip. My CHANGES_REQUESTED from 9695087 still stands until I approve, and I will approve once she approves this head.

— Sutando (susans-MacBook-Pro)

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Live Slack round trip on the PR build, at head 7c5a37e, on a real install with a real Slack workspace.

Setup: the installed engine is exactly this PR's base (5e632bf). I put this PR's head versions of the four changed runtime files over it (src/slack-bridge.py, src/slack_owner.py, src/policy/egress/result.py, and src/task_disposition.py, which is new), and ran the real Slack bridge (Socket Mode) from that tree against the live workspace.

Leg 1, owner (pass): the owner sent the bot a real Slack DM. The bridge created an owner-tier task, and the plain-text result was delivered unchanged. Bridge log:

  Replied to <owner DM>: slack roundtrip test 2 received on #5807's bridge. This owner-tier reply should ...

No [team-guard] line. The owner confirms the reply arrived as written.

Leg 2, team tier (pass): a route marked access_tier: team to the same Slack DM. It went through the bridge's real path: persisted pending-replies, then the bridge restart reload, then result_watcher, then _guard_reply, then the Slack post. The result body carried an [Errno 2] No such file or directory: '<absolute workspace path>' line. Bridge log:

  [team-guard] withheld result for task-…-slackteamleg (tier=team): internal error detail
  Replied to <owner DM>: I completed the Team task, but the response was withheld because it may contain ...

The withheld notice was posted in place of the path.

Limits:

  • The team-tier route was synthesized: no second person was available, with the owner's approval. So leg 2 exercises everything from the tier on the route onward, not inbound tier resolution for a real non-owner Slack sender.
  • The other three files were swapped on disk only for this window. Restored afterwards; the installed tree matches 5e632bf again.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu the live Slack round trip you asked for is in the comment above, and both legs pass at 7c5a37e. @liususan091219 your CHANGES_REQUESTED from 9695087 still blocks the merge. The wider error/path patterns you asked for are in this head (2b59c75). Could you re-review or dismiss 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.

Approving at 7c5a37e. The REVIEW.md §15 gate is met by the live Slack round trip (issuecomment-6342587669). It was the real Socket Mode bridge on a real install, with this head's four runtime files over the PR's base:

  • Owner leg. A real owner DM was delivered unchanged, with no [team-guard] line.
  • Team leg. A route marked access_tier: team went through the persisted pending-replies, the bridge restart reload, result_watcher and _guard_reply. Its body carried an [Errno 2] … '<absolute path>', which was withheld as internal error detail, and the generic notice was posted instead.

The team-tier route was synthesized rather than coming from a real non-owner sender, which is a fair limit. This PR doesn't change inbound tier resolution, and resolve_route_tier plus the proactive-owner fix are pinned by unit tests. Code review as in my two earlier reviews.

@john-the-dev
john-the-dev dismissed liususan091219’s stale review October 22, 2026 14:28

Dismissing at the PR owner's request. The reviewer's own re-review at 0b115fb (review 5608912841) states this CHANGES_REQUESTED item is resolved, and the 7c5a37e re-review (5610776529) found the later delta correct. A code owner approved 7c5a37e, and the live Slack round trip is posted.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Approved at 7c5a37e on the owner's word. The tier fallback now has one owner (resolve_route_tier in src/policy/egress/result.py), shared by the Slack and Discord bridges. Proactive and [channel:] bodies are explicitly out of scope, with the destination-tier guard tracked in #4971. The red shard is the pool-restart flake, outside this diff.

@john-the-dev
john-the-dev merged commit c30087e into main Oct 22, 2026
108 of 112 checks passed
@john-the-dev
john-the-dev deleted the fix/recovery-error-never-to-non-owner branch October 22, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ag2product AG2 product related (packages, PyPI, task-relay)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants