agent-relay-server 0.107.0 → 0.109.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 (217) hide show
  1. package/docs/openapi.json +589 -1
  2. package/package.json +4 -4
  3. package/public/assets/MessageBubble-XgN61quY.js +4 -0
  4. package/public/assets/{MessageBubble-PEdvVh_m.js.map → MessageBubble-XgN61quY.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-LY-msOsr.js +2 -0
  6. package/public/assets/PlanGraphPanel-LY-msOsr.js.map +1 -0
  7. package/public/assets/activity-D0EotVU9.js +2 -0
  8. package/public/assets/activity-D0EotVU9.js.map +1 -0
  9. package/public/assets/{agent-profiles-CuTQtmV6.js → agent-profiles-CAGvryb9.js} +2 -2
  10. package/public/assets/{agent-profiles-CuTQtmV6.js.map → agent-profiles-CAGvryb9.js.map} +1 -1
  11. package/public/assets/agents-CXXZ_sU9.js +2 -0
  12. package/public/assets/{agents-CgRFJyks.js.map → agents-CXXZ_sU9.js.map} +1 -1
  13. package/public/assets/{analytics-C3OTFIXo.js → analytics-Q6Dl_CE9.js} +3 -3
  14. package/public/assets/{analytics-C3OTFIXo.js.map → analytics-Q6Dl_CE9.js.map} +1 -1
  15. package/public/assets/{automation-CH9YGYoE.js → automation-zig0cp23.js} +2 -2
  16. package/public/assets/{automation-CH9YGYoE.js.map → automation-zig0cp23.js.map} +1 -1
  17. package/public/assets/{badge-D8fABzMs.js → badge-kdQDWO6e.js} +2 -2
  18. package/public/assets/{badge-D8fABzMs.js.map → badge-kdQDWO6e.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-BRjUWyMT.js} +2 -2
  20. package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-BRjUWyMT.js.map} +1 -1
  21. package/public/assets/{button-KASZcscA.js → button-BKFvNXa3.js} +2 -2
  22. package/public/assets/{button-KASZcscA.js.map → button-BKFvNXa3.js.map} +1 -1
  23. package/public/assets/card-B0y1i_2D.js +2 -0
  24. package/public/assets/{card-Cds0MWME.js.map → card-B0y1i_2D.js.map} +1 -1
  25. package/public/assets/{channels-CcSnLzCG.js → channels-DOYB1iiA.js} +2 -2
  26. package/public/assets/{channels-CcSnLzCG.js.map → channels-DOYB1iiA.js.map} +1 -1
  27. package/public/assets/chat-C3QlsFBd.js +2 -0
  28. package/public/assets/{chat-BLcm2Aht.js.map → chat-C3QlsFBd.js.map} +1 -1
  29. package/public/assets/{connectors-DbPOlQhL.js → connectors-C5CeK-2M.js} +2 -2
  30. package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C5CeK-2M.js.map} +1 -1
  31. package/public/assets/{copy-button-d9DFNX9v.js → copy-button-aLaiAkv2.js} +2 -2
  32. package/public/assets/{copy-button-d9DFNX9v.js.map → copy-button-aLaiAkv2.js.map} +1 -1
  33. package/public/assets/display-_5kFrHJh.js +3 -0
  34. package/public/assets/display-_5kFrHJh.js.map +1 -0
  35. package/public/assets/{dist-DOAjFOS4.js → dist-BDYU37NZ.js} +2 -2
  36. package/public/assets/{dist-DOAjFOS4.js.map → dist-BDYU37NZ.js.map} +1 -1
  37. package/public/assets/dist-BnfqT2cl.js +2 -0
  38. package/public/assets/{dist-DC-TqTz2.js.map → dist-BnfqT2cl.js.map} +1 -1
  39. package/public/assets/dist-CF28Y5wI.js +2 -0
  40. package/public/assets/{dist-CBVZk9U8.js.map → dist-CF28Y5wI.js.map} +1 -1
  41. package/public/assets/{dist-DTXKbk2K.js → dist-DDh8a_SK.js} +2 -2
  42. package/public/assets/{dist-DTXKbk2K.js.map → dist-DDh8a_SK.js.map} +1 -1
  43. package/public/assets/{dist-BbUJ1Vht.js → dist-DKoRwEZq.js} +2 -2
  44. package/public/assets/{dist-BbUJ1Vht.js.map → dist-DKoRwEZq.js.map} +1 -1
  45. package/public/assets/{dist-UhEAFdvZ.js → dist-DQPz52Zx.js} +2 -2
  46. package/public/assets/{dist-UhEAFdvZ.js.map → dist-DQPz52Zx.js.map} +1 -1
  47. package/public/assets/{dist-CgqfdHSt.js → dist-D_qcpfbI.js} +2 -2
  48. package/public/assets/{dist-CgqfdHSt.js.map → dist-D_qcpfbI.js.map} +1 -1
  49. package/public/assets/{dist-DybScJmm.js → dist-DnyQI0qt.js} +2 -2
  50. package/public/assets/{dist-DybScJmm.js.map → dist-DnyQI0qt.js.map} +1 -1
  51. package/public/assets/{dist-BwwlmyBq.js → dist-EO8jbi2F.js} +2 -2
  52. package/public/assets/{dist-BwwlmyBq.js.map → dist-EO8jbi2F.js.map} +1 -1
  53. package/public/assets/{dist-BlZvUZZB.js → dist-avJPT3qF.js} +2 -2
  54. package/public/assets/{dist-BlZvUZZB.js.map → dist-avJPT3qF.js.map} +1 -1
  55. package/public/assets/dist-cyag2tir.js +2 -0
  56. package/public/assets/{dist-DreO5AcZ.js.map → dist-cyag2tir.js.map} +1 -1
  57. package/public/assets/{es2015-DUjFyjDp.js → es2015-C9FSEGd7.js} +2 -2
  58. package/public/assets/{es2015-DUjFyjDp.js.map → es2015-C9FSEGd7.js.map} +1 -1
  59. package/public/assets/{formatted-body-impl-BJTthVHg.js → formatted-body-impl-DCQSVyWB.js} +2 -2
  60. package/public/assets/{formatted-body-impl-BJTthVHg.js.map → formatted-body-impl-DCQSVyWB.js.map} +1 -1
  61. package/public/assets/index-D6hQkte1.css +2 -0
  62. package/public/assets/index-RCbIyk26.js +23 -0
  63. package/public/assets/index-RCbIyk26.js.map +1 -0
  64. package/public/assets/{input-CtHfLn1i.js → input-DfN6sUbX.js} +2 -2
  65. package/public/assets/{input-CtHfLn1i.js.map → input-DfN6sUbX.js.map} +1 -1
  66. package/public/assets/{insights-DklrIhb6.js → insights-CsLs6NbL.js} +2 -2
  67. package/public/assets/{insights-DklrIhb6.js.map → insights-CsLs6NbL.js.map} +1 -1
  68. package/public/assets/{integrations-hkSW8CzM.js → integrations-CztQfrI7.js} +2 -2
  69. package/public/assets/{integrations-hkSW8CzM.js.map → integrations-CztQfrI7.js.map} +1 -1
  70. package/public/assets/{lib-l-JkMVR9.js → lib-DRRHEqRZ.js} +2 -2
  71. package/public/assets/{lib-l-JkMVR9.js.map → lib-DRRHEqRZ.js.map} +1 -1
  72. package/public/assets/{lucide-react-C4eXYCwx.js → lucide-react-DmbfpRvJ.js} +2 -2
  73. package/public/assets/{lucide-react-C4eXYCwx.js.map → lucide-react-DmbfpRvJ.js.map} +1 -1
  74. package/public/assets/{maintenance-FAAMGpuZ.js → maintenance-BN5F0Lnc.js} +2 -2
  75. package/public/assets/{maintenance-FAAMGpuZ.js.map → maintenance-BN5F0Lnc.js.map} +1 -1
  76. package/public/assets/managed-agents-R_LI9qS5.js +4 -0
  77. package/public/assets/{managed-agents-CVca-s3t.js.map → managed-agents-R_LI9qS5.js.map} +1 -1
  78. package/public/assets/markdown-preview-impl-DQlmK_ev.js +2 -0
  79. package/public/assets/{markdown-preview-impl-BPW4OQo9.js.map → markdown-preview-impl-DQlmK_ev.js.map} +1 -1
  80. package/public/assets/memory-kn4bgMXC.js +2 -0
  81. package/public/assets/{memory-BzuDTHC5.js.map → memory-kn4bgMXC.js.map} +1 -1
  82. package/public/assets/{messages-_W24PNvT.js → messages-Dv3Tv7zC.js} +2 -2
  83. package/public/assets/{messages-_W24PNvT.js.map → messages-Dv3Tv7zC.js.map} +1 -1
  84. package/public/assets/{orchestrators-Du6I_n8i.js → orchestrators-DcNLRMzk.js} +2 -2
  85. package/public/assets/{orchestrators-Du6I_n8i.js.map → orchestrators-DcNLRMzk.js.map} +1 -1
  86. package/public/assets/overview-BOLzPSSC.js +2 -0
  87. package/public/assets/{overview-Dyvm1qpT.js.map → overview-BOLzPSSC.js.map} +1 -1
  88. package/public/assets/{pairs-B8x1RCNd.js → pairs-B4r0egpx.js} +2 -2
  89. package/public/assets/{pairs-B8x1RCNd.js.map → pairs-B4r0egpx.js.map} +1 -1
  90. package/public/assets/projects-cOeuiMjl.js +2 -0
  91. package/public/assets/{projects-vt63bwHN.js.map → projects-cOeuiMjl.js.map} +1 -1
  92. package/public/assets/prompt-settings-E7-6Osj4.js +2 -0
  93. package/public/assets/{prompt-settings-xVXmvxB5.js.map → prompt-settings-E7-6Osj4.js.map} +1 -1
  94. package/public/assets/{react-dom-BMF4hNzG.js → react-dom-jQ4Vs380.js} +2 -2
  95. package/public/assets/{react-dom-BMF4hNzG.js.map → react-dom-jQ4Vs380.js.map} +1 -1
  96. package/public/assets/registry-CTN3jOnb.js +2 -0
  97. package/public/assets/{registry-ypiICauZ.js.map → registry-CTN3jOnb.js.map} +1 -1
  98. package/public/assets/{security-BfOQ6T5r.js → security-BctIcBZb.js} +2 -2
  99. package/public/assets/{security-BfOQ6T5r.js.map → security-BctIcBZb.js.map} +1 -1
  100. package/public/assets/{select-Dx8Ro-A3.js → select-BndvVb0A.js} +2 -2
  101. package/public/assets/{select-Dx8Ro-A3.js.map → select-BndvVb0A.js.map} +1 -1
  102. package/public/assets/settings-B_tRq-P4.js +6 -0
  103. package/public/assets/{settings-87clTx4N.js.map → settings-B_tRq-P4.js.map} +1 -1
  104. package/public/assets/store-DDWg67Pg.js +9 -0
  105. package/public/assets/store-DDWg67Pg.js.map +1 -0
  106. package/public/assets/tasks-BgTTxVET.js +2 -0
  107. package/public/assets/tasks-BgTTxVET.js.map +1 -0
  108. package/public/assets/teams-DJiLBEgF.js +2 -0
  109. package/public/assets/{teams-DJWp4AKi.js.map → teams-DJiLBEgF.js.map} +1 -1
  110. package/public/assets/{terminal-viewer-impl-DgMuRAe_.js → terminal-viewer-impl-BLdiyCNR.js} +3 -3
  111. package/public/assets/{terminal-viewer-impl-DgMuRAe_.js.map → terminal-viewer-impl-BLdiyCNR.js.map} +1 -1
  112. package/public/assets/types-DSwhOFcJ.js +2 -0
  113. package/public/assets/types-DSwhOFcJ.js.map +1 -0
  114. package/public/assets/{use-keyboard-viewport-RYMALvu1.js → use-keyboard-viewport-Dw2CHZ50.js} +2 -2
  115. package/public/assets/{use-keyboard-viewport-RYMALvu1.js.map → use-keyboard-viewport-Dw2CHZ50.js.map} +1 -1
  116. package/public/assets/user-avatar-rt59cEsc.js +2 -0
  117. package/public/assets/{user-avatar-CfAUS3PZ.js.map → user-avatar-rt59cEsc.js.map} +1 -1
  118. package/public/assets/{work-queue-D-Otxd9u.js → work-queue-BYRdBuLR.js} +2 -2
  119. package/public/assets/{work-queue-D-Otxd9u.js.map → work-queue-BYRdBuLR.js.map} +1 -1
  120. package/public/assets/workspaces-DCRT3l9H.js +3 -0
  121. package/public/assets/workspaces-DCRT3l9H.js.map +1 -0
  122. package/public/index.html +23 -23
  123. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  124. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
  125. package/src/branch-landed.ts +17 -3
  126. package/src/config-store.ts +12 -1
  127. package/src/db/agents.ts +3 -2
  128. package/src/db/continuations.ts +13 -3
  129. package/src/db/index.ts +3 -0
  130. package/src/db/issue-lifecycle.ts +12 -0
  131. package/src/db/mappers.ts +17 -0
  132. package/src/db/messages.ts +9 -4
  133. package/src/db/migrations.ts +112 -2
  134. package/src/db/notification-subscriptions.ts +1 -1
  135. package/src/db/prompt-templates.ts +19 -0
  136. package/src/db/schema.ts +74 -2
  137. package/src/db/stats.ts +3 -2
  138. package/src/db/task-comment-through.ts +112 -0
  139. package/src/db/task-core.ts +14 -1
  140. package/src/db/task-entity.ts +629 -0
  141. package/src/db/task-maintenance.ts +4 -3
  142. package/src/db/task-memory.ts +180 -0
  143. package/src/db/tasks.ts +24 -10
  144. package/src/issue-tracker/adapter.ts +15 -0
  145. package/src/issue-tracker/github.ts +40 -2
  146. package/src/issue-tracker/index.ts +1 -0
  147. package/src/mcp/index.ts +12 -1
  148. package/src/mcp/tools-spawn.ts +4 -0
  149. package/src/mcp-dispatch-tools.ts +193 -0
  150. package/src/mcp-notification-subscription-tools.ts +62 -4
  151. package/src/mcp-plan-tools.ts +30 -3
  152. package/src/mcp-release-tools.ts +33 -0
  153. package/src/mcp-task-memory-tools.ts +115 -0
  154. package/src/mcp-task-tools.ts +232 -0
  155. package/src/mcp-task-weave-tools.ts +66 -0
  156. package/src/notification-router.ts +24 -2
  157. package/src/notification-rules.ts +4 -0
  158. package/src/notification-types.ts +23 -0
  159. package/src/prompt-resolver.ts +66 -3
  160. package/src/routes/agents-spawn.ts +10 -0
  161. package/src/routes/index.ts +17 -1
  162. package/src/routes/messages.ts +6 -6
  163. package/src/routes/plans.ts +10 -2
  164. package/src/routes/tasks.ts +335 -2
  165. package/src/routing-policy-store.ts +147 -2
  166. package/src/security.ts +4 -0
  167. package/src/self-resume.ts +13 -2
  168. package/src/services/issue-lifecycle.ts +9 -1
  169. package/src/services/plan-graph.ts +107 -9
  170. package/src/services/release-readiness.ts +142 -0
  171. package/src/services/send-message.ts +31 -0
  172. package/src/services/spawn-agent.ts +81 -4
  173. package/src/services/task-context-hydration.ts +361 -0
  174. package/src/services/task-entity.ts +149 -0
  175. package/src/services/task-issue-weave.ts +558 -0
  176. package/src/services/task-lifecycle.ts +465 -0
  177. package/src/services/task-plan-view.ts +178 -0
  178. package/src/services/task-semantic-events.ts +137 -0
  179. package/src/services/transient-agent-reaper.ts +55 -22
  180. package/src/spawn-targets.ts +27 -2
  181. package/src/task-authz.ts +52 -0
  182. package/src/task-dispatcher.ts +461 -0
  183. package/src/upgrade-install-verify.ts +20 -1
  184. package/public/assets/MessageBubble-PEdvVh_m.js +0 -4
  185. package/public/assets/PlanGraphPanel-DSts7Rtq.js +0 -2
  186. package/public/assets/PlanGraphPanel-DSts7Rtq.js.map +0 -1
  187. package/public/assets/activity-BcYf8YNO.js +0 -2
  188. package/public/assets/activity-BcYf8YNO.js.map +0 -1
  189. package/public/assets/agents-CgRFJyks.js +0 -2
  190. package/public/assets/card-Cds0MWME.js +0 -2
  191. package/public/assets/chat-BLcm2Aht.js +0 -2
  192. package/public/assets/display-BWi8DQCp.js +0 -3
  193. package/public/assets/display-BWi8DQCp.js.map +0 -1
  194. package/public/assets/dist-CBVZk9U8.js +0 -2
  195. package/public/assets/dist-DC-TqTz2.js +0 -2
  196. package/public/assets/dist-DreO5AcZ.js +0 -2
  197. package/public/assets/index-3ydeNO0c.js +0 -23
  198. package/public/assets/index-3ydeNO0c.js.map +0 -1
  199. package/public/assets/index-BjjzI2LT.css +0 -2
  200. package/public/assets/managed-agents-CVca-s3t.js +0 -4
  201. package/public/assets/markdown-preview-impl-BPW4OQo9.js +0 -2
  202. package/public/assets/memory-BzuDTHC5.js +0 -2
  203. package/public/assets/overview-Dyvm1qpT.js +0 -2
  204. package/public/assets/projects-vt63bwHN.js +0 -2
  205. package/public/assets/prompt-settings-xVXmvxB5.js +0 -2
  206. package/public/assets/registry-ypiICauZ.js +0 -2
  207. package/public/assets/settings-87clTx4N.js +0 -6
  208. package/public/assets/store-DUfrGvOE.js +0 -9
  209. package/public/assets/store-DUfrGvOE.js.map +0 -1
  210. package/public/assets/tasks-1Kvp53wh.js +0 -2
  211. package/public/assets/tasks-1Kvp53wh.js.map +0 -1
  212. package/public/assets/teams-DJWp4AKi.js +0 -2
  213. package/public/assets/types-DorDDvEb.js +0 -2
  214. package/public/assets/types-DorDDvEb.js.map +0 -1
  215. package/public/assets/user-avatar-CfAUS3PZ.js +0 -2
  216. package/public/assets/workspaces-CIsQqQby.js +0 -3
  217. package/public/assets/workspaces-CIsQqQby.js.map +0 -1
