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,10 +1,12 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, listPlansWithBoundOwner, planThreadExists, replacePlan, ValidationError } from "../db";
2
+ import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, ValidationError } from "../db";
3
3
  import { associateIssue, planNodeIssueEntityId, removeAssociation } from "../db/issue-associations";
4
4
  import { resolveIssueRefFromString } from "../db/issue-refs";
5
5
  import { emitRelayEvent } from "../events";
6
6
  import { cleanString, cleanStringArray, optionalEnum } from "../validation";
7
7
  import { defaultIssueRepo } from "../config";
8
+ import { buildTaskGraphView, parseTaskId, planStatusToTaskStatus, projectPlanOverTasks } from "./task-plan-view";
9
+ import { createTaskEntity, setTaskEntityStatus } from "./task-entity";
8
10
  import type { AuthContext } from "./auth-context";
9
11
  import type { AgentCard, Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
10
12
 
@@ -82,8 +84,20 @@ function cleanRefs(value: unknown): PlanNode["refs"] | undefined {
82
84
  if (issueId) {
83
85
  out.issueId = resolveIssueRefFromString({ ref: issueId, defaultRepo: defaultIssueRepo() }).id;
84
86
  }
85
- const unsupported = ["agentId", "taskId", "landingRef"].filter((key) => cleanString(ref[key], `refs.${key}`, { max: 240 }));
86
- if (unsupported.length > 0) throw new ValidationError("agentId/taskId/landingRef refs are populated by live binding in slice c");
87
+ // #836: refs.taskId is the live binding seam to a durable Task. Validate it resolves to a
88
+ // deliverable Task; store the canonical numeric id as a string. Once bound, the node's
89
+ // label/scopeBlurb/status PROJECT from the Task (see projectPlanOverTasks) — no double-keeping.
90
+ const taskIdRaw = ref.taskId;
91
+ const taskIdStr = typeof taskIdRaw === "number" ? String(taskIdRaw) : cleanString(taskIdRaw, "refs.taskId", { max: 240 });
92
+ if (taskIdStr) {
93
+ const taskId = parseTaskId(taskIdStr);
94
+ if (taskId === undefined) throw new ValidationError("refs.taskId must be a positive task id");
95
+ if (!getTaskDetail(taskId)) throw new ValidationError(`refs.taskId ${taskId} is not a deliverable task`);
96
+ out.taskId = String(taskId);
97
+ }
98
+ // agentId/landingRef remain populated by live binding in later slices (#838); reject here.
99
+ const unsupported = ["agentId", "landingRef"].filter((key) => cleanString(ref[key], `refs.${key}`, { max: 240 }));
100
+ if (unsupported.length > 0) throw new ValidationError("agentId/landingRef refs are populated by live binding in a later slice");
87
101
  return Object.keys(out).length ? out : undefined;
88
102
  }
89
103
 
@@ -300,11 +314,39 @@ function optionalProp<K extends keyof PlanNode>(key: K, value: PlanNode[K] | und
300
314
  return value === undefined ? { [key]: undefined } as Partial<PlanNode> : { [key]: value } as Partial<PlanNode>;
301
315
  }
302
316
 
317
+ // The Task this node is bound to (#836), or undefined for an unbound node / missing node.
318
+ function boundTaskId(plan: Plan, nodeId: string): number | undefined {
319
+ const node = plan.nodes.find((item) => item.id === nodeId);
320
+ return node ? parseTaskId(node.refs?.taskId) : undefined;
321
+ }
322
+
323
+ // Cycle safety across BOTH edge stores (#836 review). Edges live in two places — the JSON
324
+ // `plan.edges` (legacy/mixed) and task_dependencies (bound pairs) — and each store's own
325
+ // checker sees only half the graph, so a cycle that SPANS the two (e.g. task edge A→B plus
326
+ // JSON edges B→C, C→A) would slip through. Validate the COMBINED projected graph (task deps ∪
327
+ // JSON edges) with the prospective edge before mutating either store. projectPlanOverTasks
328
+ // already unions both stores into `edges`, so this is the single source of truth for the graph.
329
+ function assertEdgeKeepsAcyclic(plan: Plan, edge: PlanEdge): void {
330
+ const projected = projectPlanOverTasks(plan);
331
+ validateEdges(projected.nodes, dedupeEdges([...projected.edges, edge]));
332
+ }
333
+
303
334
  export function addPlanEdge(planId: string, edgeInput: unknown, ctx: AuthContext): Plan {
304
335
  const plan = existingPlan(planId);
305
336
  const edge = cleanEdge(edgeInput);
337
+ assertEdgeKeepsAcyclic(plan, edge);
338
+ // #836: when BOTH endpoints are Task-bound, the canonical edge store is task_dependencies,
339
+ // not the plan JSON. Plan edge {from,to} means `to` depends on `from`. The combined-graph
340
+ // pre-check above already rejected cross-store cycles; the edge surfaces back via projection.
341
+ const fromTask = boundTaskId(plan, edge.from);
342
+ const toTask = boundTaskId(plan, edge.to);
343
+ if (fromTask !== undefined && toTask !== undefined) {
344
+ addTaskDependency(toTask, fromTask, actorId(ctx));
345
+ const projected = projectPlanOverTasks(existingPlan(planId));
346
+ emitPlan("plan.changed", projected, actorId(ctx));
347
+ return projected;
348
+ }
306
349
  const edges = dedupeEdges([...plan.edges, edge]);
307
- validateEdges(plan.nodes, edges);
308
350
  const updated = replacePlan({ ...plan, edges }, actorId(ctx));
309
351
  emitPlan("plan.changed", updated, actorId(ctx));
310
352
  return updated;
@@ -327,6 +369,15 @@ export function removePlanNode(planId: string, nodeId: string, ctx: AuthContext)
327
369
  export function removePlanEdge(planId: string, edgeInput: unknown, ctx: AuthContext): Plan {
328
370
  const plan = existingPlan(planId);
329
371
  const edge = cleanEdge(edgeInput);
372
+ // #836: a bound-pair edge lives in task_dependencies. Try that store first; fall through to
373
+ // the JSON path so a legacy edge stored before the nodes were bound is still removable.
374
+ const fromTask = boundTaskId(plan, edge.from);
375
+ const toTask = boundTaskId(plan, edge.to);
376
+ if (fromTask !== undefined && toTask !== undefined && removeTaskDependency(toTask, fromTask, actorId(ctx))) {
377
+ const projected = projectPlanOverTasks(existingPlan(planId));
378
+ emitPlan("plan.changed", projected, actorId(ctx));
379
+ return projected;
380
+ }
330
381
  const edges = plan.edges.filter((item) => item.from !== edge.from || item.to !== edge.to);
331
382
  if (edges.length === plan.edges.length) throw new ValidationError(`edge ${edge.from}->${edge.to} not found`);
332
383
  const updated = replacePlan({ ...plan, edges }, actorId(ctx));
@@ -367,11 +418,58 @@ export function updateBoundPlanNodesFromAgent(agentId: string, actor = "server")
367
418
  return status ? updateBoundPlanNodesForAgent(agentId, status, actor) : [];
368
419
  }
369
420
 
370
- export function getPlanGraph(input: { planId?: unknown; threadId?: unknown; teamId?: unknown; channel?: unknown }): Plan | null {
371
- const planId = cleanString(input.planId, "planId", { max: 120 });
372
- if (planId) return getPlan(planId);
421
+ // #836: `view: "tasks"` returns the pure task-backed DAG synthesized from a team's deliverable
422
+ // Tasks (no plan row needed) — the endgame view. Otherwise a stored plan is fetched and overlaid
423
+ // with its Task bindings via projectPlanOverTasks (coexistence: unbound plans pass through).
424
+ export function getPlanGraph(input: { planId?: unknown; threadId?: unknown; teamId?: unknown; channel?: unknown; view?: unknown }): Plan | null {
425
+ const view = cleanString(input.view, "view", { max: 40 });
373
426
  const teamId = cleanString(input.teamId, "teamId", { max: 120 });
374
- if (teamId) return getPlanByTeam(teamId, cleanString(input.channel, "channel", { max: 120 }));
427
+ if (view === "tasks") {
428
+ if (!teamId) throw new ValidationError("view 'tasks' requires teamId");
429
+ return buildTaskGraphView({ teamId });
430
+ }
431
+ const planId = cleanString(input.planId, "planId", { max: 120 });
432
+ if (planId) { const plan = getPlan(planId); return plan ? projectPlanOverTasks(plan) : null; }
433
+ if (teamId) { const plan = getPlanByTeam(teamId, cleanString(input.channel, "channel", { max: 120 })); return plan ? projectPlanOverTasks(plan) : null; }
375
434
  const threadId = cleanThreadId(input.threadId);
376
- return getPlanByThread(threadId, cleanString(input.channel, "channel", { max: 120 }));
435
+ const plan = getPlanByThread(threadId, cleanString(input.channel, "channel", { max: 120 }));
436
+ return plan ? projectPlanOverTasks(plan) : null;
437
+ }
438
+
439
+ // --- #836 migration: plan node → Task. The "no double-bookkeeping" path. Either links an
440
+ // existing deliverable Task, or mints one from the node's label/scopeBlurb/status and binds it.
441
+ // After binding, the node's display fields project from the Task (projectPlanOverTasks).
442
+ export interface BindPlanNodeInput {
443
+ taskId?: number;
444
+ }
445
+
446
+ export function bindPlanNodeToTask(planId: string, nodeId: string, input: BindPlanNodeInput, ctx: AuthContext): { plan: Plan; taskId: number } {
447
+ const plan = existingPlan(planId);
448
+ const node = plan.nodes.find((item) => item.id === nodeId);
449
+ if (!node) throw new ValidationError(`node ${nodeId} not found`);
450
+ if (node.refs?.taskId) throw new ValidationError(`node ${nodeId} is already bound to task ${node.refs.taskId}`);
451
+ const actor = actorId(ctx);
452
+ let taskId = input.taskId;
453
+ if (taskId === undefined) {
454
+ const created = createTaskEntity({
455
+ title: node.label,
456
+ ...(node.scopeBlurb ? { intent: node.scopeBlurb } : {}),
457
+ ...(plan.teamId ? { teamId: plan.teamId } : {}),
458
+ createdBy: actor,
459
+ });
460
+ taskId = created.id;
461
+ // Carry the plan node's coarse status onto the new Task so the migrated board reads true.
462
+ const status = planStatusToTaskStatus(node.status);
463
+ if (status !== "open") {
464
+ setTaskEntityStatus(taskId, {
465
+ status,
466
+ ...(status === "blocked" ? { blockedBy: [{ type: "external", note: "migrated from plan node" }] } : {}),
467
+ actor,
468
+ });
469
+ }
470
+ } else if (!getTaskDetail(taskId)) {
471
+ throw new ValidationError(`task ${taskId} not found or not a deliverable task`);
472
+ }
473
+ const updated = updatePlanNode(planId, nodeId, { refs: { ...(node.refs ?? {}), taskId: String(taskId) } }, ctx);
474
+ return { plan: projectPlanOverTasks(updated), taskId };
377
475
  }
@@ -0,0 +1,142 @@
1
+ import { getTaskDetail, listDeliverableTasks } from "../db/task-entity";
2
+ import type { TaskCommitRef, TaskDetail, TaskIssueLink } from "../types";
3
+
4
+ export type ReleaseBump = "none" | "patch" | "minor" | "major";
5
+
6
+ export interface ReleaseReadinessEvidence {
7
+ source: "task-title" | "issue-title" | "commit-subject" | "label" | "fallback";
8
+ value: string;
9
+ bump: ReleaseBump;
10
+ reason: string;
11
+ }
12
+
13
+ export interface ReleaseReadinessTask {
14
+ taskId: number;
15
+ title: string;
16
+ status: string;
17
+ deliverableStage: string;
18
+ issues: TaskIssueLink[];
19
+ commits: TaskCommitRef[];
20
+ commitRange?: string;
21
+ suggestedBump: ReleaseBump;
22
+ evidence: ReleaseReadinessEvidence[];
23
+ }
24
+
25
+ export interface ReleaseReadinessReport {
26
+ generatedAt: number;
27
+ unreleasedCount: number;
28
+ suggestedBump: ReleaseBump;
29
+ commitRange?: string;
30
+ tasks: ReleaseReadinessTask[];
31
+ }
32
+
33
+ export interface ReleaseReadinessOptions {
34
+ teamId?: string;
35
+ limit?: number;
36
+ }
37
+
38
+ const PATCH_TYPES = new Set(["fix", "chore", "docs", "refactor", "perf", "test", "build", "ci"]);
39
+
40
+ function bumpRank(bump: ReleaseBump): number {
41
+ if (bump === "major") return 3;
42
+ if (bump === "minor") return 2;
43
+ if (bump === "patch") return 1;
44
+ return 0;
45
+ }
46
+
47
+ function maxBump(values: ReleaseBump[]): ReleaseBump {
48
+ return values.reduce((best, bump) => bumpRank(bump) > bumpRank(best) ? bump : best, "none" as ReleaseBump);
49
+ }
50
+
51
+ function commitRange(commits: TaskCommitRef[]): string | undefined {
52
+ const shas = commits.map((commit) => commit.sha).filter(Boolean);
53
+ if (shas.length === 0) return undefined;
54
+ if (shas.length === 1) return shas[0];
55
+ return `${shas[0]}..${shas[shas.length - 1]}`;
56
+ }
57
+
58
+ function evidenceFromConventionalPrefix(source: ReleaseReadinessEvidence["source"], value: string): ReleaseReadinessEvidence | undefined {
59
+ const normalized = value.trim();
60
+ const match = normalized.match(/^([a-z][a-z0-9-]*)(?:\([^)]+\))?(!)?:/i);
61
+ if (!match) return undefined;
62
+ const type = match[1]!.toLowerCase();
63
+ const breaking = match[2] === "!";
64
+ if (breaking) return { source, value: normalized, bump: "major", reason: "conventional commit breaking marker" };
65
+ if (type === "feat") return { source, value: normalized, bump: "minor", reason: "conventional feat prefix" };
66
+ if (PATCH_TYPES.has(type)) return { source, value: normalized, bump: "patch", reason: `conventional ${type} prefix` };
67
+ return undefined;
68
+ }
69
+
70
+ function evidenceFromLabel(label: string): ReleaseReadinessEvidence | undefined {
71
+ const value = label.trim();
72
+ const normalized = value.toLowerCase();
73
+ if (!normalized) return undefined;
74
+ if (["breaking", "breaking-change", "major"].includes(normalized)) {
75
+ return { source: "label", value, bump: "major", reason: "release label" };
76
+ }
77
+ if (["feat", "feature", "minor"].includes(normalized)) {
78
+ return { source: "label", value, bump: "minor", reason: "release label" };
79
+ }
80
+ if (["fix", "bug", "bugfix", "chore", "patch"].includes(normalized)) {
81
+ return { source: "label", value, bump: "patch", reason: "release label" };
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ function labelsFromTask(task: TaskDetail): string[] {
87
+ const raw = task.metadata.labels ?? task.metadata.issueLabels ?? task.metadata.releaseLabels;
88
+ if (!Array.isArray(raw)) return [];
89
+ return raw.filter((item): item is string => typeof item === "string");
90
+ }
91
+
92
+ function classifyTask(task: TaskDetail): { bump: ReleaseBump; evidence: ReleaseReadinessEvidence[] } {
93
+ const evidence = [
94
+ evidenceFromConventionalPrefix("task-title", task.title),
95
+ ...task.issues.map((issue) => issue.title ? evidenceFromConventionalPrefix("issue-title", issue.title) : undefined),
96
+ ...task.commits?.map((commit) => commit.subject ? evidenceFromConventionalPrefix("commit-subject", commit.subject) : undefined) ?? [],
97
+ ...labelsFromTask(task).map(evidenceFromLabel),
98
+ ].filter((item): item is ReleaseReadinessEvidence => item !== undefined);
99
+
100
+ const bump = maxBump(evidence.map((item) => item.bump));
101
+ if (bump !== "none") return { bump, evidence };
102
+ return {
103
+ bump: "patch",
104
+ evidence: [{ source: "fallback", value: task.title, bump: "patch", reason: "landed deliverable without feat/fix/chore evidence" }],
105
+ };
106
+ }
107
+
108
+ export function releaseReadinessReport(options: ReleaseReadinessOptions = {}): ReleaseReadinessReport {
109
+ const landed = listDeliverableTasks({
110
+ deliverableStage: "landed-on-main",
111
+ teamId: options.teamId,
112
+ limit: options.limit ?? 500,
113
+ })
114
+ .map((task) => getTaskDetail(task.id))
115
+ .filter((task): task is TaskDetail => Boolean(task))
116
+ .filter((task) => task.deliverableStage === "landed-on-main");
117
+
118
+ const tasks = landed.map((task): ReleaseReadinessTask => {
119
+ const classification = classifyTask(task);
120
+ const commits = task.commits ?? [];
121
+ return {
122
+ taskId: task.id,
123
+ title: task.title,
124
+ status: task.status,
125
+ deliverableStage: task.deliverableStage ?? "landed-on-main",
126
+ issues: task.issues,
127
+ commits,
128
+ ...(commitRange(commits) ? { commitRange: commitRange(commits) } : {}),
129
+ suggestedBump: classification.bump,
130
+ evidence: classification.evidence,
131
+ };
132
+ });
133
+
134
+ const allCommits = tasks.flatMap((task) => task.commits);
135
+ return {
136
+ generatedAt: Date.now(),
137
+ unreleasedCount: tasks.length,
138
+ suggestedBump: tasks.length === 0 ? "none" : maxBump(tasks.map((task) => task.suggestedBump)),
139
+ ...(commitRange(allCommits) ? { commitRange: commitRange(allCommits) } : {}),
140
+ tasks,
141
+ };
142
+ }
@@ -24,6 +24,7 @@
24
24
  import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
25
25
  import {
26
26
  ValidationError,
27
+ getAgent,
27
28
  getMessage,
28
29
  listAgents,
29
30
  promoteLineageCapturedResponse,
@@ -185,6 +186,8 @@ export function sendMessageService(
185
186
  ?? spawnObligationAnsweredBy(input.from, input.to);
186
187
  if (answered !== null) input.replyTo = answered;
187
188
  }
189
+ const suppressedParent = suppressResultsOnlyAck(input);
190
+ if (suppressedParent) return { message: suppressedParent, created: false, receipt };
188
191
  const result = sendMessageWithResult(input);
189
192
  if (result.created) {
190
193
  if (result.message.deliveryStatus === "queued") {
@@ -207,3 +210,31 @@ export function sendMessageService(
207
210
  }
208
211
  return { message: result.message, created: result.created, receipt };
209
212
  }
213
+
214
+ function suppressResultsOnlyAck(input: SendMessageInput): Message | null {
215
+ if (input.kind && input.kind !== "chat" && input.kind !== "session") return null;
216
+ const sender = getAgent(input.from);
217
+ const profile = isRecord(sender?.meta?.agentProfile) ? sender.meta.agentProfile : undefined;
218
+ const notifications = isRecord(profile?.notifications) ? profile.notifications : undefined;
219
+ if (notifications?.responseMode !== "results-only") return null;
220
+ if (!isPassiveAck(input.body)) return null;
221
+ if (isRecord(input.payload?.responseCapture)) return null;
222
+ const session = isRecord(input.payload?.session) ? input.payload.session : undefined;
223
+ if (session?.final === true) return null;
224
+ return input.replyTo ? getMessage(input.replyTo) : null;
225
+ }
226
+
227
+ function isPassiveAck(body: string): boolean {
228
+ const text = body.toLowerCase().replace(/[.!]+/g, "").replace(/\s+/g, " ").trim();
229
+ return [
230
+ "noted",
231
+ "noted standing by",
232
+ "acknowledged",
233
+ "acknowledged standing by",
234
+ "standing by",
235
+ "ok standing by",
236
+ "okay standing by",
237
+ "understood",
238
+ "understood standing by",
239
+ ].includes(text);
240
+ }
@@ -25,7 +25,7 @@
25
25
  // deliberately out of scope here — this slice converges the two external TRANSPORTS.
26
26
  import type { SpawnProvider } from "agent-relay-sdk";
27
27
  import { DEFAULT_USER_ID, errMessage, isRecord, stringValue } from "agent-relay-sdk";
28
- import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, listAgents, listWorkspaces, resolveAgentOwnership, resolveProjectForCwd, resolveScopedAnchor, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
28
+ import { assignTaskAgent, countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, listAgents, listWorkspaces, recordTaskSpawnRequested, resolveAgentOwnership, resolveProjectForCwd, resolveScopedAnchor, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
29
29
  import { createCommand } from "../commands-db";
30
30
  import { emitCommandEvent } from "../command-events";
31
31
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -38,6 +38,8 @@ import type { ProjectSettingsPolicy } from "../project-settings";
38
38
  import type { ProjectSkillsPolicy } from "../project-skills";
39
39
  import { applyDefaultSharedMcp, type SharedMcpSpawnOverride } from "../shared-mcp";
40
40
  import { autoSeedProjectForCwd } from "./project";
41
+ import { hydrateTaskContext, projectTaskHydration } from "./task-context-hydration";
42
+ import { dispatchTask } from "../task-dispatcher";
41
43
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
42
44
  import { isPathWithinBase } from "../utils";
43
45
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
@@ -70,6 +72,16 @@ export interface SpawnAgentInput {
70
72
  profile?: string;
71
73
  prompt?: string;
72
74
  systemPromptAppend?: string;
75
+ /**
76
+ * #838 — spawn-with-task. When set, relay hydrates the worker's FIRST-TURN context FROM the
77
+ * durable Task #834 (intent + linked-issue spec/acceptance + recorded decisions + history +
78
+ * attribution) instead of the caller hand-writing a brief, records the spawn on the Task for
79
+ * attribution, and (when model/effort are omitted) MAY resolve them from the Task's dispatch
80
+ * work-shape (#839). Absent = today's behavior, byte-identical.
81
+ */
82
+ taskId?: number;
83
+ /** #838 — the latest one-line steer; the actionable instruction layered over the hydrated Task context. */
84
+ steer?: string;
73
85
  /**
74
86
  * #409 — project instruction projection. When set, relay composes an instruction
75
87
  * preamble from the spawn's resolved project (own entries + inherited `contains`-
@@ -194,7 +206,45 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
194
206
  const inheritedCwd = callerCwd && isPathWithinBase(callerCwd, orchestrator.baseDir) ? callerCwd : undefined;
195
207
  const resolvedCwd = input.cwd || inheritedCwd || orchestrator.baseDir;
196
208
 
197
- const modelParams = resolveSpawnModelParams(input.provider, input.model, input.effort);
209
+ // #838 spawn-with-task hydration. When a taskId is given, relay assembles the worker's
210
+ // first-turn context FROM the durable Task (intent + linked-issue spec/acceptance + recorded
211
+ // decisions + history + latest steer), resolved server-side ONCE, and projects it onto the
212
+ // provider-appropriate channel (Claude → systemPromptAppend; Codex/other → first message). The
213
+ // caller-supplied systemPromptAppend/prompt are preserved. Absent (no taskId) = byte-identical
214
+ // to today: `resolvedSystemPromptAppend`/`resolvedPrompt` stay the raw inputs.
215
+ let resolvedSystemPromptAppend = input.systemPromptAppend;
216
+ let resolvedPrompt = input.prompt;
217
+ let hydratedTask: Awaited<ReturnType<typeof hydrateTaskContext>> | undefined;
218
+ if (input.taskId !== undefined) {
219
+ hydratedTask = await hydrateTaskContext({ taskId: input.taskId, steer: input.steer });
220
+ const projection = projectTaskHydration(
221
+ input.provider,
222
+ hydratedTask.context,
223
+ hydratedTask.steer,
224
+ input.systemPromptAppend,
225
+ input.prompt,
226
+ );
227
+ resolvedSystemPromptAppend = projection.systemPromptAppend;
228
+ resolvedPrompt = projection.prompt;
229
+ }
230
+
231
+ // #838/#839 — OPTIONAL dispatch composition: when a Task is given and the caller pinned NEITHER
232
+ // model NOR effort, resolve them from the Task's dispatch work-shape (provider stays the explicit
233
+ // pin). Best-effort: a dispatch failure never blocks the spawn. Explicit model/effort always win,
234
+ // and a no-taskId spawn is untouched — the coupling is opt-in, not hard-wired.
235
+ let dispatchModel = input.model;
236
+ let dispatchEffort = input.effort;
237
+ if (hydratedTask && !input.model && !input.effort && hydratedTask.task.dispatch) {
238
+ try {
239
+ const decision = dispatchTask(hydratedTask.task.dispatch, { provider: input.provider });
240
+ dispatchModel = decision.target.model ?? dispatchModel;
241
+ dispatchEffort = decision.target.effort ?? dispatchEffort;
242
+ } catch (err) {
243
+ console.warn(`task ${input.taskId} dispatch resolution skipped: ${errMessage(err)}`);
244
+ }
245
+ }
246
+
247
+ const modelParams = resolveSpawnModelParams(input.provider, dispatchModel, dispatchEffort);
198
248
 
199
249
  // #331 — default the child's approval mode to the CALLER's, not a hardcoded `guarded` (a
200
250
  // headless `guarded` child wedges on its first tool-call approval prompt). Explicit always
@@ -333,7 +383,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
333
383
  ...(userAnchor ? { userAnchor } : {}),
334
384
  ...(agentProfile ? { profile: agentProfile } : {}),
335
385
  ...(callerId ? { spawnedBy: callerId } : {}),
336
- ...(input.systemPromptAppend ? { callerSystemPromptAppend: input.systemPromptAppend } : {}),
386
+ ...(resolvedSystemPromptAppend ? { callerSystemPromptAppend: resolvedSystemPromptAppend } : {}),
337
387
  });
338
388
  const systemPromptAppend = composedInstructions.systemPromptAppend;
339
389
  // TODO(#569): comms:delivered emitter — deferred, lives on the #575 delivery path.
@@ -384,7 +434,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
384
434
  approvalMode,
385
435
  permissionMode: approvalMode,
386
436
  providerArgs: input.providerArgs ?? [],
387
- prompt: input.prompt,
437
+ prompt: resolvedPrompt,
388
438
  systemPromptAppend,
389
439
  relayInjectionEvents: composedInstructions.relayInjectionEvents,
390
440
  landStrategy: input.landStrategy,
@@ -431,10 +481,37 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
431
481
  },
432
482
  });
433
483
 
484
+ // #838 — record the spawn on the Task FOR attribution, keyed on spawnRequestId. Done now (not
485
+ // after registration) so even a fire-and-forget spawn leaves the durable hook #831 reads to
486
+ // drive one-shot reap. Best-effort: an attribution write never fails the spawn.
487
+ if (input.taskId !== undefined) {
488
+ try {
489
+ recordTaskSpawnRequested(input.taskId, {
490
+ spawnRequestId,
491
+ provider: input.provider,
492
+ requestedBy,
493
+ ...(hydratedTask?.steer ? { steer: hydratedTask.steer } : {}),
494
+ });
495
+ } catch (err) {
496
+ console.warn(`task ${input.taskId} spawn attribution skipped: ${errMessage(err)}`);
497
+ }
498
+ }
499
+
434
500
  // #255 — resolve the spawned child's id once it registers back to THIS relay (same DB) with
435
501
  // meta.spawnRequestId set. Bounded poll; 0 opts out (pure fire-and-forget, e.g. the HTTP 202).
436
502
  const waitMs = Math.min(input.waitForRegistrationMs ?? 0, 30_000);
437
503
  const agentId = waitMs > 0 ? await waitForSpawnedAgent(spawnRequestId, waitMs) : null;
504
+
505
+ // #838 — once the child's id is known (waitForRegistrationMs > 0), stamp it as the Task owner
506
+ // via the #834 attribution API. Fire-and-forget spawns resolve the id later; #831 closes that
507
+ // gap by reading the spawn_requested event above. Best-effort, never fails the spawn.
508
+ if (input.taskId !== undefined && agentId) {
509
+ try {
510
+ assignTaskAgent(input.taskId, agentId, "worker", requestedBy);
511
+ } catch (err) {
512
+ console.warn(`task ${input.taskId} owner attribution skipped: ${errMessage(err)}`);
513
+ }
514
+ }
438
515
  return {
439
516
  ok: true,
440
517
  spawnRequestId,