agent-relay-server 0.126.0 → 0.127.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-B6YgulnX.js → MessageBubble-D2cnxyTy.js} +2 -2
  4. package/public/assets/{MessageBubble-B6YgulnX.js.map → MessageBubble-D2cnxyTy.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel--DNxHn1T.js → PlanGraphPanel-Cnxt-x7R.js} +2 -2
  6. package/public/assets/{PlanGraphPanel--DNxHn1T.js.map → PlanGraphPanel-Cnxt-x7R.js.map} +1 -1
  7. package/public/assets/{activity-Fo5aLYD6.js → activity-BEm1BQB1.js} +2 -2
  8. package/public/assets/{activity-Fo5aLYD6.js.map → activity-BEm1BQB1.js.map} +1 -1
  9. package/public/assets/{agent-profiles-C3p0xclG.js → agent-profiles-C8V_oCiM.js} +2 -2
  10. package/public/assets/{agent-profiles-C3p0xclG.js.map → agent-profiles-C8V_oCiM.js.map} +1 -1
  11. package/public/assets/{agents-_GBQyF5b.js → agents-D8mc9X1j.js} +2 -2
  12. package/public/assets/{agents-_GBQyF5b.js.map → agents-D8mc9X1j.js.map} +1 -1
  13. package/public/assets/{automation-Cf_Ewi9f.js → automation-BEhJZkNQ.js} +2 -2
  14. package/public/assets/{automation-Cf_Ewi9f.js.map → automation-BEhJZkNQ.js.map} +1 -1
  15. package/public/assets/{chat-BdbIWZpQ.js → chat-BAH9bEPx.js} +2 -2
  16. package/public/assets/chat-BAH9bEPx.js.map +1 -0
  17. package/public/assets/{formatted-body-jjhFXTap.js → formatted-body-Rv9RDPsQ.js} +3 -3
  18. package/public/assets/{formatted-body-jjhFXTap.js.map → formatted-body-Rv9RDPsQ.js.map} +1 -1
  19. package/public/assets/{formatted-body-impl-gOLBKeyW.js → formatted-body-impl-XFU4tPeT.js} +2 -2
  20. package/public/assets/{formatted-body-impl-gOLBKeyW.js.map → formatted-body-impl-XFU4tPeT.js.map} +1 -1
  21. package/public/assets/{index-CgH1BZwq.js → index-Bhy_jnX4.js} +6 -6
  22. package/public/assets/{index-CgH1BZwq.js.map → index-Bhy_jnX4.js.map} +1 -1
  23. package/public/assets/{insights-CTW85An9.js → insights-DpXPAEwH.js} +2 -2
  24. package/public/assets/{insights-CTW85An9.js.map → insights-DpXPAEwH.js.map} +1 -1
  25. package/public/assets/{maintenance-D5PEygZz.js → maintenance-BrkqfGwx.js} +2 -2
  26. package/public/assets/{maintenance-D5PEygZz.js.map → maintenance-BrkqfGwx.js.map} +1 -1
  27. package/public/assets/{managed-agents-pkpAzZy8.js → managed-agents-CLANxOAN.js} +2 -2
  28. package/public/assets/{managed-agents-pkpAzZy8.js.map → managed-agents-CLANxOAN.js.map} +1 -1
  29. package/public/assets/{markdown-preview-impl-DMzFE-Lo.js → markdown-preview-impl-CcZFkjn-.js} +2 -2
  30. package/public/assets/{markdown-preview-impl-DMzFE-Lo.js.map → markdown-preview-impl-CcZFkjn-.js.map} +1 -1
  31. package/public/assets/{memory-BnquVYQz.js → memory-BoQw8q-i.js} +2 -2
  32. package/public/assets/{memory-BnquVYQz.js.map → memory-BoQw8q-i.js.map} +1 -1
  33. package/public/assets/{messages-DgxLlndC.js → messages-DfKNuph2.js} +2 -2
  34. package/public/assets/{messages-DgxLlndC.js.map → messages-DfKNuph2.js.map} +1 -1
  35. package/public/assets/{orchestrators-BaWxeAlK.js → orchestrators-sQh8e7-V.js} +2 -2
  36. package/public/assets/{orchestrators-BaWxeAlK.js.map → orchestrators-sQh8e7-V.js.map} +1 -1
  37. package/public/assets/{overview-Kvs-j2gg.js → overview-B0z8ZNtp.js} +2 -2
  38. package/public/assets/{overview-Kvs-j2gg.js.map → overview-B0z8ZNtp.js.map} +1 -1
  39. package/public/assets/{pairs-eDzbqcER.js → pairs-B3FhGmn-.js} +2 -2
  40. package/public/assets/{pairs-eDzbqcER.js.map → pairs-B3FhGmn-.js.map} +1 -1
  41. package/public/assets/{prompt-settings-CjcqBG_r.js → prompt-settings-CCj_s008.js} +2 -2
  42. package/public/assets/{prompt-settings-CjcqBG_r.js.map → prompt-settings-CCj_s008.js.map} +1 -1
  43. package/public/assets/{registry-C1DSnjjg.js → registry-mD_y06Ru.js} +2 -2
  44. package/public/assets/{registry-C1DSnjjg.js.map → registry-mD_y06Ru.js.map} +1 -1
  45. package/public/assets/{security-VbEe_jwm.js → security-CTSA9niL.js} +2 -2
  46. package/public/assets/{security-VbEe_jwm.js.map → security-CTSA9niL.js.map} +1 -1
  47. package/public/assets/{select-CVfwgfKl.js → select-gEQEHCIf.js} +2 -2
  48. package/public/assets/{select-CVfwgfKl.js.map → select-gEQEHCIf.js.map} +1 -1
  49. package/public/assets/{settings-ujA9hZW1.js → settings-ByAR-TYj.js} +2 -2
  50. package/public/assets/{settings-ujA9hZW1.js.map → settings-ByAR-TYj.js.map} +1 -1
  51. package/public/assets/{tasks-BtbKaa-b.js → tasks-CZyJX881.js} +2 -2
  52. package/public/assets/{tasks-BtbKaa-b.js.map → tasks-CZyJX881.js.map} +1 -1
  53. package/public/assets/{teams-CrVDQ99v.js → teams-BrL4sEL7.js} +2 -2
  54. package/public/assets/{teams-CrVDQ99v.js.map → teams-BrL4sEL7.js.map} +1 -1
  55. package/public/assets/{terminal-viewer-impl-Aays_e7-.js → terminal-viewer-impl-LjIph8ql.js} +2 -2
  56. package/public/assets/{terminal-viewer-impl-Aays_e7-.js.map → terminal-viewer-impl-LjIph8ql.js.map} +1 -1
  57. package/public/assets/types-BqeSkWrv.js.map +1 -1
  58. package/public/assets/{work-queue-DDSGZO4Z.js → work-queue-CSu5kRsw.js} +2 -2
  59. package/public/assets/{work-queue-DDSGZO4Z.js.map → work-queue-CSu5kRsw.js.map} +1 -1
  60. package/public/index.html +2 -2
  61. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  62. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +2 -2
  63. package/runner/src/relay-mcp-proxy.ts +1 -3
  64. package/src/db/index.ts +1 -0
  65. package/src/db/task-deliverables.ts +206 -0
  66. package/src/db/task-entity.ts +4 -8
  67. package/src/mcp/index.ts +4 -4
  68. package/src/mcp/tools-workspace.ts +3 -36
  69. package/src/mcp-task-deliver-tools.ts +143 -0
  70. package/src/notification-rules.ts +1 -0
  71. package/src/notification-types.ts +2 -0
  72. package/src/prompt-resolver.ts +6 -6
  73. package/src/services/task-delivery.ts +275 -0
  74. package/src/workspace-phase.ts +5 -5
  75. package/public/assets/chat-BdbIWZpQ.js.map +0 -1
