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.
- package/docs/openapi.json +29 -1
- package/package.json +3 -3
- package/public/assets/{MessageBubble-XgN61quY.js → MessageBubble-WYqSyKPM.js} +2 -2
- package/public/assets/{MessageBubble-XgN61quY.js.map → MessageBubble-WYqSyKPM.js.map} +1 -1
- package/public/assets/PlanGraphPanel-Dvu17jGe.js +2 -0
- package/public/assets/PlanGraphPanel-Dvu17jGe.js.map +1 -0
- package/public/assets/{activity-D0EotVU9.js → activity-wAN_upn7.js} +2 -2
- package/public/assets/{activity-D0EotVU9.js.map → activity-wAN_upn7.js.map} +1 -1
- package/public/assets/{agent-profiles-CAGvryb9.js → agent-profiles-D8WLdTCs.js} +2 -2
- package/public/assets/{agent-profiles-CAGvryb9.js.map → agent-profiles-D8WLdTCs.js.map} +1 -1
- package/public/assets/{agents-CXXZ_sU9.js → agents-C5FajNPw.js} +2 -2
- package/public/assets/{agents-CXXZ_sU9.js.map → agents-C5FajNPw.js.map} +1 -1
- package/public/assets/{automation-zig0cp23.js → automation-ShdXg0pX.js} +2 -2
- package/public/assets/{automation-zig0cp23.js.map → automation-ShdXg0pX.js.map} +1 -1
- package/public/assets/chat-aRCk0pLJ.js +2 -0
- package/public/assets/{chat-C3QlsFBd.js.map → chat-aRCk0pLJ.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DCQSVyWB.js → formatted-body-impl-DZc7C4-2.js} +2 -2
- package/public/assets/{formatted-body-impl-DCQSVyWB.js.map → formatted-body-impl-DZc7C4-2.js.map} +1 -1
- package/public/assets/{index-D6hQkte1.css → index-D-Ntiy3M.css} +1 -1
- package/public/assets/{index-RCbIyk26.js → index-DXEYz_JV.js} +13 -13
- package/public/assets/index-DXEYz_JV.js.map +1 -0
- package/public/assets/{insights-CsLs6NbL.js → insights-FzTX2wNh.js} +2 -2
- package/public/assets/{insights-CsLs6NbL.js.map → insights-FzTX2wNh.js.map} +1 -1
- package/public/assets/{maintenance-BN5F0Lnc.js → maintenance-C8Uw8XiI.js} +2 -2
- package/public/assets/{maintenance-BN5F0Lnc.js.map → maintenance-C8Uw8XiI.js.map} +1 -1
- package/public/assets/{managed-agents-R_LI9qS5.js → managed-agents-DVbJ2hqh.js} +2 -2
- package/public/assets/{managed-agents-R_LI9qS5.js.map → managed-agents-DVbJ2hqh.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DQlmK_ev.js → markdown-preview-impl-C5D-B-9S.js} +2 -2
- package/public/assets/{markdown-preview-impl-DQlmK_ev.js.map → markdown-preview-impl-C5D-B-9S.js.map} +1 -1
- package/public/assets/{memory-kn4bgMXC.js → memory-Bp-D_V80.js} +2 -2
- package/public/assets/{memory-kn4bgMXC.js.map → memory-Bp-D_V80.js.map} +1 -1
- package/public/assets/{messages-Dv3Tv7zC.js → messages-DE1QSeHI.js} +2 -2
- package/public/assets/{messages-Dv3Tv7zC.js.map → messages-DE1QSeHI.js.map} +1 -1
- package/public/assets/{orchestrators-DcNLRMzk.js → orchestrators-B0u2lQfw.js} +2 -2
- package/public/assets/{orchestrators-DcNLRMzk.js.map → orchestrators-B0u2lQfw.js.map} +1 -1
- package/public/assets/{overview-BOLzPSSC.js → overview-DpaEvvLT.js} +2 -2
- package/public/assets/{overview-BOLzPSSC.js.map → overview-DpaEvvLT.js.map} +1 -1
- package/public/assets/{pairs-B4r0egpx.js → pairs-DRp_qAu8.js} +2 -2
- package/public/assets/{pairs-B4r0egpx.js.map → pairs-DRp_qAu8.js.map} +1 -1
- package/public/assets/{prompt-settings-E7-6Osj4.js → prompt-settings-CNoBiymk.js} +2 -2
- package/public/assets/{prompt-settings-E7-6Osj4.js.map → prompt-settings-CNoBiymk.js.map} +1 -1
- package/public/assets/{registry-CTN3jOnb.js → registry-0A4QFMWC.js} +2 -2
- package/public/assets/{registry-CTN3jOnb.js.map → registry-0A4QFMWC.js.map} +1 -1
- package/public/assets/{security-BctIcBZb.js → security-C9P6yiQ3.js} +2 -2
- package/public/assets/{security-BctIcBZb.js.map → security-C9P6yiQ3.js.map} +1 -1
- package/public/assets/{select-BndvVb0A.js → select-B1PVUj6D.js} +2 -2
- package/public/assets/{select-BndvVb0A.js.map → select-B1PVUj6D.js.map} +1 -1
- package/public/assets/{settings-B_tRq-P4.js → settings-TnHlB9-t.js} +2 -2
- package/public/assets/{settings-B_tRq-P4.js.map → settings-TnHlB9-t.js.map} +1 -1
- package/public/assets/{tasks-BgTTxVET.js → tasks-1oK_ggFC.js} +2 -2
- package/public/assets/{tasks-BgTTxVET.js.map → tasks-1oK_ggFC.js.map} +1 -1
- package/public/assets/{teams-DJiLBEgF.js → teams-BBb0ZYiF.js} +2 -2
- package/public/assets/{teams-DJiLBEgF.js.map → teams-BBb0ZYiF.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BLdiyCNR.js → terminal-viewer-impl-DzvaRjkp.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BLdiyCNR.js.map → terminal-viewer-impl-DzvaRjkp.js.map} +1 -1
- package/public/assets/types-DSwhOFcJ.js.map +1 -1
- package/public/assets/{user-avatar-rt59cEsc.js → user-avatar-X1pfizLv.js} +2 -2
- package/public/assets/{user-avatar-rt59cEsc.js.map → user-avatar-X1pfizLv.js.map} +1 -1
- package/public/assets/{work-queue-BYRdBuLR.js → work-queue-DQMbkSD8.js} +2 -2
- package/public/assets/{work-queue-BYRdBuLR.js.map → work-queue-DQMbkSD8.js.map} +1 -1
- package/public/assets/{workspaces-DCRT3l9H.js → workspaces-BNLP4IlV.js} +3 -3
- package/public/assets/{workspaces-DCRT3l9H.js.map → workspaces-BNLP4IlV.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/agents.ts +3 -0
- package/src/db/merge-lease.ts +5 -1
- package/src/db/notification-subscriptions.ts +15 -0
- package/src/db/task-detail-projection.ts +66 -0
- package/src/db/task-entity.ts +53 -0
- package/src/mcp/tools-spawn.ts +1 -1
- package/src/mcp-dispatch-tools.ts +4 -1
- package/src/mcp-notification-subscription-tools.ts +2 -1
- package/src/notification-rules.ts +17 -1
- package/src/notification-types.ts +18 -0
- package/src/reviewer-pipeline.ts +13 -0
- package/src/routes/agents-spawn.ts +3 -2
- package/src/routes/agents.ts +2 -0
- package/src/routes/commands.ts +14 -0
- package/src/routes/index.ts +2 -1
- package/src/routes/tasks.ts +19 -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 +116 -30
- package/src/services/task-entity.ts +28 -6
- package/src/services/task-lifecycle.ts +36 -7
- package/src/services/task-semantic-events.ts +241 -13
- package/src/spawn-command.ts +3 -0
- package/src/workspace-auto-merge.ts +17 -0
- package/public/assets/PlanGraphPanel-LY-msOsr.js +0 -2
- package/public/assets/PlanGraphPanel-LY-msOsr.js.map +0 -1
- 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,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,
|
|
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 (
|
|
200
|
-
throw new ValidationError(`cwd '${
|
|
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 =
|
|
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.
|
|
258
|
-
|
|
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 =
|
|
349
|
-
if (
|
|
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 =
|
|
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
|
-
...(
|
|
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:
|
|
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
|
-
...(
|
|
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:
|
|
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
|
-
...(
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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);
|