agent-relay-server 0.121.0 → 0.121.2
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 +34 -7
- 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/agents.ts +6 -2
- 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/event-loop-lag.ts +10 -1
- 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 +36 -4
- 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/routes/workspaces.ts +25 -7
- package/src/security.ts +35 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +8 -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-merge.ts +16 -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
|
}
|
|
@@ -113,8 +118,12 @@ export function getBusConnectionCount(): number {
|
|
|
113
118
|
return busConnections.size;
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: string[]; orphanedTasks: Task[] } {
|
|
117
|
-
|
|
121
|
+
export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0): { agentIds: string[]; orphanedTasks: Task[] } {
|
|
122
|
+
// #1048 defense-in-depth: subtract the relay's own recently-observed event-loop lag
|
|
123
|
+
// from the cutoff so a bus-grace expiry can't fire on a heartbeat gap that was really
|
|
124
|
+
// the relay stalling. Secondary to the stale reaper (this only touches already-`stale`
|
|
125
|
+
// rows), but kept symmetric. lagGraceMs is passed in — bus.ts stays health-agnostic.
|
|
126
|
+
const cutoff = Date.now() - graceMs - Math.max(0, lagGraceMs);
|
|
118
127
|
const rows = getDb().query("SELECT id FROM agents WHERE status = 'stale' AND last_seen < ? AND id NOT IN ('user', 'system')").all(cutoff) as Array<{ id: string }>;
|
|
119
128
|
const orphanedTasks: Task[] = [];
|
|
120
129
|
for (const row of rows) {
|
|
@@ -123,6 +132,9 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: s
|
|
|
123
132
|
revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
|
|
124
133
|
setStatus(row.id, "offline");
|
|
125
134
|
emitAgentStatusEvent(row.id);
|
|
135
|
+
// #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
|
|
136
|
+
// clean idle transition. Flush any held turn-final report-up before the agent ages out.
|
|
137
|
+
flushIdleLineageReport(row.id);
|
|
126
138
|
const tasks = orphanTasksForAgent(row.id);
|
|
127
139
|
for (const task of tasks) {
|
|
128
140
|
orphanedTasks.push(task);
|
|
@@ -156,7 +168,14 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
156
168
|
const wasUnavailable = current ? isAgentUnavailable(current) : false;
|
|
157
169
|
const runtime = runtimeFromMeta(frame.payload.meta);
|
|
158
170
|
heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
|
|
159
|
-
if (frame.payload.status)
|
|
171
|
+
if (frame.payload.status) {
|
|
172
|
+
setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
173
|
+
// #1040 — a heartbeat frame can carry the busy→idle transition instead of a `status`
|
|
174
|
+
// frame. Flush the held report-up on THIS edge too, else `before.status` is already
|
|
175
|
+
// non-busy at the next status frame → the flush never fires → the child deadlocks idle
|
|
176
|
+
// with its verdict held and the coordinator waits forever.
|
|
177
|
+
if (current?.status === "busy" && frame.payload.status !== "busy") flushIdleLineageReport(conn.agentId);
|
|
178
|
+
}
|
|
160
179
|
emitAgentStatusEvent(conn.agentId);
|
|
161
180
|
// #416 — flush queued messages if the agent transitioned from unavailable to available.
|
|
162
181
|
if (wasUnavailable) {
|
|
@@ -200,6 +219,9 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
200
219
|
emitAgentStatusEvent(conn.agentId);
|
|
201
220
|
// #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
|
|
202
221
|
probeWorkspaceOnTurnEnd(before, after);
|
|
222
|
+
// #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
|
|
223
|
+
// report-up to its spawner (held back while the child was mid-work).
|
|
224
|
+
if (before?.status === "busy" && after && after.status !== "busy") flushIdleLineageReport(conn.agentId);
|
|
203
225
|
// #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
|
|
204
226
|
// marks the offline edge with `terminalProviderExit`; the owning service turns that into a
|
|
205
227
|
// terminal event + #636 diagnosis (and clears the hold on a genuine resume).
|
|
@@ -466,18 +488,23 @@ function hasRunnerLifecycleCapabilities(capabilities: string[], meta: Record<str
|
|
|
466
488
|
}
|
|
467
489
|
|
|
468
490
|
function handleResume(ws: BusWebSocket, frameId: string, since: number): void {
|
|
469
|
-
|
|
470
|
-
if (since > 0 && oldest > 0 && oldest > since + 1) {
|
|
491
|
+
if (hasReplayGap(since)) {
|
|
471
492
|
sendError(ws, frameId, "REPLAY_GAP", "requested cursor is older than retained bus outbox events");
|
|
472
493
|
return;
|
|
473
494
|
}
|
|
474
|
-
|
|
495
|
+
// One page only. The replay cap silently dropped the backlog tail between item 500 and the head
|
|
496
|
+
// (#1022 F1); we now report `truncated` + the raw `nextSince` row seq so the client loops resume
|
|
497
|
+
// until it has drained the whole backlog rather than lurching to the live head on the next event.
|
|
498
|
+
const slice = replayEventsSlice(since);
|
|
499
|
+
const events = slice.events.flatMap(outboxToPayloads);
|
|
475
500
|
send(ws, {
|
|
476
501
|
type: "resumed",
|
|
477
502
|
payload: {
|
|
478
503
|
events,
|
|
479
504
|
fromSeq: events[0]?.seq ?? since,
|
|
480
505
|
toSeq: events.at(-1)?.seq ?? since,
|
|
506
|
+
nextSince: slice.lastSeq,
|
|
507
|
+
truncated: slice.truncated,
|
|
481
508
|
},
|
|
482
509
|
});
|
|
483
510
|
}
|
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/agents.ts
CHANGED
|
@@ -491,9 +491,13 @@ function mergeRelayContextMarkers(agentId: string, next: ContextState | undefine
|
|
|
491
491
|
}
|
|
492
492
|
|
|
493
493
|
|
|
494
|
-
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
494
|
+
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): string[] {
|
|
495
495
|
const now = Date.now();
|
|
496
|
-
|
|
496
|
+
// #1048 — extend the stale cutoff by the relay's own recently-observed event-loop
|
|
497
|
+
// lag. A missed heartbeat while THIS process was stalled means "the relay couldn't
|
|
498
|
+
// read it", not "the agent died"; the caller passes the grace in (this module stays
|
|
499
|
+
// health-agnostic). lagGraceMs of 0 is the pre-#1048 behavior.
|
|
500
|
+
const cutoff = now - ttlMs - Math.max(0, lagGraceMs);
|
|
497
501
|
getDb().query("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
|
|
498
502
|
const rows = getDb()
|
|
499
503
|
.query(
|
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 {
|