larkin 0.3.3 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -33
- package/assets/readme/larkin-hero.svg +111 -0
- package/dist/agent/agent-state-store.cjs +321 -22
- package/dist/agent/agent-state-store.mjs +320 -22
- package/dist/agent/agent-transport.cjs +6 -4
- package/dist/agent/agent-transport.mjs +6 -4
- package/dist/agent/host-reminder-orchestrator.cjs +9 -3
- package/dist/agent/host-reminder-orchestrator.mjs +9 -3
- package/dist/agent/inbox-projection.cjs +114 -23
- package/dist/agent/inbox-projection.mjs +112 -23
- package/dist/app/cli.mjs +3 -1
- package/dist/app/local-control.mjs +108 -2
- package/dist/app/runtime-process.mjs +4 -0
- package/dist/app/session-cli.mjs +55 -20
- package/dist/feishu/document-comment.cjs +2 -1
- package/dist/feishu/document-comment.mjs +2 -1
- package/dist/feishu/host-business-state.cjs +7 -2
- package/dist/feishu/host-business-state.mjs +7 -2
- package/dist/feishu/host-shell.cjs +138 -32
- package/dist/feishu/host-shell.mjs +138 -32
- package/dist/runtime/pi-compaction-recovery.cjs +623 -0
- package/dist/runtime/pi-compaction-recovery.mjs +602 -0
- package/dist/runtime/pi-provider-config.cjs +2 -2
- package/dist/runtime/pi-provider-config.mjs +2 -2
- package/dist/runtime/pi-rpc-client.cjs +39 -22
- package/dist/runtime/pi-rpc-client.mjs +39 -22
- package/dist/runtime/provider-error-classifier.cjs +39 -0
- package/dist/runtime/provider-error-classifier.mjs +34 -0
- package/dist/runtime/runtime-adapters.mjs +82 -19
- package/dist/runtime/runtime-host.cjs +663 -12
- package/dist/runtime/runtime-host.mjs +661 -11
- package/package.json +1 -1
|
@@ -4,7 +4,8 @@ import * as path from "node:path";
|
|
|
4
4
|
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
|
-
import { targetKeyOfInboxEnvelope } from "./inbox-projection.mjs";
|
|
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
|
]);
|
|
@@ -103,7 +104,9 @@ function normalizeInboxState(value) {
|
|
|
103
104
|
continue;
|
|
104
105
|
const row = candidate;
|
|
105
106
|
if (typeof row.target === "string" && row.target && validSequence(row.seq))
|
|
106
|
-
state.messages[messageId] = {
|
|
107
|
+
state.messages[messageId] = {
|
|
108
|
+
target: row.target, seq: row.seq, ...(typeof row.kind === "string" && row.kind ? { kind: row.kind } : {}),
|
|
109
|
+
};
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
if (raw.drafts && typeof raw.drafts === "object" && !Array.isArray(raw.drafts)) {
|
|
@@ -158,6 +161,14 @@ function validInboxLockOwner(value) {
|
|
|
158
161
|
&& typeof owner.processStartToken === "string" && !!owner.processStartToken
|
|
159
162
|
&& typeof owner.nonce === "string" && /^[0-9a-f-]{36}$/i.test(owner.nonce);
|
|
160
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
|
+
}
|
|
161
172
|
export class AgentStateStore {
|
|
162
173
|
paths;
|
|
163
174
|
boundary;
|
|
@@ -424,13 +435,14 @@ export class AgentStateStore {
|
|
|
424
435
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
425
436
|
throw new Error("Inbox envelope must be an object");
|
|
426
437
|
const input = value;
|
|
427
|
-
const target =
|
|
438
|
+
const target = targetKeyOfInboxEnvelope(input);
|
|
428
439
|
const current = state.targets[target] ?? { latest_received_seq: 0, model_seen_seq: 0 };
|
|
429
440
|
const requested = validSequence(input.target_seq) ? input.target_seq : 0;
|
|
430
441
|
const targetSeq = requested > current.latest_received_seq ? requested : current.latest_received_seq + 1;
|
|
431
442
|
state.targets[target] = { ...current, latest_received_seq: targetSeq };
|
|
432
443
|
if (typeof input.message_id === "string" && input.message_id) {
|
|
433
|
-
state.messages[input.message_id] = { target, seq: targetSeq
|
|
444
|
+
state.messages[input.message_id] = { target, seq: targetSeq,
|
|
445
|
+
...(typeof input.kind === "string" && input.kind ? { kind: input.kind } : {}) };
|
|
434
446
|
const messageIds = Object.keys(state.messages);
|
|
435
447
|
for (const stale of messageIds.slice(0, Math.max(0, messageIds.length - 2_048)))
|
|
436
448
|
delete state.messages[stale];
|
|
@@ -438,8 +450,9 @@ export class AgentStateStore {
|
|
|
438
450
|
return { ...input, envelope_version: 2, target, target_seq: targetSeq };
|
|
439
451
|
}
|
|
440
452
|
reconcileInboxRows(rows, state) {
|
|
441
|
-
|
|
442
|
-
|
|
453
|
+
const targets = rows.map((row) => targetKeyOfInboxEnvelope(row));
|
|
454
|
+
return rows.map((row, index) => {
|
|
455
|
+
const target = targets[index];
|
|
443
456
|
const existing = typeof row.message_id === "string" ? state.messages[row.message_id] : undefined;
|
|
444
457
|
const current = state.targets[target] ?? { latest_received_seq: 0, model_seen_seq: 0 };
|
|
445
458
|
const targetSeq = validSequence(row.target_seq) ? row.target_seq
|
|
@@ -447,7 +460,8 @@ export class AgentStateStore {
|
|
|
447
460
|
current.latest_received_seq = Math.max(current.latest_received_seq, targetSeq);
|
|
448
461
|
state.targets[target] = current;
|
|
449
462
|
if (typeof row.message_id === "string" && row.message_id)
|
|
450
|
-
state.messages[row.message_id] = { target, seq: targetSeq
|
|
463
|
+
state.messages[row.message_id] = { target, seq: targetSeq,
|
|
464
|
+
...(typeof row.kind === "string" && row.kind ? { kind: row.kind } : {}) };
|
|
451
465
|
return { ...row, envelope_version: 2, target, target_seq: targetSeq };
|
|
452
466
|
});
|
|
453
467
|
}
|
|
@@ -573,23 +587,302 @@ export class AgentStateStore {
|
|
|
573
587
|
else
|
|
574
588
|
append();
|
|
575
589
|
}
|
|
576
|
-
/**
|
|
577
|
-
|
|
590
|
+
/**
|
|
591
|
+
* Append one canonical Inbox envelope and return the exact normalized object
|
|
592
|
+
* serialized to disk. Stable message_id dedupe and locator coherence share the
|
|
593
|
+
* same lock as poll, so HostShell can deliver that very object without a
|
|
594
|
+
* persistence/delivery split.
|
|
595
|
+
*/
|
|
596
|
+
appendCanonicalInboxOnce(value) {
|
|
578
597
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
579
598
|
throw new Error("Inbox envelope must be an object");
|
|
580
|
-
const
|
|
599
|
+
const input = value;
|
|
600
|
+
const messageId = input.message_id;
|
|
581
601
|
if (typeof messageId !== "string" || !messageId)
|
|
582
602
|
throw new Error("Inbox envelope requires message_id");
|
|
603
|
+
const incomingTarget = targetKeyOfInboxEnvelope(input);
|
|
583
604
|
const file = this.file("inbox");
|
|
584
605
|
return this.withInboxLock(file, () => {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if (
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
606
|
+
const state = this.inboxState();
|
|
607
|
+
const matching = this.readNdjson("inbox").filter((row) => row.message_id === messageId);
|
|
608
|
+
if (matching.length > 1)
|
|
609
|
+
throw new Error("Inbox duplicate message_id has multiple pending canonical rows");
|
|
610
|
+
if (matching.length === 1) {
|
|
611
|
+
const existing = matching[0];
|
|
612
|
+
if (targetKeyOfInboxEnvelope(existing) !== incomingTarget) {
|
|
613
|
+
throw new Error("Inbox duplicate message_id conflicts with its canonical target");
|
|
614
|
+
}
|
|
615
|
+
if (existing.target_seq !== undefined && !validSequence(existing.target_seq)) {
|
|
616
|
+
throw new Error("Inbox duplicate message_id has a malformed canonical sequence");
|
|
617
|
+
}
|
|
618
|
+
const known = state.messages[messageId];
|
|
619
|
+
if (known) {
|
|
620
|
+
if (known.target !== incomingTarget)
|
|
621
|
+
throw new Error("Inbox message state conflicts with its canonical target");
|
|
622
|
+
if (validSequence(existing.target_seq) && existing.target_seq !== known.seq) {
|
|
623
|
+
throw new Error("Inbox message state conflicts with its canonical sequence");
|
|
624
|
+
}
|
|
625
|
+
const targetState = state.targets[known.target];
|
|
626
|
+
if (targetState && targetState.model_seen_seq >= known.seq) {
|
|
627
|
+
throw new Error("Inbox pending row conflicts with consumed model-seen state");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return { status: "duplicate_pending", envelope: existing };
|
|
631
|
+
}
|
|
632
|
+
const known = state.messages[messageId];
|
|
633
|
+
if (known) {
|
|
634
|
+
if (known.target !== incomingTarget)
|
|
635
|
+
throw new Error("Inbox duplicate message_id conflicts with consumed canonical target");
|
|
636
|
+
const targetState = state.targets[known.target];
|
|
637
|
+
if (!targetState || targetState.model_seen_seq < known.seq) {
|
|
638
|
+
throw new Error("Inbox state references a missing unconsumed canonical row");
|
|
639
|
+
}
|
|
640
|
+
return { status: "duplicate_consumed", envelope: null };
|
|
641
|
+
}
|
|
642
|
+
return { status: "appended", envelope: this.appendInboxUnlocked(input) };
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/** Append a canonical Inbox envelope once by stable message_id under the shared cross-process lock. */
|
|
646
|
+
appendInboxOnce(value) {
|
|
647
|
+
return this.appendCanonicalInboxOnce(value).status === "appended";
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Resolve one Runtime delivery solely from canonical Inbox row/state. This is
|
|
651
|
+
* the single replay resolver used for startup migration and every retry; it
|
|
652
|
+
* never derives a DM/generic fallback from stale RuntimeInput text.
|
|
653
|
+
*/
|
|
654
|
+
resolveInboxDeliverySource(messageId) {
|
|
655
|
+
if (!messageId)
|
|
656
|
+
return { status: "missing", code: "canonical_inbox_row_missing" };
|
|
657
|
+
return this.withInboxLock(this.file("inbox"), () => {
|
|
658
|
+
let rows;
|
|
659
|
+
try {
|
|
660
|
+
rows = this.readNdjson("inbox");
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return { status: "invalid", code: "canonical_inbox_malformed" };
|
|
664
|
+
}
|
|
665
|
+
const matching = rows.filter((row) => row?.message_id === messageId);
|
|
666
|
+
if (matching.length > 1)
|
|
667
|
+
return { status: "invalid", code: "duplicate_message_id" };
|
|
668
|
+
const state = this.inboxState();
|
|
669
|
+
const known = state.messages[messageId];
|
|
670
|
+
if (matching.length === 1) {
|
|
671
|
+
const envelope = matching[0];
|
|
672
|
+
let target;
|
|
673
|
+
try {
|
|
674
|
+
target = targetKeyOfInboxEnvelope(envelope);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
return { status: "invalid", code: "canonical_inbox_malformed" };
|
|
678
|
+
}
|
|
679
|
+
if (envelope.target_seq !== undefined && !validSequence(envelope.target_seq)) {
|
|
680
|
+
return { status: "invalid", code: "canonical_inbox_malformed" };
|
|
681
|
+
}
|
|
682
|
+
if (known) {
|
|
683
|
+
const targetState = state.targets[known.target];
|
|
684
|
+
if (known.target !== target || !validSequence(known.seq)
|
|
685
|
+
|| (validSequence(envelope.target_seq) && envelope.target_seq !== known.seq)
|
|
686
|
+
|| Boolean(targetState && targetState.model_seen_seq >= known.seq)) {
|
|
687
|
+
return { status: "invalid", code: "inbox_state_conflict" };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return { status: "pending", target, envelope };
|
|
691
|
+
}
|
|
692
|
+
if (!known)
|
|
693
|
+
return { status: "missing", code: "canonical_inbox_row_missing" };
|
|
694
|
+
if (!isCanonicalInboxTarget(known.target) || !validSequence(known.seq)) {
|
|
695
|
+
return { status: "invalid", code: "inbox_state_conflict" };
|
|
696
|
+
}
|
|
697
|
+
const targetState = state.targets[known.target];
|
|
698
|
+
if (targetState && targetState.model_seen_seq >= known.seq)
|
|
699
|
+
return { status: "consumed", target: known.target };
|
|
700
|
+
return { status: "missing", code: "canonical_inbox_row_missing" };
|
|
591
701
|
});
|
|
592
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
|
+
}
|
|
593
886
|
/**
|
|
594
887
|
* Recoverably bridge an outbox wake into the canonical Inbox. A durable
|
|
595
888
|
* Runtime ledger is authoritative after the Inbox has been drained, so a
|
|
@@ -601,6 +894,7 @@ export class AgentStateStore {
|
|
|
601
894
|
const messageId = value.message_id;
|
|
602
895
|
if (typeof messageId !== "string" || !messageId)
|
|
603
896
|
throw new Error("Inbox envelope requires message_id");
|
|
897
|
+
targetKeyOfInboxEnvelope(value);
|
|
604
898
|
const file = this.file("inbox");
|
|
605
899
|
return this.withInboxLock(file, () => {
|
|
606
900
|
const ledger = this.readJson("runtimeDeliveries", { version: 1, records: [] });
|
|
@@ -649,6 +943,9 @@ export class AgentStateStore {
|
|
|
649
943
|
* this transaction is active waits and becomes part of the next batch.
|
|
650
944
|
*/
|
|
651
945
|
pollInbox(options = {}) {
|
|
946
|
+
if (options.target && !isCanonicalInboxTarget(options.target)) {
|
|
947
|
+
throw new Error(`Invalid canonical Inbox poll target ${JSON.stringify(options.target)}`);
|
|
948
|
+
}
|
|
652
949
|
const file = this.file("inbox");
|
|
653
950
|
return this.withInboxLock(file, () => {
|
|
654
951
|
let rawRows;
|
|
@@ -667,7 +964,7 @@ export class AgentStateStore {
|
|
|
667
964
|
const envelopes = [];
|
|
668
965
|
const remaining = [];
|
|
669
966
|
for (const row of rows) {
|
|
670
|
-
const selectedTarget =
|
|
967
|
+
const selectedTarget = targetKeyOfInboxEnvelope(row);
|
|
671
968
|
if (envelopes.length < limit && (!options.target || options.target === selectedTarget))
|
|
672
969
|
envelopes.push(row);
|
|
673
970
|
else
|
|
@@ -676,7 +973,7 @@ export class AgentStateStore {
|
|
|
676
973
|
const pendingCount = remaining.reduce((count, row) => {
|
|
677
974
|
if (!options.target)
|
|
678
975
|
return count + 1;
|
|
679
|
-
const rowTarget =
|
|
976
|
+
const rowTarget = targetKeyOfInboxEnvelope(row);
|
|
680
977
|
return count + (rowTarget === options.target ? 1 : 0);
|
|
681
978
|
}, 0);
|
|
682
979
|
if (!envelopes.length)
|
|
@@ -710,7 +1007,7 @@ export class AgentStateStore {
|
|
|
710
1007
|
}
|
|
711
1008
|
let seenThroughSeq = null;
|
|
712
1009
|
for (const envelope of envelopes) {
|
|
713
|
-
const target =
|
|
1010
|
+
const target = targetKeyOfInboxEnvelope(envelope);
|
|
714
1011
|
const targetSeq = Number(envelope.target_seq);
|
|
715
1012
|
const targetState = state.targets[target] ?? { latest_received_seq: targetSeq, model_seen_seq: 0 };
|
|
716
1013
|
if (validSequence(targetSeq)) {
|
|
@@ -731,11 +1028,12 @@ export class AgentStateStore {
|
|
|
731
1028
|
resolveInboxMessageTarget(messageId) {
|
|
732
1029
|
return this.withInboxLock(this.file("inbox"), () => {
|
|
733
1030
|
const state = this.inboxState();
|
|
734
|
-
const known = state.messages[messageId]
|
|
1031
|
+
const known = state.messages[messageId];
|
|
735
1032
|
if (known)
|
|
736
|
-
return known
|
|
1033
|
+
return targetKeyOfInboxEnvelope({ message_id: messageId, target: known.target,
|
|
1034
|
+
...(known.kind ? { kind: known.kind } : {}) });
|
|
737
1035
|
const row = this.readNdjson("inbox").find((candidate) => candidate.message_id === messageId);
|
|
738
|
-
return row ?
|
|
1036
|
+
return row ? targetKeyOfInboxEnvelope(row) : null;
|
|
739
1037
|
});
|
|
740
1038
|
}
|
|
741
1039
|
listInboxDrafts() {
|
|
@@ -87,12 +87,14 @@ function createAgentTransport(env = process.env) {
|
|
|
87
87
|
// —— legacy events/inbox characterization:从 host 写的 inbox 文件读入站正文(读后清空=已读)——
|
|
88
88
|
// 必须严格对齐 agentApiEventsResponseSchema,否则 CLI 报 INVALID_JSON_RESPONSE,Claude 读不到消息。
|
|
89
89
|
if (p.includes("/events") || p.includes("/inbox")) {
|
|
90
|
-
let envelopes = [];
|
|
91
90
|
try {
|
|
92
|
-
envelopes = stateStore.pollInbox().envelopes;
|
|
91
|
+
const envelopes = stateStore.pollInbox().envelopes;
|
|
92
|
+
return { ok: true, status: 200, data: projectInboxEvents(envelopes) };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return { ok: false, status: 409,
|
|
96
|
+
error: `Inbox consumption rejected; persisted rows were left unchanged: ${error instanceof Error ? error.message : String(error)}` };
|
|
93
97
|
}
|
|
94
|
-
catch { /* preserve legacy: malformed/unreadable inbox returns empty and is not cleared */ }
|
|
95
|
-
return { ok: true, status: 200, data: projectInboxEvents(envelopes) };
|
|
96
98
|
}
|
|
97
99
|
// —— legacy history characterization:接飞书群历史 im +chat-messages-list ——
|
|
98
100
|
// 需 bot 有 im:message:readonly;缺 scope 则优雅降级为空(不让 Claude 崩)。
|
|
@@ -80,12 +80,14 @@ export function createAgentTransport(env = process.env) {
|
|
|
80
80
|
// —— legacy events/inbox characterization:从 host 写的 inbox 文件读入站正文(读后清空=已读)——
|
|
81
81
|
// 必须严格对齐 agentApiEventsResponseSchema,否则 CLI 报 INVALID_JSON_RESPONSE,Claude 读不到消息。
|
|
82
82
|
if (p.includes("/events") || p.includes("/inbox")) {
|
|
83
|
-
let envelopes = [];
|
|
84
83
|
try {
|
|
85
|
-
envelopes = stateStore.pollInbox().envelopes;
|
|
84
|
+
const envelopes = stateStore.pollInbox().envelopes;
|
|
85
|
+
return { ok: true, status: 200, data: projectInboxEvents(envelopes) };
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return { ok: false, status: 409,
|
|
89
|
+
error: `Inbox consumption rejected; persisted rows were left unchanged: ${error instanceof Error ? error.message : String(error)}` };
|
|
86
90
|
}
|
|
87
|
-
catch { /* preserve legacy: malformed/unreadable inbox returns empty and is not cleared */ }
|
|
88
|
-
return { ok: true, status: 200, data: projectInboxEvents(envelopes) };
|
|
89
91
|
}
|
|
90
92
|
// —— legacy history characterization:接飞书群历史 im +chat-messages-list ——
|
|
91
93
|
// 需 bot 有 im:message:readonly;缺 scope 则优雅降级为空(不让 Claude 崩)。
|
|
@@ -37,6 +37,12 @@ exports.HostReminderOrchestrator = void 0;
|
|
|
37
37
|
const fs = __importStar(require("node:fs"));
|
|
38
38
|
const host_business_state_js_1 = require("../feishu/host-business-state.cjs");
|
|
39
39
|
const defaultReminderStore = __importStar(require("./reminder-store.cjs"));
|
|
40
|
+
const inbox_projection_js_1 = require("./inbox-projection.cjs");
|
|
41
|
+
function withCanonicalTarget(envelope) {
|
|
42
|
+
const input = envelope;
|
|
43
|
+
const target = (0, inbox_projection_js_1.targetKeyOfInboxEnvelope)(input);
|
|
44
|
+
return input.target === target ? envelope : { ...envelope, target };
|
|
45
|
+
}
|
|
40
46
|
class HostReminderOrchestrator {
|
|
41
47
|
options;
|
|
42
48
|
lastSnapshot = new Map();
|
|
@@ -99,14 +105,14 @@ class HostReminderOrchestrator {
|
|
|
99
105
|
}
|
|
100
106
|
deliver(agent, reminder, overdueMs) {
|
|
101
107
|
const recurrence = reminder.repeat ? this.store.parseRepeat(reminder.repeat, reminder.tz) : null;
|
|
102
|
-
const envelope = this.options.envelopeProjector.createReminderEnvelope(agent.agentId, {
|
|
108
|
+
const envelope = withCanonicalTarget(this.options.envelopeProjector.createReminderEnvelope(agent.agentId, {
|
|
103
109
|
reminderId: reminder.reminderId,
|
|
104
110
|
title: reminder.title,
|
|
105
111
|
repeat: reminder.repeat ? String(reminder.repeat) : null,
|
|
106
112
|
fireAt: reminder.fireAt,
|
|
107
113
|
msgRef: reminder.msgRef,
|
|
108
114
|
channel: reminder.channel ? String(reminder.channel) : null,
|
|
109
|
-
}, overdueMs, reminder.repeat && recurrence && !("error" in recurrence) ? (recurrence.description || null) : null);
|
|
115
|
+
}, overdueMs, reminder.repeat && recurrence && !("error" in recurrence) ? (recurrence.description || null) : null));
|
|
110
116
|
try {
|
|
111
117
|
this.options.stateStore(agent).appendNdjson("inbox", envelope);
|
|
112
118
|
}
|
|
@@ -268,7 +274,7 @@ class HostReminderOrchestrator {
|
|
|
268
274
|
this.redelivered.add(agent.agentId);
|
|
269
275
|
return;
|
|
270
276
|
}
|
|
271
|
-
const envelope = existing ?? this.options.envelopeProjector.createRedeliveryEnvelope(agent.agentId, wakeCount);
|
|
277
|
+
const envelope = withCanonicalTarget(existing ?? this.options.envelopeProjector.createRedeliveryEnvelope(agent.agentId, wakeCount));
|
|
272
278
|
if (!existing) {
|
|
273
279
|
try {
|
|
274
280
|
this.options.stateStore(agent).appendNdjson("inbox", envelope);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { countWakeEnvelopes } from "../feishu/host-business-state.mjs";
|
|
3
3
|
import * as defaultReminderStore from "./reminder-store.mjs";
|
|
4
|
+
import { targetKeyOfInboxEnvelope } from "./inbox-projection.mjs";
|
|
5
|
+
function withCanonicalTarget(envelope) {
|
|
6
|
+
const input = envelope;
|
|
7
|
+
const target = targetKeyOfInboxEnvelope(input);
|
|
8
|
+
return input.target === target ? envelope : { ...envelope, target };
|
|
9
|
+
}
|
|
4
10
|
export class HostReminderOrchestrator {
|
|
5
11
|
options;
|
|
6
12
|
lastSnapshot = new Map();
|
|
@@ -63,14 +69,14 @@ export class HostReminderOrchestrator {
|
|
|
63
69
|
}
|
|
64
70
|
deliver(agent, reminder, overdueMs) {
|
|
65
71
|
const recurrence = reminder.repeat ? this.store.parseRepeat(reminder.repeat, reminder.tz) : null;
|
|
66
|
-
const envelope = this.options.envelopeProjector.createReminderEnvelope(agent.agentId, {
|
|
72
|
+
const envelope = withCanonicalTarget(this.options.envelopeProjector.createReminderEnvelope(agent.agentId, {
|
|
67
73
|
reminderId: reminder.reminderId,
|
|
68
74
|
title: reminder.title,
|
|
69
75
|
repeat: reminder.repeat ? String(reminder.repeat) : null,
|
|
70
76
|
fireAt: reminder.fireAt,
|
|
71
77
|
msgRef: reminder.msgRef,
|
|
72
78
|
channel: reminder.channel ? String(reminder.channel) : null,
|
|
73
|
-
}, overdueMs, reminder.repeat && recurrence && !("error" in recurrence) ? (recurrence.description || null) : null);
|
|
79
|
+
}, overdueMs, reminder.repeat && recurrence && !("error" in recurrence) ? (recurrence.description || null) : null));
|
|
74
80
|
try {
|
|
75
81
|
this.options.stateStore(agent).appendNdjson("inbox", envelope);
|
|
76
82
|
}
|
|
@@ -232,7 +238,7 @@ export class HostReminderOrchestrator {
|
|
|
232
238
|
this.redelivered.add(agent.agentId);
|
|
233
239
|
return;
|
|
234
240
|
}
|
|
235
|
-
const envelope = existing ?? this.options.envelopeProjector.createRedeliveryEnvelope(agent.agentId, wakeCount);
|
|
241
|
+
const envelope = withCanonicalTarget(existing ?? this.options.envelopeProjector.createRedeliveryEnvelope(agent.agentId, wakeCount));
|
|
236
242
|
if (!existing) {
|
|
237
243
|
try {
|
|
238
244
|
this.options.stateStore(agent).appendNdjson("inbox", envelope);
|