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.
- package/docs/openapi.json +29 -1
- package/package.json +3 -3
- package/public/assets/{MessageBubble-XgN61quY.js → MessageBubble-CmqJMGYF.js} +2 -2
- package/public/assets/{MessageBubble-XgN61quY.js.map → MessageBubble-CmqJMGYF.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-LY-msOsr.js → PlanGraphPanel-K6aFJmdd.js} +2 -2
- package/public/assets/{PlanGraphPanel-LY-msOsr.js.map → PlanGraphPanel-K6aFJmdd.js.map} +1 -1
- package/public/assets/{activity-D0EotVU9.js → activity-DyqKrJe_.js} +2 -2
- package/public/assets/{activity-D0EotVU9.js.map → activity-DyqKrJe_.js.map} +1 -1
- package/public/assets/{agent-profiles-CAGvryb9.js → agent-profiles-DRQ5vbPo.js} +2 -2
- package/public/assets/{agent-profiles-CAGvryb9.js.map → agent-profiles-DRQ5vbPo.js.map} +1 -1
- package/public/assets/{agents-CXXZ_sU9.js → agents-BJ9sm23O.js} +2 -2
- package/public/assets/{agents-CXXZ_sU9.js.map → agents-BJ9sm23O.js.map} +1 -1
- package/public/assets/{automation-zig0cp23.js → automation-CJ2-uiYt.js} +2 -2
- package/public/assets/{automation-zig0cp23.js.map → automation-CJ2-uiYt.js.map} +1 -1
- package/public/assets/chat-D0VtHShz.js +2 -0
- package/public/assets/{chat-C3QlsFBd.js.map → chat-D0VtHShz.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DCQSVyWB.js → formatted-body-impl-ChCxpVtI.js} +2 -2
- package/public/assets/{formatted-body-impl-DCQSVyWB.js.map → formatted-body-impl-ChCxpVtI.js.map} +1 -1
- package/public/assets/{index-RCbIyk26.js → index-BIRtwnUI.js} +13 -13
- package/public/assets/index-BIRtwnUI.js.map +1 -0
- package/public/assets/{index-D6hQkte1.css → index-D-Ntiy3M.css} +1 -1
- package/public/assets/{insights-CsLs6NbL.js → insights-BGuB8DWZ.js} +2 -2
- package/public/assets/{insights-CsLs6NbL.js.map → insights-BGuB8DWZ.js.map} +1 -1
- package/public/assets/{maintenance-BN5F0Lnc.js → maintenance-BFyuZuv5.js} +2 -2
- package/public/assets/{maintenance-BN5F0Lnc.js.map → maintenance-BFyuZuv5.js.map} +1 -1
- package/public/assets/{managed-agents-R_LI9qS5.js → managed-agents-BIXZUkrA.js} +2 -2
- package/public/assets/{managed-agents-R_LI9qS5.js.map → managed-agents-BIXZUkrA.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DQlmK_ev.js → markdown-preview-impl-DTI--VmH.js} +2 -2
- package/public/assets/{markdown-preview-impl-DQlmK_ev.js.map → markdown-preview-impl-DTI--VmH.js.map} +1 -1
- package/public/assets/{memory-kn4bgMXC.js → memory-OoM2E30h.js} +2 -2
- package/public/assets/{memory-kn4bgMXC.js.map → memory-OoM2E30h.js.map} +1 -1
- package/public/assets/{messages-Dv3Tv7zC.js → messages-BHvC3SNR.js} +2 -2
- package/public/assets/{messages-Dv3Tv7zC.js.map → messages-BHvC3SNR.js.map} +1 -1
- package/public/assets/{orchestrators-DcNLRMzk.js → orchestrators-9pzQqf6-.js} +2 -2
- package/public/assets/{orchestrators-DcNLRMzk.js.map → orchestrators-9pzQqf6-.js.map} +1 -1
- package/public/assets/{overview-BOLzPSSC.js → overview-L8iYOYmR.js} +2 -2
- package/public/assets/{overview-BOLzPSSC.js.map → overview-L8iYOYmR.js.map} +1 -1
- package/public/assets/{pairs-B4r0egpx.js → pairs-BCejxa6_.js} +2 -2
- package/public/assets/{pairs-B4r0egpx.js.map → pairs-BCejxa6_.js.map} +1 -1
- package/public/assets/{prompt-settings-E7-6Osj4.js → prompt-settings-D9y9hhSy.js} +2 -2
- package/public/assets/{prompt-settings-E7-6Osj4.js.map → prompt-settings-D9y9hhSy.js.map} +1 -1
- package/public/assets/{registry-CTN3jOnb.js → registry-BwBg64Vj.js} +2 -2
- package/public/assets/{registry-CTN3jOnb.js.map → registry-BwBg64Vj.js.map} +1 -1
- package/public/assets/{security-BctIcBZb.js → security-upd2zf8l.js} +2 -2
- package/public/assets/{security-BctIcBZb.js.map → security-upd2zf8l.js.map} +1 -1
- package/public/assets/{select-BndvVb0A.js → select-Cu55Vi-R.js} +2 -2
- package/public/assets/{select-BndvVb0A.js.map → select-Cu55Vi-R.js.map} +1 -1
- package/public/assets/{settings-B_tRq-P4.js → settings-Dk5iNqCZ.js} +2 -2
- package/public/assets/{settings-B_tRq-P4.js.map → settings-Dk5iNqCZ.js.map} +1 -1
- package/public/assets/{tasks-BgTTxVET.js → tasks-BoDuvHnZ.js} +2 -2
- package/public/assets/{tasks-BgTTxVET.js.map → tasks-BoDuvHnZ.js.map} +1 -1
- package/public/assets/{teams-DJiLBEgF.js → teams-C6Pn-gpw.js} +2 -2
- package/public/assets/{teams-DJiLBEgF.js.map → teams-C6Pn-gpw.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BLdiyCNR.js → terminal-viewer-impl-afW96xEF.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BLdiyCNR.js.map → terminal-viewer-impl-afW96xEF.js.map} +1 -1
- package/public/assets/types-DSwhOFcJ.js.map +1 -1
- package/public/assets/{user-avatar-rt59cEsc.js → user-avatar-tSPnqEk5.js} +2 -2
- package/public/assets/{user-avatar-rt59cEsc.js.map → user-avatar-tSPnqEk5.js.map} +1 -1
- package/public/assets/{work-queue-BYRdBuLR.js → work-queue-DP3UdDwC.js} +2 -2
- package/public/assets/{work-queue-BYRdBuLR.js.map → work-queue-DP3UdDwC.js.map} +1 -1
- package/public/assets/{workspaces-DCRT3l9H.js → workspaces-Dt2xndG4.js} +3 -3
- package/public/assets/{workspaces-DCRT3l9H.js.map → workspaces-Dt2xndG4.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/src/config.ts +5 -0
- package/src/bus.ts +3 -1
- package/src/db/merge-lease.ts +5 -1
- package/src/db/task-detail-projection.ts +66 -0
- package/src/db/task-entity.ts +51 -0
- package/src/mcp/tools-spawn.ts +1 -1
- package/src/mcp-dispatch-tools.ts +4 -1
- package/src/routes/agents-spawn.ts +3 -2
- package/src/routes/agents.ts +2 -0
- package/src/routes/index.ts +2 -1
- package/src/routes/tasks.ts +18 -3
- package/src/runtime-tokens.ts +4 -0
- package/src/security.ts +4 -0
- package/src/services/register-agent.ts +46 -2
- package/src/services/spawn-agent.ts +114 -30
- package/src/services/task-entity.ts +8 -0
- package/src/spawn-command.ts +3 -0
- package/public/assets/chat-C3QlsFBd.js +0 -2
- 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 {
|
|
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,
|
|
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 (
|
|
200
|
-
throw new ValidationError(`cwd '${
|
|
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 =
|
|
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.
|
|
258
|
-
|
|
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 =
|
|
349
|
-
if (
|
|
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 =
|
|
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
|
-
...(
|
|
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:
|
|
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
|
-
...(
|
|
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:
|
|
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
|
-
...(
|
|
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:
|
|
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
|
}
|
package/src/spawn-command.ts
CHANGED
|
@@ -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"),
|