@code-yeongyu/senpi 2026.10.18 → 2026.10.19
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/CHANGELOG.md +47 -0
- package/dist/bundle/chunks/{anthropic-messages-DDBWM4JZ.js → anthropic-messages-EMTBHJMM.js} +2 -2
- package/dist/bundle/chunks/{args-2MYPVFPH.js → args-LWR7VFKS.js} +1 -1
- package/dist/bundle/chunks/{azure-openai-responses-3UG5TQU7.js → azure-openai-responses-SNWKFDXK.js} +1 -1
- package/dist/bundle/chunks/chatgpt-subscription.js +1 -1
- package/dist/bundle/chunks/{chunk-SOF44GFZ.js → chunk-35M5MVUZ.js} +149 -145
- package/dist/bundle/chunks/chunk-3DNYAO3P.js +2 -0
- package/dist/bundle/chunks/{chunk-T2MHSEQ2.js → chunk-5AOKDC6G.js} +1 -1
- package/dist/bundle/chunks/{chunk-MYXNQ7YZ.js → chunk-7QPFFAJT.js} +3 -3
- package/dist/bundle/chunks/{chunk-R36U2DJC.js → chunk-AUZXSLZN.js} +1 -1
- package/dist/bundle/chunks/{chunk-PMTG4YNS.js → chunk-BOCN744P.js} +1 -1
- package/dist/bundle/chunks/{chunk-XTAWER7O.js → chunk-CICJOV6I.js} +1 -1
- package/dist/bundle/chunks/{chunk-4UCLDXEF.js → chunk-FM7NDZR5.js} +1 -1
- package/dist/bundle/chunks/{chunk-TBRWPNA4.js → chunk-FSENE7JK.js} +2 -2
- package/dist/bundle/chunks/{chunk-TUD7XRIS.js → chunk-G7LLAQXI.js} +3 -3
- package/dist/bundle/chunks/{chunk-BGD3MZ47.js → chunk-GOKAWZUO.js} +1 -1
- package/dist/bundle/chunks/{chunk-XC6UG5SI.js → chunk-IM6ZWYTW.js} +2 -2
- package/dist/bundle/chunks/{chunk-KHATAB7P.js → chunk-KM4FMQII.js} +1 -1
- package/dist/bundle/chunks/chunk-LAWS3FYQ.js +11 -0
- package/dist/bundle/chunks/{chunk-W6YQYGE2.js → chunk-MVNVDGYF.js} +1 -1
- package/dist/bundle/chunks/{chunk-W4BO6S7F.js → chunk-PMGEQYEP.js} +2 -2
- package/dist/bundle/chunks/chunk-QGJLZ6BI.js +2 -0
- package/dist/bundle/chunks/{chunk-5RQNCWS2.js → chunk-QJ6MUFXY.js} +1 -1
- package/dist/bundle/chunks/{chunk-57Q7Q627.js → chunk-QRGAMVEE.js} +1 -1
- package/dist/bundle/chunks/{chunk-W2257FYP.js → chunk-QXEWAKXA.js} +1 -1
- package/dist/bundle/chunks/{chunk-UUMKAA7S.js → chunk-RL5QMGQD.js} +9 -9
- package/dist/bundle/chunks/{chunk-D2UQYZVK.js → chunk-SHKXJWK3.js} +1 -1
- package/dist/bundle/chunks/chunk-T36LJ6MW.js +3 -0
- package/dist/bundle/chunks/{chunk-S4V72F76.js → chunk-VCD7FD64.js} +1 -1
- package/dist/bundle/chunks/{chunk-MYPTZCPF.js → chunk-VEKNDMUA.js} +1 -1
- package/dist/bundle/chunks/{chunk-IRBAGET4.js → chunk-VPV7ADF3.js} +4 -4
- package/dist/bundle/chunks/{chunk-6DDAEDY7.js → chunk-WEHIEACU.js} +1 -1
- package/dist/bundle/chunks/{chunk-C2HSPFXN.js → chunk-WQLMQGOW.js} +1 -1
- package/dist/bundle/chunks/{chunk-QZIKKUUP.js → chunk-X6TWPBI6.js} +2 -2
- package/dist/bundle/chunks/{chunk-VYWODOAM.js → chunk-YUMRZIKJ.js} +6 -6
- package/dist/bundle/chunks/chunk-ZPSJQHDK.js +395 -0
- package/dist/bundle/chunks/{cli-main-MVVPTGMS.js → cli-main-EABMHWFV.js} +1 -1
- package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-H4ZPUBX4.js → cloudflare-workers-ai-system-one-PF7FU63D.js} +1 -1
- package/dist/bundle/chunks/{google-generative-ai-YLAYNQLO.js → google-generative-ai-UFCHYLRE.js} +1 -1
- package/dist/bundle/chunks/{google-vertex-THFXM5R2.js → google-vertex-2FWDY7XX.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-H4ERPCR5.js → help-fast-path-BYWJN6YX.js} +1 -1
- package/dist/bundle/chunks/{host-command-2CMRIKC3.js → host-command-5T34JLBP.js} +1 -1
- package/dist/bundle/chunks/{host-lifecycle-6Y2EKT4U.js → host-lifecycle-GSRIQP6Z.js} +1 -1
- package/dist/bundle/chunks/host-lifecycle.js +5 -5
- package/dist/bundle/chunks/{host-session-command-MVHY4TKA.js → host-session-command-LF6JODPX.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-2ZQ43N73.js → interactive-mode-GMWHBIUC.js} +1 -1
- package/dist/bundle/chunks/{list-tips-AWDAUHF2.js → list-tips-VSWED3PT.js} +1 -1
- package/dist/bundle/chunks/{mistral-conversations-GUGTVRCL.js → mistral-conversations-QHZLDTSP.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-NO4PIOVG.js → multi-session-host-3J7STKGD.js} +6 -6
- package/dist/bundle/chunks/openai-codex-responses-7YOBPZ2N.js +10 -0
- package/dist/bundle/chunks/{openai-completions-I5N2UIEK.js → openai-completions-HSZPYXEA.js} +1 -1
- package/dist/bundle/chunks/{openai-responses-SGLUJVZW.js → openai-responses-7W2KF3W7.js} +1 -1
- package/dist/bundle/chunks/openrouter.js +1 -1
- package/dist/bundle/chunks/{package-manager-cli-HTZQXYNI.js → package-manager-cli-LWQZSWKL.js} +1 -1
- package/dist/bundle/chunks/radius.js +1 -1
- package/dist/bundle/chunks/{rotation-stream-TOOIK43C.js → rotation-stream-QRRWL73X.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-6ZWGPICX.js → rpc-mode-6LSWRKXR.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-2YW4B655.js → schedule-command-7CXMW4WK.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-XC2LPLCD.js → session-control-endpoint-E265WAIQ.js} +1 -1
- package/dist/bundle/chunks/{session-picker-7W7GIRJT.js → session-picker-XRB3LP5W.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +315 -310
- package/dist/bundle/chunks/{typesafe-system-one-VBWWTJTF.js → typesafe-system-one-YA75435G.js} +1 -1
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-run-continuation.d.ts +24 -0
- package/dist/core/agent-run-continuation.js +57 -0
- package/dist/core/agent-session.d.ts +38 -1
- package/dist/core/agent-session.js +122 -13
- package/dist/core/extension-handler-stall-report.d.ts +11 -0
- package/dist/core/extension-handler-stall-report.js +26 -0
- package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +1 -0
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +13 -1
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
- package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/executable.d.ts +5 -0
- package/dist/core/extensions/builtin/anthropic-subscription/executable.js +11 -1
- package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/failover.js +3 -2
- package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -0
- package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +29 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.js +4 -0
- package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +4 -2
- package/dist/core/extensions/handler-budget.d.ts +84 -0
- package/dist/core/extensions/handler-budget.js +136 -0
- package/dist/core/extensions/runner.d.ts +23 -0
- package/dist/core/extensions/runner.js +184 -99
- package/dist/core/footer-data-provider.js +2 -1
- package/dist/core/keybindings.d.ts +1 -1
- package/dist/core/keybindings.js +3 -2
- package/dist/core/model-resolver.js +1 -1
- package/dist/core/pending-input-journal.d.ts +6 -1
- package/dist/core/pending-input-journal.js +90 -34
- package/dist/core/pending-input-public.d.ts +11 -0
- package/dist/core/pending-input-public.js +10 -0
- package/dist/core/pending-input-session-port.d.ts +15 -0
- package/dist/core/pending-input-session-port.js +93 -0
- package/dist/core/pending-input-session.d.ts +108 -0
- package/dist/core/pending-input-session.js +220 -0
- package/dist/core/pending-input-store.d.ts +32 -1
- package/dist/core/pending-input-store.js +74 -26
- package/dist/core/pending-input-types.d.ts +25 -4
- package/dist/core/pending-input.js +36 -18
- package/dist/core/session-entry-file.js +1 -0
- package/dist/core/session-log.js +1 -1
- package/dist/core/session-manager.d.ts +1 -0
- package/dist/core/session-manager.js +5 -1
- package/dist/core/session-move/bundle.d.ts +39 -0
- package/dist/core/session-move/bundle.js +108 -0
- package/dist/core/session-move/export.d.ts +18 -0
- package/dist/core/session-move/export.js +83 -0
- package/dist/core/session-move/finish.d.ts +24 -0
- package/dist/core/session-move/finish.js +81 -0
- package/dist/core/session-move/import.d.ts +32 -0
- package/dist/core/session-move/import.js +124 -0
- package/dist/core/session-move/move-ledger.d.ts +47 -0
- package/dist/core/session-move/move-ledger.js +151 -0
- package/dist/core/session-move/ownership.d.ts +61 -0
- package/dist/core/session-move/ownership.js +89 -0
- package/dist/core/session-move/requeue.d.ts +8 -0
- package/dist/core/session-move/requeue.js +18 -0
- package/dist/core/session-move/write-guard.d.ts +22 -0
- package/dist/core/session-move/write-guard.js +44 -0
- package/dist/core/settings-manager.d.ts +13 -0
- package/dist/core/settings-manager.js +17 -0
- package/dist/core/turn-deadline.d.ts +2 -0
- package/dist/core/turn-deadline.js +10 -3
- package/dist/modes/interactive/components/ask-user-async-widget.d.ts +7 -0
- package/dist/modes/interactive/components/ask-user-async-widget.js +20 -6
- package/dist/modes/rpc/connection-handler.js +3 -0
- package/dist/modes/rpc/effect-commands.js +3 -4
- package/dist/modes/rpc/host-decision.d.ts +1 -1
- package/dist/modes/rpc/host-decision.js +2 -0
- package/dist/modes/rpc/host-ensure-liveness.d.ts +8 -0
- package/dist/modes/rpc/host-ensure-liveness.js +18 -1
- package/dist/modes/rpc/host-ensure-types.d.ts +2 -0
- package/dist/modes/rpc/host-ensure.js +8 -2
- package/dist/modes/rpc/host-lifecycle-proxy.d.ts +9 -2
- package/dist/modes/rpc/host-lifecycle-proxy.js +36 -8
- package/dist/modes/rpc/host-observe-request.js +1 -0
- package/dist/modes/rpc/multi-session-host.js +3 -33
- package/dist/modes/rpc/rpc-client.d.ts +10 -0
- package/dist/modes/rpc/rpc-client.js +11 -0
- package/dist/modes/rpc/rpc-input-validation.js +8 -0
- package/dist/modes/rpc/rpc-types.d.ts +25 -1
- package/dist/modes/rpc/session-command-activity.js +1 -0
- package/dist/modes/rpc/session-control-actions-data.js +17 -8
- package/dist/modes/rpc/session-held.js +1 -0
- package/dist/modes/rpc/session-release.js +16 -1
- package/dist/modes/rpc/socket-ownership.d.ts +17 -0
- package/dist/modes/rpc/socket-ownership.js +52 -0
- package/dist/modes/rpc/tool-child/admission.d.ts +42 -0
- package/dist/modes/rpc/tool-child/admission.js +81 -0
- package/dist/modes/rpc/tool-child/admission.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/admission.test.js +166 -0
- package/dist/modes/rpc/tool-child/codec.d.ts +21 -0
- package/dist/modes/rpc/tool-child/codec.js +137 -0
- package/dist/modes/rpc/tool-child/codec.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/codec.test.js +234 -0
- package/dist/modes/rpc/tool-child/context.d.ts +42 -0
- package/dist/modes/rpc/tool-child/context.js +23 -0
- package/dist/modes/rpc/tool-child/flow-control.d.ts +24 -0
- package/dist/modes/rpc/tool-child/flow-control.js +80 -0
- package/dist/modes/rpc/tool-child/payload-ref-paths.d.ts +9 -0
- package/dist/modes/rpc/tool-child/payload-ref-paths.js +45 -0
- package/dist/modes/rpc/tool-child/payload-ref.d.ts +58 -0
- package/dist/modes/rpc/tool-child/payload-ref.js +166 -0
- package/dist/modes/rpc/tool-child/payload-ref.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/payload-ref.test.js +254 -0
- package/dist/modes/rpc/tool-child/payload-wire.d.ts +13 -0
- package/dist/modes/rpc/tool-child/payload-wire.js +22 -0
- package/dist/modes/rpc/tool-child/protocol-guard.d.ts +9 -0
- package/dist/modes/rpc/tool-child/protocol-guard.js +128 -0
- package/dist/modes/rpc/tool-child/protocol.d.ts +192 -0
- package/dist/modes/rpc/tool-child/protocol.js +54 -0
- package/dist/utils/wsl.d.ts +2 -2
- package/dist/utils/wsl.js +4 -4
- package/docs/providers.md +2 -0
- package/docs/rpc.md +20 -2
- package/docs/settings.md +1 -0
- package/docs/terminal-setup.md +2 -0
- package/package.json +10 -6
- package/dist/bundle/chunks/chunk-COJK5FDH.js +0 -2
- package/dist/bundle/chunks/chunk-ES3GHZE5.js +0 -395
- package/dist/bundle/chunks/chunk-IDXW4FAO.js +0 -2
- package/dist/bundle/chunks/chunk-PFNBY43W.js +0 -2
- package/dist/bundle/chunks/chunk-SA2ZLWBD.js +0 -11
- package/dist/bundle/chunks/openai-codex-responses-5LRKZCBW.js +0 -10
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { SessionPendingInputJournal } from "./pending-input-journal.js";
|
|
3
|
+
import { createSessionQueuedInputPort } from "./pending-input-session-port.js";
|
|
4
|
+
import { PENDING_INPUT_TRANSITION, PendingInputStore } from "./pending-input-store.js";
|
|
5
|
+
export class ClientMessageIdConflictError extends Error {
|
|
6
|
+
constructor(clientMessageId) {
|
|
7
|
+
super(`Client message id ${clientMessageId} was already used for a different message.`);
|
|
8
|
+
this.code = "client_message_id_conflict";
|
|
9
|
+
this.name = "ClientMessageIdConflictError";
|
|
10
|
+
this.clientMessageId = clientMessageId;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The pending inputs of one AgentSession (senpi#3549): the store, opened on the first identified input, and the
|
|
15
|
+
* agent queue port over it. Input without a client id never touches the store and keeps the legacy queue path.
|
|
16
|
+
*/
|
|
17
|
+
export class SessionPendingInputs {
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.lifetimeId = randomUUID();
|
|
20
|
+
this.attempts = 0;
|
|
21
|
+
/** Bumped synchronously by every clear, so a change can tell a clear it raced from a real divergence. */
|
|
22
|
+
this.clears = 0;
|
|
23
|
+
this.options = options;
|
|
24
|
+
// A session file that already holds pending inputs is recovered now: the agent's first drain must see them.
|
|
25
|
+
const journaled = options.sessionManager
|
|
26
|
+
.getEntries()
|
|
27
|
+
.some((entry) => entry.type === "custom" && entry.customType === PENDING_INPUT_TRANSITION);
|
|
28
|
+
// Opened eagerly, unawaited: every later use awaits this same opening and surfaces its failure, so the
|
|
29
|
+
// eager start only must not report it a second time as an unhandled rejection.
|
|
30
|
+
if (journaled)
|
|
31
|
+
this.store().catch(() => undefined);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* How a restored input (from the RPC admission ledger after a reopen) re-enters the native queue: under the
|
|
35
|
+
* store's rebound queue id while it is still queued; not at all once the store retired it (delivered, removed,
|
|
36
|
+
* failed); `undefined` when the store never held it, so the caller restores it as before.
|
|
37
|
+
*/
|
|
38
|
+
async restoreHandle(clientMessageId) {
|
|
39
|
+
if (this.storeOpening === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const input = (await this.storeOpening).current(clientMessageId);
|
|
42
|
+
if (input === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
return input.phase === "queued" ? { queuedId: input.handle.queuedId } : "retired";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Admits an identified input and, only when it is newly admitted, puts its native copy in the agent queue
|
|
48
|
+
* inside the same gated step, so no drain, edit or removal can run between the two. A repeated submit of the
|
|
49
|
+
* same input is answered from the record (`replayed`) and enqueues nothing.
|
|
50
|
+
*/
|
|
51
|
+
async admit(input, enqueue) {
|
|
52
|
+
const store = await this.store();
|
|
53
|
+
return store.transaction(async (tx) => {
|
|
54
|
+
const result = await tx.submit({
|
|
55
|
+
clientMessageId: input.clientMessageId,
|
|
56
|
+
fingerprint: admissionFingerprint(input),
|
|
57
|
+
text: input.text,
|
|
58
|
+
attachments: (input.images ?? []).map(attachmentFingerprint),
|
|
59
|
+
requestedMode: input.mode,
|
|
60
|
+
});
|
|
61
|
+
if (result?.outcome === "client_message_id_conflict")
|
|
62
|
+
throw new ClientMessageIdConflictError(input.clientMessageId);
|
|
63
|
+
const admitted = tx.current(input.clientMessageId);
|
|
64
|
+
if (result?.outcome === "admitted" && admitted !== undefined)
|
|
65
|
+
enqueue(admitted.handle.queuedId);
|
|
66
|
+
return result;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Edits or removes an identified input: the store transition is journaled first, and only when it answers
|
|
71
|
+
* `edited`/`removed` does `applyNative` change the native queue, inside the same gated step, so no drain
|
|
72
|
+
* can see one without the other. Returns undefined when the store never admitted this input.
|
|
73
|
+
*/
|
|
74
|
+
async change(request, applyNative) {
|
|
75
|
+
if (this.storeOpening === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
const clearsAtStart = this.clears;
|
|
78
|
+
const store = await this.storeOpening;
|
|
79
|
+
return store.transaction(async (tx) => {
|
|
80
|
+
const current = tx.current(request.clientMessageId);
|
|
81
|
+
if (current === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
const target = {
|
|
84
|
+
effectId: request.effectId ?? randomUUID(),
|
|
85
|
+
handle: current.handle,
|
|
86
|
+
expectedRevision: request.expectedRevision ?? current.revision,
|
|
87
|
+
};
|
|
88
|
+
const result = request.text === undefined
|
|
89
|
+
? await tx.apply(request.clientMessageId, { type: "remove", ...target })
|
|
90
|
+
: await tx.apply(request.clientMessageId, {
|
|
91
|
+
type: "edit",
|
|
92
|
+
...target,
|
|
93
|
+
text: request.text,
|
|
94
|
+
attachments: current.attachments,
|
|
95
|
+
});
|
|
96
|
+
const outcome = legacyOutcome(result?.outcome);
|
|
97
|
+
if (outcome !== "edited" && outcome !== "removed")
|
|
98
|
+
return outcome;
|
|
99
|
+
const native = applyNative();
|
|
100
|
+
// A synchronous clear can empty the queue while the change is being journaled; its own record of the
|
|
101
|
+
// clear is queued behind this step and retires the input, so the change simply found nothing. Any other
|
|
102
|
+
// missing native copy is a real divergence (review L1, L-c).
|
|
103
|
+
if (native === "not_found" && this.clears !== clearsAtStart)
|
|
104
|
+
return "not_found";
|
|
105
|
+
if (native !== outcome)
|
|
106
|
+
throw new PendingInputDivergenceError(request.clientMessageId, outcome, native);
|
|
107
|
+
return outcome;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Records an explicit clear (Stop, `clear_queue`, unload with clearQueue) of identified inputs that just left the
|
|
112
|
+
* native queue: each still-queued one is removed by `stop`, so a reopen never queues it again.
|
|
113
|
+
*/
|
|
114
|
+
async recordCleared(clientMessageIds) {
|
|
115
|
+
this.clears += 1;
|
|
116
|
+
if (this.storeOpening === undefined || clientMessageIds.length === 0)
|
|
117
|
+
return;
|
|
118
|
+
const store = await this.storeOpening;
|
|
119
|
+
// One record for the whole clear: it lands or not as a unit, never split around a later entry.
|
|
120
|
+
await store.transaction(async (tx) => {
|
|
121
|
+
const queued = clientMessageIds.filter((clientMessageId) => tx.current(clientMessageId)?.phase === "queued");
|
|
122
|
+
if (queued.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
await tx.applyAll(queued.map((clientMessageId) => ({
|
|
125
|
+
clientMessageId,
|
|
126
|
+
event: { type: "cancel", cause: "stop", consumerFenced: true },
|
|
127
|
+
})));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Pending inputs (queued, sending, failed) and the outcome of each known id, consistent through
|
|
132
|
+
* `throughCursor`: apply it, then only updates with a later cursor. Absence from `inputs` is never an outcome.
|
|
133
|
+
*/
|
|
134
|
+
async snapshot(knownClientMessageIds = []) {
|
|
135
|
+
const store = this.storeOpening === undefined ? undefined : await this.storeOpening;
|
|
136
|
+
const inputs = (store?.snapshot() ?? []).filter((input) => input.state !== "delivered" && input.state !== "removed");
|
|
137
|
+
const outcomes = knownClientMessageIds.map((clientMessageId) => {
|
|
138
|
+
const read = store?.read(clientMessageId);
|
|
139
|
+
return read?.status === "admitted"
|
|
140
|
+
? { clientMessageId, status: "admitted", projection: read.projection }
|
|
141
|
+
: { clientMessageId, status: "not_admitted" };
|
|
142
|
+
});
|
|
143
|
+
return { lifetimeId: this.lifetimeId, throughCursor: store?.throughCursor ?? 0, inputs, outcomes };
|
|
144
|
+
}
|
|
145
|
+
/** The agent's queue port. Before the store opens nothing identified is queued, so it drains natively. */
|
|
146
|
+
queuedInputPort() {
|
|
147
|
+
return {
|
|
148
|
+
claim: async (queue, drain) => (await this.openPort())?.claim(queue, drain) ?? drain(),
|
|
149
|
+
commitConsumption: async (queue, messages) => (await this.openPort())?.commitConsumption(queue, messages),
|
|
150
|
+
release: async (queue, messages, giveBack) => {
|
|
151
|
+
const port = await this.openPort();
|
|
152
|
+
if (port === undefined)
|
|
153
|
+
giveBack.restore();
|
|
154
|
+
else
|
|
155
|
+
await port.release(queue, messages, giveBack);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
store() {
|
|
160
|
+
this.storeOpening ??= PendingInputStore.open({
|
|
161
|
+
journal: this.options.journal ?? new SessionPendingInputJournal(this.options.sessionManager),
|
|
162
|
+
lifetimeId: this.lifetimeId,
|
|
163
|
+
nextQueuedId: this.options.nextQueuedId,
|
|
164
|
+
// The agent port commits consumption before any provider sees an input, so an attempt left `sending` by a
|
|
165
|
+
// crash was never sent and returns to the queue under its id (T28).
|
|
166
|
+
uncommittedAttemptsAreUnsent: true,
|
|
167
|
+
}).then((store) => {
|
|
168
|
+
const onChange = this.options.onChange;
|
|
169
|
+
if (onChange !== undefined)
|
|
170
|
+
store.subscribe((projections, cursor) => onChange({ lifetimeId: this.lifetimeId, cursor, changes: projections }));
|
|
171
|
+
return store;
|
|
172
|
+
});
|
|
173
|
+
return this.storeOpening;
|
|
174
|
+
}
|
|
175
|
+
async openPort() {
|
|
176
|
+
if (this.storeOpening === undefined)
|
|
177
|
+
return undefined;
|
|
178
|
+
const store = await this.storeOpening;
|
|
179
|
+
this.port ??= createSessionQueuedInputPort({
|
|
180
|
+
store,
|
|
181
|
+
ownerId: `${process.pid}:${this.lifetimeId}`,
|
|
182
|
+
writerGeneration: 1,
|
|
183
|
+
nextAttemptId: () => `${this.lifetimeId}:${++this.attempts}`,
|
|
184
|
+
});
|
|
185
|
+
return this.port;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function admissionFingerprint(input) {
|
|
189
|
+
const images = (input.images ?? []).map(attachmentFingerprint);
|
|
190
|
+
return sha256(JSON.stringify([input.mode, input.text, images]));
|
|
191
|
+
}
|
|
192
|
+
function attachmentFingerprint(image) {
|
|
193
|
+
return `${image.mimeType}:${sha256(image.data)}`;
|
|
194
|
+
}
|
|
195
|
+
function sha256(value) {
|
|
196
|
+
return createHash("sha256").update(value).digest("hex");
|
|
197
|
+
}
|
|
198
|
+
/** The store changed an input that the native queue no longer held: the two views disagree and must not be hidden. */
|
|
199
|
+
export class PendingInputDivergenceError extends Error {
|
|
200
|
+
constructor(clientMessageId, recorded, native) {
|
|
201
|
+
super(`Queued input ${clientMessageId} was ${recorded} in its record but ${native} in the queue.`);
|
|
202
|
+
this.code = "pending_input_divergence";
|
|
203
|
+
this.name = "PendingInputDivergenceError";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** `sending` keeps the legacy spelling `already_delivered` (the handoff began); the snapshot says `sending`. */
|
|
207
|
+
function legacyOutcome(outcome) {
|
|
208
|
+
switch (outcome) {
|
|
209
|
+
case "edited":
|
|
210
|
+
case "removed":
|
|
211
|
+
case "conflict":
|
|
212
|
+
case "already_delivered":
|
|
213
|
+
return outcome;
|
|
214
|
+
case "sending":
|
|
215
|
+
return "already_delivered";
|
|
216
|
+
default:
|
|
217
|
+
return "not_found";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=pending-input-session.js.map
|
|
@@ -7,6 +7,8 @@ export interface PendingInputJournalRecord {
|
|
|
7
7
|
readonly cursor: number;
|
|
8
8
|
readonly input: PendingInput;
|
|
9
9
|
readonly result?: PendingInputOperationResult;
|
|
10
|
+
/** Further inputs changed in the same step (a consumer's batch), recorded atomically with `input`. */
|
|
11
|
+
readonly batch?: readonly PendingInput[];
|
|
10
12
|
}
|
|
11
13
|
export interface PendingInputJournal {
|
|
12
14
|
append(record: PendingInputJournalRecord): Promise<void>;
|
|
@@ -18,13 +20,27 @@ export interface PendingInputStoreOptions {
|
|
|
18
20
|
readonly nextQueuedId: () => string;
|
|
19
21
|
readonly dispatch?: (token: DeliveryToken, input: PendingInput) => void;
|
|
20
22
|
readonly restoreToDraft?: (text: string) => void;
|
|
23
|
+
/** Passed to every reopen rebind; see the `rebind` event (senpi#3549). */
|
|
24
|
+
readonly uncommittedAttemptsAreUnsent?: boolean;
|
|
21
25
|
/**
|
|
22
26
|
* Receives a listener's throw; an owner should always supply it, since the default rethrow is uncaught. A committed transition is not undone by a failing observer, and its dispatch
|
|
23
27
|
* still runs; by default the error is rethrown on a later microtask so it is never silently lost.
|
|
24
28
|
*/
|
|
25
29
|
readonly onListenerError?: (error: unknown) => void;
|
|
26
30
|
}
|
|
27
|
-
|
|
31
|
+
/** Called after each committed transition with its projection and the journal cursor it was written at. */
|
|
32
|
+
export type PendingInputListener = (projections: readonly PendingInputProjection[], cursor: number) => void;
|
|
33
|
+
/** Transitions inside one gated step: each is committed directly, since the caller already holds the gate. */
|
|
34
|
+
export interface PendingInputTransaction {
|
|
35
|
+
submit(event: Omit<SubmitEvent, "type" | "handle">): Promise<PendingInputOperationResult | undefined>;
|
|
36
|
+
apply(clientMessageId: string, event: Exclude<PendingInputEvent, SubmitEvent>): Promise<PendingInputOperationResult | undefined>;
|
|
37
|
+
/** Applies every change in one journal record: all of them land or none does. */
|
|
38
|
+
applyAll(changes: readonly {
|
|
39
|
+
readonly clientMessageId: string;
|
|
40
|
+
readonly event: Exclude<PendingInputEvent, SubmitEvent>;
|
|
41
|
+
}[]): Promise<(PendingInputOperationResult | undefined)[]>;
|
|
42
|
+
current(clientMessageId: string): PendingInput | undefined;
|
|
43
|
+
}
|
|
28
44
|
export type PendingInputRead = {
|
|
29
45
|
readonly status: "admitted";
|
|
30
46
|
readonly projection: PendingInputProjection;
|
|
@@ -62,11 +78,26 @@ export declare class PendingInputStore {
|
|
|
62
78
|
apply(clientMessageId: string, event: Exclude<PendingInputEvent, SubmitEvent>): Promise<PendingInputOperationResult | undefined>;
|
|
63
79
|
/** Explicit Stop over the inputs the caller saw: each named revision is removed or reported, never guessed. */
|
|
64
80
|
stop(items: readonly StopItem[], consumerFenced: boolean): Promise<StopReceipt>;
|
|
81
|
+
/**
|
|
82
|
+
* Runs `work` under the gate every command shares, so a consumer can change its native buffer and the
|
|
83
|
+
* journal in one step that no edit, removal or other consumer can interleave with.
|
|
84
|
+
*/
|
|
85
|
+
transaction<T>(work: (tx: PendingInputTransaction) => Promise<T>): Promise<T>;
|
|
86
|
+
/** The full record of one input, for the session that owns this store. */
|
|
87
|
+
current(clientMessageId: string): PendingInput | undefined;
|
|
65
88
|
read(clientMessageId: string): PendingInputRead;
|
|
89
|
+
/** The cursor of the newest committed transition; a snapshot taken now covers everything through it. */
|
|
90
|
+
get throughCursor(): number;
|
|
66
91
|
snapshot(): readonly PendingInputProjection[];
|
|
67
92
|
subscribe(listener: PendingInputListener): () => void;
|
|
68
93
|
private serialize;
|
|
94
|
+
private admit;
|
|
69
95
|
private commit;
|
|
96
|
+
/**
|
|
97
|
+
* Reduces every change, then records all that changed in ONE journal record, so a crash or a failed write
|
|
98
|
+
* keeps either all of them or none (a consumer committing a batch never sees half of it delivered).
|
|
99
|
+
*/
|
|
100
|
+
private commitMany;
|
|
70
101
|
private reportListenerError;
|
|
71
102
|
}
|
|
72
103
|
/** A previous write may have landed without being synced; only a reopen can say what the journal holds. */
|
|
@@ -14,25 +14,24 @@ export class PendingInputStore {
|
|
|
14
14
|
static async open(options) {
|
|
15
15
|
const store = new PendingInputStore(options);
|
|
16
16
|
for (const record of await options.journal.read()) {
|
|
17
|
-
|
|
17
|
+
for (const input of [record.input, ...(record.batch ?? [])])
|
|
18
|
+
store.inputs.set(input.clientMessageId, input);
|
|
18
19
|
store.cursor = Math.max(store.cursor, record.cursor);
|
|
19
20
|
}
|
|
20
21
|
for (const input of [...store.inputs.values()]) {
|
|
21
22
|
if (input.phase === "delivered" || input.phase === "removed")
|
|
22
23
|
continue;
|
|
23
24
|
const handle = { lifetimeId: options.lifetimeId, queuedId: options.nextQueuedId() };
|
|
24
|
-
await store.commit(input.clientMessageId, {
|
|
25
|
+
await store.commit(input.clientMessageId, {
|
|
26
|
+
type: "rebind",
|
|
27
|
+
handle,
|
|
28
|
+
attemptsUnsent: options.uncommittedAttemptsAreUnsent === true,
|
|
29
|
+
});
|
|
25
30
|
}
|
|
26
31
|
return store;
|
|
27
32
|
}
|
|
28
33
|
submit(event) {
|
|
29
|
-
return this.serialize(() =>
|
|
30
|
-
const handle = this.inputs.get(event.clientMessageId)?.handle ?? {
|
|
31
|
-
lifetimeId: this.options.lifetimeId,
|
|
32
|
-
queuedId: this.options.nextQueuedId(),
|
|
33
|
-
};
|
|
34
|
-
return this.commit(event.clientMessageId, { ...event, type: "submit", handle });
|
|
35
|
-
});
|
|
34
|
+
return this.serialize(() => this.admit(event));
|
|
36
35
|
}
|
|
37
36
|
apply(clientMessageId, event) {
|
|
38
37
|
return this.serialize(() => this.commit(clientMessageId, event));
|
|
@@ -67,12 +66,32 @@ export class PendingInputStore {
|
|
|
67
66
|
return { removed, conflicts, retained, stale };
|
|
68
67
|
});
|
|
69
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Runs `work` under the gate every command shares, so a consumer can change its native buffer and the
|
|
71
|
+
* journal in one step that no edit, removal or other consumer can interleave with.
|
|
72
|
+
*/
|
|
73
|
+
transaction(work) {
|
|
74
|
+
return this.serialize(() => work({
|
|
75
|
+
submit: (event) => this.admit(event),
|
|
76
|
+
apply: (clientMessageId, event) => this.commit(clientMessageId, event),
|
|
77
|
+
applyAll: (changes) => this.commitMany(changes),
|
|
78
|
+
current: (clientMessageId) => this.inputs.get(clientMessageId),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/** The full record of one input, for the session that owns this store. */
|
|
82
|
+
current(clientMessageId) {
|
|
83
|
+
return this.inputs.get(clientMessageId);
|
|
84
|
+
}
|
|
70
85
|
read(clientMessageId) {
|
|
71
86
|
const input = this.inputs.get(clientMessageId);
|
|
72
87
|
return input === undefined
|
|
73
88
|
? { status: "not_admitted", clientMessageId }
|
|
74
89
|
: { status: "admitted", projection: projectPendingInput(input) };
|
|
75
90
|
}
|
|
91
|
+
/** The cursor of the newest committed transition; a snapshot taken now covers everything through it. */
|
|
92
|
+
get throughCursor() {
|
|
93
|
+
return this.cursor;
|
|
94
|
+
}
|
|
76
95
|
snapshot() {
|
|
77
96
|
return [...this.inputs.values()].map(projectPendingInput);
|
|
78
97
|
}
|
|
@@ -90,18 +109,43 @@ export class PendingInputStore {
|
|
|
90
109
|
this.gate = run.catch(() => undefined);
|
|
91
110
|
return run;
|
|
92
111
|
}
|
|
112
|
+
admit(event) {
|
|
113
|
+
const handle = this.inputs.get(event.clientMessageId)?.handle ?? {
|
|
114
|
+
lifetimeId: this.options.lifetimeId,
|
|
115
|
+
queuedId: this.options.nextQueuedId(),
|
|
116
|
+
};
|
|
117
|
+
return this.commit(event.clientMessageId, { ...event, type: "submit", handle });
|
|
118
|
+
}
|
|
93
119
|
async commit(clientMessageId, event) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
return (await this.commitMany([{ clientMessageId, event }]))[0];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Reduces every change, then records all that changed in ONE journal record, so a crash or a failed write
|
|
124
|
+
* keeps either all of them or none (a consumer committing a batch never sees half of it delivered).
|
|
125
|
+
*/
|
|
126
|
+
async commitMany(changes) {
|
|
127
|
+
const results = [];
|
|
128
|
+
const committed = [];
|
|
129
|
+
const staged = new Map();
|
|
130
|
+
for (const { clientMessageId, event } of changes) {
|
|
131
|
+
const current = staged.get(clientMessageId) ?? this.inputs.get(clientMessageId);
|
|
132
|
+
const decision = reducePendingInput(current, event);
|
|
133
|
+
const result = decision.result?.clientMessageId === "" ? { ...decision.result, clientMessageId } : decision.result;
|
|
134
|
+
results.push(result);
|
|
135
|
+
if (decision.next === undefined || decision.effects.length === 0)
|
|
136
|
+
continue;
|
|
137
|
+
staged.set(clientMessageId, decision.next);
|
|
138
|
+
committed.push({ next: decision.next, decision: { ...decision, result } });
|
|
139
|
+
}
|
|
140
|
+
const [first, ...rest] = committed;
|
|
141
|
+
if (first === undefined)
|
|
142
|
+
return results;
|
|
100
143
|
const record = {
|
|
101
144
|
type: PENDING_INPUT_TRANSITION,
|
|
102
145
|
cursor: this.cursor + 1,
|
|
103
|
-
input: next,
|
|
104
|
-
...(result === undefined ? {} : { result }),
|
|
146
|
+
input: first.next,
|
|
147
|
+
...(first.decision.result === undefined ? {} : { result: first.decision.result }),
|
|
148
|
+
...(rest.length === 0 ? {} : { batch: rest.map((entry) => entry.next) }),
|
|
105
149
|
};
|
|
106
150
|
try {
|
|
107
151
|
await this.options.journal.append(record);
|
|
@@ -114,23 +158,27 @@ export class PendingInputStore {
|
|
|
114
158
|
throw error;
|
|
115
159
|
}
|
|
116
160
|
this.cursor = record.cursor;
|
|
117
|
-
|
|
118
|
-
|
|
161
|
+
for (const { next } of committed)
|
|
162
|
+
this.inputs.set(next.clientMessageId, next);
|
|
163
|
+
// One notification per record: every input it changed, under its one cursor.
|
|
164
|
+
const projections = committed.map(({ next }) => projectPendingInput(next));
|
|
119
165
|
for (const listener of this.listeners) {
|
|
120
166
|
try {
|
|
121
|
-
listener(
|
|
167
|
+
listener(projections, record.cursor);
|
|
122
168
|
}
|
|
123
169
|
catch (error) {
|
|
124
170
|
this.reportListenerError(error);
|
|
125
171
|
}
|
|
126
172
|
}
|
|
127
|
-
for (const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
173
|
+
for (const { next, decision } of committed) {
|
|
174
|
+
for (const effect of decision.effects) {
|
|
175
|
+
if (effect.type === "dispatch")
|
|
176
|
+
this.options.dispatch?.(effect.token, next);
|
|
177
|
+
if (effect.type === "restore_to_draft")
|
|
178
|
+
this.options.restoreToDraft?.(effect.text);
|
|
179
|
+
}
|
|
132
180
|
}
|
|
133
|
-
return
|
|
181
|
+
return results;
|
|
134
182
|
}
|
|
135
183
|
reportListenerError(error) {
|
|
136
184
|
const report = this.options.onListenerError;
|
|
@@ -34,6 +34,8 @@ export interface SupersededVersion {
|
|
|
34
34
|
export interface PendingInputFailure {
|
|
35
35
|
readonly delivery: DeliveryCertainty;
|
|
36
36
|
readonly reason: string;
|
|
37
|
+
/** The reservation that failed, kept so later evidence must name the attempt it is about. */
|
|
38
|
+
readonly attempt?: DeliveryToken;
|
|
37
39
|
}
|
|
38
40
|
/** The recorded answer of one operation, replayed byte for byte when the same effect id repeats. */
|
|
39
41
|
export interface RecordedOperation {
|
|
@@ -94,12 +96,22 @@ export type PendingInputEvent = SubmitEvent | {
|
|
|
94
96
|
} | {
|
|
95
97
|
readonly type: "reserve";
|
|
96
98
|
readonly token: DeliveryToken;
|
|
99
|
+
}
|
|
100
|
+
/** The consumer gave the attempt back before any of it was committed or sent: queued again, same id. */
|
|
101
|
+
| {
|
|
102
|
+
readonly type: "release";
|
|
103
|
+
readonly token: DeliveryToken;
|
|
97
104
|
} | {
|
|
98
105
|
readonly type: "consumed";
|
|
99
106
|
readonly token: DeliveryToken;
|
|
107
|
+
}
|
|
108
|
+
/** A handled-command receipt: with the reservation's token, or naming the lifetime of a still-queued input. */
|
|
109
|
+
| {
|
|
110
|
+
readonly type: "handled";
|
|
111
|
+
readonly token: DeliveryToken;
|
|
100
112
|
} | {
|
|
101
113
|
readonly type: "handled";
|
|
102
|
-
readonly
|
|
114
|
+
readonly lifetimeId: string;
|
|
103
115
|
} | {
|
|
104
116
|
readonly type: "refused";
|
|
105
117
|
readonly token: DeliveryToken;
|
|
@@ -109,13 +121,15 @@ export type PendingInputEvent = SubmitEvent | {
|
|
|
109
121
|
readonly token: DeliveryToken;
|
|
110
122
|
readonly reason: string;
|
|
111
123
|
}
|
|
112
|
-
/** An identity-bearing delivery witness found after the fact (a late event or recovery). */
|
|
124
|
+
/** An identity-bearing delivery witness found after the fact (a late event or recovery), for one failed attempt. */
|
|
113
125
|
| {
|
|
114
126
|
readonly type: "witnessed";
|
|
127
|
+
readonly attemptId: string;
|
|
115
128
|
}
|
|
116
|
-
/** Complete, fenced evidence that no consumer can still deliver the attempt. */
|
|
129
|
+
/** Complete, fenced evidence that no consumer can still deliver the named failed attempt. */
|
|
117
130
|
| {
|
|
118
131
|
readonly type: "non_delivery_proven";
|
|
132
|
+
readonly attemptId: string;
|
|
119
133
|
}
|
|
120
134
|
/** Teardown that is not an explicit user action: reload, unload, crash, handoff. */
|
|
121
135
|
| {
|
|
@@ -129,9 +143,16 @@ export type PendingInputEvent = SubmitEvent | {
|
|
|
129
143
|
readonly type: "cancel";
|
|
130
144
|
readonly cause: "stop" | "child_cancel";
|
|
131
145
|
readonly consumerFenced: boolean;
|
|
132
|
-
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A reopen. `attemptsUnsent` is the journal owner's proof that an attempt still `sending` was never handed to
|
|
149
|
+
* its consumer: the engine commits consumption before any provider sees the input, so a reservation without a
|
|
150
|
+
* later delivered record returns to the queue under the same id. Without it the attempt stays uncertain.
|
|
151
|
+
*/
|
|
152
|
+
| {
|
|
133
153
|
readonly type: "rebind";
|
|
134
154
|
readonly handle: PendingInputHandle;
|
|
155
|
+
readonly attemptsUnsent?: boolean;
|
|
135
156
|
};
|
|
136
157
|
export type OperationOutcome = "admitted" | "replayed" | "client_message_id_conflict" | "edited" | "removed" | "requeued" | "already_delivered" | "not_found" | "conflict" | "sending" | "not_retryable" | "not_editable" | "unresolved" | "effect_id_conflict" | "stale_handle";
|
|
137
158
|
export interface PendingInputOperationResult {
|
|
@@ -54,15 +54,21 @@ function reduceLifecycle(current, event) {
|
|
|
54
54
|
};
|
|
55
55
|
return { next, effects: [{ type: "persist" }, { type: "dispatch", token: event.token }] };
|
|
56
56
|
}
|
|
57
|
+
case "release": {
|
|
58
|
+
if (!holdsAttempt(current, event.token))
|
|
59
|
+
return unchanged(current);
|
|
60
|
+
const { attempt: _attempt, ...rest } = current;
|
|
61
|
+
return persist({ ...rest, phase: "queued" });
|
|
62
|
+
}
|
|
57
63
|
case "consumed":
|
|
58
64
|
return holdsAttempt(current, event.token) ? persist(deliver(current, "consumed")) : unchanged(current);
|
|
59
65
|
case "handled":
|
|
60
|
-
if (
|
|
61
|
-
return persist(deliver(current, "handled"));
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
if ("token" in event)
|
|
67
|
+
return holdsAttempt(current, event.token) ? persist(deliver(current, "handled")) : unchanged(current);
|
|
68
|
+
// Without a token the receipt must name the lifetime of the still-queued input it settles.
|
|
69
|
+
return current.phase === "queued" && event.lifetimeId === current.handle.lifetimeId
|
|
70
|
+
? persist(deliver(current, "handled"))
|
|
71
|
+
: unchanged(current);
|
|
66
72
|
case "refused":
|
|
67
73
|
return holdsAttempt(current, event.token)
|
|
68
74
|
? persist(fail(current, "not_delivered", event.reason))
|
|
@@ -76,12 +82,10 @@ function reduceLifecycle(current, event) {
|
|
|
76
82
|
? persist(fail(current, "uncertain", "the delivering owner was lost"))
|
|
77
83
|
: unchanged(current);
|
|
78
84
|
case "witnessed":
|
|
79
|
-
return current.
|
|
80
|
-
? persist(deliver(current, "consumed"))
|
|
81
|
-
: unchanged(current);
|
|
85
|
+
return uncertainAbout(current, event.attemptId) ? persist(deliver(current, "consumed")) : unchanged(current);
|
|
82
86
|
case "non_delivery_proven":
|
|
83
|
-
return current
|
|
84
|
-
? persist(fail(current, "not_delivered", current.failure
|
|
87
|
+
return uncertainAbout(current, event.attemptId)
|
|
88
|
+
? persist(fail(current, "not_delivered", current.failure?.reason ?? ""))
|
|
85
89
|
: unchanged(current);
|
|
86
90
|
case "teardown":
|
|
87
91
|
return current.phase === "sending"
|
|
@@ -90,7 +94,7 @@ function reduceLifecycle(current, event) {
|
|
|
90
94
|
case "cancel":
|
|
91
95
|
return cancel(current, event.cause, event.consumerFenced);
|
|
92
96
|
case "rebind":
|
|
93
|
-
return rebind(current, event.handle);
|
|
97
|
+
return rebind(current, event.handle, event.attemptsUnsent === true);
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
function cancel(current, cause, consumerFenced) {
|
|
@@ -108,13 +112,16 @@ function cancel(current, cause, consumerFenced) {
|
|
|
108
112
|
: [{ type: "persist" }],
|
|
109
113
|
};
|
|
110
114
|
}
|
|
111
|
-
function rebind(current, handle) {
|
|
115
|
+
function rebind(current, handle, attemptsUnsent) {
|
|
112
116
|
if (current.phase === "delivered" || current.phase === "removed")
|
|
113
117
|
return unchanged(current);
|
|
114
118
|
const rebound = { ...current, handle, revision: 1 };
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
if (current.phase !== "sending")
|
|
120
|
+
return persist(rebound);
|
|
121
|
+
if (!attemptsUnsent)
|
|
122
|
+
return persist(fail(rebound, "uncertain", "the session reopened during handoff"));
|
|
123
|
+
const { attempt: _attempt, ...unsent } = rebound;
|
|
124
|
+
return persist({ ...unsent, phase: "queued" });
|
|
118
125
|
}
|
|
119
126
|
function tokenFitsInput(input, token) {
|
|
120
127
|
return token.lifetimeId === input.handle.lifetimeId && token.clientMessageId === input.clientMessageId;
|
|
@@ -132,8 +139,19 @@ function deliver(input, deliveredBy) {
|
|
|
132
139
|
return settled({ ...input, phase: "delivered", ...(deliveredBy === undefined ? {} : { deliveredBy }) });
|
|
133
140
|
}
|
|
134
141
|
function fail(input, delivery, reason) {
|
|
135
|
-
const { attempt
|
|
136
|
-
|
|
142
|
+
const { attempt, ...rest } = input;
|
|
143
|
+
const failedAttempt = attempt ?? input.failure?.attempt;
|
|
144
|
+
return {
|
|
145
|
+
...rest,
|
|
146
|
+
phase: "failed",
|
|
147
|
+
failure: { delivery, reason, ...(failedAttempt === undefined ? {} : { attempt: failedAttempt }) },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Late evidence only settles the uncertain failure of the exact attempt it names. */
|
|
151
|
+
function uncertainAbout(input, attemptId) {
|
|
152
|
+
return (input.phase === "failed" &&
|
|
153
|
+
input.failure?.delivery === "uncertain" &&
|
|
154
|
+
input.failure.attempt?.attemptId === attemptId);
|
|
137
155
|
}
|
|
138
156
|
function settled(input) {
|
|
139
157
|
const { attempt: _attempt, failure: _failure, ...rest } = input;
|
|
@@ -134,6 +134,7 @@ export function terminateFinalSessionLine(filePath) {
|
|
|
134
134
|
return;
|
|
135
135
|
const segment = Buffer.alloc(size - segmentStart);
|
|
136
136
|
readSync(fd, segment, 0, segment.length, segmentStart);
|
|
137
|
+
// Not synced on purpose: a lost truncate only brings back an uncommitted tail, and the next append syncs.
|
|
137
138
|
if (parseSessionEntryLine(segment.toString("utf8")) === null)
|
|
138
139
|
ftruncateSync(fd, segmentStart);
|
|
139
140
|
else
|
package/dist/core/session-log.js
CHANGED
|
@@ -7,7 +7,7 @@ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
|
7
7
|
const MAX_STRING_LENGTH = 200;
|
|
8
8
|
const DEBUG_PREFIX = `[${APP_NAME}-session]`;
|
|
9
9
|
const BLOCKED_KEY = /^(?:__proto__|constructor|prototype|headers?|env(?:ironment)?|authorization|credential(?:s)?|password|secret|token|api_?key|client_?secret)$/i;
|
|
10
|
-
const ALLOWED_DATA_KEY = /^(?:action|attemptId|disposition|stage|error|mode|count|willRetry|deferAdmission|delivered|restored|cause|accepted|skipped|rejectionCause|reason|durationMs|kind|retryable|phase|op|bytes|generation|requestId|tokens|tokensBefore|tokensAfter|contextWindow|attempt|aborted|sessionId|role|provider|model|source|actor|from|to|duringTurn|persistDefault)$/;
|
|
10
|
+
const ALLOWED_DATA_KEY = /^(?:action|attemptId|disposition|stage|error|mode|count|willRetry|deferAdmission|delivered|restored|cause|accepted|skipped|rejectionCause|reason|durationMs|kind|retryable|phase|op|bytes|generation|requestId|tokens|tokensBefore|tokensAfter|contextWindow|attempt|aborted|sessionId|role|provider|model|source|actor|from|to|duringTurn|persistDefault|extension|handlerEvent)$/;
|
|
11
11
|
const SENSITIVE_TEXT = /((?:authorization\s*[:=]\s*(?:bearer|basic)\s+)|(?:bearer\s+)|(?:[?&](?:api[_-]?key|token|secret|password|auth(?:orization)?)=))[^\s&,"'}\]]+/gi;
|
|
12
12
|
export function createSessionLogger(agentDir, options = {}) {
|
|
13
13
|
if (typeof agentDir !== "string" || agentDir.length === 0) {
|