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
package/src/bus.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { getCommand, updateCommand } from "./commands-db";
|
|
|
7
7
|
import { emitCommandEvent } from "./command-events";
|
|
8
8
|
import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
|
|
9
9
|
import { reconcileTerminalProviderExit } from "./services/terminal-provider-exit";
|
|
10
|
-
import { registerAgent } from "./services/register-agent";
|
|
10
|
+
import { bindSpawnedTaskToReadyAgent, registerAgent } from "./services/register-agent";
|
|
11
11
|
import { authContextFromBus } from "./services/auth-context";
|
|
12
12
|
import { commandAuthorizationResource, dispatchCommand } from "./services/dispatch-command";
|
|
13
13
|
import { auditProviderStateTransition } from "./services/provider-state-audit";
|
|
@@ -182,6 +182,7 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
182
182
|
if (!setStatus(conn.agentId, frame.payload.agentStatus, guard)) {
|
|
183
183
|
return sendError(ws, frame.id, "AGENT_NOT_FOUND", "agent not found");
|
|
184
184
|
}
|
|
185
|
+
const readyTransition = frame.payload.ready === true && !before?.ready;
|
|
185
186
|
if (frame.payload.ready !== undefined && !markReady(conn.agentId, frame.payload.ready, guard)) {
|
|
186
187
|
return sendError(ws, frame.id, "AGENT_NOT_FOUND", "agent not found");
|
|
187
188
|
}
|
|
@@ -192,6 +193,7 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
192
193
|
auditProviderStateTransition(conn.agentId, before, after);
|
|
193
194
|
const timelineEvent = agentProviderTimelineEvent(after);
|
|
194
195
|
auditRunnerTimelineEvent(conn.agentId, timelineEvent);
|
|
196
|
+
if (readyTransition) bindSpawnedTaskToReadyAgent(conn.agentId, "bus-ready");
|
|
195
197
|
// A real PreCompact/SessionStart hook arrives as a timelineEvent in the
|
|
196
198
|
// merged meta — clears any pending stall watch (stale events ignored).
|
|
197
199
|
noteAgentTimelineEvent(conn.agentId, timelineEvent);
|
package/src/db/merge-lease.ts
CHANGED
|
@@ -231,7 +231,11 @@ export function acquireMergePause(input: {
|
|
|
231
231
|
|
|
232
232
|
export function renewMergePause(id: string, ttlMs: number = MAIN_MERGE_PAUSE_TTL_MS, now: number = Date.now()): MergePauseRecord | null {
|
|
233
233
|
const current = getMergePause(id);
|
|
234
|
-
if (!current
|
|
234
|
+
if (!current) return null;
|
|
235
|
+
// Re-establish even when the pause has just expired: the maintenance reaper runs on a
|
|
236
|
+
// ~60s cadence, so a tardy renew that arrives before the sweep can still extend the
|
|
237
|
+
// hold. Returns null only when the record is already gone (reaped or replaced by a
|
|
238
|
+
// concurrent acquirer via ON CONFLICT UPDATE, which changes the row id).
|
|
235
239
|
getDb().query("UPDATE workspace_merge_pauses SET updated_at = ?, expires_at = ? WHERE id = ?").run(now, now + ttlMs, id);
|
|
236
240
|
return getMergePause(id);
|
|
237
241
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TaskDetail,
|
|
3
|
+
TaskDispatch,
|
|
4
|
+
TaskDispatchDecisionProjection,
|
|
5
|
+
TaskDispatchWorkShapeProjection,
|
|
6
|
+
} from "../types";
|
|
7
|
+
import { parseJson } from "../utils";
|
|
8
|
+
import { getAgent } from "./agents.ts";
|
|
9
|
+
import { getDb } from "./connection.ts";
|
|
10
|
+
|
|
11
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function taskDispatchWorkShape(dispatch: TaskDispatch | undefined): TaskDispatchWorkShapeProjection {
|
|
16
|
+
return {
|
|
17
|
+
specified: dispatch !== undefined,
|
|
18
|
+
complexity: dispatch?.complexity ?? null,
|
|
19
|
+
type: dispatch?.type ?? null,
|
|
20
|
+
contextFootprint: dispatch?.contextFootprint ?? null,
|
|
21
|
+
criticality: dispatch?.criticality ?? null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function latestTaskDispatchDecision(id: number): TaskDispatchDecisionProjection | undefined {
|
|
26
|
+
const row = getDb().query(`
|
|
27
|
+
SELECT source, metadata, created_at FROM task_events
|
|
28
|
+
WHERE task_id = ? AND type = 'dispatch.resolved'
|
|
29
|
+
ORDER BY id DESC LIMIT 1
|
|
30
|
+
`).get(id) as { source: string; metadata: string; created_at: number } | undefined;
|
|
31
|
+
if (!row) return undefined;
|
|
32
|
+
const meta = parseJson<Record<string, unknown>>(row.metadata, {});
|
|
33
|
+
const decision = isObject(meta.decision) ? meta.decision : {};
|
|
34
|
+
const target = isObject(decision.target) ? decision.target : {};
|
|
35
|
+
const warnings = Array.isArray(decision.warnings)
|
|
36
|
+
? decision.warnings.filter((item): item is string => typeof item === "string")
|
|
37
|
+
: [];
|
|
38
|
+
return {
|
|
39
|
+
target: {
|
|
40
|
+
...(typeof target.provider === "string" ? { provider: target.provider } : {}),
|
|
41
|
+
...(typeof target.model === "string" ? { model: target.model } : {}),
|
|
42
|
+
...(typeof target.effort === "string" ? { effort: target.effort } : {}),
|
|
43
|
+
},
|
|
44
|
+
rationale: typeof decision.rationale === "string" ? decision.rationale : row.source,
|
|
45
|
+
pinned: decision.pinned === true,
|
|
46
|
+
...(isObject(decision.factors) ? { factors: decision.factors } : {}),
|
|
47
|
+
warnings,
|
|
48
|
+
source: typeof meta.source === "string" ? meta.source : row.source,
|
|
49
|
+
decidedAt: row.created_at,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function taskBoundWorkerModel(agentId: string | undefined): TaskDetail["boundWorkerModel"] {
|
|
54
|
+
if (!agentId) return undefined;
|
|
55
|
+
const agent = getAgent(agentId);
|
|
56
|
+
const model = agent?.providerCapabilities?.model;
|
|
57
|
+
return {
|
|
58
|
+
agentId,
|
|
59
|
+
...(typeof model?.provider === "string" ? { provider: model.provider } : {}),
|
|
60
|
+
...(typeof model?.id === "string" ? { model: model.id } : typeof model?.alias === "string" ? { model: model.alias } : {}),
|
|
61
|
+
...(typeof model?.effort === "string" ? { effort: model.effort } : {}),
|
|
62
|
+
...(typeof model?.source === "string" ? { source: model.source } : {}),
|
|
63
|
+
...(typeof model?.confidence === "string" ? { confidence: model.confidence } : {}),
|
|
64
|
+
...(typeof model?.lastUpdatedAt === "number" ? { lastUpdatedAt: model.lastUpdatedAt } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/db/task-entity.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { parseJson } from "../utils";
|
|
29
29
|
import { getDb, ValidationError } from "./connection.ts";
|
|
30
30
|
import { rowToTask } from "./mappers.ts";
|
|
31
|
+
import { latestTaskDispatchDecision, taskBoundWorkerModel, taskDispatchWorkShape } from "./task-detail-projection.ts";
|
|
31
32
|
import { TASK_SELECT, insertTaskEvent, DELIVERABLE_TASK_KIND } from "./task-core.ts";
|
|
32
33
|
import { associateIssue, listIssuesForEntity, removeAssociation } from "./issue-associations.ts";
|
|
33
34
|
import { resolveIssueRefFromString } from "./issue-refs.ts";
|
|
@@ -483,6 +484,18 @@ export interface RecordTaskSpawnInput {
|
|
|
483
484
|
steer?: string;
|
|
484
485
|
}
|
|
485
486
|
|
|
487
|
+
export interface RecordTaskDispatchDecisionInput {
|
|
488
|
+
decision: {
|
|
489
|
+
target?: unknown;
|
|
490
|
+
rationale?: string;
|
|
491
|
+
pinned?: boolean;
|
|
492
|
+
factors?: unknown;
|
|
493
|
+
warnings?: readonly unknown[];
|
|
494
|
+
};
|
|
495
|
+
source: string;
|
|
496
|
+
actor: string;
|
|
497
|
+
}
|
|
498
|
+
|
|
486
499
|
// Idempotent by spawnRequestId: a retried spawn (orchestrator re-dispatch, client retry) reusing
|
|
487
500
|
// the SAME spawnRequestId must NOT append a second attribution event. The append-only `task_events`
|
|
488
501
|
// is the ledger, so we check-before-insert on the spawnRequestId stamped in metadata (mirrors the
|
|
@@ -512,6 +525,35 @@ export function recordTaskSpawnRequested(id: number, input: RecordTaskSpawnInput
|
|
|
512
525
|
return getTaskDetail(id)!;
|
|
513
526
|
}
|
|
514
527
|
|
|
528
|
+
export function recordTaskDispatchDecision(id: number, input: RecordTaskDispatchDecisionInput): TaskDetail {
|
|
529
|
+
requireDeliverable(id);
|
|
530
|
+
const source = requireText(input.source, "source", 120);
|
|
531
|
+
const actor = requireText(input.actor, "actor", 200);
|
|
532
|
+
const target = isObject(input.decision.target) ? input.decision.target : {};
|
|
533
|
+
const rationale = optionalText(input.decision.rationale, "rationale", 4000) ?? "No dispatch rationale recorded";
|
|
534
|
+
const warnings = Array.isArray(input.decision.warnings)
|
|
535
|
+
? input.decision.warnings.filter((item): item is string => typeof item === "string")
|
|
536
|
+
: [];
|
|
537
|
+
insertTaskEvent(id, {
|
|
538
|
+
source: actor,
|
|
539
|
+
type: "dispatch.resolved",
|
|
540
|
+
severity: warnings.length ? "warning" : "info",
|
|
541
|
+
title: `Dispatch resolved: ${typeof target.provider === "string" ? target.provider : "provider"}`,
|
|
542
|
+
body: rationale,
|
|
543
|
+
metadata: {
|
|
544
|
+
source,
|
|
545
|
+
decision: {
|
|
546
|
+
target,
|
|
547
|
+
rationale,
|
|
548
|
+
pinned: input.decision.pinned === true,
|
|
549
|
+
...(isObject(input.decision.factors) ? { factors: input.decision.factors } : {}),
|
|
550
|
+
warnings,
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
}, Date.now());
|
|
554
|
+
return getTaskDetail(id)!;
|
|
555
|
+
}
|
|
556
|
+
|
|
515
557
|
// --- Reads / projections.
|
|
516
558
|
function issueLinks(id: number): TaskIssueLink[] {
|
|
517
559
|
return listIssuesForEntity("task", taskEntityId(String(id))).map((item) => ({
|
|
@@ -525,15 +567,24 @@ function issueLinks(id: number): TaskIssueLink[] {
|
|
|
525
567
|
}));
|
|
526
568
|
}
|
|
527
569
|
|
|
570
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
571
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
572
|
+
}
|
|
573
|
+
|
|
528
574
|
export function getTaskDetail(id: number): TaskDetail | null {
|
|
529
575
|
const task = getRawTask(id);
|
|
530
576
|
if (!task || task.kind !== DELIVERABLE_TASK_KIND) return null;
|
|
577
|
+
const decision = latestTaskDispatchDecision(id);
|
|
578
|
+
const workerModel = taskBoundWorkerModel(task.ownerAgentId);
|
|
531
579
|
return {
|
|
532
580
|
...task,
|
|
533
581
|
issues: issueLinks(id),
|
|
534
582
|
dependsOn: listTaskDependencies(id),
|
|
535
583
|
dependents: listTaskDependents(id),
|
|
536
584
|
gates: latestGates(id),
|
|
585
|
+
dispatchWorkShape: taskDispatchWorkShape(task.dispatch),
|
|
586
|
+
...(decision ? { dispatchDecision: decision } : {}),
|
|
587
|
+
...(workerModel ? { boundWorkerModel: workerModel } : {}),
|
|
537
588
|
};
|
|
538
589
|
}
|
|
539
590
|
|
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -24,7 +24,7 @@ const SPAWN_LAND_STRATEGIES = ["direct", "pr"] as const;
|
|
|
24
24
|
export const SPAWN_TOOLS: ToolDefinition[] = [
|
|
25
25
|
{
|
|
26
26
|
name: "relay_spawn_agent",
|
|
27
|
-
description: `Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via \`prompt\`
|
|
27
|
+
description: `Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via \`prompt\` or binding it to a durable Task via \`taskId\` + \`steer\` so Relay hydrates the first turn server-side. Defaults to your own host (override with orchestratorId), applies profile \`${DEFAULT_SPAWN_PROFILE_NAME}\` when profile is omitted, and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).`,
|
|
28
28
|
requiredScopes: ["command:spawn"],
|
|
29
29
|
inputSchema: {
|
|
30
30
|
type: "object",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { McpNotFoundError } from "./mcp-errors";
|
|
10
10
|
import { ValidationError } from "./db";
|
|
11
11
|
import { authContextFromMcp } from "./services/auth-context";
|
|
12
|
-
import { addTaskEntityDependency, createTaskEntity, getTaskEntity } from "./services/task-entity";
|
|
12
|
+
import { addTaskEntityDependency, createTaskEntity, getTaskEntity, recordTaskEntityDispatchDecision } from "./services/task-entity";
|
|
13
13
|
import { dispatchTask, type DispatchPin } from "./task-dispatcher";
|
|
14
14
|
import { cleanString } from "./validation";
|
|
15
15
|
import {
|
|
@@ -145,6 +145,9 @@ export function runTaskDispatch(input: RunTaskDispatchInput): RunTaskDispatchRes
|
|
|
145
145
|
if (!shape && !inlineShape) shape = {};
|
|
146
146
|
|
|
147
147
|
const decision = dispatchTask(shape, pin);
|
|
148
|
+
if (taskId !== undefined) {
|
|
149
|
+
recordTaskEntityDispatchDecision(taskId, { decision, source: "dispatch", actor });
|
|
150
|
+
}
|
|
148
151
|
|
|
149
152
|
let reviewTaskId: number | undefined;
|
|
150
153
|
if (decision.review && createReview && taskId !== undefined) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: agents-spawn.
|
|
2
|
-
import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
|
|
3
3
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
4
4
|
import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
|
|
5
5
|
import { ValidationError, listAgents } from "../db";
|
|
@@ -15,7 +15,7 @@ import { authContextFromRequest } from "../services/auth-context";
|
|
|
15
15
|
import { listHostDirectories } from "../agent-spawn";
|
|
16
16
|
import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
|
|
17
17
|
import { quotaStateFromUnknown } from "../quota";
|
|
18
|
-
import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMode } from "../types";
|
|
18
|
+
import { type AgentKind, type AgentLifecycle, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMode } from "../types";
|
|
19
19
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
20
20
|
|
|
21
21
|
const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
|
|
@@ -128,6 +128,7 @@ export const postAgentSpawn: Handler = async (req) => {
|
|
|
128
128
|
effort: cleanString(parsed.body.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
|
|
129
129
|
approvalMode: optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
|
|
130
130
|
workspaceMode: optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
131
|
+
lifecycle: optionalEnum(parsed.body.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined,
|
|
131
132
|
label: cleanString(parsed.body.label, "label", { max: 120 }),
|
|
132
133
|
policyName: cleanString(parsed.body.policyName, "policyName", { max: 120 }),
|
|
133
134
|
profile: cleanString(parsed.body.profile, "profile", { max: 120 }),
|
package/src/routes/agents.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { cleanStringArray } from "../validation";
|
|
|
7
7
|
import { clearActiveMemories, memoryBroker } from "../memory-service";
|
|
8
8
|
import { emitAgentRemoved, emitAgentStatus } from "../sse";
|
|
9
9
|
import { notifyAgentReady } from "../agent-lifecycle-events";
|
|
10
|
+
import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
|
|
10
11
|
import { flushQueuedDirectMessages } from "../services/managed-running";
|
|
11
12
|
import { isAgentUnavailable } from "../db/agent-predicates";
|
|
12
13
|
import { getCompactionWatch } from "../compaction-watch";
|
|
@@ -299,6 +300,7 @@ export const patchAgentReady: Handler = async (req, params) => {
|
|
|
299
300
|
// agents and dedups repeat readies.
|
|
300
301
|
if (body.ready && !before?.ready) {
|
|
301
302
|
notifyAgentReady(params.id!);
|
|
303
|
+
bindSpawnedTaskToReadyAgent(params.id!, "agent-ready");
|
|
302
304
|
// #416 — also flush queued direct messages: a not-ready agent that becomes ready
|
|
303
305
|
// may have queued messages waiting (e.g. starting up, clearing context).
|
|
304
306
|
flushQueuedDirectMessages(params.id!);
|
package/src/routes/index.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, get
|
|
|
45
45
|
import { getSettingRoute, getSettingsNamespaceRoute, getSettingsRoute, putSettingRoute, putSettingsDefaultRoute } from "./settings";
|
|
46
46
|
import { postNotificationPreviewRoute } from "./notifications";
|
|
47
47
|
import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
|
|
48
|
-
import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim, postTaskCreate, getTaskDetailRoute, getTaskMemoryRoute, getTaskHistoryRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge, postTaskDispatch } from "./tasks";
|
|
48
|
+
import { getTaskById, getTaskEntities, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim, postTaskCreate, getTaskDetailRoute, getTaskMemoryRoute, getTaskHistoryRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge, postTaskDispatch } from "./tasks";
|
|
49
49
|
import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
|
|
50
50
|
import { postTeamDissolveByIdRoute } from "./teams";
|
|
51
51
|
import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
|
|
@@ -350,6 +350,7 @@ const routes: Route[] = [
|
|
|
350
350
|
route("PATCH", "/api/automations/:id", patchAutomation),
|
|
351
351
|
route("DELETE", "/api/automations/:id", deleteAutomationById),
|
|
352
352
|
route("POST", "/api/automations/:id/run", postAutomationRun),
|
|
353
|
+
route("GET", "/api/task-entities", getTaskEntities),
|
|
353
354
|
route("GET", "/api/tasks", getTasks),
|
|
354
355
|
route("POST", "/api/tasks", postTaskCreate),
|
|
355
356
|
route("GET", "/api/tasks/:id", getTaskById),
|
package/src/routes/tasks.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: tasks.
|
|
2
2
|
import { MAX_BODY_BYTES } from "../config";
|
|
3
3
|
import { VALID_TASK_STATUSES, agentSessionStatus, auditEvent, dispatchTaskCallbacks, emitCommand, error, json, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, resolveActorLabel, type Handler } from "./_shared";
|
|
4
|
-
import { getComponentAuth } from "../security";
|
|
4
|
+
import { getComponentAuth, isFullReachRequest } from "../security";
|
|
5
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
5
6
|
import { agentIdFromComponent, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
|
|
6
7
|
import { dispatchShapeArg, pinArg, runTaskDispatch } from "../mcp-dispatch-tools";
|
|
7
8
|
import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus } from "../db";
|
|
@@ -18,7 +19,9 @@ import { errMessage, isRecord, TASK_DELIVERABLE_STAGES, type TaskBlocker, type T
|
|
|
18
19
|
import { type AgentSessionGuard, type TaskStatus, type TaskStatusInput } from "../types";
|
|
19
20
|
|
|
20
21
|
function actorOf(req: Request, fallback?: unknown): string {
|
|
21
|
-
|
|
22
|
+
if (typeof fallback === "string" && fallback.trim()) return fallback.trim();
|
|
23
|
+
const ctx = authContextFromRequest(req);
|
|
24
|
+
return ctx.callerAgentId ?? resolveActorLabel(req) ?? (ctx.actor.id !== "system" ? ctx.actor.id : "dashboard");
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
function taskDetailOr404(id: number) {
|
|
@@ -65,6 +68,18 @@ export const getTasks: Handler = (req) => {
|
|
|
65
68
|
}));
|
|
66
69
|
};
|
|
67
70
|
|
|
71
|
+
export const getTaskEntities: Handler = (req) => {
|
|
72
|
+
const url = new URL(req.url);
|
|
73
|
+
const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
|
|
74
|
+
if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
|
|
75
|
+
return json(listTaskEntities({
|
|
76
|
+
status: url.searchParams.get("status") ?? undefined,
|
|
77
|
+
deliverableStage: url.searchParams.get("deliverableStage") ?? undefined,
|
|
78
|
+
teamId: url.searchParams.get("teamId") ?? undefined,
|
|
79
|
+
limit: limitRaw ?? 100,
|
|
80
|
+
}));
|
|
81
|
+
};
|
|
82
|
+
|
|
68
83
|
// --- #834 durable Task entity (kind='deliverable') HTTP surface ---
|
|
69
84
|
|
|
70
85
|
export const postTaskCreate: Handler = async (req) => {
|
|
@@ -328,7 +343,7 @@ export const postTaskDispatch: Handler = async (req, params) => {
|
|
|
328
343
|
const component = getComponentAuth(req);
|
|
329
344
|
const agentId = component ? agentIdFromComponent(component) : undefined;
|
|
330
345
|
const scopes = component?.scope ?? [];
|
|
331
|
-
if (!isTaskDispatchAuthorized(id, agentId, scopes)) return error("forbidden", 403);
|
|
346
|
+
if (!isFullReachRequest(req) && !isTaskDispatchAuthorized(id, agentId, scopes)) return error("forbidden", 403);
|
|
332
347
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
333
348
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
334
349
|
const body = parsed.body ?? {};
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -94,6 +94,7 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
94
94
|
label?: string;
|
|
95
95
|
policyName?: string;
|
|
96
96
|
spawnRequestId?: string;
|
|
97
|
+
taskId?: number;
|
|
97
98
|
createdBy?: string;
|
|
98
99
|
/**
|
|
99
100
|
* Agent profile name. The spawn grant (the `command:spawn`/`command:shutdown` scope + the
|
|
@@ -128,6 +129,7 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
128
129
|
cwdPrefixes: [input.cwd],
|
|
129
130
|
...(input.policyName ? { policies: [input.policyName] } : {}),
|
|
130
131
|
...(input.spawnRequestId ? { spawnRequestIds: [input.spawnRequestId] } : {}),
|
|
132
|
+
...(input.taskId !== undefined ? { taskIds: [String(input.taskId)] } : {}),
|
|
131
133
|
...(grant.canSpawn && grant.maxSpawnedAgents ? { maxSpawnedAgents: grant.maxSpawnedAgents } : {}),
|
|
132
134
|
...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
|
|
133
135
|
...(input.teamId ? { teamId: input.teamId } : {}),
|
|
@@ -250,6 +252,7 @@ export function runnerRuntimeTokenEnv(input: {
|
|
|
250
252
|
label?: string;
|
|
251
253
|
policyName?: string;
|
|
252
254
|
spawnRequestId?: string;
|
|
255
|
+
taskId?: number;
|
|
253
256
|
createdBy?: string;
|
|
254
257
|
/** Agent profile name — spawn grant is resolved from it in the mint (see issueRunnerRuntimeToken). */
|
|
255
258
|
profile?: string;
|
|
@@ -266,6 +269,7 @@ export function runnerRuntimeTokenEnv(input: {
|
|
|
266
269
|
AGENT_RELAY_TOKEN_JTI: issued.record.jti,
|
|
267
270
|
AGENT_RELAY_TOKEN_PROFILE: issued.record.profileId ?? "provider-agent",
|
|
268
271
|
...(issued.record.expiresAt ? { AGENT_RELAY_TOKEN_EXPIRES_AT: String(issued.record.expiresAt) } : {}),
|
|
272
|
+
...(input.taskId !== undefined ? { AGENT_RELAY_TASK_ID: String(input.taskId) } : {}),
|
|
269
273
|
};
|
|
270
274
|
}
|
|
271
275
|
|
package/src/security.ts
CHANGED
|
@@ -87,6 +87,10 @@ export function isAuthorized(req: Request): boolean {
|
|
|
87
87
|
return Boolean(presented && verifyComponentToken(presented));
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
export function isFullReachRequest(req: Request): boolean {
|
|
91
|
+
return Boolean(extractToken(req)) && isAuthorized(req) && !getComponentAuth(req) && !getIntegrationAuth(req);
|
|
92
|
+
}
|
|
93
|
+
|
|
90
94
|
type IntegrationAuth = IntegrationTokenConfig;
|
|
91
95
|
|
|
92
96
|
type AuthorizationResource = {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// managed came-up-running reconcile (delegated to managed-running.ts), the single
|
|
10
10
|
// status broadcast, the single timeline note, the spawned-child parent-wake, and
|
|
11
11
|
// the first-registration audit row.
|
|
12
|
-
import { isReservedAgentId } from "agent-relay-sdk";
|
|
13
|
-
import { ValidationError, createActivityEvent, getAgent, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
12
|
+
import { errMessage, isReservedAgentId } from "agent-relay-sdk";
|
|
13
|
+
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
14
14
|
import { emitAgentStatus, emitNewMessage } from "../sse";
|
|
15
15
|
import { noteAgentTimelineEvent } from "../compaction-watch";
|
|
16
16
|
import { agentProviderTimelineEvent } from "../provider-timeline-event";
|
|
@@ -19,6 +19,7 @@ import { resolveSpawnLineage, resolveTeamLineage } from "../security";
|
|
|
19
19
|
import { flushQueuedDirectMessages, markManagedAgentRunning } from "./managed-running";
|
|
20
20
|
import { isAgentUnavailable } from "../db/agent-predicates";
|
|
21
21
|
import { recordSpawnPromptIssueActivity } from "./spawn-issue-activity";
|
|
22
|
+
import { assignTaskEntityAgent, setTaskEntityStatus } from "./task-entity";
|
|
22
23
|
import type { AuthContext } from "./auth-context";
|
|
23
24
|
import type { AgentCard, RegisterAgentInput } from "../types";
|
|
24
25
|
|
|
@@ -27,6 +28,48 @@ function metaStr(meta: Record<string, unknown> | undefined, key: string): string
|
|
|
27
28
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
function metaTaskId(meta: Record<string, unknown> | undefined): number | undefined {
|
|
32
|
+
const value = meta?.taskId;
|
|
33
|
+
const parsed = typeof value === "string" ? Number(value) : value;
|
|
34
|
+
return typeof parsed === "number" && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function taskIdsForReadyAgent(agent: AgentCard): number[] {
|
|
38
|
+
const ids = new Set<number>();
|
|
39
|
+
const directTaskId = metaTaskId(agent.meta);
|
|
40
|
+
if (directTaskId !== undefined) ids.add(directTaskId);
|
|
41
|
+
const spawnRequestId = metaStr(agent.meta, "spawnRequestId");
|
|
42
|
+
if (spawnRequestId) {
|
|
43
|
+
for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
|
|
44
|
+
}
|
|
45
|
+
return [...ids].sort((a, b) => a - b);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function bindSpawnedTaskToReadyAgent(agentId: string, actor = "agent-relay"): void {
|
|
49
|
+
const agent = getAgent(agentId);
|
|
50
|
+
if (!agent?.ready) return;
|
|
51
|
+
const taskIds = taskIdsForReadyAgent(agent);
|
|
52
|
+
for (const taskId of taskIds) {
|
|
53
|
+
try {
|
|
54
|
+
const before = getTaskDetail(taskId);
|
|
55
|
+
if (!before) continue;
|
|
56
|
+
if (before.ownerAgentId && before.ownerAgentId !== agent.id) continue;
|
|
57
|
+
const assigned = before.ownerAgentId === agent.id
|
|
58
|
+
? before
|
|
59
|
+
: assignTaskEntityAgent(taskId, agent.id, "worker", agent.spawnedBy ?? actor);
|
|
60
|
+
if (assigned.status === "open" || assigned.status === "claimed") {
|
|
61
|
+
setTaskEntityStatus(taskId, {
|
|
62
|
+
status: "in_progress",
|
|
63
|
+
note: `worker ${agent.id} ready`,
|
|
64
|
+
actor: agent.spawnedBy ?? actor,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.warn(`task ${taskId} ready binding skipped for ${agent.id}: ${errMessage(error)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
30
73
|
export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): AgentCard {
|
|
31
74
|
// Reserved built-in identities — the `system` lifecycle sender and the whole `user:` human
|
|
32
75
|
// namespace (legacy bare `'user'`, `user:default`, every `user:<id>`) — are mechanical sinks,
|
|
@@ -73,6 +116,7 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
|
|
|
73
116
|
// case) wakes its block-polling parent. Fire on the first-ever ready, regardless of
|
|
74
117
|
// transport. notifyAgentReady no-ops for non-spawned agents and dedups repeats.
|
|
75
118
|
if (agent.ready && !existing?.ready) notifyAgentReady(agent.id);
|
|
119
|
+
if (agent.ready && !existing?.ready) bindSpawnedTaskToReadyAgent(agent.id, ctx.actor.id);
|
|
76
120
|
|
|
77
121
|
// #416 — flush queued direct messages when a re-registering agent was previously unavailable
|
|
78
122
|
// (stale, compacting, blocked) or a new agent becomes ready. No-op for brand-new agents
|