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.
Files changed (32) hide show
  1. package/README.md +72 -33
  2. package/assets/readme/larkin-hero.svg +111 -0
  3. package/dist/agent/agent-state-store.cjs +321 -22
  4. package/dist/agent/agent-state-store.mjs +320 -22
  5. package/dist/agent/agent-transport.cjs +6 -4
  6. package/dist/agent/agent-transport.mjs +6 -4
  7. package/dist/agent/host-reminder-orchestrator.cjs +9 -3
  8. package/dist/agent/host-reminder-orchestrator.mjs +9 -3
  9. package/dist/agent/inbox-projection.cjs +114 -23
  10. package/dist/agent/inbox-projection.mjs +112 -23
  11. package/dist/app/cli.mjs +3 -1
  12. package/dist/app/local-control.mjs +108 -2
  13. package/dist/app/runtime-process.mjs +4 -0
  14. package/dist/app/session-cli.mjs +55 -20
  15. package/dist/feishu/document-comment.cjs +2 -1
  16. package/dist/feishu/document-comment.mjs +2 -1
  17. package/dist/feishu/host-business-state.cjs +7 -2
  18. package/dist/feishu/host-business-state.mjs +7 -2
  19. package/dist/feishu/host-shell.cjs +138 -32
  20. package/dist/feishu/host-shell.mjs +138 -32
  21. package/dist/runtime/pi-compaction-recovery.cjs +623 -0
  22. package/dist/runtime/pi-compaction-recovery.mjs +602 -0
  23. package/dist/runtime/pi-provider-config.cjs +2 -2
  24. package/dist/runtime/pi-provider-config.mjs +2 -2
  25. package/dist/runtime/pi-rpc-client.cjs +39 -22
  26. package/dist/runtime/pi-rpc-client.mjs +39 -22
  27. package/dist/runtime/provider-error-classifier.cjs +39 -0
  28. package/dist/runtime/provider-error-classifier.mjs +34 -0
  29. package/dist/runtime/runtime-adapters.mjs +82 -19
  30. package/dist/runtime/runtime-host.cjs +663 -12
  31. package/dist/runtime/runtime-host.mjs +661 -11
  32. package/package.json +1 -1
@@ -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;
@@ -11,6 +11,7 @@ exports.resolveDocumentCommentContext = resolveDocumentCommentContext;
11
11
  exports.projectDocumentCommentEnvelope = projectDocumentCommentEnvelope;
12
12
  const node_crypto_1 = require("node:crypto");
13
13
  exports.DOCUMENT_COMMENT_EVENT = "drive.notice.comment_add_v1";
14
+ const DOCUMENT_COMMENT_TARGET_PATTERN = /^document-comment:(doc|docx|sheet|file):([A-Za-z0-9_-]+):([A-Za-z0-9_-]+):(in-thread|top-level)$/;
14
15
  function record(value) {
15
16
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
16
17
  }
@@ -39,7 +40,7 @@ function documentCommentTarget(target, commentId, topLevel = false) {
39
40
  return `document-comment:${target.fileType}:${target.fileToken}:${commentId}:${topLevel ? "top-level" : "in-thread"}`;
40
41
  }
