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
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/agents.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { ValidationError, getDb } from "./connection.ts";
|
|
|
22
22
|
import { rowToAgent, rowToContextSnapshot, rowToQuotaSnapshot } from "./mappers.ts";
|
|
23
23
|
import { closeOpenPairsForAgent } from "./pair-maintenance.ts";
|
|
24
24
|
import { parseStringArray } from "./shared.ts";
|
|
25
|
+
import { removeSubscriptionsForAgent } from "./notification-subscriptions.ts";
|
|
25
26
|
import { listAgentQuotaSnapshotsFromProviderStore } from "./provider-quotas.ts";
|
|
26
27
|
import { settleSingleTargetOnDemandTasks } from "./task-maintenance.ts";
|
|
27
28
|
import { claimTaskWhere } from "./task-core.ts";
|
|
@@ -544,6 +545,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
544
545
|
// Offline past the prune horizon and being deleted — a hard kill, not recoverable.
|
|
545
546
|
revokeRuntimeTokensForAgent(row.id, now, "pruned");
|
|
546
547
|
closeOpenPairsForAgent(row.id, now);
|
|
548
|
+
removeSubscriptionsForAgent(row.id);
|
|
547
549
|
}
|
|
548
550
|
|
|
549
551
|
getDb()
|
|
@@ -570,6 +572,7 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
|
570
572
|
getDb().query(`UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE ${claimTaskWhere()} AND claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')`).run(now, id);
|
|
571
573
|
revokeRuntimeTokensForAgent(id, now);
|
|
572
574
|
closeOpenPairsForAgent(id, now);
|
|
575
|
+
removeSubscriptionsForAgent(id);
|
|
573
576
|
return getDb().query("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
|
|
574
577
|
})();
|
|
575
578
|
return deleted ? { ok: true } : { ok: false, error: "agent not found" };
|
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
|
}
|
|
@@ -109,6 +109,21 @@ export function removeSubscription(input: {
|
|
|
109
109
|
return getDb().query(`DELETE FROM notification_subscriptions WHERE ${conditions.join(" AND ")}`).run(...params).changes;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export function removeSubscriptionsForAgent(agentId: string): number {
|
|
113
|
+
return getDb()
|
|
114
|
+
.query("DELETE FROM notification_subscriptions WHERE agent_id = ?")
|
|
115
|
+
.run(cleanAgentId(agentId))
|
|
116
|
+
.changes;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function removeSubscriptionsForTask(taskId: number): number {
|
|
120
|
+
if (!Number.isSafeInteger(taskId) || taskId <= 0) throw new ValidationError("taskId must be a positive integer");
|
|
121
|
+
return getDb()
|
|
122
|
+
.query("DELETE FROM notification_subscriptions WHERE json_extract(scope_match, '$.taskId') = ?")
|
|
123
|
+
.run(String(taskId))
|
|
124
|
+
.changes;
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
export function listSubscriptions(agentId: string): NotificationSubscription[] {
|
|
113
128
|
const rows = getDb().query(`
|
|
114
129
|
SELECT * FROM notification_subscriptions
|
|
@@ -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
|
|
|
@@ -541,6 +592,7 @@ export interface ListDeliverableTasksFilter {
|
|
|
541
592
|
status?: string;
|
|
542
593
|
deliverableStage?: string;
|
|
543
594
|
teamId?: string;
|
|
595
|
+
createdBy?: string;
|
|
544
596
|
limit?: number;
|
|
545
597
|
}
|
|
546
598
|
|
|
@@ -550,6 +602,7 @@ export function listDeliverableTasks(filter: ListDeliverableTasksFilter = {}): T
|
|
|
550
602
|
if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
|
|
551
603
|
if (filter.deliverableStage) { conditions.push("deliverable_stage = ?"); params.push(filter.deliverableStage); }
|
|
552
604
|
if (filter.teamId) { conditions.push("team_id = ?"); params.push(filter.teamId); }
|
|
605
|
+
if (filter.createdBy) { conditions.push("created_by = ?"); params.push(filter.createdBy); }
|
|
553
606
|
const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
|
|
554
607
|
params.push(limit);
|
|
555
608
|
return (getDb().query(`${TASK_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC LIMIT ?`).all(...params as any[]) as any[]).map(rowToTask);
|
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) {
|
|
@@ -21,8 +21,9 @@ import { getTaskEntity } from "./services/task-entity";
|
|
|
21
21
|
import { cleanString } from "./validation";
|
|
22
22
|
import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "./notification-types";
|
|
23
23
|
import { previewNotificationRouting, whyNotified } from "./notification-router";
|
|
24
|
+
import { TASK_SEMANTIC_NOTIFICATION_TYPES } from "./services/task-semantic-events";
|
|
24
25
|
|
|
25
|
-
const TASK_NOTIFICATION_TYPES =
|
|
26
|
+
const TASK_NOTIFICATION_TYPES = TASK_SEMANTIC_NOTIFICATION_TYPES as readonly NotificationType[];
|
|
26
27
|
|
|
27
28
|
export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
|
|
28
29
|
{
|
|
@@ -272,6 +272,22 @@ registerDefaultAudienceRules("message.new", [
|
|
|
272
272
|
audiencePredicates.threadParticipant(),
|
|
273
273
|
]);
|
|
274
274
|
|
|
275
|
-
for (const type of [
|
|
275
|
+
for (const type of [
|
|
276
|
+
"task.created",
|
|
277
|
+
"task.started",
|
|
278
|
+
"task.blocked",
|
|
279
|
+
"task.needs_decision",
|
|
280
|
+
"task.gate_failed",
|
|
281
|
+
"task.committed_on_branch",
|
|
282
|
+
"task.review_requested",
|
|
283
|
+
"task.land_conflict",
|
|
284
|
+
"task.landed",
|
|
285
|
+
"task.landed_on_main",
|
|
286
|
+
"task.closed",
|
|
287
|
+
"task.working",
|
|
288
|
+
"task.done",
|
|
289
|
+
"task.deploying",
|
|
290
|
+
"task.deployed",
|
|
291
|
+
] as const) {
|
|
276
292
|
registerDefaultAudienceRules(type, []);
|
|
277
293
|
}
|
|
@@ -7,8 +7,17 @@ export type NotificationType =
|
|
|
7
7
|
| "task.started"
|
|
8
8
|
| "task.blocked"
|
|
9
9
|
| "task.needs_decision"
|
|
10
|
+
| "task.gate_failed"
|
|
11
|
+
| "task.committed_on_branch"
|
|
12
|
+
| "task.review_requested"
|
|
13
|
+
| "task.land_conflict"
|
|
10
14
|
| "task.landed"
|
|
15
|
+
| "task.landed_on_main"
|
|
11
16
|
| "task.closed"
|
|
17
|
+
| "task.working"
|
|
18
|
+
| "task.done"
|
|
19
|
+
| "task.deploying"
|
|
20
|
+
| "task.deployed"
|
|
12
21
|
| "agent.ready"
|
|
13
22
|
| "agent.exited"
|
|
14
23
|
| "agent.spawn_failed"
|
|
@@ -94,8 +103,17 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
94
103
|
"task.started": null,
|
|
95
104
|
"task.blocked": null,
|
|
96
105
|
"task.needs_decision": null,
|
|
106
|
+
"task.gate_failed": null,
|
|
107
|
+
"task.committed_on_branch": null,
|
|
108
|
+
"task.review_requested": null,
|
|
109
|
+
"task.land_conflict": null,
|
|
97
110
|
"task.landed": null,
|
|
111
|
+
"task.landed_on_main": null,
|
|
98
112
|
"task.closed": null,
|
|
113
|
+
"task.working": null,
|
|
114
|
+
"task.done": null,
|
|
115
|
+
"task.deploying": null,
|
|
116
|
+
"task.deployed": null,
|
|
99
117
|
"agent.ready": null,
|
|
100
118
|
"agent.exited": null,
|
|
101
119
|
"agent.spawn_failed": null,
|
package/src/reviewer-pipeline.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getSpawnPolicy } from "./config-store";
|
|
|
5
5
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
6
6
|
import { routeNotification } from "./notification-router";
|
|
7
7
|
import { getWorkspace, listAgents, patchWorkspaceMetadata, updateWorkspaceStatus } from "./db";
|
|
8
|
+
import { emitWorkspaceTaskSemanticNotifications } from "./services/task-lifecycle";
|
|
8
9
|
import type { Command, WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
9
10
|
|
|
10
11
|
type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED";
|
|
@@ -59,6 +60,18 @@ export function handleReviewerMergeResult(command: Command, result: Record<strin
|
|
|
59
60
|
compareRef,
|
|
60
61
|
});
|
|
61
62
|
if (!updated) return false;
|
|
63
|
+
emitWorkspaceTaskSemanticNotifications({
|
|
64
|
+
workspace: updated,
|
|
65
|
+
transition: "review-requested",
|
|
66
|
+
actor: "reviewer-pipeline",
|
|
67
|
+
occurrenceKey: `workspace-review:${workspace.id}:${command.id}`,
|
|
68
|
+
outcome: {
|
|
69
|
+
reviewer: target,
|
|
70
|
+
prUrl,
|
|
71
|
+
prNumber,
|
|
72
|
+
compareRef,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
62
75
|
|
|
63
76
|
// Obligation (ttl null by type default): a review request must reach the reviewer even if they're
|
|
64
77
|
// offline a while — it's not a bystander notice.
|
|
@@ -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/commands.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "../compaction-watch";
|
|
|
11
11
|
import { isRecord } from "agent-relay-sdk";
|
|
12
12
|
import { authContextFromRequest } from "../services/auth-context";
|
|
13
13
|
import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
|
|
14
|
+
import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
|
|
14
15
|
import { ServiceAuthError } from "../services/errors";
|
|
15
16
|
import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
|
|
16
17
|
import { applyIdleRefreshResult } from "../maintenance/workspaces";
|
|
@@ -305,6 +306,19 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
305
306
|
mergeError: command.error ?? "merge failed",
|
|
306
307
|
...noteMergeNoProgress(current.metadata),
|
|
307
308
|
});
|
|
309
|
+
if (nextStatus === "conflict") {
|
|
310
|
+
emitWorkspaceTaskSemanticNotifications({
|
|
311
|
+
workspace: current,
|
|
312
|
+
transition: "land-conflict",
|
|
313
|
+
actor: "workspace-merge",
|
|
314
|
+
occurrenceKey: `workspace-merge-conflict:${current.id}:${command.id}`,
|
|
315
|
+
outcome: {
|
|
316
|
+
reason: command.error ?? "merge failed",
|
|
317
|
+
commandId: command.id,
|
|
318
|
+
status: nextStatus,
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
308
322
|
notifyBranchLandFailed({
|
|
309
323
|
workspace: current,
|
|
310
324
|
commandId: command.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,19 @@ 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
|
+
createdBy: url.searchParams.get("createdBy") ?? undefined,
|
|
80
|
+
limit: limitRaw ?? 100,
|
|
81
|
+
}));
|
|
82
|
+
};
|
|
83
|
+
|
|
68
84
|
// --- #834 durable Task entity (kind='deliverable') HTTP surface ---
|
|
69
85
|
|
|
70
86
|
export const postTaskCreate: Handler = async (req) => {
|
|
@@ -328,7 +344,7 @@ export const postTaskDispatch: Handler = async (req, params) => {
|
|
|
328
344
|
const component = getComponentAuth(req);
|
|
329
345
|
const agentId = component ? agentIdFromComponent(component) : undefined;
|
|
330
346
|
const scopes = component?.scope ?? [];
|
|
331
|
-
if (!isTaskDispatchAuthorized(id, agentId, scopes)) return error("forbidden", 403);
|
|
347
|
+
if (!isFullReachRequest(req) && !isTaskDispatchAuthorized(id, agentId, scopes)) return error("forbidden", 403);
|
|
332
348
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
333
349
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
334
350
|
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
|