@@ -1,13 +1,31 @@
1
1
  // Auto-split from routes.ts (#299). Domain: tasks.
2
2
  import { MAX_BODY_BYTES } from "../config";
3
- import { VALID_TASK_STATUSES, agentSessionStatus, auditEvent, dispatchTaskCallbacks, emitCommand, error, json, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, type Handler } from "./_shared";
3
+ import { VALID_TASK_STATUSES, agentSessionStatus, auditEvent, dispatchTaskCallbacks, emitCommand, error, json, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, resolveActorLabel, type Handler } from "./_shared";
4
+ import { getComponentAuth } from "../security";
5
+ import { agentIdFromComponent, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
6
+ import { dispatchShapeArg, pinArg, runTaskDispatch } from "../mcp-dispatch-tools";
4
7
  import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus } from "../db";
8
+ import { readTaskMemoryEnvelope } from "../db/task-memory";
9
+ import {
10
+ addTaskEntityDependency, addTaskEntityRef, assignTaskEntityAgent, createTaskEntity, getTaskEntity,
11
+ listTaskEntities, recordTaskEntityGate, removeTaskEntityDependency, removeTaskEntityRef, setTaskEntityStage, setTaskEntityStatus, taskEntityHistory, updateTaskEntity,
12
+ } from "../services/task-entity";
13
+ import { CommentThroughError, commentOnTaskThrough, taskCloseBridge } from "../services/task-issue-weave";
5
14
  import { captureTaskResultMemory, injectMemoryForTaskClaim } from "../memory-service";