41
42
  function parseDocumentCommentTarget(value) {
42
- const match = /^document-comment:(doc|docx|sheet|file):([A-Za-z0-9_-]+):([A-Za-z0-9_-]+):(in-thread|top-level)$/.exec(value);
43
+ const match = DOCUMENT_COMMENT_TARGET_PATTERN.exec(value);
43
44
  return match ? {
44
45
  fileType: match[1], fileToken: match[2], commentId: match[3], topLevel: match[4] === "top-level",
45
46
  } : null;
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  export const DOCUMENT_COMMENT_EVENT = "drive.notice.comment_add_v1";
3
+ const DOCUMENT_COMMENT_TARGET_PATTERN = /^document-comment:(doc|docx|sheet|file):([A-Za-z0-9_-]+):([A-Za-z0-9_-]+):(in-thread|top-level)$/;
3
4
  function record(value) {
4
5
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
5
6
  }
@@ -28,7 +29,7 @@ export function documentCommentTarget(target, commentId, topLevel = false) {
28
29
  return `document-comment:${target.fileType}:${target.fileToken}:${commentId}:${topLevel ? "top-level" : "in-thread"}`;
29
30
  }
30
31
  export function parseDocumentCommentTarget(value) {
31
- const match = /^document-comment:(doc|docx|sheet|file):([A-Za-z0-9_-]+):([A-Za-z0-9_-]+):(in-thread|top-level)$/.exec(value);
32
+ const match = DOCUMENT_COMMENT_TARGET_PATTERN.exec(value);
32
33
  return match ? {
33
34
  fileType: match[1], fileToken: match[2], commentId: match[3], topLevel: match[4] === "top-level",
34
35
  } : null;
@@ -10,6 +10,7 @@ exports.shouldPreventiveReconnect = shouldPreventiveReconnect;
10
10
  exports.countWakeEnvelopes = countWakeEnvelopes;
11
11
  exports.projectActivityStatus = projectActivityStatus;
12
12
  exports.projectSessionStatus = projectSessionStatus;
13
+ const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
13
14
  const node_crypto_1 = __importDefault(require("node:crypto"));
14
15
  const message_policy_js_1 = require("./message-policy.cjs");
15
16
  function asRecord(value) {
@@ -337,7 +338,8 @@ class HostEnvelopeProjector {
337
338
  : `本条存量提醒缺少可用的飞书 message_id/chat_id,无法安全推断原会话;请先用 ${this.larkCommand("im +chat-search")} 确认目标,禁止猜测发送`,
338
339
  `这是你之前用 ${this.agentCommand("reminder schedule")} 设置的提醒,请按标题执行相应动作。管理: ${this.agentCommand("reminder list")} / ${this.agentCommand("reminder snooze")} / ${this.agentCommand("reminder cancel")}`,
339
340
  ].filter((line) => Boolean(line));
340
- return {
341
+ const envelope = {
342
+ kind: "reminder",
341
343
  message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}`,
342
344
  seq,
343
345
  sender_name: "定时提醒",
@@ -349,10 +351,12 @@ class HostEnvelopeProjector {
349
351
  thread_id: null,
350
352
  wake: true,
351
353
  };
354
+ return { ...envelope, target: inbox_projection_js_1.RUNTIME_REMINDER_TARGET };
352
355
  }
353
356
  createRedeliveryEnvelope(agentId, wakeCount) {
354
357
  const seq = this.nextSequence(agentId);
355
- return {
358
+ const envelope = {
359
+ kind: "redelivery",
356
360
  message_id: `redeliver_${this.randomHex(6)}`,
357
361
  seq,
358
362
  sender_name: "系统",
@@ -363,6 +367,7 @@ class HostEnvelopeProjector {
363
367
  timestamp: this.now().toISOString(),
364
368
  thread_id: null,
365
369
  };
370
+ return { ...envelope, target: inbox_projection_js_1.RUNTIME_REDELIVERY_TARGET };
366
371
  }
367
372
  }
368
373
  exports.HostEnvelopeProjector = HostEnvelopeProjector;
@@ -1,3 +1,4 @@
1
+ import { RUNTIME_REDELIVERY_TARGET, RUNTIME_REMINDER_TARGET } from "../agent/inbox-projection.mjs";
1
2
  import crypto from "node:crypto";
2
3
  import { createMessageEnvelope, targetAliases, targetFor, } from "./message-policy.mjs";
3
4
  function asRecord(value) {
@@ -323,7 +324,8 @@ export class HostEnvelopeProjector {
323
324
  : `本条存量提醒缺少可用的飞书 message_id/chat_id,无法安全推断原会话;请先用 ${this.larkCommand("im +chat-search")} 确认目标,禁止猜测发送`,
324
325
  `这是你之前用 ${this.agentCommand("reminder schedule")} 设置的提醒,请按标题执行相应动作。管理: ${this.agentCommand("reminder list")} / ${this.agentCommand("reminder snooze")} / ${this.agentCommand("reminder cancel")}`,
325
326
  ].filter((line) => Boolean(line));
326
- return {
327
+ const envelope = {
328
+ kind: "reminder",
327
329
  message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}`,
328
330
  seq,
329
331
  sender_name: "定时提醒",
@@ -335,10 +337,12 @@ export class HostEnvelopeProjector {
335
337
  thread_id: null,
336
338
  wake: true,
337
339
  };
340
+ return { ...envelope, target: RUNTIME_REMINDER_TARGET };
338
341
  }
339
342
  createRedeliveryEnvelope(agentId, wakeCount) {
340
343
  const seq = this.nextSequence(agentId);
341
- return {
344
+ const envelope = {
345
+ kind: "redelivery",
342
346
  message_id: `redeliver_${this.randomHex(6)}`,
343
347
  seq,
344
348
  sender_name: "系统",
@@ -349,6 +353,7 @@ export class HostEnvelopeProjector {
349
353
  timestamp: this.now().toISOString(),
350
354
  thread_id: null,
351
355
  };
356
+ return { ...envelope, target: RUNTIME_REDELIVERY_TARGET };
352
357
  }
353
358
  }
354
359
  export function countWakeEnvelopes(lines) {
@@ -200,6 +200,16 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
200
200
  return store;
201
201
  };
202
202
  const hostState = new host_business_state_js_1.HostStateProjection(stateStore, log);
203
+ const recordInboundDeliveryFailure = (agent, code) => {
204
+ const at = new Date().toISOString();
205
+ const reason = "Inbound Runtime delivery failed non-retryably; durable Inbox/ledger state is retained for recovery.";
206
+ const nextAction = "Inspect the delivery/status error, correct the Runtime or canonical Inbox state, then restart to replay safely.";
207
+ hostState.updateStatus(agent, {
208
+ inboundDeliveryHealth: { state: "error", code, at, reason, nextAction },
209
+ runtimeReadiness: { runtime: agent.runtime, state: "incompatible", reason, nextAction },
210
+ });
211
+ hostState.recordStatusError(agent, `${reason} ${nextAction} code=${code}`);
212
+ };
203
213
  const agentStates = new Map();
204
214
  const saveAgentState = (record) => {
205
215
  try {
@@ -335,6 +345,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
335
345
  return;
336
346
  }
337
347
  const telemetryMessageId = String(event.message_id || event.event_id || eventKey);
348
+ let canonicalInboxDurable = false;
338
349
  if (wake)
339
350
  telemetry?.beginMessage(agent.agentId, telemetryMessageId);
340
351
  if (event.event_id)
@@ -345,58 +356,76 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
345
356
  senderIdentity.ensureChatNames(agent, event.chat_id, 3_000),
346
357
  event._sender_is_bot ? Promise.resolve(null) : senderIdentity.ensureSenderSignature(agent, event.sender_id, 3_000),
347
358
  ]);
348
- const envelope = envelopeProjector.projectInbound(agent, event, { anchorReply: wake, names, signature });
349
- envelope.target = (0, inbox_projection_js_1.targetKeyOfInboxEnvelope)({ ...envelope, chat_id: event.chat_id, thread_id: event.thread_id });
359
+ const projected = envelopeProjector.projectInbound(agent, event, { anchorReply: wake, names, signature });
360
+ projected.target = (0, inbox_projection_js_1.targetKeyOfInboxEnvelope)({ ...projected, chat_id: event.chat_id, thread_id: event.thread_id });
350
361
  if (wake)
351
- envelope.wake = true;
352
- const inboxEnvelope = (0, inbox_projection_js_1.projectInboxEnvelope)(envelope, {
362
+ projected.wake = true;
363
+ const candidate = (0, inbox_projection_js_1.projectInboxEnvelope)(projected, {
353
364
  chat_id: event.chat_id,
354
365
  thread_id: event.thread_id,
355
366
  ...(event.create_time !== undefined ? { create_time: String(event.create_time) } : {}),
356
367
  sender_id: event.sender_id,
357
- content: String(envelope.content ?? event.content ?? ""),
368
+ content: String(projected.content ?? event.content ?? ""),
358
369
  });
370
+ let append;
359
371
  try {
360
- stateStore(agent).appendNdjson("inbox", inboxEnvelope);
372
+ append = stateStore(agent).appendCanonicalInboxOnce(candidate);
361
373
  }
362
374
  catch (error) {
363
375
  throw new Error(`inbox 写失败: ${errorMessage(error)}`);
364
376
  }
365
- // An event becomes permanently seen only after its canonical Inbox append
366
- // is durable. Failures remain eligible for same-process redelivery.
377
+ canonicalInboxDurable = true;
378
+ // An event becomes permanently transport-seen only after the canonical
379
+ // append/dedupe decision is durable. Agent model-seen state is untouched.
367
380
  if (event.event_id)
368
381
  seenEventIds.add(eventKey);
369
- hostState.appendConversation(agent, {
370
- direction: "in", from: envelope.sender_name, senderType: envelope.sender_type,
371
- target: (0, message_policy_js_1.targetFor)(event).target, wake, text: event.content, messageId: envelope.message_id,
372
- at: envelope.timestamp || new Date().toISOString(),
373
- });
374
- return envelope;
382
+ if (append.status === "duplicate_consumed")
383
+ return null;
384
+ const inboxEnvelope = append.envelope;
385
+ if (append.status === "appended")
386
+ hostState.appendConversation(agent, {
387
+ direction: "in", from: inboxEnvelope.sender_name, senderType: inboxEnvelope.sender_type,
388
+ target: String(inboxEnvelope.target || (0, message_policy_js_1.targetFor)(event).target), wake, text: event.content, messageId: inboxEnvelope.message_id,
389
+ at: inboxEnvelope.timestamp || new Date().toISOString(),
390
+ });
391
+ return inboxEnvelope;
375
392
  };
376
- const envelope = wake && telemetry
393
+ const inboxEnvelope = wake && telemetry
377
394
  ? await telemetry.phase(telemetryMessageId, "feishu.receive", api_1.SpanKind.CONSUMER, receive)
378
395
  : await receive();
379
- if (!wake)
396
+ if (!wake || !inboxEnvelope)
380
397
  return;
381
- const receipt = await runtimeHost.deliver(agent.agentId, envelope);
382
- if (receipt.status === "accepted" || receipt.status === "duplicate" || receipt.status === "deferred") {
398
+ const receipt = await runtimeHost.deliver(agent.agentId, inboxEnvelope);
399
+ if (receipt.status === "error") {
400
+ recordInboundDeliveryFailure(agent, "non_retryable_receipt");
383
401
  hostState.appendStatusLog(agent, "deliverLog", {
384
- from: envelope.sender_name,
385
- target: (0, message_policy_js_1.targetFor)(event).target,
386
- excerpt: (0, host_business_state_js_1.safeConversationExcerpt)(event.content, 180),
387
- at: new Date().toISOString(),
388
- }, 30);
389
- if (envelope.sender_type === "human" || envelope.sender_type === "agent")
390
- processingEyes.add(agent, String(envelope.message_id || ""));
391
- if (receipt.status === "deferred")
392
- log(`Runtime 暂缓投递,消息保留在 inbox seq=${envelope.seq}: ${receipt.reason}`);
402
+ at: new Date().toISOString(), status: "error",
403
+ reason: "non-retryable Runtime delivery error; durable recovery state retained",
404
+ }, 80);
405
+ return;
393
406
  }
407
+ hostState.appendStatusLog(agent, "deliverLog", {
408
+ from: inboxEnvelope.sender_name,
409
+ target: String(inboxEnvelope.target || (0, message_policy_js_1.targetFor)(event).target),
410
+ excerpt: (0, host_business_state_js_1.safeConversationExcerpt)(event.content, 180),
411
+ at: new Date().toISOString(),
412
+ }, 30);
413
+ if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent")
414
+ processingEyes.add(agent, String(inboxEnvelope.message_id || ""));
415
+ if (receipt.status === "deferred")
416
+ log(`Runtime 暂缓投递,消息保留在 inbox seq=${inboxEnvelope.seq}: ${receipt.reason}`);
394
417
  }
395
418
  catch (error) {
396
419
  if (wake)
397
420
  telemetry?.delivery(agent.agentId, telemetryMessageId, "error");
398
- log(`onFeishuMessage 异常 agent=${agent.name}: ${error instanceof Error ? error.stack || error.message : String(error)}`);
399
- hostState.recordStatusError(agent, `onFeishuMessage: ${errorMessage(error)}`);
421
+ if (canonicalInboxDurable) {
422
+ log(`onFeishuMessage Runtime delivery failed agent=${agent.name}; canonical Inbox retained`);
423
+ recordInboundDeliveryFailure(agent, "runtime_delivery_exception");
424
+ }
425
+ else {
426
+ log(`onFeishuMessage 异常 agent=${agent.name}: ${error instanceof Error ? error.stack || error.message : String(error)}`);
427
+ hostState.recordStatusError(agent, `onFeishuMessage: ${errorMessage(error)}`);
428
+ }
400
429
  }
401
430
  finally {
402
431
  if (event.event_id)
@@ -1241,16 +1270,24 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1241
1270
  return;
1242
1271
  }
1243
1272
  if (message.type === "delivery") {
1273
+ if (message.status === "error")
1274
+ recordInboundDeliveryFailure(agent, "runtime_delivery_event");
1244
1275
  hostState.appendStatusLog(agent, "deliverLog", {
1245
1276
  at: new Date().toISOString(), deliveryId: message.deliveryId, messageId: message.messageId,
1246
- status: message.status, ...(message.reason ? { reason: (0, host_business_state_js_1.safeConversationExcerpt)(message.reason, 120) } : {}),
1277
+ status: message.status,
1278
+ ...(message.status === "error"
1279
+ ? { reason: "non-retryable Runtime delivery error; durable recovery state retained" }
1280
+ : (message.reason ? { reason: (0, host_business_state_js_1.safeConversationExcerpt)(message.reason, 120) } : {})),
1247
1281
  }, 80);
1248
1282
  return;
1249
1283
  }
1250
1284
  if (message.type === "session") {
1251
1285
  const record = agentStates.get(agent.agentId);
1252
1286
  if (record && record.state.sessions[message.runtime] !== message.sessionId) {
1253
- 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];
1254
1291
  saveAgentState(record);
1255
1292
  log(`持久化 agent=${agent.name} runtime=${message.runtime} sessionId=${message.sessionId}`);
1256
1293
  }
@@ -1266,7 +1303,8 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1266
1303
  hostState.recordStatusError(agent, `auth: ${readiness.reason}; ${readiness.nextAction}`);
1267
1304
  }
1268
1305
  else {
1269
- hostState.recordStatusError(agent, `${message.event.errorCategory || "provider"}: ${message.event.message}${message.event.nextAction ? `; ${message.event.nextAction}` : ""}`);
1306
+ const category = message.event.errorCategory || "provider";
1307
+ hostState.recordStatusError(agent, `${category}: Runtime input failed ${message.event.retryable ? "retryably" : "non-retryably"}; inspect delivery health and Runtime/provider configuration`);
1270
1308
  }
1271
1309
  }
1272
1310
  };
@@ -1397,6 +1435,74 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1397
1435
  : unavailable ? "reset committed but Runtime/channel readiness is unavailable"
1398
1436
  : "reset committed but readiness did not converge before timeout" };
1399
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
+ },
1400
1506
  async start() {
1401
1507
  try {
1402
1508
  node_fs_1.default.mkdirSync(larkinHome, { recursive: true });