Skip to content

fix(delegate): add action=list so a lost delegation ID is recoverable - #1546

Open
yatul wants to merge 1 commit into
nextlevelbuilder:devfrom
yatul:fix/delegate-list-action
Open

fix(delegate): add action=list so a lost delegation ID is recoverable#1546
yatul wants to merge 1 commit into
nextlevelbuilder:devfrom
yatul:fix/delegate-list-action

Conversation

@yatul

@yatul yatul commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #1545. Adds action: "list" to delegate, scoped to the chat the delegation was raised in.

Not stacked on anything — this applies to dev on its own.

The problem

A delegation result is addressable only by the UUID returned once, in a tool result, which the calling model then has to carry forward by hand. One mistyped character orphans a completed, durably stored result: get answers delegation result not found, and there is nothing else to ask. Two real failures from one production session, same caller:

created  b1bc9f93-3169-4680-92ce-d64c6e6a7d0a
get      b1bc9f93-3169-4680-92ce-d64c6e6a3d0a   -> delegation result not found   (x3)

and, earlier, the tail of the previous delegation spliced onto the next one's prefix. After three failures the loop guard fires and the agent gives up. The delegated work had completed and written its deliverable; only the handle was lost.

spawn — the sibling async mechanism over the same table — has had list/wait/cancel all along. delegate had delegate/get.

Scope: tenant + calling agent + origin chat

Tenant and agent as get already resolves; the third predicate is OriginChatID.

The chat, not the session, because a delegation belongs to the conversation it was raised in. Work started in a team chat should stay visible in that team chat and not surface in someone's DM with the same agent. That is deliberate: the history should be available where it began, to the people who were there. A session is an implementation detail of one stretch of a conversation, not of the conversation.

The failure mode in the issue then falls out for free. Deferring long work, clearing the session and context, and coming back to ask for status is ordinary use — the clean context is the point of the reset. A SessionKey predicate would return an empty list exactly then, which is when the handle is most likely already gone. With the chat, a reset changes the session key and nothing else.

Not spawn's current scope. #1525 (P1-high, area:security) reports that spawn's list/wait/cancel filter on the parent agent key alone and ignore the session, so one chat enumerates another chat's tasks — and on an open agent, one user reads another user's task text. The chat predicate is what keeps that from being reintroduced here.

OriginUserID is deliberately not added on top. In a direct chat the chat ID is already per person. In a group, a member asking after work the group watched being handed out is reading their own conversation's history. Happy to add it if you would rather have it stricter — it is a predicate, not a redesign.

get is unchanged, deliberately

#1525 is an enumeration defect: no prior knowledge required, and task text is disclosed. get is access through an unguessable handle — there is no way to walk the space and nothing is disclosed without one.

A session predicate on get would also break fetching a result by an ID kept across a reset or from another chat with the same agent, which is the exact failure this PR exists to remove. list draws a boundary around discovery; get stays a capability check.

Implementation notes

  • ToolChatIDFromCtx is used deliberately, being the same accessor createDelegateCompletion writes from. OriginChatIDFromCtx prefers the workspace chat ID and would disagree with the stored value on delegated runs.
  • No chat in context is a refusal, not a fallback to listing everything.
  • No schema change: the origin fields are already persisted. ListByParent is filtered in Go behind a cap of 20, which suits handle recovery; a dedicated store predicate is the obvious next step if this ever needs to page.

Tests

property test
only this chat's delegations, and spawns sharing the table are excluded TestDelegateListIsScopedToItsChat
a session reset does not hide the delegation TestDelegateListSurvivesSessionReset
no chat means refuse, without querying the store TestDelegateListRefusesWithoutChat
the cap holds TestDelegateListStopsAtItsLimit

The fake store leaves ListBySession embedded and nil, so a refactor back to session scoping panics rather than passing quietly.

go test ./cmd/... ./internal/tools/ ./internal/agent/... ./internal/gateway/... ./internal/http/... is green.

Fixes nextlevelbuilder#1545. A delegation result was addressable only by the UUID returned once
in a tool result, which the calling model had to carry forward by hand. One
mistyped character orphaned a completed, durably stored result with no way back:
`get` answers "delegation result not found", and there was nothing else to ask.
Observed in production with a 31B-class caller — one flipped character, and
separately a splice of the previous delegation's tail onto the next one's prefix.

`spawn`, the sibling async mechanism over the same table, has had list/wait/cancel
all along; `delegate` had delegate/get.

