larkin 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { agentCliPromptCapabilities } from "../agent/agent-cli-capabilities.mjs";
5
+ import { isCanonicalInboxTarget, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
5
6
  import { SpanKind } from "@opentelemetry/api";
6
7
  import { classifyRuntimePrerequisite, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
7
8
  import { resolveOfficialLarkCli } from "../app/official-lark-cli.mjs";
@@ -19,6 +20,20 @@ export class RuntimeSessionResetError extends Error {
19
20
  const MAX_DELIVERIES = 2048;
20
21
  const now = () => new Date().toISOString();
21
22
  const isActiveDelivery = (status) => ["pending", "submitting", "accepted"].includes(status);
23
+ const REPLAY_FAILURE_CODES = new Set([
24
+ "canonical_inbox_row_missing", "canonical_inbox_malformed", "duplicate_message_id",
25
+ "inbox_state_conflict", "delivery_target_conflict", "structured_target_invalid",
26
+ "structured_target_missing", "wake_reason_conflict",
27
+ ]);
28
+ function replayFailureReason(code) {
29
+ return `Runtime delivery quarantined (${code}): no safe canonical Inbox target is available; the Inbox/ledger remain durable for operator recovery`;
30
+ }
31
+ function replayFailureCodeOf(record) {
32
+ if (record.status !== "error" || typeof record.reason !== "string")
33
+ return null;
34
+ const code = /^Runtime delivery quarantined \(([^)]+)\):/.exec(record.reason)?.[1];
35
+ return code && REPLAY_FAILURE_CODES.has(code) ? code : null;
36
+ }
22
37
  function deliveryFile(agent) {
23
38
  return agent.config.stateDir ? path.join(agent.config.stateDir, "runtime-deliveries.json") : null;
24
39
  }
@@ -151,6 +166,81 @@ export function createRuntimeHost(options) {
151
166
  emitConsumed(agent, persist(agent));
152
167
  return agent.records.get(record.deliveryId) ?? record;
153
168
  };
169
+ const scrubQuarantinedInput = (record, reason) => {
170
+ const staleInput = record.input && typeof record.input === "object" ? record.input : null;
171
+ record.input = {
172
+ inputId: typeof staleInput?.inputId === "string" && staleInput.inputId ? staleInput.inputId : record.deliveryId,
173
+ deliveryId: record.deliveryId,
174
+ kind: "wake",
175
+ text: reason,
176
+ attempt: Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0,
177
+ };
178
+ };
179
+ /** Rebuild every submitted notice from canonical structured state, never persisted text. */
180
+ const prepareRecordInput = (agent, record, busy) => {
181
+ if (record.target !== undefined && (typeof record.target !== "string" || !isCanonicalInboxTarget(record.target))) {
182
+ return { status: "error", code: "structured_target_invalid", reason: replayFailureReason("structured_target_invalid") };
183
+ }
184
+ if (record.wakeReason !== undefined && typeof record.wakeReason !== "string") {
185
+ return { status: "error", code: "wake_reason_conflict", reason: replayFailureReason("wake_reason_conflict") };
186
+ }
187
+ let target = typeof record.target === "string" ? record.target : null;
188
+ let wakeReason = typeof record.wakeReason === "string" ? record.wakeReason : undefined;
189
+ if (agent.stateStore?.resolveInboxDeliverySource) {
190
+ let source;
191
+ try {
192
+ source = agent.stateStore.resolveInboxDeliverySource(record.messageId);
193
+ }
194
+ catch {
195
+ source = { status: "invalid", code: "canonical_inbox_malformed" };
196
+ }
197
+ if (source.status === "consumed")
198
+ return { status: "consumed" };
199
+ if (source.status === "missing" || source.status === "invalid") {
200
+ return { status: "error", code: source.code, reason: replayFailureReason(source.code) };
201
+ }
202
+ if (target && target !== source.target) {
203
+ return { status: "error", code: "delivery_target_conflict", reason: replayFailureReason("delivery_target_conflict") };
204
+ }
205
+ const canonicalWakeReason = typeof source.envelope.wake_reason === "string" ? source.envelope.wake_reason : undefined;
206
+ if (record.wakeReason !== undefined && wakeReason !== canonicalWakeReason) {
207
+ return { status: "error", code: "wake_reason_conflict", reason: replayFailureReason("wake_reason_conflict") };
208
+ }
209
+ target = source.target;
210
+ wakeReason = canonicalWakeReason;
211
+ }
212
+ if (!target)
213
+ return { status: "error", code: "structured_target_missing", reason: replayFailureReason("structured_target_missing") };
214
+ const staleInput = record.input && typeof record.input === "object" ? record.input : null;
215
+ const attempt = Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0;
216
+ const inputId = typeof staleInput?.inputId === "string" && staleInput.inputId ? staleInput.inputId : record.deliveryId;
217
+ record.target = target;
218
+ if (wakeReason)
219
+ record.wakeReason = wakeReason;
220
+ else
221
+ delete record.wakeReason;
222
+ record.input = {
223
+ inputId,
224
+ deliveryId: record.deliveryId,
225
+ kind: busy ? "inbox_update" : "wake",
226
+ text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId: record.deliveryId, target,
227
+ ...(wakeReason ? { wakeReason } : {}) }),
228
+ attempt,
229
+ };
230
+ return { status: "ready", target };
231
+ };
232
+ const quarantineRecord = (agent, record, code) => {
233
+ const reason = replayFailureReason(code);
234
+ scrubQuarantinedInput(record, reason);
235
+ record.reason = reason;
236
+ record.retryable = false;
237
+ const finalRecord = setRecord(agent, record, "error");
238
+ if (finalRecord.status === "consumed")
239
+ return { status: "accepted", deliveryId: record.deliveryId };
240
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId,
241
+ messageId: record.messageId, status: "error", reason });
242
+ return { status: "error", deliveryId: record.deliveryId, reason, retryable: false };
243
+ };
154
244
  const reconcileExternalConsumption = (agent) => {
155
245
  if (agent.stateStore) {
156
246
  agent.stateStore.withInboxTransaction(() => reconcileFile(agent, agent.stateStore.readJson("runtimeDeliveries", { version: 1, records: [] })));
@@ -307,6 +397,20 @@ export function createRuntimeHost(options) {
307
397
  if (!busy)
308
398
  agent.busy = true; // Reserve the turn before prompt() can yield.
309
399
  try {
400
+ const prepared = prepareRecordInput(agent, record, busy);
401
+ if (prepared.status === "consumed") {
402
+ if (!busy)
403
+ agent.busy = false;
404
+ const consumedRecord = setRecord(agent, record, "consumed");
405
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: consumedRecord.deliveryId,
406
+ messageId: consumedRecord.messageId, status: "consumed" });
407
+ return { status: "accepted", deliveryId: record.deliveryId };
408
+ }
409
+ if (prepared.status === "error") {
410
+ if (!busy)
411
+ agent.busy = false;
412
+ return quarantineRecord(agent, record, prepared.code);
413
+ }
310
414
  const session = await ensureSession(agent);
311
415
  const submittingRecord = setRecord(agent, record, "submitting");
312
416
  if (submittingRecord.status === "consumed") {
@@ -799,7 +903,14 @@ export function createRuntimeHost(options) {
799
903
  const deliveryFile = stateStore.readJson("runtimeDeliveries", { version: 1, records: [] });
800
904
  if (!stateStore.readNdjson)
801
905
  return deliveryFile;
802
- const inboxIds = new Set(stateStore.readNdjson("inbox").flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
906
+ let inboxRows;
907
+ try {
908
+ inboxRows = stateStore.readNdjson("inbox");
909
+ }
910
+ catch {
911
+ return deliveryFile;
912
+ }
913
+ const inboxIds = new Set(inboxRows.flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
803
914
  let changed = false;
804
915
  const records = deliveryFile.records.map((record) => {
805
916
  const synthetic = /^(?:redeliver_|rem_|interaction_)/.test(record.messageId);
@@ -822,11 +933,34 @@ export function createRuntimeHost(options) {
822
933
  stabilityTimer: null, recreateReason: null, stopped: false, disabledReason: null,
823
934
  configurationRecovery: null, stateStore, readiness: null,
824
935
  turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false };
825
- for (const record of agent.records.values())
826
- if (isActiveDelivery(record.status))
827
- record.status = "pending";
828
- emitConsumed(agent, persist(agent));
936
+ const startupConsumed = [];
937
+ const startupQuarantined = [];
938
+ for (const record of agent.records.values()) {
939
+ const existingQuarantine = replayFailureCodeOf(record);
940
+ if (existingQuarantine) {
941
+ startupQuarantined.push({ record, code: existingQuarantine });
942
+ continue;
943
+ }
944
+ if (!isActiveDelivery(record.status))
945
+ continue;
946
+ record.status = "pending";
947
+ const prepared = prepareRecordInput(agent, record, false);
948
+ if (prepared.status === "consumed") {
949
+ record.status = "consumed";
950
+ record.updatedAt = now();
951
+ startupConsumed.push(record);
952
+ }
953
+ else if (prepared.status === "error") {
954
+ record.status = "error";
955
+ record.updatedAt = now();
956
+ scrubQuarantinedInput(record, prepared.reason);
957
+ record.reason = prepared.reason;
958
+ record.retryable = false;
959
+ startupQuarantined.push({ record, code: prepared.code });
960
+ }
961
+ }
829
962
  managed.set(config.agentId, agent);
963
+ emitConsumed(agent, [...startupConsumed, ...persist(agent)]);
830
964
  try {
831
965
  await ensureSession(agent);
832
966
  activeCount += 1;
@@ -844,6 +978,13 @@ export function createRuntimeHost(options) {
844
978
  emit({ type: "agent-status", agentId: config.agentId, status: "error", error: reason,
845
979
  ...(error instanceof RuntimePrerequisiteError ? { readiness: error.readiness } : {}) });
846
980
  }
981
+ const visibleQuarantines = startupQuarantined.filter(({ record }) => agent.records.get(record.deliveryId)?.status === "error");
982
+ for (const { record, code } of visibleQuarantines)
983
+ emit({ type: "delivery", agentId: config.agentId,
984
+ deliveryId: record.deliveryId, messageId: record.messageId, status: "error", reason: replayFailureReason(code) });
985
+ if (visibleQuarantines.length)
986
+ emit({ type: "agent-status", agentId: config.agentId, status: "error",
987
+ error: `${visibleQuarantines.length} Runtime delivery record(s) quarantined; canonical Inbox recovery is required` });
847
988
  agent.poller = setInterval(() => reconcileExternalConsumption(agent), 250);
848
989
  agent.poller.unref?.();
849
990
  });
@@ -859,12 +1000,33 @@ export function createRuntimeHost(options) {
859
1000
  telemetry?.delivery(agentId, messageId, "error");
860
1001
  throw new Error(`unknown runtime Agent: ${agentId}`);
861
1002
  }
1003
+ let target;
1004
+ try {
1005
+ target = targetKeyOfInboxEnvelope(envelope);
1006
+ }
1007
+ catch (error) {
1008
+ const reason = `Inbox target derivation failed: ${error instanceof Error ? error.message : String(error)}`;
1009
+ const deliveryId = `invalid-target-${crypto.createHash("sha256").update(`${agentId}\0${messageId}`).digest("hex").slice(0, 24)}`;
1010
+ telemetry?.delivery(agentId, messageId, "error");
1011
+ emit({ type: "delivery", agentId, deliveryId, messageId, status: "error", reason });
1012
+ return { status: "error", deliveryId, reason, retryable: false };
1013
+ }
862
1014
  reconcileExternalConsumption(agent);
863
1015
  const existingId = agent.byMessage.get(messageId);
864
1016
  if (existingId) {
865
1017
  const existing = agent.records.get(existingId);
866
1018
  if (existing?.status === "error") {
867
- existing.input.attempt += 1;
1019
+ if (existing.target && existing.target !== target)
1020
+ return quarantineRecord(agent, existing, "delivery_target_conflict");
1021
+ existing.target = target;
1022
+ if (typeof envelope.wake_reason === "string" && envelope.wake_reason)
1023
+ existing.wakeReason = envelope.wake_reason;
1024
+ else
1025
+ delete existing.wakeReason;
1026
+ const priorAttempt = existing.input && Number.isSafeInteger(existing.input.attempt) ? existing.input.attempt : 0;
1027
+ existing.input = existing.input && typeof existing.input === "object"
1028
+ ? { ...existing.input, attempt: priorAttempt + 1 }
1029
+ : { inputId: existing.deliveryId, deliveryId: existing.deliveryId, kind: "wake", text: "", attempt: priorAttempt + 1 };
868
1030
  delete existing.reason;
869
1031
  delete existing.retryable;
870
1032
  setRecord(agent, existing, "pending");
@@ -885,11 +1047,12 @@ export function createRuntimeHost(options) {
885
1047
  }
886
1048
  const deliveryId = crypto.randomUUID();
887
1049
  const busy = agent.busy || agent.submitting;
1050
+ const wakeReason = typeof envelope.wake_reason === "string" ? envelope.wake_reason : undefined;
888
1051
  const input = { inputId: deliveryId, deliveryId, kind: busy ? "inbox_update" : "wake",
889
- text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId,
890
- ...(typeof envelope.target === "string" ? { target: envelope.target } : {}),
891
- ...(typeof envelope.wake_reason === "string" ? { wakeReason: envelope.wake_reason } : {}) }), attempt: 0 };
892
- const record = { deliveryId, messageId, status: "pending", input, updatedAt: now() };
1052
+ text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId, target,
1053
+ ...(wakeReason ? { wakeReason } : {}) }), attempt: 0 };
1054
+ const record = { deliveryId, messageId, status: "pending", input, target,
1055
+ ...(wakeReason ? { wakeReason } : {}), updatedAt: now() };
893
1056
  agent.records.set(deliveryId, record);
894
1057
  agent.byMessage.set(messageId, deliveryId);
895
1058
  persist(agent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [