agent-relay-server 0.126.0 → 0.127.1

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 (82) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-B6YgulnX.js → MessageBubble-DnXnkt4a.js} +2 -2
  4. package/public/assets/{MessageBubble-B6YgulnX.js.map → MessageBubble-DnXnkt4a.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel--DNxHn1T.js → PlanGraphPanel-D6CPEpQD.js} +2 -2
  6. package/public/assets/{PlanGraphPanel--DNxHn1T.js.map → PlanGraphPanel-D6CPEpQD.js.map} +1 -1
  7. package/public/assets/{activity-Fo5aLYD6.js → activity-ZPwHcPLK.js} +2 -2
  8. package/public/assets/{activity-Fo5aLYD6.js.map → activity-ZPwHcPLK.js.map} +1 -1
  9. package/public/assets/{agent-profiles-C3p0xclG.js → agent-profiles-CT0E9LV1.js} +2 -2
  10. package/public/assets/{agent-profiles-C3p0xclG.js.map → agent-profiles-CT0E9LV1.js.map} +1 -1
  11. package/public/assets/{agents-_GBQyF5b.js → agents-Dt5TncjC.js} +2 -2
  12. package/public/assets/{agents-_GBQyF5b.js.map → agents-Dt5TncjC.js.map} +1 -1
  13. package/public/assets/{automation-Cf_Ewi9f.js → automation-yzUJbvT2.js} +2 -2
  14. package/public/assets/{automation-Cf_Ewi9f.js.map → automation-yzUJbvT2.js.map} +1 -1
  15. package/public/assets/chat-DhYX9BDG.js +2 -0
  16. package/public/assets/chat-DhYX9BDG.js.map +1 -0
  17. package/public/assets/{formatted-body-jjhFXTap.js → formatted-body-D5-1aiyB.js} +3 -3
  18. package/public/assets/{formatted-body-jjhFXTap.js.map → formatted-body-D5-1aiyB.js.map} +1 -1
  19. package/public/assets/{formatted-body-impl-gOLBKeyW.js → formatted-body-impl-Do5NbJUA.js} +2 -2
  20. package/public/assets/{formatted-body-impl-gOLBKeyW.js.map → formatted-body-impl-Do5NbJUA.js.map} +1 -1
  21. package/public/assets/{index-CgH1BZwq.js → index-BdNafiPz.js} +6 -6
  22. package/public/assets/{index-CgH1BZwq.js.map → index-BdNafiPz.js.map} +1 -1
  23. package/public/assets/{insights-CTW85An9.js → insights-i6AEZT_a.js} +2 -2
  24. package/public/assets/{insights-CTW85An9.js.map → insights-i6AEZT_a.js.map} +1 -1
  25. package/public/assets/{maintenance-D5PEygZz.js → maintenance-CJ6wikpg.js} +2 -2
  26. package/public/assets/{maintenance-D5PEygZz.js.map → maintenance-CJ6wikpg.js.map} +1 -1
  27. package/public/assets/{managed-agents-pkpAzZy8.js → managed-agents-JZn0xNJR.js} +3 -3
  28. package/public/assets/managed-agents-JZn0xNJR.js.map +1 -0
  29. package/public/assets/{markdown-preview-impl-DMzFE-Lo.js → markdown-preview-impl-BsvAkpR4.js} +2 -2
  30. package/public/assets/{markdown-preview-impl-DMzFE-Lo.js.map → markdown-preview-impl-BsvAkpR4.js.map} +1 -1
  31. package/public/assets/{memory-BnquVYQz.js → memory-BE9IwCdC.js} +2 -2
  32. package/public/assets/{memory-BnquVYQz.js.map → memory-BE9IwCdC.js.map} +1 -1
  33. package/public/assets/{messages-DgxLlndC.js → messages-vXgdc3H4.js} +2 -2
  34. package/public/assets/{messages-DgxLlndC.js.map → messages-vXgdc3H4.js.map} +1 -1
  35. package/public/assets/{orchestrators-BaWxeAlK.js → orchestrators-CribhLg-.js} +2 -2
  36. package/public/assets/{orchestrators-BaWxeAlK.js.map → orchestrators-CribhLg-.js.map} +1 -1
  37. package/public/assets/{overview-Kvs-j2gg.js → overview-q3O9K9VK.js} +2 -2
  38. package/public/assets/{overview-Kvs-j2gg.js.map → overview-q3O9K9VK.js.map} +1 -1
  39. package/public/assets/{pairs-eDzbqcER.js → pairs-BNOnd0IE.js} +2 -2
  40. package/public/assets/{pairs-eDzbqcER.js.map → pairs-BNOnd0IE.js.map} +1 -1
  41. package/public/assets/{prompt-settings-CjcqBG_r.js → prompt-settings-Bdz_xCgD.js} +2 -2
  42. package/public/assets/{prompt-settings-CjcqBG_r.js.map → prompt-settings-Bdz_xCgD.js.map} +1 -1
  43. package/public/assets/{registry-C1DSnjjg.js → registry-DPcyGypi.js} +2 -2
  44. package/public/assets/{registry-C1DSnjjg.js.map → registry-DPcyGypi.js.map} +1 -1
  45. package/public/assets/{security-VbEe_jwm.js → security-Z3l3u-Pl.js} +2 -2
  46. package/public/assets/{security-VbEe_jwm.js.map → security-Z3l3u-Pl.js.map} +1 -1
  47. package/public/assets/{select-CVfwgfKl.js → select-BX_lW49D.js} +2 -2
  48. package/public/assets/{select-CVfwgfKl.js.map → select-BX_lW49D.js.map} +1 -1
  49. package/public/assets/{settings-ujA9hZW1.js → settings-Y381jZt7.js} +2 -2
  50. package/public/assets/{settings-ujA9hZW1.js.map → settings-Y381jZt7.js.map} +1 -1
  51. package/public/assets/{tasks-BtbKaa-b.js → tasks-Di6d0_Ch.js} +2 -2
  52. package/public/assets/{tasks-BtbKaa-b.js.map → tasks-Di6d0_Ch.js.map} +1 -1
  53. package/public/assets/{teams-CrVDQ99v.js → teams-DJv4l-_l.js} +2 -2
  54. package/public/assets/{teams-CrVDQ99v.js.map → teams-DJv4l-_l.js.map} +1 -1
  55. package/public/assets/{terminal-viewer-impl-Aays_e7-.js → terminal-viewer-impl-3zBLpRel.js} +2 -2
  56. package/public/assets/{terminal-viewer-impl-Aays_e7-.js.map → terminal-viewer-impl-3zBLpRel.js.map} +1 -1
  57. package/public/assets/types-BqeSkWrv.js.map +1 -1
  58. package/public/assets/{work-queue-DDSGZO4Z.js → work-queue-D73-hP0c.js} +2 -2
  59. package/public/assets/{work-queue-DDSGZO4Z.js.map → work-queue-D73-hP0c.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/provisioning.ts +121 -0
  64. package/runner/src/relay-mcp-proxy.ts +1 -3
  65. package/src/db/index.ts +1 -0
  66. package/src/db/task-deliverables.ts +206 -0
  67. package/src/db/task-entity.ts +4 -8
  68. package/src/mcp/index.ts +4 -4
  69. package/src/mcp/tools-workspace.ts +3 -36
  70. package/src/mcp-task-deliver-tools.ts +143 -0
  71. package/src/notification-rules.ts +1 -0
  72. package/src/notification-types.ts +2 -0
  73. package/src/prompt-resolver.ts +6 -6
  74. package/src/routes/agent-sessions.ts +4 -1
  75. package/src/routes/agents.ts +3 -0
  76. package/src/services/task-delivery.ts +275 -0
  77. package/src/services/task-entity.ts +32 -1
  78. package/src/workspace-actions.ts +4 -2
  79. package/src/workspace-phase.ts +5 -5
  80. package/public/assets/chat-BdbIWZpQ.js +0 -2
  81. package/public/assets/chat-BdbIWZpQ.js.map +0 -1
  82. package/public/assets/managed-agents-pkpAzZy8.js.map +0 -1
@@ -15,33 +15,19 @@ import { optionalBoolean, optionalPositiveInt, optionalString } from "./validati
15
15
  export const WORKSPACE_TOOLS: ToolDefinition[] = [
16
16
  {
17
17
  name: "relay_workspace_status",
18
- description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes; do not use wait:true after relay_workspace_ready unless explicitly debugging. Normal branch workers mark ready, stop their turn, and are woken later by landed-success or landed-failure notifications. Defaults to your own workspace; pass workspaceId to target another.",
18
+ description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes; do not use wait:true after relay_task_deliver unless explicitly debugging. Normal branch workers deliver the Task, stop their turn, and are woken later by landed-success or landed-failure notifications. Defaults to your own workspace; pass workspaceId to target another.",
19
19
  requiredScopes: ["agents:read", "agent:read", "agent:write"],
20
20
  alwaysLoad: true,
21
21
  inputSchema: {
22
22
  type: "object",
23
23
  properties: {
24
24
  workspaceId: { type: "string", description: "Defaults to your own isolated workspace (resolved from your identity)." },
25
- wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout. Do not use this after relay_workspace_ready in the normal worker flow; stop and wait for landed-success/landed-failure instead." },
25
+ wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout. Do not use this after relay_task_deliver in the normal worker flow; stop and wait for landed-success/landed-failure instead." },
26
26
  timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Max seconds to wait when wait:true (default 300, max 600). Returns early on any transition." },
27
27
  },
28
28
  additionalProperties: false,
29
29
  },
30
30
  },
31
- {
32
- name: "relay_workspace_ready",
33
- description: "Signal that your branch work is finished and ready to merge back to main. This kicks off the auto-merge-back: a clean merge lands immediately; on conflict a steward agent reconciles and lands it (escalating if it can't). After this tool returns, stop your turn and go idle; Relay wakes you later with landed-success when the merge lands and your branch refreshes, or landed-failure if landing fatally fails. Defaults to your own workspace.",
34
- requiredScopes: ["agent:write"],
35
- alwaysLoad: true,
36
- inputSchema: {
37
- type: "object",
38
- properties: {
39
- workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
40
- detail: { type: "string", description: "Optional note recorded on the activity feed." },
41
- },
42
- additionalProperties: false,
43
- },
44
- },
45
31
  {
46
32
  name: "relay_workspace_deps",
47
33
  description: "Re-provision your worktree's dependencies when the shared symlinked node_modules has gone stale (e.g. typecheck reports a missing module). checkOnly:true just reports staleness without changing anything. Self-service — acts only on your own worktree.",
@@ -98,25 +84,6 @@ export const WORKSPACE_TOOLS: ToolDefinition[] = [
98
84
  additionalProperties: false,
99
85
  },
100
86
  },
101
- {
102
- name: "relay_workspace_land",
103
- description: "Dispatch a base merge for your workspace directly (lease-serialized per repo, same path the auto-merge job uses). Most branch agents should use relay_workspace_ready instead and let the relay land it; this is the explicit/steward path and requires the command:write scope.",
104
- requiredScopes: ["command:write"],
105
- inputSchema: {
106
- type: "object",
107
- properties: {
108
- workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
109
- strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto). Falls back to the instance landing.strategy config when unset." },
110
- deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
111
- prTitle: { type: "string" },
112
- prBody: { type: "string" },
113
- autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "Auto-merge policy for PR-strategy lands. Defaults to 'on-green' when strategy resolves to 'pr'. Ignored for rebase-ff/auto strategies." },
114
- reviewer: { type: "string", description: "GitHub login or team slug to request as a reviewer on the opened PR (pr strategy only)." },
115
- detail: { type: "string" },
116
- },
117
- additionalProperties: false,
118
- },
119
- },
120
87
  ];