6
15
  import { cleanEpoch, cleanMeta, cleanString, optionalEnum } from "../validation";
7
16
  import { emitMessageClaimed, emitTaskChanged } from "../sse";
8
- import { errMessage, isRecord } from "agent-relay-sdk";
17
+ import { errMessage, isRecord, TASK_DELIVERABLE_STAGES, type TaskBlocker, type TaskDispatch, type TaskGateStatus } from "agent-relay-sdk";
9
18
  import { type AgentSessionGuard, type TaskStatus, type TaskStatusInput } from "../types";
10
19
 
20
+ function actorOf(req: Request, fallback?: unknown): string {
21
+ return resolveActorLabel(req) ?? (typeof fallback === "string" && fallback.trim() ? fallback.trim() : "dashboard");
22
+ }
23
+
24
+ function taskDetailOr404(id: number) {
25
+ const detail = getTaskEntity(id);
26
+ return detail ? json(detail) : error("deliverable task not found", 404);
27
+ }
28
+
11
29
  function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
12
30
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
13
31
  const status = optionalEnum(body.status, "status", VALID_TASK_STATUSES);
@@ -27,14 +45,329 @@ export const getTasks: Handler = (req) => {
27
45
  const url = new URL(req.url);
28
46
  const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
29
47
  if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
48
+ // The legacy queue defaults to the claim lane (#834). `kind=deliverable` returns the
49
+ // durable Tasks (optionally filtered by deliverable stage / team).
50
+ const kind = url.searchParams.get("kind") ?? undefined;
51
+ if (kind === "deliverable") {
52
+ return json(listTaskEntities({
53
+ status: url.searchParams.get("status") ?? undefined,
54
+ deliverableStage: url.searchParams.get("deliverableStage") ?? undefined,
55
+ teamId: url.searchParams.get("teamId") ?? undefined,
56
+ limit: limitRaw ?? 100,
57
+ }));
58
+ }
30
59
  return json(listTasks({
31
60
  status: url.searchParams.get("status") ?? undefined,
32
61
  source: url.searchParams.get("source") ?? undefined,
33
62
  target: url.searchParams.get("target") ?? undefined,
63
+ kind: kind === "all" ? "all" : "claim",
34
64
  limit: limitRaw ?? 100,
35
65
  }));
36
66
  };
