agent-relay-server 0.106.0 → 0.108.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 (149) hide show
  1. package/docs/openapi.json +503 -1
  2. package/package.json +6 -5
  3. package/public/assets/{MessageBubble-CPYQQLDI.js → MessageBubble-DYJezSjU.js} +2 -2
  4. package/public/assets/{MessageBubble-CPYQQLDI.js.map → MessageBubble-DYJezSjU.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-B7xZt5DI.js → PlanGraphPanel-MWcVw38W.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-B7xZt5DI.js.map → PlanGraphPanel-MWcVw38W.js.map} +1 -1
  7. package/public/assets/{activity-UKQrQ8_0.js → activity-Db3X3dg8.js} +2 -2
  8. package/public/assets/{activity-UKQrQ8_0.js.map → activity-Db3X3dg8.js.map} +1 -1
  9. package/public/assets/{agent-profiles-Zr_5nNEJ.js → agent-profiles-Di2w5U1o.js} +2 -2
  10. package/public/assets/{agent-profiles-Zr_5nNEJ.js.map → agent-profiles-Di2w5U1o.js.map} +1 -1
  11. package/public/assets/{agents-FdrfmJsj.js → agents-GSJXvmU1.js} +2 -2
  12. package/public/assets/{agents-FdrfmJsj.js.map → agents-GSJXvmU1.js.map} +1 -1
  13. package/public/assets/{analytics-VximqXQ9.js → analytics-B9USQLl5.js} +18 -18
  14. package/public/assets/{analytics-VximqXQ9.js.map → analytics-B9USQLl5.js.map} +1 -1
  15. package/public/assets/{automation-DlEcJvgu.js → automation-XRePs1dT.js} +2 -2
  16. package/public/assets/{automation-DlEcJvgu.js.map → automation-XRePs1dT.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-C_VNyER3.js} +2 -2
  18. package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-C_VNyER3.js.map} +1 -1
  19. package/public/assets/{channels-CcSnLzCG.js → channels-DACvNoC2.js} +2 -2
  20. package/public/assets/{channels-CcSnLzCG.js.map → channels-DACvNoC2.js.map} +1 -1
  21. package/public/assets/{chat-D1bunuI-.js → chat-D_0uHgB6.js} +2 -2
  22. package/public/assets/{chat-D1bunuI-.js.map → chat-D_0uHgB6.js.map} +1 -1
  23. package/public/assets/{connectors-DbPOlQhL.js → connectors-C0B38LUK.js} +2 -2
  24. package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C0B38LUK.js.map} +1 -1
  25. package/public/assets/display-1J__Ti1K.js +3 -0
  26. package/public/assets/display-1J__Ti1K.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-4v9SkUD1.js → formatted-body-impl-BKWCAjBd.js} +2 -2
  28. package/public/assets/{formatted-body-impl-4v9SkUD1.js.map → formatted-body-impl-BKWCAjBd.js.map} +1 -1
  29. package/public/assets/index-DlBSuzd2.js +23 -0
  30. package/public/assets/index-DlBSuzd2.js.map +1 -0
  31. package/public/assets/{insights-CntgHcTp.js → insights-BzNoYvDe.js} +2 -2
  32. package/public/assets/{insights-CntgHcTp.js.map → insights-BzNoYvDe.js.map} +1 -1
  33. package/public/assets/{integrations-hkSW8CzM.js → integrations-DLzJpt9G.js} +2 -2
  34. package/public/assets/{integrations-hkSW8CzM.js.map → integrations-DLzJpt9G.js.map} +1 -1
  35. package/public/assets/{maintenance-Dw7frQtu.js → maintenance-B6_0-ECm.js} +2 -2
  36. package/public/assets/{maintenance-Dw7frQtu.js.map → maintenance-B6_0-ECm.js.map} +1 -1
  37. package/public/assets/{managed-agents-Bj--bFmZ.js → managed-agents-CpzKXDtV.js} +2 -2
  38. package/public/assets/{managed-agents-Bj--bFmZ.js.map → managed-agents-CpzKXDtV.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-DGLCocU8.js → markdown-preview-impl-DxBnlJ2k.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-DGLCocU8.js.map → markdown-preview-impl-DxBnlJ2k.js.map} +1 -1
  41. package/public/assets/{memory-yj2MIxUA.js → memory-Bnm7ILWL.js} +2 -2
  42. package/public/assets/{memory-yj2MIxUA.js.map → memory-Bnm7ILWL.js.map} +1 -1
  43. package/public/assets/{messages-BT5mL7iz.js → messages-D9LkocTc.js} +2 -2
  44. package/public/assets/{messages-BT5mL7iz.js.map → messages-D9LkocTc.js.map} +1 -1
  45. package/public/assets/{orchestrators-BVDAhrPk.js → orchestrators-C__JfrNr.js} +2 -2
  46. package/public/assets/{orchestrators-BVDAhrPk.js.map → orchestrators-C__JfrNr.js.map} +1 -1
  47. package/public/assets/{overview-DlqJIqYK.js → overview-BQ0b0V7Q.js} +2 -2
  48. package/public/assets/{overview-DlqJIqYK.js.map → overview-BQ0b0V7Q.js.map} +1 -1
  49. package/public/assets/{pairs-DQ4tNCo3.js → pairs-ChyeBs0Z.js} +2 -2
  50. package/public/assets/{pairs-DQ4tNCo3.js.map → pairs-ChyeBs0Z.js.map} +1 -1
  51. package/public/assets/{projects-vt63bwHN.js → projects-v5Kp1Oaq.js} +2 -2
  52. package/public/assets/{projects-vt63bwHN.js.map → projects-v5Kp1Oaq.js.map} +1 -1
  53. package/public/assets/{prompt-settings-DlsqOP0C.js → prompt-settings-YhAOlYc6.js} +2 -2
  54. package/public/assets/{prompt-settings-DlsqOP0C.js.map → prompt-settings-YhAOlYc6.js.map} +1 -1
  55. package/public/assets/{registry-Cw2fdWrn.js → registry-DZoAfrzx.js} +2 -2
  56. package/public/assets/{registry-Cw2fdWrn.js.map → registry-DZoAfrzx.js.map} +1 -1
  57. package/public/assets/{security-ucr7IT1V.js → security-DRCj00eA.js} +2 -2
  58. package/public/assets/{security-ucr7IT1V.js.map → security-DRCj00eA.js.map} +1 -1
  59. package/public/assets/{select-D8UWCtYZ.js → select-ZOpLw2Fd.js} +2 -2
  60. package/public/assets/{select-D8UWCtYZ.js.map → select-ZOpLw2Fd.js.map} +1 -1
  61. package/public/assets/settings-DGgwr9WH.js +6 -0
  62. package/public/assets/settings-DGgwr9WH.js.map +1 -0
  63. package/public/assets/{store-DUfrGvOE.js → store-Bnag1kY-.js} +2 -2
  64. package/public/assets/{store-DUfrGvOE.js.map → store-Bnag1kY-.js.map} +1 -1
  65. package/public/assets/{tasks-jRUvxUd5.js → tasks-DUTu8ZhA.js} +2 -2
  66. package/public/assets/{tasks-jRUvxUd5.js.map → tasks-DUTu8ZhA.js.map} +1 -1
  67. package/public/assets/{teams-Dn0yggD-.js → teams-BJpaoG2m.js} +2 -2
  68. package/public/assets/{teams-Dn0yggD-.js.map → teams-BJpaoG2m.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-fLly20IP.js → terminal-viewer-impl-CQhh6oXa.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-fLly20IP.js.map → terminal-viewer-impl-CQhh6oXa.js.map} +1 -1
  71. package/public/assets/types-BiJFuTiL.js +2 -0
  72. package/public/assets/types-BiJFuTiL.js.map +1 -0
  73. package/public/assets/{user-avatar-BWLUHt2e.js → user-avatar-BYGrUx1U.js} +2 -2
  74. package/public/assets/{user-avatar-BWLUHt2e.js.map → user-avatar-BYGrUx1U.js.map} +1 -1
  75. package/public/assets/{work-queue-BAnNY0xE.js → work-queue-BH2-mARc.js} +2 -2
  76. package/public/assets/{work-queue-BAnNY0xE.js.map → work-queue-BH2-mARc.js.map} +1 -1
  77. package/public/assets/{workspaces-CIsQqQby.js → workspaces-DldYyXZR.js} +2 -2
  78. package/public/assets/{workspaces-CIsQqQby.js.map → workspaces-DldYyXZR.js.map} +1 -1
  79. package/public/index.html +4 -4
  80. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  81. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
  82. package/src/branch-landed.ts +93 -4
  83. package/src/config-store.ts +12 -1
  84. package/src/db/agents.ts +3 -2
  85. package/src/db/continuations.ts +13 -3
  86. package/src/db/index.ts +3 -0
  87. package/src/db/issue-lifecycle.ts +12 -0
  88. package/src/db/mappers.ts +17 -0
  89. package/src/db/messages.ts +9 -4
  90. package/src/db/migrations.ts +91 -1
  91. package/src/db/notification-subscriptions.ts +1 -1
  92. package/src/db/prompt-templates.ts +19 -0
  93. package/src/db/provider-quotas.ts +6 -0
  94. package/src/db/schema.ts +67 -1
  95. package/src/db/stats.ts +3 -2
  96. package/src/db/task-comment-through.ts +112 -0
  97. package/src/db/task-core.ts +14 -1
  98. package/src/db/task-entity.ts +629 -0
  99. package/src/db/task-maintenance.ts +4 -3
  100. package/src/db/task-memory.ts +180 -0
  101. package/src/db/tasks.ts +24 -10
  102. package/src/issue-tracker/adapter.ts +15 -0
  103. package/src/issue-tracker/github.ts +40 -2
  104. package/src/issue-tracker/index.ts +1 -0
  105. package/src/mcp/index.ts +12 -1
  106. package/src/mcp/tools-spawn.ts +4 -0
  107. package/src/mcp-dispatch-tools.ts +164 -0
  108. package/src/mcp-notification-subscription-tools.ts +62 -4
  109. package/src/mcp-plan-tools.ts +30 -3
  110. package/src/mcp-release-tools.ts +33 -0
  111. package/src/mcp-task-memory-tools.ts +132 -0
  112. package/src/mcp-task-tools.ts +232 -0
  113. package/src/mcp-task-weave-tools.ts +66 -0
  114. package/src/notification-router.ts +24 -2
  115. package/src/notification-rules.ts +4 -0
  116. package/src/notification-types.ts +25 -0
  117. package/src/prompt-resolver.ts +66 -3
  118. package/src/routes/agents-spawn.ts +10 -0
  119. package/src/routes/commands.ts +17 -1
  120. package/src/routes/index.ts +16 -2
  121. package/src/routes/plans.ts +10 -2
  122. package/src/routes/provider-quotas.ts +13 -1
  123. package/src/routes/tasks.ts +276 -2
  124. package/src/routing-policy-store.ts +147 -2
  125. package/src/security.ts +2 -0
  126. package/src/self-resume.ts +13 -2
  127. package/src/services/issue-lifecycle.ts +9 -1
  128. package/src/services/plan-graph.ts +107 -9
  129. package/src/services/release-readiness.ts +142 -0
  130. package/src/services/send-message.ts +31 -0
  131. package/src/services/spawn-agent.ts +81 -4
  132. package/src/services/task-context-hydration.ts +361 -0
  133. package/src/services/task-entity.ts +149 -0
  134. package/src/services/task-issue-weave.ts +558 -0
  135. package/src/services/task-lifecycle.ts +465 -0
  136. package/src/services/task-plan-view.ts +178 -0
  137. package/src/services/task-semantic-events.ts +137 -0
  138. package/src/services/transient-agent-reaper.ts +71 -23
  139. package/src/spawn-targets.ts +27 -2
  140. package/src/task-dispatcher.ts +461 -0
  141. package/src/upgrade-install-verify.ts +20 -1
  142. package/public/assets/display-BWi8DQCp.js +0 -3
  143. package/public/assets/display-BWi8DQCp.js.map +0 -1
  144. package/public/assets/index-Vmhw442d.js +0 -23
  145. package/public/assets/index-Vmhw442d.js.map +0 -1
  146. package/public/assets/settings-Dvf_ps1B.js +0 -6
  147. package/public/assets/settings-Dvf_ps1B.js.map +0 -1
  148. package/public/assets/types-DorDDvEb.js +0 -2
  149. package/public/assets/types-DorDDvEb.js.map +0 -1