121
88
 
122
89
  function isWorkspaceAdmin(auth: McpAuthContext): boolean {
@@ -219,7 +186,7 @@ export async function relayWorkspaceMutation(auth: McpAuthContext, action: Works
219
186
  if (action === "ready") {
220
187
  payload.contract = readyContract(result.workspace);
221
188
  payload.guidance = describeWorkspacePhase(result.workspace);
222
- // #1084 relay_workspace_ready is an explicit, platform-owned completion signal: the caller
189
+ // Legacy workspace-ready route/CLI path is an explicit, platform-owned completion signal: the caller
223
190
  // is declaring its work window closed. Close it, forwarding only the held final group.
224
191
  markAgentWorkFinal(result.workspace.ownerAgentId ?? callerAgentId(auth), "workspace-ready");
225
192
  }
@@ -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
  {
@@ -614,7 +614,10 @@ export const postAgentTerminalSession: Handler = async (req, params) => {
614
614
  resource: { agentId: agent.id, orchestratorId: orchestrator.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId, terminalAttach: true },
615
615
  });
616
616
  if (denied) return denied;
617
- if (agent.providerCapabilities?.terminal?.live?.read && metaTmuxSession) {
617
+ // Provider-owned attach surfaces are more specific than the runner's tmux pane.
618
+ // Codex runs its real interactive TUI as a guest attached to the app-server thread;
619
+ // the runner pane can be a stale launcher frame and is not the right input target.
620
+ if (agent.providerCapabilities?.terminal?.attach?.create !== true && agent.providerCapabilities?.terminal?.live?.read && metaTmuxSession) {
618
621
  return json({
619
622
  mode: "live",
620
623
  orchestratorId: orchestrator.id,
@@ -216,6 +216,9 @@ export const postHeartbeat: Handler = async (req, params) => {
216
216
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
217
217
  // #416 — capture unavailability before heartbeat to detect the transition.
218
218
  const beforeHeartbeat = getAgent(params.id!);
219
+ if (beforeHeartbeat?.kind === "channel" && !beforeHeartbeat.ready) {
220
+ return error("channel agent not ready; re-register required", 409);
221
+ }
219
222
  const wasUnavailable = beforeHeartbeat ? isAgentUnavailable(beforeHeartbeat) : false;
220
223
  if (!heartbeat(params.id!, guard)) return error("agent not found", 404);
221
224
  if (wasUnavailable) {
@@ -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
+ }
@@ -38,7 +38,7 @@ import { errMessage } from "agent-relay-sdk";
38
38
  import { findSpawnCommandByRequestId } from "../commands-db";
39
39
  import { defaultIssueRepo } from "../config";
40
40
  import { emitTaskChanged } from "../sse";
41
- import type { CommandStatus, Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem, TaskStatus, WorkspaceRecord } from "../types";
41
+ import type { CommandStatus, Task, TaskDeliverableEventMetadata, TaskDetail, TaskEvent, TaskGateState, TaskListItem, TaskStatus, WorkspaceRecord } from "../types";
42
42
  import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
43
43
  import { emitRelayEvent } from "../events";
44
44
  import { routeNotification } from "../notification-router";
@@ -389,6 +389,30 @@ export function reconcileAutoScaffoldedTaskReaps(opts: ReconcileAutoScaffoldedTa
389
389
  return { dryRun, scanned: completed.length + canceled.length + skipped.length, completed, canceled, skipped };
390
390
  }
391
391
 
392
+ function latestDeliverableEvent(taskId: number): TaskEvent | undefined {
393
+ return [...listTaskEvents(taskId)].reverse().find((event) => event.type === "deliverable");
394
+ }
395
+
396
+ function restoreDeliveredDisposition(task: TaskDetail, event: TaskEvent): void {
397
+ const metadata = event.metadata as unknown as Partial<TaskDeliverableEventMetadata>;
398
+ if (metadata.disposition === "failed" && task.status !== "failed") {
399
+ setTaskEntityStatus(task.id, {
400
+ status: "failed",
401
+ ...(typeof metadata.reason === "string" && metadata.reason.trim() ? { note: metadata.reason } : {}),
402
+ actor: "task-lifecycle",
403
+ });
404
+ return;
405
+ }
406
+ if (metadata.disposition === "blocked" && task.status !== "blocked" && Array.isArray(metadata.blockedBy) && metadata.blockedBy.length > 0) {
407
+ setTaskEntityStatus(task.id, {
408
+ status: "blocked",
409
+ blockedBy: metadata.blockedBy,
410
+ ...(typeof metadata.reason === "string" && metadata.reason.trim() ? { note: metadata.reason } : {}),
411
+ actor: "task-lifecycle",
412
+ });
413
+ }
414
+ }
415
+
392
416
  // #889 — finalize bound tasks for a reaped agent whose doneWhen is "agent-exit" / "reaped".
393
417
  // Called by the transient reaper after requesting shutdown so ephemeral run-and-exit workers
394
418
  // don't leave their auto-scaffolded Task stuck in_progress forever.
@@ -407,6 +431,13 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
407
431
  if (!isAgentExitDoneWhen(task.doneWhen)) continue;
408
432
  if (opts.producedWork === false) {
409
433
  try {
434
+ const deliverable = latestDeliverableEvent(taskId);
435
+ if (deliverable) {
436
+ if (!task.reapedAt) markTaskAgentReaped(taskId, { agentId, reason: opts.reason ?? "agent-exit" });
437
+ restoreDeliveredDisposition(task, deliverable);
438
+ finalized.push(taskId);
439
+ continue;
440
+ }
410
441
  if (!task.reapedAt) markTaskAgentReaped(taskId, { agentId, reason: opts.reason ?? "agent-exit" });
411
442
  setTaskEntityStatus(taskId, { status: "canceled", note: "worker reaped without landing or producing work; task canceled", actor: "task-lifecycle" });
412
443
  notifyEmptyTransientReap(agentId, task, opts);
@@ -28,7 +28,7 @@ export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
28
28
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
29
29
  import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES, worktreeReapable } from "./workspace-phase";
30
30
  import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
31
- import { fetchHostMergePreview } from "./workspace-probe";
31
+ import { fetchHostMergePreview, refreshWorkspaceGitState } from "./workspace-probe";
32
32
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
33
33
 
34
34
  // Single source of truth for the action verb set. The route's `optionalEnum` and
@@ -251,7 +251,9 @@ export async function applyWorkspaceAction(workspace: WorkspaceRecord, input: Ap
251
251
  if (!nextStatus) return { ok: false, httpStatus: 400, error: `unsupported action: ${action}` };
252
252
 
253
253
  if (READY_TO_LAND_STATUSES.has(nextStatus)) {
254
- const dirtyCount = metadataNumber(workspace.metadata, "gitDirtyCount") ?? 0;
254
+ await refreshWorkspaceGitState(workspace);
255
+ const freshWorkspace = getWorkspace(workspace.id) ?? workspace;
256
+ const dirtyCount = metadataNumber(freshWorkspace.metadata, "gitDirtyCount") ?? 0;
255
257
  if (dirtyCount > 0) {
256
258
  return {
257
259
  ok: false,