larkin 0.4.1 → 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 +194 -1
- package/dist/agent/agent-state-store.mjs +192 -0
- 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/host-shell.cjs +72 -1
- package/dist/feishu/host-shell.mjs +72 -1
- 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 +490 -2
- package/dist/runtime/runtime-host.mjs +488 -1
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import { TargetRootLayout } from "../platform/root-layout.mjs";
|
|
|
5
5
|
import { acquireProcessLock, inspectProcess } from "../platform/process-state.mjs";
|
|
6
6
|
import { isWindows } from "../platform/secure-metadata.mjs";
|
|
7
7
|
import { isCanonicalInboxTarget, targetKeyOfInboxEnvelope } from "./inbox-projection.mjs";
|
|
8
|
+
import { buildStrictProviderErrorInput, classifyStrictProviderError } from "../runtime/provider-error-classifier.mjs";
|
|
8
9
|
const JSON_KEYS = new Set([
|
|
9
10
|
"agentState", "status", "map", "replyctx", "botIdentity", "senderProfiles", "readReceipts", "pendingReact", "runtimeDeliveries", "inboxState", "freshnessState", "documentComments", "reminders", "interactions",
|
|
10
11
|
]);
|
|
@@ -160,6 +161,14 @@ function validInboxLockOwner(value) {
|
|
|
160
161
|
&& typeof owner.processStartToken === "string" && !!owner.processStartToken
|
|
161
162
|
&& typeof owner.nonce === "string" && /^[0-9a-f-]{36}$/i.test(owner.nonce);
|
|
162
163
|
}
|
|
164
|
+
export class ContextOverflowRecoveryError extends Error {
|
|
165
|
+
code;
|
|
166
|
+
constructor(code, message) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.code = code;
|
|
169
|
+
this.name = "ContextOverflowRecoveryError";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
163
172
|
export class AgentStateStore {
|
|
164
173
|
paths;
|
|
165
174
|
boundary;
|
|
@@ -691,6 +700,189 @@ export class AgentStateStore {
|
|
|
691
700
|
return { status: "missing", code: "canonical_inbox_row_missing" };
|
|
692
701
|
});
|
|
693
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Re-arm only the durable deliveries proven to be the context-window incident.
|
|
705
|
+
* The Inbox bytes are intentionally untouched. An optional synchronous commit
|
|
706
|
+
* hook lets RuntimeHost swap its in-memory generation while this same lock is
|
|
707
|
+
* still held. If the hook fails, the ledger is restored before the lock is
|
|
708
|
+
* released. The callback also receives a guarded rollback for post-callback
|
|
709
|
+
* commit failures before retry scheduling begins.
|
|
710
|
+
*/
|
|
711
|
+
rearmContextOverflow(onCommit, expected) {
|
|
712
|
+
let lockActive = true;
|
|
713
|
+
try {
|
|
714
|
+
return this.withInboxTransaction(() => {
|
|
715
|
+
let rows;
|
|
716
|
+
try {
|
|
717
|
+
rows = this.readNdjson("inbox");
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox is malformed");
|
|
721
|
+
}
|
|
722
|
+
if (rows.length === 0)
|
|
723
|
+
throw new ContextOverflowRecoveryError("inbox_empty", "canonical Inbox backlog is empty");
|
|
724
|
+
const messageIds = new Set();
|
|
725
|
+
for (const row of rows) {
|
|
726
|
+
try {
|
|
727
|
+
targetKeyOfInboxEnvelope(row);
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox contains a malformed row");
|
|
731
|
+
}
|
|
732
|
+
if (typeof row.message_id !== "string" || !row.message_id) {
|
|
733
|
+
throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox row has no message identity");
|
|
734
|
+
}
|
|
735
|
+
if (messageIds.has(row.message_id))
|
|
736
|
+
throw new ContextOverflowRecoveryError("duplicate_message_id", "canonical Inbox contains duplicate message identities");
|
|
737
|
+
messageIds.add(row.message_id);
|
|
738
|
+
}
|
|
739
|
+
const ledger = this.readJson("runtimeDeliveries", { version: 1, records: [] });
|
|
740
|
+
if (!Array.isArray(ledger.records))
|
|
741
|
+
throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "Runtime delivery ledger is malformed");
|
|
742
|
+
const ledgerRecords = ledger.records;
|
|
743
|
+
const matches = new Map();
|
|
744
|
+
const deliveryIds = new Set();
|
|
745
|
+
const inputIds = new Set();
|
|
746
|
+
for (const candidate of ledger.records) {
|
|
747
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
748
|
+
continue;
|
|
749
|
+
const record = candidate;
|
|
750
|
+
if (typeof record.messageId !== "string" || !messageIds.has(record.messageId))
|
|
751
|
+
continue;
|
|
752
|
+
if (typeof record.deliveryId !== "string" || !record.deliveryId)
|
|
753
|
+
throw new ContextOverflowRecoveryError("delivery_missing", "a matching Runtime delivery record has no delivery identity");
|
|
754
|
+
if (!record.input || typeof record.input !== "object" || Array.isArray(record.input)) {
|
|
755
|
+
throw new ContextOverflowRecoveryError("delivery_identity_invalid", "a matching Runtime delivery record has no Runtime input identity");
|
|
756
|
+
}
|
|
757
|
+
const input = record.input;
|
|
758
|
+
if (typeof input.inputId !== "string" || !input.inputId.trim()
|
|
759
|
+
|| typeof input.deliveryId !== "string" || input.deliveryId !== record.deliveryId) {
|
|
760
|
+
throw new ContextOverflowRecoveryError("delivery_identity_invalid", "a matching Runtime delivery record has malformed input identity");
|
|
761
|
+
}
|
|
762
|
+
if (matches.has(record.messageId))
|
|
763
|
+
throw new ContextOverflowRecoveryError("delivery_duplicate", "a canonical Inbox row maps to multiple delivery records");
|
|
764
|
+
if (deliveryIds.has(record.deliveryId))
|
|
765
|
+
throw new ContextOverflowRecoveryError("delivery_duplicate", "matching Runtime delivery records share a delivery identity");
|
|
766
|
+
if (inputIds.has(input.inputId))
|
|
767
|
+
throw new ContextOverflowRecoveryError("duplicate_input_id", "matching Runtime delivery records share an input identity");
|
|
768
|
+
deliveryIds.add(record.deliveryId);
|
|
769
|
+
inputIds.add(input.inputId);
|
|
770
|
+
matches.set(record.messageId, record);
|
|
771
|
+
}
|
|
772
|
+
if (expected?.messageId && !messageIds.has(expected.messageId)) {
|
|
773
|
+
throw new ContextOverflowRecoveryError("delivery_missing", "the expected context-overflow message is not in canonical Inbox");
|
|
774
|
+
}
|
|
775
|
+
if (expected?.messageId) {
|
|
776
|
+
const expectedRecord = matches.get(expected.messageId);
|
|
777
|
+
if (!expectedRecord || (expected.deliveryId && expectedRecord.deliveryId !== expected.deliveryId)
|
|
778
|
+
|| (expected.inputId && expectedRecord.input.inputId !== expected.inputId)) {
|
|
779
|
+
throw new ContextOverflowRecoveryError("delivery_identity_invalid", "the expected context-overflow delivery identity changed");
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
for (const messageId of messageIds) {
|
|
783
|
+
const record = matches.get(messageId);
|
|
784
|
+
if (!record)
|
|
785
|
+
throw new ContextOverflowRecoveryError("delivery_missing", "a canonical Inbox row has no Runtime delivery record");
|
|
786
|
+
if (record.status !== "error" || record.retryable === true) {
|
|
787
|
+
throw new ContextOverflowRecoveryError("delivery_not_terminal", "a canonical Inbox row is not backed by a non-retryable terminal Runtime error");
|
|
788
|
+
}
|
|
789
|
+
const reasonCategory = classifyStrictProviderError(buildStrictProviderErrorInput({
|
|
790
|
+
reason: record.reason, errorCategory: record.errorCategory,
|
|
791
|
+
}));
|
|
792
|
+
if (record.retryable !== false || reasonCategory !== "context_window"
|
|
793
|
+
|| (record.errorCategory !== undefined && record.errorCategory !== "context_window")) {
|
|
794
|
+
throw new ContextOverflowRecoveryError("delivery_not_context_window", "a Runtime delivery error is not classified as context_window");
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
for (const candidate of ledgerRecords) {
|
|
798
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
799
|
+
continue;
|
|
800
|
+
const record = candidate;
|
|
801
|
+
if (typeof record.deliveryId === "string" && deliveryIds.has(record.deliveryId)
|
|
802
|
+
&& (typeof record.messageId !== "string" || !messageIds.has(record.messageId))) {
|
|
803
|
+
throw new ContextOverflowRecoveryError("delivery_duplicate", "a matching delivery identity is also used by an unrelated Runtime delivery record");
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const updatedAt = new Date().toISOString();
|
|
807
|
+
const records = ledgerRecords.map((candidate) => {
|
|
808
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
809
|
+
return candidate;
|
|
810
|
+
const record = candidate;
|
|
811
|
+
if (typeof record.messageId !== "string" || !messageIds.has(record.messageId))
|
|
812
|
+
return candidate;
|
|
813
|
+
return { ...record, status: "pending", retryable: true, updatedAt,
|
|
814
|
+
reason: "context-window recovery rearmed the retained Inbox delivery" };
|
|
815
|
+
});
|
|
816
|
+
const committedLedger = { ...ledger, records };
|
|
817
|
+
let restored = false;
|
|
818
|
+
const restoreUnlocked = () => {
|
|
819
|
+
if (restored)
|
|
820
|
+
return;
|
|
821
|
+
const current = this.readJson("runtimeDeliveries", { version: 1, records: [] });
|
|
822
|
+
if (!Array.isArray(current.records))
|
|
823
|
+
throw new Error("Runtime delivery rollback found a malformed ledger");
|
|
824
|
+
const originalMatching = ledgerRecords.filter((candidate) => {
|
|
825
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
826
|
+
return false;
|
|
827
|
+
const record = candidate;
|
|
828
|
+
return typeof record.messageId === "string" && messageIds.has(record.messageId);
|
|
829
|
+
});
|
|
830
|
+
const originalDeliveryIds = new Set(originalMatching.flatMap((candidate) => {
|
|
831
|
+
const deliveryId = candidate.deliveryId;
|
|
832
|
+
return typeof deliveryId === "string" ? [deliveryId] : [];
|
|
833
|
+
}));
|
|
834
|
+
const currentMatching = current.records.filter((candidate) => {
|
|
835
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
836
|
+
return false;
|
|
837
|
+
const record = candidate;
|
|
838
|
+
return (typeof record.deliveryId === "string" && originalDeliveryIds.has(record.deliveryId))
|
|
839
|
+
|| (typeof record.messageId === "string" && messageIds.has(record.messageId));
|
|
840
|
+
});
|
|
841
|
+
const matchingRestored = currentMatching.length === originalMatching.length
|
|
842
|
+
&& currentMatching.every((candidate, index) => JSON.stringify(candidate) === JSON.stringify(originalMatching[index]));
|
|
843
|
+
if (!matchingRestored) {
|
|
844
|
+
const unrelated = current.records.filter((candidate) => {
|
|
845
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
846
|
+
return true;
|
|
847
|
+
const record = candidate;
|
|
848
|
+
return !((typeof record.deliveryId === "string" && originalDeliveryIds.has(record.deliveryId))
|
|
849
|
+
|| (typeof record.messageId === "string" && messageIds.has(record.messageId)));
|
|
850
|
+
});
|
|
851
|
+
const restoredRecords = [...unrelated];
|
|
852
|
+
for (const candidate of originalMatching) {
|
|
853
|
+
const originalIndex = ledgerRecords.indexOf(candidate);
|
|
854
|
+
restoredRecords.splice(Math.min(originalIndex, restoredRecords.length), 0, candidate);
|
|
855
|
+
}
|
|
856
|
+
this.writeJson("runtimeDeliveries", { ...current, records: restoredRecords });
|
|
857
|
+
}
|
|
858
|
+
restored = true;
|
|
859
|
+
};
|
|
860
|
+
const rollback = () => {
|
|
861
|
+
if (lockActive)
|
|
862
|
+
restoreUnlocked();
|
|
863
|
+
else
|
|
864
|
+
this.withInboxTransaction(restoreUnlocked);
|
|
865
|
+
};
|
|
866
|
+
try {
|
|
867
|
+
this.writeJson("runtimeDeliveries", committedLedger);
|
|
868
|
+
onCommit?.([...messageIds], rollback);
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
try {
|
|
872
|
+
rollback();
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
throw new Error("context-window recovery ledger rollback failed");
|
|
876
|
+
}
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
return { rearmedCount: messageIds.size, remainingPendingCount: rows.length };
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
finally {
|
|
883
|
+
lockActive = false;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
694
886
|
/**
|
|
695
887
|
* Recoverably bridge an outbox wake into the canonical Inbox. A durable
|
|
696
888
|
* Runtime ledger is authoritative after the Inbox has been drained, so a
|
package/dist/app/cli.mjs
CHANGED
|
@@ -77,7 +77,8 @@ Examples:
|
|
|
77
77
|
|
|
78
78
|
Credentials, internal paths, serverId, activeAgent, and raw config are never exposed here.`,
|
|
79
79
|
session: `Usage: larkin session reset --agent <App ID> --json [--wait-ready <seconds>]
|
|
80
|
-
|
|
80
|
+
larkin session recover --agent <App ID> --reason context-overflow --json [--wait-ready <seconds>]
|
|
81
|
+
Reset replaces one idle, zero-backlog Runtime session. Recover is an explicit operator-only context-window recovery that preserves and replays canonical Inbox deliveries.`,
|
|
81
82
|
"pi-auth": `Usage: larkin pi-auth status [--agent <App ID>] [--json]
|
|
82
83
|
larkin pi-auth logout <provider> [--agent <App ID>]
|
|
83
84
|
Show non-sensitive official Pi credential metadata or remove one target provider credential.`,
|
|
@@ -124,6 +125,7 @@ Usage: larkin <command>
|
|
|
124
125
|
chats List known chats; use free/strict <oc_id> to configure mention requirements
|
|
125
126
|
config Inspect effective config/source, edit mention inheritance, or explicitly apply runtime changes
|
|
126
127
|
session reset Replace one idle, zero-backlog Agent Runtime session for a fresh scenario
|
|
128
|
+
session recover Explicitly recover a context-overflowed session and replay retained Inbox deliveries
|
|
127
129
|
pi-auth Show non-sensitive built-in Pi auth status or logout one provider
|
|
128
130
|
comment reply Reply to the exact cloud-document comment bound by a polled Inbox message
|
|
129
131
|
telemetry Inspect, export, import, or flush the durable OpenTelemetry trace queue
|
|
@@ -15,6 +15,48 @@ const AGENT_ID = /^cli_[A-Za-z0-9]+$/;
|
|
|
15
15
|
const OPERATION_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
|
16
16
|
const AUTHORIZATION = /^[A-Za-z0-9_-]{43,128}$/;
|
|
17
17
|
const UNIX_SOCKET_PATH_MAX_BYTES = process.platform === "darwin" ? 103 : 107;
|
|
18
|
+
function recoveryErrorText(code) {
|
|
19
|
+
switch (code) {
|
|
20
|
+
case "unknown_agent": return "unknown Agent";
|
|
21
|
+
case "agent_busy": return "Agent is not idle";
|
|
22
|
+
case "channel_unavailable": return "Runtime channel is unavailable";
|
|
23
|
+
case "channel_reconnecting": return "Runtime channel is reconnecting";
|
|
24
|
+
case "runtime_unavailable": return "Runtime readiness is unavailable";
|
|
25
|
+
case "recovery_unavailable": return "context-overflow recovery is unavailable";
|
|
26
|
+
case "recovery_staged_not_committed": return "context-overflow recovery was staged but not committed";
|
|
27
|
+
case "state_persistence_failed": return "context-overflow recovery committed but state persistence failed";
|
|
28
|
+
case "recovery_timeout": return "context-overflow recovery committed but readiness did not converge";
|
|
29
|
+
case "recovery_refused": return "context-overflow recovery was refused";
|
|
30
|
+
default: return "context-overflow recovery failed";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function sanitizeRecoveryReadiness(value) {
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
35
|
+
return undefined;
|
|
36
|
+
const candidate = value;
|
|
37
|
+
if (!["codex", "claude", "pi"].includes(String(candidate.runtime))
|
|
38
|
+
|| !["missing", "unauthenticated", "unavailable", "incompatible", "ready"].includes(String(candidate.state)))
|
|
39
|
+
return undefined;
|
|
40
|
+
const runtime = candidate.runtime;
|
|
41
|
+
const state = candidate.state;
|
|
42
|
+
return { runtime, state,
|
|
43
|
+
...(state === "ready" ? {} : { reason: `Runtime readiness is ${state}.`, nextAction: "Inspect Runtime/provider configuration, then retry." }) };
|
|
44
|
+
}
|
|
45
|
+
function sanitizeSessionRecoveryResponse(response, agentId) {
|
|
46
|
+
const code = typeof response.code === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(response.code) ? response.code : undefined;
|
|
47
|
+
return {
|
|
48
|
+
ok: response.ok === true, agentId, ...(code ? { code } : {}),
|
|
49
|
+
...(code || response.error ? { error: recoveryErrorText(code) } : {}),
|
|
50
|
+
recoveryCommitted: response.recoveryCommitted === true, generationChanged: response.generationChanged === true,
|
|
51
|
+
sessionChanged: response.sessionChanged === true, turns: 0, runtimeReady: response.runtimeReady === true,
|
|
52
|
+
channelConnected: response.channelConnected === true, reconnecting: response.reconnecting === true,
|
|
53
|
+
pendingCount: Math.max(0, Number(response.pendingCount) || 0), rearmedCount: Math.max(0, Number(response.rearmedCount) || 0),
|
|
54
|
+
replayStatus: ["scheduled", "pending", "consumed", "not_started"].includes(response.replayStatus) ? response.replayStatus : "not_started",
|
|
55
|
+
remainingPendingCount: Math.max(0, Number(response.remainingPendingCount) || 0),
|
|
56
|
+
readyForFreshScenario: response.readyForFreshScenario === true, inboundObserved: false,
|
|
57
|
+
...(sanitizeRecoveryReadiness(response.readiness) ? { readiness: sanitizeRecoveryReadiness(response.readiness) } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
18
60
|
function socketPathsFit(root) {
|
|
19
61
|
if (isWindows)
|
|
20
62
|
return true; // Windows named-pipe socket paths use a much larger limit
|
|
@@ -92,7 +134,7 @@ function parseRequest(line) {
|
|
|
92
134
|
throw new Error("invalid agent control request");
|
|
93
135
|
}
|
|
94
136
|
if (value.operation === "session-reset") {
|
|
95
|
-
if (value.waitReadyMs !== undefined && (!Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
|
|
137
|
+
if (value.waitReadyMs !== undefined && (typeof value.waitReadyMs !== "number" || !Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
|
|
96
138
|
throw new Error("invalid session reset waitReadyMs");
|
|
97
139
|
}
|
|
98
140
|
if (Object.keys(value).some((key) => !["agentId", "authorization", "operation", "waitReadyMs"].includes(key))) {
|
|
@@ -100,6 +142,17 @@ function parseRequest(line) {
|
|
|
100
142
|
}
|
|
101
143
|
return value;
|
|
102
144
|
}
|
|
145
|
+
if (value.operation === "session-recover") {
|
|
146
|
+
if (value.reason !== "context-overflow")
|
|
147
|
+
throw new Error("session recovery requires reason=context-overflow");
|
|
148
|
+
if (value.waitReadyMs !== undefined && (typeof value.waitReadyMs !== "number" || !Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
|
|
149
|
+
throw new Error("invalid session recovery waitReadyMs");
|
|
150
|
+
}
|
|
151
|
+
if (Object.keys(value).some((key) => !["agentId", "authorization", "operation", "reason", "waitReadyMs"].includes(key))) {
|
|
152
|
+
throw new Error("session recovery control request 包含未知字段");
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
103
156
|
if (value.operation !== undefined || !OPERATION_ID.test(String(value.operationId || ""))) {
|
|
104
157
|
throw new Error("invalid agent upsert request");
|
|
105
158
|
}
|
|
@@ -498,13 +551,14 @@ export function createSupervisorControlServer({ larkinHome, authorityToken, ensu
|
|
|
498
551
|
},
|
|
499
552
|
};
|
|
500
553
|
}
|
|
501
|
-
export function createAgentControlServer({ larkinHome, authorityToken, upsert, resetSession, maxRememberedOperations = 256, }) {
|
|
554
|
+
export function createAgentControlServer({ larkinHome, authorityToken, upsert, resetSession, recoverSession, maxRememberedOperations = 256, }) {
|
|
502
555
|
let socket = "";
|
|
503
556
|
let socketRoot = "";
|
|
504
557
|
let socketIdentity = null;
|
|
505
558
|
const completed = new Map();
|
|
506
559
|
const inFlight = new Map();
|
|
507
560
|
const resetInFlight = new Map();
|
|
561
|
+
const recoveryInFlight = new Map();
|
|
508
562
|
const agentQueues = new Map();
|
|
509
563
|
const upsertConflict = (operationId, agentId) => ({
|
|
510
564
|
ok: false, operationId, agentId, code: "operation_conflict", error: "operationId 已绑定其他 Agent 或操作",
|
|
@@ -564,6 +618,52 @@ export function createAgentControlServer({ larkinHome, authorityToken, upsert, r
|
|
|
564
618
|
return;
|
|
565
619
|
}
|
|
566
620
|
if ("operation" in request) {
|
|
621
|
+
if (request.operation === "session-recover") {
|
|
622
|
+
const recoveryRequest = request;
|
|
623
|
+
let operation = recoveryInFlight.get(recoveryRequest.agentId);
|
|
624
|
+
if (!operation) {
|
|
625
|
+
const executeRecovery = async () => {
|
|
626
|
+
try {
|
|
627
|
+
if (!recoverSession)
|
|
628
|
+
throw new Error("session recovery control unavailable");
|
|
629
|
+
const result = await recoverSession({ agentId: recoveryRequest.agentId, reason: recoveryRequest.reason,
|
|
630
|
+
waitReadyMs: recoveryRequest.waitReadyMs ?? 30_000 });
|
|
631
|
+
return sanitizeSessionRecoveryResponse(result, recoveryRequest.agentId);
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
const code = typeof error.code === "string"
|
|
635
|
+
? String(error.code)
|
|
636
|
+
: error instanceof RuntimePrerequisiteError && error.readiness.state === "unavailable"
|
|
637
|
+
? "runtime_unavailable" : "recovery_refused";
|
|
638
|
+
const projection = error;
|
|
639
|
+
return { ok: false, agentId: recoveryRequest.agentId, code,
|
|
640
|
+
error: recoveryErrorText(code), recoveryCommitted: false,
|
|
641
|
+
generationChanged: false, sessionChanged: false, turns: Math.max(0, Number(projection.turns) || 0),
|
|
642
|
+
runtimeReady: projection.runtimeReady === true, channelConnected: projection.channelConnected === true,
|
|
643
|
+
reconnecting: projection.reconnecting === true, pendingCount: Math.max(0, Number(projection.pendingCount) || 0),
|
|
644
|
+
rearmedCount: Math.max(0, Number(projection.rearmedCount) || 0), replayStatus: "not_started",
|
|
645
|
+
remainingPendingCount: Math.max(0, Number(projection.remainingPendingCount) || 0),
|
|
646
|
+
readyForFreshScenario: false, inboundObserved: false,
|
|
647
|
+
...(error instanceof RuntimePrerequisiteError ? { readiness: sanitizeRecoveryReadiness(error.readiness) } : {}) };
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
const prior = agentQueues.get(recoveryRequest.agentId) ?? Promise.resolve();
|
|
651
|
+
const executing = prior.catch(() => { }).then(executeRecovery);
|
|
652
|
+
let queued;
|
|
653
|
+
queued = executing.finally(() => {
|
|
654
|
+
if (agentQueues.get(recoveryRequest.agentId) === queued)
|
|
655
|
+
agentQueues.delete(recoveryRequest.agentId);
|
|
656
|
+
if (recoveryInFlight.get(recoveryRequest.agentId) === queued)
|
|
657
|
+
recoveryInFlight.delete(recoveryRequest.agentId);
|
|
658
|
+
});
|
|
659
|
+
agentQueues.set(recoveryRequest.agentId, queued);
|
|
660
|
+
recoveryInFlight.set(recoveryRequest.agentId, queued);
|
|
661
|
+
operation = queued;
|
|
662
|
+
}
|
|
663
|
+
const response = await operation;
|
|
664
|
+
connection.end(`${JSON.stringify(response)}\n`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
567
667
|
const resetRequest = request;
|
|
568
668
|
let operation = resetInFlight.get(resetRequest.agentId);
|
|
569
669
|
if (!operation) {
|
|
@@ -776,6 +876,12 @@ export async function requestSessionReset({ larkinHome, agentId, waitReadyMs = 3
|
|
|
776
876
|
request: { operation: "session-reset", agentId, waitReadyMs },
|
|
777
877
|
});
|
|
778
878
|
}
|
|
879
|
+
export async function requestSessionRecovery({ larkinHome, agentId, reason = "context-overflow", waitReadyMs = 30_000, }) {
|
|
880
|
+
return requestAgentControl({
|
|
881
|
+
larkinHome, timeoutMs: Math.max(1_000, waitReadyMs + 1_000),
|
|
882
|
+
request: { operation: "session-recover", agentId, reason, waitReadyMs },
|
|
883
|
+
});
|
|
884
|
+
}
|
|
779
885
|
export function cleanupStaleAgentControlSocket(larkinHome, expectedToken) {
|
|
780
886
|
const authority = secureAuthority(larkinHome);
|
|
781
887
|
if (!sameSecret(authority.token, expectedToken))
|
|
@@ -104,6 +104,10 @@ export async function main(env = process.env, overrides = {}) {
|
|
|
104
104
|
const result = await hostShell.resetSession(request.agentId, request.waitReadyMs);
|
|
105
105
|
return { ok: result.readyForFreshScenario, agentId: request.agentId, ...result };
|
|
106
106
|
},
|
|
107
|
+
async recoverSession(request) {
|
|
108
|
+
const result = await hostShell.recoverSession(request.agentId, request.reason, request.waitReadyMs);
|
|
109
|
+
return { ok: result.recoveryCommitted, agentId: request.agentId, ...result };
|
|
110
|
+
},
|
|
107
111
|
});
|
|
108
112
|
await controlServer.start();
|
|
109
113
|
await markConfigAppliedAfterRuntimeReady(env, hostShell.agents, hostShell.start());
|
package/dist/app/session-cli.mjs
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { loadConfig } from "../platform/config.mjs";
|
|
2
|
-
import { requestSessionReset } from "./local-control.mjs";
|
|
3
|
-
function
|
|
4
|
-
if (
|
|
2
|
+
import { requestSessionRecovery, requestSessionReset } from "./local-control.mjs";
|
|
3
|
+
function sanitizeRecoveryReadiness(value) {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5
|
+
return undefined;
|
|
6
|
+
const candidate = value;
|
|
7
|
+
if (!["codex", "claude", "pi"].includes(String(candidate.runtime))
|
|
8
|
+
|| !["missing", "unauthenticated", "unavailable", "incompatible", "ready"].includes(String(candidate.state)))
|
|
9
|
+
return undefined;
|
|
10
|
+
return { runtime: candidate.runtime, state: candidate.state,
|
|
11
|
+
...(candidate.state === "ready" ? {} : { reason: `Runtime readiness is ${String(candidate.state)}.`, nextAction: "Inspect Runtime/provider configuration, then retry." }) };
|
|
12
|
+
}
|
|
13
|
+
function parseSessionArguments(args) {
|
|
14
|
+
if (args[0] !== "reset" && args[0] !== "recover")
|
|
5
15
|
throw new Error("unsupported session subcommand");
|
|
16
|
+
const operation = args[0];
|
|
6
17
|
const values = new Map();
|
|
7
18
|
let json = false;
|
|
8
19
|
for (let index = 1; index < args.length; index += 1) {
|
|
@@ -13,7 +24,7 @@ function parseResetArguments(args) {
|
|
|
13
24
|
json = true;
|
|
14
25
|
continue;
|
|
15
26
|
}
|
|
16
|
-
if (!["--agent", "--wait-ready"].includes(token)) {
|
|
27
|
+
if (!["--agent", "--wait-ready", "--reason"].includes(token)) {
|
|
17
28
|
throw new Error(token.startsWith("-") ? `unknown flag: ${token}` : `unexpected positional: ${token}`);
|
|
18
29
|
}
|
|
19
30
|
if (values.has(token))
|
|
@@ -32,7 +43,12 @@ function parseResetArguments(args) {
|
|
|
32
43
|
const waitSeconds = Number(values.get("--wait-ready") ?? "30");
|
|
33
44
|
if (!Number.isFinite(waitSeconds) || waitSeconds < 0 || waitSeconds > 300)
|
|
34
45
|
throw new Error("--wait-ready must be 0..300 seconds");
|
|
35
|
-
|
|
46
|
+
const reason = values.get("--reason");
|
|
47
|
+
if (operation === "recover" && reason !== "context-overflow")
|
|
48
|
+
throw new Error("--reason context-overflow is required for session recover");
|
|
49
|
+
if (operation === "reset" && reason !== undefined)
|
|
50
|
+
throw new Error("--reason is only valid for session recover");
|
|
51
|
+
return { operation, agentId, waitReadyMs: Math.round(waitSeconds * 1000), ...(reason ? { reason: reason } : {}) };
|
|
36
52
|
}
|
|
37
53
|
export async function runSessionCli(args, env = process.env, dependencies = {}) {
|
|
38
54
|
const io = dependencies.io ?? { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value) };
|
|
@@ -42,29 +58,46 @@ export async function runSessionCli(args, env = process.env, dependencies = {})
|
|
|
42
58
|
};
|
|
43
59
|
let parsed;
|
|
44
60
|
try {
|
|
45
|
-
parsed =
|
|
61
|
+
parsed = parseSessionArguments(args);
|
|
46
62
|
}
|
|
47
63
|
catch (error) {
|
|
48
64
|
return fail("invalid_arguments", error instanceof Error ? error.message : String(error));
|
|
49
65
|
}
|
|
50
66
|
const agentId = parsed.agentId;
|
|
51
67
|
if (typeof env.LARKIN_AGENT_ID === "string" && env.LARKIN_AGENT_ID.trim()) {
|
|
52
|
-
return fail("user_authority_required",
|
|
68
|
+
return fail("user_authority_required", parsed.operation === "reset"
|
|
69
|
+
? "session reset is available only from a user terminal"
|
|
70
|
+
: "session recovery is available only from a user terminal", agentId);
|
|
53
71
|
}
|
|
54
72
|
try {
|
|
55
73
|
const loaded = loadConfig(env);
|
|
56
|
-
const result =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
const result = parsed.operation === "recover"
|
|
75
|
+
? await (dependencies.requestRecovery ?? requestSessionRecovery)({
|
|
76
|
+
larkinHome: loaded.config.larkinHome, agentId, reason: parsed.reason, waitReadyMs: parsed.waitReadyMs,
|
|
77
|
+
})
|
|
78
|
+
: await (dependencies.request ?? requestSessionReset)({
|
|
79
|
+
larkinHome: loaded.config.larkinHome, agentId, waitReadyMs: parsed.waitReadyMs,
|
|
80
|
+
});
|
|
81
|
+
const output = "recoveryCommitted" in result
|
|
82
|
+
? {
|
|
83
|
+
ok: result.ok, agent_id: result.agentId, recovery_committed: result.recoveryCommitted,
|
|
84
|
+
generation_changed: result.generationChanged, session_changed: result.sessionChanged, turns: result.turns,
|
|
85
|
+
runtime_ready: result.runtimeReady, channel_connected: result.channelConnected, reconnecting: result.reconnecting,
|
|
86
|
+
pending_count: result.pendingCount, rearmed_count: result.rearmedCount, replay_status: result.replayStatus,
|
|
87
|
+
remaining_pending_count: result.remainingPendingCount, ready_for_fresh_scenario: result.readyForFreshScenario,
|
|
88
|
+
inbound_observed: result.inboundObserved, ...(result.code ? { code: result.code } : {}),
|
|
89
|
+
...(result.error ? { error: "context-overflow recovery failed" } : {}),
|
|
90
|
+
...(sanitizeRecoveryReadiness(result.readiness) ? { readiness: sanitizeRecoveryReadiness(result.readiness) } : {}),
|
|
91
|
+
}
|
|
92
|
+
: {
|
|
93
|
+
ok: result.ok, agent_id: result.agentId,
|
|
94
|
+
reset_committed: result.resetCommitted, generation_changed: result.generationChanged,
|
|
95
|
+
session_changed: result.sessionChanged, turns: result.turns, runtime_ready: result.runtimeReady,
|
|
96
|
+
channel_connected: result.channelConnected, reconnecting: result.reconnecting,
|
|
97
|
+
pending_count: result.pendingCount, ready_for_fresh_scenario: result.readyForFreshScenario,
|
|
98
|
+
inbound_observed: result.inboundObserved, ...(result.code ? { code: result.code } : {}),
|
|
99
|
+
...(result.error ? { error: result.error } : {}), ...(result.readiness ? { readiness: result.readiness } : {}),
|
|
100
|
+
};
|
|
68
101
|
io.stdout(`${JSON.stringify(output, null, 2)}\n`);
|
|
69
102
|
return result.ok ? 0 : 1;
|
|
70
103
|
}
|
|
@@ -72,7 +105,9 @@ export async function runSessionCli(args, env = process.env, dependencies = {})
|
|
|
72
105
|
const message = error instanceof Error ? error.message : String(error);
|
|
73
106
|
const output = {
|
|
74
107
|
ok: false, agent_id: agentId,
|
|
75
|
-
code: /timeout/i.test(message) ? "control_timeout" : "control_unavailable",
|
|
108
|
+
code: /timeout/i.test(message) ? "control_timeout" : "control_unavailable",
|
|
109
|
+
error: parsed.operation === "recover"
|
|
110
|
+
? (/timeout/i.test(message) ? "session recovery control timed out" : "session recovery control unavailable") : message,
|
|
76
111
|
};
|
|
77
112
|
io.stdout(`${JSON.stringify(output, null, 2)}\n`);
|
|
78
113
|
return 1;
|
|
@@ -1284,7 +1284,10 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
1284
1284
|
if (message.type === "session") {
|
|
1285
1285
|
const record = agentStates.get(agent.agentId);
|
|
1286
1286
|
if (record && record.state.sessions[message.runtime] !== message.sessionId) {
|
|
1287
|
-
|
|
1287
|
+
if (message.sessionId)
|
|
1288
|
+
record.state.sessions[message.runtime] = message.sessionId;
|
|
1289
|
+
else
|
|
1290
|
+
delete record.state.sessions[message.runtime];
|
|
1288
1291
|
saveAgentState(record);
|
|
1289
1292
|
log(`持久化 agent=${agent.name} runtime=${message.runtime} sessionId=${message.sessionId}`);
|
|
1290
1293
|
}
|
|
@@ -1432,6 +1435,74 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
1432
1435
|
: unavailable ? "reset committed but Runtime/channel readiness is unavailable"
|
|
1433
1436
|
: "reset committed but readiness did not converge before timeout" };
|
|
1434
1437
|
},
|
|
1438
|
+
async recoverSession(agentId, reason, waitReadyMs = 30_000) {
|
|
1439
|
+
const agent = agents.find((candidate) => candidate.agentId === agentId);
|
|
1440
|
+
if (!agent)
|
|
1441
|
+
throw Object.assign(new Error(`未知 Agent: ${agentId}`), { code: "unknown_agent" });
|
|
1442
|
+
if (reason !== "context-overflow")
|
|
1443
|
+
throw Object.assign(new Error("unsupported recovery reason"), { code: "recovery_refused" });
|
|
1444
|
+
if (!runtimeHost.recoverSession)
|
|
1445
|
+
throw Object.assign(new Error("Runtime session recovery 尚未就绪"), { code: "recovery_unavailable" });
|
|
1446
|
+
const connectionState = (status = hostState.readStatus(agent)) => {
|
|
1447
|
+
const connectedAt = Date.parse(String(status.connectedAt || ""));
|
|
1448
|
+
const channelConnected = Number.isFinite(connectedAt) && connectedAt >= Date.parse(daemonStartedAt) - 1000;
|
|
1449
|
+
return { channelConnected, reconnecting: (0, agent_readiness_js_1.isChannelReconnecting)(status) };
|
|
1450
|
+
};
|
|
1451
|
+
const readinessProjection = () => {
|
|
1452
|
+
const status = hostState.readStatus(agent);
|
|
1453
|
+
const turns = status.session && typeof status.session === "object"
|
|
1454
|
+
? Math.max(0, Number(status.session.turns) || 0) : 0;
|
|
1455
|
+
const runtimeReady = Boolean(status.runtimeReadiness && typeof status.runtimeReadiness === "object"
|
|
1456
|
+
&& status.runtimeReadiness.state === "ready");
|
|
1457
|
+
const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
|
|
1458
|
+
return { turns, runtimeReady, ...connectionState(status), pendingCount };
|
|
1459
|
+
};
|
|
1460
|
+
const initialProjection = readinessProjection();
|
|
1461
|
+
if (!initialProjection.channelConnected)
|
|
1462
|
+
throw Object.assign(new Error(`Agent ${agentId} channel is not connected`), { code: "channel_unavailable", ...initialProjection });
|
|
1463
|
+
if (initialProjection.reconnecting)
|
|
1464
|
+
throw Object.assign(new Error(`Agent ${agentId} channel is reconnecting`), { code: "channel_reconnecting", ...initialProjection });
|
|
1465
|
+
let recovery;
|
|
1466
|
+
try {
|
|
1467
|
+
recovery = await runtimeHost.recoverSession(agentId, reason);
|
|
1468
|
+
}
|
|
1469
|
+
catch (error) {
|
|
1470
|
+
throw Object.assign(error instanceof Error ? error : new Error(String(error)), readinessProjection());
|
|
1471
|
+
}
|
|
1472
|
+
const record = agentStates.get(agentId);
|
|
1473
|
+
try {
|
|
1474
|
+
if (record) {
|
|
1475
|
+
if (recovery.sessionId)
|
|
1476
|
+
record.state.sessions[agent.runtime] = recovery.sessionId;
|
|
1477
|
+
else
|
|
1478
|
+
delete record.state.sessions[agent.runtime];
|
|
1479
|
+
record.store.writeJson("agentState", record.state);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
catch (error) {
|
|
1483
|
+
const projection = readinessProjection();
|
|
1484
|
+
return { recoveryCommitted: true, generationChanged: recovery.generationChanged, sessionChanged: recovery.sessionChanged,
|
|
1485
|
+
...projection, rearmedCount: recovery.rearmedCount, replayStatus: "pending", remainingPendingCount: projection.pendingCount,
|
|
1486
|
+
readyForFreshScenario: false, inboundObserved: false, code: "state_persistence_failed",
|
|
1487
|
+
error: "recovery committed but Agent session state persistence failed" };
|
|
1488
|
+
}
|
|
1489
|
+
const deadline = Date.now() + Math.max(0, waitReadyMs);
|
|
1490
|
+
let projection = readinessProjection();
|
|
1491
|
+
do {
|
|
1492
|
+
projection = readinessProjection();
|
|
1493
|
+
if (projection.turns === 0 && projection.runtimeReady && projection.channelConnected && !projection.reconnecting)
|
|
1494
|
+
break;
|
|
1495
|
+
if (Date.now() >= deadline)
|
|
1496
|
+
break;
|
|
1497
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
|
|
1498
|
+
} while (true);
|
|
1499
|
+
const replayStatus = projection.pendingCount === 0 ? "consumed" : "pending";
|
|
1500
|
+
const ready = projection.turns === 0 && projection.runtimeReady && projection.channelConnected && !projection.reconnecting;
|
|
1501
|
+
return { recoveryCommitted: true, generationChanged: recovery.generationChanged, sessionChanged: recovery.sessionChanged,
|
|
1502
|
+
...projection, rearmedCount: recovery.rearmedCount, replayStatus, remainingPendingCount: projection.pendingCount,
|
|
1503
|
+
readyForFreshScenario: ready, inboundObserved: false,
|
|
1504
|
+
...(!ready ? { code: "recovery_timeout", error: "recovery committed but Runtime/channel readiness did not converge before timeout" } : {}) };
|
|
1505
|
+
},
|
|
1435
1506
|
async start() {
|
|
1436
1507
|
try {
|
|
1437
1508
|
node_fs_1.default.mkdirSync(larkinHome, { recursive: true });
|