fix(delegate): add action=list so a lost delegation ID is recoverable - #1546
fix(delegate): add action=list so a lost delegation ID is recoverable#1546yatul wants to merge 1 commit into
Conversation
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
left a comment
There was a problem hiding this comment.
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
executeGetCompletionpatterns, 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.
ToolChatIDFromCtxis correctly chosen overOriginChatIDFromCtxto match whatcreateDelegateCompletionwrites.- No-chat refusal (not fallback) is correct — listing everything would cross the boundary this predicate draws.
- Cap of 20 suits handle recovery;
ListByParentreuse is pragmatic. - Tests are excellent — the nil embedded
SubagentTaskStoretrick 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
left a comment
There was a problem hiding this comment.
Review Summary
Verdict: Approve — clean, well-scoped fix for a real production problem.
Mandatory Gates
- Duplicate/prior implementation: Clear — no prior
listaction exists for delegate; spawn has it but delegate did not. - Project standards: Follows existing
executeGetCompletionpatterns, 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 exclusionTestDelegateListSurvivesSessionReset— tests the exact failure caseTestDelegateListRefusesWithoutChat— 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)
Fixes #1545. Adds
action: "list"todelegate, scoped to the chat the delegation was raised in.Not stacked on anything — this applies to
devon 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:
getanswersdelegation result not found, and there is nothing else to ask. Two real failures from one production session, same caller: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 hadlist/wait/cancelall along.delegatehaddelegate/get.Scope: tenant + calling agent + origin chat
Tenant and agent as
getalready resolves; the third predicate isOriginChatID.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
SessionKeypredicate 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 thatspawn'slist/wait/cancelfilter 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.OriginUserIDis 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.getis unchanged, deliberately#1525 is an enumeration defect: no prior knowledge required, and task text is disclosed.
getis access through an unguessable handle — there is no way to walk the space and nothing is disclosed without one.A session predicate on
getwould 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.listdraws a boundary around discovery;getstays a capability check.Implementation notes
ToolChatIDFromCtxis used deliberately, being the same accessorcreateDelegateCompletionwrites from.OriginChatIDFromCtxprefers the workspace chat ID and would disagree with the stored value on delegated runs.ListByParentis 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
TestDelegateListIsScopedToItsChatTestDelegateListSurvivesSessionResetTestDelegateListRefusesWithoutChatTestDelegateListStopsAtItsLimitThe fake store leaves
ListBySessionembedded 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.