agent-relay-server 0.109.0 → 0.110.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 (83) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-XgN61quY.js → MessageBubble-CmqJMGYF.js} +2 -2
  4. package/public/assets/{MessageBubble-XgN61quY.js.map → MessageBubble-CmqJMGYF.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-LY-msOsr.js → PlanGraphPanel-K6aFJmdd.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-LY-msOsr.js.map → PlanGraphPanel-K6aFJmdd.js.map} +1 -1
  7. package/public/assets/{activity-D0EotVU9.js → activity-DyqKrJe_.js} +2 -2
  8. package/public/assets/{activity-D0EotVU9.js.map → activity-DyqKrJe_.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CAGvryb9.js → agent-profiles-DRQ5vbPo.js} +2 -2
  10. package/public/assets/{agent-profiles-CAGvryb9.js.map → agent-profiles-DRQ5vbPo.js.map} +1 -1
  11. package/public/assets/{agents-CXXZ_sU9.js → agents-BJ9sm23O.js} +2 -2
  12. package/public/assets/{agents-CXXZ_sU9.js.map → agents-BJ9sm23O.js.map} +1 -1
  13. package/public/assets/{automation-zig0cp23.js → automation-CJ2-uiYt.js} +2 -2
  14. package/public/assets/{automation-zig0cp23.js.map → automation-CJ2-uiYt.js.map} +1 -1
  15. package/public/assets/chat-D0VtHShz.js +2 -0
  16. package/public/assets/{chat-C3QlsFBd.js.map → chat-D0VtHShz.js.map} +1 -1
  17. package/public/assets/{formatted-body-impl-DCQSVyWB.js → formatted-body-impl-ChCxpVtI.js} +2 -2
  18. package/public/assets/{formatted-body-impl-DCQSVyWB.js.map → formatted-body-impl-ChCxpVtI.js.map} +1 -1
  19. package/public/assets/{index-RCbIyk26.js → index-BIRtwnUI.js} +13 -13
  20. package/public/assets/index-BIRtwnUI.js.map +1 -0
  21. package/public/assets/{index-D6hQkte1.css → index-D-Ntiy3M.css} +1 -1
  22. package/public/assets/{insights-CsLs6NbL.js → insights-BGuB8DWZ.js} +2 -2
  23. package/public/assets/{insights-CsLs6NbL.js.map → insights-BGuB8DWZ.js.map} +1 -1
  24. package/public/assets/{maintenance-BN5F0Lnc.js → maintenance-BFyuZuv5.js} +2 -2
  25. package/public/assets/{maintenance-BN5F0Lnc.js.map → maintenance-BFyuZuv5.js.map} +1 -1
  26. package/public/assets/{managed-agents-R_LI9qS5.js → managed-agents-BIXZUkrA.js} +2 -2
  27. package/public/assets/{managed-agents-R_LI9qS5.js.map → managed-agents-BIXZUkrA.js.map} +1 -1
  28. package/public/assets/{markdown-preview-impl-DQlmK_ev.js → markdown-preview-impl-DTI--VmH.js} +2 -2
  29. package/public/assets/{markdown-preview-impl-DQlmK_ev.js.map → markdown-preview-impl-DTI--VmH.js.map} +1 -1
  30. package/public/assets/{memory-kn4bgMXC.js → memory-OoM2E30h.js} +2 -2
  31. package/public/assets/{memory-kn4bgMXC.js.map → memory-OoM2E30h.js.map} +1 -1
  32. package/public/assets/{messages-Dv3Tv7zC.js → messages-BHvC3SNR.js} +2 -2
  33. package/public/assets/{messages-Dv3Tv7zC.js.map → messages-BHvC3SNR.js.map} +1 -1
  34. package/public/assets/{orchestrators-DcNLRMzk.js → orchestrators-9pzQqf6-.js} +2 -2
  35. package/public/assets/{orchestrators-DcNLRMzk.js.map → orchestrators-9pzQqf6-.js.map} +1 -1
  36. package/public/assets/{overview-BOLzPSSC.js → overview-L8iYOYmR.js} +2 -2
  37. package/public/assets/{overview-BOLzPSSC.js.map → overview-L8iYOYmR.js.map} +1 -1
  38. package/public/assets/{pairs-B4r0egpx.js → pairs-BCejxa6_.js} +2 -2
  39. package/public/assets/{pairs-B4r0egpx.js.map → pairs-BCejxa6_.js.map} +1 -1
  40. package/public/assets/{prompt-settings-E7-6Osj4.js → prompt-settings-D9y9hhSy.js} +2 -2
  41. package/public/assets/{prompt-settings-E7-6Osj4.js.map → prompt-settings-D9y9hhSy.js.map} +1 -1
  42. package/public/assets/{registry-CTN3jOnb.js → registry-BwBg64Vj.js} +2 -2
  43. package/public/assets/{registry-CTN3jOnb.js.map → registry-BwBg64Vj.js.map} +1 -1
  44. package/public/assets/{security-BctIcBZb.js → security-upd2zf8l.js} +2 -2
  45. package/public/assets/{security-BctIcBZb.js.map → security-upd2zf8l.js.map} +1 -1
  46. package/public/assets/{select-BndvVb0A.js → select-Cu55Vi-R.js} +2 -2
  47. package/public/assets/{select-BndvVb0A.js.map → select-Cu55Vi-R.js.map} +1 -1
  48. package/public/assets/{settings-B_tRq-P4.js → settings-Dk5iNqCZ.js} +2 -2
  49. package/public/assets/{settings-B_tRq-P4.js.map → settings-Dk5iNqCZ.js.map} +1 -1
  50. package/public/assets/{tasks-BgTTxVET.js → tasks-BoDuvHnZ.js} +2 -2
  51. package/public/assets/{tasks-BgTTxVET.js.map → tasks-BoDuvHnZ.js.map} +1 -1
  52. package/public/assets/{teams-DJiLBEgF.js → teams-C6Pn-gpw.js} +2 -2
  53. package/public/assets/{teams-DJiLBEgF.js.map → teams-C6Pn-gpw.js.map} +1 -1
  54. package/public/assets/{terminal-viewer-impl-BLdiyCNR.js → terminal-viewer-impl-afW96xEF.js} +2 -2
  55. package/public/assets/{terminal-viewer-impl-BLdiyCNR.js.map → terminal-viewer-impl-afW96xEF.js.map} +1 -1
  56. package/public/assets/types-DSwhOFcJ.js.map +1 -1
  57. package/public/assets/{user-avatar-rt59cEsc.js → user-avatar-tSPnqEk5.js} +2 -2
  58. package/public/assets/{user-avatar-rt59cEsc.js.map → user-avatar-tSPnqEk5.js.map} +1 -1
  59. package/public/assets/{work-queue-BYRdBuLR.js → work-queue-DP3UdDwC.js} +2 -2
  60. package/public/assets/{work-queue-BYRdBuLR.js.map → work-queue-DP3UdDwC.js.map} +1 -1
  61. package/public/assets/{workspaces-DCRT3l9H.js → workspaces-Dt2xndG4.js} +3 -3
  62. package/public/assets/{workspaces-DCRT3l9H.js.map → workspaces-Dt2xndG4.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/merge-lease.ts +5 -1
  68. package/src/db/task-detail-projection.ts +66 -0
  69. package/src/db/task-entity.ts +51 -0
  70. package/src/mcp/tools-spawn.ts +1 -1
  71. package/src/mcp-dispatch-tools.ts +4 -1
  72. package/src/routes/agents-spawn.ts +3 -2
  73. package/src/routes/agents.ts +2 -0
  74. package/src/routes/index.ts +2 -1
  75. package/src/routes/tasks.ts +18 -3
  76. package/src/runtime-tokens.ts +4 -0
  77. package/src/security.ts +4 -0
  78. package/src/services/register-agent.ts +46 -2
  79. package/src/services/spawn-agent.ts +114 -30
  80. package/src/services/task-entity.ts +8 -0
  81. package/src/spawn-command.ts +3 -0
  82. package/public/assets/chat-C3QlsFBd.js +0 -2
  83. 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,7 @@ 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";
42
45
  import { dispatchTask } from "../task-dispatcher";
43
46
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
44
47
  import { isPathWithinBase } from "../utils";
@@ -51,6 +54,60 @@ import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
51
54
  /** The spawn scope every transport must hold (admin `*` / `admin:*` pass; `system` bypasses). */
52
55
  const SPAWN_SCOPE = "command:spawn";
53
56
 
57
+ function hasGitMarker(dir: string): boolean {
58
+ try {
59
+ const marker = statSync(`${dir}/.git`);
60
+ return marker.isDirectory() || marker.isFile();
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function findGitRepoRoot(cwd: string): string | null {
67
+ if (!cwd.trim()) return null;
68
+ let current = resolve(cwd);
69
+ try {
70
+ const stat = statSync(current);
71
+ if (!stat.isDirectory()) current = dirname(current);
72
+ } catch {
73
+ return null;
74
+ }
75
+ for (;;) {
76
+ if (hasGitMarker(current)) return current;
77
+ const parent = dirname(current);
78
+ if (parent === current) return null;
79
+ current = parent;
80
+ }
81
+ }
82
+
83
+ function assertIsolatedSpawnHasGitRepo(cwd: string): void {
84
+ if (findGitRepoRoot(cwd)) return;
85
+ 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".`);
86
+ }
87
+
88
+ function taskLinkedRepoCwd(taskId: number, callerCwd?: string): string {
89
+ const task = getTaskDetail(taskId);
90
+ if (!task) throw new ValidationError(`task ${taskId} not found or not a deliverable task`);
91
+ const repos = [...new Set(task.issues.map((issue) => issue.repo).filter((repo): repo is string => Boolean(repo)))];
92
+ if (repos.length === 0) {
93
+ throw new ValidationError(`task ${taskId} has no linked issue repo; pass cwd/orchestratorId explicitly`);
94
+ }
95
+ const candidates = listProjects()
96
+ .filter((project) => project.issueRepo && repos.includes(project.issueRepo))
97
+ .flatMap((project) => rootsForProject(project.id).map((root) => ({ project, root })));
98
+ if (candidates.length === 0) {
99
+ 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`);
100
+ }
101
+ const matchingCallerRoot = callerCwd
102
+ ? candidates
103
+ .filter((candidate) => isPathWithinBase(callerCwd, candidate.root.rootPath))
104
+ .sort((a, b) => b.root.rootPath.length - a.root.rootPath.length)[0]
105
+ : undefined;
106
+ if (matchingCallerRoot) return matchingCallerRoot.root.rootPath;
107
+ if (candidates.length === 1) return candidates[0]!.root.rootPath;
108
+ throw new ValidationError(`task ${taskId} links repo ${repos.join(", ")} with multiple registered roots; pass cwd explicitly`);
109
+ }
110
+
54
111
  /**
55
112
  * A normalized spawn request — every transport validates its own wire shape (enums,
56
113
  * lengths) and hands the service this. Caller-context defaults (cwd, approvalMode,
@@ -177,6 +234,19 @@ function canSpawn(ctx: AuthContext): boolean {
177
234
  return ctx.scopes.includes(SPAWN_SCOPE);
178
235
  }
179
236
 
237
+ function agentIdFromTaskCreator(createdBy: string | undefined): string | undefined {
238
+ const raw = createdBy?.trim();
239
+ if (!raw) return undefined;
240
+ const candidates = [raw, raw.startsWith("agent:") ? raw.slice("agent:".length) : undefined].filter((value): value is string => Boolean(value));
241
+ return candidates.find((candidate) => getAgent(candidate)) ?? undefined;
242
+ }
243
+
244
+ function taskSpawnLabel(task: { id: number; title: string } | undefined, fallback?: string): string | undefined {
245
+ if (fallback || !task) return fallback;
246
+ const label = `task-${task.id}: ${task.title.trim() || "untitled"}`;
247
+ return label.length <= 120 ? label : `${label.slice(0, 117)}...`;
248
+ }
249
+
180
250
  /**
181
251
  * Spawn a provider agent. Transport-agnostic: every gate + side effect lives here so the
182
252
  * MCP and HTTP paths cannot drift. Throws `McpAuthError` (→403) on a gate denial,
@@ -189,22 +259,30 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
189
259
  // the #221 gate AND the #328/#331/#324 caller-context inheritance — one lookup, reused.
190
260
  const callerId = ctx.callerAgentId;
191
261
  const caller: AgentCard | undefined = (callerId ? getAgent(callerId) : undefined) ?? undefined;
262
+ const linkedTask = input.taskId !== undefined ? getTaskDetail(input.taskId) ?? undefined : undefined;
263
+ const taskCreatorAgentId = !callerId ? agentIdFromTaskCreator(linkedTask?.createdBy) : undefined;
264
+ const lineageAgentId = callerId ?? taskCreatorAgentId;
265
+ const lineageAgent: AgentCard | undefined = (lineageAgentId ? getAgent(lineageAgentId) : undefined) ?? undefined;
266
+ const callerCwd = stringValue(caller?.meta?.cwd) ?? stringValue(lineageAgent?.meta?.cwd);
267
+ const taskDefaultCwd = input.taskId !== undefined && !input.orchestratorId && !input.cwd
268
+ ? taskLinkedRepoCwd(input.taskId, callerCwd)
269
+ : undefined;
270
+ const requestedCwd = input.cwd ?? taskDefaultCwd;
192
271
 
193
272
  // #255/#328 — bias the host to the caller's own machine when neither an explicit
194
273
  // 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);
274
+ const orchestrator = selectSpawnOrchestrator(input.provider, input.orchestratorId, requestedCwd, caller?.machine ?? lineageAgent?.machine);
196
275
 
197
276
  // #308 §3 — an explicit cwd must resolve within the TARGET host's base dir (a path valid on
198
277
  // 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.`);
278
+ if (requestedCwd && !isPathWithinBase(requestedCwd, orchestrator.baseDir)) {
279
+ 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
280
  }
202
281
  // #328 — default cwd to the caller's OWN repo (so "spawn a helper for my current task" Just
203
282
  // Works, esp. isolated mode which needs a git repo), but only when it resolves within the
204
283
  // target host's base. Precedence: explicit cwd > caller cwd > base dir.
205
- const callerCwd = stringValue(caller?.meta?.cwd);
206
284
  const inheritedCwd = callerCwd && isPathWithinBase(callerCwd, orchestrator.baseDir) ? callerCwd : undefined;
207
- const resolvedCwd = input.cwd || inheritedCwd || orchestrator.baseDir;
285
+ const resolvedCwd = requestedCwd || inheritedCwd || orchestrator.baseDir;
208
286
 
209
287
  // #838 — spawn-with-task hydration. When a taskId is given, relay assembles the worker's
210
288
  // first-turn context FROM the durable Task (intent + linked-issue spec/acceptance + recorded
@@ -234,9 +312,11 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
234
312
  // and a no-taskId spawn is untouched — the coupling is opt-in, not hard-wired.
235
313
  let dispatchModel = input.model;
236
314
  let dispatchEffort = input.effort;
315
+ let taskDispatchDecision: ReturnType<typeof dispatchTask> | undefined;
237
316
  if (hydratedTask && !input.model && !input.effort && hydratedTask.task.dispatch) {
238
317
  try {
239
318
  const decision = dispatchTask(hydratedTask.task.dispatch, { provider: input.provider });
319
+ taskDispatchDecision = decision;
240
320
  dispatchModel = decision.target.model ?? dispatchModel;
241
321
  dispatchEffort = decision.target.effort ?? dispatchEffort;
242
322
  } catch (err) {
@@ -245,18 +325,23 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
245
325
  }
246
326
 
247
327
  const modelParams = resolveSpawnModelParams(input.provider, dispatchModel, dispatchEffort);
328
+ const resolvedLabel = taskSpawnLabel(linkedTask, input.label);
248
329
 
249
330
  // #331 — default the child's approval mode to the CALLER's, not a hardcoded `guarded` (a
250
331
  // headless `guarded` child wedges on its first tool-call approval prompt). Explicit always
251
332
  // 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";
333
+ const callerApprovalMode = (caller?.meta?.approvalMode ?? lineageAgent?.meta?.approvalMode) as SpawnApprovalMode | undefined;
334
+ const approvalMode = input.approvalMode ?? callerApprovalMode ?? (input.taskId !== undefined ? "open" : "guarded");
254
335
 
255
336
  // #324 — agent-initiated spawns (a real caller behind the token) default to `isolated` (a
256
337
  // 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);
338
+ // `inherit` fallback. Task-triggered spawns are coordinator work and should get an isolated
339
+ // branch unless explicitly narrowed.
340
+ const workspaceMode = input.workspaceMode ?? (callerId || input.taskId !== undefined ? ("isolated" as WorkspaceMode) : undefined);
259
341
  const lifecycle = input.lifecycle ?? "persistent";
342
+ if (workspaceMode === "isolated" && (input.taskId !== undefined || input.workspaceMode === "isolated")) {
343
+ assertIsolatedSpawnHasGitRepo(resolvedCwd);
344
+ }
260
345
 
261
346
  const spawnRequestId = input.spawnRequestId ?? generateSpawnRequestId();
262
347
 
@@ -344,9 +429,9 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
344
429
 
345
430
  // Provenance actor: the resolved caller agent when known, else the transport's label, else
346
431
  // 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);
432
+ const requestedBy = callerId ?? taskCreatorAgentId ?? input.requestedBy ?? ctx.actor.id;
433
+ const callerTeam = lineageAgentId ? (lineageAgent?.teamId ? { id: lineageAgent.teamId } : ensureTeam(lineageAgentId)) : undefined;
434
+ if (lineageAgentId && callerTeam && !lineageAgent?.teamId) setAgentTeam(lineageAgentId, callerTeam.id);
350
435
 
351
436
  // #405 — durable project binding: the project whose registered root contains the spawn cwd.
352
437
  // Stamped on the child token's constraints (identity-only, like teamId) so callerProject(auth)
@@ -374,7 +459,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
374
459
  // Both resolve null until someone has actually written to them, so a fresh deployment stays
375
460
  // byte-identical. Injection of their active (promoted) entries happens in the composer.
376
461
  const profileAnchor = resolveScopedAnchor("profile", profileName);
377
- const operatorId = callerId ? resolveAgentOwnership(callerId).ownerUserId : DEFAULT_USER_ID;
462
+ const operatorId = lineageAgentId ? resolveAgentOwnership(lineageAgentId).ownerUserId : DEFAULT_USER_ID;
378
463
  const userAnchor = resolveScopedAnchor("user", operatorId);
379
464
  const composedInstructions = composeSpawnInstructionsWithEvents({
380
465
  ...(callerProject ? { callerProject } : {}),
@@ -382,7 +467,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
382
467
  ...(profileAnchor ? { profileAnchor } : {}),
383
468
  ...(userAnchor ? { userAnchor } : {}),
384
469
  ...(agentProfile ? { profile: agentProfile } : {}),
385
- ...(callerId ? { spawnedBy: callerId } : {}),
470
+ ...(lineageAgentId ? { spawnedBy: lineageAgentId } : {}),
386
471
  ...(resolvedSystemPromptAppend ? { callerSystemPromptAppend: resolvedSystemPromptAppend } : {}),
387
472
  });
388
473
  const systemPromptAppend = composedInstructions.systemPromptAppend;
@@ -395,9 +480,10 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
395
480
  orchestratorId: orchestrator.id,
396
481
  cwd: resolvedCwd,
397
482
  provider: input.provider,
398
- label: input.label,
483
+ label: resolvedLabel,
399
484
  policyName: input.policyName,
400
485
  spawnRequestId,
486
+ taskId: input.taskId,
401
487
  createdBy: requestedBy,
402
488
  // Thread the resolved profile into the mint so the spawn grant (command:spawn/shutdown +
403
489
  // maxSpawnedAgents quota) is honored — parity with the HTTP path (agent-sessions.ts). #349
@@ -405,7 +491,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
405
491
  // (P0 regression #364). For an agent caller, agentInitiated still forces a non-spawn-capable
406
492
  // child regardless of profile (no grandchildren).
407
493
  profile: profileName,
408
- ...(callerId ? { agentInitiated: true, spawnedBy: callerId } : {}),
494
+ ...(lineageAgentId ? { agentInitiated: true, spawnedBy: lineageAgentId } : {}),
409
495
  ...(callerTeam ? { teamId: callerTeam.id } : {}),
410
496
  ...(callerProject ? { projectId: callerProject.id } : {}),
411
497
  });
@@ -426,7 +512,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
426
512
  cwd: resolvedCwd,
427
513
  ...(workspaceMode ? { workspaceMode } : {}),
428
514
  lifecycle,
429
- label: input.label,
515
+ label: resolvedLabel,
430
516
  profile: profileName,
431
517
  agentProfile,
432
518
  tags: input.tags ?? [],
@@ -440,6 +526,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
440
526
  landStrategy: input.landStrategy,
441
527
  autoMerge: input.autoMerge,
442
528
  reviewer: input.reviewer,
529
+ taskId: input.taskId,
443
530
  resumeWorkspace: input.resumeWorkspace,
444
531
  ...(expectReply ? { expectReply } : {}),
445
532
  policyName: input.policyName,
@@ -451,7 +538,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
451
538
  orchestratorId: orchestrator.id,
452
539
  // #308 — stamp the spawning parent so a FAILED agent.spawn command (child never registers,
453
540
  // so there's no agent row to resolve `spawnedBy` from) can be routed back to it.
454
- ...(callerId ? { extra: { spawnedBy: callerId } } : {}),
541
+ ...(lineageAgentId ? { extra: { spawnedBy: lineageAgentId } } : {}),
455
542
  }),
456
543
  });
