feat(relay): add shared-room agent delegation#60
Conversation
2c4fdbf to
336b73a
Compare
There was a problem hiding this comment.
Pull request overview
Adds opt-in shared-room agent delegation so machine bots can create visible task cards, claim delegated work, persist task state/audit, and report lifecycle results across Telegram, Discord, and Slack.
Changes:
- Adds delegation domain, runtime, approval, command/card, config, diagnostics, state, and adapter integration.
- Adds OpenSpec requirements/design/tasks and user/admin documentation.
- Adds unit and runtime tests for parsing, lifecycle, config, state, diagnostics, and Discord adapter/runtime behavior.
Reviewed changes
Copilot reviewed 46 out of 46 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
vitest.config.ts |
Configures Vitest serialization/timeouts. |
extensions/relay/core/agent-delegation.ts |
Adds delegation task model, lifecycle, trust, eligibility, redaction, and rendering helpers. |
extensions/relay/core/agent-delegation-runtime.ts |
Adds shared ingress evaluation and prompt handoff helpers. |
extensions/relay/core/agent-delegation-approval.ts |
Adds task/session scoped approval grant helpers. |
extensions/relay/core/types.ts |
Extends relay config/state types for delegation. |
extensions/relay/core/index.ts |
Exports new delegation helpers. |
extensions/relay/commands/delegation.ts |
Adds delegation command/action parsing and task card rendering. |
extensions/relay/commands/remote.ts |
Adds delegation help text. |
extensions/relay/state/tunnel-store.ts |
Persists delegation tasks and audit history. |
extensions/relay/state/schema.ts |
Extends relay store schema. |
extensions/relay/state/migration.ts |
Normalizes delegation state fields. |
extensions/relay/config/schema.ts |
Adds delegation config schema types. |
extensions/relay/config/loader.ts |
Resolves and validates delegation config. |
extensions/relay/config/tunnel-config.ts |
Wires delegation into legacy runtime config. |
extensions/relay/config/diagnostics.ts |
Reports delegation readiness/warnings. |
extensions/relay/adapters/discord/adapter.ts |
Allows bot-authored Discord messages when delegation is enabled. |
extensions/relay/adapters/discord/runtime.ts |
Handles Discord delegation messages/actions and task completion reporting. |
extensions/relay/adapters/slack/runtime.ts |
Handles Slack delegation messages/actions and task completion reporting. |
extensions/relay/adapters/telegram/runtime.ts |
Handles Telegram delegation commands and task completion reporting. |
README.md |
Documents delegation commands and behavior. |
docs/adapters.md |
Updates adapter/shared-room delegation notes. |
docs/config.md |
Documents delegation configuration. |
docs/shared-room-parity.md |
Updates platform parity for delegation/shared rooms. |
docs/testing.md |
Adds manual delegation smoke checks. |
openspec/changes/add-shared-room-agent-delegation/.openspec.yaml |
Adds OpenSpec metadata. |
openspec/changes/add-shared-room-agent-delegation/proposal.md |
Defines proposal rationale and impact. |
openspec/changes/add-shared-room-agent-delegation/design.md |
Defines design goals, decisions, risks, and migration plan. |
openspec/changes/add-shared-room-agent-delegation/tasks.md |
Tracks implementation tasks. |
openspec/changes/add-shared-room-agent-delegation/specs/relay-agent-delegation/spec.md |
Adds delegation requirements. |
openspec/changes/add-shared-room-agent-delegation/specs/shared-room-machine-bots/spec.md |
Adds shared-room bot routing requirements. |
openspec/changes/add-shared-room-agent-delegation/specs/messenger-relay-sessions/spec.md |
Adds delegated prompt delivery requirements. |
openspec/changes/add-shared-room-agent-delegation/specs/relay-configuration/spec.md |
Adds delegation configuration requirements. |
openspec/changes/add-shared-room-agent-delegation/specs/relay-broker-topology/spec.md |
Adds delegation broker ownership requirements. |
tests/relay/agent-delegation.test.ts |
Tests delegation domain helpers. |
tests/relay/agent-delegation-runtime.test.ts |
Tests runtime ingress helpers. |
tests/relay/agent-delegation-approval.test.ts |
Tests approval grant helpers. |
tests/relay/delegation-commands.test.ts |
Tests command parsing and card rendering. |
tests/relay/config-loader.test.ts |
Tests delegation config loading/validation. |
tests/relay/config-diagnostics.test.ts |
Tests delegation diagnostics. |
tests/state-store.test.ts |
Tests delegation state migration/audit/stale handling. |
tests/discord-runtime.test.ts |
Tests Discord delegation runtime flow. |
tests/discord-adapter.test.ts |
Tests Discord bot-message delegation ingress. |
tests/relay/binding-authority.test.ts |
Updates test state fixtures. |
tests/relay/requester-file-delivery.test.ts |
Updates test state fixtures. |
tests/relay/local-disconnect.test.ts |
Stabilizes env isolation for tests. |
tests/integration.test.ts |
Stabilizes config path env for integration tests. |
Comments suppressed due to low confidence (3)
extensions/relay/adapters/discord/runtime.ts:231
- When delegation parsing returns an ignored result such as
not-delegationornot-eligible, this falls through to the normal shared-room routing path. Because the adapter now admits bot-authored Discord messages whenever delegation is enabled, ordinary bot output can be treated as a regular authorized shared-room prompt in guilds with broad allow settings, violating the requirement that bot-authored non-delegation output remain inert.
}
extensions/relay/adapters/slack/runtime.ts:398
- When delegation parsing returns an ignored result such as
not-delegationornot-eligible, this falls through to the normal Slack routing path. The live fallback now normalizes bot-authored Slack messages for delegation, so ordinary peer-bot output can be routed as a regular channel prompt when user allow-lists are broad, instead of remaining inert.
await this.handlePairing(message, pairingCode);
return;
extensions/relay/adapters/telegram/runtime.ts:2207
- After reporting a completed delegation task, this continues into the normal Telegram output path. Delegated task results can therefore be delivered to the route's existing active output binding in addition to the originating task room, which breaks the task-scoped output boundary for delegated work.
);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 50 out of 50 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (11)
extensions/relay/adapters/discord/runtime.ts:516
- This ignores the task returned by
upsertDelegationTask, so a compare-and-set conflict can be silently lost. If a human cancels or otherwise mutates the task while prompt delivery is in flight, the store will keep the newer state but this code still renders the stalestartedcard, misleading users and leaving the active-session map associated with a task that was not actually advanced.
const started = transitionDelegationTask(claimedTask, { kind: "start", summary: `Started in ${route.sessionLabel}.` });
const next = started.ok ? started.task : claimedTask;
await this.store.upsertDelegationTask(next);
await this.sendDelegationTaskCard(message, next);
extensions/relay/adapters/slack/runtime.ts:620
- This ignores the task returned by
upsertDelegationTask, so a concurrent cancellation or mutation during prompt handoff can be rejected by the store while the runtime still renders the stalestartedstate. Use the returned task ortryUpsertDelegationTaskresult before announcing the lifecycle transition.
const started = transitionDelegationTask(claimedTask, { kind: "start", summary: `Started in ${route.sessionLabel}.` });
const next = started.ok ? started.task : claimedTask;
await this.store.upsertDelegationTask(next);
await this.sendDelegationTaskCard(message, next);
extensions/relay/adapters/telegram/runtime.ts:1148
- This ignores the task returned by
upsertDelegationTask, so if a cancellation or other mutation races with prompt handoff, the store can reject this stalestartedupdate while the runtime still posts it as if it applied. Check the returned/current task before rendering the task card.
const started = transitionDelegationTask(claimedTask, { kind: "start", summary: `Started in ${route.sessionLabel}.` });
const next = started.ok ? started.task : claimedTask;
await this.store.upsertDelegationTask(next);
await this.sendTelegramDelegationTaskCard(message, next);
extensions/relay/adapters/discord/runtime.ts:532
- Terminal updates also ignore the task returned by
upsertDelegationTask. If a user cancels/declines the task after it is read but before this write, the store rejects the stale completion via conflict detection, yet this code still posts the stale completed/failed card to the room.
const next = transition.ok ? transition.task : task;
await this.store.upsertDelegationTask(next);
await this.adapter.sendText({ channel: DISCORD_CHANNEL, conversationId: next.room.conversationId, userId: "delegation" }, renderDelegationTaskCard(next, { commandPrefix: "relay task" }).text);
extensions/relay/adapters/slack/runtime.ts:636
- Terminal updates ignore the task returned by
upsertDelegationTask. A racing cancellation or other lifecycle mutation can make the store keep the newer state while this code still posts a stale completed/failed task card to the Slack room/thread.
const next = transition.ok ? transition.task : task;
await this.store.upsertDelegationTask(next);
await this.adapter.sendText({ channel: SLACK_CHANNEL, conversationId: next.room.conversationId, userId: "delegation", ...(next.room.threadId ? { threadTs: next.room.threadId } : {}) } as ChannelRouteAddress, renderDelegationTaskCard(next, { commandPrefix: "relay task" }).text);
extensions/relay/adapters/telegram/runtime.ts:1167
- Terminal updates ignore the task returned by
upsertDelegationTask. If cancellation or another lifecycle mutation races with completion, the compare-and-set conflict keeps the newer stored state but this code still reports the stale completed/failed task card to Telegram.
const next = transition.ok ? transition.task : task;
await this.store.upsertDelegationTask(next);
await this.api.sendPlainText(Number(next.room.conversationId), renderDelegationTaskCard(next, { commandPrefix: "/task" }).text);
extensions/relay/adapters/discord/runtime.ts:528
- Completion lookup omits the configured
runningTimeoutMs, so an active delegated turn that has exceeded the running timeout can still be marked completed/failed when it eventually finishes. Other task lookups pass the timeout and normalize expired running tasks; terminal reporting should do the same before accepting completion.
const task = await this.store.getDelegationTask(taskId);
if (!task) return false;
const summary = summarizeTextDeterministically(route.notification.lastAssistantText ?? route.notification.lastFailure ?? status, 320);
const transition = status === "completed"
? transitionDelegationTask(task, { kind: "complete", summary: summary || "Completed." })
extensions/relay/adapters/slack/runtime.ts:632
- Completion lookup omits the configured
runningTimeoutMs, allowing an over-time delegated task to be completed when it eventually returns instead of being normalized to expired. Pass the Slack delegation running timeout here before applying the terminal transition.
const task = await this.store.getDelegationTask(taskId);
if (!task) return false;
const summary = route.notification.lastAssistantText ?? route.notification.lastFailure ?? status;
const transition = status === "completed"
? transitionDelegationTask(task, { kind: "complete", summary })
extensions/relay/adapters/telegram/runtime.ts:1163
- Completion lookup omits the configured delegation
runningTimeoutMs, so a task that has exceeded its running timeout can still be completed when the Pi turn finishes. Use the same timeout-normalized lookup used by task actions before applying the terminal transition.
const task = await this.store.getDelegationTask(taskId);
if (!task) return false;
const summary = route.notification.lastAssistantText ?? route.notification.lastFailure ?? status;
const transition = status === "completed"
? transitionDelegationTask(task, { kind: "complete", summary })
extensions/relay/adapters/slack/runtime.ts:436
applySharedRoomPreRoutingruns beforeisSlackIdentityAllowed, and it can write active channel selections forrelay use <machine> <session>in a channel. That lets an unauthorized Slack user mutate shared-room routing state even though normal prompt handling rejects them later. Authorize the sender before pre-routing side effects or defer those writes until after authorization.
const delegatedCommand = parseDelegationInvocation(message.text, { prefixes: ["relay", "pirelay"] }) ?? parseDelegationInvocation(stripLeadingSlackMentions(message.text), { prefixes: ["relay", "pirelay"] });
const routedMessage = await this.applySharedRoomPreRouting(message);
if (!routedMessage) {
if (delegatedCommand) return;
return;
extensions/relay/adapters/slack/runtime.ts:432
- Bot-authored Slack messages are now normalized so delegation can be handled, but this new delegation parsing happens after the existing pairing branch. A bot message containing
relay pair <pin>can still callhandlePairingand mutate channel bindings before it is classified as a delegation/peer-bot event. Drop peer-bot messages before pairing unless they are validated delegation commands/actions.
const delegatedCommand = parseDelegationInvocation(message.text, { prefixes: ["relay", "pirelay"] }) ?? parseDelegationInvocation(stripLeadingSlackMentions(message.text), { prefixes: ["relay", "pirelay"] });
Summary
add-shared-room-agent-delegationadd-relay-approval-gatesValidation
npm run typechecknpm testnpm run openspec:validateopenspec validate add-shared-room-agent-delegation --strict