agent-relay-server 0.121.0 → 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-BaPGFJxq.js → MessageBubble-CLqxCG9b.js} +2 -2
- package/public/assets/{MessageBubble-BaPGFJxq.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BXCVfmln.js → PlanGraphPanel-BkkToFEb.js} +2 -2
- package/public/assets/{PlanGraphPanel-BXCVfmln.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
- package/public/assets/{activity-D_shwAZt.js → activity-D-ocGQGs.js} +2 -2
- package/public/assets/{activity-D_shwAZt.js.map → activity-D-ocGQGs.js.map} +1 -1
- package/public/assets/{agent-profiles-BrfVz5IV.js → agent-profiles-CRPgYTyb.js} +2 -2
- package/public/assets/{agent-profiles-BrfVz5IV.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
- package/public/assets/{agents-B5w-Z9Ic.js → agents-Chw5E_2q.js} +2 -2
- package/public/assets/{agents-B5w-Z9Ic.js.map → agents-Chw5E_2q.js.map} +1 -1
- package/public/assets/{analytics-Bm3D12UN.js → analytics-BmNDG0hR.js} +2 -2
- package/public/assets/{analytics-Bm3D12UN.js.map → analytics-BmNDG0hR.js.map} +1 -1
- package/public/assets/{automation-DOTL8BFs.js → automation-D7g90kTv.js} +2 -2
- package/public/assets/{automation-DOTL8BFs.js.map → automation-D7g90kTv.js.map} +1 -1
- package/public/assets/{branch-state-badge-BlqeJZ5-.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
- package/public/assets/{branch-state-badge-BlqeJZ5-.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
- package/public/assets/{channels-BoiKfK6Q.js → channels-CLwPeEPi.js} +2 -2
- package/public/assets/{channels-BoiKfK6Q.js.map → channels-CLwPeEPi.js.map} +1 -1
- package/public/assets/{chat-PeU3iOaa.js → chat-Cgj7VKzc.js} +2 -2
- package/public/assets/{chat-PeU3iOaa.js.map → chat-Cgj7VKzc.js.map} +1 -1
- package/public/assets/{connectors-BvV8Hee2.js → connectors-Br6fiTny.js} +2 -2
- package/public/assets/{connectors-BvV8Hee2.js.map → connectors-Br6fiTny.js.map} +1 -1
- package/public/assets/{formatted-body-C1FgzlT1.js → formatted-body-CmkuD23M.js} +3 -3
- package/public/assets/{formatted-body-C1FgzlT1.js.map → formatted-body-CmkuD23M.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BTpU1xH4.js → formatted-body-impl-DbdFmbwW.js} +2 -2
- package/public/assets/{formatted-body-impl-BTpU1xH4.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
- package/public/assets/{index-DZEahmyc.js → index-D9OogaB5.js} +6 -6
- package/public/assets/{index-DZEahmyc.js.map → index-D9OogaB5.js.map} +1 -1
- package/public/assets/{insights-BqmgSpQm.js → insights-qvaPqWCV.js} +2 -2
- package/public/assets/{insights-BqmgSpQm.js.map → insights-qvaPqWCV.js.map} +1 -1
- package/public/assets/{integrations-CuT92zQi.js → integrations-DYEGSqq_.js} +2 -2
- package/public/assets/{integrations-CuT92zQi.js.map → integrations-DYEGSqq_.js.map} +1 -1
- package/public/assets/{maintenance-lM7FeXVC.js → maintenance-C0HIEB-J.js} +2 -2
- package/public/assets/{maintenance-lM7FeXVC.js.map → maintenance-C0HIEB-J.js.map} +1 -1
- package/public/assets/{managed-agents-DaPNv3yY.js → managed-agents-DdS7aI38.js} +2 -2
- package/public/assets/{managed-agents-DaPNv3yY.js.map → managed-agents-DdS7aI38.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-Wia3Ho1s.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
- package/public/assets/{markdown-preview-impl-Wia3Ho1s.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
- package/public/assets/{memory-B1v_zD-M.js → memory-CSwx7bz8.js} +2 -2
- package/public/assets/{memory-B1v_zD-M.js.map → memory-CSwx7bz8.js.map} +1 -1
- package/public/assets/{messages-BNOx3yK6.js → messages-Be9CmvDv.js} +2 -2
- package/public/assets/{messages-BNOx3yK6.js.map → messages-Be9CmvDv.js.map} +1 -1
- package/public/assets/{orchestrators-DWFy4asl.js → orchestrators-BgtvvHvo.js} +2 -2
- package/public/assets/{orchestrators-DWFy4asl.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
- package/public/assets/{overview-C_12cFPM.js → overview-BvhbPWtF.js} +2 -2
- package/public/assets/{overview-C_12cFPM.js.map → overview-BvhbPWtF.js.map} +1 -1
- package/public/assets/{pairs-DIc7gX83.js → pairs-CpXSOMx0.js} +2 -2
- package/public/assets/{pairs-DIc7gX83.js.map → pairs-CpXSOMx0.js.map} +1 -1
- package/public/assets/{projects-DPjvJUzT.js → projects-DdPNPFJI.js} +2 -2
- package/public/assets/{projects-DPjvJUzT.js.map → projects-DdPNPFJI.js.map} +1 -1
- package/public/assets/{prompt-settings-CtumNgG8.js → prompt-settings-BpZse7-X.js} +2 -2
- package/public/assets/{prompt-settings-CtumNgG8.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
- package/public/assets/{registry-DzUhgMq9.js → registry-pb1gTZEg.js} +2 -2
- package/public/assets/{registry-DzUhgMq9.js.map → registry-pb1gTZEg.js.map} +1 -1
- package/public/assets/{security-BJ3_u0uY.js → security-BgcX1n9D.js} +2 -2
- package/public/assets/{security-BJ3_u0uY.js.map → security-BgcX1n9D.js.map} +1 -1
- package/public/assets/{select-AWvlctyQ.js → select-DZPVpKPv.js} +2 -2
- package/public/assets/{select-AWvlctyQ.js.map → select-DZPVpKPv.js.map} +1 -1
- package/public/assets/{settings-5ofqi-bN.js → settings-hd5kzdRB.js} +2 -2
- package/public/assets/{settings-5ofqi-bN.js.map → settings-hd5kzdRB.js.map} +1 -1
- package/public/assets/store-DKTLubBT.js +9 -0
- package/public/assets/store-DKTLubBT.js.map +1 -0
- package/public/assets/{tasks-BEE9bBf7.js → tasks-C5d2LU5E.js} +2 -2
- package/public/assets/{tasks-BEE9bBf7.js.map → tasks-C5d2LU5E.js.map} +1 -1
- package/public/assets/{teams-C27H_Gwo.js → teams-B8f2pGJe.js} +2 -2
- package/public/assets/{teams-C27H_Gwo.js.map → teams-B8f2pGJe.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-WD2TboA7.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
- package/public/assets/{terminal-viewer-impl-WD2TboA7.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-CtmPK2hK.js → work-queue-9imc7aNK.js} +2 -2
- package/public/assets/{work-queue-CtmPK2hK.js.map → work-queue-9imc7aNK.js.map} +1 -1
- package/public/assets/{workspaces-B26EKv_H.js → workspaces-CLYc3yF3.js} +2 -2
- package/public/assets/{workspaces-B26EKv_H.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-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 +44 -1
- package/src/bus.ts +28 -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.ts +50 -0
- package/src/db/claim-sql.ts +16 -0
- package/src/db/continuations.ts +44 -3
- package/src/db/message-reads.ts +34 -4
- package/src/db/messages.ts +128 -4
- package/src/db/pairs.ts +1 -0
- package/src/db/schema.ts +6 -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 +28 -2
- 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/routes/agents.ts +4 -0
- package/src/routes/orchestrator-proxy.ts +13 -1
- package/src/routes/orchestrator.ts +4 -0
- package/src/security.ts +20 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +2 -0
- package/src/services/plan-graph.ts +42 -12
- package/src/services/register-agent.ts +5 -1
- package/src/services/send-message.ts +36 -0
- package/src/services/spawn-agent.ts +21 -2
- package/src/services/task-semantic-events.ts +17 -1
- package/src/spawn-command.ts +3 -0
- package/src/spawn-targets.ts +26 -1
- package/src/sse.ts +13 -5
- 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/store-D5et8685.js +0 -9
- package/public/assets/store-D5et8685.js.map +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { emitRelayEvent } from "./events";
|
|
2
2
|
import { routeNotification } from "./notification-router";
|
|
3
|
-
import {
|
|
3
|
+
import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "./lifecycle-signal-registry";
|
|
4
|
+
import { deliverableTaskIdsBySpawnRequestId, getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
|
|
4
5
|
import { crashReportFor } from "./agent-death-diagnosis";
|
|
5
6
|
import { renderTemplate } from "./prompt-resolver";
|
|
6
7
|
import type { AgentCard, AgentDeathDiagnosis } from "./types";
|
|
@@ -82,6 +83,26 @@ function spawnRequestIdOf(child: AgentCard): string | undefined {
|
|
|
82
83
|
return typeof s === "string" ? s : undefined;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// #1032b — record that a first-class lifecycle event (ready/exit) told the parent about this child,
|
|
87
|
+
// so a redundant "Task status pulse" for the same child/task is suppressed instead of double-waking.
|
|
88
|
+
// #1038 — agent.ready means the child is coming up to work its task, so it is state-keyed to
|
|
89
|
+
// `working` (same as a report-up): it supersedes a redundant `working` pulse only, never a later
|
|
90
|
+
// distinct-state transition for the same task within the TTL.
|
|
91
|
+
function stampChildLifecycleSignal(parent: string, child: AgentCard): void {
|
|
92
|
+
stampLifecycleSignal(parent, childSignalKey(child.id));
|
|
93
|
+
const direct = child.meta?.taskId;
|
|
94
|
+
const directNum = typeof direct === "string" ? Number(direct) : direct;
|
|
95
|
+
if (typeof directNum === "number" && Number.isSafeInteger(directNum) && directNum > 0) {
|
|
96
|
+
stampLifecycleSignal(parent, taskSignalKey(directNum, LIFECYCLE_STATE_WORKING));
|
|
97
|
+
}
|
|
98
|
+
const spawnRequestId = spawnRequestIdOf(child);
|
|
99
|
+
if (spawnRequestId) {
|
|
100
|
+
for (const taskId of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) {
|
|
101
|
+
stampLifecycleSignal(parent, taskSignalKey(taskId, LIFECYCLE_STATE_WORKING));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
/**
|
|
86
107
|
* A spawned child became genuinely ready (registered AND past onboarding — ready+idle, not merely
|
|
87
108
|
* process-started). Emits the durable `agent.ready` event and, unless suppressed, pushes the
|
|
@@ -152,6 +173,7 @@ export function notifyAgentReady(childId: string): void {
|
|
|
152
173
|
replyExpected: false,
|
|
153
174
|
},
|
|
154
175
|
});
|
|
176
|
+
stampChildLifecycleSignal(parent, child);
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
/**
|
package/src/agent-ref.ts
CHANGED
|
@@ -99,9 +99,19 @@ function idMatchesSegment(id: string, ref: string): boolean {
|
|
|
99
99
|
export function matchAgents(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): AgentCard[] {
|
|
100
100
|
const trimmed = ref.trim();
|
|
101
101
|
if (!trimmed) return [];
|
|
102
|
-
const pool = opts.excludeId ? agents.filter((a) => a.id !== opts.excludeId) : agents;
|
|
103
102
|
|
|
104
103
|
const selector = parseSelector(trimmed);
|
|
104
|
+
// An exact id reference — a bare full id, or an `id:` selector — names ONE agent unambiguously,
|
|
105
|
+
// so it is honored even when it targets the excluded (self) agent: addressing your own full id
|
|
106
|
+
// is a deliberate self/peer send, NOT the #290 bare-ref self-loop that `excludeId` guards
|
|
107
|
+
// against. Without this exemption an exact self-id is refused by the matcher yet still offered by
|
|
108
|
+
// the "did you mean" hint — the resolver suggesting the very id it just rejected (#1035).
|
|
109
|
+
const exactId = selector ? (selector.field === "id" ? selector.value : null) : trimmed;
|
|
110
|
+
const selfExact = opts.excludeId && exactId
|
|
111
|
+
? agents.find((a) => a.id === opts.excludeId && a.id === exactId)
|
|
112
|
+
: undefined;
|
|
113
|
+
const pool = opts.excludeId && !selfExact ? agents.filter((a) => a.id !== opts.excludeId) : agents;
|
|
114
|
+
|
|
105
115
|
if (selector) return selector.value ? pool.filter((a) => matchesSelector(a, selector)) : [];
|
|
106
116
|
|
|
107
117
|
const exact = pool.filter((a) => matchesExact(a, trimmed));
|
|
@@ -207,9 +217,14 @@ function ambiguousMessage(ref: string, candidates: AgentCard[]): string {
|
|
|
207
217
|
return `agent reference "${ref}" is ambiguous — matches ${candidates.length}: ${list}${more}. Use a full id, label, or a fan-out selector (tag:/cap:/label:/team:).`;
|
|
208
218
|
}
|
|
209
219
|
|
|
210
|
-
function notFoundMessage(ref: string, agents: AgentCard[]): string {
|
|
220
|
+
function notFoundMessage(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): string {
|
|
211
221
|
const needle = ref.toLowerCase();
|
|
212
222
|
const near = agents
|
|
223
|
+
// Canonicalize the suggestion pool to the SAME identity the matcher used: never offer the
|
|
224
|
+
// excluded (self) agent, and never suggest an id/label that EQUALS the ref — if it were an
|
|
225
|
+
// exact match it would have resolved, so surfacing it means offering the id we just rejected
|
|
226
|
+
// (#1035, the "Did you mean: <the id you typed>?" self-suggestion).
|
|
227
|
+
.filter((a) => a.id !== opts.excludeId && a.id !== ref && a.label !== ref)
|
|
213
228
|
.filter((a) => isPseudoAgent(a) && (a.id.toLowerCase().includes(needle) || a.label?.toLowerCase().includes(needle)))
|
|
214
229
|
.slice(0, 3)
|
|
215
230
|
.map(describeAgent);
|
|
@@ -261,7 +276,7 @@ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions =
|
|
|
261
276
|
if (offline.length > 1) {
|
|
262
277
|
return { kind: "ambiguous", message: ambiguousMessage(target, offline), candidates: offline };
|
|
263
278
|
}
|
|
264
|
-
return { kind: "not_found", message: notFoundMessage(target, agents) };
|
|
279
|
+
return { kind: "not_found", message: notFoundMessage(target, agents, opts) };
|
|
265
280
|
}
|
|
266
281
|
|
|
267
282
|
/**
|
package/src/bus-outbox.ts
CHANGED
|
@@ -45,6 +45,24 @@ export function appendEvent(type: string, source: string, data: unknown, subject
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export function replayEvents(since: number, limit = 500): BusEvent[] {
|
|
48
|
+
return replayEventsSlice(since, limit).events;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReplaySlice {
|
|
52
|
+
events: BusEvent[];
|
|
53
|
+
/** The highest real outbox row seq in this slice (or `since` when empty). Resume from here to
|
|
54
|
+
* continue past the slice — driven by the raw row seq, not the (possibly batch-expanded) payload
|
|
55
|
+
* seq, so a legacy `message.batch` row can't be half-skipped. */
|
|
56
|
+
lastSeq: number;
|
|
57
|
+
/** More retained events exist beyond `lastSeq` — the caller MUST fetch the next slice, or the
|
|
58
|
+
* backlog tail between this slice and the outbox head is silently dropped (#1022 F1). */
|
|
59
|
+
truncated: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One page of the resume backlog plus whether more remains. `replayEvents`' 500-row cap used to
|
|
63
|
+
* be a silent ceiling: a resume returning exactly `limit` rows dropped everything between the last
|
|
64
|
+
* replayed event and the outbox head. Callers loop on `truncated`, resuming from `lastSeq`. */
|
|
65
|
+
export function replayEventsSlice(since: number, limit = 500): ReplaySlice {
|
|
48
66
|
const rows = getDb().query(`
|
|
49
67
|
SELECT seq, event_type, source, subject, data, timestamp
|
|
50
68
|
FROM bus_outbox
|
|
@@ -52,7 +70,10 @@ export function replayEvents(since: number, limit = 500): BusEvent[] {
|
|
|
52
70
|
ORDER BY seq ASC
|
|
53
71
|
LIMIT ?
|
|
54
72
|
`).all(since, limit) as OutboxRow[];
|
|
55
|
-
|
|
73
|
+
const events = rows.map(rowToEvent);
|
|
74
|
+
const lastSeq = rows.length > 0 ? rows[rows.length - 1]!.seq : since;
|
|
75
|
+
const truncated = rows.length === limit && getOutboxCursor() > lastSeq;
|
|
76
|
+
return { events, lastSeq, truncated };
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
export function pruneOutbox(retentionMs = 60 * 60 * 1000): number {
|
|
@@ -71,6 +92,28 @@ export function getOldestOutboxCursor(): number {
|
|
|
71
92
|
return row.cursor ?? 0;
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
/** The highest seq ever assigned to the outbox — read from `sqlite_sequence`, so it survives the
|
|
96
|
+
* retention prune that empties the table. Needed for gap detection: once the outbox is fully
|
|
97
|
+
* pruned `getOldestOutboxCursor()` returns 0, which read as "no gap" and let a reconnecting client
|
|
98
|
+
* believe it was caught up while its post-cursor events had aged out (#1022 F2). */
|
|
99
|
+
export function getOutboxHighWater(): number {
|
|
100
|
+
const row = getDb().query("SELECT seq FROM sqlite_sequence WHERE name = 'bus_outbox'").get() as { seq: number } | undefined;
|
|
101
|
+
return row?.seq ?? 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether a resume from `since` has lost events to the retention prune. Two cases:
|
|
105
|
+
* - outbox non-empty: the oldest retained event is newer than `since + 1` → the events between
|
|
106
|
+
* were pruned.
|
|
107
|
+
* - outbox fully pruned (oldest = 0): a gap exists iff events after `since` were ever assigned
|
|
108
|
+
* (`since` is behind the high-water mark) and have since aged out.
|
|
109
|
+
* A client that hits a gap must reconcile from scratch rather than resume from a stale cursor. */
|
|
110
|
+
export function hasReplayGap(since: number): boolean {
|
|
111
|
+
if (since <= 0) return false;
|
|
112
|
+
const oldest = getOldestOutboxCursor();
|
|
113
|
+
if (oldest > 0) return oldest > since + 1;
|
|
114
|
+
return since < getOutboxHighWater();
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
function rowToEvent(row: OutboxRow): BusEvent {
|
|
75
118
|
return {
|
|
76
119
|
seq: row.seq,
|
package/src/bus.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
2
|
import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
|
|
3
|
-
import {
|
|
3
|
+
import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
|
|
4
4
|
import { projectAgentStatusData } from "./agent-card-projection";
|
|
5
5
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
6
6
|
import { getCommand, updateCommand } from "./commands-db";
|
|
@@ -15,6 +15,7 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
|
|
|
15
15
|
import { ServiceAuthError } from "./services/errors";
|
|
16
16
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
|
|
17
17
|
import { flushQueuedDirectMessages } from "./services/managed-running";
|
|
18
|
+
import { flushIdleLineageReport } from "./services/send-message";
|
|
18
19
|
import { isAgentUnavailable } from "./db/agent-predicates";
|
|
19
20
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
20
21
|
import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
|
|
@@ -105,6 +106,10 @@ export function busHandleClose(ws: BusWebSocket): void {
|
|
|
105
106
|
if (!conn.transportReconnect) {
|
|
106
107
|
setStatus(conn.agentId, "stale", { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
107
108
|
emitAgentStatusEvent(conn.agentId);
|
|
109
|
+
// #1040 — a WS close is a busy→exit edge (the runner crashed/dropped before its stop-hook
|
|
110
|
+
// status frame). Flush any held turn-final report-up so it still reaches the spawner instead
|
|
111
|
+
// of dying with the socket. No-op when nothing was deferred.
|
|
112
|
+
flushIdleLineageReport(conn.agentId);
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
}
|
|
@@ -123,6 +128,9 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: s
|
|
|
123
128
|
revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
|
|
124
129
|
setStatus(row.id, "offline");
|
|
125
130
|
emitAgentStatusEvent(row.id);
|
|
131
|
+
// #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
|
|
132
|
+
// clean idle transition. Flush any held turn-final report-up before the agent ages out.
|
|
133
|
+
flushIdleLineageReport(row.id);
|
|
126
134
|
const tasks = orphanTasksForAgent(row.id);
|
|
127
135
|
for (const task of tasks) {
|
|
128
136
|
orphanedTasks.push(task);
|
|
@@ -156,7 +164,14 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
156
164
|
const wasUnavailable = current ? isAgentUnavailable(current) : false;
|
|
157
165
|
const runtime = runtimeFromMeta(frame.payload.meta);
|
|
158
166
|
heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
|
|
159
|
-
if (frame.payload.status)
|
|
167
|
+
if (frame.payload.status) {
|
|
168
|
+
setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
169
|
+
// #1040 — a heartbeat frame can carry the busy→idle transition instead of a `status`
|
|
170
|
+
// frame. Flush the held report-up on THIS edge too, else `before.status` is already
|
|
171
|
+
// non-busy at the next status frame → the flush never fires → the child deadlocks idle
|
|
172
|
+
// with its verdict held and the coordinator waits forever.
|
|
173
|
+
if (current?.status === "busy" && frame.payload.status !== "busy") flushIdleLineageReport(conn.agentId);
|
|
174
|
+
}
|
|
160
175
|
emitAgentStatusEvent(conn.agentId);
|
|
161
176
|
// #416 — flush queued messages if the agent transitioned from unavailable to available.
|
|
162
177
|
if (wasUnavailable) {
|
|
@@ -200,6 +215,9 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
200
215
|
emitAgentStatusEvent(conn.agentId);
|
|
201
216
|
// #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
|
|
202
217
|
probeWorkspaceOnTurnEnd(before, after);
|
|
218
|
+
// #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
|
|
219
|
+
// report-up to its spawner (held back while the child was mid-work).
|
|
220
|
+
if (before?.status === "busy" && after && after.status !== "busy") flushIdleLineageReport(conn.agentId);
|
|
203
221
|
// #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
|
|
204
222
|
// marks the offline edge with `terminalProviderExit`; the owning service turns that into a
|
|
205
223
|
// terminal event + #636 diagnosis (and clears the hold on a genuine resume).
|
|
@@ -466,18 +484,23 @@ function hasRunnerLifecycleCapabilities(capabilities: string[], meta: Record<str
|
|
|
466
484
|
}
|
|
467
485
|
|
|
468
486
|
function handleResume(ws: BusWebSocket, frameId: string, since: number): void {
|
|
469
|
-
|
|
470
|
-
if (since > 0 && oldest > 0 && oldest > since + 1) {
|
|
487
|
+
if (hasReplayGap(since)) {
|
|
471
488
|
sendError(ws, frameId, "REPLAY_GAP", "requested cursor is older than retained bus outbox events");
|
|
472
489
|
return;
|
|
473
490
|
}
|
|
474
|
-
|
|
491
|
+
// One page only. The replay cap silently dropped the backlog tail between item 500 and the head
|
|
492
|
+
// (#1022 F1); we now report `truncated` + the raw `nextSince` row seq so the client loops resume
|
|
493
|
+
// until it has drained the whole backlog rather than lurching to the live head on the next event.
|
|
494
|
+
const slice = replayEventsSlice(since);
|
|
495
|
+
const events = slice.events.flatMap(outboxToPayloads);
|
|
475
496
|
send(ws, {
|
|
476
497
|
type: "resumed",
|
|
477
498
|
payload: {
|
|
478
499
|
events,
|
|
479
500
|
fromSeq: events[0]?.seq ?? since,
|
|
480
501
|
toSeq: events.at(-1)?.seq ?? since,
|
|
502
|
+
nextSince: slice.lastSeq,
|
|
503
|
+
truncated: slice.truncated,
|
|
481
504
|
},
|
|
482
505
|
});
|
|
483
506
|
}
|
package/src/channel-target.ts
CHANGED
|
@@ -19,6 +19,10 @@ export function parseChannelRouteTarget(target: string): ChannelRouteTarget {
|
|
|
19
19
|
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
20
20
|
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
21
21
|
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
22
|
+
// `capability:` is an accepted alias of `cap:` at send time (planSend/FANOUT_PREFIXES + the SDK
|
|
23
|
+
// matcher both honor it). Parse it here too so channel routing, broadcast scoping and the
|
|
24
|
+
// claimable gate don't silently treat a `capability:` message as a bare agent id (#1022 F3).
|
|
25
|
+
if (target.startsWith("capability:")) return { type: "capability", id: target.slice("capability:".length) };
|
|
22
26
|
if (target === "broadcast") return { type: "broadcast" };
|
|
23
27
|
if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
|
|
24
28
|
return { type: "agent", id: target };
|
package/src/cli/steward.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Steward commands — auto-split from cli.ts (#294). Briefing rail for the steward
|
|
2
2
|
// (#208): the attention queue, per-workspace diagnostics, and check suggestions.
|
|
3
3
|
import { apiRequest } from "./_shared";
|
|
4
|
+
import { resolveStewardChecks } from "../gate-resolver";
|
|
4
5
|
|
|
5
6
|
// Steward briefing commands (#208): queue of workspaces needing attention, a
|
|
6
7
|
// per-workspace diagnostics inspection, and a check-command suggestion.
|
|
@@ -39,21 +40,21 @@ export async function handleStewardCommand(args: string[]): Promise<void> {
|
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
// checks: suggest validation commands from the workspace's changed files.
|
|
43
|
+
// checks: suggest validation commands from the workspace's changed files. Derive
|
|
44
|
+
// the gate from the repo (#1036) — hardcoding `bun test` produced bogus failures in
|
|
45
|
+
// non-bun repos. Resolve the worktree to read its package.json / land-gates pin:
|
|
46
|
+
// an explicit `--repo` wins, else the workspace's own worktreePath/repoRoot.
|
|
43
47
|
const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
|
|
44
48
|
const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
|
|
45
|
-
|
|
49
|
+
let repoRoot = repo;
|
|
50
|
+
if (!repoRoot) {
|
|
51
|
+
try {
|
|
52
|
+
const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`) as any;
|
|
53
|
+
repoRoot = ws?.worktreePath ?? ws?.repoRoot;
|
|
54
|
+
} catch {
|
|
55
|
+
// Best-effort: without a resolvable path the resolver falls back to its default.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const checks = resolveStewardChecks(files, repoRoot);
|
|
46
59
|
console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
|
|
47
60
|
}
|
|
48
|
-
|
|
49
|
-
// Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
|
|
50
|
-
// steward can refine; cheaper than re-deriving from project docs every run.
|
|
51
|
-
function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
|
|
52
|
-
const checks: Array<{ command: string; reason: string }> = [];
|
|
53
|
-
const has = (re: RegExp) => files.some((f) => re.test(f));
|
|
54
|
-
if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
|
|
55
|
-
if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
|
|
56
|
-
else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
|
|
57
|
-
if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
|
|
58
|
-
return checks;
|
|
59
|
-
}
|
package/src/commands-db.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { STALE_TTL_MS } from "./config";
|
|
2
3
|
import { getAgent, getDb, getTerminalEvent, recordTerminalEvent } from "./db";
|
|
3
4
|
import { stringValue } from "agent-relay-sdk";
|
|
4
5
|
import type { Command, CommandStatus, CreateCommandInput, UpdateCommandInput } from "./types";
|
|
@@ -63,6 +64,14 @@ function isTerminalStatus(status: CommandStatus): boolean {
|
|
|
63
64
|
function isLegitimateTerminalCorrection(type: string, from: CommandStatus, to: CommandStatus): boolean {
|
|
64
65
|
if (type === "agent.spawn" && from === "succeeded" && to === "failed") return true;
|
|
65
66
|
if (type === "orchestrator.upgrade" && from === "failed" && to === "succeeded") return true;
|
|
67
|
+
// #1025 A7 — a lost merge RESULT: the orchestrator lands the branch (base pushed,
|
|
68
|
+
// worktree recycled onto `<branch>-N`) but dies before POSTing, so the command ages to
|
|
69
|
+
// `timed_out`. When it finally reports the real `succeeded` result, that late correction
|
|
70
|
+
// must be applied — otherwise setWorkspaceBranch never runs, the row keeps the old
|
|
71
|
+
// (deleted) branch, the worktree sits on `-N`, and every later preview reports `missing`
|
|
72
|
+
// → the lane degrades into a silent previewUnavailable strand (A5). The merge already
|
|
73
|
+
// happened, so there is no double-land hazard: applying the result only reconciles state.
|
|
74
|
+
if (type === "workspace.merge" && from === "timed_out" && to === "succeeded") return true;
|
|
66
75
|
return false;
|
|
67
76
|
}
|
|
68
77
|
|
|
@@ -162,16 +171,34 @@ export function findLatestCommandByCorrelation(type: string, correlationId: stri
|
|
|
162
171
|
return row ? rowToCommand(row) : null;
|
|
163
172
|
}
|
|
164
173
|
|
|
165
|
-
|
|
174
|
+
// #1034 — count a parent's still-in-flight spawn commands, EXCLUDING any whose child has
|
|
175
|
+
// already registered as a live agent. Spawn commands settle to a terminal status
|
|
176
|
+
// asynchronously (the orchestrator reports back AFTER the child registers), so between
|
|
177
|
+
// registration and settlement a child is otherwise double-counted: once by
|
|
178
|
+
// `countLiveSpawnedAgents` (ready=1) and once here (command still running). Combined into
|
|
179
|
+
// `occupied = live + inFlight`, that transient over-count could push a parent at its quota
|
|
180
|
+
// boundary over the limit and spuriously reject a concurrent spawn — which then "succeeded
|
|
181
|
+
// seconds later" once the command settled. The `NOT EXISTS` dedup mirrors the live-child
|
|
182
|
+
// liveness predicate in `countLiveSpawnedAgents` (and the display-side dedup already in
|
|
183
|
+
// relay_my_agents) so each child counts exactly once.
|
|
184
|
+
export function countInFlightSpawnCommands(parentId: string, now: number = Date.now()): number {
|
|
166
185
|
const row = getDb()
|
|
167
186
|
.query(
|
|
168
187
|
`SELECT count(*) AS n
|
|
169
|
-
FROM commands
|
|
170
|
-
WHERE type = 'agent.spawn'
|
|
171
|
-
AND status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
|
|
172
|
-
AND json_extract(params, '$.spawnedBy') =
|
|
188
|
+
FROM commands c
|
|
189
|
+
WHERE c.type = 'agent.spawn'
|
|
190
|
+
AND c.status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
|
|
191
|
+
AND json_extract(c.params, '$.spawnedBy') = ?
|
|
192
|
+
AND NOT EXISTS (
|
|
193
|
+
SELECT 1 FROM agents a
|
|
194
|
+
WHERE a.spawned_by = ?
|
|
195
|
+
AND a.status NOT IN ('offline', 'stale')
|
|
196
|
+
AND a.ready = 1
|
|
197
|
+
AND a.last_seen > ?
|
|
198
|
+
AND json_extract(a.meta, '$.spawnRequestId') = coalesce(json_extract(c.params, '$.spawnRequestId'), c.correlation_id)
|
|
199
|
+
)`,
|
|
173
200
|
)
|
|
174
|
-
.get(...SPAWN_IN_FLIGHT_STATUSES, parentId) as { n: number };
|
|
201
|
+
.get(...SPAWN_IN_FLIGHT_STATUSES, parentId, parentId, now - STALE_TTL_MS) as { n: number };
|
|
175
202
|
return row.n;
|
|
176
203
|
}
|
|
177
204
|
|
|
@@ -244,11 +271,38 @@ export function deleteCommand(id: string): boolean {
|
|
|
244
271
|
return true;
|
|
245
272
|
}
|
|
246
273
|
|
|
247
|
-
|
|
248
|
-
|
|
274
|
+
// #1025 A6 — sweep both claimed/active leases past their deadline AND pending commands
|
|
275
|
+
// whose target orchestrator is permanently gone. Pending commands are normally left for a
|
|
276
|
+
// busy host to poll (#930/#953), but a `workspace.merge` (or `agent.spawn`) whose target
|
|
277
|
+
// host was decommissioned / re-registered under a new agent id will NEVER poll again — the
|
|
278
|
+
// command lingers forever, `recoverAgedInFlightWorkspaces` files the workspace under
|
|
279
|
+
// `stillActive` indefinitely, and the parked worker waits on a `landed-success` that can't
|
|
280
|
+
// fire. The `isTargetGone` predicate (supplied by the caller, which knows agent liveness)
|
|
281
|
+
// gates the pending sweep so a merely-busy host is untouched; a gone target's expired
|
|
282
|
+
// pending command is timed out, then settled → the workspace returns to review_requested
|
|
283
|
+
// and re-schedules, and the spawn parent is told. Opt-in: callers without the predicate
|
|
284
|
+
// keep the historical claimed/active-only behavior.
|
|
285
|
+
export function expireCommands(
|
|
286
|
+
now: number = Date.now(),
|
|
287
|
+
opts: { isTargetGone?: (command: Command) => boolean } = {},
|
|
288
|
+
): Command[] {
|
|
289
|
+
const activeRows = getDb()
|
|
249
290
|
.query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status IN (${CLAIMED_ACTIVE_STATUSES.map(() => "?").join(",")})`)
|
|
250
291
|
.all(now, ...CLAIMED_ACTIVE_STATUSES) as CommandRow[];
|
|
251
|
-
|
|
292
|
+
let pendingRows: CommandRow[] = [];
|
|
293
|
+
if (opts.isTargetGone) {
|
|
294
|
+
const candidates = getDb()
|
|
295
|
+
.query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status = 'pending'`)
|
|
296
|
+
.all(now) as CommandRow[];
|
|
297
|
+
pendingRows = candidates.filter((row) => opts.isTargetGone!(rowToCommand(row)));
|
|
298
|
+
}
|
|
299
|
+
const rows = [...activeRows, ...pendingRows];
|
|
300
|
+
for (const row of rows) {
|
|
301
|
+
updateCommand(row.id, {
|
|
302
|
+
status: "timed_out",
|
|
303
|
+
error: row.status === "pending" ? "command target gone; expired before dispatch" : "command timed out",
|
|
304
|
+
});
|
|
305
|
+
}
|
|
252
306
|
return rows.map((row) => getCommand(row.id)).filter((command): command is Command => Boolean(command));
|
|
253
307
|
}
|
|
254
308
|
|
package/src/config.ts
CHANGED
|
@@ -116,6 +116,56 @@ export function relayAuthHeaders(base: Record<string, string> = {}): Record<stri
|
|
|
116
116
|
return token ? { ...base, [RELAY_TOKEN_HEADER]: token } : base;
|
|
117
117
|
}
|
|
118
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
|
+
|
|
119
169
|
export function allowUnauthenticated(): boolean {
|
|
120
170
|
return process.env.AGENT_RELAY_ALLOW_UNAUTH === "1";
|
|
121
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/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/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
|
}
|