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
package/src/config.ts
CHANGED
|
@@ -64,6 +64,10 @@ export const STUCK_BUSY_PROVIDER_TURN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUS
|
|
|
64
64
|
export const STUCK_BUSY_STARTUP_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_STARTUP_MS", STUCK_BUSY_THRESHOLD_MS);
|
|
65
65
|
export const STUCK_BUSY_RECONNECT_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_RECONNECT_MS", STUCK_BUSY_THRESHOLD_MS);
|
|
66
66
|
export const STUCK_BUSY_SHUTDOWN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_SHUTDOWN_MS", STUCK_BUSY_THRESHOLD_MS);
|
|
67
|
+
export const EVENT_LOOP_LAG_SAMPLE_MS = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_SAMPLE_MS", 1_000);
|
|
68
|
+
export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES", 60);
|
|
69
|
+
export const EVENT_LOOP_LAG_WARN_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_WARN_MS", 250);
|
|
70
|
+
export const EVENT_LOOP_LAG_LOG_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_LOG_MS", EVENT_LOOP_LAG_WARN_MS);
|
|
67
71
|
// #713 — provider-token silence ALONE is not a wedge signal for Claude (long turns, extended
|
|
68
72
|
// thinking, and native compaction all go token-silent while healthy). Before declaring stuck we
|
|
69
73
|
// ALSO require the transport heartbeat to be genuinely dead: no server frame / heartbeat-ack within
|
|
@@ -112,6 +116,56 @@ export function relayAuthHeaders(base: Record<string, string> = {}): Record<stri
|
|
|
112
116
|
return token ? { ...base, [RELAY_TOKEN_HEADER]: token } : base;
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
// #1024 — origins the orchestrator proxy may forward the relay MASTER token (relayToken()) to.
|
|
120
|
+
// A dynamically-registered orchestrator can carry an attacker-controlled apiUrl; forwarding the
|
|
121
|
+
// master token there leaks full relay admin. This is the defense-in-depth backstop behind the
|
|
122
|
+
// registration lock: even if some path seeds an orchestrator row, the proxy refuses to talk to an
|
|
123
|
+
// untrusted apiUrl at all (never sending the token OR the caller's query string). Explicit
|
|
124
|
+
// operator overrides — comma-separated origins, e.g. "https://orch.example.com".
|
|
125
|
+
export const TRUSTED_ORCHESTRATOR_ORIGINS = (process.env.AGENT_RELAY_TRUSTED_ORCHESTRATOR_ORIGINS || "")
|
|
126
|
+
.split(",")
|
|
127
|
+
.map((origin) => origin.trim())
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
|
|
130
|
+
// A private/LAN/tailnet IP literal — never a public, attacker-reachable address.
|
|
131
|
+
function isPrivateIpLiteral(host: string): boolean {
|
|
132
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
133
|
+
if (v4) {
|
|
134
|
+
const a = Number(v4[1]), b = Number(v4[2]);
|
|
135
|
+
if (a === 10 || a === 127) return true; // 10.0.0.0/8, loopback
|
|
136
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
137
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
138
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
|
139
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (tailnet)
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (/^f[cd][0-9a-f]*:/.test(host)) return true; // IPv6 ULA fc00::/7
|
|
143
|
+
if (/^fe[89ab][0-9a-f]*:/.test(host)) return true; // IPv6 link-local fe80::/10
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Whether the relay master token may be forwarded to this orchestrator apiUrl (#1024). Trusts
|
|
148
|
+
// loopback, bare single-label hostnames (LAN/MagicDNS — never a public FQDN), private DNS
|
|
149
|
+
// suffixes, private/CGNAT/ULA IP literals, and any explicit AGENT_RELAY_TRUSTED_ORCHESTRATOR_ORIGINS
|
|
150
|
+
// entry. A public IP or public FQDN (e.g. http://attacker.example) is refused unless allowlisted.
|
|
151
|
+
export function isTrustedOrchestratorApiUrl(apiUrl: string | undefined): boolean {
|
|
152
|
+
if (!apiUrl) return false;
|
|
153
|
+
let url: URL;
|
|
154
|
+
try {
|
|
155
|
+
url = new URL(apiUrl);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (TRUSTED_ORCHESTRATOR_ORIGINS.includes(url.origin)) return true;
|
|
160
|
+
let host = url.hostname.toLowerCase();
|
|
161
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // unwrap IPv6 literal
|
|
162
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1") return true;
|
|
163
|
+
// A bare single-label host (no dot, not an IPv6 literal) is a LAN/MagicDNS name, never public.
|
|
164
|
+
if (!host.includes(".") && !host.includes(":")) return true;
|
|
165
|
+
if (/\.(local|internal|lan|home|ts\.net|tailnet)$/.test(host)) return true;
|
|
166
|
+
return isPrivateIpLiteral(host);
|
|
167
|
+
}
|
|
168
|
+
|
|
115
169
|
export function allowUnauthenticated(): boolean {
|
|
116
170
|
return process.env.AGENT_RELAY_ALLOW_UNAUTH === "1";
|
|
117
171
|
}
|
package/src/db/claim-sql.ts
CHANGED
|
@@ -26,3 +26,19 @@ type MessageIdRef = "m.id" | "messages.id";
|
|
|
26
26
|
export function messageTaskClosed(messageRef: MessageIdRef): string {
|
|
27
27
|
return `EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = ${messageRef} AND t.status IN (${CLOSED_TASK_STATUS_SQL}))`;
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* SQL predicate: a claimable message that has NO linked task has already been handled by its claim
|
|
32
|
+
* holder. A claimable with a linked task settles via `messageTaskClosed`; one WITHOUT a task had no
|
|
33
|
+
* completion state at all — after the 30-min lease expired `releaseExpiredClaims` nulled the claim
|
|
34
|
+
* and the poll re-offered the finished work to every matching agent, forever (#1022 F6). The runner
|
|
35
|
+
* only marks a claimable read AFTER winning the claim (losers `continue` without reading), so a
|
|
36
|
+
* read receipt from any non-sender uniquely means "the holder took and processed it" — the missing
|
|
37
|
+
* settle signal. Gate the expired/unclaimed poll branch with `AND NOT ${claimableHandledNoTask(...)}`.
|
|
38
|
+
* `messageRef` is a fixed internal column reference, not user input.
|
|
39
|
+
*/
|
|
40
|
+
export function claimableHandledNoTask(messageRef: MessageIdRef): string {
|
|
41
|
+
const fromAgentRef = messageRef === "m.id" ? "m.from_agent" : "messages.from_agent";
|
|
42
|
+
return `(NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = ${messageRef}) ` +
|
|
43
|
+
`AND EXISTS (SELECT 1 FROM message_reads mr WHERE mr.message_id = ${messageRef} AND mr.agent_id <> ${fromAgentRef}))`;
|
|
44
|
+
}
|
package/src/db/connection.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { DB_SLOW_QUERY_MS } from "../config";
|
|
4
5
|
|
|
@@ -79,7 +80,8 @@ export async function runDbMaintenanceWorker<T>(operation: string, args: string[
|
|
|
79
80
|
throw new Error("database worker requires a file-backed database");
|
|
80
81
|
}
|
|
81
82
|
if (signal?.aborted) throw new Error("database worker aborted");
|
|
82
|
-
const
|
|
83
|
+
const bunPath = existsSync(process.execPath) ? process.execPath : (Bun.which("bun") ?? process.execPath);
|
|
84
|
+
const proc = Bun.spawn([bunPath, join(import.meta.dir, "maintenance-worker.ts"), operation, dbPath, ...args], {
|
|
83
85
|
stdin: "ignore",
|
|
84
86
|
stdout: "pipe",
|
|
85
87
|
stderr: "pipe",
|
package/src/db/continuations.ts
CHANGED
|
@@ -55,6 +55,16 @@ export function seedContinuationObjectiveFromSpawnPrompt(agentId: string, spawnR
|
|
|
55
55
|
return ensureContinuationEnvelope({ agentId, objective: params.prompt, config, now });
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// #1023 — the ruledOut ledger was append-only with no cap: cumulative resends
|
|
59
|
+
// duplicated entries verbatim, and nothing bounded total size, so one long-running
|
|
60
|
+
// agent could render a multi-megabyte continuation straight into its own fresh
|
|
61
|
+
// context. MAX_LEDGER_TOTAL_BYTES/MAX_LEDGER_ENTRIES bound what stays in the
|
|
62
|
+
// envelope; dedupe drops literal resends. Entries pushed out by the bound are
|
|
63
|
+
// returned as droppedLedgerEntries so the caller can archive them (still
|
|
64
|
+
// recallable via relay_recall) instead of silently destroying them.
|
|
65
|
+
const MAX_LEDGER_TOTAL_BYTES = 256 * 1024;
|
|
66
|
+
const MAX_LEDGER_ENTRIES = 500;
|
|
67
|
+
|
|
58
68
|
export function updateContinuationAfterCompact(input: {
|
|
59
69
|
agentId: string;
|
|
60
70
|
generation: number;
|
|
@@ -63,7 +73,7 @@ export function updateContinuationAfterCompact(input: {
|
|
|
63
73
|
archiveRefs?: string[];
|
|
64
74
|
spentTokens: number;
|
|
65
75
|
now?: number;
|
|
66
|
-
}): ContinuationEnvelope {
|
|
76
|
+
}): { envelope: ContinuationEnvelope; droppedLedgerEntries: ContinuationLedgerEntry[] } {
|
|
67
77
|
const existing = getContinuationEnvelope(input.agentId);
|
|
68
78
|
if (!existing) throw new ValidationError("continuation envelope not found");
|
|
69
79
|
const now = input.now ?? Date.now();
|
|
@@ -76,20 +86,51 @@ export function updateContinuationAfterCompact(input: {
|
|
|
76
86
|
tokens: existing.budget.spent.tokens + Math.max(0, input.spentTokens),
|
|
77
87
|
},
|
|
78
88
|
};
|
|
89
|
+
const { ledger, dropped } = mergeLedger(existing.ledger, input.ledgerEntries);
|
|
79
90
|
getDb().query(`
|
|
80
91
|
UPDATE agent_continuations
|
|
81
92
|
SET generation = ?, ledger = ?, working_state = ?, archive_refs = ?, budget = ?, updated_at = ?
|
|
82
93
|
WHERE agent_id = ?
|
|
83
94
|
`).run(
|
|
84
95
|
input.generation,
|
|
85
|
-
JSON.stringify(
|
|
96
|
+
JSON.stringify(ledger),
|
|
86
97
|
input.workingState,
|
|
87
98
|
JSON.stringify([...existing.archiveRefs, ...(input.archiveRefs ?? [])]),
|
|
88
99
|
JSON.stringify(nextBudget),
|
|
89
100
|
now,
|
|
90
101
|
input.agentId,
|
|
91
102
|
);
|
|
92
|
-
return getContinuationEnvelope(input.agentId)
|
|
103
|
+
return { envelope: getContinuationEnvelope(input.agentId)!, droppedLedgerEntries: dropped };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ledgerEntryKey(entry: ContinuationLedgerEntry): string {
|
|
107
|
+
return `${entry.ruledOut.trim().toLowerCase()} ${entry.why.trim().toLowerCase()}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ledgerEntryBytes(entry: ContinuationLedgerEntry): number {
|
|
111
|
+
return new TextEncoder().encode(entry.ruledOut).byteLength + new TextEncoder().encode(entry.why).byteLength + 48;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function mergeLedger(
|
|
115
|
+
existing: ContinuationLedgerEntry[],
|
|
116
|
+
incoming: ContinuationLedgerEntry[],
|
|
117
|
+
): { ledger: ContinuationLedgerEntry[]; dropped: ContinuationLedgerEntry[] } {
|
|
118
|
+
const seen = new Set(existing.map(ledgerEntryKey));
|
|
119
|
+
const merged = [...existing];
|
|
120
|
+
for (const entry of incoming) {
|
|
121
|
+
const key = ledgerEntryKey(entry);
|
|
122
|
+
if (seen.has(key)) continue;
|
|
123
|
+
seen.add(key);
|
|
124
|
+
merged.push(entry);
|
|
125
|
+
}
|
|
126
|
+
let start = merged.length > MAX_LEDGER_ENTRIES ? merged.length - MAX_LEDGER_ENTRIES : 0;
|
|
127
|
+
let totalBytes = 0;
|
|
128
|
+
for (let i = merged.length - 1; i >= start; i -= 1) totalBytes += ledgerEntryBytes(merged[i]!);
|
|
129
|
+
while (totalBytes > MAX_LEDGER_TOTAL_BYTES && start < merged.length - 1) {
|
|
130
|
+
totalBytes -= ledgerEntryBytes(merged[start]!);
|
|
131
|
+
start += 1;
|
|
132
|
+
}
|
|
133
|
+
return { ledger: merged.slice(start), dropped: merged.slice(0, start) };
|
|
93
134
|
}
|
|
94
135
|
|
|
95
136
|
export function appendContinuationArchiveRefs(agentId: string, archiveRefs: string[]): ContinuationEnvelope | null {
|
package/src/db/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export * from "./continuations.ts";
|
|
|
33
33
|
export * from "./continuation-archives.ts";
|
|
34
34
|
export * from "./crash-reports.ts";
|
|
35
35
|
export * from "./terminal-events.ts";
|
|
36
|
+
export * from "./retention.ts";
|
|
36
37
|
export * from "./plans.ts";
|
|
37
38
|
export * from "./teams.ts";
|
|
38
39
|
export * from "./blackboard.ts";
|
|
@@ -29,6 +29,97 @@ try {
|
|
|
29
29
|
return db.query("DELETE FROM messages WHERE created_at < ?").run(cutoff).changes;
|
|
30
30
|
})();
|
|
31
31
|
console.log(JSON.stringify({ prunedMessages }));
|
|
32
|
+
} else if (operation === "retention-prune") {
|
|
33
|
+
if (!arg) throw new Error("retention-prune requires options JSON");
|
|
34
|
+
const opts = JSON.parse(arg) as {
|
|
35
|
+
messageCutoff: number;
|
|
36
|
+
activityCutoff: number;
|
|
37
|
+
activityMaxRows: number;
|
|
38
|
+
spawnCommandCutoff: number;
|
|
39
|
+
batchSize: number;
|
|
40
|
+
};
|
|
41
|
+
const batchSize = Math.min(Math.max(Math.trunc(opts.batchSize), 1), 50_000);
|
|
42
|
+
const result = db.transaction(() => {
|
|
43
|
+
db.query(`
|
|
44
|
+
UPDATE messages
|
|
45
|
+
SET reply_to = NULL
|
|
46
|
+
WHERE reply_to IN (
|
|
47
|
+
SELECT id FROM messages
|
|
48
|
+
WHERE created_at < ?
|
|
49
|
+
ORDER BY created_at ASC, id ASC
|
|
50
|
+
LIMIT ?
|
|
51
|
+
)
|
|
52
|
+
`).run(opts.messageCutoff, batchSize);
|
|
53
|
+
const prunedMessages = db.query(`
|
|
54
|
+
DELETE FROM messages
|
|
55
|
+
WHERE id IN (
|
|
56
|
+
SELECT id FROM messages
|
|
57
|
+
WHERE created_at < ?
|
|
58
|
+
ORDER BY created_at ASC, id ASC
|
|
59
|
+
LIMIT ?
|
|
60
|
+
)
|
|
61
|
+
`).run(opts.messageCutoff, batchSize).changes;
|
|
62
|
+
const prunedActivityEvents = db.query(`
|
|
63
|
+
DELETE FROM activity_events
|
|
64
|
+
WHERE id IN (
|
|
65
|
+
SELECT ae.id
|
|
66
|
+
FROM activity_events ae
|
|
67
|
+
WHERE (
|
|
68
|
+
ae.created_at < ?
|
|
69
|
+
OR ae.id NOT IN (
|
|
70
|
+
SELECT id
|
|
71
|
+
FROM activity_events
|
|
72
|
+
ORDER BY created_at DESC, id DESC
|
|
73
|
+
LIMIT ?
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
AND NOT (
|
|
77
|
+
ae.kind = 'state'
|
|
78
|
+
AND ae.title = 'Agent registered'
|
|
79
|
+
AND EXISTS (
|
|
80
|
+
SELECT 1
|
|
81
|
+
FROM commands c
|
|
82
|
+
WHERE c.type = 'agent.spawn'
|
|
83
|
+
AND c.status = 'succeeded'
|
|
84
|
+
AND coalesce(json_extract(c.params, '$.registrationConfirmed'), 0) != 1
|
|
85
|
+
AND (
|
|
86
|
+
c.correlation_id = json_extract(ae.metadata, '$.spawnRequestId')
|
|
87
|
+
OR json_extract(c.params, '$.spawnRequestId') = json_extract(ae.metadata, '$.spawnRequestId')
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
ORDER BY ae.created_at ASC, ae.id ASC
|
|
92
|
+
LIMIT ?
|
|
93
|
+
)
|
|
94
|
+
`).run(opts.activityCutoff, opts.activityMaxRows, batchSize).changes;
|
|
95
|
+
const prunedSpawnCommands = db.query(`
|
|
96
|
+
DELETE FROM commands
|
|
97
|
+
WHERE id IN (
|
|
98
|
+
SELECT c.id
|
|
99
|
+
FROM commands c
|
|
100
|
+
WHERE c.type = 'agent.spawn'
|
|
101
|
+
AND c.status IN ('succeeded', 'failed', 'canceled', 'timed_out')
|
|
102
|
+
AND c.updated_at < ?
|
|
103
|
+
AND NOT (
|
|
104
|
+
c.status = 'succeeded'
|
|
105
|
+
AND coalesce(json_extract(c.params, '$.registrationConfirmed'), 0) != 1
|
|
106
|
+
)
|
|
107
|
+
AND NOT EXISTS (
|
|
108
|
+
SELECT 1
|
|
109
|
+
FROM managed_agent_state mas
|
|
110
|
+
WHERE mas.spawn_request_id IS NOT NULL
|
|
111
|
+
AND (
|
|
112
|
+
mas.spawn_request_id = c.correlation_id
|
|
113
|
+
OR mas.spawn_request_id = json_extract(c.params, '$.spawnRequestId')
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
ORDER BY c.updated_at ASC, c.created_at ASC
|
|
117
|
+
LIMIT ?
|
|
118
|
+
)
|
|
119
|
+
`).run(opts.spawnCommandCutoff, batchSize).changes;
|
|
120
|
+
return { prunedMessages, prunedActivityEvents, prunedSpawnCommands };
|
|
121
|
+
})();
|
|
122
|
+
console.log(JSON.stringify(result));
|
|
32
123
|
} else {
|
|
33
124
|
throw new Error(`unknown maintenance operation: ${operation}`);
|
|
34
125
|
}
|
package/src/db/message-reads.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
|
|
3
|
+
import { isRecord, stringValue, isMechanicalMessageKind, isReportVerbosity, type ReportVerbosity } from "agent-relay-sdk";
|
|
4
4
|
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
|
|
5
5
|
import { parseJson } from "../utils";
|
|
6
6
|
import { isLiveIsolatedWorkspace } from "../workspace-phase";
|
|
@@ -19,7 +19,7 @@ import { sendMessageWithResult } from "./messages.ts";
|
|
|
19
19
|
import { deleteArtifactLinksForEntity } from "./artifacts.ts";
|
|
20
20
|
import { getDb, getDbPath, runDbMaintenanceWorker, timedQuery } from "./connection.ts";
|
|
21
21
|
import { legacyChannelTargets, isChannelAgentId } from "./delivery.ts";
|
|
22
|
-
import { messageTaskClosed } from "./claim-sql.ts";
|
|
22
|
+
import { claimableHandledNoTask, messageTaskClosed } from "./claim-sql.ts";
|
|
23
23
|
import { MSG_SELECT, rowToMessage } from "./mappers.ts";
|
|
24
24
|
import type {
|
|
25
25
|
AgentCard,
|
|
@@ -306,7 +306,11 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
306
306
|
targetClauses.push(freshFanoutTargetClause(`tag:${tag}`, agent, params));
|
|
307
307
|
}
|
|
308
308
|
for (const cap of agentCaps) {
|
|
309
|
+
// A capability fan-out is accepted at send time under both `cap:` and its `capability:` alias
|
|
310
|
+
// (planSend/FANOUT_PREFIXES + the SDK matcher). Poll for BOTH stored forms, or a message sent
|
|
311
|
+
// to `capability:X` gets a delivered receipt yet is invisible to every poll (#1022 F3).
|
|
309
312
|
targetClauses.push(freshFanoutTargetClause(`cap:${cap}`, agent, params));
|
|
313
|
+
targetClauses.push(freshFanoutTargetClause(`capability:${cap}`, agent, params));
|
|
310
314
|
}
|
|
311
315
|
if (agentLabel) {
|
|
312
316
|
targetClauses.push(freshFanoutTargetClause(`label:${agentLabel}`, agent, params));
|
|
@@ -338,7 +342,9 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
338
342
|
// pruned/deleted its message claims are released to NULL (see pruneOfflineAgents/deleteAgent),
|
|
339
343
|
// so without it a finished task's message resurrects as fresh work on the next restart (which
|
|
340
344
|
// mints a new agent id, so message_reads no longer dedups it) — the automation re-delivery bug.
|
|
341
|
-
|
|
345
|
+
// `claimableHandledNoTask` closes the same gap for claimables with NO linked task: once the
|
|
346
|
+
// holder handled one, its lease expired and it got re-offered to everyone forever (#1022 F6).
|
|
347
|
+
conditions.push(`(claimable = 0 OR claimed_by = ? OR ((claimed_by IS NULL OR claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT ${messageTaskClosed("m.id")} AND NOT ${claimableHandledNoTask("m.id")}))`);
|
|
342
348
|
params.push(query.for, Date.now());
|
|
343
349
|
|
|
344
350
|
if (query.sinceId !== undefined) {
|
|
@@ -594,6 +600,30 @@ export function seedSpawnPromptMirror(
|
|
|
594
600
|
}
|
|
595
601
|
}
|
|
596
602
|
|
|
603
|
+
/**
|
|
604
|
+
* #1037 — the per-spawn report-up verbosity a spawner chose, read from the persisted `agent.spawn`
|
|
605
|
+
* command params by spawnRequestId. Mirrors seedSpawnPromptMirror: at first registration the relay
|
|
606
|
+
* has both the resolved child id and the spawn command, so registration stamps this onto child meta
|
|
607
|
+
* where the lineage report-up delivery gate reads it. Returns undefined when absent/unparseable
|
|
608
|
+
* (the gate then defaults to `quiet`).
|
|
609
|
+
*/
|
|
610
|
+
export function readSpawnReportVerbosity(spawnRequestId: string | undefined): ReportVerbosity | undefined {
|
|
611
|
+
if (!spawnRequestId) return undefined;
|
|
612
|
+
try {
|
|
613
|
+
const row = getDb().query(`
|
|
614
|
+
SELECT params FROM commands
|
|
615
|
+
WHERE type = 'agent.spawn' AND correlation_id = ?
|
|
616
|
+
ORDER BY created_at DESC
|
|
617
|
+
LIMIT 1
|
|
618
|
+
`).get(spawnRequestId) as { params: string } | undefined;
|
|
619
|
+
const params = row ? parseJson<unknown>(row.params, {}) : {};
|
|
620
|
+
if (!isRecord(params)) return undefined;
|
|
621
|
+
return isReportVerbosity(params.reportVerbosity) ? params.reportVerbosity : undefined;
|
|
622
|
+
} catch {
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
597
627
|
export function markRead(messageId: number, agentId: string): boolean {
|
|
598
628
|
const exists = getDb().query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
|
|
599
629
|
if (!exists) return false;
|
|
@@ -607,7 +637,7 @@ export function markRead(messageId: number, agentId: string): boolean {
|
|
|
607
637
|
delivery_next_retry_at = NULL,
|
|
608
638
|
delivery_poison_reason = NULL,
|
|
609
639
|
delivery_updated_at = ?
|
|
610
|
-
WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'label:%' OR to_target LIKE 'team:%')
|
|
640
|
+
WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'capability:%' OR to_target LIKE 'label:%' OR to_target LIKE 'team:%')
|
|
611
641
|
`).run(Date.now(), messageId, agentId, agentId);
|
|
612
642
|
return true;
|
|
613
643
|
}
|
package/src/db/messages.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
|
|
3
|
+
import { isRecord, stringValue, isMechanicalMessageKind, isReportVerbosity, type ReportVerbosity } from "agent-relay-sdk";
|
|
4
|
+
import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "../lifecycle-signal-registry";
|
|
4
5
|
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
|
|
5
6
|
import { parseJson } from "../utils";
|
|
6
7
|
import { isLiveIsolatedWorkspace } from "../workspace-phase";
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
type RuntimeContracts,
|
|
14
15
|
} from "../contracts";
|
|
15
16
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, DEFAULT_FANOUT_TTL_SECONDS } from "../config";
|
|
16
|
-
import { matchAgents } from "../agent-ref";
|
|
17
|
+
import { matchAgents, messageMatchesAgent } from "../agent-ref";
|
|
17
18
|
import { isFanoutTarget } from "../channel-target";
|
|
18
19
|
import { getAgent, validateAgentSession } from "./agents.ts";
|
|
19
20
|
import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from "./artifacts.ts";
|
|
@@ -26,6 +27,7 @@ import { getMessage } from "./message-queries.ts";
|
|
|
26
27
|
import { childStreamSubscribed, type ChildStream } from "./notification-subscriptions.ts";
|
|
27
28
|
import { runningAgentForPolicy } from "./policy-runtime.ts";
|
|
28
29
|
import { getTask, releaseExpiredClaims } from "./tasks.ts";
|
|
30
|
+
import { deliverableTaskIdsBySpawnRequestId } from "./task-entity.ts";
|
|
29
31
|
import { insertTaskEvent, claimTaskWhere, CLAIM_TASK_KIND } from "./task-core.ts";
|
|
30
32
|
import type {
|
|
31
33
|
AgentCard,
|
|
@@ -334,6 +336,105 @@ function findRecentExplicitLineageDuplicate(input: {
|
|
|
334
336
|
return row ? rowToMessage(row) : null;
|
|
335
337
|
}
|
|
336
338
|
|
|
339
|
+
/** Per-spawn report-up verbosity (#1037), read from the child's meta; defaults to `quiet`. */
|
|
340
|
+
function reportVerbosityOf(child: AgentCard): ReportVerbosity {
|
|
341
|
+
const value = child.meta?.reportVerbosity;
|
|
342
|
+
return isReportVerbosity(value) ? value : "quiet";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The child is mid-turn (not yet idle/awaiting-input). The authoritative "still working" signal. */
|
|
346
|
+
function childIsBusy(child: AgentCard): boolean {
|
|
347
|
+
return child.status === "busy";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// #1037 — a quiet child's turn-final response that arrives while the child is still busy is a
|
|
351
|
+
// mid-task yield, not the end of its work. Instead of shipping it upstream (mid-work narration) or
|
|
352
|
+
// dropping it (a genuine last turn whose idle status simply hasn't landed yet), we HOLD the latest
|
|
353
|
+
// one per child and flush it when the child transitions to idle (see flushDeferredLineageReport,
|
|
354
|
+
// driven off the bus turn-end edge). Coalescing on childId means only the final turn's report lands.
|
|
355
|
+
const deferredLineageReports = new Map<string, number>();
|
|
356
|
+
|
|
357
|
+
function deferLineageReport(childId: string, messageId: number): void {
|
|
358
|
+
deferredLineageReports.set(childId, messageId);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function clearDeferredLineageReport(childId: string): void {
|
|
362
|
+
deferredLineageReports.delete(childId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Test seam: clear the #1037 deferred report-up hold between tests. */
|
|
366
|
+
export function __resetDeferredLineageReports(): void {
|
|
367
|
+
deferredLineageReports.clear();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* #1037 — flush a child's deferred turn-final report to its spawner. Called when the child
|
|
372
|
+
* transitions out of busy (idle/awaiting-input): re-runs the promotion, which now passes the idle
|
|
373
|
+
* gate and delivers. No-op when nothing was deferred. Returns the promoted message, or null.
|
|
374
|
+
*/
|
|
375
|
+
export function flushDeferredLineageReport(childId: string): { message: Message; created: boolean } | null {
|
|
376
|
+
const messageId = deferredLineageReports.get(childId);
|
|
377
|
+
if (messageId === undefined) return null;
|
|
378
|
+
deferredLineageReports.delete(childId);
|
|
379
|
+
const message = getMessage(messageId);
|
|
380
|
+
if (!message) return null;
|
|
381
|
+
return promoteLineageCapturedResponse(message);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* #1040/#1041 — the persisted turn-final response captures needed to rebuild the in-memory
|
|
386
|
+
* deferred-report hold after a relay restart. Bounded by a recency window (a report is only ever
|
|
387
|
+
* held while its child was mid-turn, so it is inherently recent). Grouped to the single newest
|
|
388
|
+
* (highest-id) final capture per from_agent child *before* the row cap is applied, so that: (a) an
|
|
389
|
+
* older mid-task final that #1037 coalesced away — persisted but never given an idempotency key —
|
|
390
|
+
* is never resurrected alongside the real verdict, and (b) a chatty child's own history can't push
|
|
391
|
+
* another child's final out of the cap. The caller re-runs promotion, which is idempotent: an
|
|
392
|
+
* already-delivered capture is a no-op, an idle child's lost report is delivered, and a still-busy
|
|
393
|
+
* child simply re-defers (holding the newest one, matching the live #1037 coalescing contract).
|
|
394
|
+
*/
|
|
395
|
+
export function listRecentFinalResponseCaptures(sinceMs: number, limit = 1000): Message[] {
|
|
396
|
+
const rows = getDb().query(`
|
|
397
|
+
${MSG_SELECT}
|
|
398
|
+
INNER JOIN (
|
|
399
|
+
SELECT from_agent, MAX(id) AS latest_id
|
|
400
|
+
FROM messages
|
|
401
|
+
WHERE kind = 'session'
|
|
402
|
+
AND created_at >= ?
|
|
403
|
+
AND json_extract(payload, '$.session.type') = 'response'
|
|
404
|
+
AND json_extract(payload, '$.session.final') = 1
|
|
405
|
+
GROUP BY from_agent
|
|
406
|
+
) latest ON latest.latest_id = m.id
|
|
407
|
+
ORDER BY m.id DESC
|
|
408
|
+
LIMIT ?
|
|
409
|
+
`).all(sinceMs, limit) as any[];
|
|
410
|
+
return rows
|
|
411
|
+
.map(rowToMessage)
|
|
412
|
+
.filter((message) => {
|
|
413
|
+
const session = sessionMeta(message);
|
|
414
|
+
return session ? isFinalSessionResponse(session) : false;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Deliverable task ids this child is executing, from its direct meta.taskId + spawnRequestId link. */
|
|
419
|
+
function childTaskIds(child: AgentCard): number[] {
|
|
420
|
+
const ids = new Set<number>();
|
|
421
|
+
const direct = child.meta?.taskId;
|
|
422
|
+
const directNum = typeof direct === "string" ? Number(direct) : direct;
|
|
423
|
+
if (typeof directNum === "number" && Number.isSafeInteger(directNum) && directNum > 0) ids.add(directNum);
|
|
424
|
+
const spawnRequestId = stringValue(child.meta?.spawnRequestId);
|
|
425
|
+
if (spawnRequestId) for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
|
|
426
|
+
return [...ids];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// #1032b — a delivered report-up is a first-class lifecycle signal to the spawner. Stamp it so a
|
|
430
|
+
// redundant "Task status pulse" for the same child/task is suppressed rather than double-waking.
|
|
431
|
+
// #1038 — a report-up conveys "the child is progressing", so it is state-keyed to `working`: it
|
|
432
|
+
// supersedes only a redundant `working` pulse, never a later distinct milestone (committed/landed/done).
|
|
433
|
+
function stampReportUpLifecycleSignal(parent: string, child: AgentCard): void {
|
|
434
|
+
stampLifecycleSignal(parent, childSignalKey(child.id));
|
|
435
|
+
for (const taskId of childTaskIds(child)) stampLifecycleSignal(parent, taskSignalKey(taskId, LIFECYCLE_STATE_WORKING));
|
|
436
|
+
}
|
|
437
|
+
|
|
337
438
|
export function promoteLineageCapturedResponse(message: Message): { message: Message; created: boolean } | null {
|
|
338
439
|
if (message.kind !== "session") return null;
|
|
339
440
|
const session = sessionMeta(message);
|
|
@@ -344,7 +445,17 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
344
445
|
const child = getAgent(message.from);
|
|
345
446
|
const parent = child?.spawnedBy;
|
|
346
447
|
if (!parent) return null;
|
|
347
|
-
|
|
448
|
+
const verbosity = reportVerbosityOf(child);
|
|
449
|
+
// #1037 verbose: forward every child stream without an explicit per-stream subscription.
|
|
450
|
+
if (stream !== "responses" && verbosity !== "verbose" && !childStreamSubscribed({ parentId: parent, child, stream })) return null;
|
|
451
|
+
|
|
452
|
+
// #1037 — the report-up delivery gate. In `quiet` (default), a turn-final response that arrives
|
|
453
|
+
// while the child is still busy is mid-task narration: defer it (flushed on the idle transition)
|
|
454
|
+
// rather than waking the spawner mid-work. `normal`/`verbose` forward every turn-final at once.
|
|
455
|
+
if (stream === "responses" && verbosity === "quiet" && childIsBusy(child)) {
|
|
456
|
+
deferLineageReport(child.id, message.id);
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
348
459
|
|
|
349
460
|
const idempotencyKey = lineageStreamReportKey(message, session, stream);
|
|
350
461
|
const existing = findMessageByIdempotencyKey(message.from, idempotencyKey);
|
|
@@ -378,7 +489,7 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
378
489
|
}
|
|
379
490
|
}
|
|
380
491
|
|
|
381
|
-
|
|
492
|
+
const result = sendMessageWithResult({
|
|
382
493
|
from: message.from,
|
|
383
494
|
to: parent,
|
|
384
495
|
kind: "chat",
|
|
@@ -390,6 +501,11 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
|
|
|
390
501
|
: { childStreamCapture: { stream, ...session } },
|
|
391
502
|
occurredAt: message.occurredAt ?? message.createdAt,
|
|
392
503
|
});
|
|
504
|
+
if (stream === "responses" && result.created) {
|
|
505
|
+
clearDeferredLineageReport(child.id);
|
|
506
|
+
stampReportUpLifecycleSignal(parent, child);
|
|
507
|
+
}
|
|
508
|
+
return result;
|
|
393
509
|
}
|
|
394
510
|
|
|
395
511
|
// Event time may be queued-then-backfilled, so it can legitimately be older than the
|
|
@@ -402,7 +518,21 @@ function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number):
|
|
|
402
518
|
return Math.floor(occurredAt);
|
|
403
519
|
}
|
|
404
520
|
|
|
405
|
-
export
|
|
521
|
+
export interface SendMessageResult {
|
|
522
|
+
message: Message;
|
|
523
|
+
created: boolean;
|
|
524
|
+
updated?: boolean;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function sendMessageWithResult(input: SendMessageInput): SendMessageResult {
|
|
528
|
+
return sendMessageWithResultInternal(input, true);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function sendMessagesBatchWithResult(inputs: SendMessageInput[]): SendMessageResult[] {
|
|
532
|
+
return getDb().transaction(() => inputs.map((input) => sendMessageWithResultInternal(input, false)))();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function sendMessageWithResultInternal(input: SendMessageInput, wrapTransaction: boolean): SendMessageResult {
|
|
406
536
|
const now = Date.now();
|
|
407
537
|
const payload = input.payload ?? {};
|
|
408
538
|
const attachmentRefs = cleanAttachmentRefs(payload);
|
|
@@ -495,7 +625,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
495
625
|
maxAgeSeconds = DEFAULT_FANOUT_TTL_SECONDS;
|
|
496
626
|
}
|
|
497
627
|
|
|
498
|
-
const
|
|
628
|
+
const insertMessage = () => {
|
|
499
629
|
const result = insert.run({
|
|
500
630
|
$from: input.from,
|
|
501
631
|
$to: input.to,
|
|
@@ -528,7 +658,8 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
528
658
|
else enforceQueueLimit(input.to);
|
|
529
659
|
}
|
|
530
660
|
return newId;
|
|
531
|
-
}
|
|
661
|
+
};
|
|
662
|
+
const id = wrapTransaction ? getDb().transaction(insertMessage)() : insertMessage();
|
|
532
663
|
|
|
533
664
|
return { message: getMessage(id)!, created: true };
|
|
534
665
|
}
|
|
@@ -543,7 +674,7 @@ export function getThread(messageId: number): Message[] {
|
|
|
543
674
|
const threadId = msg.threadId ?? msg.id;
|
|
544
675
|
return (
|
|
545
676
|
getDb()
|
|
546
|
-
.query(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC`)
|
|
677
|
+
.query(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC, m.id ASC`)
|
|
547
678
|
.all(threadId) as any[]
|
|
548
679
|
).map(rowToMessage);
|
|
549
680
|
}
|
|
@@ -606,6 +737,14 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
|
|
|
606
737
|
// A same-agent re-claim is allowed (idempotent lease refresh, see claimMessageRow); only a
|
|
607
738
|
// claim held by a *different* agent blocks (#261).
|
|
608
739
|
if (msg.claimedBy && msg.claimedBy !== agentId) return { ok: false, error: `already claimed by ${msg.claimedBy}` };
|
|
740
|
+
// The claimer must actually be a recipient — the same predicate that gates live delivery (bus/sse
|
|
741
|
+
// push) and poll. Without it an unrelated agent could claim a claimable addressed directly to
|
|
742
|
+
// someone else, and `pollMessages`/`messageMatchesAgent` then hid it from the rightful recipient,
|
|
743
|
+
// so the message vanished for its intended owner (#1022 F7). Checked AFTER the claimedBy guard so
|
|
744
|
+
// a genuine "already claimed" contention still reports that precise reason; a fan-out claimable
|
|
745
|
+
// (tag:/cap:/broadcast/…) still matches every eligible pool member, and the holder's own re-claim
|
|
746
|
+
// still matches (claimedBy is null or itself here, so the ownership short-circuit never fires).
|
|
747
|
+
if (!messageMatchesAgent(msg, agentId, agent)) return { ok: false, error: "message is not addressed to this agent" };
|
|
609
748
|
|
|
610
749
|
try {
|
|
611
750
|
return getDb().transaction(() => {
|
package/src/db/migrations.ts
CHANGED
|
@@ -706,6 +706,7 @@ export function applyMigrations(): void {
|
|
|
706
706
|
getDb().run("ALTER TABLE agents ADD COLUMN team_id TEXT");
|
|
707
707
|
}
|
|
708
708
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_team ON agents(team_id)");
|
|
709
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_spawn_request ON agents(json_extract(meta, '$.spawnRequestId'))");
|
|
709
710
|
getDb().run(`
|
|
710
711
|
CREATE TABLE IF NOT EXISTS notification_subscriptions (
|
|
711
712
|
id TEXT PRIMARY KEY,
|
|
@@ -913,6 +914,12 @@ export function applyMigrations(): void {
|
|
|
913
914
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_agent_active_memories_agent ON agent_active_memories(agent_id)");
|
|
914
915
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
|
|
915
916
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
|
|
917
|
+
getDb().run(`
|
|
918
|
+
CREATE INDEX IF NOT EXISTS idx_activity_registered_spawn_request
|
|
919
|
+
ON activity_events(json_extract(metadata, '$.spawnRequestId'))
|
|
920
|
+
WHERE kind = 'state' AND title = 'Agent registered'
|
|
921
|
+
`);
|
|
922
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_commands_spawn_watchdog ON commands(type, status, updated_at)");
|
|
916
923
|
|
|
917
924
|
getDb().run(`
|
|
918
925
|
CREATE TABLE IF NOT EXISTS integration_registry (
|
package/src/db/pairs.ts
CHANGED
|
@@ -205,6 +205,7 @@ export function createPair(input: CreatePairInput): {
|
|
|
205
205
|
|
|
206
206
|
const resolved = resolvePairTarget(input.target, input.from);
|
|
207
207
|
if (!resolved.ok) return resolved;
|
|
208
|
+
if (resolved.agent.id === input.from) return { ok: false, code: "invalid", error: "cannot pair with yourself" };
|
|
208
209
|
|
|
209
210
|
const now = Date.now();
|
|
210
211
|
const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_PAIR_TTL_MS, 10_000), MAX_PAIR_TTL_MS);
|