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.
@@ -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
- Atomically replace one idle, zero-backlog Agent Runtime session through authenticated local control.`,
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());
@@ -1,8 +1,19 @@
1
1
  import { loadConfig } from "../platform/config.mjs";
2
- import { requestSessionReset } from "./local-control.mjs";
3
- function parseResetArguments(args) {
4
- if (args[0] !== "reset")
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
- return { agentId, waitReadyMs: Math.round(waitSeconds * 1000) };
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 = parseResetArguments(args);
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", "session reset is available only from a user terminal", agentId);
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 = await (dependencies.request ?? requestSessionReset)({
57
- larkinHome: loaded.config.larkinHome, agentId, waitReadyMs: parsed.waitReadyMs,
58
- });
59
- const output = {
60
- ok: result.ok, agent_id: result.agentId,
61
- reset_committed: result.resetCommitted, generation_changed: result.generationChanged,
62
- session_changed: result.sessionChanged, turns: result.turns, runtime_ready: result.runtimeReady,
63
- channel_connected: result.channelConnected, reconnecting: result.reconnecting,
64
- pending_count: result.pendingCount, ready_for_fresh_scenario: result.readyForFreshScenario,
65
- inbound_observed: result.inboundObserved, ...(result.code ? { code: result.code } : {}),
66
- ...(result.error ? { error: result.error } : {}), ...(result.readiness ? { readiness: result.readiness } : {}),
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", error: message,
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
- record.state.sessions[message.runtime] = message.sessionId;
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 });