@@ -0,0 +1,143 @@
1
+ import { McpAuthError } from "./mcp-errors";
2
+ import { ValidationError } from "./db";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { deliverTask } from "./services/task-delivery";
5
+ import { ServiceAuthError } from "./services/errors";
6
+ import { cleanString, optionalEnum } from "./validation";
7
+ import {
8
+ TASK_DELIVERY_DISPOSITIONS,
9
+ TASK_BLOCKER_TYPES,
10
+ TASK_GATE_STATUSES,
11
+ isRecord,
12
+ type TaskDeliveryGateReports,
13
+ type TaskDeliveryResult,
14
+ } from "./types";
15
+
16
+ const TASK_ID_PROP = { oneOf: [{ type: "integer", minimum: 1 }, { type: "string" }], description: "Task id. Optional when the caller is bound to exactly one Task." } as const;
17
+ const COMMIT_SCHEMA = {
18
+ type: "object",
19
+ properties: { sha: { type: "string" }, subject: { type: "string" } },
20
+ required: ["sha"],
21
+ additionalProperties: false,
22
+ } as const;
23
+ const GATE_RESULT_SCHEMA = {
24
+ type: "object",
25
+ properties: {
26
+ status: { type: "string", enum: TASK_GATE_STATUSES },
27
+ detail: { type: "string" },
28
+ count: { type: "integer", minimum: 0 },
29
+ runId: { type: "string" },
30
+ },
31
+ required: ["status"],
32
+ additionalProperties: false,
33
+ } as const;
34
+
35
+ export const TASK_DELIVER_TOOLS = [
36
+ {
37
+ name: "relay_task_deliver",
38
+ description: "Submit a structured Delivery Result for a durable Task. Writes a typed deliverable event to the Task thread before any side effect. disposition:'complete' requires passing typecheck+test gates and auto-triggers the caller's task-bound isolated workspace handoff to land.",
39
+ requiredScopes: ["task:write"],
40
+ alwaysLoad: true,
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ taskId: TASK_ID_PROP,
45
+ disposition: { type: "string", enum: TASK_DELIVERY_DISPOSITIONS },
46
+ summary: { type: "string" },
47
+ reason: { type: "string", description: "Required for blocked/failed deliveries." },
48
+ blockedBy: {
49
+ type: "array",
50
+ description: "Required for blocked deliveries; records the blocker set on the Task.",
51
+ items: {
52
+ type: "object",
53
+ properties: { type: { type: "string", enum: TASK_BLOCKER_TYPES }, ref: { type: "string" }, note: { type: "string" } },
54
+ required: ["type"],
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ commits: { type: "array", items: COMMIT_SCHEMA },
59
+ gates: {
60
+ type: "object",
61
+ properties: {
62
+ typecheck: GATE_RESULT_SCHEMA,
63
+ test: GATE_RESULT_SCHEMA,
64
+ other: {
65
+ type: "array",
66
+ items: {
67
+ type: "object",
68
+ properties: { name: { type: "string" }, status: { type: "string", enum: TASK_GATE_STATUSES }, detail: { type: "string" }, count: { type: "integer", minimum: 0 }, runId: { type: "string" } },
69
+ required: ["name", "status"],
70
+ additionalProperties: false,
71
+ },
72
+ },
73
+ },
74
+ additionalProperties: false,
75
+ },
76
+ filesChanged: { type: "integer", minimum: 0 },
77
+ rca: { type: "string" },
78
+ testCoverage: { type: "string" },
79
+ risks: { type: "array", items: { type: "string" } },
80
+ selfAcceptance: {
81
+ type: "array",
82
+ items: {
83
+ type: "object",
84
+ properties: { criterion: { type: "string" }, status: { type: "string", enum: ["met", "unmet"] }, note: { type: "string" } },
85
+ required: ["criterion", "status"],
86
+ additionalProperties: false,
87
+ },
88
+ },
89
+ workspaceId: { type: "string", description: "Defaults to the caller's isolated workspace." },
90
+ idempotencyKey: { type: "string", description: "Dedupes retry delivery and prevents double-land on replay." },
91
+ },
92
+ required: ["disposition", "summary"],
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown>; alwaysLoad?: boolean }>;
97
+
98
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
99
+
100
+ function optionalPositiveInt(value: unknown, field: string): number | undefined {
101
+ if (value === undefined || value === null) return undefined;
102
+ const n = typeof value === "string" ? Number(value) : value;
103
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) throw new ValidationError(`${field} must be a positive integer`);
104
+ return n;
105
+ }
106
+
107
+ function cleanDeliveryGates(value: unknown): TaskDeliveryGateReports | undefined {
108
+ if (value === undefined || value === null) return undefined;
109
+ if (!isRecord(value)) throw new ValidationError("gates must be an object");
110
+ return value as TaskDeliveryGateReports;
111
+ }
112
+
113
+ function cleanDeliveryResult(args: Record<string, unknown>): TaskDeliveryResult {
114
+ return {
115
+ disposition: optionalEnum(args.disposition, "disposition", TASK_DELIVERY_DISPOSITIONS)!,
116
+ summary: cleanString(args.summary, "summary", { required: true, max: 1_000_000 })!,
117
+ ...(args.reason !== undefined ? { reason: cleanString(args.reason, "reason", { max: 64_000 }) } : {}),
118
+ ...(Array.isArray(args.blockedBy) ? { blockedBy: args.blockedBy as TaskDeliveryResult["blockedBy"] } : {}),
119
+ ...(Array.isArray(args.commits) ? { commits: args.commits as TaskDeliveryResult["commits"] } : {}),
120
+ ...(args.gates !== undefined ? { gates: cleanDeliveryGates(args.gates) } : {}),
121
+ ...(typeof args.filesChanged === "number" ? { filesChanged: args.filesChanged } : {}),
122
+ ...(args.rca !== undefined ? { rca: cleanString(args.rca, "rca", { max: 64_000 }) } : {}),
123
+ ...(args.testCoverage !== undefined ? { testCoverage: cleanString(args.testCoverage, "testCoverage", { max: 64_000 }) } : {}),
124
+ ...(Array.isArray(args.risks) ? { risks: args.risks.filter((risk): risk is string => typeof risk === "string") } : {}),
125
+ ...(Array.isArray(args.selfAcceptance) ? { selfAcceptance: args.selfAcceptance as TaskDeliveryResult["selfAcceptance"] } : {}),
126
+ };
127
+ }
128
+
129
+ export async function relayTaskDeliverTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
130
+ if (name !== "relay_task_deliver") throw new ValidationError(`unknown tool: ${name}`);
131
+ if (!isRecord(args)) throw new ValidationError("arguments must be an object");
132
+ try {
133
+ return await deliverTask({
134
+ ...cleanDeliveryResult(args),
135
+ taskId: optionalPositiveInt(args.taskId, "taskId"),
136
+ workspaceId: cleanString(args.workspaceId, "workspaceId", { max: 240 }),
137
+ idempotencyKey: cleanString(args.idempotencyKey, "idempotencyKey", { max: 240 }),
138
+ }, authContextFromMcp(auth));
139
+ } catch (error) {
140
+ if (error instanceof ServiceAuthError) throw new McpAuthError(error.message);
141
+ throw error;
142
+ }
143
+ }
@@ -274,6 +274,7 @@ registerDefaultAudienceRules("message.new", [
274
274
  ]);