@@ -1,4 +1,3 @@
1
- import { getPlan } from "../db";
2
1
  import { getPlanGraph } from "../services/plan-graph";
3
2
  import { cleanString } from "../validation";
4
3
  import { error, json, parseQueryInt, type Handler } from "./_shared";
@@ -7,6 +6,13 @@ export const getPlansRoute: Handler = (req) => {
7
6
  const url = new URL(req.url);
8
7
  const channel = cleanString(url.searchParams.get("channel") ?? undefined, "channel", { max: 120 });
9
8
  const teamId = cleanString(url.searchParams.get("teamId") ?? undefined, "teamId", { max: 120 });
9
+ // #836: view=tasks returns the pure task-backed DAG (no plan row) for a team.
10
+ const view = cleanString(url.searchParams.get("view") ?? undefined, "view", { max: 40 });
11
+ if (view === "tasks") {
12
+ if (!teamId) return error("view=tasks requires teamId");
13
+ const plan = getPlanGraph({ teamId, view: "tasks" });
14
+ return plan ? json(plan) : error("plan not found", 404);
15
+ }
10
16
  if (teamId) {
11
17
  const plan = getPlanGraph({ teamId, channel });
12
18
  return plan ? json(plan) : error("plan not found", 404);
@@ -21,6 +27,8 @@ export const getPlansRoute: Handler = (req) => {
21
27
 
22
28
  export const getPlanByIdRoute: Handler = (_req, params) => {
23
29
  const id = cleanString(params.id, "id", { required: true, max: 120 })!;
24
- const plan = getPlan(id);
30
+ // #836: go through getPlanGraph so bound nodes project from their Task (label/scope/status)
31
+ // and task→task dependency edges surface — a raw getPlan would serve a stale, edge-less view.
32
+ const plan = getPlanGraph({ planId: id });
25
33
  return plan ? json(plan) : error("plan not found", 404);
26
34
  };
@@ -1,5 +1,5 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { acquireProviderQuotaLease, getOrchestrator, getProviderQuotaLease, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
2
+ import { acquireProviderQuotaLease, getOrchestrator, getProviderQuotaLease, getProviderQuotaHistory, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
3
3
  import { emitRelayEvent } from "../events";
4
4
  import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
5
5
  import { authContextFromRequest } from "../services/auth-context";
@@ -79,6 +79,18 @@ export const getProviderQuotaAdvisoryRoute: Handler = () => {
79
79
  return json({ advisories });
80
80
  };
81
81
 
82
+ // #609 — per-provider 30-day quota history (consumed by #607 analytics chart + #608 sparkline).
83
+ export const getProviderQuotaHistoryRoute: Handler = (req) => {
84
+ const url = new URL(req.url);
85
+ const provider = url.searchParams.get("provider");
86
+ if (!provider) return error("provider query parameter required");
87
+ const sinceMs = url.searchParams.get("sinceMs") ? Number(url.searchParams.get("sinceMs")) : undefined;
88
+ if (sinceMs !== undefined && (!Number.isFinite(sinceMs) || sinceMs < 0)) return error("sinceMs must be a positive timestamp");
89
+ const accountKey = url.searchParams.get("accountKey") ?? undefined;
90
+ const limit = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 10_000, clamp: true });
91
+ return json(getProviderQuotaHistory(provider, { sinceMs, accountKey, limit: limit ?? undefined }));
92
+ };
93
+
82
94
  export const postProviderQuotaRoute: Handler = async (req) => {
83
95
  const parsed = await parseBody<unknown>(req);
84
96
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -1,13 +1,27 @@
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
4
  import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus } from "../db";
5
+ import {
6
+ addTaskEntityDependency, addTaskEntityRef, assignTaskEntityAgent, createTaskEntity, getTaskEntity,
7
+ listTaskEntities, recordTaskEntityGate, removeTaskEntityDependency, removeTaskEntityRef, setTaskEntityStage, setTaskEntityStatus, updateTaskEntity,
8
+ } from "../services/task-entity";
9
+ import { CommentThroughError, commentOnTaskThrough, taskCloseBridge } from "../services/task-issue-weave";
5
10
  import { captureTaskResultMemory, injectMemoryForTaskClaim } from "../memory-service";
6
11
  import { cleanEpoch, cleanMeta, cleanString, optionalEnum } from "../validation";
7
12
  import { emitMessageClaimed, emitTaskChanged } from "../sse";
8
- import { errMessage, isRecord } from "agent-relay-sdk";
13
+ import { errMessage, isRecord, TASK_DELIVERABLE_STAGES, type TaskBlocker, type TaskDispatch, type TaskGateStatus } from "agent-relay-sdk";
9
14
  import { type AgentSessionGuard, type TaskStatus, type TaskStatusInput } from "../types";
10
15
 
16
+ function actorOf(req: Request, fallback?: unknown): string {
17
+ return resolveActorLabel(req) ?? (typeof fallback === "string" && fallback.trim() ? fallback.trim() : "dashboard");
18
+ }
19
+
20
+ function taskDetailOr404(id: number) {
21
+ const detail = getTaskEntity(id);
22
+ return detail ? json(detail) : error("deliverable task not found", 404);
23
+ }
24
+
11
25
  function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
12
26
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
13
27
  const status = optionalEnum(body.status, "status", VALID_TASK_STATUSES);
@@ -27,14 +41,274 @@ export const getTasks: Handler = (req) => {
27
41
  const url = new URL(req.url);
28
42
  const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
29
43
  if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
44
+ // The legacy queue defaults to the claim lane (#834). `kind=deliverable` returns the
45
+ // durable Tasks (optionally filtered by deliverable stage / team).
46
+ const kind = url.searchParams.get("kind") ?? undefined;
47
+ if (kind === "deliverable") {
48
+ return json(listTaskEntities({
49
+ status: url.searchParams.get("status") ?? undefined,
50
+ deliverableStage: url.searchParams.get("deliverableStage") ?? undefined,
51
+ teamId: url.searchParams.get("teamId") ?? undefined,
52
+ limit: limitRaw ?? 100,
53
+ }));
54
+ }
30
55
  return json(listTasks({
31
56
  status: url.searchParams.get("status") ?? undefined,
32
57
  source: url.searchParams.get("source") ?? undefined,
33
58
  target: url.searchParams.get("target") ?? undefined,
59
+ kind: kind === "all" ? "all" : "claim",
34
60
  limit: limitRaw ?? 100,
35
61
  }));
36
62
  };
37
63
 
64
+ // --- #834 durable Task entity (kind='deliverable') HTTP surface ---
65
+
66
+ export const postTaskCreate: Handler = async (req) => {
67
+ const parsed = await parseBody<Record<string, unknown>>(req);
68
+ if (!parsed.ok) return error(parsed.error, parsed.status);
69
+ const body = parsed.body;
70
+ if (!isRecord(body)) return error("JSON object body required");
71
+ try {
72
+ const created = createTaskEntity({
73
+ title: body.title as string,
74
+ intent: cleanString(body.intent, "intent", { max: MAX_BODY_BYTES }),
75
+ doneWhen: cleanString(body.doneWhen, "doneWhen", { max: MAX_BODY_BYTES }),
76
+ teamId: cleanString(body.teamId, "teamId", { max: 200 }),
77
+ dispatch: body.dispatch as TaskDispatch | undefined,
78
+ createdBy: actorOf(req, body.createdBy),
79
+ });
80
+ if (Array.isArray(body.issueRefs)) {
81
+ for (const ref of body.issueRefs) {
82
+ if (typeof ref === "string" && ref.trim()) addTaskEntityRef(created.id, { issue: ref, defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }), actor: actorOf(req, body.createdBy) });
83
+ }
84
+ }
85
+ if (Array.isArray(body.dependsOn)) {
86
+ for (const dep of body.dependsOn) {
87
+ const depId = parseId(String(dep));
88
+ if (depId !== null) addTaskEntityDependency(created.id, depId, actorOf(req, body.createdBy));
89
+ }
90
+ }
91
+ return json(getTaskEntity(created.id), 201);
92
+ } catch (e) {
93
+ if (e instanceof ValidationError) return error(e.message, 400);
94
+ throw e;
95
+ }
96
+ };
97
+
98
+ export const getTaskDetailRoute: Handler = (_req, params) => {
99
+ const id = parseId(params.id);
100
+ if (id === null) return error("invalid task id");
101
+ return taskDetailOr404(id);
102
+ };
103
+
104
+ export const patchTaskEntity: Handler = async (req, params) => {
105
+ const id = parseId(params.id);
106
+ if (id === null) return error("invalid task id");
107
+ const parsed = await parseBody<Record<string, unknown>>(req);
108
+ if (!parsed.ok) return error(parsed.error, parsed.status);
109
+ const body = parsed.body;
110
+ if (!isRecord(body)) return error("JSON object body required");
111
+ try {
112
+ const detail = updateTaskEntity(id, {
113
+ ...(body.title !== undefined ? { title: cleanString(body.title, "title", { max: 500 }) } : {}),
114
+ ...(body.intent !== undefined ? { intent: cleanString(body.intent, "intent", { max: MAX_BODY_BYTES }) ?? "" } : {}),
115
+ ...(body.latestSteer !== undefined ? { latestSteer: cleanString(body.latestSteer, "latestSteer", { max: MAX_BODY_BYTES }) ?? "" } : {}),
116
+ ...(body.doneWhen !== undefined ? { doneWhen: cleanString(body.doneWhen, "doneWhen", { max: MAX_BODY_BYTES }) ?? "" } : {}),
117
+ ...(body.dispatch !== undefined ? { dispatch: body.dispatch as TaskDispatch } : {}),
118
+ ...(body.teamId !== undefined ? { teamId: cleanString(body.teamId, "teamId", { max: 200 }) ?? "" } : {}),
119
+ actor: actorOf(req),
120
+ });
121
+ return json(detail);
122
+ } catch (e) {
123
+ return entityError(e);
124
+ }
125
+ };
126
+
127
+ export const postTaskEntityStatus: Handler = async (req, params) => {
128
+ const id = parseId(params.id);
129
+ if (id === null) return error("invalid task id");
130
+ const parsed = await parseBody<Record<string, unknown>>(req);
131
+ if (!parsed.ok) return error(parsed.error, parsed.status);
132
+ const body = parsed.body;
133
+ if (!isRecord(body)) return error("JSON object body required");
134
+ try {
135
+ const detail = setTaskEntityStatus(id, {
136
+ status: body.status as TaskStatus,
137
+ blockedBy: body.blockedBy as TaskBlocker[] | undefined,
138
+ note: cleanString(body.note, "note", { max: 4000 }),
139
+ actor: actorOf(req),
140
+ });
141
+ return json(detail);
142
+ } catch (e) {
143
+ return entityError(e);
144
+ }
145
+ };
146
+
147
+ export const postTaskEntityStage: Handler = async (req, params) => {
148
+ const id = parseId(params.id);
149
+ if (id === null) return error("invalid task id");
150
+ const parsed = await parseBody<Record<string, unknown>>(req);
151
+ if (!parsed.ok) return error(parsed.error, parsed.status);
152
+ const body = parsed.body;
153
+ if (!isRecord(body)) return error("JSON object body required");
154
+ try {
155
+ const detail = setTaskEntityStage(id, {
156
+ deliverableStage: optionalEnum(body.deliverableStage, "deliverableStage", TASK_DELIVERABLE_STAGES) as any,
157
+ branch: cleanString(body.branch, "branch", { max: 500 }),
158
+ commit: isRecord(body.commit) ? { sha: body.commit.sha as string, subject: body.commit.subject as string | undefined } : undefined,
159
+ actor: actorOf(req),
160
+ });
161
+ return json(detail);
162
+ } catch (e) {
163
+ return entityError(e);
164
+ }
165
+ };
166
+
167
+ export const postTaskEntityGate: Handler = async (req, params) => {
168
+ const id = parseId(params.id);
169
+ if (id === null) return error("invalid task id");
170
+ const parsed = await parseBody<Record<string, unknown>>(req);
171
+ if (!parsed.ok) return error(parsed.error, parsed.status);
172
+ const body = parsed.body;
173
+ if (!isRecord(body)) return error("JSON object body required");
174
+ try {
175
+ return json(recordTaskEntityGate(id, {
176
+ gate: cleanString(body.gate, "gate", { required: true, max: 120 })!,
177
+ status: body.status as TaskGateStatus,
178
+ detail: cleanString(body.detail, "detail", { max: 4000 }),
179
+ commitSha: cleanString(body.commitSha, "commitSha", { max: 80 }),
180
+ runId: cleanString(body.runId, "runId", { max: 240 }),
181
+ actor: actorOf(req),
182
+ }));
183
+ } catch (e) {
184
+ return entityError(e);
185
+ }
186
+ };
187
+
188
+ export const postTaskDependency: Handler = async (req, params) => {
189
+ const id = parseId(params.id);
190
+ if (id === null) return error("invalid task id");
191
+ const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
192
+ if (!parsed.ok) return error(parsed.error, parsed.status);
193
+ const dep = parseId(String(parsed.body?.dependsOnTaskId));
194
+ if (dep === null) return error("dependsOnTaskId required");
195
+ try {
196
+ addTaskEntityDependency(id, dep, actorOf(req));
197
+ return taskDetailOr404(id);
198
+ } catch (e) {
199
+ return entityError(e);
200
+ }
201
+ };
202
+
203
+ export const deleteTaskDependency: Handler = async (req, params) => {
204
+ const id = parseId(params.id);
205
+ if (id === null) return error("invalid task id");
206
+ const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
207
+ if (!parsed.ok) return error(parsed.error, parsed.status);
208
+ const dep = parseId(String(parsed.body?.dependsOnTaskId));
209
+ if (dep === null) return error("dependsOnTaskId required");
210
+ removeTaskEntityDependency(id, dep, actorOf(req));
211
+ return taskDetailOr404(id);
212
+ };
213
+
214
+ export const postTaskRef: Handler = async (req, params) => {
215
+ const id = parseId(params.id);
216
+ if (id === null) return error("invalid task id");
217
+ const parsed = await parseBody<Record<string, unknown>>(req);
218
+ if (!parsed.ok) return error(parsed.error, parsed.status);
219
+ const body = parsed.body;
220
+ if (!isRecord(body)) return error("JSON object body required");
221
+ try {
222
+ const detail = addTaskEntityRef(id, {
223
+ issue: cleanString(body.issue, "issue", { max: 1000 }),
224
+ defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }),
225
+ branch: cleanString(body.branch, "branch", { max: 500 }),
226
+ commit: isRecord(body.commit) ? { sha: body.commit.sha as string, subject: body.commit.subject as string | undefined } : undefined,
227
+ actor: actorOf(req),
228
+ });
229
+ return json(detail);
230
+ } catch (e) {
231
+ return entityError(e);
232
+ }
233
+ };
234
+
235
+ export const deleteTaskRef: Handler = async (req, params) => {
236
+ const id = parseId(params.id);
237
+ if (id === null) return error("invalid task id");
238
+ const parsed = await parseBody<Record<string, unknown>>(req);
239
+ if (!parsed.ok) return error(parsed.error, parsed.status);
240
+ const body = parsed.body;
241
+ if (!isRecord(body)) return error("JSON object body required");
242
+ try {
243
+ const detail = removeTaskEntityRef(id, {
244
+ issueRefId: cleanString(body.issueRefId, "issueRefId", { max: 120 }),
245
+ branch: body.branch === true,
246
+ commitSha: cleanString(body.commitSha, "commitSha", { max: 80 }),
247
+ actor: actorOf(req),
248
+ });
249
+ return json(detail);
250
+ } catch (e) {
251
+ return entityError(e);
252
+ }
253
+ };
254
+
255
+ export const postTaskAssign: Handler = async (req, params) => {
256
+ const id = parseId(params.id);
257
+ if (id === null) return error("invalid task id");
258
+ const parsed = await parseBody<Record<string, unknown>>(req);
259
+ if (!parsed.ok) return error(parsed.error, parsed.status);
260
+ const body = parsed.body;
261
+ if (!isRecord(body)) return error("JSON object body required");
262
+ try {
263
+ const detail = assignTaskEntityAgent(id, cleanString(body.agentId, "agentId", { required: true, max: 200 })!, cleanString(body.role, "role", { max: 120 }), actorOf(req));
264
+ return json(detail);
265
+ } catch (e) {
266
+ return entityError(e);
267
+ }
268
+ };
269
+
270
+ // #833 — comment-on-Task writes THROUGH to the linked GitHub issue (discussion lives in
271
+ // GitHub, never a relay table). Execution telemetry never reaches this path.
272
+ export const postTaskComment: Handler = async (req, params) => {
273
+ const id = parseId(params.id);
274
+ if (id === null) return error("invalid task id");
275
+ const parsed = await parseBody<Record<string, unknown>>(req);
276
+ if (!parsed.ok) return error(parsed.error, parsed.status);
277
+ const body = parsed.body;
278
+ if (!isRecord(body)) return error("JSON object body required");
279
+ try {
280
+ const result = await commentOnTaskThrough({
281
+ taskId: id,
282
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
283
+ clientId: cleanString(body.clientId, "clientId", { max: 240 }),
284
+ author: actorOf(req, body.author),
285
+ });
286
+ return json(result);
287
+ } catch (e) {
288
+ // Upstream GitHub write failed (well-formed request, no receipt written) → 502, not 400/500.
289
+ if (e instanceof CommentThroughError) return error(e.message, 502);
290
+ return entityError(e);
291
+ }
292
+ };
293
+
294
+ // #833 — read the #482/#474 close-bridge projection for a Task's linked issues.
295
+ export const getTaskCloseBridge: Handler = (_req, params) => {
296
+ const id = parseId(params.id);
297
+ if (id === null) return error("invalid task id");
298
+ try {
299
+ return json({ taskId: id, bridge: taskCloseBridge(id) });
300
+ } catch (e) {
301
+ return entityError(e);
302
+ }
303
+ };
304
+
305
+ function entityError(e: unknown): Response {
306
+ if (e instanceof ValidationError) {
307
+ return error(e.message, /not found/.test(e.message) ? 404 : 400);
308
+ }
309
+ throw e;
310
+ }
311
+
38
312
  export const getTaskById: Handler = (_req, params) => {
39
313
  const id = parseId(params.id);
40
314
  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
@@ -199,6 +199,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
199
199
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
200
200
  if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
201
201
  if (pathname === "/api/provider-quotas/advisory") return "agent:read";
202
+ if (pathname === "/api/provider-quotas/history") return "agent:read";
202
203
  if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
203
204
  if (pathname === "/api/routing-policy") return method === "GET" ? "agent:read" : "settings:write:routing";
204
205
  if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
@@ -286,6 +287,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
286
287
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
287
288
  if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
288
289
  if (pathname === "/api/provider-quotas/advisory") return "agent:read";
290
+ if (pathname === "/api/provider-quotas/history") return "agent:read";
289
291
  if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
290
292
  // Per-provider quota config (#605): reads are agent:read (the orchestrator
291
293
  // collector reads its config each tick); writes are settings:write:provider.
@@ -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