agent-relay-server 0.109.0 → 0.111.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 (95) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-XgN61quY.js → MessageBubble-WYqSyKPM.js} +2 -2
  4. package/public/assets/{MessageBubble-XgN61quY.js.map → MessageBubble-WYqSyKPM.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-Dvu17jGe.js +2 -0
  6. package/public/assets/PlanGraphPanel-Dvu17jGe.js.map +1 -0
  7. package/public/assets/{activity-D0EotVU9.js → activity-wAN_upn7.js} +2 -2
  8. package/public/assets/{activity-D0EotVU9.js.map → activity-wAN_upn7.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CAGvryb9.js → agent-profiles-D8WLdTCs.js} +2 -2
  10. package/public/assets/{agent-profiles-CAGvryb9.js.map → agent-profiles-D8WLdTCs.js.map} +1 -1
  11. package/public/assets/{agents-CXXZ_sU9.js → agents-C5FajNPw.js} +2 -2
  12. package/public/assets/{agents-CXXZ_sU9.js.map → agents-C5FajNPw.js.map} +1 -1
  13. package/public/assets/{automation-zig0cp23.js → automation-ShdXg0pX.js} +2 -2
  14. package/public/assets/{automation-zig0cp23.js.map → automation-ShdXg0pX.js.map} +1 -1
  15. package/public/assets/chat-aRCk0pLJ.js +2 -0
  16. package/public/assets/{chat-C3QlsFBd.js.map → chat-aRCk0pLJ.js.map} +1 -1
  17. package/public/assets/{formatted-body-impl-DCQSVyWB.js → formatted-body-impl-DZc7C4-2.js} +2 -2
  18. package/public/assets/{formatted-body-impl-DCQSVyWB.js.map → formatted-body-impl-DZc7C4-2.js.map} +1 -1
  19. package/public/assets/{index-D6hQkte1.css → index-D-Ntiy3M.css} +1 -1
  20. package/public/assets/{index-RCbIyk26.js → index-DXEYz_JV.js} +13 -13
  21. package/public/assets/index-DXEYz_JV.js.map +1 -0
  22. package/public/assets/{insights-CsLs6NbL.js → insights-FzTX2wNh.js} +2 -2
  23. package/public/assets/{insights-CsLs6NbL.js.map → insights-FzTX2wNh.js.map} +1 -1
  24. package/public/assets/{maintenance-BN5F0Lnc.js → maintenance-C8Uw8XiI.js} +2 -2
  25. package/public/assets/{maintenance-BN5F0Lnc.js.map → maintenance-C8Uw8XiI.js.map} +1 -1
  26. package/public/assets/{managed-agents-R_LI9qS5.js → managed-agents-DVbJ2hqh.js} +2 -2
  27. package/public/assets/{managed-agents-R_LI9qS5.js.map → managed-agents-DVbJ2hqh.js.map} +1 -1
  28. package/public/assets/{markdown-preview-impl-DQlmK_ev.js → markdown-preview-impl-C5D-B-9S.js} +2 -2
  29. package/public/assets/{markdown-preview-impl-DQlmK_ev.js.map → markdown-preview-impl-C5D-B-9S.js.map} +1 -1
  30. package/public/assets/{memory-kn4bgMXC.js → memory-Bp-D_V80.js} +2 -2
  31. package/public/assets/{memory-kn4bgMXC.js.map → memory-Bp-D_V80.js.map} +1 -1
  32. package/public/assets/{messages-Dv3Tv7zC.js → messages-DE1QSeHI.js} +2 -2
  33. package/public/assets/{messages-Dv3Tv7zC.js.map → messages-DE1QSeHI.js.map} +1 -1
  34. package/public/assets/{orchestrators-DcNLRMzk.js → orchestrators-B0u2lQfw.js} +2 -2
  35. package/public/assets/{orchestrators-DcNLRMzk.js.map → orchestrators-B0u2lQfw.js.map} +1 -1
  36. package/public/assets/{overview-BOLzPSSC.js → overview-DpaEvvLT.js} +2 -2
  37. package/public/assets/{overview-BOLzPSSC.js.map → overview-DpaEvvLT.js.map} +1 -1
  38. package/public/assets/{pairs-B4r0egpx.js → pairs-DRp_qAu8.js} +2 -2
  39. package/public/assets/{pairs-B4r0egpx.js.map → pairs-DRp_qAu8.js.map} +1 -1
  40. package/public/assets/{prompt-settings-E7-6Osj4.js → prompt-settings-CNoBiymk.js} +2 -2
  41. package/public/assets/{prompt-settings-E7-6Osj4.js.map → prompt-settings-CNoBiymk.js.map} +1 -1
  42. package/public/assets/{registry-CTN3jOnb.js → registry-0A4QFMWC.js} +2 -2
  43. package/public/assets/{registry-CTN3jOnb.js.map → registry-0A4QFMWC.js.map} +1 -1
  44. package/public/assets/{security-BctIcBZb.js → security-C9P6yiQ3.js} +2 -2
  45. package/public/assets/{security-BctIcBZb.js.map → security-C9P6yiQ3.js.map} +1 -1
  46. package/public/assets/{select-BndvVb0A.js → select-B1PVUj6D.js} +2 -2
  47. package/public/assets/{select-BndvVb0A.js.map → select-B1PVUj6D.js.map} +1 -1
  48. package/public/assets/{settings-B_tRq-P4.js → settings-TnHlB9-t.js} +2 -2
  49. package/public/assets/{settings-B_tRq-P4.js.map → settings-TnHlB9-t.js.map} +1 -1
  50. package/public/assets/{tasks-BgTTxVET.js → tasks-1oK_ggFC.js} +2 -2
  51. package/public/assets/{tasks-BgTTxVET.js.map → tasks-1oK_ggFC.js.map} +1 -1
  52. package/public/assets/{teams-DJiLBEgF.js → teams-BBb0ZYiF.js} +2 -2
  53. package/public/assets/{teams-DJiLBEgF.js.map → teams-BBb0ZYiF.js.map} +1 -1
  54. package/public/assets/{terminal-viewer-impl-BLdiyCNR.js → terminal-viewer-impl-DzvaRjkp.js} +2 -2
  55. package/public/assets/{terminal-viewer-impl-BLdiyCNR.js.map → terminal-viewer-impl-DzvaRjkp.js.map} +1 -1
  56. package/public/assets/types-DSwhOFcJ.js.map +1 -1
  57. package/public/assets/{user-avatar-rt59cEsc.js → user-avatar-X1pfizLv.js} +2 -2
  58. package/public/assets/{user-avatar-rt59cEsc.js.map → user-avatar-X1pfizLv.js.map} +1 -1
  59. package/public/assets/{work-queue-BYRdBuLR.js → work-queue-DQMbkSD8.js} +2 -2
  60. package/public/assets/{work-queue-BYRdBuLR.js.map → work-queue-DQMbkSD8.js.map} +1 -1
  61. package/public/assets/{workspaces-DCRT3l9H.js → workspaces-BNLP4IlV.js} +3 -3
  62. package/public/assets/{workspaces-DCRT3l9H.js.map → workspaces-BNLP4IlV.js.map} +1 -1
  63. package/public/index.html +2 -2
  64. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  65. package/runner/src/config.ts +5 -0
  66. package/src/bus.ts +3 -1
  67. package/src/db/agents.ts +3 -0
  68. package/src/db/merge-lease.ts +5 -1
  69. package/src/db/notification-subscriptions.ts +15 -0
  70. package/src/db/task-detail-projection.ts +66 -0
  71. package/src/db/task-entity.ts +53 -0
  72. package/src/mcp/tools-spawn.ts +1 -1
  73. package/src/mcp-dispatch-tools.ts +4 -1
  74. package/src/mcp-notification-subscription-tools.ts +2 -1
  75. package/src/notification-rules.ts +17 -1
  76. package/src/notification-types.ts +18 -0
  77. package/src/reviewer-pipeline.ts +13 -0
  78. package/src/routes/agents-spawn.ts +3 -2
  79. package/src/routes/agents.ts +2 -0
  80. package/src/routes/commands.ts +14 -0
  81. package/src/routes/index.ts +2 -1
  82. package/src/routes/tasks.ts +19 -3
  83. package/src/runtime-tokens.ts +4 -0
  84. package/src/security.ts +4 -0
  85. package/src/services/register-agent.ts +46 -2
  86. package/src/services/spawn-agent.ts +116 -30
  87. package/src/services/task-entity.ts +28 -6
  88. package/src/services/task-lifecycle.ts +36 -7
  89. package/src/services/task-semantic-events.ts +241 -13
  90. package/src/spawn-command.ts +3 -0
  91. package/src/workspace-auto-merge.ts +17 -0
  92. package/public/assets/PlanGraphPanel-LY-msOsr.js +0 -2
  93. package/public/assets/PlanGraphPanel-LY-msOsr.js.map +0 -1
  94. package/public/assets/chat-C3QlsFBd.js +0 -2
  95. package/public/assets/index-RCbIyk26.js.map +0 -1
@@ -25,7 +25,9 @@
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 { assignTaskAgent, countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, listAgents, listWorkspaces, recordTaskSpawnRequested, resolveAgentOwnership, resolveProjectForCwd, resolveScopedAnchor, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
28
+ import { statSync } from "node:fs";
29
+ import { dirname, resolve } from "node:path";
30
+ import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, getTaskDetail, listAgents, listProjects, listWorkspaces, recordTaskSpawnRequested, resolveAgentOwnership, resolveProjectForCwd, resolveScopedAnchor, rootsForProject, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
29
31
  import { createCommand } from "../commands-db";
30
32
  import { emitCommandEvent } from "../command-events";
31
33
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -39,6 +41,8 @@ import type { ProjectSkillsPolicy } from "../project-skills";
39
41
  import { applyDefaultSharedMcp, type SharedMcpSpawnOverride } from "../shared-mcp";
40
42
  import { autoSeedProjectForCwd } from "./project";
41
43
  import { hydrateTaskContext, projectTaskHydration } from "./task-context-hydration";
44
+ import { recordTaskEntityDispatchDecision } from "./task-entity";
45
+ import { autoSubscribeTaskCoordinator } from "./task-semantic-events";
42
46
  import { dispatchTask } from "../task-dispatcher";
43
47
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
44
48
  import { isPathWithinBase } from "../utils";
@@ -51,6 +55,60 @@ import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
51
55
  /** The spawn scope every transport must hold (admin `*` / `admin:*` pass; `system` bypasses). */
52
56
  const SPAWN_SCOPE = "command:spawn";
53
57
 
58
+ function hasGitMarker(dir: string): boolean {
59
+ try {
60
+ const marker = statSync(`${dir}/.git`);
61
+ return marker.isDirectory() || marker.isFile();
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function findGitRepoRoot(cwd: string): string | null {
68
+ if (!cwd.trim()) return null;
69
+ let current = resolve(cwd);
70
+ try {
71
+ const stat = statSync(current);
72
+ if (!stat.isDirectory()) current = dirname(current);
73
+ } catch {
74
+ return null;
75
+ }
76
+ for (;;) {
77
+ if (hasGitMarker(current)) return current;
78
+ const parent = dirname(current);
79
+ if (parent === current) return null;
80
+ current = parent;
81
+ }
82
+ }
83
+
84
+ function assertIsolatedSpawnHasGitRepo(cwd: string): void {
85
+ if (findGitRepoRoot(cwd)) return;
86
+ throw new ValidationError(`workspaceMode "isolated" requires a git repo, but ${cwd} is not one. Pass an explicit cwd under a repo, or use workspaceMode "shared"/"inherit".`);
87
+ }
88
+
89
+ function taskLinkedRepoCwd(taskId: number, callerCwd?: string): string {
90
+ const task = getTaskDetail(taskId);
91
+ if (!task) throw new ValidationError(`task ${taskId} not found or not a deliverable task`);
92
+ const repos = [...new Set(task.issues.map((issue) => issue.repo).filter((repo): repo is string => Boolean(repo)))];
93
+ if (repos.length === 0) {
94
+ throw new ValidationError(`task ${taskId} has no linked issue repo; pass cwd/orchestratorId explicitly`);
95
+ }
96
+ const candidates = listProjects()
97
+ .filter((project) => project.issueRepo && repos.includes(project.issueRepo))
98
+ .flatMap((project) => rootsForProject(project.id).map((root) => ({ project, root })));
99
+ if (candidates.length === 0) {
100
+ throw new ValidationError(`task ${taskId} links repo ${repos.join(", ")} but no Relay project root is registered for it; pass cwd/orchestratorId explicitly or add a project root`);
101
+ }
102
+ const matchingCallerRoot = callerCwd
103
+ ? candidates
104
+ .filter((candidate) => isPathWithinBase(callerCwd, candidate.root.rootPath))
105
+ .sort((a, b) => b.root.rootPath.length - a.root.rootPath.length)[0]
106
+ : undefined;
107
+ if (matchingCallerRoot) return matchingCallerRoot.root.rootPath;
108
+ if (candidates.length === 1) return candidates[0]!.root.rootPath;
109
+ throw new ValidationError(`task ${taskId} links repo ${repos.join(", ")} with multiple registered roots; pass cwd explicitly`);
110
+ }
111
+
54
112
  /**
55
113
  * A normalized spawn request — every transport validates its own wire shape (enums,
56
114
  * lengths) and hands the service this. Caller-context defaults (cwd, approvalMode,
@@ -177,6 +235,19 @@ function canSpawn(ctx: AuthContext): boolean {
177
235
  return ctx.scopes.includes(SPAWN_SCOPE);
178
236
  }
179
237
 
238
+ function agentIdFromTaskCreator(createdBy: string | undefined): string | undefined {
239
+ const raw = createdBy?.trim();
240
+ if (!raw) return undefined;
241
+ const candidates = [raw, raw.startsWith("agent:") ? raw.slice("agent:".length) : undefined].filter((value): value is string => Boolean(value));
242
+ return candidates.find((candidate) => getAgent(candidate)) ?? undefined;
243
+ }
244
+
245
+ function taskSpawnLabel(task: { id: number; title: string } | undefined, fallback?: string): string | undefined {
246
+ if (fallback || !task) return fallback;
247
+ const label = `task-${task.id}: ${task.title.trim() || "untitled"}`;
248
+ return label.length <= 120 ? label : `${label.slice(0, 117)}...`;
249
+ }
250
+
180
251
  /**
181
252
  * Spawn a provider agent. Transport-agnostic: every gate + side effect lives here so the
182
253
  * MCP and HTTP paths cannot drift. Throws `McpAuthError` (→403) on a gate denial,
@@ -189,22 +260,30 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
189
260
  // the #221 gate AND the #328/#331/#324 caller-context inheritance — one lookup, reused.
190
261
  const callerId = ctx.callerAgentId;
191
262
  const caller: AgentCard | undefined = (callerId ? getAgent(callerId) : undefined) ?? undefined;
263
+ const linkedTask = input.taskId !== undefined ? getTaskDetail(input.taskId) ?? undefined : undefined;
264
+ const taskCreatorAgentId = !callerId ? agentIdFromTaskCreator(linkedTask?.createdBy) : undefined;
265
+ const lineageAgentId = callerId ?? taskCreatorAgentId;
266
+ const lineageAgent: AgentCard | undefined = (lineageAgentId ? getAgent(lineageAgentId) : undefined) ?? undefined;
267
+ const callerCwd = stringValue(caller?.meta?.cwd) ?? stringValue(lineageAgent?.meta?.cwd);
268
+ const taskDefaultCwd = input.taskId !== undefined && !input.orchestratorId && !input.cwd
269
+ ? taskLinkedRepoCwd(input.taskId, callerCwd)
270
+ : undefined;
271
+ const requestedCwd = input.cwd ?? taskDefaultCwd;
192
272
 
193
273
  // #255/#328 — bias the host to the caller's own machine when neither an explicit
194
274
  // orchestratorId nor a cwd pins it, so an agent spawning a helper lands on its own host.
195
- const orchestrator = selectSpawnOrchestrator(input.provider, input.orchestratorId, input.cwd, caller?.machine);
275
+ const orchestrator = selectSpawnOrchestrator(input.provider, input.orchestratorId, requestedCwd, caller?.machine ?? lineageAgent?.machine);
196
276
 
197
277
  // #308 §3 — an explicit cwd must resolve within the TARGET host's base dir (a path valid on
198
278
  // your host may not exist on a different orchestrator).
199
- if (input.cwd && !isPathWithinBase(input.cwd, orchestrator.baseDir)) {
200
- throw new ValidationError(`cwd '${input.cwd}' is not within ${orchestrator.id} (host ${orchestrator.hostname})'s base dir '${orchestrator.baseDir}' — a path valid on your host may not exist on the target. Pass a cwd under that base dir, or omit cwd to default to it.`);
279
+ if (requestedCwd && !isPathWithinBase(requestedCwd, orchestrator.baseDir)) {
280
+ throw new ValidationError(`cwd '${requestedCwd}' is not within ${orchestrator.id} (host ${orchestrator.hostname})'s base dir '${orchestrator.baseDir}' — a path valid on your host may not exist on the target. Pass a cwd under that base dir, or omit cwd to default to it.`);
201
281
  }
202
282
  // #328 — default cwd to the caller's OWN repo (so "spawn a helper for my current task" Just
203
283
  // Works, esp. isolated mode which needs a git repo), but only when it resolves within the
204
284
  // target host's base. Precedence: explicit cwd > caller cwd > base dir.
205
- const callerCwd = stringValue(caller?.meta?.cwd);
206
285
  const inheritedCwd = callerCwd && isPathWithinBase(callerCwd, orchestrator.baseDir) ? callerCwd : undefined;
207
- const resolvedCwd = input.cwd || inheritedCwd || orchestrator.baseDir;
286
+ const resolvedCwd = requestedCwd || inheritedCwd || orchestrator.baseDir;
208
287
 
209
288
  // #838 — spawn-with-task hydration. When a taskId is given, relay assembles the worker's
210
289
  // first-turn context FROM the durable Task (intent + linked-issue spec/acceptance + recorded
@@ -234,9 +313,11 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
234
313
  // and a no-taskId spawn is untouched — the coupling is opt-in, not hard-wired.
235
314
  let dispatchModel = input.model;
236
315
  let dispatchEffort = input.effort;
316
+ let taskDispatchDecision: ReturnType<typeof dispatchTask> | undefined;
237
317
  if (hydratedTask && !input.model && !input.effort && hydratedTask.task.dispatch) {
238
318
  try {
239
319
  const decision = dispatchTask(hydratedTask.task.dispatch, { provider: input.provider });
320
+ taskDispatchDecision = decision;
240
321
  dispatchModel = decision.target.model ?? dispatchModel;
241
322
  dispatchEffort = decision.target.effort ?? dispatchEffort;
242
323
  } catch (err) {
@@ -245,18 +326,23 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
245
326
  }
246
327
 
247
328
  const modelParams = resolveSpawnModelParams(input.provider, dispatchModel, dispatchEffort);
329
+ const resolvedLabel = taskSpawnLabel(linkedTask, input.label);
248
330
 
249
331
  // #331 — default the child's approval mode to the CALLER's, not a hardcoded `guarded` (a
250
332
  // headless `guarded` child wedges on its first tool-call approval prompt). Explicit always
251
333
  // wins and can NARROW a child; non-agent callers keep the safe `guarded` default.
252
- const callerApprovalMode = caller?.meta?.approvalMode as SpawnApprovalMode | undefined;
253
- const approvalMode = input.approvalMode ?? callerApprovalMode ?? "guarded";
334
+ const callerApprovalMode = (caller?.meta?.approvalMode ?? lineageAgent?.meta?.approvalMode) as SpawnApprovalMode | undefined;
335
+ const approvalMode = input.approvalMode ?? callerApprovalMode ?? (input.taskId !== undefined ? "open" : "guarded");
254
336
 
255
337
  // #324 — agent-initiated spawns (a real caller behind the token) default to `isolated` (a
256
338
  // branch worker that auto-lands); non-agent callers (admin/dashboard) keep the orchestrator's
257
- // `inherit` fallback. Explicit wins.
258
- const workspaceMode = input.workspaceMode ?? (callerId ? ("isolated" as WorkspaceMode) : undefined);
339
+ // `inherit` fallback. Task-triggered spawns are coordinator work and should get an isolated
340
+ // branch unless explicitly narrowed.
341
+ const workspaceMode = input.workspaceMode ?? (callerId || input.taskId !== undefined ? ("isolated" as WorkspaceMode) : undefined);
259
342
  const lifecycle = input.lifecycle ?? "persistent";
343
+ if (workspaceMode === "isolated" && (input.taskId !== undefined || input.workspaceMode === "isolated")) {
344
+ assertIsolatedSpawnHasGitRepo(resolvedCwd);
345
+ }
260
346
 
261
347
  const spawnRequestId = input.spawnRequestId ?? generateSpawnRequestId();
262
348
 
@@ -344,9 +430,9 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
344
430
 
345
431
  // Provenance actor: the resolved caller agent when known, else the transport's label, else
346
432
  // the raw token actor. Recorded in the command payload, the token mint, and the audit row.
347
- const requestedBy = callerId ?? input.requestedBy ?? ctx.actor.id;
348
- const callerTeam = callerId ? (caller?.teamId ? { id: caller.teamId } : ensureTeam(callerId)) : undefined;
349
- if (callerId && callerTeam && !caller?.teamId) setAgentTeam(callerId, callerTeam.id);
433
+ const requestedBy = callerId ?? taskCreatorAgentId ?? input.requestedBy ?? ctx.actor.id;
434
+ const callerTeam = lineageAgentId ? (lineageAgent?.teamId ? { id: lineageAgent.teamId } : ensureTeam(lineageAgentId)) : undefined;
435
+ if (lineageAgentId && callerTeam && !lineageAgent?.teamId) setAgentTeam(lineageAgentId, callerTeam.id);
350
436
 
351
437
  // #405 — durable project binding: the project whose registered root contains the spawn cwd.
352
438
  // Stamped on the child token's constraints (identity-only, like teamId) so callerProject(auth)
@@ -374,7 +460,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
374
460
  // Both resolve null until someone has actually written to them, so a fresh deployment stays
375
461
  // byte-identical. Injection of their active (promoted) entries happens in the composer.
376
462
  const profileAnchor = resolveScopedAnchor("profile", profileName);
377
- const operatorId = callerId ? resolveAgentOwnership(callerId).ownerUserId : DEFAULT_USER_ID;
463
+ const operatorId = lineageAgentId ? resolveAgentOwnership(lineageAgentId).ownerUserId : DEFAULT_USER_ID;
378
464
  const userAnchor = resolveScopedAnchor("user", operatorId);
379
465
  const composedInstructions = composeSpawnInstructionsWithEvents({
380
466
  ...(callerProject ? { callerProject } : {}),
@@ -382,7 +468,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
382
468
  ...(profileAnchor ? { profileAnchor } : {}),
383
469
  ...(userAnchor ? { userAnchor } : {}),
384
470
  ...(agentProfile ? { profile: agentProfile } : {}),
385
- ...(callerId ? { spawnedBy: callerId } : {}),
471
+ ...(lineageAgentId ? { spawnedBy: lineageAgentId } : {}),
386
472
  ...(resolvedSystemPromptAppend ? { callerSystemPromptAppend: resolvedSystemPromptAppend } : {}),
387
473
  });
388
474
  const systemPromptAppend = composedInstructions.systemPromptAppend;
@@ -395,9 +481,10 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
395
481
  orchestratorId: orchestrator.id,
396
482
  cwd: resolvedCwd,
397
483
  provider: input.provider,
398
- label: input.label,
484
+ label: resolvedLabel,
399
485
  policyName: input.policyName,
400
486
  spawnRequestId,
487
+ taskId: input.taskId,
401
488
  createdBy: requestedBy,
402
489
  // Thread the resolved profile into the mint so the spawn grant (command:spawn/shutdown +
403
490
  // maxSpawnedAgents quota) is honored — parity with the HTTP path (agent-sessions.ts). #349
@@ -405,7 +492,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
405
492
  // (P0 regression #364). For an agent caller, agentInitiated still forces a non-spawn-capable
406
493
  // child regardless of profile (no grandchildren).
407
494
  profile: profileName,
408
- ...(callerId ? { agentInitiated: true, spawnedBy: callerId } : {}),
495
+ ...(lineageAgentId ? { agentInitiated: true, spawnedBy: lineageAgentId } : {}),
409
496
  ...(callerTeam ? { teamId: callerTeam.id } : {}),
410
497
  ...(callerProject ? { projectId: callerProject.id } : {}),
411
498
  });
@@ -426,7 +513,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
426
513
  cwd: resolvedCwd,
427
514
  ...(workspaceMode ? { workspaceMode } : {}),
428
515
  lifecycle,
429
- label: input.label,
516
+ label: resolvedLabel,
430
517
  profile: profileName,
431
518
  agentProfile,
432
519
  tags: input.tags ?? [],
@@ -440,6 +527,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
440
527
  landStrategy: input.landStrategy,
441
528
  autoMerge: input.autoMerge,
442
529
  reviewer: input.reviewer,
530
+ taskId: input.taskId,
443
531
  resumeWorkspace: input.resumeWorkspace,
444
532
  ...(expectReply ? { expectReply } : {}),
445
533
  policyName: input.policyName,
@@ -451,7 +539,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
451
539
  orchestratorId: orchestrator.id,
452
540
  // #308 — stamp the spawning parent so a FAILED agent.spawn command (child never registers,
453
541
  // so there's no agent row to resolve `spawnedBy` from) can be routed back to it.
454
- ...(callerId ? { extra: { spawnedBy: callerId } } : {}),
542
+ ...(lineageAgentId ? { extra: { spawnedBy: lineageAgentId } } : {}),
455
543
  }),
456
544
  });
457
545
  emitCommandEvent(command, "command.requested");
@@ -472,7 +560,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
472
560
  approvalMode,
473
561
  lifecycle,
474
562
  workspaceMode: workspaceMode ?? null,
475
- label: input.label ?? null,
563
+ label: resolvedLabel ?? null,
476
564
  commandId: command.id,
477
565
  spawnRequestId,
478
566
  requestedVia: input.requestedVia ?? null,
@@ -486,12 +574,20 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
486
574
  // drive one-shot reap. Best-effort: an attribution write never fails the spawn.
487
575
  if (input.taskId !== undefined) {
488
576
  try {
577
+ if (taskDispatchDecision) {
578
+ recordTaskEntityDispatchDecision(input.taskId, {
579
+ decision: taskDispatchDecision,
580
+ source: "spawn",
581
+ actor: requestedBy,
582
+ });
583
+ }
489
584
  recordTaskSpawnRequested(input.taskId, {
490
585
  spawnRequestId,
491
586
  provider: input.provider,
492
587
  requestedBy,
493
588
  ...(hydratedTask?.steer ? { steer: hydratedTask.steer } : {}),
494
589
  });
590
+ autoSubscribeTaskCoordinator(requestedBy, input.taskId);
495
591
  } catch (err) {
496
592
  console.warn(`task ${input.taskId} spawn attribution skipped: ${errMessage(err)}`);
497
593
  }
@@ -502,16 +598,6 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
502
598
  const waitMs = Math.min(input.waitForRegistrationMs ?? 0, 30_000);
503
599
  const agentId = waitMs > 0 ? await waitForSpawnedAgent(spawnRequestId, waitMs) : null;
504
600
 
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
- }
515
601
  return {
516
602
  ok: true,
517
603
  spawnRequestId,
@@ -11,6 +11,7 @@ import {
11
11
  listDeliverableTasks,
12
12
  listTaskEvents,
13
13
  recordGate,
14
+ recordTaskDispatchDecision,
14
15
  removeTaskDependency,
15
16
  removeTaskRef,
16
17
  setTaskStage,
@@ -19,6 +20,7 @@ import {
19
20
  type AddTaskRefInput,
20
21
  type CreateDeliverableTaskInput,
21
22
  type ListDeliverableTasksFilter,
23
+ type RecordTaskDispatchDecisionInput,
22
24
  type RecordGateInput,
23
25
  type RemoveTaskRefInput,
24
26
  type SetTaskStageInput,
@@ -27,16 +29,17 @@ import {
27
29
  } from "../db";
28
30
  import { emitTaskChanged } from "../sse";
29
31
  import type { Task, TaskDetail, TaskEvent, TaskGateState } from "../types";
30
- import { emitTaskSemanticNotification, taskTransitionForStatus } from "./task-semantic-events";
32
+ import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
33
+ import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
31
34
 
32
35
  export function createTaskEntity(input: CreateDeliverableTaskInput): TaskDetail {
33
36
  const detail = createDeliverableTask(input);
37
+ autoSubscribeTaskCoordinator(input.createdBy, detail.id);
34
38
  emitTaskChanged(detail, "task.created");
35
39
  emitTaskSemanticNotification({
36
40
  task: detail,
37
41
  transition: "created",
38
42
  actor: input.createdBy,
39
- recipients: input.createdBy ? [input.createdBy] : [],
40
43
  occurrenceKey: latestTaskEventOccurrence(detail.id, "task.created"),
41
44
  });
42
45
  return detail;
@@ -66,23 +69,27 @@ export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): Task
66
69
  task: detail,
67
70
  transition,
68
71
  actor: input.actor,
69
- recipients: detail.createdBy ? [detail.createdBy] : [],
70
72
  occurrenceKey: latestTaskEventOccurrence(detail.id, `status:${detail.status}`),
71
73
  });
72
74
  }
75
+ if (isTerminalTaskStatus(detail.status)) removeSubscriptionsForTask(detail.id);
73
76
  return detail;
74
77
  }
75
78
 
79
+ function isTerminalTaskStatus(status: Task["status"]): boolean {
80
+ return status === "done" || status === "failed" || status === "canceled";
81
+ }
82
+
76
83
  export function setTaskEntityStage(id: number, input: SetTaskStageInput): TaskDetail {
77
84
  const before = getTaskDetail(id);
78
85
  const detail = setTaskStage(id, input);
79
86
  emitTaskChanged(detail, "task.stage");
80
- if (input.actor !== "task-lifecycle" && before?.deliverableStage !== "landed-on-main" && detail.deliverableStage === "landed-on-main") {
87
+ const transition = taskTransitionForStage(detail.deliverableStage);
88
+ if (input.actor !== "task-lifecycle" && transition && before?.deliverableStage !== detail.deliverableStage) {
81
89
  emitTaskSemanticNotification({
82
90
  task: detail,
83
- transition: "landed",
91
+ transition,
84
92
  actor: input.actor,
85
- recipients: detail.createdBy ? [detail.createdBy] : [],
86
93
  branch: detail.branch,
87
94
  occurrenceKey: latestTaskEventOccurrence(detail.id, `stage:${detail.deliverableStage}`),
88
95
  });
@@ -109,6 +116,15 @@ export function recordTaskEntityGate(id: number, input: RecordGateInput): { gate
109
116
  const gate = recordGate(id, input);
110
117
  const detail = getTaskDetail(id);
111
118
  if (detail) emitTaskChanged(detail, "task.gate");
119
+ if (detail && gate.status === "fail") {
120
+ emitTaskSemanticNotification({
121
+ task: detail,
122
+ transition: "gate-failed",
123
+ actor: input.actor,
124
+ occurrenceKey: latestTaskEventOccurrence(detail.id, `gate:${gate.gate}`),
125
+ outcome: { gate: gate.gate, status: gate.status, ...(gate.detail ? { detail: gate.detail } : {}) },
126
+ });
127
+ }
112
128
  return { gate, gates: detail?.gates ?? [] };
113
129
  }
114
130
 
@@ -144,6 +160,12 @@ export function assignTaskEntityAgent(id: number, agentId: string, role?: string
144
160
  return detail;
145
161
  }
146
162
 
163
+ export function recordTaskEntityDispatchDecision(id: number, input: RecordTaskDispatchDecisionInput): TaskDetail {
164
+ const detail = recordTaskDispatchDecision(id, input);
165
+ emitTaskChanged(detail, "task.dispatch");
166
+ return detail;
167
+ }
168
+
147
169
  export function taskEntityHistory(id: number): TaskEvent[] {
148
170
  return listTaskEvents(id);
149
171
  }
@@ -36,7 +36,7 @@ import { requestTransientAgentShutdown } from "./transient-agent-reaper";
36
36
  import { emitRelayEvent } from "../events";
37
37
  import { routeNotification } from "../notification-router";
38
38
  import { resolveCoordinatorAgentId } from "../steward-identity";
39
- import { emitTaskSemanticNotification } from "./task-semantic-events";
39
+ import { emitTaskSemanticNotification, type TaskSemanticTransition } from "./task-semantic-events";
40
40
  import type { IssueRef, TaskDeliverableStage, TaskGateState, WorkspaceRecord } from "../types";
41
41
  import type { IssueTrackerAdapter } from "../issue-tracker";
42
42
 
@@ -179,7 +179,7 @@ function landKeyFor(input: LandedTaskLifecycleInput): string {
179
179
  // `agent.spawn_requested` attribution keyed on the worker's meta.spawnRequestId (fire-and-forget,
180
180
  // owner not yet backfilled), and the Task's recorded branch column. Deduped + ordered. Returns []
181
181
  // for an ordinary non-task branch → the land is a strict no-op (behavior byte-identical).
182
- function resolveBoundTaskIds(workspace: LifecycleWorkspace): number[] {
182
+ export function boundTaskIdsForWorkspace(workspace: LifecycleWorkspace): number[] {
183
183
  const ids = new Set<number>();
184
184
  const owner = workspace.ownerAgentId?.trim();
185
185
  if (owner) {
@@ -195,6 +195,35 @@ function resolveBoundTaskIds(workspace: LifecycleWorkspace): number[] {
195
195
  return [...ids].sort((a, b) => a - b);
196
196
  }
197
197
 
198
+ export function emitWorkspaceTaskSemanticNotifications(input: {
199
+ workspace: LifecycleWorkspace;
200
+ transition: TaskSemanticTransition;
201
+ actor?: string;
202
+ occurrenceKey?: string;
203
+ outcome?: Record<string, unknown>;
204
+ sha?: string;
205
+ }): number[] {
206
+ const emitted: number[] = [];
207
+ const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
208
+ for (const taskId of boundTaskIdsForWorkspace(input.workspace)) {
209
+ const task = getTaskDetail(taskId);
210
+ if (!task) continue;
211
+ emitTaskSemanticNotification({
212
+ task,
213
+ transition: input.transition,
214
+ actor: input.actor,
215
+ recipients: coordinator ? [coordinator] : [],
216
+ workspaceId: input.workspace.id,
217
+ branch: input.workspace.branch,
218
+ ...(input.sha ? { sha: input.sha } : {}),
219
+ ...(input.occurrenceKey ? { occurrenceKey: input.occurrenceKey } : {}),
220
+ ...(input.outcome ? { outcome: input.outcome } : {}),
221
+ });
222
+ emitted.push(taskId);
223
+ }
224
+ return emitted;
225
+ }
226
+
198
227
  // --- reap signal (decoupled from task-done) ----------------------------------------------------
199
228
  interface ReapResult {
200
229
  reaped: boolean;
@@ -382,7 +411,7 @@ function emitDeliverableLandedEvent(input: LandedTaskLifecycleInput, outcome: Ta
382
411
  const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
383
412
  emitTaskSemanticNotification({
384
413
  task,
385
- transition: "landed",
414
+ transition: "landed-on-main",
386
415
  actor: "task-lifecycle",
387
416
  recipients: coordinator ? [coordinator] : [],
388
417
  workspaceId: input.workspace.id,
@@ -401,7 +430,7 @@ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: T
401
430
  type: "task.landed",
402
431
  scope: {
403
432
  taskId: String(outcome.taskId),
404
- transition: "landed",
433
+ transition: "landed-on-main",
405
434
  landKey,
406
435
  ...(input.workspace.ownerAgentId ? { agentId: input.workspace.ownerAgentId, author: input.workspace.ownerAgentId } : {}),
407
436
  ...(task?.createdBy ? { parent: task.createdBy } : {}),
@@ -417,8 +446,8 @@ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: T
417
446
  kind: "task.lifecycle-error",
418
447
  notification: "task-lifecycle-error",
419
448
  taskId: outcome.taskId,
420
- transition: "landed",
421
- semanticEvent: "task.landed",
449
+ transition: "landed-on-main",
450
+ semanticEvent: "task.landed-on-main",
422
451
  workspaceId: input.workspace.id,
423
452
  branch: input.workspace.branch,
424
453
  ...(input.mergedSha ? { sha: input.mergedSha } : {}),
@@ -442,7 +471,7 @@ export async function handleLandedTaskLifecycle(
442
471
  const log = opts.log ?? ((message: string) => console.warn(message));
443
472
  // Non-default-base lands never drive a deliverable (mirror the issue-lifecycle guard).
444
473
  if ((input.workspace.baseRef ?? "main") !== "main") return { handled: false, reason: "non-default-branch", tasks: [] };
445
- const taskIds = resolveBoundTaskIds(input.workspace);
474
+ const taskIds = boundTaskIdsForWorkspace(input.workspace);
446
475
  if (taskIds.length === 0) return { handled: false, reason: "no-bound-task", tasks: [] };
447
476
 
448
477
  const diffSummary = landedDiffSummary(input, log);