275
275
 
276
276
  for (const type of [
277
+ "task.deliverable",
277
278
  "task.created",
278
279
  "task.started",
279
280
  "task.blocked",
@@ -2,6 +2,7 @@ export type NotificationType =
2
2
  | "message.new"
3
3
  | "landed-failure"
4
4
  | "branch.landed"
5
+ | "task.deliverable"
5
6
  | "task.deliverable_landed"
6
7
  | "task.created"
7
8
  | "task.started"
@@ -99,6 +100,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
99
100
  "message.new": null,
100
101
  "landed-failure": null,
101
102
  "branch.landed": null,
103
+ "task.deliverable": null,
102
104
  // The #831 auto-driven "task deliverable landed" receipt to the coordinator. Durable (null):
103
105
  // it carries the auto-actions the coordinator confirms/exception-handles, so it must survive
104
106
  // the coordinator being briefly offline rather than expiring as an ephemeral bystander ping.
@@ -289,7 +289,7 @@ const DEFAULT_PROMPT_TEMPLATES = [
289
289
  template: lines(
290
290
  "You are in an isolated git worktree on branch {{branch}}, based on {{baseRef}} — NOT the main checkout. {{baseRef}} moves under you as other agents land in parallel; that's expected.",
291
291
  "Your shell's working directory is already this worktree — run commands directly without prefixing `cd`.",
292
- "Changes reach the base via: commit your work, then call `relay_workspace_ready`. Relay rebases onto the latest base, lands, and pushes for you.",
292
+ "Changes reach the base via: commit your work, then call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates. Relay records the typed delivery on the Task, then rebases onto the latest base, lands, and pushes for you.",
293
293
  "Do NOT push, rebase, merge, resolve conflicts, or `cd` into the main checkout — Relay (and a steward, spawned only if a clean auto-merge isn't possible) own all of that.",
294
294
  "After `ready` the status is `ready` (a normal, healthy hand-off state, not a stall). Stop your turn and go idle; Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails.",
295
295
  "Call `relay_workspace_status` anytime to see where you are and the exact next step.",
@@ -297,7 +297,7 @@ const DEFAULT_PROMPT_TEMPLATES = [
297
297
  defaultTemplate: lines(
298
298
  "You are in an isolated git worktree on branch {{branch}}, based on {{baseRef}} — NOT the main checkout. {{baseRef}} moves under you as other agents land in parallel; that's expected.",
299
299
  "Your shell's working directory is already this worktree — run commands directly without prefixing `cd`.",
300
- "Changes reach the base via: commit your work, then call `relay_workspace_ready`. Relay rebases onto the latest base, lands, and pushes for you.",
300
+ "Changes reach the base via: commit your work, then call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates. Relay records the typed delivery on the Task, then rebases onto the latest base, lands, and pushes for you.",
301
301
  "Do NOT push, rebase, merge, resolve conflicts, or `cd` into the main checkout — Relay (and a steward, spawned only if a clean auto-merge isn't possible) own all of that.",
302
302
  "After `ready` the status is `ready` (a normal, healthy hand-off state, not a stall). Stop your turn and go idle; Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails.",
303
303
  "Call `relay_workspace_status` anytime to see where you are and the exact next step.",
@@ -423,8 +423,8 @@ const DEFAULT_PROMPT_TEMPLATES = [
423
423
  category: "workspace-phase",
424
424
  title: "Workspace Active Hint",
425
425
  description: "Workspace status hint for active isolated work.",
426
- template: "Commit your work in this worktree, then mark it ready. Relay rebases onto the latest base, lands, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
427
- defaultTemplate: "Commit your work in this worktree, then mark it ready. Relay rebases onto the latest base, lands, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
426
+ template: "Commit your work in this worktree, then deliver the Task with `relay_task_deliver` using disposition `complete` and passing `typecheck` + `test` gates. Relay records the typed delivery, lands the branch, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
427
+ defaultTemplate: "Commit your work in this worktree, then deliver the Task with `relay_task_deliver` using disposition `complete` and passing `typecheck` + `test` gates. Relay records the typed delivery, lands the branch, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
428
428
  variables: [],
429
429
  },
430
430
  {
@@ -432,8 +432,8 @@ const DEFAULT_PROMPT_TEMPLATES = [
432
432
  category: "workspace-phase",
433
433
  title: "Workspace Ready Headline",
434
434
  description: "Workspace status headline for healthy handoff states.",
435
- template: "Handed off — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-ready state (not an escalation).",
436
- defaultTemplate: "Handed off — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-ready state (not an escalation).",
435
+ template: "Delivered — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-delivery state (not an escalation).",
436
+ defaultTemplate: "Delivered — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-delivery state (not an escalation).",
437
437
  variables: [],
438
438
  },
439
439
  {
@@ -0,0 +1,275 @@
1
+ import {
2
+ getTaskDetail,
3
+ getWorkspace,
4
+ insertTaskEvent,
5
+ listTaskEvents,
6
+ ownedIsolatedWorkspace,
7
+ recordGate,
8
+ recordTaskDeliverable,
9
+ setTaskStatus,
10
+ ValidationError,
11
+ } from "../db";
12
+ import { emitRelayEvent } from "../events";
13
+ import { routeNotification } from "../notification-router";
14
+ import { emitTaskChanged, emitNewMessages } from "../sse";
15
+ import { agentBoundTaskIds, hasTaskReadAdminScope } from "../task-authz";
16
+ import { applyWorkspaceAction } from "../workspace-actions";
17
+ import { emitTaskSemanticNotification } from "./task-semantic-events";
18
+ import { ServiceAuthError } from "./errors";
19
+ import { resolveCoordinatorAgentId } from "../steward-identity";
20
+ import { markAgentWorkFinal } from "./work-finality";
21
+ import type { AuthContext } from "./auth-context";
22
+ import type { Command, TaskBlocker, TaskDeliverableEventMetadata, TaskDeliveryGateReports, TaskDeliveryResult, TaskGateStatus, TaskDetail, WorkspaceRecord } from "../types";
23
+
24
+ const REQUIRED_COMPLETE_GATES = ["typecheck", "test"] as const;
25
+
26
+ export interface DeliverTaskInput extends TaskDeliveryResult {
27
+ taskId?: number;
28
+ workspaceId?: string;
29
+ idempotencyKey?: string;
30
+ }
31
+
32
+ interface GateWrite {
33
+ gate: string;
34
+ status: TaskGateStatus;
35
+ detail?: string;
36
+ count?: number;
37
+ runId?: string;
38
+ }
39
+
40
+ function callerLabel(ctx: AuthContext): string {
41
+ return ctx.callerAgentId ?? ctx.actor.id;
42
+ }
43
+
44
+ function taskIdFrom(value: unknown): number | undefined {
45
+ if (value === undefined || value === null) return undefined;
46
+ const n = typeof value === "string" ? Number(value) : value;
47
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) throw new ValidationError("taskId must be a positive integer");
48
+ return n;
49
+ }
50
+
51
+ function resolveTaskId(ctx: AuthContext, explicit: unknown): number {
52
+ const taskId = taskIdFrom(explicit);
53
+ const caller = ctx.callerAgentId;
54
+ const bound = caller ? agentBoundTaskIds(caller) : new Set<number>();
55
+ const admin = hasTaskReadAdminScope(ctx.scopes);
56
+ if (taskId !== undefined) {
57
+ if (admin || bound.has(taskId)) return taskId;
58
+ throw new ServiceAuthError("taskId is not bound to the caller");
59
+ }
60
+ if (bound.size === 1) return [...bound][0]!;
61
+ if (bound.size === 0) throw new ServiceAuthError("caller is not bound to a Task");
62
+ throw new ValidationError("caller is bound to multiple Tasks; pass taskId explicitly");
63
+ }
64
+
65
+ function resolveWorkspace(ctx: AuthContext, input: { workspaceId?: string }, taskId: number): WorkspaceRecord | undefined {
66
+ const caller = ctx.callerAgentId;
67
+ const explicit = input.workspaceId?.trim();
68
+ const ws = explicit ? getWorkspace(explicit) : caller ? ownedIsolatedWorkspace(caller) : undefined;
69
+ if (!ws) return undefined;
70
+ if (!hasTaskReadAdminScope(ctx.scopes) && (!caller || ws.ownerAgentId !== caller)) {
71
+ throw new ServiceAuthError(`workspace ${ws.id} is not owned by the caller`);
72
+ }
73
+ const task = getTaskDetail(taskId);
74
+ if (!task) throw new ValidationError("task not found");
75
+ if (task.branch && ws.branch && task.branch !== ws.branch) {
76
+ throw new ValidationError(`task ${taskId} is bound to branch ${task.branch}, not workspace branch ${ws.branch}`);
77
+ }
78
+ return ws;
79
+ }
80
+
81
+ function gateWrites(gates: TaskDeliveryGateReports | undefined, deliveryKey: string): GateWrite[] {
82
+ const writes: GateWrite[] = [];
83
+ if (gates?.typecheck) writes.push({ gate: "typecheck", ...gatePayload(gates.typecheck, `${deliveryKey}:typecheck`) });
84
+ if (gates?.test) writes.push({ gate: "test", ...gatePayload(gates.test, `${deliveryKey}:test`) });
85
+ for (const gate of gates?.other ?? []) {
86
+ writes.push({ gate: gate.name, ...gatePayload(gate, `${deliveryKey}:other:${gate.name}`) });
87
+ }
88
+ return writes;
89
+ }
90
+
91
+ function gatePayload(input: { status: TaskGateStatus; detail?: string; count?: number; runId?: string }, fallbackRunId: string): Omit<GateWrite, "gate"> {
92
+ return {
93
+ status: input.status,
94
+ ...(input.detail ? { detail: input.detail } : {}),
95
+ ...(typeof input.count === "number" ? { count: input.count } : {}),
96
+ runId: input.runId ?? fallbackRunId,
97
+ };
98
+ }
99
+
100
+ function normalizedDoneWhen(task: TaskDetail): string {
101
+ return (task.doneWhen ?? "").toLowerCase();
102
+ }
103
+
104
+ function completeDeliveryLands(task: TaskDetail): boolean {
105
+ const dispatchType = task.dispatch?.type;
106
+ if (dispatchType === "review" || dispatchType === "research" || dispatchType === "ops") return false;
107
+ const acceptance = normalizedDoneWhen(task);
108
+ if (/\b(no[-\s]?branch|review(?:ed)?|verdict|report[-\s]?only|none)\b/.test(acceptance)) return false;
109
+ if (/\b(merged?|landed(?:-on-main)?|live[-\s]?verified|deployed|agent[-\s]?exit|reaped)\b/.test(acceptance)) return true;
110
+ return true;
111
+ }
112
+
113
+ function cleanReason(value: unknown, disposition: "blocked" | "failed"): string {
114
+ if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${disposition} delivery requires reason`);
115
+ const cleaned = value.trim();
116
+ if (cleaned.length > 64_000) throw new ValidationError("reason must be 64000 characters or fewer");
117
+ return cleaned;
118
+ }
119
+
120
+ function cleanBlockedBy(value: unknown): TaskBlocker[] {
121
+ if (!Array.isArray(value) || value.length === 0) throw new ValidationError("blocked delivery requires blockedBy");
122
+ return value as TaskBlocker[];
123
+ }
124
+
125
+ function completeGateProblem(gates: TaskDeliveryGateReports | undefined): string | undefined {
126
+ if (!gates) return "missing-gate-block";
127
+ const writes = gateWrites(gates, "check");
128
+ const byName = new Map(writes.map((gate) => [gate.gate, gate.status]));
129
+ const missing = REQUIRED_COMPLETE_GATES.filter((gate) => !byName.has(gate));
130
+ if (missing.length > 0) return `missing-gates:${missing.join("+")}`;
131
+ const failed = writes.filter((gate) => gate.status !== "pass").map((gate) => gate.gate);
132
+ if (failed.length > 0) return `failed-gates:${failed.join("+")}`;
133
+ return undefined;
134
+ }
135
+
136
+ function deliveryEvent(taskId: number, idempotencyKey: string | undefined): { id: number; metadata: TaskDeliverableEventMetadata } | undefined {
137
+ const events = listTaskEvents(taskId).filter((event) => event.type === "deliverable").reverse();
138
+ const event = idempotencyKey
139
+ ? events.find((item) => item.metadata.idempotencyKey === idempotencyKey)
140
+ : events[0];
141
+ if (!event) return undefined;
142
+ return { id: event.id, metadata: event.metadata as unknown as TaskDeliverableEventMetadata };
143
+ }
144
+
145
+ function notifyDelivery(taskId: number, eventId: number, metadata: TaskDeliverableEventMetadata): void {
146
+ const task = getTaskDetail(taskId);
147
+ if (!task) return;
148
+ emitRelayEvent({
149
+ type: "task.deliverable",
150
+ source: metadata.actor,
151
+ subject: String(taskId),
152
+ data: { taskId, eventId, disposition: metadata.disposition, summary: metadata.summary, workspaceId: metadata.workspaceId },
153
+ });
154
+ const coordinator = resolveCoordinatorAgentId(task.ownerAgentId ?? metadata.actor);
155
+ const recipients = [...new Set([coordinator, task.createdBy].filter((item): item is string => Boolean(item && item.trim())))];
156
+ const delivered = routeNotification({
157
+ type: "task.deliverable",
158
+ scope: {
159
+ taskId: String(taskId),
160
+ transition: "deliverable",
161
+ ...(task.ownerAgentId ? { agentId: task.ownerAgentId, author: task.ownerAgentId } : {}),
162
+ ...(task.createdBy ? { parent: task.createdBy } : {}),
163
+ ...(task.teamId ? { teamId: task.teamId } : {}),
164
+ ...(metadata.workspaceId ? { workspaceId: metadata.workspaceId } : {}),
165
+ },
166
+ recipients,
167
+ ttlMs: null,
168
+ message: {
169
+ subject: `Task T${taskId} delivery: ${metadata.disposition}`,
170
+ body: `Task T${taskId} delivery (${metadata.disposition}): ${metadata.summary}`,
171
+ payload: {
172
+ kind: "task.deliverable",
173
+ notification: "task-deliverable",
174
+ taskId,
175
+ eventId,
176
+ disposition: metadata.disposition,
177
+ summary: metadata.summary,
178
+ gates: metadata.gates,
179
+ risks: metadata.risks,
180
+ workspaceId: metadata.workspaceId,
181
+ },
182
+ idempotencyKey: metadata.idempotencyKey ? `task:${taskId}:deliverable:${metadata.idempotencyKey}` : `task:${taskId}:deliverable:event:${eventId}`,
183
+ replyExpected: false,
184
+ },
185
+ });
186
+ if (delivered.length > 0) emitNewMessages(delivered);
187
+ }
188
+
189
+ function emitCommand(command: Command): void {
190
+ emitRelayEvent({
191
+ type: command.status === "pending" ? "command.requested" : `command.${command.status}`,
192
+ source: command.source,
193
+ subject: command.id,
194
+ data: { command },
195
+ });
196
+ }
197
+
198
+ export async function deliverTask(input: DeliverTaskInput, ctx: AuthContext): Promise<Record<string, unknown>> {
199
+ const actor = callerLabel(ctx);
200
+ const taskId = resolveTaskId(ctx, input.taskId);
201
+ const before = getTaskDetail(taskId);
202
+ if (!before) throw new ValidationError("task not found");
203
+ if (before.kind !== "deliverable") throw new ValidationError("task not found");
204
+ const wantsLand = input.disposition === "complete" && completeDeliveryLands(before);
205
+ const isVerdictOnlyComplete = input.disposition === "complete" && !wantsLand;
206
+ const gateProblem = input.disposition === "complete" ? completeGateProblem(input.gates) : undefined;
207
+ if (gateProblem) throw new ValidationError(`complete delivery cannot land without passing gate block (${gateProblem})`);
208
+ const reason = input.disposition === "blocked" || input.disposition === "failed" ? cleanReason(input.reason, input.disposition) : undefined;
209
+ const blockedBy = input.disposition === "blocked" ? cleanBlockedBy(input.blockedBy) : input.blockedBy;
210
+ const workspace = wantsLand || input.workspaceId ? resolveWorkspace(ctx, input, taskId) : undefined;
211
+ if (wantsLand && (!workspace || workspace.mode !== "isolated" || !workspace.worktreePath)) {
212
+ throw new ValidationError("complete delivery requires a live isolated workspace");
213
+ }
214
+ const landMeta: TaskDeliverableEventMetadata["land"] | undefined = wantsLand && workspace
215
+ ? { requested: true, triggered: false, reason: "pending", workspaceId: workspace.id, workspaceStatus: workspace.status }
216
+ : undefined;
217
+ const delivery = recordTaskDeliverable(taskId, {
218
+ ...input,
219
+ actor,
220
+ ...(reason ? { reason } : {}),
221
+ ...(blockedBy ? { blockedBy } : {}),
222
+ ...(workspace ? { workspaceId: workspace.id } : {}),
223
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
224
+ ...(landMeta ? { land: landMeta } : {}),
225
+ });
226
+ const eventMeta = deliveryEvent(taskId, input.idempotencyKey)?.metadata;
227
+ if (delivery.created) {
228
+ const deliveryKey = input.idempotencyKey ?? `event:${delivery.event.id}`;
229
+ for (const gate of gateWrites(input.gates, deliveryKey)) {
230
+ recordGate(taskId, { ...gate, actor });
231
+ }
232
+ if (input.disposition === "blocked") {
233
+ setTaskStatus(taskId, { status: "blocked", blockedBy: blockedBy as TaskBlocker[], note: reason, actor });
234
+ } else if (input.disposition === "failed") {
235
+ setTaskStatus(taskId, { status: "failed", note: reason, actor });
236
+ } else if (isVerdictOnlyComplete) {
237
+ setTaskStatus(taskId, { status: "done", note: "complete delivery recorded without land", actor });
238
+ }
239
+ const detail = getTaskDetail(taskId);
240
+ if (detail) {
241
+ emitTaskChanged(detail, "task.deliverable");
242
+ notifyDelivery(taskId, delivery.event.id, eventMeta ?? (delivery.event.metadata as unknown as TaskDeliverableEventMetadata));
243
+ if (input.disposition === "failed") {
244
+ emitTaskSemanticNotification({ task: detail, transition: "gate-failed", actor, occurrenceKey: `event:${delivery.event.id}`, outcome: { disposition: "failed" } });
245
+ }
246
+ }
247
+ }
248
+
249
+ let land: Record<string, unknown> | undefined;
250
+ if (wantsLand && delivery.created && workspace) {
251
+ markAgentWorkFinal(actor, "task-deliver");
252
+ const result = await applyWorkspaceAction(workspace, {
253
+ action: "ready",
254
+ agentId: actor,
255
+ detail: `Task T${taskId} delivered complete`,
256
+ auditMetadata: { via: "task_deliver", actor, taskId, deliveryEventId: delivery.event.id },
257
+ });
258
+ if (!result.ok) {
259
+ land = { requested: true, triggered: false, error: result.error, workspaceId: workspace.id };
260
+ } else {
261
+ if (result.command) emitCommand(result.command);
262
+ land = { requested: true, triggered: true, workspace: result.workspace, command: result.command };
263
+ }
264
+ } else if (wantsLand && !delivery.created) {
265
+ land = { requested: true, triggered: false, reason: "duplicate-delivery" };
266
+ }
267
+
268
+ return {
269
+ taskId,
270
+ event: delivery.event,
271
+ created: delivery.created,
272
+ delivery: eventMeta ?? delivery.event.metadata,
273
+ ...(land ? { land } : {}),
274
+ };
275
+ }
@@ -84,7 +84,7 @@ export const LAND_PENDING_STALL_MS = 15 * 60 * 1000;
84
84
  export const DIRTY_WORKTREE_LAND_SKIP_REASON = "worktree has uncommitted changes";
85
85
 
86
86
  type WorkspacePhase =
87
- | "working" // active — your turn: commit, then mark ready
87
+ | "working" // active — your turn: commit, then deliver
88
88
  | "land-pending" // ready | review_requested — handed off; auto-merge will land it
89
89
  | "landing" // merge_planned — merge dispatched, in progress
90
90
  | "steward" // conflict — auto-merge couldn't; a steward is reconciling (not your job)
@@ -122,13 +122,13 @@ const WAIT_ACTION: WorkspaceNextAction = {
122
122
  const CHECK_STATUS_ACTION: WorkspaceNextAction = {
123
123
  tool: "relay_workspace_status",
124
124
  cli: "agent-relay workspace status",
125
- when: "only if you are explicitly checking state later; do not poll or wait after marking ready",
125
+ when: "only if you are explicitly checking state later; do not poll or wait after delivering",
126
126
  };
127
127
 
128
128
  const READY_ACTION: WorkspaceNextAction = {
129
- tool: "relay_workspace_ready",
130
- cli: "agent-relay workspace ready",
131
- when: "after you commit your work in this worktree",
129
+ tool: "relay_task_deliver",
130
+ cli: "MCP relay_task_deliver",
131
+ when: "after you commit your work and have passing delivery gates",
132
132
  };
133
133
 
134
134
  // Map every WorkspaceStatus to the branch agent's mental model. Statuses that