larkin 0.3.3 → 0.4.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/README.md +72 -33
- package/assets/readme/larkin-hero.svg +111 -0
- package/dist/agent/agent-state-store.cjs +321 -22
- package/dist/agent/agent-state-store.mjs +320 -22
- package/dist/agent/agent-transport.cjs +6 -4
- package/dist/agent/agent-transport.mjs +6 -4
- package/dist/agent/host-reminder-orchestrator.cjs +9 -3
- package/dist/agent/host-reminder-orchestrator.mjs +9 -3
- package/dist/agent/inbox-projection.cjs +114 -23
- package/dist/agent/inbox-projection.mjs +112 -23
- package/dist/app/cli.mjs +3 -1
- package/dist/app/local-control.mjs +108 -2
- package/dist/app/runtime-process.mjs +4 -0
- package/dist/app/session-cli.mjs +55 -20
- package/dist/feishu/document-comment.cjs +2 -1
- package/dist/feishu/document-comment.mjs +2 -1
- package/dist/feishu/host-business-state.cjs +7 -2
- package/dist/feishu/host-business-state.mjs +7 -2
- package/dist/feishu/host-shell.cjs +138 -32
- package/dist/feishu/host-shell.mjs +138 -32
- package/dist/runtime/pi-compaction-recovery.cjs +623 -0
- package/dist/runtime/pi-compaction-recovery.mjs +602 -0
- package/dist/runtime/pi-provider-config.cjs +2 -2
- package/dist/runtime/pi-provider-config.mjs +2 -2
- package/dist/runtime/pi-rpc-client.cjs +39 -22
- package/dist/runtime/pi-rpc-client.mjs +39 -22
- package/dist/runtime/provider-error-classifier.cjs +39 -0
- package/dist/runtime/provider-error-classifier.mjs +34 -0
- package/dist/runtime/runtime-adapters.mjs +82 -19
- package/dist/runtime/runtime-host.cjs +663 -12
- package/dist/runtime/runtime-host.mjs +661 -11
- package/package.json +1 -1
|
@@ -2,10 +2,13 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { agentCliPromptCapabilities } from "../agent/agent-cli-capabilities.mjs";
|
|
5
|
+
import { isCanonicalInboxTarget, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
|
|
5
6
|
import { SpanKind } from "@opentelemetry/api";
|
|
7
|
+
import { buildStrictProviderErrorInput, classifyStrictProviderError } from "./provider-error-classifier.mjs";
|
|
6
8
|
import { classifyRuntimePrerequisite, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
|
|
7
9
|
import { resolveOfficialLarkCli } from "../app/official-lark-cli.mjs";
|
|
8
10
|
import { assertAgentWorkspaceBound, managedLarkCliEnv } from "../app/agent-lark-cli-workspace.mjs";
|
|
11
|
+
import { PiCompactionBreaker, PiCompactionRecoveryMachine } from "./pi-compaction-recovery.mjs";
|
|
9
12
|
export class RuntimeSessionResetError extends Error {
|
|
10
13
|
code;
|
|
11
14
|
pendingCount;
|
|
@@ -16,9 +19,33 @@ export class RuntimeSessionResetError extends Error {
|
|
|
16
19
|
this.name = "RuntimeSessionResetError";
|
|
17
20
|
}
|
|
18
21
|
}
|
|
22
|
+
export class RuntimeSessionRecoveryError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
pendingCount;
|
|
25
|
+
constructor(code, message, pendingCount = 0) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.pendingCount = pendingCount;
|
|
29
|
+
this.name = "RuntimeSessionRecoveryError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
19
32
|
const MAX_DELIVERIES = 2048;
|
|
20
33
|
const now = () => new Date().toISOString();
|
|
21
34
|
const isActiveDelivery = (status) => ["pending", "submitting", "accepted"].includes(status);
|
|
35
|
+
const REPLAY_FAILURE_CODES = new Set([
|
|
36
|
+
"canonical_inbox_row_missing", "canonical_inbox_malformed", "duplicate_message_id",
|
|
37
|
+
"inbox_state_conflict", "delivery_target_conflict", "structured_target_invalid",
|
|
38
|
+
"structured_target_missing", "wake_reason_conflict",
|
|
39
|
+
]);
|
|
40
|
+
function replayFailureReason(code) {
|
|
41
|
+
return `Runtime delivery quarantined (${code}): no safe canonical Inbox target is available; the Inbox/ledger remain durable for operator recovery`;
|
|
42
|
+
}
|
|
43
|
+
function replayFailureCodeOf(record) {
|
|
44
|
+
if (record.status !== "error" || typeof record.reason !== "string")
|
|
45
|
+
return null;
|
|
46
|
+
const code = /^Runtime delivery quarantined \(([^)]+)\):/.exec(record.reason)?.[1];
|
|
47
|
+
return code && REPLAY_FAILURE_CODES.has(code) ? code : null;
|
|
48
|
+
}
|
|
22
49
|
function deliveryFile(agent) {
|
|
23
50
|
return agent.config.stateDir ? path.join(agent.config.stateDir, "runtime-deliveries.json") : null;
|
|
24
51
|
}
|
|
@@ -91,6 +118,9 @@ export function createRuntimeHost(options) {
|
|
|
91
118
|
const listeners = new Set();
|
|
92
119
|
const log = options.log ?? (() => { });
|
|
93
120
|
const telemetry = options.telemetry;
|
|
121
|
+
const compactTimeoutMs = options.compactTimeoutMs ?? 120_000;
|
|
122
|
+
if (!Number.isFinite(compactTimeoutMs) || compactTimeoutMs <= 0)
|
|
123
|
+
throw new Error("compactTimeoutMs must be positive");
|
|
94
124
|
const retryPolicy = {
|
|
95
125
|
baseDelayMs: options.retryPolicy?.baseDelayMs ?? 250,
|
|
96
126
|
maxDelayMs: options.retryPolicy?.maxDelayMs ?? 10_000,
|
|
@@ -151,6 +181,82 @@ export function createRuntimeHost(options) {
|
|
|
151
181
|
emitConsumed(agent, persist(agent));
|
|
152
182
|
return agent.records.get(record.deliveryId) ?? record;
|
|
153
183
|
};
|
|
184
|
+
const scrubQuarantinedInput = (record, reason) => {
|
|
185
|
+
const staleInput = record.input && typeof record.input === "object" ? record.input : null;
|
|
186
|
+
record.input = {
|
|
187
|
+
inputId: typeof staleInput?.inputId === "string" && staleInput.inputId ? staleInput.inputId : record.deliveryId,
|
|
188
|
+
deliveryId: record.deliveryId,
|
|
189
|
+
kind: "wake",
|
|
190
|
+
text: reason,
|
|
191
|
+
attempt: Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
/** Rebuild every submitted notice from canonical structured state, never persisted text. */
|
|
195
|
+
const prepareRecordInput = (agent, record, busy) => {
|
|
196
|
+
if (record.target !== undefined && (typeof record.target !== "string" || !isCanonicalInboxTarget(record.target))) {
|
|
197
|
+
return { status: "error", code: "structured_target_invalid", reason: replayFailureReason("structured_target_invalid") };
|
|
198
|
+
}
|
|
199
|
+
if (record.wakeReason !== undefined && typeof record.wakeReason !== "string") {
|
|
200
|
+
return { status: "error", code: "wake_reason_conflict", reason: replayFailureReason("wake_reason_conflict") };
|
|
201
|
+
}
|
|
202
|
+
let target = typeof record.target === "string" ? record.target : null;
|
|
203
|
+
let wakeReason = typeof record.wakeReason === "string" ? record.wakeReason : undefined;
|
|
204
|
+
if (agent.stateStore?.resolveInboxDeliverySource) {
|
|
205
|
+
let source;
|
|
206
|
+
try {
|
|
207
|
+
source = agent.stateStore.resolveInboxDeliverySource(record.messageId);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
source = { status: "invalid", code: "canonical_inbox_malformed" };
|
|
211
|
+
}
|
|
212
|
+
if (source.status === "consumed")
|
|
213
|
+
return { status: "consumed" };
|
|
214
|
+
if (source.status === "missing" || source.status === "invalid") {
|
|
215
|
+
return { status: "error", code: source.code, reason: replayFailureReason(source.code) };
|
|
216
|
+
}
|
|
217
|
+
if (target && target !== source.target) {
|
|
218
|
+
return { status: "error", code: "delivery_target_conflict", reason: replayFailureReason("delivery_target_conflict") };
|
|
219
|
+
}
|
|
220
|
+
const canonicalWakeReason = typeof source.envelope.wake_reason === "string" ? source.envelope.wake_reason : undefined;
|
|
221
|
+
if (record.wakeReason !== undefined && wakeReason !== canonicalWakeReason) {
|
|
222
|
+
return { status: "error", code: "wake_reason_conflict", reason: replayFailureReason("wake_reason_conflict") };
|
|
223
|
+
}
|
|
224
|
+
target = source.target;
|
|
225
|
+
wakeReason = canonicalWakeReason;
|
|
226
|
+
}
|
|
227
|
+
if (!target)
|
|
228
|
+
return { status: "error", code: "structured_target_missing", reason: replayFailureReason("structured_target_missing") };
|
|
229
|
+
const staleInput = record.input && typeof record.input === "object" ? record.input : null;
|
|
230
|
+
const attempt = Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0;
|
|
231
|
+
const inputId = typeof staleInput?.inputId === "string" && staleInput.inputId ? staleInput.inputId : record.deliveryId;
|
|
232
|
+
record.target = target;
|
|
233
|
+
if (wakeReason)
|
|
234
|
+
record.wakeReason = wakeReason;
|
|
235
|
+
else
|
|
236
|
+
delete record.wakeReason;
|
|
237
|
+
record.input = {
|
|
238
|
+
inputId,
|
|
239
|
+
deliveryId: record.deliveryId,
|
|
240
|
+
kind: busy ? "inbox_update" : "wake",
|
|
241
|
+
text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId: record.deliveryId, target,
|
|
242
|
+
...(wakeReason ? { wakeReason } : {}) }),
|
|
243
|
+
attempt,
|
|
244
|
+
};
|
|
245
|
+
return { status: "ready", target };
|
|
246
|
+
};
|
|
247
|
+
const quarantineRecord = (agent, record, code) => {
|
|
248
|
+
const reason = replayFailureReason(code);
|
|
249
|
+
scrubQuarantinedInput(record, reason);
|
|
250
|
+
record.reason = reason;
|
|
251
|
+
record.retryable = false;
|
|
252
|
+
delete record.errorCategory;
|
|
253
|
+
const finalRecord = setRecord(agent, record, "error");
|
|
254
|
+
if (finalRecord.status === "consumed")
|
|
255
|
+
return { status: "accepted", deliveryId: record.deliveryId };
|
|
256
|
+
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId,
|
|
257
|
+
messageId: record.messageId, status: "error", reason });
|
|
258
|
+
return { status: "error", deliveryId: record.deliveryId, reason, retryable: false };
|
|
259
|
+
};
|
|
154
260
|
const reconcileExternalConsumption = (agent) => {
|
|
155
261
|
if (agent.stateStore) {
|
|
156
262
|
agent.stateStore.withInboxTransaction(() => reconcileFile(agent, agent.stateStore.readJson("runtimeDeliveries", { version: 1, records: [] })));
|
|
@@ -284,12 +390,14 @@ export function createRuntimeHost(options) {
|
|
|
284
390
|
if (result.status === "accepted") {
|
|
285
391
|
delete record.reason;
|
|
286
392
|
delete record.retryable;
|
|
393
|
+
delete record.errorCategory;
|
|
287
394
|
const finalRecord = setRecord(agent, record, "accepted");
|
|
288
395
|
if (finalRecord.status !== "consumed")
|
|
289
396
|
emit({ type: "delivery", agentId: agent.config.agentId,
|
|
290
397
|
deliveryId: record.deliveryId, messageId: record.messageId, status: "accepted" });
|
|
291
398
|
return { status: "accepted", deliveryId: record.deliveryId };
|
|
292
399
|
}
|
|
400
|
+
delete record.errorCategory;
|
|
293
401
|
const retryable = result.status === "deferred" || result.retryable;
|
|
294
402
|
record.reason = result.reason;
|
|
295
403
|
record.retryable = retryable;
|
|
@@ -307,6 +415,20 @@ export function createRuntimeHost(options) {
|
|
|
307
415
|
if (!busy)
|
|
308
416
|
agent.busy = true; // Reserve the turn before prompt() can yield.
|
|
309
417
|
try {
|
|
418
|
+
const prepared = prepareRecordInput(agent, record, busy);
|
|
419
|
+
if (prepared.status === "consumed") {
|
|
420
|
+
if (!busy)
|
|
421
|
+
agent.busy = false;
|
|
422
|
+
const consumedRecord = setRecord(agent, record, "consumed");
|
|
423
|
+
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: consumedRecord.deliveryId,
|
|
424
|
+
messageId: consumedRecord.messageId, status: "consumed" });
|
|
425
|
+
return { status: "accepted", deliveryId: record.deliveryId };
|
|
426
|
+
}
|
|
427
|
+
if (prepared.status === "error") {
|
|
428
|
+
if (!busy)
|
|
429
|
+
agent.busy = false;
|
|
430
|
+
return quarantineRecord(agent, record, prepared.code);
|
|
431
|
+
}
|
|
310
432
|
const session = await ensureSession(agent);
|
|
311
433
|
const submittingRecord = setRecord(agent, record, "submitting");
|
|
312
434
|
if (submittingRecord.status === "consumed") {
|
|
@@ -330,6 +452,7 @@ export function createRuntimeHost(options) {
|
|
|
330
452
|
const reason = error instanceof Error ? error.message : String(error);
|
|
331
453
|
record.reason = reason;
|
|
332
454
|
record.retryable = true;
|
|
455
|
+
delete record.errorCategory;
|
|
333
456
|
setRecord(agent, record, "pending");
|
|
334
457
|
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId, messageId: record.messageId, status: "deferred", reason });
|
|
335
458
|
return { status: "deferred", deliveryId: record.deliveryId, reason };
|
|
@@ -456,6 +579,106 @@ export function createRuntimeHost(options) {
|
|
|
456
579
|
})();
|
|
457
580
|
agent.configurationRecovery = recovery;
|
|
458
581
|
};
|
|
582
|
+
let recoverContextOverflowInternal;
|
|
583
|
+
const beginPiManualCompaction = (agent, session, record, nativeWillRetry = false) => {
|
|
584
|
+
if (agent.adapter.id !== "pi" || !session.compact || !agent.compactionBreaker)
|
|
585
|
+
return;
|
|
586
|
+
const existing = agent.compactionMachines.get(record.input.inputId);
|
|
587
|
+
if (existing) {
|
|
588
|
+
existing.agentEnd({ exactOverflow: true, willRetry: nativeWillRetry });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const key = `${record.deliveryId}:${record.input.inputId}`;
|
|
592
|
+
const prior = agent.compactionBreaker.get(key);
|
|
593
|
+
if (prior && prior.manualAttempt >= 1 && ["manual_sent", "manual_ambiguous", "manual_failed", "native_failed"].includes(prior.state)) {
|
|
594
|
+
agent.busy = false;
|
|
595
|
+
agent.turnInProgress = false;
|
|
596
|
+
void recoverContextOverflowInternal(agent.config.agentId, key, "validated Pi compaction breaker state after restart", key)
|
|
597
|
+
.catch((error) => log("Pi restart fallback failed", String(error)));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const machine = new PiCompactionRecoveryMachine({
|
|
601
|
+
breaker: agent.compactionBreaker, key, messageId: record.messageId, deliveryId: record.deliveryId,
|
|
602
|
+
inputId: record.input.inputId, sessionGeneration: agent.generation,
|
|
603
|
+
onAction: (action) => {
|
|
604
|
+
if (action === "retry_input") {
|
|
605
|
+
machine.retrySubmitted();
|
|
606
|
+
record.status = "pending";
|
|
607
|
+
record.retryable = true;
|
|
608
|
+
record.reason = "Pi manual compaction succeeded; retrying the same delivery input";
|
|
609
|
+
record.updatedAt = now();
|
|
610
|
+
persist(agent);
|
|
611
|
+
queueMicrotask(() => { if (!agent.stopped)
|
|
612
|
+
void submit(agent, record, false); });
|
|
613
|
+
}
|
|
614
|
+
else if (action === "fresh_session_fallback") {
|
|
615
|
+
agent.busy = false;
|
|
616
|
+
agent.turnInProgress = false;
|
|
617
|
+
if (record.status !== "error") {
|
|
618
|
+
record.reason = machine.recordSnapshot.fallbackReason || "Pi compaction recovery fallback";
|
|
619
|
+
record.retryable = false;
|
|
620
|
+
record.errorCategory = "context_window";
|
|
621
|
+
setRecord(agent, record, "error");
|
|
622
|
+
}
|
|
623
|
+
void recoverContextOverflowInternal(agent.config.agentId, key, machine.recordSnapshot.fallbackReason || "Pi compaction recovery fallback", key)
|
|
624
|
+
.catch((error) => log("Pi context fallback failed", String(error)));
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
agent.compactionMachines.set(record.input.inputId, machine);
|
|
629
|
+
machine.agentEnd({ exactOverflow: true, willRetry: nativeWillRetry });
|
|
630
|
+
if (nativeWillRetry)
|
|
631
|
+
return;
|
|
632
|
+
machine.agentSettled();
|
|
633
|
+
const deadline = new Date(Date.now() + compactTimeoutMs).toISOString();
|
|
634
|
+
machine.manualCompactSent(deadline);
|
|
635
|
+
let compactSettled = false;
|
|
636
|
+
const compactTimer = setTimeout(() => {
|
|
637
|
+
if (compactSettled)
|
|
638
|
+
return;
|
|
639
|
+
compactSettled = true;
|
|
640
|
+
machine.compactTimeout();
|
|
641
|
+
}, compactTimeoutMs);
|
|
642
|
+
compactTimer.unref?.();
|
|
643
|
+
void session.compact().then(() => {
|
|
644
|
+
if (compactSettled)
|
|
645
|
+
return;
|
|
646
|
+
compactSettled = true;
|
|
647
|
+
clearTimeout(compactTimer);
|
|
648
|
+
machine.compactResponse({ success: true });
|
|
649
|
+
}).catch((error) => {
|
|
650
|
+
if (compactSettled)
|
|
651
|
+
return;
|
|
652
|
+
compactSettled = true;
|
|
653
|
+
clearTimeout(compactTimer);
|
|
654
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
655
|
+
machine.compactResponse({ success: false, ambiguous: /process exited|process failed|session replaced|protocol/i.test(message) });
|
|
656
|
+
});
|
|
657
|
+
};
|
|
658
|
+
const recoverStalePiCompaction = async (agent) => {
|
|
659
|
+
const breaker = agent.compactionBreaker;
|
|
660
|
+
if (!breaker)
|
|
661
|
+
return;
|
|
662
|
+
const stale = breaker.listNonTerminal().find((candidate) => candidate.state !== "native_succeeded");
|
|
663
|
+
if (!stale) {
|
|
664
|
+
const succeeded = breaker.listNonTerminal().find((candidate) => candidate.state === "native_succeeded");
|
|
665
|
+
if (succeeded)
|
|
666
|
+
breaker.transition(succeeded.key, {}, "closed");
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const record = [...agent.records.values()].find((candidate) => candidate.deliveryId === stale.deliveryId
|
|
670
|
+
|| candidate.input.inputId === stale.inputId);
|
|
671
|
+
if (!record)
|
|
672
|
+
return;
|
|
673
|
+
record.reason = stale.fallbackReason || "Pi compaction recovery resumed after restart";
|
|
674
|
+
record.retryable = false;
|
|
675
|
+
record.errorCategory = "context_window";
|
|
676
|
+
setRecord(agent, record, "error");
|
|
677
|
+
breaker.forceFallback(stale.key, record.reason);
|
|
678
|
+
agent.busy = false;
|
|
679
|
+
agent.turnInProgress = false;
|
|
680
|
+
await recoverContextOverflowInternal(agent.config.agentId, stale.key, record.reason, stale.key);
|
|
681
|
+
};
|
|
459
682
|
const observe = (agent, session, event) => {
|
|
460
683
|
if (agent.session !== session)
|
|
461
684
|
return; // Ignore late output from a replaced child.
|
|
@@ -464,6 +687,34 @@ export function createRuntimeHost(options) {
|
|
|
464
687
|
if (event.type !== "turn-end")
|
|
465
688
|
telemetry?.runtimeEvent(agent.config.agentId, event);
|
|
466
689
|
emit({ type: "runtime", agentId: agent.config.agentId, event });
|
|
690
|
+
if (event.type === "runtime-observation") {
|
|
691
|
+
if (event.phase === "compaction_start" && event.reason === "overflow" && event.inputId) {
|
|
692
|
+
const overflowRecord = [...agent.records.values()].find((candidate) => candidate.input.inputId === event.inputId);
|
|
693
|
+
if (overflowRecord && !agent.compactionMachines.has(event.inputId))
|
|
694
|
+
beginPiManualCompaction(agent, session, overflowRecord, true);
|
|
695
|
+
}
|
|
696
|
+
const candidates = [...agent.compactionMachines.values()].filter((machine) => machine.recordSnapshot.sessionGeneration === agent.generation
|
|
697
|
+
&& (!event.inputId || machine.recordSnapshot.inputId === event.inputId));
|
|
698
|
+
const machines = event.inputId ? candidates : candidates.length === 1 ? candidates : [];
|
|
699
|
+
for (const machine of machines) {
|
|
700
|
+
if (event.phase === "compaction_start" && event.reason === "overflow")
|
|
701
|
+
machine.compactionStart({ reason: "overflow" });
|
|
702
|
+
if (event.phase === "compaction_end" && event.reason === "manual") {
|
|
703
|
+
machine.compactionEnd({ reason: "manual", success: event.success === true, willRetry: event.willRetry === true });
|
|
704
|
+
}
|
|
705
|
+
if (event.phase === "compaction_end" && event.reason === "overflow") {
|
|
706
|
+
machine.compactionEnd({ reason: "overflow", success: event.success === true, willRetry: event.willRetry === true });
|
|
707
|
+
}
|
|
708
|
+
if (event.phase === "settled")
|
|
709
|
+
machine.agentSettled();
|
|
710
|
+
}
|
|
711
|
+
if (event.phase === "compaction_end" && event.reason === "overflow" && event.success !== true) {
|
|
712
|
+
for (const record of agent.records.values()) {
|
|
713
|
+
if (isActiveDelivery(record.status))
|
|
714
|
+
agent.piOverflowCompactionFailed.add(record.input.inputId);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
467
718
|
if (event.type === "session-init") {
|
|
468
719
|
agent.config.sessionId = event.sessionId;
|
|
469
720
|
emit({ type: "session", agentId: agent.config.agentId, runtime: agent.adapter.id, sessionId: event.sessionId, launchId: agent.launchId,
|
|
@@ -479,6 +730,13 @@ export function createRuntimeHost(options) {
|
|
|
479
730
|
emit({ type: "activity", agentId: agent.config.agentId, activity: "working", activityKind: "working", detailKind: "turn_started" });
|
|
480
731
|
}
|
|
481
732
|
else if (event.type === "turn-end") {
|
|
733
|
+
for (const [inputId, machine] of agent.compactionMachines) {
|
|
734
|
+
if (machine.recordSnapshot.sessionGeneration !== agent.generation)
|
|
735
|
+
continue;
|
|
736
|
+
machine.close();
|
|
737
|
+
if (["closed", "fallback_committed"].includes(machine.state))
|
|
738
|
+
agent.compactionMachines.delete(inputId);
|
|
739
|
+
}
|
|
482
740
|
const recoveredAuthentication = agent.authFailureActive && agent.turnInProgress
|
|
483
741
|
&& agent.turnHadAuthenticatedOutput && !agent.turnHadFailure;
|
|
484
742
|
agent.turnInProgress = false;
|
|
@@ -511,6 +769,29 @@ export function createRuntimeHost(options) {
|
|
|
511
769
|
if (!record || record.status === "consumed" || record.status === "error")
|
|
512
770
|
return;
|
|
513
771
|
agent.turnHadFailure = true;
|
|
772
|
+
if (agent.adapter.id === "pi" && event.errorCategory === "context_window" && event.willRetry === true && session.compact) {
|
|
773
|
+
beginPiManualCompaction(agent, session, record, true);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (agent.adapter.id === "pi" && event.errorCategory === "context_window" && event.willRetry !== true && session.compact) {
|
|
777
|
+
record.reason = event.message;
|
|
778
|
+
record.retryable = false;
|
|
779
|
+
record.errorCategory = "context_window";
|
|
780
|
+
setRecord(agent, record, "error");
|
|
781
|
+
if (agent.piOverflowCompactionFailed.has(record.input.inputId)) {
|
|
782
|
+
agent.busy = false;
|
|
783
|
+
agent.turnInProgress = false;
|
|
784
|
+
void recoverContextOverflowInternal(agent.config.agentId, `${record.deliveryId}:${record.input.inputId}`, "native Pi overflow compaction failed", `${record.deliveryId}:${record.input.inputId}`)
|
|
785
|
+
.catch((error) => log("Pi native compaction fallback failed", String(error)));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
// Pi emits this terminal error only after agent_settled; release the
|
|
789
|
+
// normal turn reservation before the one authorized recovery action.
|
|
790
|
+
agent.busy = false;
|
|
791
|
+
agent.turnInProgress = false;
|
|
792
|
+
beginPiManualCompaction(agent, session, record);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
514
795
|
if (event.willRetry) {
|
|
515
796
|
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId,
|
|
516
797
|
messageId: record.messageId, status: "deferred", reason: event.message });
|
|
@@ -521,6 +802,13 @@ export function createRuntimeHost(options) {
|
|
|
521
802
|
agent.busy = false;
|
|
522
803
|
record.reason = event.message;
|
|
523
804
|
record.retryable = event.retryable;
|
|
805
|
+
const classifiedCategory = event.errorCategory ?? classifyStrictProviderError(buildStrictProviderErrorInput({
|
|
806
|
+
message: event.message, errorCategory: event.errorCategory, upstream: event.upstream,
|
|
807
|
+
}));
|
|
808
|
+
if (classifiedCategory)
|
|
809
|
+
record.errorCategory = classifiedCategory;
|
|
810
|
+
else
|
|
811
|
+
delete record.errorCategory;
|
|
524
812
|
const finalRecord = setRecord(agent, record, event.retryable ? "pending" : "error");
|
|
525
813
|
if (finalRecord.status !== "consumed")
|
|
526
814
|
emit({ type: "delivery", agentId: agent.config.agentId,
|
|
@@ -607,6 +895,129 @@ export function createRuntimeHost(options) {
|
|
|
607
895
|
});
|
|
608
896
|
return agent.starting;
|
|
609
897
|
};
|
|
898
|
+
recoverContextOverflowInternal = async (agentId, deliveryKey, reason, breakerKey) => {
|
|
899
|
+
const agent = managed.get(agentId);
|
|
900
|
+
if (!agent || agent.stopped)
|
|
901
|
+
throw new RuntimeSessionRecoveryError("unknown_agent", `unknown runtime Agent: ${agentId}`);
|
|
902
|
+
if (agent.adapter.id !== "pi")
|
|
903
|
+
throw new RuntimeSessionRecoveryError("recovery_unavailable", "Pi context fallback is unavailable for this runtime");
|
|
904
|
+
const oldSession = agent.session;
|
|
905
|
+
if (!oldSession || agent.busy || agent.submitting || agent.starting) {
|
|
906
|
+
throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} is not idle for Pi context fallback`);
|
|
907
|
+
}
|
|
908
|
+
const record = [...agent.records.values()].find((candidate) => candidate.deliveryId === deliveryKey || candidate.input.inputId === deliveryKey || `${candidate.deliveryId}:${candidate.input.inputId}` === deliveryKey);
|
|
909
|
+
if (!record)
|
|
910
|
+
throw new RuntimeSessionRecoveryError("recovery_refused", "Pi context fallback delivery identity is unavailable");
|
|
911
|
+
const probeEnv = runtimeEnv(agent.config, `${agent.launchId}:pi-context-fallback`);
|
|
912
|
+
await assertOfficialCliReady(agent.config, probeEnv);
|
|
913
|
+
const readiness = agent.adapter.probe ? await agent.adapter.probe({ agentId: agent.config.agentId,
|
|
914
|
+
workspaceDir: agent.config.workspaceDir, stateDir: agent.config.stateDir, env: probeEnv }) : { runtime: "pi", state: "ready" };
|
|
915
|
+
if (readiness.state !== "ready")
|
|
916
|
+
throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "Pi context fallback readiness failed");
|
|
917
|
+
const standingPrompt = options.promptBuilder.build({
|
|
918
|
+
agentId: agent.config.agentId, name: agent.config.displayName || agent.config.name,
|
|
919
|
+
description: agent.config.description || "", runtime: agent.adapter.id, cli: agentCliPromptCapabilities("larkin"),
|
|
920
|
+
});
|
|
921
|
+
let fresh;
|
|
922
|
+
try {
|
|
923
|
+
fresh = await agent.adapter.createSession({ agentId: agent.config.agentId, model: agent.config.model,
|
|
924
|
+
reasoningEffort: agent.config.effort || null, workspaceDir: agent.config.workspaceDir, stateDir: agent.config.stateDir,
|
|
925
|
+
resumeSessionId: null, standingPrompt, env: probeEnv });
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "Pi fresh fallback session could not be staged");
|
|
929
|
+
}
|
|
930
|
+
if (agent.session !== oldSession || agent.busy || agent.submitting || agent.starting || agent.stopped) {
|
|
931
|
+
await fresh.close("Pi context fallback commit precondition changed").catch(() => { });
|
|
932
|
+
throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} changed during Pi context fallback`);
|
|
933
|
+
}
|
|
934
|
+
let unsubscribeFresh = null;
|
|
935
|
+
try {
|
|
936
|
+
unsubscribeFresh = fresh.subscribe((next) => observe(agent, fresh, next));
|
|
937
|
+
}
|
|
938
|
+
catch {
|
|
939
|
+
await fresh.close("Pi context fallback subscription failed").catch(() => { });
|
|
940
|
+
throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "Pi context fallback subscription failed");
|
|
941
|
+
}
|
|
942
|
+
const oldSessionId = oldSession.sessionId;
|
|
943
|
+
const oldGeneration = agent.generation;
|
|
944
|
+
const oldLaunchId = agent.launchId;
|
|
945
|
+
const oldConfigSessionId = agent.config.sessionId;
|
|
946
|
+
const oldReadiness = agent.readiness;
|
|
947
|
+
const oldDisabledReason = agent.disabledReason;
|
|
948
|
+
const oldRecord = { ...record, input: { ...record.input } };
|
|
949
|
+
let durableRollback = null;
|
|
950
|
+
let rearmedCount = 1;
|
|
951
|
+
let remainingPendingCount = 1;
|
|
952
|
+
const commitSwap = (messageIds, rollback) => {
|
|
953
|
+
if (agent.session !== oldSession || agent.busy || agent.submitting || agent.starting || agent.stopped) {
|
|
954
|
+
throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} changed during Pi context fallback`);
|
|
955
|
+
}
|
|
956
|
+
durableRollback = rollback ?? null;
|
|
957
|
+
if (breakerKey && agent.compactionBreaker)
|
|
958
|
+
agent.compactionBreaker.forceFallbackInTransaction(breakerKey, reason);
|
|
959
|
+
agent.generation += 1;
|
|
960
|
+
agent.launchId = crypto.randomUUID();
|
|
961
|
+
agent.config.sessionId = null;
|
|
962
|
+
agent.session = fresh;
|
|
963
|
+
agent.readiness = readiness;
|
|
964
|
+
agent.disabledReason = null;
|
|
965
|
+
for (const candidate of agent.records.values()) {
|
|
966
|
+
if (!messageIds.includes(candidate.messageId))
|
|
967
|
+
continue;
|
|
968
|
+
candidate.status = "pending";
|
|
969
|
+
candidate.retryable = true;
|
|
970
|
+
candidate.reason = "context-window recovery rearmed the retained Inbox delivery";
|
|
971
|
+
candidate.updatedAt = now();
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
try {
|
|
975
|
+
if (agent.stateStore?.rearmContextOverflow) {
|
|
976
|
+
const rearmed = agent.stateStore.rearmContextOverflow((messageIds, rollback) => commitSwap(messageIds, rollback), {
|
|
977
|
+
messageId: record.messageId, deliveryId: record.deliveryId, inputId: record.input.inputId,
|
|
978
|
+
});
|
|
979
|
+
rearmedCount = rearmed.rearmedCount;
|
|
980
|
+
remainingPendingCount = rearmed.remainingPendingCount;
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
commitSwap([record.messageId]);
|
|
984
|
+
persist(agent);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
catch (error) {
|
|
988
|
+
try {
|
|
989
|
+
const rollback = durableRollback;
|
|
990
|
+
if (rollback)
|
|
991
|
+
rollback();
|
|
992
|
+
}
|
|
993
|
+
catch { /* preserve primary error */ }
|
|
994
|
+
agent.generation = oldGeneration;
|
|
995
|
+
agent.launchId = oldLaunchId;
|
|
996
|
+
agent.config.sessionId = oldConfigSessionId;
|
|
997
|
+
agent.session = oldSession;
|
|
998
|
+
agent.readiness = oldReadiness;
|
|
999
|
+
agent.disabledReason = oldDisabledReason;
|
|
1000
|
+
Object.assign(record, oldRecord);
|
|
1001
|
+
try {
|
|
1002
|
+
unsubscribeFresh?.();
|
|
1003
|
+
}
|
|
1004
|
+
catch { /* preserve primary failure */ }
|
|
1005
|
+
await fresh.close("Pi context fallback rolled back").catch(() => { });
|
|
1006
|
+
throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", error instanceof Error ? error.message : "Pi context fallback commit failed");
|
|
1007
|
+
}
|
|
1008
|
+
if (fresh.sessionId) {
|
|
1009
|
+
agent.config.sessionId = fresh.sessionId;
|
|
1010
|
+
emit({ type: "session", agentId, runtime: agent.adapter.id, sessionId: fresh.sessionId, launchId: agent.launchId,
|
|
1011
|
+
...(fresh.effectiveModel ? { model: fresh.effectiveModel } : {}),
|
|
1012
|
+
...(fresh.effectiveReasoningEffort ? { reasoningEffort: fresh.effectiveReasoningEffort } : {}) });
|
|
1013
|
+
}
|
|
1014
|
+
emit({ type: "agent-status", agentId, status: "active", readiness });
|
|
1015
|
+
await oldSession.close("Pi context fallback committed").catch((error) => log("previous Pi session close after fallback failed", String(error)));
|
|
1016
|
+
void retryPending(agent);
|
|
1017
|
+
return { generationChanged: true, sessionChanged: oldSessionId !== fresh.sessionId, turns: 0,
|
|
1018
|
+
runtimeReady: true, pendingCount: remainingPendingCount, rearmedCount,
|
|
1019
|
+
replayStatus: remainingPendingCount > 0 ? "pending" : "consumed", sessionId: fresh.sessionId };
|
|
1020
|
+
};
|
|
610
1021
|
return {
|
|
611
1022
|
async probe(config) {
|
|
612
1023
|
const adapter = options.adapterFor(config.runtime);
|
|
@@ -781,6 +1192,180 @@ export function createRuntimeHost(options) {
|
|
|
781
1192
|
return { generationChanged: true, sessionChanged: oldSessionId !== fresh.sessionId, turns: 0,
|
|
782
1193
|
runtimeReady: true, pendingCount: 0, sessionId: fresh.sessionId };
|
|
783
1194
|
},
|
|
1195
|
+
async recoverSession(agentId, reason) {
|
|
1196
|
+
if (reason !== "context-overflow")
|
|
1197
|
+
throw new RuntimeSessionRecoveryError("recovery_refused", "unsupported recovery reason");
|
|
1198
|
+
const agent = managed.get(agentId);
|
|
1199
|
+
if (agent && agent.adapter.id !== "pi")
|
|
1200
|
+
throw new RuntimeSessionRecoveryError("recovery_unavailable", "context-overflow recovery is available only for Pi Runtime");
|
|
1201
|
+
if (!agent || agent.stopped)
|
|
1202
|
+
throw new RuntimeSessionRecoveryError("unknown_agent", `unknown runtime Agent: ${agentId}`);
|
|
1203
|
+
if (!agent.stateStore?.rearmContextOverflow) {
|
|
1204
|
+
throw new RuntimeSessionRecoveryError("recovery_unavailable", "canonical Inbox recovery is unavailable");
|
|
1205
|
+
}
|
|
1206
|
+
if (agent.busy || agent.turnInProgress || agent.submitting || agent.starting) {
|
|
1207
|
+
throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} is not idle`);
|
|
1208
|
+
}
|
|
1209
|
+
const oldSession = agent.session;
|
|
1210
|
+
if (!oldSession)
|
|
1211
|
+
throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} Runtime session is unavailable`);
|
|
1212
|
+
const oldSessionId = oldSession.sessionId;
|
|
1213
|
+
if (agent.stateStore.readNdjson) {
|
|
1214
|
+
const pendingRows = agent.stateStore.withInboxTransaction(() => agent.stateStore.readNdjson("inbox"));
|
|
1215
|
+
if (pendingRows.length === 0) {
|
|
1216
|
+
throw new RuntimeSessionRecoveryError("recovery_refused", "context-window recovery has no retained Inbox backlog");
|
|
1217
|
+
}
|
|
1218
|
+
const messageIds = new Set(pendingRows.flatMap((row) => {
|
|
1219
|
+
const messageId = row && typeof row === "object" && !Array.isArray(row)
|
|
1220
|
+
? row.message_id : undefined;
|
|
1221
|
+
return typeof messageId === "string" && messageId ? [messageId] : [];
|
|
1222
|
+
}));
|
|
1223
|
+
const persisted = agent.stateStore.readJson("runtimeDeliveries", { version: 1, records: [] });
|
|
1224
|
+
if (Array.isArray(persisted.records) && persisted.records.some((record) => messageIds.has(record.messageId) && record.status !== "error")) {
|
|
1225
|
+
throw new RuntimeSessionRecoveryError("recovery_refused", "context-window recovery is already staged or completed");
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
const probeEnv = runtimeEnv(agent.config, `${agent.launchId}:context-overflow-recovery`);
|
|
1229
|
+
let readiness;
|
|
1230
|
+
try {
|
|
1231
|
+
await assertOfficialCliReady(agent.config, probeEnv);
|
|
1232
|
+
readiness = agent.adapter.probe ? await agent.adapter.probe({ agentId: agent.config.agentId,
|
|
1233
|
+
workspaceDir: agent.config.workspaceDir, stateDir: agent.config.stateDir,
|
|
1234
|
+
env: { LARKIN_PI_COMMAND: process.env.LARKIN_PI_COMMAND, LARKIN_CODEX_COMMAND: process.env.LARKIN_CODEX_COMMAND,
|
|
1235
|
+
LARKIN_CLAUDE_COMMAND: process.env.LARKIN_CLAUDE_COMMAND, ...probeEnv } })
|
|
1236
|
+
: { runtime: agent.adapter.id, state: "ready" };
|
|
1237
|
+
}
|
|
1238
|
+
catch (error) {
|
|
1239
|
+
if (error instanceof RuntimePrerequisiteError)
|
|
1240
|
+
throw error;
|
|
1241
|
+
throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "Runtime readiness probe failed");
|
|
1242
|
+
}
|
|
1243
|
+
if (readiness.state !== "ready")
|
|
1244
|
+
throw new RuntimePrerequisiteError(readiness);
|
|
1245
|
+
const standingPrompt = options.promptBuilder.build({
|
|
1246
|
+
agentId: agent.config.agentId, name: agent.config.displayName || agent.config.name,
|
|
1247
|
+
description: agent.config.description || "", runtime: agent.adapter.id,
|
|
1248
|
+
cli: agentCliPromptCapabilities("larkin"),
|
|
1249
|
+
});
|
|
1250
|
+
let fresh;
|
|
1251
|
+
try {
|
|
1252
|
+
fresh = await agent.adapter.createSession({
|
|
1253
|
+
agentId: agent.config.agentId, model: agent.config.model, reasoningEffort: agent.config.effort || null,
|
|
1254
|
+
workspaceDir: agent.config.workspaceDir, stateDir: agent.config.stateDir,
|
|
1255
|
+
resumeSessionId: null, standingPrompt, env: probeEnv,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
catch {
|
|
1259
|
+
throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "fresh Runtime session could not be staged");
|
|
1260
|
+
}
|
|
1261
|
+
let unsubscribeFresh = null;
|
|
1262
|
+
try {
|
|
1263
|
+
unsubscribeFresh = fresh.subscribe((event) => observe(agent, fresh, event));
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
await fresh.close("context-window recovery subscription failed").catch(() => { });
|
|
1267
|
+
throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "fresh Runtime session subscription failed");
|
|
1268
|
+
}
|
|
1269
|
+
const oldGeneration = agent.generation;
|
|
1270
|
+
const oldLaunchId = agent.launchId;
|
|
1271
|
+
const oldConfigSessionId = agent.config.sessionId;
|
|
1272
|
+
const oldReadiness = agent.readiness;
|
|
1273
|
+
const oldDisabledReason = agent.disabledReason;
|
|
1274
|
+
const oldRecords = new Map([...agent.records.entries()].map(([id, record]) => [id, { ...record, input: { ...record.input } }]));
|
|
1275
|
+
const cleanupFresh = async (reason) => {
|
|
1276
|
+
const unsubscribe = unsubscribeFresh;
|
|
1277
|
+
unsubscribeFresh = null;
|
|
1278
|
+
try {
|
|
1279
|
+
unsubscribe?.();
|
|
1280
|
+
}
|
|
1281
|
+
catch { /* close still owns staged-session cleanup */ }
|
|
1282
|
+
await fresh.close(reason).catch(() => { });
|
|
1283
|
+
};
|
|
1284
|
+
const restoreRuntime = () => {
|
|
1285
|
+
agent.session = oldSession;
|
|
1286
|
+
agent.generation = oldGeneration;
|
|
1287
|
+
agent.launchId = oldLaunchId;
|
|
1288
|
+
agent.config.sessionId = oldConfigSessionId;
|
|
1289
|
+
agent.readiness = oldReadiness;
|
|
1290
|
+
agent.disabledReason = oldDisabledReason;
|
|
1291
|
+
agent.records = new Map(oldRecords);
|
|
1292
|
+
agent.byMessage = new Map([...oldRecords.values()].map((record) => [record.messageId, record.deliveryId]));
|
|
1293
|
+
};
|
|
1294
|
+
let recoveryProjectionEmitted = false;
|
|
1295
|
+
const compensateProjection = () => {
|
|
1296
|
+
if (!recoveryProjectionEmitted)
|
|
1297
|
+
return;
|
|
1298
|
+
try {
|
|
1299
|
+
emit({ type: "session", agentId, runtime: agent.adapter.id, sessionId: oldSessionId, launchId: oldLaunchId });
|
|
1300
|
+
}
|
|
1301
|
+
catch { /* preserve the original listener failure */ }
|
|
1302
|
+
try {
|
|
1303
|
+
emit({ type: "agent-status", agentId, status: "active", ...(oldReadiness ? { readiness: oldReadiness } : {}) });
|
|
1304
|
+
}
|
|
1305
|
+
catch { /* preserve the original listener failure */ }
|
|
1306
|
+
};
|
|
1307
|
+
let durableRollback = null;
|
|
1308
|
+
try {
|
|
1309
|
+
const rearmed = agent.stateStore.rearmContextOverflow((messageIds, rollback) => {
|
|
1310
|
+
// This callback runs under the canonical Inbox lock. All potentially
|
|
1311
|
+
// racing checks above are synchronous from this process, while the
|
|
1312
|
+
// state-store method rechecks every row and delivery on disk.
|
|
1313
|
+
if (agent.session !== oldSession || agent.busy || agent.turnInProgress || agent.submitting || agent.starting || agent.stopped) {
|
|
1314
|
+
throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} changed during recovery`);
|
|
1315
|
+
}
|
|
1316
|
+
durableRollback = rollback;
|
|
1317
|
+
agent.generation += 1;
|
|
1318
|
+
agent.launchId = crypto.randomUUID();
|
|
1319
|
+
agent.config.sessionId = null;
|
|
1320
|
+
agent.session = fresh;
|
|
1321
|
+
agent.readiness = readiness;
|
|
1322
|
+
agent.disabledReason = null;
|
|
1323
|
+
for (const record of agent.records.values()) {
|
|
1324
|
+
if (!messageIds.includes(record.messageId))
|
|
1325
|
+
continue;
|
|
1326
|
+
record.status = "pending";
|
|
1327
|
+
record.retryable = true;
|
|
1328
|
+
record.reason = "context-window recovery rearmed the retained Inbox delivery";
|
|
1329
|
+
record.updatedAt = now();
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
recoveryProjectionEmitted = true;
|
|
1333
|
+
if (fresh.sessionId) {
|
|
1334
|
+
agent.config.sessionId = fresh.sessionId;
|
|
1335
|
+
emit({ type: "session", agentId, runtime: agent.adapter.id, sessionId: fresh.sessionId, launchId: agent.launchId,
|
|
1336
|
+
...(fresh.effectiveModel ? { model: fresh.effectiveModel } : {}),
|
|
1337
|
+
...(fresh.effectiveReasoningEffort ? { reasoningEffort: fresh.effectiveReasoningEffort } : {}) });
|
|
1338
|
+
}
|
|
1339
|
+
emit({ type: "agent-status", agentId, status: "active", readiness });
|
|
1340
|
+
durableRollback = null;
|
|
1341
|
+
await oldSession.close("context-window recovery committed")
|
|
1342
|
+
.catch((error) => log("previous runtime close after context-window recovery failed", String(error)));
|
|
1343
|
+
void retryPending(agent);
|
|
1344
|
+
return { generationChanged: true, sessionChanged: oldSessionId !== fresh.sessionId, turns: 0,
|
|
1345
|
+
runtimeReady: true, pendingCount: rearmed.remainingPendingCount, rearmedCount: rearmed.rearmedCount,
|
|
1346
|
+
replayStatus: rearmed.remainingPendingCount > 0 ? "pending" : "consumed", sessionId: fresh.sessionId };
|
|
1347
|
+
}
|
|
1348
|
+
catch (error) {
|
|
1349
|
+
try {
|
|
1350
|
+
const rollback = durableRollback;
|
|
1351
|
+
if (rollback)
|
|
1352
|
+
rollback();
|
|
1353
|
+
}
|
|
1354
|
+
catch { /* preserve the original failure */ }
|
|
1355
|
+
restoreRuntime();
|
|
1356
|
+
compensateProjection();
|
|
1357
|
+
await cleanupFresh("context-window recovery not committed");
|
|
1358
|
+
if (error instanceof RuntimeSessionRecoveryError)
|
|
1359
|
+
throw error;
|
|
1360
|
+
if (typeof error.code === "string") {
|
|
1361
|
+
throw new RuntimeSessionRecoveryError("recovery_refused", error instanceof Error ? error.message : "context-window recovery was refused");
|
|
1362
|
+
}
|
|
1363
|
+
throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "context-window recovery was not committed");
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
async recoverContextOverflow(agentId, deliveryKey, reason) {
|
|
1367
|
+
return recoverContextOverflowInternal(agentId, deliveryKey, reason);
|
|
1368
|
+
},
|
|
784
1369
|
async start(configs) {
|
|
785
1370
|
let activeCount = 0;
|
|
786
1371
|
let recoveringCount = 0;
|
|
@@ -799,7 +1384,14 @@ export function createRuntimeHost(options) {
|
|
|
799
1384
|
const deliveryFile = stateStore.readJson("runtimeDeliveries", { version: 1, records: [] });
|
|
800
1385
|
if (!stateStore.readNdjson)
|
|
801
1386
|
return deliveryFile;
|
|
802
|
-
|
|
1387
|
+
let inboxRows;
|
|
1388
|
+
try {
|
|
1389
|
+
inboxRows = stateStore.readNdjson("inbox");
|
|
1390
|
+
}
|
|
1391
|
+
catch {
|
|
1392
|
+
return deliveryFile;
|
|
1393
|
+
}
|
|
1394
|
+
const inboxIds = new Set(inboxRows.flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
|
|
803
1395
|
let changed = false;
|
|
804
1396
|
const records = deliveryFile.records.map((record) => {
|
|
805
1397
|
const synthetic = /^(?:redeliver_|rem_|interaction_)/.test(record.messageId);
|
|
@@ -820,15 +1412,44 @@ export function createRuntimeHost(options) {
|
|
|
820
1412
|
byMessage: new Map(persisted.records.map((record) => [record.messageId, record.deliveryId])),
|
|
821
1413
|
generation: 0, poller: null, retryTimer: null, recreateAttempts: 0,
|
|
822
1414
|
stabilityTimer: null, recreateReason: null, stopped: false, disabledReason: null,
|
|
823
|
-
configurationRecovery: null, stateStore,
|
|
1415
|
+
configurationRecovery: null, stateStore,
|
|
1416
|
+
compactionBreaker: config.runtime === "pi"
|
|
1417
|
+
? new PiCompactionBreaker(config.stateDir ?? path.join(config.workspaceDir, ".larkin", "agents", config.agentId), {
|
|
1418
|
+
withLock: stateStore ? (operation) => stateStore.withInboxTransaction(operation) : undefined,
|
|
1419
|
+
}) : undefined,
|
|
1420
|
+
compactionMachines: new Map(), piOverflowCompactionFailed: new Set(), readiness: null,
|
|
824
1421
|
turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false };
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1422
|
+
const startupConsumed = [];
|
|
1423
|
+
const startupQuarantined = [];
|
|
1424
|
+
for (const record of agent.records.values()) {
|
|
1425
|
+
const existingQuarantine = replayFailureCodeOf(record);
|
|
1426
|
+
if (existingQuarantine) {
|
|
1427
|
+
startupQuarantined.push({ record, code: existingQuarantine });
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (!isActiveDelivery(record.status))
|
|
1431
|
+
continue;
|
|
1432
|
+
record.status = "pending";
|
|
1433
|
+
const prepared = prepareRecordInput(agent, record, false);
|
|
1434
|
+
if (prepared.status === "consumed") {
|
|
1435
|
+
record.status = "consumed";
|
|
1436
|
+
record.updatedAt = now();
|
|
1437
|
+
startupConsumed.push(record);
|
|
1438
|
+
}
|
|
1439
|
+
else if (prepared.status === "error") {
|
|
1440
|
+
record.status = "error";
|
|
1441
|
+
record.updatedAt = now();
|
|
1442
|
+
scrubQuarantinedInput(record, prepared.reason);
|
|
1443
|
+
record.reason = prepared.reason;
|
|
1444
|
+
record.retryable = false;
|
|
1445
|
+
startupQuarantined.push({ record, code: prepared.code });
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
829
1448
|
managed.set(config.agentId, agent);
|
|
1449
|
+
emitConsumed(agent, [...startupConsumed, ...persist(agent)]);
|
|
830
1450
|
try {
|
|
831
1451
|
await ensureSession(agent);
|
|
1452
|
+
await recoverStalePiCompaction(agent);
|
|
832
1453
|
activeCount += 1;
|
|
833
1454
|
await retryPending(agent);
|
|
834
1455
|
}
|
|
@@ -844,6 +1465,13 @@ export function createRuntimeHost(options) {
|
|
|
844
1465
|
emit({ type: "agent-status", agentId: config.agentId, status: "error", error: reason,
|
|
845
1466
|
...(error instanceof RuntimePrerequisiteError ? { readiness: error.readiness } : {}) });
|
|
846
1467
|
}
|
|
1468
|
+
const visibleQuarantines = startupQuarantined.filter(({ record }) => agent.records.get(record.deliveryId)?.status === "error");
|
|
1469
|
+
for (const { record, code } of visibleQuarantines)
|
|
1470
|
+
emit({ type: "delivery", agentId: config.agentId,
|
|
1471
|
+
deliveryId: record.deliveryId, messageId: record.messageId, status: "error", reason: replayFailureReason(code) });
|
|
1472
|
+
if (visibleQuarantines.length)
|
|
1473
|
+
emit({ type: "agent-status", agentId: config.agentId, status: "error",
|
|
1474
|
+
error: `${visibleQuarantines.length} Runtime delivery record(s) quarantined; canonical Inbox recovery is required` });
|
|
847
1475
|
agent.poller = setInterval(() => reconcileExternalConsumption(agent), 250);
|
|
848
1476
|
agent.poller.unref?.();
|
|
849
1477
|
});
|
|
@@ -859,12 +1487,33 @@ export function createRuntimeHost(options) {
|
|
|
859
1487
|
telemetry?.delivery(agentId, messageId, "error");
|
|
860
1488
|
throw new Error(`unknown runtime Agent: ${agentId}`);
|
|
861
1489
|
}
|
|
1490
|
+
let target;
|
|
1491
|
+
try {
|
|
1492
|
+
target = targetKeyOfInboxEnvelope(envelope);
|
|
1493
|
+
}
|
|
1494
|
+
catch (error) {
|
|
1495
|
+
const reason = `Inbox target derivation failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1496
|
+
const deliveryId = `invalid-target-${crypto.createHash("sha256").update(`${agentId}\0${messageId}`).digest("hex").slice(0, 24)}`;
|
|
1497
|
+
telemetry?.delivery(agentId, messageId, "error");
|
|
1498
|
+
emit({ type: "delivery", agentId, deliveryId, messageId, status: "error", reason });
|
|
1499
|
+
return { status: "error", deliveryId, reason, retryable: false };
|
|
1500
|
+
}
|
|
862
1501
|
reconcileExternalConsumption(agent);
|
|
863
1502
|
const existingId = agent.byMessage.get(messageId);
|
|
864
1503
|
if (existingId) {
|
|
865
1504
|
const existing = agent.records.get(existingId);
|
|
866
1505
|
if (existing?.status === "error") {
|
|
867
|
-
existing.
|
|
1506
|
+
if (existing.target && existing.target !== target)
|
|
1507
|
+
return quarantineRecord(agent, existing, "delivery_target_conflict");
|
|
1508
|
+
existing.target = target;
|
|
1509
|
+
if (typeof envelope.wake_reason === "string" && envelope.wake_reason)
|
|
1510
|
+
existing.wakeReason = envelope.wake_reason;
|
|
1511
|
+
else
|
|
1512
|
+
delete existing.wakeReason;
|
|
1513
|
+
const priorAttempt = existing.input && Number.isSafeInteger(existing.input.attempt) ? existing.input.attempt : 0;
|
|
1514
|
+
existing.input = existing.input && typeof existing.input === "object"
|
|
1515
|
+
? { ...existing.input, attempt: priorAttempt + 1 }
|
|
1516
|
+
: { inputId: existing.deliveryId, deliveryId: existing.deliveryId, kind: "wake", text: "", attempt: priorAttempt + 1 };
|
|
868
1517
|
delete existing.reason;
|
|
869
1518
|
delete existing.retryable;
|
|
870
1519
|
setRecord(agent, existing, "pending");
|
|
@@ -885,11 +1534,12 @@ export function createRuntimeHost(options) {
|
|
|
885
1534
|
}
|
|
886
1535
|
const deliveryId = crypto.randomUUID();
|
|
887
1536
|
const busy = agent.busy || agent.submitting;
|
|
1537
|
+
const wakeReason = typeof envelope.wake_reason === "string" ? envelope.wake_reason : undefined;
|
|
888
1538
|
const input = { inputId: deliveryId, deliveryId, kind: busy ? "inbox_update" : "wake",
|
|
889
|
-
text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId,
|
|
890
|
-
...(
|
|
891
|
-
|
|
892
|
-
|
|
1539
|
+
text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId, target,
|
|
1540
|
+
...(wakeReason ? { wakeReason } : {}) }), attempt: 0 };
|
|
1541
|
+
const record = { deliveryId, messageId, status: "pending", input, target,
|
|
1542
|
+
...(wakeReason ? { wakeReason } : {}), updatedAt: now() };
|
|
893
1543
|
agent.records.set(deliveryId, record);
|
|
894
1544
|
agent.byMessage.set(messageId, deliveryId);
|
|
895
1545
|
persist(agent);
|