457
544
  emitCommandEvent(command, "command.requested");
@@ -472,7 +559,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
472
559
  approvalMode,
473
560
  lifecycle,
474
561
  workspaceMode: workspaceMode ?? null,
475
- label: input.label ?? null,
562
+ label: resolvedLabel ?? null,
476
563
  commandId: command.id,
477
564
  spawnRequestId,
478
565
  requestedVia: input.requestedVia ?? null,
@@ -486,6 +573,13 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
486
573
  // drive one-shot reap. Best-effort: an attribution write never fails the spawn.
487
574
  if (input.taskId !== undefined) {
488
575
  try {
576
+ if (taskDispatchDecision) {
577
+ recordTaskEntityDispatchDecision(input.taskId, {
578
+ decision: taskDispatchDecision,
579
+ source: "spawn",
580
+ actor: requestedBy,
581
+ });
582
+ }
489
583
  recordTaskSpawnRequested(input.taskId, {
490
584
  spawnRequestId,
491
585
  provider: input.provider,
@@ -502,16 +596,6 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
502
596
  const waitMs = Math.min(input.waitForRegistrationMs ?? 0, 30_000);
503
597
  const agentId = waitMs > 0 ? await waitForSpawnedAgent(spawnRequestId, waitMs) : null;
504
598
 
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
599
  return {
516
600
  ok: true,
517
601
  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,
@@ -144,6 +146,12 @@ export function assignTaskEntityAgent(id: number, agentId: string, role?: string
144
146
  return detail;
145
147
  }
146
148
 
149
+ export function recordTaskEntityDispatchDecision(id: number, input: RecordTaskDispatchDecisionInput): TaskDetail {
150
+ const detail = recordTaskDispatchDecision(id, input);
151
+ emitTaskChanged(detail, "task.dispatch");
152
+ return detail;
153
+ }
154
+
147
155
  export function taskEntityHistory(id: number): TaskEvent[] {
148
156
  return listTaskEvents(id);
149
157
  }
@@ -100,6 +100,8 @@ interface BuildSpawnCommandOptions {
100
100
  landStrategy?: WorkspaceLandingPolicy;
101
101
  autoMerge?: WorkspaceAutoMergePolicy;
102
102
  reviewer?: string;
103
+ /** #838 — durable Task this worker was spawned to execute. */
104
+ taskId?: number;
103
105
  /**
104
106
  * #413 — when true, the child registering with this spawnRequestId gets a pending reply
105
107
  * obligation back to its spawner, so the Stop hook refuses to let it idle until it delivers
@@ -160,6 +162,7 @@ export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string
160
162
  ...def(opts.landStrategy, "landStrategy"),
161
163
  ...def(opts.autoMerge, "autoMerge"),
162
164
  ...def(opts.reviewer, "reviewer"),
165
+ ...def(opts.taskId, "taskId"),
163
166
  ...def(opts.expectReply, "expectReply"),
164
167
  ...def(opts.headless, "headless"),
165
168
  ...def(opts.policyName, "policyName"),