37
67
 
68
+ // --- #834 durable Task entity (kind='deliverable') HTTP surface ---
69
+
70
+ export const postTaskCreate: Handler = async (req) => {
71
+ const parsed = await parseBody<Record<string, unknown>>(req);
72
+ if (!parsed.ok) return error(parsed.error, parsed.status);
73
+ const body = parsed.body;
74
+ if (!isRecord(body)) return error("JSON object body required");
75
+ try {
76
+ const created = createTaskEntity({
77
+ title: body.title as string,
78
+ intent: cleanString(body.intent, "intent", { max: MAX_BODY_BYTES }),
79
+ doneWhen: cleanString(body.doneWhen, "doneWhen", { max: MAX_BODY_BYTES }),
80
+ teamId: cleanString(body.teamId, "teamId", { max: 200 }),
81
+ dispatch: body.dispatch as TaskDispatch | undefined,
82
+ createdBy: actorOf(req, body.createdBy),
83
+ });
84
+ if (Array.isArray(body.issueRefs)) {
85
+ for (const ref of body.issueRefs) {
86
+ if (typeof ref === "string" && ref.trim()) addTaskEntityRef(created.id, { issue: ref, defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }), actor: actorOf(req, body.createdBy) });
87
+ }
88
+ }
89
+ if (Array.isArray(body.dependsOn)) {
90
+ for (const dep of body.dependsOn) {
91
+ const depId = parseId(String(dep));
92
+ if (depId !== null) addTaskEntityDependency(created.id, depId, actorOf(req, body.createdBy));
93
+ }
94
+ }
95
+ return json(getTaskEntity(created.id), 201);
96
+ } catch (e) {
97
+ if (e instanceof ValidationError) return error(e.message, 400);
98
+ throw e;
99
+ }
100
+ };
101
+
102
+ export const getTaskDetailRoute: Handler = (_req, params) => {
103
+ const id = parseId(params.id);
104
+ if (id === null) return error("invalid task id");
105
+ return taskDetailOr404(id);
106
+ };
107
+
108
+ export const getTaskMemoryRoute: Handler = (req, params) => {
109
+ const id = parseId(params.id);
110
+ if (id === null) return error("invalid task id");
111
+ if (!getTaskEntity(id)) return error("deliverable task not found", 404);
112
+ const component = getComponentAuth(req);
113
+ const agentId = component ? agentIdFromComponent(component) : undefined;
114
+ const scopes = component?.scope ?? [];
115
+ if (!isTaskReadAuthorized(id, agentId, scopes)) return error("forbidden", 403);
116
+ return json(readTaskMemoryEnvelope(id));
117
+ };
118
+
119
+ export const getTaskHistoryRoute: Handler = (req, params) => {
120
+ const id = parseId(params.id);
121
+ if (id === null) return error("invalid task id");
122
+ if (!getTaskEntity(id)) return error("deliverable task not found", 404);
123
+ const component = getComponentAuth(req);
124
+ const agentId = component ? agentIdFromComponent(component) : undefined;
125
+ const scopes = component?.scope ?? [];
126
+ if (!isTaskReadAuthorized(id, agentId, scopes)) return error("forbidden", 403);
127
+ return json(taskEntityHistory(id));
128
+ };
129
+
130
+ export const patchTaskEntity: Handler = async (req, params) => {
131
+ const id = parseId(params.id);
132
+ if (id === null) return error("invalid task id");
133
+ const parsed = await parseBody<Record<string, unknown>>(req);
134
+ if (!parsed.ok) return error(parsed.error, parsed.status);
135
+ const body = parsed.body;
136
+ if (!isRecord(body)) return error("JSON object body required");
137
+ try {
138
+ const detail = updateTaskEntity(id, {
139
+ ...(body.title !== undefined ? { title: cleanString(body.title, "title", { max: 500 }) } : {}),
140
+ ...(body.intent !== undefined ? { intent: cleanString(body.intent, "intent", { max: MAX_BODY_BYTES }) ?? "" } : {}),
141
+ ...(body.latestSteer !== undefined ? { latestSteer: cleanString(body.latestSteer, "latestSteer", { max: MAX_BODY_BYTES }) ?? "" } : {}),
142
+ ...(body.doneWhen !== undefined ? { doneWhen: cleanString(body.doneWhen, "doneWhen", { max: MAX_BODY_BYTES }) ?? "" } : {}),
143
+ ...(body.dispatch !== undefined ? { dispatch: body.dispatch as TaskDispatch } : {}),
144
+ ...(body.teamId !== undefined ? { teamId: cleanString(body.teamId, "teamId", { max: 200 }) ?? "" } : {}),
145
+ actor: actorOf(req),
146
+ });
147
+ return json(detail);
148
+ } catch (e) {
149
+ return entityError(e);
150
+ }
151
+ };
152
+
153
+ export const postTaskEntityStatus: Handler = async (req, params) => {
154
+ const id = parseId(params.id);
155
+ if (id === null) return error("invalid task id");
156
+ const parsed = await parseBody<Record<string, unknown>>(req);
157
+ if (!parsed.ok) return error(parsed.error, parsed.status);
158
+ const body = parsed.body;
159
+ if (!isRecord(body)) return error("JSON object body required");
160
+ try {
161
+ const detail = setTaskEntityStatus(id, {
162
+ status: body.status as TaskStatus,
163
+ blockedBy: body.blockedBy as TaskBlocker[] | undefined,
164
+ note: cleanString(body.note, "note", { max: 4000 }),
165
+ actor: actorOf(req),
166
+ });
167
+ return json(detail);
168
+ } catch (e) {
169
+ return entityError(e);
170
+ }
171
+ };
172
+
173
+ export const postTaskEntityStage: Handler = async (req, params) => {
174
+ const id = parseId(params.id);
175
+ if (id === null) return error("invalid task id");
176
+ const parsed = await parseBody<Record<string, unknown>>(req);
177
+ if (!parsed.ok) return error(parsed.error, parsed.status);
178
+ const body = parsed.body;
179
+ if (!isRecord(body)) return error("JSON object body required");
180
+ try {
181
+ const detail = setTaskEntityStage(id, {
182
+ deliverableStage: optionalEnum(body.deliverableStage, "deliverableStage", TASK_DELIVERABLE_STAGES) as any,
183
+ branch: cleanString(body.branch, "branch", { max: 500 }),
184
+ commit: isRecord(body.commit) ? { sha: body.commit.sha as string, subject: body.commit.subject as string | undefined } : undefined,
185
+ actor: actorOf(req),
186
+ });
187
+ return json(detail);
188
+ } catch (e) {
189
+ return entityError(e);
190
+ }
191
+ };
192
+
193
+ export const postTaskEntityGate: Handler = async (req, params) => {
194
+ const id = parseId(params.id);
195
+ if (id === null) return error("invalid task id");
196
+ const parsed = await parseBody<Record<string, unknown>>(req);
197
+ if (!parsed.ok) return error(parsed.error, parsed.status);
198
+ const body = parsed.body;
199
+ if (!isRecord(body)) return error("JSON object body required");
200
+ try {
201
+ return json(recordTaskEntityGate(id, {
202
+ gate: cleanString(body.gate, "gate", { required: true, max: 120 })!,
203
+ status: body.status as TaskGateStatus,
204
+ detail: cleanString(body.detail, "detail", { max: 4000 }),
205
+ commitSha: cleanString(body.commitSha, "commitSha", { max: 80 }),
206
+ runId: cleanString(body.runId, "runId", { max: 240 }),
207
+ actor: actorOf(req),
208
+ }));
209
+ } catch (e) {
210
+ return entityError(e);
211
+ }
212
+ };
213
+
214
+ export const postTaskDependency: Handler = async (req, params) => {
215
+ const id = parseId(params.id);
216
+ if (id === null) return error("invalid task id");
217
+ const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
218
+ if (!parsed.ok) return error(parsed.error, parsed.status);
219
+ const dep = parseId(String(parsed.body?.dependsOnTaskId));
220
+ if (dep === null) return error("dependsOnTaskId required");
221
+ try {
222
+ addTaskEntityDependency(id, dep, actorOf(req));
223
+ return taskDetailOr404(id);
224
+ } catch (e) {
225
+ return entityError(e);
226
+ }
227
+ };
228
+
229
+ export const deleteTaskDependency: Handler = async (req, params) => {
230
+ const id = parseId(params.id);
231
+ if (id === null) return error("invalid task id");
232
+ const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
233
+ if (!parsed.ok) return error(parsed.error, parsed.status);
234
+ const dep = parseId(String(parsed.body?.dependsOnTaskId));
235
+ if (dep === null) return error("dependsOnTaskId required");
236
+ removeTaskEntityDependency(id, dep, actorOf(req));
237
+ return taskDetailOr404(id);
238
+ };
239
+
240
+ export const postTaskRef: Handler = async (req, params) => {
241
+ const id = parseId(params.id);
242
+ if (id === null) return error("invalid task id");
243
+ const parsed = await parseBody<Record<string, unknown>>(req);
244
+ if (!parsed.ok) return error(parsed.error, parsed.status);
245
+ const body = parsed.body;
246
+ if (!isRecord(body)) return error("JSON object body required");
247
+ try {
248
+ const detail = addTaskEntityRef(id, {
249
+ issue: cleanString(body.issue, "issue", { max: 1000 }),
250
+ defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }),
251
+ branch: cleanString(body.branch, "branch", { max: 500 }),
252
+ commit: isRecord(body.commit) ? { sha: body.commit.sha as string, subject: body.commit.subject as string | undefined } : undefined,
253
+ actor: actorOf(req),
254
+ });
255
+ return json(detail);
256
+ } catch (e) {
257
+ return entityError(e);
258
+ }
259
+ };
260
+
261
+ export const deleteTaskRef: Handler = async (req, params) => {
262
+ const id = parseId(params.id);
263
+ if (id === null) return error("invalid task id");
264
+ const parsed = await parseBody<Record<string, unknown>>(req);
265
+ if (!parsed.ok) return error(parsed.error, parsed.status);
266
+ const body = parsed.body;
267
+ if (!isRecord(body)) return error("JSON object body required");
268
+ try {
269
+ const detail = removeTaskEntityRef(id, {
270
+ issueRefId: cleanString(body.issueRefId, "issueRefId", { max: 120 }),
271
+ branch: body.branch === true,
272
+ commitSha: cleanString(body.commitSha, "commitSha", { max: 80 }),
273
+ actor: actorOf(req),
274
+ });
275
+ return json(detail);
276
+ } catch (e) {
277
+ return entityError(e);
278
+ }
279
+ };
280
+
281
+ export const postTaskAssign: Handler = async (req, params) => {
282
+ const id = parseId(params.id);
283
+ if (id === null) return error("invalid task id");
284
+ const parsed = await parseBody<Record<string, unknown>>(req);
285
+ if (!parsed.ok) return error(parsed.error, parsed.status);
286
+ const body = parsed.body;
287
+ if (!isRecord(body)) return error("JSON object body required");
288
+ try {
289
+ const detail = assignTaskEntityAgent(id, cleanString(body.agentId, "agentId", { required: true, max: 200 })!, cleanString(body.role, "role", { max: 120 }), actorOf(req));
290
+ return json(detail);
291
+ } catch (e) {
292
+ return entityError(e);
293
+ }
294
+ };
295
+
296
+ // #833 — comment-on-Task writes THROUGH to the linked GitHub issue (discussion lives in
297
+ // GitHub, never a relay table). Execution telemetry never reaches this path.
298
+ export const postTaskComment: Handler = async (req, params) => {
299
+ const id = parseId(params.id);
300
+ if (id === null) return error("invalid task id");
301
+ const parsed = await parseBody<Record<string, unknown>>(req);
302
+ if (!parsed.ok) return error(parsed.error, parsed.status);
303
+ const body = parsed.body;
304
+ if (!isRecord(body)) return error("JSON object body required");
305
+ try {
306
+ const result = await commentOnTaskThrough({
307
+ taskId: id,
308
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
309
+ clientId: cleanString(body.clientId, "clientId", { max: 240 }),
310
+ author: actorOf(req, body.author),
311
+ });
312
+ return json(result);
313
+ } catch (e) {
314
+ // Upstream GitHub write failed (well-formed request, no receipt written) → 502, not 400/500.
315
+ if (e instanceof CommentThroughError) return error(e.message, 502);
316
+ return entityError(e);
317
+ }
318
+ };
319
+
320
+ // #845 — HTTP dispatch decision endpoint. Invokes the SAME path as the MCP relay_task_dispatch
321
+ // tool (via runTaskDispatch). Accepts an optional inline dispatch override + pin; always returns
322
+ // the decision + rationale so the UI can show the #839 guard before any spawn. Never spawns.
323
+ // Auth: admin scope OR a bound agent (#848: no scope-only gate).
324
+ export const postTaskDispatch: Handler = async (req, params) => {
325
+ const id = parseId(params.id);
326
+ if (id === null) return error("invalid task id");
327
+ if (!getTaskEntity(id)) return error("deliverable task not found", 404);
328
+ const component = getComponentAuth(req);
329
+ const agentId = component ? agentIdFromComponent(component) : undefined;
330
+ const scopes = component?.scope ?? [];
331
+ if (!isTaskDispatchAuthorized(id, agentId, scopes)) return error("forbidden", 403);
332
+ const parsed = await parseBody<Record<string, unknown>>(req);
333
+ if (!parsed.ok) return error(parsed.error, parsed.status);
334
+ const body = parsed.body ?? {};
335
+ try {
336
+ const result = runTaskDispatch({
337
+ taskId: id,
338
+ dispatch: dispatchShapeArg(body.dispatch),
339
+ pin: pinArg(body.pin),
340
+ createReview: body.createReview !== false,
341
+ actor: actorOf(req),
342
+ });
343
+ return json({
344
+ taskId: result.taskId,
345
+ decision: result.decision,
346
+ ...(result.reviewTaskId !== undefined ? { reviewTaskId: result.reviewTaskId } : {}),
347
+ });
348
+ } catch (e) {
349
+ return entityError(e);
350
+ }
351
+ };
352
+
353
+ // #833 — read the #482/#474 close-bridge projection for a Task's linked issues.
354
+ export const getTaskCloseBridge: Handler = (_req, params) => {
355
+ const id = parseId(params.id);
356
+ if (id === null) return error("invalid task id");
357
+ try {
358
+ return json({ taskId: id, bridge: taskCloseBridge(id) });
359
+ } catch (e) {
360
+ return entityError(e);
361
+ }
362
+ };
363
+
364
+ function entityError(e: unknown): Response {
365
+ if (e instanceof ValidationError) {
366
+ return error(e.message, /not found/.test(e.message) ? 404 : 400);
367
+ }
368
+ throw e;
369
+ }
370
+
38
371
  export const getTaskById: Handler = (_req, params) => {
39
372
  const id = parseId(params.id);
40
373
  if (id === null) return error("invalid task id");
@@ -9,8 +9,25 @@
9
9
  import { ValidationError } from "./db";
10
10
  import { getConfig, setConfig } from "./config-store";
11
11
  import { notifyRoutingPolicyChanged } from "./routing-policy-push";
12
- import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
13
- import type { ProviderQuotaRoutingConfig, QuotaBandThresholds, RoutingPolicy, RoutingRule } from "agent-relay-sdk";
12
+ import {
13
+ isRecord,
14
+ SPAWN_PROVIDERS,
15
+ TASK_DISPATCH_COMPLEXITIES,
16
+ TASK_DISPATCH_CRITICALITIES,
17
+ TASK_DISPATCH_FOOTPRINTS,
18
+ TASK_DISPATCH_TYPES,
19
+ } from "agent-relay-sdk";
20
+ import type {
21
+ DispatchAutoReview,
22
+ DispatchMatch,
23
+ DispatchModelTarget,
24
+ DispatchPolicy,
25
+ DispatchRule,
26
+ ProviderQuotaRoutingConfig,
27
+ QuotaBandThresholds,
28
+ RoutingPolicy,
29
+ RoutingRule,
30
+ } from "agent-relay-sdk";
14
31
 
15
32
  export const ROUTING_POLICY_NAMESPACE = "routing-policy";
16
33
  export const ROUTING_POLICY_KEY = "default";
@@ -21,6 +38,37 @@ export const DEFAULT_ROUTING_POLICY: RoutingPolicy = {
21
38
  rules: [],
22
39
  };
23
40
 
41
+ /**
42
+ * #839 — built-in dispatch policy. Encodes the coordinator routing heuristics that used to
43
+ * live as tribal knowledge in memory files, so they are one inspectable + editable place that
44
+ * survives compaction. Used by the dispatcher whenever `RoutingPolicy.dispatch` is unset.
45
+ * Rules are evaluated top-down; the first match wins, so put the most specific rules first.
46
+ */
47
+ export const DEFAULT_DISPATCH_POLICY: DispatchPolicy = {
48
+ rules: [
49
+ { label: "review → Codex (reviewer edge)", match: { type: "review" }, target: { provider: "codex", effort: "high" } },
50
+ { label: "debug → Codex high", match: { type: "debug" }, target: { provider: "codex", effort: "high" } },
51
+ { label: "ui → Sonnet", match: { type: "ui" }, target: { provider: "claude", model: "sonnet-4.6", effort: "medium" } },
52
+ { label: "complex build → Opus 4.8 high", match: { type: "build", complexity: "complex" }, target: { provider: "claude", model: "opus-4.8", effort: "high" } },
53
+ { label: "research → Opus 4.8 high", match: { type: "research" }, target: { provider: "claude", model: "opus-4.8", effort: "high" } },
54
+ { label: "refactor → Opus 4.8 medium", match: { type: "refactor" }, target: { provider: "claude", model: "opus-4.8", effort: "medium" } },
55
+ { label: "moderate build → Sonnet medium", match: { type: "build", complexity: "moderate" }, target: { provider: "claude", model: "sonnet-4.6", effort: "medium" } },
56
+ { label: "ops → Sonnet low", match: { type: "ops" }, target: { provider: "claude", model: "sonnet-4.6", effort: "low" } },
57
+ { label: "trivial → Sonnet low", match: { complexity: "trivial" }, target: { provider: "claude", model: "sonnet-4.6", effort: "low" } },
58
+ ],
59
+ fallback: { provider: "claude", model: "sonnet-4.6", effort: "medium" },
60
+ // Large footprint demands a 1M-window model; the dispatcher escalates same-family (e.g.
61
+ // opus-4.8 → opus-4.8[1m]) when the matched model's window is below this.
62
+ footprintMinContextTokens: { large: 1_000_000 },
63
+ // The winning build+review pattern, made automatic: high criticality OR complex builds get
64
+ // a dependent adversarial review Task, routed to Codex per the review rule above.
65
+ autoReview: {
66
+ whenCriticality: ["high"],
67
+ whenComplexity: ["complex"],
68
+ target: { provider: "codex", effort: "high" },
69
+ },
70
+ };
71
+
24
72
  /** Retrieve the stored routing policy, falling back to the default when absent. */
25
73
  export function getRoutingPolicy(): RoutingPolicy {
26
74
  const entry = getConfig<RoutingPolicy>(ROUTING_POLICY_NAMESPACE, ROUTING_POLICY_KEY);
@@ -59,9 +107,106 @@ export function normalizeRoutingPolicy(value: unknown): RoutingPolicy {
59
107
  rules: normalizeRules(input.rules),
60
108
  ...(input.quotaBands !== undefined ? { quotaBands: normalizeQuotaBands(input.quotaBands) } : {}),
61
109
  ...(input.providerQuota !== undefined ? { providerQuota: normalizeProviderQuota(input.providerQuota) } : {}),
110
+ ...(input.dispatch !== undefined ? { dispatch: normalizeDispatchPolicy(input.dispatch) } : {}),
62
111
  };
63
112
  }
64
113
 
114
+ // --- #839 dispatch-policy normalizer ---------------------------------------
115
+
116
+ function normalizeDispatchTarget(raw: unknown, ctx: string): DispatchModelTarget {
117
+ if (!isRecord(raw)) throw new ValidationError(`${ctx} must be an object`);
118
+ if (typeof raw.provider !== "string" || !(SPAWN_PROVIDERS as readonly string[]).includes(raw.provider)) {
119
+ throw new ValidationError(`${ctx}.provider must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
120
+ }
121
+ return {
122
+ provider: raw.provider as DispatchModelTarget["provider"],
123
+ ...(typeof raw.model === "string" && raw.model ? { model: raw.model } : {}),
124
+ ...(typeof raw.effort === "string" && raw.effort ? { effort: raw.effort } : {}),
125
+ };
126
+ }
127
+
128
+ // Preserves the input shape (scalar in → scalar out) so a stored policy round-trips identically
129
+ // and no-op saves stay silent. Both forms are accepted by the dispatch matcher.
130
+ function normalizeMatchDimension(raw: unknown, allowed: readonly string[], ctx: string): string | string[] | undefined {
131
+ if (raw === undefined) return undefined;
132
+ const values = Array.isArray(raw) ? raw : [raw];
133
+ for (const v of values) {
134
+ if (typeof v !== "string" || !allowed.includes(v)) {
135
+ throw new ValidationError(`${ctx} must be one of: ${allowed.join(", ")}`);
136
+ }
137
+ }
138
+ return (Array.isArray(raw) ? values : values[0]) as string | string[];
139
+ }
140
+
141
+ function normalizeDispatchMatch(raw: unknown, ctx: string): DispatchMatch {
142
+ if (!isRecord(raw)) throw new ValidationError(`${ctx} must be an object`);
143
+ const match: DispatchMatch = {};
144
+ const complexity = normalizeMatchDimension(raw.complexity, TASK_DISPATCH_COMPLEXITIES, `${ctx}.complexity`);
145
+ const type = normalizeMatchDimension(raw.type, TASK_DISPATCH_TYPES, `${ctx}.type`);
146
+ const footprint = normalizeMatchDimension(raw.contextFootprint, TASK_DISPATCH_FOOTPRINTS, `${ctx}.contextFootprint`);
147
+ const criticality = normalizeMatchDimension(raw.criticality, TASK_DISPATCH_CRITICALITIES, `${ctx}.criticality`);
148
+ if (complexity) match.complexity = complexity as DispatchMatch["complexity"];
149
+ if (type) match.type = type as DispatchMatch["type"];
150
+ if (footprint) match.contextFootprint = footprint as DispatchMatch["contextFootprint"];
151
+ if (criticality) match.criticality = criticality as DispatchMatch["criticality"];
152
+ return match;
153
+ }
154
+
155
+ function normalizeDispatchRule(raw: unknown, idx: number): DispatchRule {
156
+ if (!isRecord(raw)) throw new ValidationError(`dispatch.rules[${idx}] must be an object`);
157
+ const rule: DispatchRule = {
158
+ match: normalizeDispatchMatch(raw.match ?? {}, `dispatch.rules[${idx}].match`),
159
+ target: normalizeDispatchTarget(raw.target, `dispatch.rules[${idx}].target`),
160
+ };
161
+ if (typeof raw.label === "string" && raw.label) rule.label = raw.label;
162
+ if (typeof raw.enabled === "boolean") rule.enabled = raw.enabled;
163
+ return rule;
164
+ }
165
+
166
+ function normalizeAutoReview(raw: unknown): DispatchAutoReview {
167
+ if (!isRecord(raw)) throw new ValidationError("dispatch.autoReview must be an object");
168
+ const out: DispatchAutoReview = {};
169
+ const whenCriticality = normalizeMatchDimension(raw.whenCriticality, TASK_DISPATCH_CRITICALITIES, "dispatch.autoReview.whenCriticality");
170
+ const whenComplexity = normalizeMatchDimension(raw.whenComplexity, TASK_DISPATCH_COMPLEXITIES, "dispatch.autoReview.whenComplexity");
171
+ // These are always arrays (the dispatcher calls .includes); coerce a scalar form up.
172
+ if (whenCriticality !== undefined) out.whenCriticality = (Array.isArray(whenCriticality) ? whenCriticality : [whenCriticality]) as DispatchAutoReview["whenCriticality"];
173
+ if (whenComplexity !== undefined) out.whenComplexity = (Array.isArray(whenComplexity) ? whenComplexity : [whenComplexity]) as DispatchAutoReview["whenComplexity"];
174
+ if (raw.target !== undefined) out.target = normalizeDispatchTarget(raw.target, "dispatch.autoReview.target");
175
+ return out;
176
+ }
177
+
178
+ export function normalizeDispatchPolicy(raw: unknown): DispatchPolicy {
179
+ if (!isRecord(raw)) throw new ValidationError("dispatch must be an object");
180
+ if (!Array.isArray(raw.rules)) throw new ValidationError("dispatch.rules must be an array");
181
+ const policy: DispatchPolicy = {
182
+ rules: raw.rules.map((r, i) => normalizeDispatchRule(r, i)),
183
+ fallback: normalizeDispatchTarget(raw.fallback, "dispatch.fallback"),
184
+ };
185
+ if (raw.footprintMinContextTokens !== undefined) {
186
+ if (!isRecord(raw.footprintMinContextTokens)) throw new ValidationError("dispatch.footprintMinContextTokens must be an object");
187
+ const out: Partial<Record<string, number>> = {};
188
+ for (const band of TASK_DISPATCH_FOOTPRINTS) {
189
+ const v = raw.footprintMinContextTokens[band];
190
+ if (v === undefined) continue;
191
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
192
+ throw new ValidationError(`dispatch.footprintMinContextTokens.${band} must be a positive number`);
193
+ }
194
+ out[band] = v;
195
+ }
196
+ policy.footprintMinContextTokens = out as DispatchPolicy["footprintMinContextTokens"];
197
+ }
198
+ if (raw.autoReview !== undefined) policy.autoReview = normalizeAutoReview(raw.autoReview);
199
+ return policy;
200
+ }
201
+
202
+ /**
203
+ * The effective dispatch policy: the stored `RoutingPolicy.dispatch`, or the built-in
204
+ * {@link DEFAULT_DISPATCH_POLICY} when none is configured. The dispatcher reads this.
205
+ */
206
+ export function getDispatchPolicy(): DispatchPolicy {
207
+ return getRoutingPolicy().dispatch ?? DEFAULT_DISPATCH_POLICY;
208
+ }
209
+
65
210
  function normalizeRules(raw: unknown): RoutingRule[] {
66
211
  if (!Array.isArray(raw)) return [];
67
212
  const rules: RoutingRule[] = [];
package/src/security.ts CHANGED
@@ -195,6 +195,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
195
195
  if (pathname === "/api/tokens/me") return null;
196
196
  if (pathname.startsWith("/api/tokens") || pathname.startsWith("/api/token-profiles")) return method === "GET" ? "token:read" : "token:write";
197
197
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
198
+ // Dispatch is spawn-class — requires command:spawn, not generic task:write (#845/#848).
199
+ if (pathname.match(/^\/api\/tasks\/[^/]+\/dispatch$/)) return "command:spawn";
198
200
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
199
201
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
200
202
  if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
@@ -304,6 +306,8 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
304
306
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
305
307
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
306
308
  if (pathname.startsWith("/api/provisioning")) return method === "GET" ? "provisioning:read" : "provisioning:write";
309
+ // Dispatch is spawn-class — requires command:spawn, not generic task:write (#845/#848).
310
+ if (pathname.match(/^\/api\/tasks\/[^/]+\/dispatch$/)) return "command:spawn";
307
311
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
308
312
  if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:write";
309
313
  if (pathname.startsWith("/api/plans")) return method === "GET" ? "plans:read" : "plans:write";
@@ -14,7 +14,7 @@ import {
14
14
  import { emitRelayEvent } from "./events";
15
15
  import { routeNotification } from "./notification-router";
16
16
  import { renderTemplate } from "./prompt-resolver";
17
- import type { AgentCard, Command, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
17
+ import type { AgentCard, Command, ContinuationDecisionEntry, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
18
18
 
19
19
  interface CompactAndResumeInput {
20
20
  agentId: string;
@@ -146,19 +146,30 @@ export function enqueueContinuationInjectionAfterClear(command: Command): Comman
146
146
  return enqueueContinuationInjectionAfterSelfResume(command);
147
147
  }
148
148
 
149
- function renderContinuation(envelope: ContinuationEnvelope): string {
149
+ export function renderContinuation(
150
+ envelope: ContinuationEnvelope,
151
+ input: { intro?: string } = {},
152
+ ): string {
150
153
  const ledger = envelope.ledger.length
151
154
  ? envelope.ledger.map((entry) => `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`).join("\n")
152
155
  : "- none";
156
+ const decisions = envelope.decisions.length ? envelope.decisions.map(renderDecisionEntry).join("\n") : "- none";
153
157
  return renderTemplate("continuation.envelope", {
158
+ intro: input.intro ?? `You have been resumed by Agent Relay — generation ${envelope.generation}.`,
154
159
  generation: String(envelope.generation),
155
160
  objective: envelope.objective,
156
161
  ruledOut: ledger,
162
+ decisions,
157
163
  workingState: envelope.workingState,
158
164
  archiveRefs: envelope.archiveRefs.length ? envelope.archiveRefs.join("\n") : "- none",
159
165
  });
160
166
  }
161
167
 
168
+ function renderDecisionEntry(entry: ContinuationDecisionEntry): string {
169
+ const by = entry.by ? ` · ${entry.by}` : "";
170
+ return `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}${by}: ${entry.decision}${entry.rationale ? `; rationale: ${entry.rationale}` : ""}`;
171
+ }
172
+
162
173
  function assertResumeSupported(agent: AgentCard, mode: SelfResumeMode): void {
163
174
  const context = agent.providerCapabilities?.context;
164
175
  const resume = context?.resume ?? (context?.clear && context?.inject ? "clear-inject" : "none");
@@ -1,5 +1,5 @@
1
1
  import { errMessage } from "agent-relay-sdk";
2
- import { getAgent, listPlansWithBoundOwner } from "../db";
2
+ import { getAgent, listPlansWithBoundOwner, deliverableTaskIdsByBranch } from "../db";
3
3
  import { listIssuesForEntity, planNodeIssueEntityId } from "../db/issue-associations";
4
4
  import {
5
5
  getSuccessfulIssueLifecycleAction,
@@ -142,6 +142,14 @@ function associatedIssueRefs(workspace: LifecycleWorkspace): IssueRef[] {
142
142
  }
143
143
  }
144
144
  }
145
+ // #834 — task-aware resolution: a deliverable Task owns its branch, so the landed branch
146
+ // resolves to its linked issue(s). This is the resolution PATH; lifecycle automation that
147
+ // advances the Task itself on land is #831.
148
+ if (workspace.branch) {
149
+ for (const taskId of deliverableTaskIdsByBranch(workspace.branch)) {
150
+ refs.push(...listIssuesForEntity("task", String(taskId)).map((item) => item.issueRef));
151
+ }
152
+ }
145
153
  return issueRefMap(refs);
146
154
  }
147
155