Scope is tenant and calling agent, as get already resolves, plus the origin chat.
The chat rather than the session, for three reasons:

  - It survives a session reset. Deferring long work, clearing the context and
    coming back to ask for status is ordinary use; a SessionKey predicate would
    return nothing exactly then — when the handle is most likely already lost.
  - It keeps chats apart, which is the enumeration boundary nextlevelbuilder#1525 is about: there
    spawn's list filters on the parent agent key alone and ignores the session,
    so one chat reads another chat's task text.
  - It does not carry a conversation between chats. A delegation raised in a team
    chat stays visible in that team chat and does not surface in someone's DM
    with the same agent. History stays where it began.

In a direct chat that separates users as well, since the chat ID is per person.
Group chats deliberately show the group what the group started.

get is left as it was, deliberately. nextlevelbuilder#1525 is an enumeration defect — no prior
knowledge needed and task text is disclosed. get is access through an unguessable
handle, and adding a predicate there would break fetching a result by an ID kept
across a reset, which is the very failure this fixes.

No schema change: the origin fields are already persisted by
createDelegateCompletion. ListByParent is filtered in Go behind a cap of 20,
which suits handle recovery; a dedicated predicate would be the next step if this
ever needs to page.

Tests pin the chat boundary, the session-reset case, refusal when there is no
chat to scope to (without querying the store), and the cap. The fake store leaves
ListBySession embedded and nil, so a refactor back to session scoping panics
rather than passing quietly.

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

@clark-cant clark-cant left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary

Adds action: "list" to the delegate tool, scoped to tenant + agent + origin chat, so a caller that lost a delegation UUID can recover it instead of orphaning a completed result.

Risk level: Low — small, well-scoped change with 4 tests pinning the contract.

Mandatory Gates

  • Duplicate / prior implementation: clear — no prior PR or merged work implements this.
  • Project standards: docs found — follows established executeGetCompletion patterns, consistent error handling, proper context propagation.
  • Strategic necessity: clear value — directly fixes #1545 (confirmed P2 bug). Real production failure where a mistyped UUID orphans durably stored work with no recovery path.

Findings

No critical or important issues. The design is sound:

  • Chat scoping (not session) is the right call — survives session resets, keeps chats apart (addresses #1525's enumeration concern), and keeps history where it began.
  • ToolChatIDFromCtx is correctly chosen over OriginChatIDFromCtx to match what createDelegateCompletion writes.
  • No-chat refusal (not fallback) is correct — listing everything would cross the boundary this predicate draws.
  • Cap of 20 suits handle recovery; ListByParent reuse is pragmatic.
  • Tests are excellent — the nil embedded SubagentTaskStore trick to catch regressions back to session scoping is particularly sharp.

Verdict: Approve.

Posted by /ck:review-pr at 2026-09-01T14:42:00Z

@clark-cant clark-cant left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review Summary

Verdict: Approve — clean, well-scoped fix for a real production problem.

Mandatory Gates

  • Duplicate/prior implementation: Clear — no prior list action exists for delegate; spawn has it but delegate did not.
  • Project standards: Follows existing executeGetCompletion patterns, proper Go conventions, good error handling.
  • Strategic necessity: Clear value — lost delegation UUIDs orphan completed work with no recovery path (#1545).

Analysis

The design choice to scope by origin chat (not session) is correct and well-argued:

  • Survives session resets (the exact failure case)
  • Keeps chats apart (avoids reintroducing #1525's enumeration defect)
  • Group chats see their own history, which is appropriate

The test suite is excellent:

  • TestDelegateListIsScopedToItsChat — verifies chat boundary + spawn exclusion
  • TestDelegateListSurvivesSessionReset — tests the exact failure case
  • TestDelegateListRefusesWithoutChat — verifies fail-closed behavior (store not queried)
  • TestDelegateListStopsAtItsLimit — verifies cap

The fake store's embedded ListBySession that panics on use is a clever regression guard against accidentally refactoring back to session scoping.

CI Status

The go test failure appears to be a pre-existing issue on dev — same failure pattern on PRs #1530 and #1537 from the same period. This PR's changes are isolated to internal/tools/ and do not introduce the failure.

Merge Note

Merge is currently blocked by CI. Once the pre-existing dev test issue is resolved, this PR should merge cleanly.


Posted by github-maintain automation at $(date -u +%Y-%m-%dT%H:%M:%SZ)

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.

delegate has no way to list your own delegations, so one mistyped UUID makes a completed result permanently unreachable

2 participants