agent-relay-server 0.120.6 → 0.121.1
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 +1 -1
- package/package.json +5 -4
- package/public/assets/{MessageBubble-So86mU9-.js → MessageBubble-CLqxCG9b.js} +2 -2
- package/public/assets/{MessageBubble-So86mU9-.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-DBZYAsww.js → PlanGraphPanel-BkkToFEb.js} +2 -2
- package/public/assets/{PlanGraphPanel-DBZYAsww.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
- package/public/assets/{activity-CZNwmXg1.js → activity-D-ocGQGs.js} +2 -2
- package/public/assets/{activity-CZNwmXg1.js.map → activity-D-ocGQGs.js.map} +1 -1
- package/public/assets/{agent-profiles-BhadoGwi.js → agent-profiles-CRPgYTyb.js} +2 -2
- package/public/assets/{agent-profiles-BhadoGwi.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
- package/public/assets/{agents-DWn1xx9x.js → agents-Chw5E_2q.js} +2 -2
- package/public/assets/{agents-DWn1xx9x.js.map → agents-Chw5E_2q.js.map} +1 -1
- package/public/assets/{analytics-DifGnk7q.js → analytics-BmNDG0hR.js} +2 -2
- package/public/assets/{analytics-DifGnk7q.js.map → analytics-BmNDG0hR.js.map} +1 -1
- package/public/assets/{automation-BExrtKkb.js → automation-D7g90kTv.js} +2 -2
- package/public/assets/{automation-BExrtKkb.js.map → automation-D7g90kTv.js.map} +1 -1
- package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
- package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
- package/public/assets/{channels-BLkdt2XR.js → channels-CLwPeEPi.js} +2 -2
- package/public/assets/{channels-BLkdt2XR.js.map → channels-CLwPeEPi.js.map} +1 -1
- package/public/assets/{chat-C7OhKtr6.js → chat-Cgj7VKzc.js} +2 -2
- package/public/assets/{chat-C7OhKtr6.js.map → chat-Cgj7VKzc.js.map} +1 -1
- package/public/assets/{connectors-C0n12Je8.js → connectors-Br6fiTny.js} +2 -2
- package/public/assets/{connectors-C0n12Je8.js.map → connectors-Br6fiTny.js.map} +1 -1
- package/public/assets/{formatted-body-BCQT2qSb.js → formatted-body-CmkuD23M.js} +3 -3
- package/public/assets/{formatted-body-BCQT2qSb.js.map → formatted-body-CmkuD23M.js.map} +1 -1
- package/public/assets/{formatted-body-impl-Bk7wnESl.js → formatted-body-impl-DbdFmbwW.js} +2 -2
- package/public/assets/{formatted-body-impl-Bk7wnESl.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
- package/public/assets/{index-CRraCeo_.js → index-D9OogaB5.js} +6 -6
- package/public/assets/{index-CRraCeo_.js.map → index-D9OogaB5.js.map} +1 -1
- package/public/assets/{insights-DEgES6jz.js → insights-qvaPqWCV.js} +2 -2
- package/public/assets/{insights-DEgES6jz.js.map → insights-qvaPqWCV.js.map} +1 -1
- package/public/assets/{integrations-v14ehskT.js → integrations-DYEGSqq_.js} +2 -2
- package/public/assets/{integrations-v14ehskT.js.map → integrations-DYEGSqq_.js.map} +1 -1
- package/public/assets/{maintenance-BgVB6_gO.js → maintenance-C0HIEB-J.js} +2 -2
- package/public/assets/{maintenance-BgVB6_gO.js.map → maintenance-C0HIEB-J.js.map} +1 -1
- package/public/assets/{managed-agents-8_NKSkhI.js → managed-agents-DdS7aI38.js} +2 -2
- package/public/assets/{managed-agents-8_NKSkhI.js.map → managed-agents-DdS7aI38.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-yxQkrd2M.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
- package/public/assets/{markdown-preview-impl-yxQkrd2M.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
- package/public/assets/{memory-CJD8eekg.js → memory-CSwx7bz8.js} +2 -2
- package/public/assets/{memory-CJD8eekg.js.map → memory-CSwx7bz8.js.map} +1 -1
- package/public/assets/{messages-DLHMZbHZ.js → messages-Be9CmvDv.js} +2 -2
- package/public/assets/{messages-DLHMZbHZ.js.map → messages-Be9CmvDv.js.map} +1 -1
- package/public/assets/{orchestrators-DEOep7FX.js → orchestrators-BgtvvHvo.js} +2 -2
- package/public/assets/{orchestrators-DEOep7FX.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
- package/public/assets/{overview-BgpfO6mt.js → overview-BvhbPWtF.js} +2 -2
- package/public/assets/{overview-BgpfO6mt.js.map → overview-BvhbPWtF.js.map} +1 -1
- package/public/assets/{pairs-8NVKHh96.js → pairs-CpXSOMx0.js} +2 -2
- package/public/assets/{pairs-8NVKHh96.js.map → pairs-CpXSOMx0.js.map} +1 -1
- package/public/assets/{projects-CZJW35C7.js → projects-DdPNPFJI.js} +2 -2
- package/public/assets/{projects-CZJW35C7.js.map → projects-DdPNPFJI.js.map} +1 -1
- package/public/assets/{prompt-settings-fqyLwUa-.js → prompt-settings-BpZse7-X.js} +2 -2
- package/public/assets/{prompt-settings-fqyLwUa-.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
- package/public/assets/{registry-BKbLL4mO.js → registry-pb1gTZEg.js} +2 -2
- package/public/assets/{registry-BKbLL4mO.js.map → registry-pb1gTZEg.js.map} +1 -1
- package/public/assets/{security-BlTFTbq0.js → security-BgcX1n9D.js} +2 -2
- package/public/assets/{security-BlTFTbq0.js.map → security-BgcX1n9D.js.map} +1 -1
- package/public/assets/{select-Zd_qh0uU.js → select-DZPVpKPv.js} +2 -2
- package/public/assets/{select-Zd_qh0uU.js.map → select-DZPVpKPv.js.map} +1 -1
- package/public/assets/{settings-nHuSVRkL.js → settings-hd5kzdRB.js} +4 -4
- package/public/assets/settings-hd5kzdRB.js.map +1 -0
- package/public/assets/store-DKTLubBT.js +9 -0
- package/public/assets/store-DKTLubBT.js.map +1 -0
- package/public/assets/{tasks-C3JhQ8vB.js → tasks-C5d2LU5E.js} +2 -2
- package/public/assets/{tasks-C3JhQ8vB.js.map → tasks-C5d2LU5E.js.map} +1 -1
- package/public/assets/{teams-C9E9Yt39.js → teams-B8f2pGJe.js} +2 -2
- package/public/assets/{teams-C9E9Yt39.js.map → teams-B8f2pGJe.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-FP0JlsvS.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
- package/public/assets/{terminal-viewer-impl-FP0JlsvS.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-Ema7Hy3s.js → work-queue-9imc7aNK.js} +2 -2
- package/public/assets/{work-queue-Ema7Hy3s.js.map → work-queue-9imc7aNK.js.map} +1 -1
- package/public/assets/{workspaces-DI317fMk.js → workspaces-CLYc3yF3.js} +2 -2
- package/public/assets/{workspaces-DI317fMk.js.map → workspaces-CLYc3yF3.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
- package/src/agent-card-projection.ts +39 -9
- package/src/agent-issue-repo.ts +10 -3
- package/src/agent-lifecycle-events.ts +23 -1
- package/src/agent-ref.ts +18 -3
- package/src/bus-outbox.ts +74 -1
- package/src/bus.ts +84 -5
- package/src/channel-target.ts +4 -0
- package/src/cli/steward.ts +15 -14
- package/src/commands-db.ts +63 -9
- package/src/config-store.ts +2 -0
- package/src/config.ts +54 -0
- package/src/db/claim-sql.ts +16 -0
- package/src/db/connection.ts +3 -1
- package/src/db/continuations.ts +44 -3
- package/src/db/index.ts +1 -0
- package/src/db/maintenance-worker.ts +91 -0
- package/src/db/message-reads.ts +34 -4
- package/src/db/messages.ts +147 -8
- package/src/db/migrations.ts +7 -0
- package/src/db/pairs.ts +1 -0
- package/src/db/retention.ts +130 -0
- package/src/db/schema.ts +11 -0
- package/src/db/stats.ts +22 -0
- package/src/event-loop-lag.ts +90 -0
- package/src/gate-resolver.ts +124 -0
- package/src/lifecycle-manager.ts +41 -10
- package/src/lifecycle-signal-registry.ts +87 -0
- package/src/maintenance/constants.ts +5 -1
- package/src/maintenance/jobs.ts +48 -8
- package/src/maintenance/workspaces.ts +75 -44
- package/src/managed-policy-escalation.ts +68 -3
- package/src/mcp/tools-spawn.ts +16 -6
- package/src/mcp-plan-tools.ts +2 -3
- package/src/notification-settings.ts +11 -0
- package/src/notification-types.ts +4 -0
- package/src/operator-alarm.ts +37 -0
- package/src/retention-config-store.ts +32 -0
- package/src/retention-config.ts +58 -0
- package/src/routes/agents.ts +4 -0
- package/src/routes/messages.ts +13 -1
- package/src/routes/orchestrator-proxy.ts +13 -1
- package/src/routes/orchestrator.ts +4 -0
- package/src/routing-policy-store.ts +11 -1
- package/src/security.ts +20 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +4 -0
- package/src/services/plan-graph.ts +42 -12
- package/src/services/register-agent.ts +5 -1
- package/src/services/send-message.ts +99 -21
- package/src/services/spawn-agent.ts +21 -2
- package/src/services/spawn-registration-watchdog.ts +74 -9
- package/src/services/task-semantic-events.ts +17 -1
- package/src/settings/registry.ts +11 -0
- package/src/spawn-command.ts +3 -0
- package/src/spawn-targets.ts +26 -1
- package/src/sse.ts +67 -14
- package/src/workspace-auto-merge.ts +90 -34
- package/src/workspace-escalation.ts +52 -3
- package/src/workspace-human-escalation.ts +68 -0
- package/src/workspace-orphans.ts +11 -6
- package/src/workspace-phase.ts +22 -1
- package/public/assets/settings-nHuSVRkL.js.map +0 -1
- package/public/assets/store-nXGxgz2Q.js +0 -9
- package/public/assets/store-nXGxgz2Q.js.map +0 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
-
import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, setPlanArchived, ValidationError } from "../db";
|
|
2
|
+
import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, sendMessage, setPlanArchived, ValidationError } from "../db";
|
|
3
3
|
import { associateIssue, planNodeIssueEntityId, removeAssociation } from "../db/issue-associations";
|
|
4
4
|
import { resolveIssueRefFromString } from "../db/issue-refs";
|
|
5
5
|
import { emitRelayEvent } from "../events";
|
|
6
6
|
import { cleanString, cleanStringArray, optionalEnum } from "../validation";
|
|
7
7
|
import { defaultIssueRepo } from "../config";
|
|
8
|
+
import { issueRepoForAgent } from "../agent-issue-repo";
|
|
8
9
|
import { buildTaskGraphView, parseTaskId, planStatusToTaskStatus, projectPlanOverTasks } from "./task-plan-view";
|
|
9
10
|
import { createTaskEntity, setTaskEntityStatus } from "./task-entity";
|
|
10
11
|
import type { AuthContext } from "./auth-context";
|
|
@@ -75,14 +76,25 @@ function cleanOwnerRef(value: unknown): PlanOwnerRef | undefined {
|
|
|
75
76
|
return Object.keys(out).length ? out : undefined;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
// #220: a bare `#NNN` issueId ref needs a default repo to resolve against — issue-refs.ts
|
|
80
|
+
// throws without one, contrary to its own error text advertising the bare form as accepted.
|
|
81
|
+
// Resolve it the same way #533 resolves chat `#NNN` linkify: the CALLING agent's own project
|
|
82
|
+
// (cwd → project → issueRepo), falling back to the single global env default. This scopes the
|
|
83
|
+
// default to the actor's actual repo rather than guessing from teamId — agents have no
|
|
84
|
+
// team→project FK; cwd is the only durable link (see agent-issue-repo.ts).
|
|
85
|
+
function resolveDefaultIssueRepo(actor: string): string | undefined {
|
|
86
|
+
const agent = getAgent(actor);
|
|
87
|
+
return (agent ? issueRepoForAgent(agent) : undefined) ?? defaultIssueRepo();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cleanRefs(value: unknown, actor: string): PlanNode["refs"] | undefined {
|
|
79
91
|
if (value === undefined || value === null) return undefined;
|
|
80
92
|
const ref = cleanRecordRef(value, "refs");
|
|
81
93
|
if (!ref) return undefined;
|
|
82
94
|
const out: PlanNode["refs"] = {};
|
|
83
95
|
const issueId = cleanString(ref.issueId, "refs.issueId", { max: 1000 });
|
|
84
96
|
if (issueId) {
|
|
85
|
-
out.issueId = resolveIssueRefFromString({ ref: issueId, defaultRepo:
|
|
97
|
+
out.issueId = resolveIssueRefFromString({ ref: issueId, defaultRepo: resolveDefaultIssueRepo(actor) }).id;
|
|
86
98
|
}
|
|
87
99
|
// #836: refs.taskId is the live binding seam to a durable Task. Validate it resolves to a
|
|
88
100
|
// deliverable Task; store the canonical numeric id as a string. Once bound, the node's
|
|
@@ -147,12 +159,12 @@ function applyBoundStatus(node: PlanNode): PlanNode {
|
|
|
147
159
|
return { ...node, status: boundStatusForAgent(getAgent(agentId)) ?? "pending" };
|
|
148
160
|
}
|
|
149
161
|
|
|
150
|
-
function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
162
|
+
function cleanNode(input: PlanNodeInput, actor: string, planTeamId?: string): PlanNode {
|
|
151
163
|
if (!isRecord(input)) throw new ValidationError("node must be an object");
|
|
152
164
|
const scopeBlurb = cleanString(input.scopeBlurb, "node.scopeBlurb", { max: 2000 });
|
|
153
165
|
const ownerRef = cleanOwnerRef(input.ownerRef);
|
|
154
166
|
const lane = cleanString(input.lane, "node.lane", { max: 120 });
|
|
155
|
-
const refs = cleanRefs(input.refs);
|
|
167
|
+
const refs = cleanRefs(input.refs, actor);
|
|
156
168
|
const blackboardRefs = cleanBlackboardRefs(input.blackboardRefs, planTeamId);
|
|
157
169
|
const statusSource = cleanStatusSource(input.statusSource);
|
|
158
170
|
return applyBoundStatus({
|
|
@@ -168,10 +180,10 @@ function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
|
168
180
|
});
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
function cleanNodes(value: unknown, planTeamId?: string): PlanNode[] {
|
|
183
|
+
function cleanNodes(value: unknown, actor: string, planTeamId?: string): PlanNode[] {
|
|
172
184
|
if (value === undefined || value === null) return [];
|
|
173
185
|
if (!Array.isArray(value)) throw new ValidationError("nodes must be an array");
|
|
174
|
-
const nodes = value.map((item) => cleanNode(item as PlanNodeInput, planTeamId));
|
|
186
|
+
const nodes = value.map((item) => cleanNode(item as PlanNodeInput, actor, planTeamId));
|
|
175
187
|
assertUnique(nodes.map((node) => node.id), "node id");
|
|
176
188
|
return nodes;
|
|
177
189
|
}
|
|
@@ -252,14 +264,32 @@ function emitPlan(type: "plan.created" | "plan.changed" | "plan.deleted", plan:
|
|
|
252
264
|
emitRelayEvent({ type, source: actor, subject: plan.id, data: { plan, actor } });
|
|
253
265
|
}
|
|
254
266
|
|
|
267
|
+
// #1035: a plan anchors to a messages.thread_id, but a fresh coordinator has no thread yet —
|
|
268
|
+
// forcing it to send a throwaway message just to mint one. When no threadId is supplied, mint the
|
|
269
|
+
// thread server-side by self-threading a lightweight system message (the same sendMessage path the
|
|
270
|
+
// caller would have used). Sent AS the actor when that agent is registered, else the reserved
|
|
271
|
+
// `system` sink, so the mint always passes the sender-registration gate.
|
|
272
|
+
function mintPlanThread(actor: string, channel: string | undefined): number {
|
|
273
|
+
const sender = getAgent(actor) ? actor : "system";
|
|
274
|
+
const root = sendMessage({
|
|
275
|
+
from: sender,
|
|
276
|
+
to: "system",
|
|
277
|
+
body: "Plan thread root",
|
|
278
|
+
...(channel ? { channel } : {}),
|
|
279
|
+
});
|
|
280
|
+
return root.threadId ?? root.id;
|
|
281
|
+
}
|
|
282
|
+
|
|
255
283
|
export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
|
|
256
|
-
const threadId = cleanThreadId(input.threadId);
|
|
257
|
-
if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
|
|
258
284
|
const channel = cleanString(input.channel, "channel", { max: 120 });
|
|
259
285
|
const actor = actorId(ctx);
|
|
286
|
+
const threadId = input.threadId === undefined || input.threadId === null
|
|
287
|
+
? mintPlanThread(actor, channel)
|
|
288
|
+
: cleanThreadId(input.threadId);
|
|
289
|
+
if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
|
|
260
290
|
const explicitTeamId = cleanString(input.teamId, "teamId", { max: 120 });
|
|
261
291
|
const teamId = explicitTeamId ?? (input.teamId === undefined || input.teamId === null ? getAgent(actor)?.teamId : undefined);
|
|
262
|
-
const nodes = cleanNodes(input.nodes, teamId);
|
|
292
|
+
const nodes = cleanNodes(input.nodes, actor, teamId);
|
|
263
293
|
const edges = cleanEdges(input.edges);
|
|
264
294
|
validateEdges(nodes, edges);
|
|
265
295
|
const plan = createPlanRow({
|
|
@@ -279,7 +309,7 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
|
|
|
279
309
|
|
|
280
310
|
export function addPlanNode(planId: string, nodeInput: PlanNodeInput, ctx: AuthContext): Plan {
|
|
281
311
|
const plan = existingPlan(planId);
|
|
282
|
-
const node = cleanNode(nodeInput, plan.teamId);
|
|
312
|
+
const node = cleanNode(nodeInput, actorId(ctx), plan.teamId);
|
|
283
313
|
if (plan.nodes.some((item) => item.id === node.id)) throw new ValidationError(`node ${node.id} already exists`);
|
|
284
314
|
const updated = replacePlan({ ...plan, nodes: [...plan.nodes, node] }, actorId(ctx));
|
|
285
315
|
syncPlanNodeIssueAssociation(updated.id, node);
|
|
@@ -299,7 +329,7 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
|
|
|
299
329
|
...(patch.ownerRef !== undefined ? optionalProp("ownerRef", cleanOwnerRef(patch.ownerRef)) : {}),
|
|
300
330
|
...(patch.lane !== undefined ? optionalProp("lane", cleanString(patch.lane, "lane", { max: 120 })) : {}),
|
|
301
331
|
...(patch.status !== undefined ? { status: optionalEnum(patch.status, "status", PLAN_STATUSES) as PlanStatus } : {}),
|
|
302
|
-
...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs)) : {}),
|
|
332
|
+
...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs, actorId(ctx))) : {}),
|
|
303
333
|
...(patch.blackboardRefs !== undefined ? optionalProp("blackboardRefs", cleanBlackboardRefs(patch.blackboardRefs, plan.teamId)) : {}),
|
|
304
334
|
statusSource,
|
|
305
335
|
};
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// status broadcast, the single timeline note, the spawned-child parent-wake, and
|
|
11
11
|
// the first-registration audit row.
|
|
12
12
|
import { errMessage, isReservedAgentId } from "agent-relay-sdk";
|
|
13
|
-
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
13
|
+
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, 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";
|
|
@@ -128,6 +128,10 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
|
|
|
128
128
|
// spawner so it can't idle with its verdict undelivered (vent #121). First-registration only.
|
|
129
129
|
const spawnRequestId = metaStr(agent.meta, "spawnRequestId");
|
|
130
130
|
const provider = metaStr(agent.meta, "provider");
|
|
131
|
+
// #1037 — stamp the spawner-chosen report-up verbosity onto the child's meta from the persisted
|
|
132
|
+
// spawn command, so the lineage report-up delivery gate can read it (defaults to quiet if absent).
|
|
133
|
+
const reportVerbosity = readSpawnReportVerbosity(spawnRequestId);
|
|
134
|
+
if (reportVerbosity) mergeAgentMeta(agent.id, { reportVerbosity });
|
|
131
135
|
const promptMirror = seedSpawnPromptMirror(agent.id, spawnRequestId);
|
|
132
136
|
if (promptMirror?.created || promptMirror?.updated) emitNewMessage(promptMirror.message);
|
|
133
137
|
seedSpawnReplyObligation(agent.id, agent.spawnedBy, spawnRequestId);
|
|
@@ -24,15 +24,19 @@
|
|
|
24
24
|
import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
|
|
25
25
|
import {
|
|
26
26
|
ValidationError,
|
|
27
|
+
flushDeferredLineageReport,
|
|
27
28
|
getAgent,
|
|
28
29
|
getMessage,
|
|
29
30
|
listAgents,
|
|
31
|
+
listRecentFinalResponseCaptures,
|
|
30
32
|
promoteLineageCapturedResponse,
|
|
33
|
+
sendMessagesBatchWithResult,
|
|
31
34
|
sendMessageWithResult,
|
|
32
35
|
channelObligationAnsweredBy,
|
|
36
|
+
type SendMessageResult as DbSendMessageResult,
|
|
33
37
|
} from "../db";
|
|
34
38
|
import { planSend, type DeliveryReceipt } from "../agent-ref";
|
|
35
|
-
import { emitMessageQueued,
|
|
39
|
+
import { emitMessageQueued, emitNewMessages } from "../sse";
|
|
36
40
|
import { getLifecycleManager } from "../lifecycle-manager";
|
|
37
41
|
import { isComponentAuthorizedFor, isIntegrationAllowed } from "../security";
|
|
38
42
|
import { AmbiguousTargetError, ServiceAuthError } from "./errors";
|
|
@@ -175,6 +179,42 @@ export function sendMessageService(
|
|
|
175
179
|
ctx: AuthContext,
|
|
176
180
|
opts: SendMessageOptions = {},
|
|
177
181
|
): SendMessageResult {
|
|
182
|
+
const { receipt, suppressedParent } = prepareSendMessage(input, ctx, opts);
|
|
183
|
+
if (suppressedParent) return { message: suppressedParent, created: false, receipt };
|
|
184
|
+
const result = sendMessageWithResult(input);
|
|
185
|
+
emitSendResults([result]);
|
|
186
|
+
return { message: result.message, created: result.created, receipt };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function sendMessagesBatchService(
|
|
190
|
+
inputs: SendMessageInput[],
|
|
191
|
+
ctx: AuthContext,
|
|
192
|
+
opts: SendMessageOptions = {},
|
|
193
|
+
): SendMessageResult[] {
|
|
194
|
+
const prepared = inputs.map((input) => ({ input, ...prepareSendMessage(input, ctx, opts) }));
|
|
195
|
+
const results: SendMessageResult[] = [];
|
|
196
|
+
const storeInputs: SendMessageInput[] = [];
|
|
197
|
+
const storeIndexes: number[] = [];
|
|
198
|
+
for (const item of prepared) {
|
|
199
|
+
if (item.suppressedParent) {
|
|
200
|
+
results.push({ message: item.suppressedParent, created: false, receipt: item.receipt });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
results.push(null as unknown as SendMessageResult);
|
|
204
|
+
storeIndexes.push(results.length - 1);
|
|
205
|
+
storeInputs.push(item.input);
|
|
206
|
+
}
|
|
207
|
+
const stored = sendMessagesBatchWithResult(storeInputs);
|
|
208
|
+
for (let i = 0; i < stored.length; i++) {
|
|
209
|
+
const result = stored[i]!;
|
|
210
|
+
const outputIndex = storeIndexes[i]!;
|
|
211
|
+
results[outputIndex] = { message: result.message, created: result.created, receipt: prepared[outputIndex]!.receipt };
|
|
212
|
+
}
|
|
213
|
+
emitSendResults(stored);
|
|
214
|
+
return results;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function prepareSendMessage(input: SendMessageInput, ctx: AuthContext, opts: SendMessageOptions): { receipt: DeliveryReceipt; suppressedParent: Message | null } {
|
|
178
218
|
const receipt = validateSendMessageAuthorization(input, ctx, opts);
|
|
179
219
|
// Reply-obligation linking: some real answers are ordinary sends without replyTo/thread linkage.
|
|
180
220
|
// Channel bridge replies have a single unambiguous open obligation case;
|
|
@@ -184,29 +224,67 @@ export function sendMessageService(
|
|
|
184
224
|
const answered = channelObligationAnsweredBy(input.from, input.to);
|
|
185
225
|
if (answered !== null) input.replyTo = answered;
|
|
186
226
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
227
|
+
return { receipt, suppressedParent: suppressResultsOnlyAck(input) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* #1037 — the child transitioned out of busy (idle/awaiting-input). Deliver its deferred turn-final
|
|
232
|
+
* report-up (a quiet child's last turn, held back so mid-work narration never reached the spawner)
|
|
233
|
+
* and fan it out over SSE. No-op when nothing was deferred. Driven off the bus turn-end edge.
|
|
234
|
+
*/
|
|
235
|
+
export function flushIdleLineageReport(childId: string): void {
|
|
236
|
+
const promoted = flushDeferredLineageReport(childId);
|
|
237
|
+
if (promoted?.created) emitNewMessages([promoted.message]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** How far back the startup sweep re-scans for held-but-lost turn-final captures (#1040). A report
|
|
241
|
+
* is only ever deferred while its child is mid-turn, so any capture lost across a restart is recent;
|
|
242
|
+
* a day is a generous ceiling that still bounds the scan. */
|
|
243
|
+
const LINEAGE_STARTUP_SWEEP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* #1040 — startup re-promotion sweep. `deferredLineageReports` is an in-memory Map, so a relay
|
|
247
|
+
* restart drops every held turn-final report-up. The captures are persisted, though, so re-run
|
|
248
|
+
* promotion for recent final response captures: an idle child whose report was lost gets it
|
|
249
|
+
* delivered now, an already-delivered one is a no-op (idempotency key), and a still-busy child
|
|
250
|
+
* re-defers (re-arming the in-memory hold so a later idle edge flushes it). Idempotent and safe to
|
|
251
|
+
* run periodically.
|
|
252
|
+
*/
|
|
253
|
+
export function sweepDeferredLineageReportsOnStartup(now: number = Date.now()): { promoted: number; scanned: number } {
|
|
254
|
+
const captures = listRecentFinalResponseCaptures(now - LINEAGE_STARTUP_SWEEP_WINDOW_MS);
|
|
255
|
+
const emitted: Message[] = [];
|
|
256
|
+
for (const capture of captures) {
|
|
257
|
+
const promoted = promoteLineageCapturedResponse(capture);
|
|
258
|
+
if (promoted?.created) emitted.push(promoted.message);
|
|
259
|
+
}
|
|
260
|
+
if (emitted.length > 0) emitNewMessages(emitted);
|
|
261
|
+
return { promoted: emitted.length, scanned: captures.length };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function emitSendResults(results: DbSendMessageResult[]): void {
|
|
265
|
+
const liveMessages: Message[] = [];
|
|
266
|
+
for (const result of results) {
|
|
267
|
+
if (result.created) {
|
|
268
|
+
if (result.message.deliveryStatus === "queued") {
|
|
269
|
+
emitMessageQueued(result.message);
|
|
270
|
+
// Wake an on-demand managed policy that has no live agent attached yet.
|
|
271
|
+
if (result.message.to?.startsWith("policy:")) {
|
|
272
|
+
getLifecycleManager().onMessageForPolicy(result.message.to.slice("policy:".length));
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
liveMessages.push(result.message);
|
|
196
276
|
}
|
|
197
|
-
|
|
198
|
-
|
|
277
|
+
const promoted = promoteLineageCapturedResponse(result.message);
|
|
278
|
+
if (promoted?.created) liveMessages.push(promoted.message);
|
|
279
|
+
} else if (result.updated) {
|
|
280
|
+
// Session-step upsert (#parity): the row changed in place (same DB id). Re-emit so the
|
|
281
|
+
// dashboard's id-keyed merge replaces the existing step live — no duplicate row.
|
|
282
|
+
liveMessages.push(result.message);
|
|
283
|
+
const promoted = promoteLineageCapturedResponse(result.message);
|
|
284
|
+
if (promoted?.created) liveMessages.push(promoted.message);
|
|
199
285
|
}
|
|
200
|
-
const promoted = promoteLineageCapturedResponse(result.message);
|
|
201
|
-
if (promoted?.created) emitNewMessage(promoted.message);
|
|
202
|
-
} else if (result.updated) {
|
|
203
|
-
// Session-step upsert (#parity): the row changed in place (same DB id). Re-emit so the
|
|
204
|
-
// dashboard's id-keyed merge replaces the existing step live — no duplicate row.
|
|
205
|
-
emitNewMessage(result.message);
|
|
206
|
-
const promoted = promoteLineageCapturedResponse(result.message);
|
|
207
|
-
if (promoted?.created) emitNewMessage(promoted.message);
|
|
208
286
|
}
|
|
209
|
-
|
|
287
|
+
emitNewMessages(liveMessages);
|
|
210
288
|
}
|
|
211
289
|
|
|
212
290
|
function suppressResultsOnlyAck(input: SendMessageInput): Message | null {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// shared `buildSpawnCommand`/`buildManagedSpawnParams` payload home directly. Folding
|
|
24
24
|
// them through `spawnAgent(systemCtx)` is a clean belt-and-suspenders follow-up (#342),
|
|
25
25
|
// deliberately out of scope here — this slice converges the two external TRANSPORTS.
|
|
26
|
-
import type { SpawnProvider } from "agent-relay-sdk";
|
|
26
|
+
import type { ReportVerbosity, SpawnProvider } from "agent-relay-sdk";
|
|
27
27
|
import { DEFAULT_USER_ID, errMessage, isRecord, stringValue } from "agent-relay-sdk";
|
|
28
28
|
import { statSync } from "node:fs";
|
|
29
29
|
import { dirname, resolve } from "node:path";
|
|
@@ -211,6 +211,12 @@ export interface SpawnAgentInput {
|
|
|
211
211
|
* Explicit true is preserved in the command payload for compatibility only.
|
|
212
212
|
*/
|
|
213
213
|
expectReply?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* #1037 — per-spawn report-up verbosity (`quiet` default | `normal` | `verbose`). Persisted in
|
|
216
|
+
* the spawn command params and stamped onto the child's meta at registration, where the lineage
|
|
217
|
+
* report-up delivery gate reads it. Absent = `quiet`.
|
|
218
|
+
*/
|
|
219
|
+
reportVerbosity?: ReportVerbosity;
|
|
214
220
|
tags?: string[];
|
|
215
221
|
capabilities?: string[];
|
|
216
222
|
providerArgs?: string[];
|
|
@@ -249,6 +255,8 @@ export interface SpawnAgentResult {
|
|
|
249
255
|
agentId: string | null;
|
|
250
256
|
registered: boolean;
|
|
251
257
|
command: Command;
|
|
258
|
+
/** #1033 — the durable Task this spawn is bound to (explicit taskId or auto-scaffolded), when any. */
|
|
259
|
+
taskId?: number;
|
|
252
260
|
task?: { id: number; autoScaffolded: boolean; nudge: string };
|
|
253
261
|
}
|
|
254
262
|
|
|
@@ -332,7 +340,16 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
332
340
|
|
|
333
341
|
// #255/#328 — bias the host to the caller's own machine when neither an explicit
|
|
334
342
|
// orchestratorId nor a cwd pins it, so an agent spawning a helper lands on its own host.
|
|
335
|
-
|
|
343
|
+
// #1034 — pass the token's orchestrator allow-set so host selection can NEVER land on a host
|
|
344
|
+
// the caller isn't authorized for (which would then trip assertResourceAllowed with a
|
|
345
|
+
// misleading `-32001 component token cannot access this resource` — the concurrent-spawn flap).
|
|
346
|
+
const orchestrator = selectSpawnOrchestrator(
|
|
347
|
+
input.provider,
|
|
348
|
+
input.orchestratorId,
|
|
349
|
+
requestedCwd,
|
|
350
|
+
caller?.machine ?? lineageAgent?.machine,
|
|
351
|
+
ctx.constraints?.orchestrators,
|
|
352
|
+
);
|
|
336
353
|
|
|
337
354
|
// #308 §3 — an explicit cwd must resolve within the TARGET host's base dir (a path valid on
|
|
338
355
|
// your host may not exist on a different orchestrator).
|
|
@@ -638,6 +655,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
638
655
|
resumeWorkspace: input.resumeWorkspace,
|
|
639
656
|
acquisition,
|
|
640
657
|
...(expectReply ? { expectReply } : {}),
|
|
658
|
+
...(input.reportVerbosity ? { reportVerbosity: input.reportVerbosity } : {}),
|
|
641
659
|
policyName: input.policyName,
|
|
642
660
|
spawnRequestId,
|
|
643
661
|
env,
|
|
@@ -727,6 +745,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
727
745
|
agentId,
|
|
728
746
|
registered: agentId !== null,
|
|
729
747
|
command,
|
|
748
|
+
...(input.taskId !== undefined ? { taskId: input.taskId } : {}),
|
|
730
749
|
...(autoScaffoldedTask ? { task: autoScaffoldedTask } : {}),
|
|
731
750
|
};
|
|
732
751
|
}
|
|
@@ -20,12 +20,21 @@ interface SpawnCommandGroup {
|
|
|
20
20
|
rows: SpawnCommandRow[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
interface SpawnCandidateRow {
|
|
24
|
+
spawn_request_id: string;
|
|
25
|
+
latest_updated_at: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
interface SpawnRegistrationWatchdogResult extends Record<string, unknown> {
|
|
24
29
|
failedSpawnCommandIds: string[];
|
|
25
30
|
notifiedSpawnCommandIds: string[];
|
|
26
31
|
silencedSpawnCommandIds: string[];
|
|
32
|
+
confirmedSpawnCommandIds: string[];
|
|
33
|
+
checkedSpawnGroups: number;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
const MAX_SPAWN_GROUPS_PER_RUN = 25;
|
|
37
|
+
|
|
29
38
|
export function reapUnregisteredSpawnCommands(
|
|
30
39
|
now = Date.now(),
|
|
31
40
|
deadlineMs = SPAWN_REGISTRATION_DEADLINE_MS,
|
|
@@ -33,20 +42,40 @@ export function reapUnregisteredSpawnCommands(
|
|
|
33
42
|
): SpawnRegistrationWatchdogResult {
|
|
34
43
|
const cutoff = now - deadlineMs;
|
|
35
44
|
const notifyCutoff = now - notifyMaxAgeMs;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
const candidateRows = getDb().query(`
|
|
46
|
+
WITH spawn_groups AS (
|
|
47
|
+
SELECT
|
|
48
|
+
coalesce(nullif(json_extract(params, '$.spawnRequestId'), ''), correlation_id) AS spawn_request_id,
|
|
49
|
+
max(updated_at) AS latest_updated_at
|
|
50
|
+
FROM commands
|
|
51
|
+
WHERE type = 'agent.spawn'
|
|
52
|
+
AND status = 'succeeded'
|
|
53
|
+
AND json_valid(params)
|
|
54
|
+
AND coalesce(json_extract(params, '$.registrationConfirmed'), 0) != 1
|
|
55
|
+
AND coalesce(nullif(json_extract(params, '$.spawnedBy'), ''), '') != ''
|
|
56
|
+
GROUP BY coalesce(nullif(json_extract(params, '$.spawnRequestId'), ''), correlation_id)
|
|
57
|
+
)
|
|
58
|
+
SELECT spawn_request_id, latest_updated_at
|
|
59
|
+
FROM spawn_groups
|
|
60
|
+
WHERE spawn_request_id IS NOT NULL
|
|
61
|
+
AND latest_updated_at <= ?
|
|
62
|
+
ORDER BY latest_updated_at DESC
|
|
63
|
+
LIMIT ?
|
|
64
|
+
`).all(cutoff, MAX_SPAWN_GROUPS_PER_RUN) as SpawnCandidateRow[];
|
|
65
|
+
const rows = loadSpawnCommandRows(candidateRows.map((row) => row.spawn_request_id));
|
|
43
66
|
|
|
44
67
|
const failedSpawnCommandIds: string[] = [];
|
|
45
68
|
const notifiedSpawnCommandIds: string[] = [];
|
|
46
69
|
const silencedSpawnCommandIds: string[] = [];
|
|
70
|
+
const confirmedSpawnCommandIds: string[] = [];
|
|
71
|
+
let checkedSpawnGroups = 0;
|
|
47
72
|
for (const group of groupSpawnCommands(rows)) {
|
|
48
73
|
if (group.latestUpdatedAt > cutoff) continue;
|
|
49
|
-
|
|
74
|
+
checkedSpawnGroups++;
|
|
75
|
+
if (hasEverRegisteredAgent(group.spawnRequestId)) {
|
|
76
|
+
confirmedSpawnCommandIds.push(...markSpawnGroupRegistrationConfirmed(group, now));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
50
79
|
|
|
51
80
|
const reason = `spawned agent did not register within ${deadlineMs}ms`;
|
|
52
81
|
const failedCommands = group.rows
|
|
@@ -70,7 +99,25 @@ export function reapUnregisteredSpawnCommands(
|
|
|
70
99
|
}
|
|
71
100
|
}
|
|
72
101
|
|
|
73
|
-
return { failedSpawnCommandIds, notifiedSpawnCommandIds, silencedSpawnCommandIds };
|
|
102
|
+
return { failedSpawnCommandIds, notifiedSpawnCommandIds, silencedSpawnCommandIds, confirmedSpawnCommandIds, checkedSpawnGroups };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadSpawnCommandRows(spawnRequestIds: string[]): SpawnCommandRow[] {
|
|
106
|
+
if (spawnRequestIds.length === 0) return [];
|
|
107
|
+
const placeholders = spawnRequestIds.map(() => "?").join(",");
|
|
108
|
+
return getDb().query(`
|
|
109
|
+
SELECT id, params, correlation_id, updated_at
|
|
110
|
+
FROM commands
|
|
111
|
+
WHERE type = 'agent.spawn'
|
|
112
|
+
AND status = 'succeeded'
|
|
113
|
+
AND json_valid(params)
|
|
114
|
+
AND coalesce(json_extract(params, '$.registrationConfirmed'), 0) != 1
|
|
115
|
+
AND (
|
|
116
|
+
correlation_id IN (${placeholders})
|
|
117
|
+
OR json_extract(params, '$.spawnRequestId') IN (${placeholders})
|
|
118
|
+
)
|
|
119
|
+
ORDER BY updated_at ASC
|
|
120
|
+
`).all(...spawnRequestIds, ...spawnRequestIds) as SpawnCommandRow[];
|
|
74
121
|
}
|
|
75
122
|
|
|
76
123
|
function groupSpawnCommands(rows: SpawnCommandRow[]): SpawnCommandGroup[] {
|
|
@@ -121,6 +168,24 @@ function hasEverRegisteredAgent(spawnRequestId: string): boolean {
|
|
|
121
168
|
return Boolean(registration);
|
|
122
169
|
}
|
|
123
170
|
|
|
171
|
+
function markSpawnGroupRegistrationConfirmed(group: SpawnCommandGroup, now: number): string[] {
|
|
172
|
+
const confirmed: string[] = [];
|
|
173
|
+
const stmt = getDb().query(`
|
|
174
|
+
UPDATE commands
|
|
175
|
+
SET params = ?
|
|
176
|
+
WHERE id = ?
|
|
177
|
+
AND status = 'succeeded'
|
|
178
|
+
`);
|
|
179
|
+
for (const row of group.rows) {
|
|
180
|
+
const params = parseParams(row.params);
|
|
181
|
+
if (params.registrationConfirmed === true) continue;
|
|
182
|
+
params.registrationConfirmed = true;
|
|
183
|
+
params.registrationConfirmedAt = now;
|
|
184
|
+
if (stmt.run(JSON.stringify(params), row.id).changes > 0) confirmed.push(row.id);
|
|
185
|
+
}
|
|
186
|
+
return confirmed;
|
|
187
|
+
}
|
|
188
|
+
|
|
124
189
|
function parseParams(raw: string): Record<string, unknown> {
|
|
125
190
|
try {
|
|
126
191
|
const parsed = JSON.parse(raw);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getAgent } from "../db";
|
|
2
2
|
import { addSubscription } from "../db/notification-subscriptions";
|
|
3
3
|
import { emitRelayEvent } from "../events";
|
|
4
|
+
import { hasRecentLifecycleSignal, taskSignalKey } from "../lifecycle-signal-registry";
|
|
4
5
|
import { notifySystemMessage } from "../notify";
|
|
5
6
|
import type { NotificationScope, NotificationType } from "../notification-types";
|
|
6
7
|
import { routeNotification } from "../notification-router";
|
|
@@ -216,7 +217,18 @@ function dedupeRecipients(recipients: Array<{ agentId: string; matchedRule: stri
|
|
|
216
217
|
return out;
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
// #1032b — a status pulse is redundant when a first-class lifecycle event (agent.ready, the lineage
|
|
221
|
+
// report-up, branch.landed/merge) already conveyed this task's state to this recipient. Suppress it
|
|
222
|
+
// rather than waking the spawner a second time for the same fact.
|
|
223
|
+
// #1038 — keyed by the pulse's own transition: a progress signal (report-up / ready) supersedes only
|
|
224
|
+
// a redundant `working` pulse, never a later DISTINCT-state transition (committed / landed / done)
|
|
225
|
+
// for the same task within the TTL.
|
|
226
|
+
function pulseSupersededByLifecycle(agentId: string, taskId: number, transition: TaskSemanticTransition): boolean {
|
|
227
|
+
return hasRecentLifecycleSignal(agentId, taskSignalKey(taskId, transition));
|
|
228
|
+
}
|
|
229
|
+
|
|
219
230
|
function enqueueStatusPulse(agentId: string, item: StatusPulseDigestItem): void {
|
|
231
|
+
if (pulseSupersededByLifecycle(agentId, item.taskId, item.transition)) return; // #1032b/#1038
|
|
220
232
|
const batch = statusPulseBatches.get(agentId) ?? { items: [] };
|
|
221
233
|
batch.items.push(item);
|
|
222
234
|
if (!batch.timer) {
|
|
@@ -233,7 +245,11 @@ export function flushTaskStatusPulses(agentId?: string): void {
|
|
|
233
245
|
if (batch.timer) clearTimeout(batch.timer);
|
|
234
246
|
statusPulseBatches.delete(target);
|
|
235
247
|
if (batch.items.length === 0) continue;
|
|
236
|
-
|
|
248
|
+
// #1032b — a lifecycle event that fired AFTER these items were queued (but within the batch
|
|
249
|
+
// window) also supersedes them; drop those before flushing so the digest carries only news.
|
|
250
|
+
const fresh = batch.items.filter((item) => !pulseSupersededByLifecycle(target, item.taskId, item.transition));
|
|
251
|
+
if (fresh.length === 0) continue;
|
|
252
|
+
const items = compactStatusPulseItems(fresh);
|
|
237
253
|
notifySystemMessage(target, {
|
|
238
254
|
subject: `Task status pulse (${items.length})`,
|
|
239
255
|
body: taskStatusPulseBody(items),
|
package/src/settings/registry.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
NOTIFICATIONS_SETTINGS_SCHEMA,
|
|
36
36
|
parseNotificationSettingsKey,
|
|
37
37
|
} from "../notification-settings";
|
|
38
|
+
import { RETENTION_CONFIG_DEFAULTS, RETENTION_CONFIG_SCHEMA, RETENTION_KEY, RETENTION_NAMESPACE } from "../retention-config";
|
|
38
39
|
|
|
39
40
|
/** Fail-closed default scope — a descriptor that omits scope requires admin to
|
|
40
41
|
* read and write (#385 owns the scope grammar; this layer only declares it). */
|
|
@@ -400,6 +401,16 @@ function seedBuiltInSettings(): void {
|
|
|
400
401
|
display: { group: "Teams", label: "Team idle watchdog", description: "Coordinator nudge when a team has no busy members.", order: 45 },
|
|
401
402
|
});
|
|
402
403
|
|
|
404
|
+
registerSetting({
|
|
405
|
+
namespace: RETENTION_NAMESPACE,
|
|
406
|
+
key: RETENTION_KEY,
|
|
407
|
+
schema: RETENTION_CONFIG_SCHEMA,
|
|
408
|
+
defaults: RETENTION_CONFIG_DEFAULTS,
|
|
409
|
+
scope: { read: "settings:read:system", write: "settings:write:system" },
|
|
410
|
+
tier: "server-runtime",
|
|
411
|
+
display: { group: "System", label: "Data retention", description: "Retention windows and batch size for high-churn Relay tables.", order: 46 },
|
|
412
|
+
});
|
|
413
|
+
|
|
403
414
|
registerSetting({
|
|
404
415
|
namespace: "provider",
|
|
405
416
|
key: "chatCaptureMode",
|
package/src/spawn-command.ts
CHANGED
|
@@ -109,6 +109,8 @@ interface BuildSpawnCommandOptions {
|
|
|
109
109
|
* registration from the persisted spawn command params.
|
|
110
110
|
*/
|
|
111
111
|
expectReply?: boolean;
|
|
112
|
+
/** #1037 — per-spawn report-up verbosity, persisted so registration can stamp it onto child meta. */
|
|
113
|
+
reportVerbosity?: string;
|
|
112
114
|
profile?: string;
|
|
113
115
|
agentProfile?: unknown;
|
|
114
116
|
headless?: boolean;
|
|
@@ -174,6 +176,7 @@ export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string
|
|
|
174
176
|
...def(opts.reviewer, "reviewer"),
|
|
175
177
|
...def(opts.taskId, "taskId"),
|
|
176
178
|
...def(opts.expectReply, "expectReply"),
|
|
179
|
+
...def(opts.reportVerbosity, "reportVerbosity"),
|
|
177
180
|
...def(opts.headless, "headless"),
|
|
178
181
|
...def(opts.policyName, "policyName"),
|
|
179
182
|
...def(opts.automationId, "automationId"),
|
package/src/spawn-targets.ts
CHANGED
|
@@ -500,7 +500,25 @@ export function selectSpawnOrchestrator(
|
|
|
500
500
|
orchestratorId?: string,
|
|
501
501
|
cwd?: string,
|
|
502
502
|
preferHost?: string,
|
|
503
|
+
/**
|
|
504
|
+
* #1034 — the caller token's `constraints.orchestrators` allow-set. When present, host
|
|
505
|
+
* SELECTION must stay INSIDE it: never silently fall back to a host the token isn't
|
|
506
|
+
* authorized for. Previously, when neither an explicit id nor a cwd nor a resolvable
|
|
507
|
+
* `preferHost` pinned the host (e.g. the caller's `machine` was transiently unset, or the
|
|
508
|
+
* caller's own host briefly flapped offline), selection grabbed `candidates[0]` — an
|
|
509
|
+
* arbitrary online host. If that host was outside the token's allow-set, the downstream
|
|
510
|
+
* `assertResourceAllowed` gate then rejected the spawn with a MISLEADING `-32001 component
|
|
511
|
+
* token cannot access this resource`, which cleared "seconds later" once the caller's host
|
|
512
|
+
* re-appeared (issue #1034). Filtering candidates to the allow-set makes selection
|
|
513
|
+
* authz-consistent: for the common single-authorized-host token it deterministically returns
|
|
514
|
+
* that host regardless of transient machine/cwd resolution, so concurrent spawns no longer
|
|
515
|
+
* spuriously reject; when the authorized host is genuinely offline, the error is an honest
|
|
516
|
+
* "authorized host offline", not a confusing authz denial. Undefined/empty = no filtering
|
|
517
|
+
* (admin `*` tokens, byte-identical to before).
|
|
518
|
+
*/
|
|
519
|
+
allowedOrchestratorIds?: string[],
|
|
503
520
|
): Orchestrator {
|
|
521
|
+
const allowSet = allowedOrchestratorIds?.length ? new Set(allowedOrchestratorIds) : null;
|
|
504
522
|
if (orchestratorId) {
|
|
505
523
|
const orchestrator = getOrchestrator(orchestratorId);
|
|
506
524
|
if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
|
|
@@ -515,7 +533,9 @@ export function selectSpawnOrchestrator(
|
|
|
515
533
|
}
|
|
516
534
|
return orchestrator;
|
|
517
535
|
}
|
|
518
|
-
const candidates = listOrchestrators().filter(
|
|
536
|
+
const candidates = listOrchestrators().filter(
|
|
537
|
+
(item) => item.status === "online" && item.providers.includes(provider) && (!allowSet || allowSet.has(item.id)),
|
|
538
|
+
);
|
|
519
539
|
if (cwd) {
|
|
520
540
|
const match = candidates.find((item) => isPathWithinBase(cwd, item.baseDir));
|
|
521
541
|
if (match) return match;
|
|
@@ -530,6 +550,11 @@ export function selectSpawnOrchestrator(
|
|
|
530
550
|
}
|
|
531
551
|
const orchestrator = candidates[0];
|
|
532
552
|
if (!orchestrator) {
|
|
553
|
+
// #1034 — when the token's host allow-set is what emptied the candidate list, say so plainly
|
|
554
|
+
// (the caller's authorized host is offline) instead of the generic "no orchestrator" miss.
|
|
555
|
+
if (allowSet) {
|
|
556
|
+
throw new McpNotFoundError(`no online orchestrator authorized for this token runs provider '${provider}'. Your token is scoped to host(s): ${[...allowSet].join(", ")} — check that one is online (call relay_spawn_targets for the live matrix).`);
|
|
557
|
+
}
|
|
533
558
|
throw new McpNotFoundError(`no online orchestrator runs provider '${provider}'. Available: ${spawnAlternatives()} (call relay_spawn_targets for the live matrix).`);
|
|
534
559
|
}
|
|
535
560
|
return orchestrator;
|