mixdog 0.9.87 → 0.9.89
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/package.json +13 -7
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +29 -35
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +73 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +16 -4
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/runtime-core.mjs +33 -15
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +0 -6
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +90 -36
- package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +17 -17
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +25 -3
- package/src/tui/App.jsx +5 -3
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/route-pickers.mjs +11 -117
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +243 -223
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +74 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +5 -3
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- package/src/standalone/agent-shard/shard-pool.mjs +0 -443
|
@@ -6,7 +6,7 @@ import { createHash, randomBytes } from 'crypto';
|
|
|
6
6
|
import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
7
7
|
import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
|
|
8
8
|
import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
|
|
9
|
-
import { loadSession, saveSessionAsync } from '../store.mjs';
|
|
9
|
+
import { loadSession, readSessionLifecycleStateFromDisk, saveSessionAsync } from '../store.mjs';
|
|
10
10
|
import { isDeliveredCompletion, logDuplicateSkip } from './delivered-completions.mjs';
|
|
11
11
|
|
|
12
12
|
const _sessionPendingMessages = new Map();
|
|
@@ -40,7 +40,128 @@ const _pendingPersistTails = new Map();
|
|
|
40
40
|
const _inDeliveryPendingIds = new Map();
|
|
41
41
|
const _ackedPendingIds = new Map();
|
|
42
42
|
const _pendingHydrations = new Map();
|
|
43
|
+
// Claim ledger for the in-delivery ids: the exact entries a drain handed to a
|
|
44
|
+
// turn. A drain consumes the not-yet-flushed persist buffer, so without this
|
|
45
|
+
// copy a failed turn could release ids whose payload exists nowhere (memory,
|
|
46
|
+
// spool, replay) any more. Claims are dropped on ack (delivered) or on
|
|
47
|
+
// release (restored below). A release may only restore while the session
|
|
48
|
+
// record still accepts pending state — a tombstoned session refuses it however
|
|
49
|
+
// old the claim is.
|
|
50
|
+
const _claimedPendingMessages = new Map();
|
|
43
51
|
let _pendingPersistImmediate = null;
|
|
52
|
+
// Lifecycle token stamped on the exact entry object a drain/enqueue observed.
|
|
53
|
+
// Symbol-keyed so it never reaches the spool (JSON.stringify ignores symbols)
|
|
54
|
+
// and never changes the entry's public shape.
|
|
55
|
+
const LIFECYCLE_TOKEN = Symbol('pendingLifecycleToken');
|
|
56
|
+
// Deterministic test seams for the check→mutate windows (close/detach racing
|
|
57
|
+
// an enqueue publish, a spool commit, or a multi-step hydrate cleanup).
|
|
58
|
+
let _pendingTestHooks = null;
|
|
59
|
+
function runPendingTestHook(name, payload) {
|
|
60
|
+
const hook = _pendingTestHooks?.[name];
|
|
61
|
+
if (typeof hook !== 'function') return;
|
|
62
|
+
try { hook(payload); } catch { /* test seam is best-effort */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Authoritative pending-state lifecycle gate. The DURABLE record is the only
|
|
66
|
+
// authority: readSessionLifecycleStateFromDisk reports absence / open / closed
|
|
67
|
+
// / unreadable straight from disk, bypassing every live/pending snapshot, so a
|
|
68
|
+
// stale open live copy (e.g. an id under _droppedSaveIds) can never mask a
|
|
69
|
+
// tombstone. Only an explicit create/reopen/new-generation record — never an
|
|
70
|
+
// ordinary enqueue — makes a session writable again.
|
|
71
|
+
// 'absent' → never saved: open and writable (generation 0)
|
|
72
|
+
// 'open' → durable record at this generation
|
|
73
|
+
// 'closed' → tombstone: refuses every write
|
|
74
|
+
// 'unreadable' → IO error / malformed / foreign record: FAIL CLOSED, and a
|
|
75
|
+
// live snapshot may only ADD refusal, never open it
|
|
76
|
+
function pendingSessionLifecycle(sessionId) {
|
|
77
|
+
let disk = null;
|
|
78
|
+
try { disk = readSessionLifecycleStateFromDisk(sessionId); } catch { disk = null; }
|
|
79
|
+
const state = disk?.state;
|
|
80
|
+
if (state === 'open' || state === 'closed') {
|
|
81
|
+
return { source: state, closed: state === 'closed', generation: Number(disk.generation) || 0 };
|
|
82
|
+
}
|
|
83
|
+
if (state === 'absent') return { source: 'absent', closed: false, generation: 0 };
|
|
84
|
+
// Unreadable/corrupt/foreign (or the read itself threw): never writable.
|
|
85
|
+
return { source: 'unreadable', closed: true, generation: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Epoch token: the exact lifecycle a claim/enqueue/hydration was taken under.
|
|
89
|
+
function pendingLifecycleToken(lifecycle) {
|
|
90
|
+
return `${lifecycle.source}:${lifecycle.generation}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function currentPendingLifecycleToken(sessionId) {
|
|
94
|
+
return pendingLifecycleToken(pendingSessionLifecycle(sessionId));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function stampLifecycleToken(entry, token) {
|
|
98
|
+
if (!entry || typeof entry !== 'object' || !token) return entry;
|
|
99
|
+
try {
|
|
100
|
+
Object.defineProperty(entry, LIFECYCLE_TOKEN, {
|
|
101
|
+
value: token, enumerable: false, configurable: true, writable: true,
|
|
102
|
+
});
|
|
103
|
+
} catch { /* frozen entry: the claim map still carries the token */ }
|
|
104
|
+
return entry;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function entryLifecycleToken(entry) {
|
|
108
|
+
const token = entry && typeof entry === 'object' ? entry[LIFECYCLE_TOKEN] : null;
|
|
109
|
+
return typeof token === 'string' && token ? token : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Clone/normalize boundary helper. Every normalized copy of an accepted entry
|
|
113
|
+
// MUST inherit the source entry's IMMUTABLE lifecycle token: a copy that loses
|
|
114
|
+
// it is later restamped with whatever epoch happens to be current, which
|
|
115
|
+
// legitimizes an old-generation entry (drain → claim → release/ack against a
|
|
116
|
+
// reopened owner). Symbols are dropped by object spread and JSON, so every
|
|
117
|
+
// constructor of a derived entry routes through here.
|
|
118
|
+
function carryLifecycleToken(target, source) {
|
|
119
|
+
const token = entryLifecycleToken(source);
|
|
120
|
+
return token ? stampLifecycleToken(target, token) : target;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Pure epoch comparison against an already-read lifecycle (no IO), so a caller
|
|
124
|
+
// that judges many entries reads the durable record once.
|
|
125
|
+
function lifecycleTokenStale(now, sinceToken) {
|
|
126
|
+
if (sinceToken === null || sinceToken === undefined) return false;
|
|
127
|
+
const separator = String(sinceToken).indexOf(':');
|
|
128
|
+
const sinceSource = String(sinceToken).slice(0, separator);
|
|
129
|
+
const sinceGeneration = Number(String(sinceToken).slice(separator + 1)) || 0;
|
|
130
|
+
// Only close/detach moves the durable generation, so a differing one means
|
|
131
|
+
// the session was closed/detached/reopened since: refuse.
|
|
132
|
+
if (now.generation !== sinceGeneration) return true;
|
|
133
|
+
// A record that was DURABLE at claim/hydration start must still be durable:
|
|
134
|
+
// an unreadable record must never be mistaken for an open session. The
|
|
135
|
+
// reverse (never-saved → first durable save at the same generation) is the
|
|
136
|
+
// ordinary lifecycle of a fresh session and stays valid.
|
|
137
|
+
if (sinceSource === 'open' && now.source !== 'open') return true;
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// True when pending state for this session must NOT be (re)created/published:
|
|
142
|
+
// tombstoned, or the durable lifecycle epoch moved since `sinceToken`.
|
|
143
|
+
function pendingLifecycleInvalidated(sessionId, sinceToken = null) {
|
|
144
|
+
const now = pendingSessionLifecycle(sessionId);
|
|
145
|
+
if (now.closed) return true;
|
|
146
|
+
return lifecycleTokenStale(now, sinceToken);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Epoch check for DESTRUCTIVE spool/ledger mutations (ack, prune, clear,
|
|
150
|
+
// foreign drain). Deliberately tombstone-TOLERANT: deleting the rows of the
|
|
151
|
+
// epoch that just closed is legitimate (the close path itself does it), while
|
|
152
|
+
// a generation move — the reopened/detached owner taking over — means those
|
|
153
|
+
// rows are no longer ours to touch. Callers re-evaluate this INSIDE the spool
|
|
154
|
+
// transaction, immediately before mutating.
|
|
155
|
+
function pendingLifecycleEpochMoved(sessionId, sinceToken = null) {
|
|
156
|
+
if (sinceToken === null || sinceToken === undefined) return false;
|
|
157
|
+
const now = pendingSessionLifecycle(sessionId);
|
|
158
|
+
const separator = String(sinceToken).indexOf(':');
|
|
159
|
+
const sinceGeneration = Number(String(sinceToken).slice(separator + 1)) || 0;
|
|
160
|
+
if (now.generation !== sinceGeneration) return true;
|
|
161
|
+
// An unreadable/foreign record is never authority for a deletion.
|
|
162
|
+
if (now.source === 'unreadable') return true;
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
44
165
|
|
|
45
166
|
function pendingIdSet(map, sessionId) {
|
|
46
167
|
let ids = map.get(sessionId);
|
|
@@ -228,7 +349,8 @@ function normalizePendingMessageEntry(entry) {
|
|
|
228
349
|
out = fallback ? { content: fallback, text: text || fallback } : null;
|
|
229
350
|
}
|
|
230
351
|
if (!out) return null;
|
|
231
|
-
|
|
352
|
+
// The lifecycle token rides along every derived copy (see carryLifecycleToken).
|
|
353
|
+
return carryLifecycleToken(marker ? { ...out, ...identity, ...marker } : { ...out, ...identity }, entry);
|
|
232
354
|
}
|
|
233
355
|
|
|
234
356
|
function pendingMessageText(entry) {
|
|
@@ -247,7 +369,7 @@ function pendingMessageQueueEntry(entry) {
|
|
|
247
369
|
? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
|
|
248
370
|
: null;
|
|
249
371
|
const base = { ...identity, content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
|
|
250
|
-
return marker ? { ...base, ...marker } : base;
|
|
372
|
+
return carryLifecycleToken(marker ? { ...base, ...marker } : base, entry);
|
|
251
373
|
}
|
|
252
374
|
|
|
253
375
|
// Canonical persisted-queue entry: a plain string for user/steering messages,
|
|
@@ -285,8 +407,16 @@ function normalizePersistedEntry(entry, options = {}) {
|
|
|
285
407
|
|
|
286
408
|
function persistPendingMessages(sessionId, messages) {
|
|
287
409
|
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
288
|
-
const
|
|
289
|
-
|
|
410
|
+
const sourceMessages = Array.isArray(messages) ? messages : [messages];
|
|
411
|
+
const persistedMessages = sourceMessages
|
|
412
|
+
.map((entry) => {
|
|
413
|
+
const normalized = normalizePersistedEntry(entry);
|
|
414
|
+
if (!normalized) return null;
|
|
415
|
+
// Carry the lifecycle token observed when this entry was accepted
|
|
416
|
+
// so the commit below can drop it if the session moved on.
|
|
417
|
+
const token = entryLifecycleToken(entry);
|
|
418
|
+
return token ? stampLifecycleToken(normalized, token) : normalized;
|
|
419
|
+
})
|
|
290
420
|
.filter(Boolean);
|
|
291
421
|
if (persistedMessages.length === 0) return 0;
|
|
292
422
|
// Async lock wait: this runs on the lead/TUI main process (tool-exec +
|
|
@@ -295,9 +425,27 @@ function persistPendingMessages(sessionId, messages) {
|
|
|
295
425
|
// Best-effort: the returned promise is fire-and-forget; depth is reported
|
|
296
426
|
// optimistically from the buffered batch length.
|
|
297
427
|
const operation = updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
428
|
+
// Durable commit window: re-read the lifecycle INSIDE the spool lock.
|
|
429
|
+
// A cross-process close/detach between acceptance and this commit must
|
|
430
|
+
// drop the old-generation input without touching the new owner's rows.
|
|
431
|
+
runPendingTestHook('persist:beforeCommit', { sessionId });
|
|
432
|
+
const committable = persistedMessages.filter((entry) => !pendingLifecycleInvalidated(
|
|
433
|
+
sessionId,
|
|
434
|
+
entryLifecycleToken(entry),
|
|
435
|
+
));
|
|
436
|
+
if (committable.length === 0) return undefined;
|
|
298
437
|
const next = normalizePendingStore(raw);
|
|
299
438
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
300
|
-
|
|
439
|
+
// Idempotent by id: a restored claim (releasePendingMessages) may be
|
|
440
|
+
// durable already, and re-appending it would leave two spool rows for
|
|
441
|
+
// one queued input (double replay after a restart).
|
|
442
|
+
const existingIds = new Set(q.map(pendingMessageId).filter(Boolean));
|
|
443
|
+
const additions = committable.filter((entry) => {
|
|
444
|
+
const id = pendingMessageId(entry);
|
|
445
|
+
return !id || !existingIds.has(id);
|
|
446
|
+
});
|
|
447
|
+
if (additions.length === 0) return undefined;
|
|
448
|
+
q.push(...additions);
|
|
301
449
|
next.sessions[sessionId] = q;
|
|
302
450
|
const now = Date.now();
|
|
303
451
|
next.updatedAt = now;
|
|
@@ -340,6 +488,8 @@ function schedulePendingMessagePersist(sessionId, message) {
|
|
|
340
488
|
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
341
489
|
const persistedMessage = normalizePersistedEntry(message);
|
|
342
490
|
if (!persistedMessage) return 0;
|
|
491
|
+
const token = entryLifecycleToken(message);
|
|
492
|
+
if (token) stampLifecycleToken(persistedMessage, token);
|
|
343
493
|
const q = _pendingPersistBuffers.get(sessionId) || [];
|
|
344
494
|
q.push(persistedMessage);
|
|
345
495
|
_pendingPersistBuffers.set(sessionId, q);
|
|
@@ -360,13 +510,75 @@ function takeBufferedPendingMessages(sessionId) {
|
|
|
360
510
|
return buffered.slice();
|
|
361
511
|
}
|
|
362
512
|
|
|
363
|
-
|
|
364
|
-
|
|
513
|
+
function claimPendingEntries(sessionId, entries) {
|
|
514
|
+
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
515
|
+
const currentToken = currentPendingLifecycleToken(sessionId);
|
|
516
|
+
const prior = _claimedPendingMessages.get(sessionId);
|
|
517
|
+
// Claims are stamped with the lifecycle epoch observed at claim time; a
|
|
518
|
+
// claim from an older epoch is stale and never merges into the new one.
|
|
519
|
+
const claims = prior && prior.token === currentToken ? prior : { token: currentToken, entries: new Map() };
|
|
520
|
+
for (const entry of entries) {
|
|
521
|
+
const id = pendingMessageId(entry);
|
|
522
|
+
if (!id) continue;
|
|
523
|
+
// The token also rides on the delivered entry OBJECT: a later claim or
|
|
524
|
+
// ack may replace/remove this session's map state, and the old release
|
|
525
|
+
// must still judge itself by the epoch it was handed.
|
|
526
|
+
//
|
|
527
|
+
// NEVER restamp: the token an entry was ACCEPTED under is immutable.
|
|
528
|
+
// Overwriting it with the epoch current at claim time was exactly how
|
|
529
|
+
// a generation-0 entry that survived a generation-1 detach got
|
|
530
|
+
// legitimized (and then restored by a failed turn). Entries reaching
|
|
531
|
+
// here without a token were never stamped (legacy/foreign paths) and
|
|
532
|
+
// are claimed under the epoch observed now.
|
|
533
|
+
if (!entryLifecycleToken(entry)) stampLifecycleToken(entry, currentToken);
|
|
534
|
+
claims.entries.set(id, entry);
|
|
535
|
+
}
|
|
536
|
+
if (claims.entries.size > 0) _claimedPendingMessages.set(sessionId, claims);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function dropPendingClaims(sessionId, ids) {
|
|
540
|
+
const claims = _claimedPendingMessages.get(sessionId);
|
|
541
|
+
if (!claims) return;
|
|
542
|
+
for (const id of ids) claims.entries.delete(id);
|
|
543
|
+
if (claims.entries.size === 0) _claimedPendingMessages.delete(sessionId);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function pendingIdStillQueued(sessionId, id) {
|
|
547
|
+
for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
|
|
548
|
+
const q = map.get(sessionId);
|
|
549
|
+
if (Array.isArray(q) && q.some((entry) => pendingMessageId(entry) === id)) return true;
|
|
550
|
+
}
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function acknowledgePendingMessages(sessionId, deliveredEntries, options = {}) {
|
|
555
|
+
const entries = Array.isArray(deliveredEntries) ? deliveredEntries : [];
|
|
556
|
+
// Expected lifecycle epoch of THIS acknowledgement: the immutable token of
|
|
557
|
+
// the delivered entries (explicit option for spool-sourced entries that
|
|
558
|
+
// never carried one), falling back to the epoch observed right now.
|
|
559
|
+
const explicitToken = typeof options?.expectedToken === 'string' && options.expectedToken
|
|
560
|
+
? options.expectedToken
|
|
561
|
+
: null;
|
|
562
|
+
const expectedToken = explicitToken
|
|
563
|
+
|| entries.map(entryLifecycleToken).find(Boolean)
|
|
564
|
+
|| currentPendingLifecycleToken(sessionId);
|
|
565
|
+
// An entry from a DIFFERENT epoch is not part of this ack: its rows belong
|
|
566
|
+
// to another owner and must not be deleted by this transaction.
|
|
567
|
+
const ids = new Set(entries
|
|
568
|
+
.filter((entry) => {
|
|
569
|
+
const token = entryLifecycleToken(entry);
|
|
570
|
+
return !token || token === expectedToken;
|
|
571
|
+
})
|
|
365
572
|
.map(pendingMessageId).filter(Boolean));
|
|
366
573
|
if (ids.size === 0) return Promise.resolve(false);
|
|
574
|
+
// The epoch already moved before we touched anything: leave the reopened
|
|
575
|
+
// owner's memory, ledger and spool rows exactly as they are.
|
|
576
|
+
if (pendingLifecycleEpochMoved(sessionId, expectedToken)) return Promise.resolve(false);
|
|
367
577
|
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
368
578
|
const acked = pendingIdSet(_ackedPendingIds, sessionId);
|
|
369
579
|
for (const id of ids) { inDelivery.delete(id); acked.add(id); }
|
|
580
|
+
// Delivered for good: the claim copy is no longer a recovery source.
|
|
581
|
+
dropPendingClaims(sessionId, ids);
|
|
370
582
|
const purgeMemory = () => {
|
|
371
583
|
for (const map of [_pendingPersistBuffers, _sessionPendingMessages, _hydratedPendingMessages]) {
|
|
372
584
|
const q = map.get(sessionId);
|
|
@@ -377,11 +589,22 @@ export function acknowledgePendingMessages(sessionId, deliveredEntries) {
|
|
|
377
589
|
}
|
|
378
590
|
};
|
|
379
591
|
purgeMemory();
|
|
592
|
+
let epochMoved = false;
|
|
380
593
|
const precedingPersist = _pendingPersistTails.get(sessionId) || Promise.resolve();
|
|
381
594
|
const operation = precedingPersist.catch(() => {}).then(() => {
|
|
382
595
|
// A failed preceding persist may have requeued after the first purge.
|
|
383
|
-
|
|
596
|
+
// Skipped once the epoch moved: those queue entries are the new
|
|
597
|
+
// owner's (a hydrate can republish the very same durable ids).
|
|
598
|
+
if (!pendingLifecycleEpochMoved(sessionId, expectedToken)) purgeMemory();
|
|
384
599
|
return updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
600
|
+
// Atomic re-validation: we now hold the spool lock. A generation
|
|
601
|
+
// movement while we waited means the rows are the reopened owner's —
|
|
602
|
+
// delete nothing and report failure so no ledger prune follows.
|
|
603
|
+
runPendingTestHook('ack:beforeCommit', { sessionId });
|
|
604
|
+
if (pendingLifecycleEpochMoved(sessionId, expectedToken)) {
|
|
605
|
+
epochMoved = true;
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
385
608
|
const next = normalizePendingStore(raw);
|
|
386
609
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
387
610
|
const kept = q.filter((entry) => !ids.has(pendingMessageId(entry)));
|
|
@@ -397,11 +620,20 @@ export function acknowledgePendingMessages(sessionId, deliveredEntries) {
|
|
|
397
620
|
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
398
621
|
});
|
|
399
622
|
_pendingPersistTails.set(sessionId, operation);
|
|
400
|
-
const reported = operation.then(() =>
|
|
623
|
+
const reported = operation.then(() => !epochMoved).catch((err) => {
|
|
401
624
|
try { process.stderr.write(`[session] pending-message ack failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
402
625
|
return false;
|
|
403
626
|
}).then((ok) => {
|
|
404
627
|
if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
|
|
628
|
+
// Refused because the epoch moved: this ack never happened. Drop the
|
|
629
|
+
// acked-id suppression again — those durable rows now belong to the
|
|
630
|
+
// reopened owner and its hydrate must be able to take them.
|
|
631
|
+
if (epochMoved) {
|
|
632
|
+
const movedAcked = pendingIdSet(_ackedPendingIds, sessionId);
|
|
633
|
+
for (const id of ids) movedAcked.delete(id);
|
|
634
|
+
if (movedAcked.size === 0) _ackedPendingIds.delete(sessionId);
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
405
637
|
// Keep the acked ids when the spool cleanup FAILED: the durable entry
|
|
406
638
|
// still exists, and forgetting that it was already delivered let the
|
|
407
639
|
// foreign-injection drain re-take it as a "new" cross-surface submit
|
|
@@ -429,10 +661,16 @@ export function recordPendingMessageDelivery(session, deliveredEntries) {
|
|
|
429
661
|
session.deliveredPendingMessageIds = [...new Set([...ledger, ...added])];
|
|
430
662
|
}
|
|
431
663
|
|
|
432
|
-
async function pruneCleanupConfirmedLedger(sessionId, confirmedEntries, session = null, persist = null) {
|
|
664
|
+
async function pruneCleanupConfirmedLedger(sessionId, confirmedEntries, session = null, persist = null, expectedToken = null) {
|
|
433
665
|
const confirmedIds = new Set((Array.isArray(confirmedEntries) ? confirmedEntries : [])
|
|
434
666
|
.map(pendingMessageId).filter(Boolean));
|
|
435
667
|
if (confirmedIds.size === 0) return false;
|
|
668
|
+
// Same atomic ownership rule as the spool ack: the ledger of a session
|
|
669
|
+
// whose durable epoch moved belongs to the reopened owner.
|
|
670
|
+
const token = expectedToken
|
|
671
|
+
|| (Array.isArray(confirmedEntries) ? confirmedEntries.map(entryLifecycleToken).find(Boolean) : null)
|
|
672
|
+
|| null;
|
|
673
|
+
if (token && pendingLifecycleEpochMoved(sessionId, token)) return false;
|
|
436
674
|
const target = session || loadSession(sessionId);
|
|
437
675
|
if (!target) return false;
|
|
438
676
|
const ledger = Array.isArray(target.deliveredPendingMessageIds)
|
|
@@ -443,6 +681,9 @@ async function pruneCleanupConfirmedLedger(sessionId, confirmedEntries, session
|
|
|
443
681
|
// Confirmed IDs need no replay protection and are removed immediately
|
|
444
682
|
// (therefore bounded below any finite confirmed-ID retention cap).
|
|
445
683
|
// Unconfirmed IDs are never size-evicted.
|
|
684
|
+
// Re-checked immediately before the mutation: the load above is an await
|
|
685
|
+
// boundary for the caller's chain.
|
|
686
|
+
if (token && pendingLifecycleEpochMoved(sessionId, token)) return false;
|
|
446
687
|
target.deliveredPendingMessageIds = kept;
|
|
447
688
|
if (typeof persist === 'function') await persist();
|
|
448
689
|
else await saveSessionAsync(target, { expectedGeneration: target.generation });
|
|
@@ -470,10 +711,47 @@ export function finalizePendingMessageDelivery(session, deliveredEntries, durabl
|
|
|
470
711
|
|
|
471
712
|
export function releasePendingMessages(sessionId, deliveredEntries) {
|
|
472
713
|
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
714
|
+
const acked = pendingIdSet(_ackedPendingIds, sessionId);
|
|
715
|
+
const claim = _claimedPendingMessages.get(sessionId);
|
|
716
|
+
// Restoring is a WRITE of pending state: allowed only while the DURABLE
|
|
717
|
+
// lifecycle is still open AND still the exact epoch THIS delivery was
|
|
718
|
+
// claimed under. The token is read from the released entry itself and only
|
|
719
|
+
// falls back to the map when the two agree — a newer claim/ack that
|
|
720
|
+
// replaced or removed the map state can never re-open an old release.
|
|
721
|
+
const claimToken = (entry) => entryLifecycleToken(entry)
|
|
722
|
+
|| (claim && claim.entries.has(pendingMessageId(entry)) ? claim.token : null);
|
|
723
|
+
const releasedIds = [];
|
|
724
|
+
const restored = [];
|
|
473
725
|
for (const entry of Array.isArray(deliveredEntries) ? deliveredEntries : []) {
|
|
474
726
|
const id = pendingMessageId(entry);
|
|
475
|
-
if (id)
|
|
727
|
+
if (!id) continue;
|
|
728
|
+
inDelivery.delete(id);
|
|
729
|
+
releasedIds.push(id);
|
|
730
|
+
// The turn did NOT deliver this input. Restore the claimed entry so it
|
|
731
|
+
// is queued (and durable) again: the drain that claimed it may have
|
|
732
|
+
// consumed the not-yet-flushed persist buffer, in which case clearing
|
|
733
|
+
// the id alone would lose the message from memory, spool and replay.
|
|
734
|
+
if (acked.has(id)) continue;
|
|
735
|
+
const token = claimToken(entry);
|
|
736
|
+
// No token at all (never claimed through a drain) → fail closed.
|
|
737
|
+
if (!token || pendingLifecycleInvalidated(sessionId, token)) continue;
|
|
738
|
+
if (pendingIdStillQueued(sessionId, id)) continue;
|
|
739
|
+
const claimed = claim?.entries.get(id) || pendingMessageQueueEntry(entry);
|
|
740
|
+
if (claimed) restored.push(stampLifecycleToken(claimed, token));
|
|
476
741
|
}
|
|
742
|
+
if (restored.length > 0) {
|
|
743
|
+
const q = _sessionPendingMessages.get(sessionId) || [];
|
|
744
|
+
q.push(...restored);
|
|
745
|
+
// FIFO by original enqueue time — a restored entry predates anything
|
|
746
|
+
// queued while the failed turn was running.
|
|
747
|
+
q.sort((a, b) => (Number(a?.enqueuedAt) || 0) - (Number(b?.enqueuedAt) || 0));
|
|
748
|
+
_sessionPendingMessages.set(sessionId, q);
|
|
749
|
+
// Durability: persistPendingMessages is id-idempotent, so an entry that
|
|
750
|
+
// was already flushed keeps its single spool row while one that never
|
|
751
|
+
// reached disk gets written now (restart replays it exactly once).
|
|
752
|
+
for (const entry of restored) schedulePendingMessagePersist(sessionId, entry);
|
|
753
|
+
}
|
|
754
|
+
dropPendingClaims(sessionId, releasedIds);
|
|
477
755
|
if (inDelivery.size === 0) _inDeliveryPendingIds.delete(sessionId);
|
|
478
756
|
}
|
|
479
757
|
|
|
@@ -489,6 +767,15 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
489
767
|
let staleLedgerEntries = [];
|
|
490
768
|
const staleUserEntries = [];
|
|
491
769
|
const ledgerSession = loadSession(sessionId);
|
|
770
|
+
// Durable lifecycle epoch this hydration started under. Publishing claims the
|
|
771
|
+
// durable entries into THIS process's memory, so it must be revalidated
|
|
772
|
+
// after every await (spool IO, test beforePublish, ack/prune): a close
|
|
773
|
+
// (tombstone) or a close/detach generation bump in between means the
|
|
774
|
+
// entries belong to the next owner, not to us. Aborting publishes
|
|
775
|
+
// nothing and leaves the durable rows untouched for that owner.
|
|
776
|
+
const startLifecycle = pendingSessionLifecycle(sessionId);
|
|
777
|
+
const startToken = pendingLifecycleToken(startLifecycle);
|
|
778
|
+
if (startLifecycle.closed) return 0;
|
|
492
779
|
try {
|
|
493
780
|
const deliveredLedger = new Set(ledgerSession?.deliveredPendingMessageIds || []);
|
|
494
781
|
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
@@ -524,35 +811,51 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
524
811
|
// acknowledges these exact ids. A crash here therefore redelivers.
|
|
525
812
|
return undefined;
|
|
526
813
|
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
814
|
+
if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
|
|
527
815
|
await options.beforePublish?.(hydrated);
|
|
816
|
+
if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
|
|
528
817
|
} catch (err) {
|
|
529
818
|
try { process.stderr.write(`[session] pending-message hydrate failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
530
819
|
return 0;
|
|
531
820
|
}
|
|
532
821
|
const cleanupConfirmed = staleLedgerEntries.slice();
|
|
533
822
|
if (alreadyDelivered.length > 0) {
|
|
534
|
-
const cleaned = await acknowledgePendingMessages(sessionId, alreadyDelivered);
|
|
823
|
+
const cleaned = await acknowledgePendingMessages(sessionId, alreadyDelivered, { expectedToken: startToken });
|
|
535
824
|
if (cleaned) cleanupConfirmed.push(...alreadyDelivered);
|
|
825
|
+
// Every individual cleanup await is its own window: stop before the
|
|
826
|
+
// next mutation once the lifecycle moved (the remaining spool rows and
|
|
827
|
+
// the ledger belong to the new owner from here on).
|
|
828
|
+
runPendingTestHook('hydrate:betweenCleanups', { sessionId });
|
|
829
|
+
if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
|
|
536
830
|
}
|
|
537
831
|
if (staleUserEntries.length > 0) {
|
|
538
832
|
try { process.stderr.write(`[session] dropped ${staleUserEntries.length} stale queued message(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
|
|
539
|
-
const cleaned = await acknowledgePendingMessages(sessionId, staleUserEntries);
|
|
833
|
+
const cleaned = await acknowledgePendingMessages(sessionId, staleUserEntries, { expectedToken: startToken });
|
|
540
834
|
if (cleaned) cleanupConfirmed.push(...staleUserEntries);
|
|
835
|
+
runPendingTestHook('hydrate:betweenCleanups', { sessionId });
|
|
836
|
+
if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
|
|
541
837
|
}
|
|
542
838
|
if (cleanupConfirmed.length > 0) {
|
|
543
839
|
try {
|
|
544
840
|
// One session save prunes both IDs whose replay spool was removed
|
|
545
841
|
// now and IDs whose spool was already absent at hydration start.
|
|
546
|
-
await pruneCleanupConfirmedLedger(sessionId, cleanupConfirmed, ledgerSession);
|
|
842
|
+
await pruneCleanupConfirmedLedger(sessionId, cleanupConfirmed, ledgerSession, null, startToken);
|
|
547
843
|
} catch (err) {
|
|
548
844
|
try { process.stderr.write(`[session] pending-message ledger prune failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
549
845
|
}
|
|
846
|
+
if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
|
|
550
847
|
}
|
|
551
848
|
if (hydrated.length > 0) {
|
|
849
|
+
// Last revalidation: the ack/prune awaits above are another window.
|
|
850
|
+
if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
|
|
552
851
|
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
553
852
|
for (const entry of hydrated) {
|
|
554
853
|
const id = pendingMessageId(entry);
|
|
555
854
|
if (id) inDelivery.add(id);
|
|
855
|
+
// Durable rows carry no symbol: stamp the epoch this hydration was
|
|
856
|
+
// validated under, so every later clone/claim/ack judges itself by
|
|
857
|
+
// that immutable token instead of the epoch current at the time.
|
|
858
|
+
stampLifecycleToken(entry, startToken);
|
|
556
859
|
}
|
|
557
860
|
const existing = _hydratedPendingMessages.get(sessionId) || [];
|
|
558
861
|
existing.push(...hydrated);
|
|
@@ -578,7 +881,12 @@ function clearPersistedPendingMessages(sessionId) {
|
|
|
578
881
|
// dropped synchronously by _dropPendingMessageState; only the durable
|
|
579
882
|
// spool row lags behind, exactly like the ack path.
|
|
580
883
|
const preceding = _pendingPersistTails.get(sessionId) || Promise.resolve();
|
|
884
|
+
// Epoch this clear was ordered under (normally the tombstone that just
|
|
885
|
+
// landed). Re-validated inside the lock: a reopen/detach that moves the
|
|
886
|
+
// generation while this async clear waits must leave the new owner's rows.
|
|
887
|
+
const epochToken = currentPendingLifecycleToken(sessionId);
|
|
581
888
|
const operation = preceding.catch(() => {}).then(() => updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
889
|
+
if (pendingLifecycleEpochMoved(sessionId, epochToken)) return undefined;
|
|
582
890
|
const next = normalizePendingStore(raw);
|
|
583
891
|
if (!Object.prototype.hasOwnProperty.call(next.sessions, sessionId)) return undefined;
|
|
584
892
|
delete next.sessions[sessionId];
|
|
@@ -695,6 +1003,17 @@ export function enqueuePendingMessage(sessionId, message) {
|
|
|
695
1003
|
// Caller-provided ids are never trusted across sessions/processes.
|
|
696
1004
|
const entry = normalized ? { ...normalized, id: newPendingMessageId() } : null;
|
|
697
1005
|
if (!sessionId || !entry) return 0;
|
|
1006
|
+
// A provider/tool completion that lands after the session was tombstoned
|
|
1007
|
+
// must NOT recreate memory/spool state (and never clears the tombstone):
|
|
1008
|
+
// only an explicit create/reopen record makes the session writable again.
|
|
1009
|
+
const token = currentPendingLifecycleToken(sessionId);
|
|
1010
|
+
if (pendingLifecycleInvalidated(sessionId)) return 0;
|
|
1011
|
+
stampLifecycleToken(entry, token);
|
|
1012
|
+
// Re-validate immediately before the memory publish: a close/detach landing
|
|
1013
|
+
// in this window must reject the input instead of recreating state for a
|
|
1014
|
+
// session the new owner already took over.
|
|
1015
|
+
runPendingTestHook('enqueue:beforePublish', { sessionId });
|
|
1016
|
+
if (pendingLifecycleInvalidated(sessionId, token)) return 0;
|
|
698
1017
|
let q = _sessionPendingMessages.get(sessionId);
|
|
699
1018
|
if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
|
|
700
1019
|
q.push(entry);
|
|
@@ -712,9 +1031,19 @@ export function enqueuePendingMessage(sessionId, message) {
|
|
|
712
1031
|
*/
|
|
713
1032
|
export function enqueueRemotePendingMessage(sessionId, message) {
|
|
714
1033
|
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
1034
|
+
// Same durable-lifecycle authority as the local enqueue: a cross-surface
|
|
1035
|
+
// submit that lands after the session was closed is refused before any
|
|
1036
|
+
// spool mutation.
|
|
1037
|
+
const token = currentPendingLifecycleToken(sessionId);
|
|
1038
|
+
if (pendingLifecycleInvalidated(sessionId)) return 0;
|
|
715
1039
|
const normalized = pendingMessageQueueEntry(message);
|
|
716
1040
|
if (!normalized) return 0;
|
|
717
|
-
|
|
1041
|
+
// The token rides into the async spool commit, which revalidates under the
|
|
1042
|
+
// spool lock (see persistPendingMessages).
|
|
1043
|
+
runPendingTestHook('enqueue:beforePublish', { sessionId });
|
|
1044
|
+
if (pendingLifecycleInvalidated(sessionId, token)) return 0;
|
|
1045
|
+
const remoteEntry = stampLifecycleToken({ ...normalized, id: newPendingMessageId() }, token);
|
|
1046
|
+
return persistPendingMessages(sessionId, [remoteEntry]);
|
|
718
1047
|
}
|
|
719
1048
|
|
|
720
1049
|
// Spool-file mtime gate so the owner's idle poller costs one stat per tick,
|
|
@@ -745,10 +1074,18 @@ function _rememberForeignSpoolScan(sessionId, mtime) {
|
|
|
745
1074
|
*/
|
|
746
1075
|
export function drainForeignUserInjections(sessionId) {
|
|
747
1076
|
if (!isValidPendingSessionId(sessionId)) return [];
|
|
1077
|
+
// DELIVERY path, not cleanup: this takes rows out of the spool and hands
|
|
1078
|
+
// them to a live turn. A tombstoned (or otherwise invalidated) session may
|
|
1079
|
+
// neither receive nor lose input, so the tombstone-TOLERANT epoch check
|
|
1080
|
+
// used by the explicit cleanup mutations (ack / clear) is wrong here — the
|
|
1081
|
+
// token is refused outright when it was captured from a closed session.
|
|
1082
|
+
// Checked BEFORE the mtime memo so a later reopen still sees this spool
|
|
1083
|
+
// state as unscanned.
|
|
1084
|
+
const epochToken = currentPendingLifecycleToken(sessionId);
|
|
1085
|
+
if (pendingLifecycleInvalidated(sessionId)) return [];
|
|
748
1086
|
let mtime = 0;
|
|
749
1087
|
try { mtime = statSync(pendingMessagesPath()).mtimeMs || 0; } catch { return []; }
|
|
750
1088
|
if (_foreignSpoolScanMtimes.get(sessionId) === mtime) return [];
|
|
751
|
-
_rememberForeignSpoolScan(sessionId, mtime);
|
|
752
1089
|
const localIds = new Set();
|
|
753
1090
|
for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
|
|
754
1091
|
for (const entry of map.get(sessionId) || []) {
|
|
@@ -770,8 +1107,25 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
770
1107
|
} catch { /* ledger unavailable — in-memory sets still guard */ }
|
|
771
1108
|
const taken = [];
|
|
772
1109
|
let droppedStale = 0;
|
|
1110
|
+
// The mtime memo may ONLY be armed by a scan that actually reached a
|
|
1111
|
+
// lifecycle-valid decision under the spool lock. Arming it upfront meant a
|
|
1112
|
+
// drain refused in-lock (a close/detach/reopen landing in the window) still
|
|
1113
|
+
// recorded this spool state as "seen": after the reopen the new owner
|
|
1114
|
+
// polled the same unchanged mtime and skipped it, stranding the rows until
|
|
1115
|
+
// some unrelated spool WRITE bumped the file. A refusal must leave the
|
|
1116
|
+
// spool immediately pollable again.
|
|
1117
|
+
let lifecycleDecided = false;
|
|
773
1118
|
try {
|
|
774
1119
|
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
1120
|
+
// Same authority re-read INSIDE the spool lock: a close/detach
|
|
1121
|
+
// landing in this window must neither deliver to the old owner nor
|
|
1122
|
+
// remove the reopened owner's rows.
|
|
1123
|
+
runPendingTestHook('foreignDrain:beforeCommit', { sessionId });
|
|
1124
|
+
if (pendingLifecycleInvalidated(sessionId, epochToken)) return undefined;
|
|
1125
|
+
// Authoritative from here on: whatever this pass decides (take,
|
|
1126
|
+
// drop-stale, or "nothing foreign here") is a real answer for THIS
|
|
1127
|
+
// spool state, so the memo may skip re-scanning it.
|
|
1128
|
+
lifecycleDecided = true;
|
|
775
1129
|
const next = normalizePendingStore(raw);
|
|
776
1130
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
777
1131
|
if (q.length === 0) return undefined;
|
|
@@ -803,6 +1157,7 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
803
1157
|
try { process.stderr.write(`[session] foreign-injection drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
804
1158
|
return [];
|
|
805
1159
|
}
|
|
1160
|
+
if (lifecycleDecided) _rememberForeignSpoolScan(sessionId, mtime);
|
|
806
1161
|
if (droppedStale > 0) {
|
|
807
1162
|
try { process.stderr.write(`[session] dropped ${droppedStale} stale foreign submit(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
|
|
808
1163
|
}
|
|
@@ -874,9 +1229,35 @@ export function drainPendingMessages(sessionId) {
|
|
|
874
1229
|
const visible = modelVisiblePendingMessages(ordered
|
|
875
1230
|
.filter(({ entry }) => !dropped.includes(entry))
|
|
876
1231
|
.map(({ entry }) => entry));
|
|
1232
|
+
// Epoch gate. Normalization above rebuilds every entry, but each copy
|
|
1233
|
+
// inherits the IMMUTABLE token the entry was accepted under. An entry from
|
|
1234
|
+
// an older epoch (a cross-process close/detach moved the generation while
|
|
1235
|
+
// it sat in memory) belongs to the previous owner: it is REJECTED here,
|
|
1236
|
+
// never restamped with the current epoch — which would legitimize it and
|
|
1237
|
+
// let a failed turn restore it into the new owner's queue.
|
|
1238
|
+
const lifecycleNow = pendingSessionLifecycle(sessionId);
|
|
1239
|
+
const accepted = [];
|
|
1240
|
+
let rejected = 0;
|
|
1241
|
+
for (const entry of visible) {
|
|
1242
|
+
const token = entryLifecycleToken(entry);
|
|
1243
|
+
if (token && (lifecycleNow.closed || lifecycleTokenStale(lifecycleNow, token))) {
|
|
1244
|
+
rejected += 1;
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
accepted.push(entry);
|
|
1248
|
+
}
|
|
1249
|
+
if (rejected > 0) {
|
|
1250
|
+
try {
|
|
1251
|
+
process.stderr.write(
|
|
1252
|
+
`[session] rejected ${rejected} queued message(s) from a superseded lifecycle epoch sessionId=${sessionId}\n`,
|
|
1253
|
+
);
|
|
1254
|
+
} catch { /* best-effort */ }
|
|
1255
|
+
}
|
|
877
1256
|
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
878
|
-
for (const entry of
|
|
879
|
-
|
|
1257
|
+
for (const entry of accepted) if (entry.id) inDelivery.add(entry.id);
|
|
1258
|
+
// Keep the exact claimed payloads until ack (delivered) or release.
|
|
1259
|
+
claimPendingEntries(sessionId, accepted);
|
|
1260
|
+
return accepted;
|
|
880
1261
|
}
|
|
881
1262
|
|
|
882
1263
|
// Snapshot queued entries without draining them. Compaction uses this to keep
|
|
@@ -913,6 +1294,12 @@ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
|
|
|
913
1294
|
try { persistPendingMessages(id, buffered); } catch { /* ignore */ }
|
|
914
1295
|
}
|
|
915
1296
|
}
|
|
1297
|
+
// Tombstoned close: the claim copies are dead (the session record itself
|
|
1298
|
+
// now refuses any restore). Detach keeps them so a still-in-flight ask can
|
|
1299
|
+
// release and restore its claimed entries after this cleanup runs.
|
|
1300
|
+
if (clearPersisted) {
|
|
1301
|
+
try { _claimedPendingMessages.delete(id); } catch { /* ignore */ }
|
|
1302
|
+
}
|
|
916
1303
|
try { _sessionPendingMessages.delete(id); } catch { /* ignore */ }
|
|
917
1304
|
try { _hydratedPendingMessages.delete(id); } catch { /* ignore */ }
|
|
918
1305
|
try { _pendingPersistBuffers.delete(id); } catch { /* ignore */ }
|
|
@@ -924,12 +1311,86 @@ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
|
|
|
924
1311
|
}
|
|
925
1312
|
}
|
|
926
1313
|
|
|
1314
|
+
// TEST-ONLY determinism seam (never called by runtime code): flush this
|
|
1315
|
+
// module's own buffered persist batch and await its own async spool tails
|
|
1316
|
+
// (persist / ack / clear) and in-flight hydrations, so tests can wait for the
|
|
1317
|
+
// asynchronous persistence tail instead of guessing a sleep duration. Tails
|
|
1318
|
+
// can re-chain (an ack chains behind a persist), so this loops until the maps
|
|
1319
|
+
// are empty, bounded by timeoutMs — a genuinely stuck write still fails loudly
|
|
1320
|
+
// instead of hanging forever.
|
|
1321
|
+
export async function _settlePendingMessageWrites({ timeoutMs = 5000 } = {}) {
|
|
1322
|
+
const budget = Math.max(0, Number(timeoutMs) || 0);
|
|
1323
|
+
const deadline = Date.now() + budget;
|
|
1324
|
+
const expired = () => new Error(
|
|
1325
|
+
`pending-message writes did not settle within ${budget}ms `
|
|
1326
|
+
+ `(tails=${_pendingPersistTails.size}, hydrations=${_pendingHydrations.size}, buffers=${_pendingPersistBuffers.size})`,
|
|
1327
|
+
);
|
|
1328
|
+
let timer = null;
|
|
1329
|
+
const expiry = new Promise((_resolve, reject) => {
|
|
1330
|
+
timer = setTimeout(() => reject(expired()), Math.max(0, deadline - Date.now()));
|
|
1331
|
+
});
|
|
1332
|
+
// Deliberately NOT unref'd: while settle waits on a stuck tail there may be
|
|
1333
|
+
// no other handle, and an unref'd timer would let the loop drain (the wait
|
|
1334
|
+
// would then never reject). The finally below always clears it.
|
|
1335
|
+
expiry.catch(() => {});
|
|
1336
|
+
try {
|
|
1337
|
+
for (;;) {
|
|
1338
|
+
// Snapshot BEFORE flushing: persistPendingMessages OVERWRITES this
|
|
1339
|
+
// session's map entry with the new operation, so an in-flight tail
|
|
1340
|
+
// captured only after the flush would be silently dropped and never
|
|
1341
|
+
// awaited. Both the pre-flush tails and the newly chained ones must
|
|
1342
|
+
// be awaited, and the loop repeats because acks/clears chain further
|
|
1343
|
+
// tails behind them.
|
|
1344
|
+
const preFlushTails = [..._pendingPersistTails.values()];
|
|
1345
|
+
flushPendingMessagePersistsSync();
|
|
1346
|
+
const inflight = new Set([
|
|
1347
|
+
...preFlushTails,
|
|
1348
|
+
..._pendingPersistTails.values(),
|
|
1349
|
+
..._pendingHydrations.values(),
|
|
1350
|
+
]);
|
|
1351
|
+
if (inflight.size === 0 && _pendingPersistBuffers.size === 0) return;
|
|
1352
|
+
if (Date.now() > deadline) throw expired();
|
|
1353
|
+
// Raced against the absolute deadline: a never-settling tail must
|
|
1354
|
+
// reject within timeoutMs instead of blocking here forever.
|
|
1355
|
+
await Promise.race([Promise.allSettled([...inflight]), expiry]);
|
|
1356
|
+
await Promise.race([new Promise((resolve) => setImmediate(resolve)), expiry]);
|
|
1357
|
+
}
|
|
1358
|
+
} finally {
|
|
1359
|
+
if (timer) clearTimeout(timer);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// TEST-ONLY: publish a promise as this session's persist tail using the same
|
|
1364
|
+
// set + self-removal pattern as persistPendingMessages, so settle tests can
|
|
1365
|
+
// model a slow (or stuck) spool write deterministically. Runtime code never
|
|
1366
|
+
// calls this and behavior is unchanged.
|
|
1367
|
+
export function _setPendingPersistTailForTest(sessionId, promise) {
|
|
1368
|
+
const operation = Promise.resolve(promise).catch(() => {});
|
|
1369
|
+
_pendingPersistTails.set(sessionId, operation);
|
|
1370
|
+
operation.finally(() => {
|
|
1371
|
+
if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
|
|
1372
|
+
}).catch(() => {});
|
|
1373
|
+
return operation;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// TEST-ONLY: deterministic seams for the check→mutate windows. Runtime code
|
|
1377
|
+
// never installs hooks; behavior is unchanged when none are set.
|
|
1378
|
+
// 'enqueue:beforePublish' — between the lifecycle check and the memory /
|
|
1379
|
+
// spool publish of a local/remote enqueue
|
|
1380
|
+
// 'persist:beforeCommit' — inside the spool lock, before the durable
|
|
1381
|
+
// commit re-validates each entry's token
|
|
1382
|
+
// 'ack:beforeCommit' — inside the spool lock, before the ack
|
|
1383
|
+
// re-validates its epoch and deletes rows
|
|
1384
|
+
// 'hydrate:betweenCleanups' — after each acknowledgement/prune await
|
|
1385
|
+
export function _setPendingTestHooks(hooks) {
|
|
1386
|
+
_pendingTestHooks = hooks && typeof hooks === 'object' ? hooks : null;
|
|
1387
|
+
return _pendingTestHooks;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
927
1390
|
setImmediate(() => {
|
|
928
|
-
// Spool hygiene belongs to the lead/daemon process.
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
// the lead's writes (observed in the first live shard run: "atomic lock
|
|
932
|
-
// timeout after 8000ms: session-pending-messages.json.lock") for zero
|
|
1391
|
+
// Spool hygiene belongs to the lead/daemon process. Channel workers share
|
|
1392
|
+
// the same spool file; a boot-time SYNC sweep in every child contends on
|
|
1393
|
+
// the cross-process lock against the lead's writes for zero
|
|
933
1394
|
// benefit — the lead already sweeps.
|
|
934
1395
|
if (process.env.MIXDOG_WORKER_MODE === '1') return;
|
|
935
1396
|
try { sweepOrphanedPendingMessages(); } catch { /* ignore */ }
|