agent-relay-server 0.121.5 → 0.122.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 +6 -6
- package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
- package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
- package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
- package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
- package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
- package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
- package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
- package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
- package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
- package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
- package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
- package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
- package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
- package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
- package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
- package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
- package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
- package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
- package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
- package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
- package/public/assets/display-CJzSoTMq.js +3 -0
- package/public/assets/display-CJzSoTMq.js.map +1 -0
- package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
- package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
- package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
- package/public/assets/index-D0xMQQb_.js +25 -0
- package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
- package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
- package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
- package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
- package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
- package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
- package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
- package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
- package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
- package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
- package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
- package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
- package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
- package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
- package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
- package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
- package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
- package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
- package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
- package/public/assets/projects-Dzdf-gkK.js +2 -0
- package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
- package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
- package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
- package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
- package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
- package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
- package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
- package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
- package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
- package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
- package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
- package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
- package/public/assets/store-BPrVuKiv.js.map +1 -0
- package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
- package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
- package/public/assets/teams-DWv6UwFV.js +2 -0
- package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
- package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
- package/public/assets/types-BeJUSfDj.js.map +1 -0
- package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
- package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
- package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
- package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
- package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
- package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
- package/runner/src/adapter.ts +53 -18
- package/runner/src/adapters/claude-delivery.ts +127 -0
- package/runner/src/adapters/claude-quota-harvest.ts +92 -0
- package/runner/src/adapters/claude-session-probe.ts +225 -0
- package/runner/src/adapters/claude-status-detectors.ts +147 -0
- package/runner/src/adapters/claude-transcript-tail.ts +128 -0
- package/runner/src/adapters/claude-transcript.ts +414 -0
- package/runner/src/adapters/claude.ts +672 -0
- package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
- package/runner/src/adapters/codex-client.ts +279 -0
- package/runner/src/adapters/codex.ts +1345 -0
- package/runner/src/attachment-cache.ts +189 -0
- package/runner/src/busy-reconciler.ts +102 -0
- package/runner/src/claim-tracker.ts +140 -0
- package/runner/src/claude-prompt-gates.ts +268 -0
- package/runner/src/config.ts +1 -4
- package/runner/src/context-probe-state.ts +6 -0
- package/runner/src/continuation-archive.ts +179 -0
- package/runner/src/control-server.ts +639 -0
- package/runner/src/index.ts +396 -0
- package/runner/src/launch-assembly.ts +508 -0
- package/runner/src/liveness.ts +50 -0
- package/runner/src/logger.ts +99 -0
- package/runner/src/mcp-outbox.ts +120 -0
- package/runner/src/message-body-config/config.ts +3 -0
- package/runner/src/message-body-config.ts +1 -0
- package/runner/src/native-self-resume.ts +202 -0
- package/runner/src/outbox.ts +342 -0
- package/runner/src/process-meta.ts +9 -0
- package/runner/src/profile-home.ts +260 -0
- package/runner/src/profile-projection.ts +305 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +314 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +102 -0
- package/runner/src/relay-instructions.ts +60 -0
- package/runner/src/relay-mcp-proxy.ts +383 -0
- package/runner/src/relay-mcp.ts +156 -0
- package/runner/src/reply-obligation-cache.ts +136 -0
- package/runner/src/response-capture-report.ts +63 -0
- package/runner/src/runner-core.ts +2409 -0
- package/runner/src/runner-helpers.ts +435 -0
- package/runner/src/runner-insights.ts +105 -0
- package/runner/src/runner.ts +14 -0
- package/runner/src/session-destroy.ts +22 -0
- package/runner/src/session-insights.ts +118 -0
- package/runner/src/session-scratch.ts +271 -0
- package/runner/src/template-resolver.ts +29 -0
- package/runner/src/version.ts +43 -0
- package/src/agent-card-projection.ts +5 -1
- package/src/agent-command-activity.ts +39 -0
- package/src/automations/reconcile.ts +2 -2
- package/src/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +118 -37
- package/src/db/message-reads.ts +50 -0
- package/src/db/messages.ts +48 -9
- package/src/db/migrations.ts +54 -0
- package/src/db/provider-quota-burn.ts +96 -67
- package/src/db/provider-quotas.ts +29 -7
- package/src/db/scheduled-timers.ts +21 -0
- package/src/db/schema.ts +1 -0
- package/src/db/tasks.ts +33 -3
- package/src/db/work-evidence.ts +10 -7
- package/src/event-loop-lag.ts +15 -0
- package/src/gate-resolver.ts +19 -1
- package/src/maintenance/jobs.ts +23 -6
- package/src/managed-policy.ts +5 -12
- package/src/mcp-plan-tools.ts +13 -1
- package/src/project-instructions.ts +36 -2
- package/src/quota-advisory.ts +6 -4
- package/src/quota-band-transition.ts +23 -14
- package/src/routes/agent-sessions.ts +39 -7
- package/src/routes/agents.ts +3 -2
- package/src/routes/tasks.ts +16 -4
- package/src/routes/workspaces.ts +35 -10
- package/src/services/plan-graph.ts +27 -9
- package/src/services/register-agent.ts +12 -12
- package/src/services/send-message.ts +43 -15
- package/src/services/spawn-agent.ts +3 -0
- package/src/spawn-command.ts +8 -0
- package/src/spawn-targets.ts +19 -34
- package/src/sse.ts +25 -3
- package/src/workspace-merge.ts +25 -5
- package/src/workspace-orphans.ts +13 -6
- package/public/assets/display-exQDWum3.js +0 -3
- package/public/assets/display-exQDWum3.js.map +0 -1
- package/public/assets/index-D9OogaB5.js +0 -25
- package/public/assets/projects-DdPNPFJI.js +0 -2
- package/public/assets/prompt-settings-BpZse7-X.js +0 -2
- package/public/assets/store-DKTLubBT.js.map +0 -1
- package/public/assets/teams-B8f2pGJe.js +0 -2
- package/public/assets/types-uVOXgvMT.js.map +0 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { SendMessageInput } from "agent-relay-sdk";
|
|
2
|
+
import type { RelayHttpClient } from "agent-relay-sdk";
|
|
3
|
+
import { errMessage } from "agent-relay-sdk";
|
|
4
|
+
import { relayMcpEndpoint } from "./relay-mcp";
|
|
5
|
+
import type { OutboxRecord } from "./outbox";
|
|
6
|
+
import { deliverContinuationArchiveRecord } from "./continuation-archive";
|
|
7
|
+
import { logger } from "./logger";
|
|
8
|
+
import { isHttpAuthError, isHttpStatusError } from "./runner-helpers";
|
|
9
|
+
|
|
10
|
+
interface RunnerOutboxDelivery {
|
|
11
|
+
record: OutboxRecord;
|
|
12
|
+
http: RelayHttpClient;
|
|
13
|
+
relayUrl: string;
|
|
14
|
+
token?: string;
|
|
15
|
+
updatePayload(seq: number, payload: unknown): void;
|
|
16
|
+
sessionLog(message: string): void;
|
|
17
|
+
recoverRuntimeTokenAfterAuthFailure(source: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Promise<void> {
|
|
21
|
+
const { record, http } = input;
|
|
22
|
+
try {
|
|
23
|
+
if (record.kind === "session-message") {
|
|
24
|
+
await http.sendMessage({
|
|
25
|
+
...(record.payload as SendMessageInput),
|
|
26
|
+
occurredAt: record.occurredAt,
|
|
27
|
+
idempotencyKey: record.idempotencyKey,
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (record.kind === "session-message-batch") {
|
|
32
|
+
const messages = Array.isArray((record.payload as { messages?: unknown }).messages)
|
|
33
|
+
? (record.payload as { messages: SendMessageInput[] }).messages
|
|
34
|
+
: [];
|
|
35
|
+
const batch = messages.map((message, index) => ({
|
|
36
|
+
...message,
|
|
37
|
+
occurredAt: typeof message.occurredAt === "number" ? message.occurredAt : record.occurredAt,
|
|
38
|
+
idempotencyKey: message.idempotencyKey ?? `${record.idempotencyKey}:${index}`,
|
|
39
|
+
}));
|
|
40
|
+
try {
|
|
41
|
+
await http.sendMessages(batch);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!isDeterministicValidationError(error)) throw error;
|
|
44
|
+
await deliverSessionBatchItems(input, batch);
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (record.kind === "insight") {
|
|
49
|
+
await http.recordInsightObservation({
|
|
50
|
+
...(record.payload as Parameters<RelayHttpClient["recordInsightObservation"]>[0]),
|
|
51
|
+
occurredAt: record.occurredAt,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (record.kind === "continuation-archive") {
|
|
56
|
+
await deliverContinuationArchiveRecord({
|
|
57
|
+
record,
|
|
58
|
+
http,
|
|
59
|
+
updatePayload: input.updatePayload,
|
|
60
|
+
sessionLog: input.sessionLog,
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (record.kind === "mcp-tool-call") {
|
|
65
|
+
await deliverBufferedMcpCall(input);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (isHttpStatusError(error, 409)) return;
|
|
71
|
+
if (isHttpAuthError(error)) input.recoverRuntimeTokenAfterAuthFailure("outbox");
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function deliverSessionBatchItems(input: RunnerOutboxDelivery, messages: SendMessageInput[]): Promise<void> {
|
|
77
|
+
for (const message of messages) {
|
|
78
|
+
try {
|
|
79
|
+
await input.http.sendMessage(message);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!isDeterministicValidationError(error)) throw error;
|
|
82
|
+
logger.warn("outbox", `dropping invalid session batch item: ${errMessage(error)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isDeterministicValidationError(error: unknown): boolean {
|
|
88
|
+
const status = typeof error === "object" && error !== null ? (error as { status?: unknown }).status : undefined;
|
|
89
|
+
return status === 400 || status === 413 || status === 422;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function deliverBufferedMcpCall(input: {
|
|
93
|
+
record: OutboxRecord;
|
|
94
|
+
relayUrl: string;
|
|
95
|
+
token?: string;
|
|
96
|
+
recoverRuntimeTokenAfterAuthFailure(source: string): void;
|
|
97
|
+
}): Promise<void> {
|
|
98
|
+
const payload = input.record.payload as { tool: string; arguments: Record<string, unknown> };
|
|
99
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
100
|
+
if (input.token) headers.authorization = `Bearer ${input.token}`;
|
|
101
|
+
const response = await fetch(relayMcpEndpoint(input.relayUrl), {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers,
|
|
104
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: payload.tool, arguments: payload.arguments } }),
|
|
105
|
+
});
|
|
106
|
+
if (response.status === 401 || response.status === 403) {
|
|
107
|
+
input.recoverRuntimeTokenAfterAuthFailure("mcp-outbox");
|
|
108
|
+
throw new Error(`relay rejected buffered ${payload.tool} with ${response.status}`);
|
|
109
|
+
}
|
|
110
|
+
if (response.status >= 500) throw new Error(`relay ${response.status} on buffered ${payload.tool}`);
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const body = await response.text().catch(() => "");
|
|
113
|
+
logger.warn("mcp-outbox", `buffered ${payload.tool} permanently rejected (${response.status}); dropping: ${body.slice(0, 200)}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const json = await response.json().catch(() => null) as { error?: { message?: string } } | null;
|
|
117
|
+
if (json?.error) {
|
|
118
|
+
logger.warn("mcp-outbox", `buffered ${payload.tool} returned a tool error; dropping: ${json.error.message ?? "(no detail)"}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { messageBodyMaxCharsFromEnv } from "./message-body-config/config";
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { errMessage } from "agent-relay-sdk";
|
|
2
|
+
import type { ProviderStatusEvent, SemanticStatus } from "./adapter";
|
|
3
|
+
import { nativeSelfResumeTimeoutMsFromEnv } from "./config";
|
|
4
|
+
|
|
5
|
+
interface RunnerTimelineEvent {
|
|
6
|
+
status: string;
|
|
7
|
+
id?: string;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
title?: string;
|
|
10
|
+
body?: string;
|
|
11
|
+
icon?: string;
|
|
12
|
+
metadata?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NativeSelfResumeHandoff {
|
|
16
|
+
commandId: string;
|
|
17
|
+
generation?: number;
|
|
18
|
+
compactedAt?: number;
|
|
19
|
+
timer: ReturnType<typeof setTimeout>;
|
|
20
|
+
resolve: (result: Record<string, unknown>) => void;
|
|
21
|
+
reject: (error: Error) => void;
|
|
22
|
+
promise: Promise<Record<string, unknown>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isNativeSelfResumeCompact(type: string, params: Record<string, unknown>): boolean {
|
|
26
|
+
if (type !== "agent.compact") return false;
|
|
27
|
+
const selfResume = params.selfResume;
|
|
28
|
+
return Boolean(selfResume && typeof selfResume === "object" && !Array.isArray(selfResume) && (selfResume as Record<string, unknown>).mode === "native");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class NativeSelfResumeTracker {
|
|
32
|
+
private current?: NativeSelfResumeHandoff;
|
|
33
|
+
|
|
34
|
+
get active(): NativeSelfResumeHandoff | undefined {
|
|
35
|
+
return this.current;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
begin(commandId: string, params: Record<string, unknown>): NativeSelfResumeHandoff {
|
|
39
|
+
this.finish(this.current);
|
|
40
|
+
let resolve!: (result: Record<string, unknown>) => void;
|
|
41
|
+
let reject!: (error: Error) => void;
|
|
42
|
+
const promise = new Promise<Record<string, unknown>>((res, rej) => {
|
|
43
|
+
resolve = res;
|
|
44
|
+
reject = rej;
|
|
45
|
+
});
|
|
46
|
+
const selfResume = params.selfResume && typeof params.selfResume === "object" && !Array.isArray(params.selfResume)
|
|
47
|
+
? params.selfResume as Record<string, unknown>
|
|
48
|
+
: undefined;
|
|
49
|
+
const generation = typeof selfResume?.generation === "number" ? selfResume.generation : undefined;
|
|
50
|
+
const timeoutMs = nativeSelfResumeTimeoutMsFromEnv();
|
|
51
|
+
const handoff: NativeSelfResumeHandoff = {
|
|
52
|
+
commandId,
|
|
53
|
+
generation,
|
|
54
|
+
promise,
|
|
55
|
+
resolve,
|
|
56
|
+
reject,
|
|
57
|
+
timer: setTimeout(() => {
|
|
58
|
+
if (this.current !== handoff) return;
|
|
59
|
+
this.current = undefined;
|
|
60
|
+
reject(new Error(`native self-resume timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
61
|
+
}, timeoutMs),
|
|
62
|
+
};
|
|
63
|
+
this.current = handoff;
|
|
64
|
+
return handoff;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
finish(handoff?: NativeSelfResumeHandoff): void {
|
|
68
|
+
if (!handoff) return;
|
|
69
|
+
clearTimeout(handoff.timer);
|
|
70
|
+
if (this.current === handoff) this.current = undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
resolve(handoff: NativeSelfResumeHandoff, result: Record<string, unknown>): void {
|
|
74
|
+
this.finish(handoff);
|
|
75
|
+
handoff.resolve(result);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
reject(handoff: NativeSelfResumeHandoff, error: Error): void {
|
|
79
|
+
this.finish(handoff);
|
|
80
|
+
handoff.reject(error);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
noteCompacted(timestamp: number = Date.now()): void {
|
|
84
|
+
if (!this.current) return;
|
|
85
|
+
this.current.compactedAt = timestamp;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface NativeSelfResumeRecoveryOptions {
|
|
90
|
+
handoff: NativeSelfResumeHandoff;
|
|
91
|
+
tracker: NativeSelfResumeTracker;
|
|
92
|
+
providerSessionId: string;
|
|
93
|
+
status: SemanticStatus;
|
|
94
|
+
diagnostics: Record<string, unknown>;
|
|
95
|
+
now: number;
|
|
96
|
+
publishTimeline(event: RunnerTimelineEvent): void;
|
|
97
|
+
setTerminalProviderExit(marker: Record<string, unknown>): void;
|
|
98
|
+
setProviderStatus(update: ProviderStatusEvent): void;
|
|
99
|
+
restartProvider(resumeId?: string): Promise<void>;
|
|
100
|
+
publishStatus(): void;
|
|
101
|
+
scheduleDrain(): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function recoverNativeSelfResumeExit(opts: NativeSelfResumeRecoveryOptions): Promise<void> {
|
|
105
|
+
const resumeId = typeof opts.diagnostics.claudeResumeId === "string" && opts.diagnostics.claudeResumeId.length > 0
|
|
106
|
+
? opts.diagnostics.claudeResumeId
|
|
107
|
+
: undefined;
|
|
108
|
+
if (opts.handoff.compactedAt && resumeId) {
|
|
109
|
+
await recoverWithClaudeResume(opts, resumeId);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await recoverWithFreshLaunch(opts, resumeId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function recoverWithClaudeResume(opts: NativeSelfResumeRecoveryOptions, resumeId: string): Promise<void> {
|
|
116
|
+
opts.publishTimeline({
|
|
117
|
+
status: "provider.restart_decision",
|
|
118
|
+
id: `provider-self-resume-relaunch-${opts.providerSessionId}-${opts.now}`,
|
|
119
|
+
timestamp: Date.now(),
|
|
120
|
+
title: "Self-resume relaunching",
|
|
121
|
+
body: `Claude exited during native self-resume; runner is relaunching with claude --resume ${resumeId}.`,
|
|
122
|
+
icon: "ti-refresh",
|
|
123
|
+
metadata: selfResumeMetadata(opts.handoff, opts.diagnostics, {
|
|
124
|
+
decision: "resume-native-self-resume",
|
|
125
|
+
reason: "self-resume-exit-after-compact",
|
|
126
|
+
claudeResumeId: resumeId,
|
|
127
|
+
compactedAt: opts.handoff.compactedAt,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
await opts.restartProvider(resumeId);
|
|
132
|
+
opts.publishStatus();
|
|
133
|
+
opts.scheduleDrain();
|
|
134
|
+
opts.tracker.resolve(opts.handoff, { recovered: true, via: "claude-resume", claudeResumeId: resumeId });
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const message = errMessage(error);
|
|
137
|
+
opts.setTerminalProviderExit(selfResumeMetadata(opts.handoff, opts.diagnostics, {
|
|
138
|
+
reason: "self-resume-relaunch-failed",
|
|
139
|
+
claudeResumeId: resumeId,
|
|
140
|
+
compactedAt: opts.handoff.compactedAt,
|
|
141
|
+
lastError: message,
|
|
142
|
+
}));
|
|
143
|
+
opts.setProviderStatus({
|
|
144
|
+
status: "offline",
|
|
145
|
+
reason: "provider-turn",
|
|
146
|
+
id: `provider-self-resume-relaunch-failed-${opts.providerSessionId}`,
|
|
147
|
+
clear: ["provider-turn", "subagent", "background-script"],
|
|
148
|
+
});
|
|
149
|
+
opts.tracker.reject(opts.handoff, new Error(`self-resume relaunch failed: ${message}`));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function recoverWithFreshLaunch(opts: NativeSelfResumeRecoveryOptions, resumeId?: string): Promise<void> {
|
|
154
|
+
opts.publishTimeline({
|
|
155
|
+
status: "provider.restart_decision",
|
|
156
|
+
id: `provider-self-resume-fresh-launch-${opts.providerSessionId}-${opts.now}`,
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
title: "Self-resume fresh-launching",
|
|
159
|
+
body: "Claude exited during native self-resume before a compacted marker; runner is fresh-launching so Relay can inject the persisted continuation.",
|
|
160
|
+
icon: "ti-refresh",
|
|
161
|
+
metadata: selfResumeMetadata(opts.handoff, opts.diagnostics, {
|
|
162
|
+
decision: "fresh-launch-native-self-resume",
|
|
163
|
+
reason: "self-resume-exit-before-compact",
|
|
164
|
+
...(resumeId ? { claudeResumeId: resumeId } : {}),
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
await opts.restartProvider();
|
|
169
|
+
opts.publishStatus();
|
|
170
|
+
opts.scheduleDrain();
|
|
171
|
+
opts.tracker.resolve(opts.handoff, {
|
|
172
|
+
recovered: true,
|
|
173
|
+
via: "fresh-launch",
|
|
174
|
+
...(resumeId ? { claudeResumeId: resumeId } : {}),
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const message = errMessage(error);
|
|
178
|
+
opts.setTerminalProviderExit(selfResumeMetadata(opts.handoff, opts.diagnostics, {
|
|
179
|
+
reason: "self-resume-fresh-launch-failed",
|
|
180
|
+
...(resumeId ? { claudeResumeId: resumeId } : {}),
|
|
181
|
+
lastError: message,
|
|
182
|
+
}));
|
|
183
|
+
opts.setProviderStatus({
|
|
184
|
+
status: "offline",
|
|
185
|
+
reason: "provider-turn",
|
|
186
|
+
id: `provider-self-resume-fresh-launch-failed-${opts.providerSessionId}`,
|
|
187
|
+
clear: ["provider-turn", "subagent", "background-script"],
|
|
188
|
+
});
|
|
189
|
+
opts.tracker.reject(opts.handoff, new Error(`self-resume fresh launch failed: ${message}`));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function selfResumeMetadata(handoff: NativeSelfResumeHandoff, diagnostics: Record<string, unknown>, extra: Record<string, unknown>): Record<string, unknown> {
|
|
194
|
+
return {
|
|
195
|
+
eventType: "provider.restart_decision",
|
|
196
|
+
selfResume: true,
|
|
197
|
+
selfResumeCommandId: handoff.commandId,
|
|
198
|
+
selfResumeGeneration: handoff.generation ?? null,
|
|
199
|
+
...extra,
|
|
200
|
+
...diagnostics,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
6
|
+
import { errMessage } from "agent-relay-sdk";
|
|
7
|
+
import { logger } from "./logger";
|
|
8
|
+
import { runnerOutboxDirFromEnv } from "./config";
|
|
9
|
+
|
|
10
|
+
// Phase 2 (#196) — the "nothing is ever lost" half. Runner→server events that used to be
|
|
11
|
+
// fire-and-forget over HTTP (session turns, reasoning/tool traces, prompt echoes, insights,
|
|
12
|
+
// hook-fatal reports) were silently dropped whenever the server was momentarily down. This
|
|
13
|
+
// is a durable, FIFO, disk-backed queue that:
|
|
14
|
+
// - survives Runner/server restart (bun:sqlite file in the runtime dir),
|
|
15
|
+
// - stamps true event time (`occurredAt`) once at enqueue and preserves it through retries,
|
|
16
|
+
// - retries with capped exponential backoff, strictly in order (an append log must not
|
|
17
|
+
// reorder turns),
|
|
18
|
+
// - poisons a permanently-failing head after maxAttempts so it can't block the queue,
|
|
19
|
+
// - is bounded with a logged drop policy (never silently truncates).
|
|
20
|
+
//
|
|
21
|
+
// Status deliberately does NOT go through here: it rides the WebSocket bus, which is
|
|
22
|
+
// last-wins and self-heals on reconnect (so it already satisfies "coalesce, don't replay
|
|
23
|
+
// stale busyes"). The coalesce mode below exists so a future state event could migrate here.
|
|
24
|
+
|
|
25
|
+
type OutboxMode = "append" | "coalesce";
|
|
26
|
+
|
|
27
|
+
interface OutboxEventInput {
|
|
28
|
+
kind: string;
|
|
29
|
+
payload: unknown;
|
|
30
|
+
mode?: OutboxMode;
|
|
31
|
+
// Required for coalesce mode: prior un-poisoned rows with the same dedupeKey are replaced.
|
|
32
|
+
dedupeKey?: string;
|
|
33
|
+
// Defaults to now. Set explicitly only to backdate (e.g. replaying a captured timestamp).
|
|
34
|
+
occurredAt?: number;
|
|
35
|
+
// Defaults to a stable derived key so server-side dedup makes retries exactly-once.
|
|
36
|
+
idempotencyKey?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OutboxRecord {
|
|
40
|
+
seq: number;
|
|
41
|
+
kind: string;
|
|
42
|
+
mode: OutboxMode;
|
|
43
|
+
occurredAt: number;
|
|
44
|
+
idempotencyKey: string;
|
|
45
|
+
payload: unknown;
|
|
46
|
+
attempts: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The transport. Resolve = delivered (row deleted). Reject = failed (retried with backoff).
|
|
50
|
+
type OutboxSend = (record: OutboxRecord) => Promise<void>;
|
|
51
|
+
|
|
52
|
+
interface OutboxOptions {
|
|
53
|
+
agentId: string;
|
|
54
|
+
send: OutboxSend;
|
|
55
|
+
// Storage directory. Defaults to AGENT_RELAY_RUNNER_OUTBOX_DIR, else a per-host temp dir.
|
|
56
|
+
dir?: string;
|
|
57
|
+
maxRows?: number;
|
|
58
|
+
maxAttempts?: number;
|
|
59
|
+
baseBackoffMs?: number;
|
|
60
|
+
maxBackoffMs?: number;
|
|
61
|
+
pollMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULTS = {
|
|
65
|
+
maxRows: 5000,
|
|
66
|
+
maxAttempts: 12,
|
|
67
|
+
baseBackoffMs: 1_000,
|
|
68
|
+
maxBackoffMs: 60_000,
|
|
69
|
+
pollMs: 5_000,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Pause between forced delivery attempts during flush() — keeps a brief server outage
|
|
73
|
+
// from hot-looping while still retrying fast enough to land within the flush deadline.
|
|
74
|
+
const FLUSH_RETRY_PAUSE_MS = 150;
|
|
75
|
+
|
|
76
|
+
interface Row {
|
|
77
|
+
seq: number;
|
|
78
|
+
kind: string;
|
|
79
|
+
mode: string;
|
|
80
|
+
occurred_at: number;
|
|
81
|
+
idempotency_key: string;
|
|
82
|
+
payload: string;
|
|
83
|
+
attempts: number;
|
|
84
|
+
next_attempt_at: number;
|
|
85
|
+
poisoned: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class Outbox {
|
|
89
|
+
private readonly db: Database;
|
|
90
|
+
private readonly agentId: string;
|
|
91
|
+
private readonly send: OutboxSend;
|
|
92
|
+
private readonly maxRows: number;
|
|
93
|
+
private readonly maxAttempts: number;
|
|
94
|
+
private readonly baseBackoffMs: number;
|
|
95
|
+
private readonly maxBackoffMs: number;
|
|
96
|
+
private readonly pollMs: number;
|
|
97
|
+
readonly path: string;
|
|
98
|
+
|
|
99
|
+
private draining = false;
|
|
100
|
+
private rerun = false;
|
|
101
|
+
// Set by flush() to make the next drain pass ignore per-row backoff (deliver NOW).
|
|
102
|
+
private forceDue = false;
|
|
103
|
+
private pollTimer?: ReturnType<typeof setInterval>;
|
|
104
|
+
private dueTimer?: ReturnType<typeof setTimeout>;
|
|
105
|
+
private stopped = false;
|
|
106
|
+
|
|
107
|
+
constructor(options: OutboxOptions) {
|
|
108
|
+
this.agentId = options.agentId;
|
|
109
|
+
this.send = options.send;
|
|
110
|
+
this.maxRows = options.maxRows ?? DEFAULTS.maxRows;
|
|
111
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULTS.maxAttempts;
|
|
112
|
+
this.baseBackoffMs = options.baseBackoffMs ?? DEFAULTS.baseBackoffMs;
|
|
113
|
+
this.maxBackoffMs = options.maxBackoffMs ?? DEFAULTS.maxBackoffMs;
|
|
114
|
+
this.pollMs = options.pollMs ?? DEFAULTS.pollMs;
|
|
115
|
+
|
|
116
|
+
const dir = options.dir ?? runnerOutboxDirFromEnv() ?? join(tmpdir(), "agent-relay-outbox");
|
|
117
|
+
this.path = options.dir === ":memory:" ? ":memory:" : join(dir, `outbox-${safeName(this.agentId)}.sqlite`);
|
|
118
|
+
if (this.path !== ":memory:") mkdirSync(dirname(this.path), { recursive: true });
|
|
119
|
+
|
|
120
|
+
this.db = new Database(this.path, { create: true });
|
|
121
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
122
|
+
this.db.exec("PRAGMA busy_timeout = 2000");
|
|
123
|
+
this.db.exec(`
|
|
124
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
125
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
126
|
+
kind TEXT NOT NULL,
|
|
127
|
+
mode TEXT NOT NULL DEFAULT 'append',
|
|
128
|
+
dedupe_key TEXT,
|
|
129
|
+
occurred_at INTEGER NOT NULL,
|
|
130
|
+
idempotency_key TEXT NOT NULL,
|
|
131
|
+
payload TEXT NOT NULL,
|
|
132
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
133
|
+
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
|
134
|
+
poisoned INTEGER NOT NULL DEFAULT 0,
|
|
135
|
+
created_at INTEGER NOT NULL
|
|
136
|
+
)
|
|
137
|
+
`);
|
|
138
|
+
// A restart is a fresh start: clear any backoff timers left by the prior process so
|
|
139
|
+
// pending events get an immediate retry (the down server may now be back). `attempts`
|
|
140
|
+
// is kept so the poison threshold still counts cumulative failures.
|
|
141
|
+
this.db.exec("UPDATE outbox SET next_attempt_at = 0 WHERE next_attempt_at > 0");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Persist an event. Returns the assigned seq. Triggers a drain.
|
|
145
|
+
enqueue(input: OutboxEventInput): number {
|
|
146
|
+
if (this.stopped) throw new Error("outbox is stopped");
|
|
147
|
+
const mode: OutboxMode = input.mode ?? "append";
|
|
148
|
+
const occurredAt = input.occurredAt ?? Date.now();
|
|
149
|
+
const payloadJson = JSON.stringify(input.payload ?? null);
|
|
150
|
+
const idempotencyKey = input.idempotencyKey ?? `${this.agentId}:${input.kind}:${occurredAt}:${shortHash(payloadJson)}`;
|
|
151
|
+
|
|
152
|
+
if (mode === "coalesce") {
|
|
153
|
+
if (!input.dedupeKey) throw new Error("coalesce mode requires a dedupeKey");
|
|
154
|
+
this.db.query("DELETE FROM outbox WHERE dedupe_key = ? AND poisoned = 0").run(input.dedupeKey);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const info = this.db
|
|
158
|
+
.query(`INSERT INTO outbox (kind, mode, dedupe_key, occurred_at, idempotency_key, payload, created_at)
|
|
159
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
160
|
+
.run(input.kind, mode, input.dedupeKey ?? null, occurredAt, idempotencyKey, payloadJson, Date.now());
|
|
161
|
+
const seq = Number(info.lastInsertRowid);
|
|
162
|
+
|
|
163
|
+
this.enforceBound();
|
|
164
|
+
// Defer the drain to a microtask so a synchronous burst of enqueues (e.g. several
|
|
165
|
+
// coalesce updates) all land — and coalesce — before the pump pulls the head.
|
|
166
|
+
queueMicrotask(() => { void this.drain(); });
|
|
167
|
+
return seq;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Bounded ring buffer: if over capacity, drop the oldest rows (defined overflow policy).
|
|
171
|
+
// Logged, never silent. Prefers dropping already-poisoned rows first, then oldest by seq.
|
|
172
|
+
private enforceBound(): void {
|
|
173
|
+
const { n } = this.db.query("SELECT count(*) AS n FROM outbox").get() as { n: number };
|
|
174
|
+
if (n <= this.maxRows) return;
|
|
175
|
+
const overflow = n - this.maxRows;
|
|
176
|
+
// Oldest poisoned first, then oldest live — both by seq.
|
|
177
|
+
const victims = this.db
|
|
178
|
+
.query("SELECT seq FROM outbox ORDER BY poisoned DESC, seq ASC LIMIT ?")
|
|
179
|
+
.all(overflow) as Array<{ seq: number }>;
|
|
180
|
+
const ids = victims.map((v) => v.seq);
|
|
181
|
+
if (ids.length === 0) return;
|
|
182
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
183
|
+
this.db.query(`DELETE FROM outbox WHERE seq IN (${placeholders})`).run(...ids);
|
|
184
|
+
logger.warn("outbox", `bound exceeded (${n}/${this.maxRows}) — dropped ${ids.length} oldest event(s)`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Begin the background pump: an initial drain plus a poll timer as a backstop.
|
|
188
|
+
start(): void {
|
|
189
|
+
if (this.pollTimer || this.stopped) return;
|
|
190
|
+
void this.drain();
|
|
191
|
+
this.pollTimer = setInterval(() => { void this.drain(); }, this.pollMs);
|
|
192
|
+
this.pollTimer.unref?.();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Drain everything we can before the process exits, ignoring per-row backoff. Used on
|
|
196
|
+
// shutdown/kill/crash (#183): the capture seam durably enqueues the end-of-session
|
|
197
|
+
// Insights datapoint, but the per-agent outbox is never reopened once the agent is gone,
|
|
198
|
+
// so a row that hasn't been POSTed yet is stranded forever. flush() blocks (bounded) on
|
|
199
|
+
// delivery — the opposite intent of the polite, backoff-respecting steady-state drain.
|
|
200
|
+
// Returns true if the queue fully drained within the deadline.
|
|
201
|
+
async flush(timeoutMs: number): Promise<boolean> {
|
|
202
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
203
|
+
for (;;) {
|
|
204
|
+
if (this.stopped) return this.pendingCount() === 0;
|
|
205
|
+
if (this.pendingCount() === 0) return true;
|
|
206
|
+
await this.drain(true);
|
|
207
|
+
if (this.pendingCount() === 0) return true;
|
|
208
|
+
if (Date.now() >= deadline) return false;
|
|
209
|
+
await new Promise<void>((resolve) => setTimeout(resolve, FLUSH_RETRY_PAUSE_MS));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Process the queue strictly oldest-first. Coalesces concurrent calls; if a drain is
|
|
214
|
+
// requested while one is running, it re-runs once at the end (so an enqueue during a
|
|
215
|
+
// send isn't missed). `force` (from flush) makes this pass ignore per-row backoff.
|
|
216
|
+
async drain(force = false): Promise<void> {
|
|
217
|
+
if (this.stopped) return;
|
|
218
|
+
if (force) this.forceDue = true;
|
|
219
|
+
if (this.draining) { this.rerun = true; return; }
|
|
220
|
+
this.draining = true;
|
|
221
|
+
try {
|
|
222
|
+
do {
|
|
223
|
+
this.rerun = false;
|
|
224
|
+
const due = this.forceDue;
|
|
225
|
+
this.forceDue = false;
|
|
226
|
+
await this.drainOnce(due);
|
|
227
|
+
} while ((this.rerun || this.forceDue) && !this.stopped);
|
|
228
|
+
} finally {
|
|
229
|
+
this.draining = false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async drainOnce(force = false): Promise<void> {
|
|
234
|
+
for (;;) {
|
|
235
|
+
if (this.stopped) return;
|
|
236
|
+
const row = this.db
|
|
237
|
+
.query("SELECT * FROM outbox WHERE poisoned = 0 ORDER BY seq ASC LIMIT 1")
|
|
238
|
+
.get() as Row | null;
|
|
239
|
+
if (!row) return;
|
|
240
|
+
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
if (!force && row.next_attempt_at > now) {
|
|
243
|
+
// Head isn't due yet. Don't reorder past it (FIFO) — schedule a wake-up and stop.
|
|
244
|
+
this.scheduleDue(row.next_attempt_at - now);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const record: OutboxRecord = {
|
|
249
|
+
seq: row.seq,
|
|
250
|
+
kind: row.kind,
|
|
251
|
+
mode: row.mode as OutboxMode,
|
|
252
|
+
occurredAt: row.occurred_at,
|
|
253
|
+
idempotencyKey: row.idempotency_key,
|
|
254
|
+
payload: safeParse(row.payload),
|
|
255
|
+
attempts: row.attempts,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await this.send(record);
|
|
260
|
+
this.db.query("DELETE FROM outbox WHERE seq = ?").run(row.seq);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const attempts = row.attempts + 1;
|
|
263
|
+
const reason = errMessage(error);
|
|
264
|
+
if (attempts >= this.maxAttempts) {
|
|
265
|
+
this.db.query("UPDATE outbox SET attempts = ?, poisoned = 1 WHERE seq = ?").run(attempts, row.seq);
|
|
266
|
+
logger.fatal("outbox", `event seq=${row.seq} kind=${row.kind} poisoned after ${attempts} attempts: ${reason}`);
|
|
267
|
+
// Move on — the next iteration picks the new head (poison no longer blocks).
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const delay = this.backoff(attempts);
|
|
271
|
+
this.db.query("UPDATE outbox SET attempts = ?, next_attempt_at = ? WHERE seq = ?").run(attempts, now + delay, row.seq);
|
|
272
|
+
logger.debug("outbox", `event seq=${row.seq} kind=${row.kind} retry ${attempts}/${this.maxAttempts} in ${delay}ms: ${reason}`);
|
|
273
|
+
this.scheduleDue(delay);
|
|
274
|
+
return; // head is now scheduled; stop until it's due (preserve order)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Pure per-row delay calc: the attempt count lives in SQLite (one outbox row
|
|
280
|
+
// at a time may be due), so this stays a stateless function rather than the
|
|
281
|
+
// stateful SDK ReconnectionManager (single in-memory counter) used for the
|
|
282
|
+
// orchestrator/voice reconnect loops — different model, deliberately not shared.
|
|
283
|
+
private backoff(attempts: number): number {
|
|
284
|
+
const exp = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** (attempts - 1));
|
|
285
|
+
return Math.round(exp / 2 + Math.random() * (exp / 2)); // full-ish jitter, never below half
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private scheduleDue(delayMs: number): void {
|
|
289
|
+
if (this.stopped || this.dueTimer) return;
|
|
290
|
+
this.dueTimer = setTimeout(() => {
|
|
291
|
+
this.dueTimer = undefined;
|
|
292
|
+
void this.drain();
|
|
293
|
+
}, Math.max(0, delayMs));
|
|
294
|
+
this.dueTimer.unref?.();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Observability / tests.
|
|
298
|
+
pendingCount(): number {
|
|
299
|
+
return (this.db.query("SELECT count(*) AS n FROM outbox WHERE poisoned = 0").get() as { n: number }).n;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
updatePayload(seq: number, payload: unknown): void {
|
|
303
|
+
const payloadJson = JSON.stringify(payload ?? null);
|
|
304
|
+
this.db.query("UPDATE outbox SET payload = ? WHERE seq = ? AND poisoned = 0").run(payloadJson, seq);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
poisonedCount(): number {
|
|
308
|
+
return (this.db.query("SELECT count(*) AS n FROM outbox WHERE poisoned = 1").get() as { n: number }).n;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
stop(): void {
|
|
312
|
+
this.stopped = true;
|
|
313
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
314
|
+
this.pollTimer = undefined;
|
|
315
|
+
if (this.dueTimer) clearTimeout(this.dueTimer);
|
|
316
|
+
this.dueTimer = undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
close(): void {
|
|
320
|
+
this.stop();
|
|
321
|
+
try { this.db.close(); } catch { /* already closed */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function safeName(value: string): string {
|
|
326
|
+
return sanitizeFsName(value, { replacement: "_", maxLen: 180, fallback: "agent" });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function safeParse(json: string): unknown {
|
|
330
|
+
try { return JSON.parse(json); } catch { return null; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Small, fast, stable string hash (FNV-1a, 32-bit) — enough to disambiguate identical
|
|
334
|
+
// kind+timestamp payloads in the idempotency key. Not security-sensitive.
|
|
335
|
+
function shortHash(value: string): string {
|
|
336
|
+
let h = 0x811c9dc5;
|
|
337
|
+
for (let i = 0; i < value.length; i++) {
|
|
338
|
+
h ^= value.charCodeAt(i);
|
|
339
|
+
h = Math.imul(h, 0x01000193);
|
|
340
|
+
}
|
|
341
|
+
return (h >>> 0).toString(36);
|
|
342
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ManagedProcess } from "./adapter";
|
|
2
|
+
|
|
3
|
+
export function providerTerminalSession(process?: ManagedProcess): string | undefined {
|
|
4
|
+
return typeof process?.meta?.tmuxSession === "string" ? process.meta.tmuxSession : undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function providerTerminalSocket(process?: ManagedProcess): string | undefined {
|
|
8
|
+
return typeof process?.meta?.tmuxSocket === "string" ? process.meta.tmuxSocket : undefined;
|
|
9
|
+
}
|