@try-works/dsh-memory 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +294 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/scripts/inspect-log.ts +40 -0
  8. package/scripts/repair-ignorable.ts +130 -0
  9. package/scripts/scan-sessions.ts +43 -0
  10. package/scripts/verify-log.ts +26 -0
  11. package/src/SYSTEM.template.md +311 -311
  12. package/src/capture.js +242 -242
  13. package/src/client/index.js +886 -886
  14. package/src/config.js +57 -57
  15. package/src/consolidate-run.js +132 -132
  16. package/src/consolidate.js +330 -330
  17. package/src/context.js +88 -88
  18. package/src/decisions.js +106 -106
  19. package/src/defaults.js +93 -93
  20. package/src/digest.js +255 -255
  21. package/src/entry.js +179 -179
  22. package/src/eval.js +72 -72
  23. package/src/files.js +60 -60
  24. package/src/fixture.js +56 -56
  25. package/src/forget.js +71 -71
  26. package/src/freshness.js +65 -65
  27. package/src/fts.js +166 -166
  28. package/src/git.js +53 -53
  29. package/src/history.js +226 -226
  30. package/src/host.js +884 -866
  31. package/src/index.js +115 -94
  32. package/src/inject.js +108 -108
  33. package/src/layers.js +178 -178
  34. package/src/ledger.js +19 -19
  35. package/src/map.js +184 -184
  36. package/src/models.js +136 -136
  37. package/src/queue.js +602 -602
  38. package/src/rank.js +31 -31
  39. package/src/recover.js +91 -91
  40. package/src/refresh.js +80 -80
  41. package/src/remember.js +58 -58
  42. package/src/rerank.js +90 -90
  43. package/src/search.js +75 -75
  44. package/src/select.js +57 -57
  45. package/src/session.js +60 -60
  46. package/src/state.js +240 -240
  47. package/src/store.js +128 -128
  48. package/src/summarize.js +195 -195
  49. package/src/system-doc.js +171 -171
  50. package/src/tasks.js +617 -617
  51. package/src/telemetry.js +122 -122
  52. package/src/tools.js +828 -818
  53. package/src/working.js +288 -288
  54. package/src/workspace-key.js +68 -68
  55. package/src/workspace.js +87 -87
package/src/queue.js CHANGED
@@ -1,602 +1,602 @@
1
- // The memory queue: an append-only journal of memory CANDIDATES and how far each got.
2
- //
3
- // This is NOT the memory record. Markdown under the layers is the record, and it stays
4
- // human-readable, git-diffable and hand-editable. This is the operational queue that feeds it:
5
- // every session that might become memory gets one entry, and that entry carries an explicit
6
- // STATE rather than a bag of flags a reader has to interpret.
7
- //
8
- // Why explicit states. The previous design stored a settled timestamp, a summary source, an
9
- // attempt counter, a review flag and a watermark in one record, and every question about it was
10
- // answered by DERIVING semantics: pending meant the timestamp was null, gave-up meant the
11
- // timestamp was set AND a review flag was set, failed meant an attempt counter crossed a
12
- // threshold, deferred meant a source string had a particular value. Each derivation is a place
13
- // to get it wrong, and several were: a failed summary was once indistinguishable from a
14
- // deliberate conclusion, and empty digests were consumed as if they were finished work.
15
- //
16
- // Why a journal rather than a map of current states. A map answers where is this now, and
17
- // nothing else. The journal also answers how it got there, which is what makes a stuck queue
18
- // visible: an entry that has been summarizing for a day is a different problem from one that
19
- // failed, and orphaned - a candidate whose material is gone - is invisible in a map that keeps
20
- // only terminal flags.
21
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
22
- import { join } from 'node:path'
23
-
24
- /** Bump when the entry envelope changes shape; readers refuse unknown versions. */
25
- export const QUEUE_FORMAT = 1
26
-
27
- /**
28
- * The lifecycle of one memory candidate.
29
- *
30
- * pending and summarized are waiting states. summarizing and consolidating are in flight.
31
- * added and skipped are successful-terminal: the work is done, whether or not anything was
32
- * written. failed and orphaned are problem-terminal: work stopped for a reason a reader must
33
- * see. held is parking, used to stage a batch deliberately.
34
- */
35
- export const STATES = {
36
- PENDING: 'pending',
37
- SUMMARIZING: 'summarizing',
38
- SUMMARIZED: 'summarized',
39
- CONSOLIDATING: 'consolidating',
40
- ADDED: 'added',
41
- SKIPPED: 'skipped',
42
- FAILED: 'failed',
43
- ORPHANED: 'orphaned',
44
- HELD: 'held',
45
- ABANDONED: 'abandoned',
46
- }
47
-
48
- /** States from which nothing further happens without an explicit operator action. */
49
- export const TERMINAL = [STATES.ADDED, STATES.SKIPPED, STATES.ABANDONED]
50
-
51
- /** The only transitions the queue records. An illegal one is a bug, not a silent state. */
52
- const TRANSITIONS = {
53
- [STATES.PENDING]: [STATES.SUMMARIZING, STATES.HELD, STATES.ORPHANED, STATES.SKIPPED],
54
- [STATES.SUMMARIZING]: [STATES.SUMMARIZED, STATES.SKIPPED, STATES.FAILED, STATES.PENDING, STATES.ABANDONED],
55
- // A candidate is a SESSION, and a session keeps producing material. So every resting state has
56
- // exactly one exit back into the summarizing stage, meaning NEW MATERIAL ARRIVED. That is an
57
- // external event rather than the queue driving itself, which is why these states still count as
58
- // resting: nothing here reopens on its own.
59
- [STATES.SUMMARIZED]: [STATES.CONSOLIDATING, STATES.SUMMARIZING, STATES.HELD, STATES.ORPHANED],
60
- [STATES.CONSOLIDATING]: [STATES.ADDED, STATES.SKIPPED, STATES.FAILED, STATES.SUMMARIZED, STATES.ABANDONED],
61
- [STATES.HELD]: [STATES.PENDING, STATES.SUMMARIZED, STATES.SUMMARIZING],
62
- [STATES.ADDED]: [STATES.SUMMARIZING],
63
- [STATES.SKIPPED]: [STATES.SUMMARIZING],
64
- // A retry re-enters the stage that failed, which the entry records as its `from`. Requeueing
65
- // (pending/summarized) is the heavier option for when the material itself must be rebuilt.
66
- [STATES.FAILED]: [STATES.SUMMARIZING, STATES.CONSOLIDATING, STATES.PENDING, STATES.SUMMARIZED, STATES.ABANDONED, STATES.HELD],
67
- [STATES.ORPHANED]: [STATES.SUMMARIZING, STATES.CONSOLIDATING, STATES.PENDING, STATES.SUMMARIZED, STATES.ABANDONED, STATES.HELD],
68
- [STATES.ABANDONED]: [STATES.PENDING, STATES.SUMMARIZED, STATES.SUMMARIZING],
69
- }
70
-
71
- /** True when a transition from one state to another is allowed. */
72
- export function canTransition(from, to) {
73
- // A candidate can be BORN waiting or BORN PARKED. Parked matters: staging a batch - the
74
- // backfill hold - creates entries that must not be worked until an operator releases them,
75
- // and refusing that would force the hold to live outside the queue it belongs to.
76
- if (from === undefined) return to === STATES.PENDING || to === STATES.SUMMARIZED || to === STATES.HELD
77
- return (TRANSITIONS[from] ?? []).includes(to)
78
- }
79
-
80
- /** Is this state finished? */
81
- export function isTerminal(state) { return TERMINAL.includes(state) }
82
-
83
- /**
84
- * Reason codes for a state that stopped. A state alone says work stopped; the reason says why,
85
- * which is the difference between a correct decision and a defect.
86
- */
87
- export const REASONS = {
88
- THIN: 'thin', // delta below the signal floor: not worth a model call
89
- TRIVIAL: 'trivial', // a ping, or maintenance chatter
90
- NO_DURABLE: 'no-durable', // the model read it and concluded nothing was durable
91
- EMPTY_REPLY: 'empty-reply', // the model returned no text: a defect
92
- UNPARSEABLE: 'unparseable', // the reply did not follow the protocol
93
- NO_MODEL: 'no-model', // no routable model produced text
94
- ATTEMPTS: 'attempts-exhausted', // retried to the cap
95
- DIGEST_GONE: 'digest-missing', // the derived material was pruned before it was used
96
- NO_ENTRIES: 'no-entries', // consolidated, and the model added nothing
97
- OPERATOR: 'operator', // parked deliberately
98
- NEEDS_APPROVAL: 'needs-approval', // parked because acting would cost more than a guess is worth
99
- UNCLASSIFIED: 'unclassified', // a legacy empty summary whose cause was never recorded
100
- STALLED: 'stalled', // a stage stopped reporting progress: presumed dead
101
- }
102
-
103
- export function queuePaths(root) {
104
- return { journal: join(root, 'state', 'queue.jsonl'), snapshot: join(root, 'state', 'queue.json') }
105
- }
106
-
107
- /** One ISO stamp, taken once so an entry is internally consistent. */
108
- function stamp(now) { return (now ?? new Date()).toISOString() }
109
-
110
- /**
111
- * Read the journal and fold it into the current state of every candidate.
112
- * The last entry for an id wins; a repeat of the same state is a refresh, not a transition.
113
- * @returns a Map of id -> entry.
114
- */
115
- export function readQueue(root) {
116
- const { journal } = queuePaths(root)
117
- const current = new Map()
118
- let raw = ''
119
- try { raw = readFileSync(journal, 'utf8') } catch { return current }
120
- for (const line of raw.split('\n')) {
121
- if (line === '') continue
122
- let entry
123
- try { entry = JSON.parse(line) } catch { continue }
124
- // Refuse a foreign envelope rather than misreading it as this format.
125
- if (entry.v !== QUEUE_FORMAT || typeof entry.id !== 'string') continue
126
- current.set(entry.id, entry)
127
- }
128
- return current
129
- }
130
-
131
- /** The journal's next position: monotonic, so order never depends on clocks. */
132
- export function nextSeq(root) {
133
- const { journal } = queuePaths(root)
134
- let last = 0
135
- try {
136
- for (const line of readFileSync(journal, 'utf8').split('\n')) {
137
- if (line === '') continue
138
- try { const e = JSON.parse(line); if (typeof e.seq === 'number' && e.seq > last) last = e.seq } catch {}
139
- }
140
- } catch {}
141
- return last + 1
142
- }
143
-
144
- /**
145
- * Append one transition.
146
- *
147
- * The journal is APPEND-ONLY: a state change never rewrites history, so the queue's past stays
148
- * auditable. A snapshot beside it is rewritten on every change, because re-reading a long
149
- * journal to answer what is pending is wasteful. The snapshot is derived and never trusted as
150
- * the source of truth.
151
- *
152
- * @param root - store root.
153
- * @param id - the candidate's stable id (a session id, or a caller-chosen key).
154
- * @param state - one of STATES.
155
- * @param options.reason - required when work stops, so a decision can be told from a defect.
156
- * @param options.attempts - attempt counter for the stage that just ran.
157
- * @param options.detail - anything else worth keeping (tier, counts, model used).
158
- * @throws when the transition is illegal, rather than recording an unreachable state.
159
- */
160
- export function transition(root, id, state, options = {}) {
161
- if (!Object.values(STATES).includes(state)) throw new Error('queue: unknown state ' + state)
162
- if (typeof id !== 'string' || id === '') throw new Error('queue: an entry needs an id')
163
- const current = readQueue(root).get(id)
164
- const from = current?.state
165
- if (from !== state && !canTransition(from, state)) {
166
- throw new Error('queue: illegal transition ' + (from ?? '(new)') + ' -> ' + state + ' for ' + id)
167
- }
168
- const stops = state === STATES.FAILED || state === STATES.SKIPPED || state === STATES.ORPHANED
169
- // The reason requirement is about ENTERING a stopping state, not restating one. A refresh -
170
- // the same state recorded again to carry new detail - keeps the reason already on the entry,
171
- // so demanding it afresh would make a legitimate refresh impossible.
172
- if (stops && from !== state && !options.reason) {
173
- throw new Error('queue: ' + state + ' needs a reason so a reader can tell a decision from a defect')
174
- }
175
- // The projection is LAST-ENTRY-WINS over standalone entries, so a transition that does not
176
- // restate a field DROPS it. Carrying counters and detail forward is therefore not a
177
- // convenience: without it an attempt counter resets on every hop and a retry cap can never be
178
- // reached, which is precisely the bug this line fixes.
179
- const attempts = options.attempts ?? current?.attempts
180
- const detail = { ...(current?.detail ?? {}), ...(options.detail ?? {}) }
181
- const retryable = state === STATES.FAILED || state === STATES.ORPHANED
182
- const nextAttemptAt = options.nextAttemptAt ?? (retryable ? current?.nextAttemptAt : undefined)
183
- const entry = {
184
- v: QUEUE_FORMAT,
185
- seq: nextSeq(root),
186
- at: stamp(options.now),
187
- id,
188
- state,
189
- ...(from === undefined || from === state ? {} : { from }),
190
- ...((options.reason ?? (from === state ? current?.reason : undefined)) ? { reason: options.reason ?? current.reason } : {}),
191
- ...(attempts === undefined ? {} : { attempts }),
192
- ...(nextAttemptAt ? { nextAttemptAt } : {}),
193
- ...(Object.keys(detail).length ? { detail } : {}),
194
- }
195
- mkdirSync(join(root, 'state'), { recursive: true })
196
- const { journal } = queuePaths(root)
197
- try { if (existsSync(journal) && statSync(journal).size > 4 * 1024 * 1024) renameSync(journal, journal + '.1') } catch {}
198
- appendFileSync(journal, JSON.stringify(entry) + '\n')
199
- writeSnapshot(root, readQueue(root))
200
- return entry
201
- }
202
-
203
- /** Rewrite the derived snapshot. Cheap to regenerate, so it is never authoritative. */
204
- function writeSnapshot(root, current) {
205
- try {
206
- const { snapshot } = queuePaths(root)
207
- const plain = {}
208
- for (const [id, entry] of current) plain[id] = entry
209
- writeFileSync(snapshot, JSON.stringify(plain, null, 2) + '\n', 'utf8')
210
- } catch {}
211
- }
212
-
213
- /** Candidates currently in one of the given states. */
214
- export function byState(root, states) {
215
- const want = new Set(Array.isArray(states) ? states : [states])
216
- return [...readQueue(root).values()].filter((e) => want.has(e.state))
217
- }
218
-
219
- /** Work waiting to be done, in journal order. */
220
- export function pending(root) {
221
- return byState(root, [STATES.PENDING, STATES.SUMMARIZED])
222
- }
223
-
224
- /** Entries that stopped for a reason a reader must see. */
225
- export function problems(root) {
226
- // ABANDONED belongs here most of all. It is the end of the retry loop, so if it is not listed
227
- // as a problem the work that gave up is the work nobody sees - which is exactly the silent
228
- // loss this queue exists to prevent.
229
- return byState(root, [STATES.FAILED, STATES.ORPHANED, STATES.ABANDONED])
230
- }
231
-
232
- /** A count per state, for status output. */
233
- export function summarize(root) {
234
- const counts = {}
235
- for (const entry of readQueue(root).values()) counts[entry.state] = (counts[entry.state] ?? 0) + 1
236
- return counts
237
- }
238
-
239
- /**
240
- * Retry policy for work that failed.
241
- *
242
- * A failure is a statement about the ATTEMPT, not about the material, so nothing may rest at
243
- * failed: it is retried until it succeeds or until the attempts are spent. Backoff keeps a
244
- * broken candidate from spending a model call every tick while it is broken, and it grows
245
- * geometrically with a ceiling so a long outage does not push the next attempt days away.
246
- *
247
- * The cap exists because retrying forever is its own failure: it burns calls on work that has
248
- * demonstrably not worked. Reaching it produces ABANDONED, which stays visible and can be
249
- * revived - unlike the old design, where an exhausted candidate was marked done and vanished.
250
- */
251
- export const RETRY = {
252
- maxAttempts: 5,
253
- baseMs: 60 * 1000,
254
- maxMs: 6 * 60 * 60 * 1000,
255
- }
256
-
257
- /**
258
- * Delay before the next attempt: base, then 4x each time, capped. Attempt 1 waits base,
259
- * attempt 2 waits 4x base, attempt 3 waits 16x base, and so on up to the ceiling.
260
- */
261
- export function backoffMs(attempts, { baseMs = RETRY.baseMs, maxMs = RETRY.maxMs } = {}) {
262
- const n = Math.max(1, Number(attempts) || 1)
263
- return Math.min(baseMs * Math.pow(4, n - 1), maxMs)
264
- }
265
-
266
- /**
267
- * What to do with one entry that stopped.
268
- * @returns {action, waitMs, attempts, reason} where action is retry, abandon or wait.
269
- */
270
- export function retryDecision(entry, now = Date.now(), options = {}) {
271
- if (!entry) return { action: 'wait', attempts: 0, reason: 'unknown-entry' }
272
- // ORPHANED is not retryable, and treating it as such is not a harmless extra attempt: the
273
- // material is GONE, so a retry is guaranteed to fail while still costing a model call. What it
274
- // needs is RECOVERY - rebuilding the digest from the source session - which is a different
275
- // action that only the tool performs, and for a large session only with approval.
276
- if (entry.state === STATES.ORPHANED) {
277
- return { action: 'recover', attempts: Number(entry.attempts ?? 0), reason: entry.reason ?? 'digest-missing' }
278
- }
279
- if (entry.state !== STATES.FAILED) {
280
- return { action: 'wait', attempts: Number(entry.attempts ?? 0), reason: 'not-retryable' }
281
- }
282
- const attempts = Number(entry.attempts ?? 1)
283
- const max = options.maxAttempts ?? RETRY.maxAttempts
284
- if (attempts >= max) return { action: 'abandon', attempts, reason: entry.reason ?? 'attempts' }
285
- const due = entry.nextAttemptAt ? Date.parse(entry.nextAttemptAt) : 0
286
- if (Number.isFinite(due) && due > 0 && now < due) return { action: 'wait', waitMs: due - now, attempts, reason: 'backoff' }
287
- return { action: 'retry', attempts, reason: entry.reason ?? '' }
288
- }
289
-
290
- /**
291
- * Everything the cadence should act on now: waiting work, plus failures whose backoff has
292
- * expired. The retry loop lives here rather than in the caller, so a new caller cannot forget
293
- * to bring failed work back into the queue.
294
- */
295
- export function due(root, now = Date.now(), options = {}) {
296
- const out = []
297
- for (const entry of readQueue(root).values()) {
298
- if (entry.state === STATES.PENDING || entry.state === STATES.SUMMARIZED) {
299
- out.push({ entry, action: 'work' })
300
- continue
301
- }
302
- const decision = retryDecision(entry, now, options)
303
- // 'recover' is reported so a caller can SEE it, but it is not work: the cadence cannot perform
304
- // it and must not mistake it for a digest to consolidate.
305
- if (decision.action !== 'wait') out.push({ entry, action: decision.action, decision })
306
- }
307
- // PRIORITY, and it is not cosmetic. Callers take a BOUNDED batch, so queue order decides what
308
- // is ever reached. A failure whose backoff expired is time-sensitive: the retry window is open
309
- // NOW, and it opened because time passed rather than because new work arrived. Waiting work has
310
- // no such deadline. Returning them in sequence order therefore starves retries completely -
311
- // measured on a real store, the single retry sat at position 101 of 111 due items behind 34
312
- // waiting ones, so with a batch of 3 it would never have run and 'we cannot let things stay
313
- // failed' would have been true in the model and false in the system.
314
- const RANK = { retry: 0, abandon: 1, work: 2, recover: 3 }
315
- out.sort((a, b) => (RANK[a.action] ?? 9) - (RANK[b.action] ?? 9))
316
- return out
317
- }
318
-
319
- /**
320
- * Record a failure and schedule the next attempt.
321
- *
322
- * The cap is enforced HERE so no caller can leave a candidate failed forever by forgetting to
323
- * check it: the last permitted failure becomes an abandonment automatically.
324
- */
325
- export function fail(root, id, options = {}) {
326
- const current = readQueue(root).get(id)
327
- const attempts = Number(options.attempts ?? ((current?.attempts ?? 0) + 1))
328
- const max = options.maxAttempts ?? RETRY.maxAttempts
329
- const state = attempts >= max ? STATES.ABANDONED : STATES.FAILED
330
- const at = options.now ?? new Date()
331
- return transition(root, id, state, {
332
- reason: options.reason,
333
- attempts,
334
- now: options.now,
335
- nextAttemptAt: new Date(at.getTime() + backoffMs(attempts)).toISOString(),
336
- detail: { ...(options.detail ?? {}), ...(options.error ? { lastError: String(options.error).slice(0, 200) } : {}) },
337
- })
338
- }
339
-
340
- /** Bring a candidate back into the queue. Works for failed, orphaned and abandoned work. */
341
- export function requeue(root, id, options = {}) {
342
- return transition(root, id, options.state ?? STATES.PENDING, {
343
- attempts: options.attempts ?? 0,
344
- detail: { ...(options.detail ?? {}), requeuedAt: new Date().toISOString(), ...(options.why ? { why: options.why } : {}) },
345
- })
346
- }
347
-
348
- // ---------------------------------------------------------------------------
349
- // Migration from the flag-based state file
350
- // ---------------------------------------------------------------------------
351
-
352
- /**
353
- * Classify one legacy record into a state. PURE: the caller supplies what is on disk.
354
- *
355
- * The guiding rule is CONSERVATIVE: where the old flags are ambiguous, prefer a state that
356
- * causes the work to be looked at again over one that declares it finished. The old design
357
- * could not distinguish a model concluding there was nothing durable from a model returning
358
- * nothing at all, so a record with no recorded reason must not be assumed to be a conclusion.
359
- *
360
- * @param record - one entry from sessions.json.
361
- * @param facts - { digestExists, digestHasSummary } describing the derived material.
362
- * @returns { state, reason, detail } with reason present whenever work stopped.
363
- */
364
- export function classifyLegacyRecord(record = {}, facts = {}) {
365
- const detail = {
366
- ...(record.tier ? { tier: record.tier } : {}),
367
- ...(record.title ? { title: record.title } : {}),
368
- ...(record.capturedAt ? { capturedAt: record.capturedAt } : {}),
369
- ...(record.lastSeq === undefined ? {} : { lastSeq: record.lastSeq }),
370
- ...(record.digestHash ? { digestHash: record.digestHash } : {}),
371
- ...(record.digestFormat === undefined ? {} : { digestFormat: record.digestFormat }),
372
- ...(record.eventCount === undefined ? {} : { eventCount: record.eventCount }),
373
- ...(record.toolCallCount === undefined ? {} : { toolCallCount: record.toolCallCount }),
374
- // The original settled time, kept because the legacy reader still asks WHEN, and a migration
375
- // timestamp would silently rewrite history.
376
- ...(record.consolidatedAt ? { legacyConsolidatedAt: record.consolidatedAt } : {}),
377
- // ABSENT is not the same as null in the old shape: absent means no writer ever set the key,
378
- // null means one explicitly wrote 'not yet'. The distinction is load-bearing for anything
379
- // that asks whether a record was ever touched, so it is carried rather than flattened.
380
- ...(('consolidatedAt' in record) ? {} : { noConsolidatedAtKey: true }),
381
- legacy: true,
382
- }
383
- const attempts = record.consolidateAttempts ?? record.summaryAttempts;
384
- const settled = Boolean(record.consolidatedAt);
385
- const source = record.summarySource;
386
- const reason = record.summaryReason;
387
-
388
- // A candidate explicitly flagged for review is a known problem, whatever else it says.
389
- if (record.needsReview) return { state: STATES.FAILED, reason: REASONS.ATTEMPTS, attempts, detail };
390
-
391
- if (!settled) {
392
- // Waiting work. What it is waiting FOR depends on whether material exists.
393
- if (source === 'backfill') return { state: STATES.HELD, reason: REASONS.OPERATOR, attempts, detail };
394
- if (!facts.digestExists) return { state: STATES.ORPHANED, reason: REASONS.DIGEST_GONE, attempts, detail };
395
- if (facts.digestHasSummary) return { state: STATES.SUMMARIZED, attempts, detail };
396
- return { state: STATES.PENDING, attempts, detail };
397
- }
398
-
399
- // Settled. The honest question is what it settled AS.
400
- if (source === 'llm' || source === 'fallback' || source === 'backfill') {
401
- return { state: STATES.ADDED, attempts, detail };
402
- }
403
- if (source === 'thin') return { state: STATES.SKIPPED, reason: REASONS.THIN, attempts, detail };
404
- if (source === 'skipped') return { state: STATES.SKIPPED, reason: REASONS.TRIVIAL, attempts, detail };
405
- if (source === 'none' || source === undefined) {
406
- // Only a recorded no-durable conclusion is trusted as a decision. Anything else - including
407
- // a legacy record that recorded no reason at all - becomes retryable work, because assuming
408
- // it was a conclusion is exactly how content was lost before.
409
- if (reason === REASONS.NO_DURABLE) return { state: STATES.SKIPPED, reason: REASONS.NO_DURABLE, attempts, detail };
410
- // A KNOWN defect is retryable: retrying is the right response to it.
411
- if (reason) return { state: STATES.FAILED, reason, attempts, detail };
412
- // A missing digest is the operative fact, and it outranks the old flag. A digest is DERIVED,
413
- // so its absence only matters when nothing came of the session: an `added` record whose digest
414
- // was pruned is fine, but one that never produced entries has lost its material and is
415
- // recoverable from the source session. Checking this only in the NOT-settled branch left 78
416
- // recoverable candidates parked as ordinary waiting work, where nothing would ever look.
417
- if (!facts.digestExists) return { state: STATES.ORPHANED, reason: REASONS.DIGEST_GONE, attempts, detail };
418
- // Settled, no source, and the digest HAS content: consolidation marked it done. It stamps a
419
- // timestamp without writing a source, so this is what a successful consolidation looks like.
420
- if (facts.digestHasSummary) return { state: STATES.ADDED, attempts, detail };
421
- // Material present but empty, cause never recorded. An UNKNOWN cause is parked, not retried.
422
- // Marking these failed would put historical sessions back through summarization on a guess.
423
- // sessions back through summarization on the strength of a guess about what went wrong -
424
- // which is a backfill by another name, and a spend nobody asked for. The old writer recorded
425
- // no reason, so a decision and a defect are genuinely indistinguishable here. Parked keeps
426
- // them counted and visible and lets an operator decide, which is the conservative answer.
427
- return { state: STATES.HELD, reason: REASONS.UNCLASSIFIED, attempts, detail };
428
- }
429
- // An unrecognised source is not silently trusted either.
430
- return { state: STATES.FAILED, reason: REASONS.UNCLASSIFIED, attempts, detail };
431
- }
432
-
433
- export function legacyFacts(root, id, readDigest) {
434
- const text = typeof readDigest === 'function' ? String(readDigest(root, id) ?? '') : '';
435
- return { digestExists: text !== '', digestHasSummary: text.includes('summary (') };
436
- }
437
-
438
- /**
439
- * Build the entries the migration WOULD write, without writing anything.
440
- * @returns an array of { id, state, reason, attempts, detail }.
441
- */
442
- export function planMigration(root, sessions, readDigest) {
443
- const existing = readQueue(root);
444
- const plan = [];
445
- for (const [id, record] of Object.entries(sessions ?? {})) {
446
- if (existing.has(id)) continue; // idempotent: never re-migrate what is already queued
447
- const facts = legacyFacts(root, id, readDigest);
448
- plan.push({ id, ...classifyLegacyRecord(record, facts) });
449
- }
450
- return plan;
451
- }
452
-
453
- /**
454
- * Apply a migration plan. Re-runnable: entries already present are skipped by planMigration,
455
- * and a state that is already current is recorded as a refresh rather than a transition.
456
- * @returns { written, counts } - how many entries were appended, and the state tally.
457
- */
458
- export function applyMigration(root, plan) {
459
- let written = 0;
460
- const counts = {};
461
- for (const item of plan) {
462
- counts[item.state] = (counts[item.state] ?? 0) + 1;
463
- // seed, not transition: the state was reached in the OLD store, and the journal must not
464
- // pretend it watched that happen.
465
- seed(root, item.id, item.state, { reason: item.reason, attempts: item.attempts, detail: item.detail });
466
- written++;
467
- }
468
- return { written, counts };
469
- }
470
-
471
- /**
472
- * Seed an entry that did not originate here.
473
- *
474
- * A migration imports candidates that already reached some state in the old store. Walking
475
- * each one through the lifecycle would FABRICATE a history it never had - a transition table
476
- * exists to describe what happened, not to be satisfied. So an imported entry is recorded
477
- * directly and marked `imported`, which keeps the journal honest: a reader can tell an entry
478
- * that was born here from one that arrived with its state already decided.
479
- *
480
- * The reason requirement still applies, so an import cannot smuggle in an unexplained stop.
481
- * @throws when the id already exists - seeding is not a way to overwrite live state.
482
- */
483
- export function seed(root, id, state, options = {}) {
484
- if (!Object.values(STATES).includes(state)) throw new Error('queue: unknown state ' + state)
485
- if (typeof id !== 'string' || id === '') throw new Error('queue: an entry needs an id')
486
- const existing = readQueue(root).get(id)
487
- if (existing) throw new Error('queue: ' + id + ' already exists in state ' + existing.state)
488
- const stops = state === STATES.FAILED || state === STATES.SKIPPED || state === STATES.ORPHANED;
489
- if (stops && !options.reason) throw new Error('queue: seeding ' + state + ' needs a reason')
490
- const entry = {
491
- v: QUEUE_FORMAT,
492
- seq: nextSeq(root),
493
- at: stamp(options.now),
494
- id,
495
- state,
496
- imported: true,
497
- ...(options.reason ? { reason: options.reason } : {}),
498
- ...(options.attempts === undefined ? {} : { attempts: options.attempts }),
499
- ...(options.detail ? { detail: options.detail } : {}),
500
- }
501
- mkdirSync(join(root, 'state'), { recursive: true })
502
- appendFileSync(queuePaths(root).journal, JSON.stringify(entry) + '\n')
503
- writeSnapshot(root, readQueue(root));
504
- return entry;
505
- }
506
-
507
- // ---------------------------------------------------------------------------
508
- // Reading the queue through the old shape (a temporary compatibility layer)
509
- // ---------------------------------------------------------------------------
510
-
511
- /**
512
- * Project one queue entry into the legacy record shape.
513
- *
514
- * This is a SHIM and it is deliberately lossy in one direction only: it does not remove the
515
- * derivation this whole change exists to delete, it CONCENTRATES it in one place. The old
516
- * readers asked questions like is-this-settled, which used to be answered by inspecting a
517
- * timestamp; here that question is answered once, from the state, and every legacy caller keeps
518
- * working unchanged. Phase 8 deletes it.
519
- *
520
- * `consolidatedAt` is genuinely three-valued in the old shape and the difference is load-bearing:
521
- * a timestamp means settled, null means explicitly pending, and ABSENT means no write ever set
522
- * it. Collapsing null and absent would change what pendingDigests returns.
523
- */
524
- /**
525
- * Has this candidate's work been DEALT WITH?
526
- *
527
- * This was a projection artefact for too long: the shim derived it from a state so that legacy
528
- * readers could keep asking an old question. It is a real question with a real answer, so it lives
529
- * here and both the queue-native readers and the projection use it - one rule, not two that agree
530
- * by accident.
531
- *
532
- * Note what is NOT settled: PENDING and SUMMARIZED are waiting, ORPHANED needs recovery, and a
533
- * plain FAILED is retryable. Treating any of those as done is how work disappeared.
534
- */
535
- export function isSettled(entry) {
536
- if (!entry) return false;
537
- if (entry.state === STATES.ADDED || entry.state === STATES.SKIPPED) return true;
538
- // Giving up SETTLES: the old code stamped a timestamp when the retry cap was reached.
539
- if (entry.state === STATES.FAILED && entry.reason === REASONS.ATTEMPTS) return true;
540
- // A migrated record that was settled in the OLD file stays settled for the old reader's
541
- // purposes, even though its state now says it is parked.
542
- if (entry.state === STATES.HELD && entry.reason === REASONS.UNCLASSIFIED) return true;
543
- if (entry.imported && entry.detail?.legacyConsolidatedAt) return true;
544
- return false;
545
- }
546
-
547
- export function advance(root, id, target, options = {}) {
548
- const current = readQueue(root).get(id);
549
- if (current?.state === target) return 0;
550
- const path = shortestPath(current?.state, target);
551
- if (!path) throw new Error('queue: no legal path from ' + (current?.state ?? '(new)') + ' to ' + target + ' for ' + id);
552
- let steps = 0;
553
- for (const step of path) {
554
- const last = step === path[path.length - 1];
555
- transition(root, id, step, last ? options : {});
556
- steps++;
557
- }
558
- return steps;
559
- }
560
-
561
- /** Breadth-first search over the transition table. The graph is tiny, so this stays cheap. */
562
- function shortestPath(from, to) {
563
- if (from === to) return [];
564
- const queue = [[from, []]];
565
- const seen = new Set([from]);
566
- while (queue.length) {
567
- const [at, path] = queue.shift();
568
- for (const next of (at === undefined ? [STATES.PENDING, STATES.SUMMARIZED, STATES.HELD] : (TRANSITIONS[at] ?? []))) {
569
- if (seen.has(next)) continue;
570
- if (next === to) return [...path, next];
571
- seen.add(next);
572
- queue.push([next, [...path, next]]);
573
- }
574
- }
575
- return undefined;
576
- }
577
-
578
- export const STAGE_TIMEOUT_MS = 30 * 60 * 1000
579
-
580
- /**
581
- * Return entries stranded in an in-progress state to the failure path.
582
- *
583
- * Called before every batch. Without it a crash during summarization or consolidation parks a
584
- * candidate forever in a state that reads as 'busy' and is, in fact, abandoned.
585
- * @returns the ids that were recovered.
586
- */
587
- export function reapStalled(root, now = Date.now(), timeoutMs = STAGE_TIMEOUT_MS) {
588
- const recovered = [];
589
- for (const entry of readQueue(root).values()) {
590
- const inProgress = entry.state === STATES.SUMMARIZING || entry.state === STATES.CONSOLIDATING;
591
- if (!inProgress) continue;
592
- const started = Date.parse(entry.at);
593
- if (!Number.isFinite(started) || now - started < timeoutMs) continue;
594
- fail(root, entry.id, {
595
- reason: REASONS.STALLED,
596
- attempts: Number(entry.detail?.consolidateAttempts ?? entry.attempts ?? 0) + 1,
597
- error: 'no progress for ' + Math.round((now - started) / 60000) + ' minutes',
598
- });
599
- recovered.push(entry.id);
600
- }
601
- return recovered;
602
- }
1
+ // The memory queue: an append-only journal of memory CANDIDATES and how far each got.
2
+ //
3
+ // This is NOT the memory record. Markdown under the layers is the record, and it stays
4
+ // human-readable, git-diffable and hand-editable. This is the operational queue that feeds it:
5
+ // every session that might become memory gets one entry, and that entry carries an explicit
6
+ // STATE rather than a bag of flags a reader has to interpret.
7
+ //
8
+ // Why explicit states. The previous design stored a settled timestamp, a summary source, an
9
+ // attempt counter, a review flag and a watermark in one record, and every question about it was
10
+ // answered by DERIVING semantics: pending meant the timestamp was null, gave-up meant the
11
+ // timestamp was set AND a review flag was set, failed meant an attempt counter crossed a
12
+ // threshold, deferred meant a source string had a particular value. Each derivation is a place
13
+ // to get it wrong, and several were: a failed summary was once indistinguishable from a
14
+ // deliberate conclusion, and empty digests were consumed as if they were finished work.
15
+ //
16
+ // Why a journal rather than a map of current states. A map answers where is this now, and
17
+ // nothing else. The journal also answers how it got there, which is what makes a stuck queue
18
+ // visible: an entry that has been summarizing for a day is a different problem from one that
19
+ // failed, and orphaned - a candidate whose material is gone - is invisible in a map that keeps
20
+ // only terminal flags.
21
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+
24
+ /** Bump when the entry envelope changes shape; readers refuse unknown versions. */
25
+ export const QUEUE_FORMAT = 1
26
+
27
+ /**
28
+ * The lifecycle of one memory candidate.
29
+ *
30
+ * pending and summarized are waiting states. summarizing and consolidating are in flight.
31
+ * added and skipped are successful-terminal: the work is done, whether or not anything was
32
+ * written. failed and orphaned are problem-terminal: work stopped for a reason a reader must
33
+ * see. held is parking, used to stage a batch deliberately.
34
+ */
35
+ export const STATES = {
36
+ PENDING: 'pending',
37
+ SUMMARIZING: 'summarizing',
38
+ SUMMARIZED: 'summarized',
39
+ CONSOLIDATING: 'consolidating',
40
+ ADDED: 'added',
41
+ SKIPPED: 'skipped',
42
+ FAILED: 'failed',
43
+ ORPHANED: 'orphaned',
44
+ HELD: 'held',
45
+ ABANDONED: 'abandoned',
46
+ }
47
+
48
+ /** States from which nothing further happens without an explicit operator action. */
49
+ export const TERMINAL = [STATES.ADDED, STATES.SKIPPED, STATES.ABANDONED]
50
+
51
+ /** The only transitions the queue records. An illegal one is a bug, not a silent state. */
52
+ const TRANSITIONS = {
53
+ [STATES.PENDING]: [STATES.SUMMARIZING, STATES.HELD, STATES.ORPHANED, STATES.SKIPPED],
54
+ [STATES.SUMMARIZING]: [STATES.SUMMARIZED, STATES.SKIPPED, STATES.FAILED, STATES.PENDING, STATES.ABANDONED],
55
+ // A candidate is a SESSION, and a session keeps producing material. So every resting state has
56
+ // exactly one exit back into the summarizing stage, meaning NEW MATERIAL ARRIVED. That is an
57
+ // external event rather than the queue driving itself, which is why these states still count as
58
+ // resting: nothing here reopens on its own.
59
+ [STATES.SUMMARIZED]: [STATES.CONSOLIDATING, STATES.SUMMARIZING, STATES.HELD, STATES.ORPHANED],
60
+ [STATES.CONSOLIDATING]: [STATES.ADDED, STATES.SKIPPED, STATES.FAILED, STATES.SUMMARIZED, STATES.ABANDONED],
61
+ [STATES.HELD]: [STATES.PENDING, STATES.SUMMARIZED, STATES.SUMMARIZING],
62
+ [STATES.ADDED]: [STATES.SUMMARIZING],
63
+ [STATES.SKIPPED]: [STATES.SUMMARIZING],
64
+ // A retry re-enters the stage that failed, which the entry records as its `from`. Requeueing
65
+ // (pending/summarized) is the heavier option for when the material itself must be rebuilt.
66
+ [STATES.FAILED]: [STATES.SUMMARIZING, STATES.CONSOLIDATING, STATES.PENDING, STATES.SUMMARIZED, STATES.ABANDONED, STATES.HELD],
67
+ [STATES.ORPHANED]: [STATES.SUMMARIZING, STATES.CONSOLIDATING, STATES.PENDING, STATES.SUMMARIZED, STATES.ABANDONED, STATES.HELD],
68
+ [STATES.ABANDONED]: [STATES.PENDING, STATES.SUMMARIZED, STATES.SUMMARIZING],
69
+ }
70
+
71
+ /** True when a transition from one state to another is allowed. */
72
+ export function canTransition(from, to) {
73
+ // A candidate can be BORN waiting or BORN PARKED. Parked matters: staging a batch - the
74
+ // backfill hold - creates entries that must not be worked until an operator releases them,
75
+ // and refusing that would force the hold to live outside the queue it belongs to.
76
+ if (from === undefined) return to === STATES.PENDING || to === STATES.SUMMARIZED || to === STATES.HELD
77
+ return (TRANSITIONS[from] ?? []).includes(to)
78
+ }
79
+
80
+ /** Is this state finished? */
81
+ export function isTerminal(state) { return TERMINAL.includes(state) }
82
+
83
+ /**
84
+ * Reason codes for a state that stopped. A state alone says work stopped; the reason says why,
85
+ * which is the difference between a correct decision and a defect.
86
+ */
87
+ export const REASONS = {
88
+ THIN: 'thin', // delta below the signal floor: not worth a model call
89
+ TRIVIAL: 'trivial', // a ping, or maintenance chatter
90
+ NO_DURABLE: 'no-durable', // the model read it and concluded nothing was durable
91
+ EMPTY_REPLY: 'empty-reply', // the model returned no text: a defect
92
+ UNPARSEABLE: 'unparseable', // the reply did not follow the protocol
93
+ NO_MODEL: 'no-model', // no routable model produced text
94
+ ATTEMPTS: 'attempts-exhausted', // retried to the cap
95
+ DIGEST_GONE: 'digest-missing', // the derived material was pruned before it was used
96
+ NO_ENTRIES: 'no-entries', // consolidated, and the model added nothing
97
+ OPERATOR: 'operator', // parked deliberately
98
+ NEEDS_APPROVAL: 'needs-approval', // parked because acting would cost more than a guess is worth
99
+ UNCLASSIFIED: 'unclassified', // a legacy empty summary whose cause was never recorded
100
+ STALLED: 'stalled', // a stage stopped reporting progress: presumed dead
101
+ }
102
+
103
+ export function queuePaths(root) {
104
+ return { journal: join(root, 'state', 'queue.jsonl'), snapshot: join(root, 'state', 'queue.json') }
105
+ }
106
+
107
+ /** One ISO stamp, taken once so an entry is internally consistent. */
108
+ function stamp(now) { return (now ?? new Date()).toISOString() }
109
+
110
+ /**
111
+ * Read the journal and fold it into the current state of every candidate.
112
+ * The last entry for an id wins; a repeat of the same state is a refresh, not a transition.
113
+ * @returns a Map of id -> entry.
114
+ */
115
+ export function readQueue(root) {
116
+ const { journal } = queuePaths(root)
117
+ const current = new Map()
118
+ let raw = ''
119
+ try { raw = readFileSync(journal, 'utf8') } catch { return current }
120
+ for (const line of raw.split('\n')) {
121
+ if (line === '') continue
122
+ let entry
123
+ try { entry = JSON.parse(line) } catch { continue }
124
+ // Refuse a foreign envelope rather than misreading it as this format.
125
+ if (entry.v !== QUEUE_FORMAT || typeof entry.id !== 'string') continue
126
+ current.set(entry.id, entry)
127
+ }
128
+ return current
129
+ }
130
+
131
+ /** The journal's next position: monotonic, so order never depends on clocks. */
132
+ export function nextSeq(root) {
133
+ const { journal } = queuePaths(root)
134
+ let last = 0
135
+ try {
136
+ for (const line of readFileSync(journal, 'utf8').split('\n')) {
137
+ if (line === '') continue
138
+ try { const e = JSON.parse(line); if (typeof e.seq === 'number' && e.seq > last) last = e.seq } catch {}
139
+ }
140
+ } catch {}
141
+ return last + 1
142
+ }
143
+
144
+ /**
145
+ * Append one transition.
146
+ *
147
+ * The journal is APPEND-ONLY: a state change never rewrites history, so the queue's past stays
148
+ * auditable. A snapshot beside it is rewritten on every change, because re-reading a long
149
+ * journal to answer what is pending is wasteful. The snapshot is derived and never trusted as
150
+ * the source of truth.
151
+ *
152
+ * @param root - store root.
153
+ * @param id - the candidate's stable id (a session id, or a caller-chosen key).
154
+ * @param state - one of STATES.
155
+ * @param options.reason - required when work stops, so a decision can be told from a defect.
156
+ * @param options.attempts - attempt counter for the stage that just ran.
157
+ * @param options.detail - anything else worth keeping (tier, counts, model used).
158
+ * @throws when the transition is illegal, rather than recording an unreachable state.
159
+ */
160
+ export function transition(root, id, state, options = {}) {
161
+ if (!Object.values(STATES).includes(state)) throw new Error('queue: unknown state ' + state)
162
+ if (typeof id !== 'string' || id === '') throw new Error('queue: an entry needs an id')
163
+ const current = readQueue(root).get(id)
164
+ const from = current?.state
165
+ if (from !== state && !canTransition(from, state)) {
166
+ throw new Error('queue: illegal transition ' + (from ?? '(new)') + ' -> ' + state + ' for ' + id)
167
+ }
168
+ const stops = state === STATES.FAILED || state === STATES.SKIPPED || state === STATES.ORPHANED
169
+ // The reason requirement is about ENTERING a stopping state, not restating one. A refresh -
170
+ // the same state recorded again to carry new detail - keeps the reason already on the entry,
171
+ // so demanding it afresh would make a legitimate refresh impossible.
172
+ if (stops && from !== state && !options.reason) {
173
+ throw new Error('queue: ' + state + ' needs a reason so a reader can tell a decision from a defect')
174
+ }
175
+ // The projection is LAST-ENTRY-WINS over standalone entries, so a transition that does not
176
+ // restate a field DROPS it. Carrying counters and detail forward is therefore not a
177
+ // convenience: without it an attempt counter resets on every hop and a retry cap can never be
178
+ // reached, which is precisely the bug this line fixes.
179
+ const attempts = options.attempts ?? current?.attempts
180
+ const detail = { ...(current?.detail ?? {}), ...(options.detail ?? {}) }
181
+ const retryable = state === STATES.FAILED || state === STATES.ORPHANED
182
+ const nextAttemptAt = options.nextAttemptAt ?? (retryable ? current?.nextAttemptAt : undefined)
183
+ const entry = {
184
+ v: QUEUE_FORMAT,
185
+ seq: nextSeq(root),
186
+ at: stamp(options.now),
187
+ id,
188
+ state,
189
+ ...(from === undefined || from === state ? {} : { from }),
190
+ ...((options.reason ?? (from === state ? current?.reason : undefined)) ? { reason: options.reason ?? current.reason } : {}),
191
+ ...(attempts === undefined ? {} : { attempts }),
192
+ ...(nextAttemptAt ? { nextAttemptAt } : {}),
193
+ ...(Object.keys(detail).length ? { detail } : {}),
194
+ }
195
+ mkdirSync(join(root, 'state'), { recursive: true })
196
+ const { journal } = queuePaths(root)
197
+ try { if (existsSync(journal) && statSync(journal).size > 4 * 1024 * 1024) renameSync(journal, journal + '.1') } catch {}
198
+ appendFileSync(journal, JSON.stringify(entry) + '\n')
199
+ writeSnapshot(root, readQueue(root))
200
+ return entry
201
+ }
202
+
203
+ /** Rewrite the derived snapshot. Cheap to regenerate, so it is never authoritative. */
204
+ function writeSnapshot(root, current) {
205
+ try {
206
+ const { snapshot } = queuePaths(root)
207
+ const plain = {}
208
+ for (const [id, entry] of current) plain[id] = entry
209
+ writeFileSync(snapshot, JSON.stringify(plain, null, 2) + '\n', 'utf8')
210
+ } catch {}
211
+ }
212
+
213
+ /** Candidates currently in one of the given states. */
214
+ export function byState(root, states) {
215
+ const want = new Set(Array.isArray(states) ? states : [states])
216
+ return [...readQueue(root).values()].filter((e) => want.has(e.state))
217
+ }
218
+
219
+ /** Work waiting to be done, in journal order. */
220
+ export function pending(root) {
221
+ return byState(root, [STATES.PENDING, STATES.SUMMARIZED])
222
+ }
223
+
224
+ /** Entries that stopped for a reason a reader must see. */
225
+ export function problems(root) {
226
+ // ABANDONED belongs here most of all. It is the end of the retry loop, so if it is not listed
227
+ // as a problem the work that gave up is the work nobody sees - which is exactly the silent
228
+ // loss this queue exists to prevent.
229
+ return byState(root, [STATES.FAILED, STATES.ORPHANED, STATES.ABANDONED])
230
+ }
231
+
232
+ /** A count per state, for status output. */
233
+ export function summarize(root) {
234
+ const counts = {}
235
+ for (const entry of readQueue(root).values()) counts[entry.state] = (counts[entry.state] ?? 0) + 1
236
+ return counts
237
+ }
238
+
239
+ /**
240
+ * Retry policy for work that failed.
241
+ *
242
+ * A failure is a statement about the ATTEMPT, not about the material, so nothing may rest at
243
+ * failed: it is retried until it succeeds or until the attempts are spent. Backoff keeps a
244
+ * broken candidate from spending a model call every tick while it is broken, and it grows
245
+ * geometrically with a ceiling so a long outage does not push the next attempt days away.
246
+ *
247
+ * The cap exists because retrying forever is its own failure: it burns calls on work that has
248
+ * demonstrably not worked. Reaching it produces ABANDONED, which stays visible and can be
249
+ * revived - unlike the old design, where an exhausted candidate was marked done and vanished.
250
+ */
251
+ export const RETRY = {
252
+ maxAttempts: 5,
253
+ baseMs: 60 * 1000,
254
+ maxMs: 6 * 60 * 60 * 1000,
255
+ }
256
+
257
+ /**
258
+ * Delay before the next attempt: base, then 4x each time, capped. Attempt 1 waits base,
259
+ * attempt 2 waits 4x base, attempt 3 waits 16x base, and so on up to the ceiling.
260
+ */
261
+ export function backoffMs(attempts, { baseMs = RETRY.baseMs, maxMs = RETRY.maxMs } = {}) {
262
+ const n = Math.max(1, Number(attempts) || 1)
263
+ return Math.min(baseMs * Math.pow(4, n - 1), maxMs)
264
+ }
265
+
266
+ /**
267
+ * What to do with one entry that stopped.
268
+ * @returns {action, waitMs, attempts, reason} where action is retry, abandon or wait.
269
+ */
270
+ export function retryDecision(entry, now = Date.now(), options = {}) {
271
+ if (!entry) return { action: 'wait', attempts: 0, reason: 'unknown-entry' }
272
+ // ORPHANED is not retryable, and treating it as such is not a harmless extra attempt: the
273
+ // material is GONE, so a retry is guaranteed to fail while still costing a model call. What it
274
+ // needs is RECOVERY - rebuilding the digest from the source session - which is a different
275
+ // action that only the tool performs, and for a large session only with approval.
276
+ if (entry.state === STATES.ORPHANED) {
277
+ return { action: 'recover', attempts: Number(entry.attempts ?? 0), reason: entry.reason ?? 'digest-missing' }
278
+ }
279
+ if (entry.state !== STATES.FAILED) {
280
+ return { action: 'wait', attempts: Number(entry.attempts ?? 0), reason: 'not-retryable' }
281
+ }
282
+ const attempts = Number(entry.attempts ?? 1)
283
+ const max = options.maxAttempts ?? RETRY.maxAttempts
284
+ if (attempts >= max) return { action: 'abandon', attempts, reason: entry.reason ?? 'attempts' }
285
+ const due = entry.nextAttemptAt ? Date.parse(entry.nextAttemptAt) : 0
286
+ if (Number.isFinite(due) && due > 0 && now < due) return { action: 'wait', waitMs: due - now, attempts, reason: 'backoff' }
287
+ return { action: 'retry', attempts, reason: entry.reason ?? '' }
288
+ }
289
+
290
+ /**
291
+ * Everything the cadence should act on now: waiting work, plus failures whose backoff has
292
+ * expired. The retry loop lives here rather than in the caller, so a new caller cannot forget
293
+ * to bring failed work back into the queue.
294
+ */
295
+ export function due(root, now = Date.now(), options = {}) {
296
+ const out = []
297
+ for (const entry of readQueue(root).values()) {
298
+ if (entry.state === STATES.PENDING || entry.state === STATES.SUMMARIZED) {
299
+ out.push({ entry, action: 'work' })
300
+ continue
301
+ }
302
+ const decision = retryDecision(entry, now, options)
303
+ // 'recover' is reported so a caller can SEE it, but it is not work: the cadence cannot perform
304
+ // it and must not mistake it for a digest to consolidate.
305
+ if (decision.action !== 'wait') out.push({ entry, action: decision.action, decision })
306
+ }
307
+ // PRIORITY, and it is not cosmetic. Callers take a BOUNDED batch, so queue order decides what
308
+ // is ever reached. A failure whose backoff expired is time-sensitive: the retry window is open
309
+ // NOW, and it opened because time passed rather than because new work arrived. Waiting work has
310
+ // no such deadline. Returning them in sequence order therefore starves retries completely -
311
+ // measured on a real store, the single retry sat at position 101 of 111 due items behind 34
312
+ // waiting ones, so with a batch of 3 it would never have run and 'we cannot let things stay
313
+ // failed' would have been true in the model and false in the system.
314
+ const RANK = { retry: 0, abandon: 1, work: 2, recover: 3 }
315
+ out.sort((a, b) => (RANK[a.action] ?? 9) - (RANK[b.action] ?? 9))
316
+ return out
317
+ }
318
+
319
+ /**
320
+ * Record a failure and schedule the next attempt.
321
+ *
322
+ * The cap is enforced HERE so no caller can leave a candidate failed forever by forgetting to
323
+ * check it: the last permitted failure becomes an abandonment automatically.
324
+ */
325
+ export function fail(root, id, options = {}) {
326
+ const current = readQueue(root).get(id)
327
+ const attempts = Number(options.attempts ?? ((current?.attempts ?? 0) + 1))
328
+ const max = options.maxAttempts ?? RETRY.maxAttempts
329
+ const state = attempts >= max ? STATES.ABANDONED : STATES.FAILED
330
+ const at = options.now ?? new Date()
331
+ return transition(root, id, state, {
332
+ reason: options.reason,
333
+ attempts,
334
+ now: options.now,
335
+ nextAttemptAt: new Date(at.getTime() + backoffMs(attempts)).toISOString(),
336
+ detail: { ...(options.detail ?? {}), ...(options.error ? { lastError: String(options.error).slice(0, 200) } : {}) },
337
+ })
338
+ }
339
+
340
+ /** Bring a candidate back into the queue. Works for failed, orphaned and abandoned work. */
341
+ export function requeue(root, id, options = {}) {
342
+ return transition(root, id, options.state ?? STATES.PENDING, {
343
+ attempts: options.attempts ?? 0,
344
+ detail: { ...(options.detail ?? {}), requeuedAt: new Date().toISOString(), ...(options.why ? { why: options.why } : {}) },
345
+ })
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Migration from the flag-based state file
350
+ // ---------------------------------------------------------------------------
351
+
352
+ /**
353
+ * Classify one legacy record into a state. PURE: the caller supplies what is on disk.
354
+ *
355
+ * The guiding rule is CONSERVATIVE: where the old flags are ambiguous, prefer a state that
356
+ * causes the work to be looked at again over one that declares it finished. The old design
357
+ * could not distinguish a model concluding there was nothing durable from a model returning
358
+ * nothing at all, so a record with no recorded reason must not be assumed to be a conclusion.
359
+ *
360
+ * @param record - one entry from sessions.json.
361
+ * @param facts - { digestExists, digestHasSummary } describing the derived material.
362
+ * @returns { state, reason, detail } with reason present whenever work stopped.
363
+ */
364
+ export function classifyLegacyRecord(record = {}, facts = {}) {
365
+ const detail = {
366
+ ...(record.tier ? { tier: record.tier } : {}),
367
+ ...(record.title ? { title: record.title } : {}),
368
+ ...(record.capturedAt ? { capturedAt: record.capturedAt } : {}),
369
+ ...(record.lastSeq === undefined ? {} : { lastSeq: record.lastSeq }),
370
+ ...(record.digestHash ? { digestHash: record.digestHash } : {}),
371
+ ...(record.digestFormat === undefined ? {} : { digestFormat: record.digestFormat }),
372
+ ...(record.eventCount === undefined ? {} : { eventCount: record.eventCount }),
373
+ ...(record.toolCallCount === undefined ? {} : { toolCallCount: record.toolCallCount }),
374
+ // The original settled time, kept because the legacy reader still asks WHEN, and a migration
375
+ // timestamp would silently rewrite history.
376
+ ...(record.consolidatedAt ? { legacyConsolidatedAt: record.consolidatedAt } : {}),
377
+ // ABSENT is not the same as null in the old shape: absent means no writer ever set the key,
378
+ // null means one explicitly wrote 'not yet'. The distinction is load-bearing for anything
379
+ // that asks whether a record was ever touched, so it is carried rather than flattened.
380
+ ...(('consolidatedAt' in record) ? {} : { noConsolidatedAtKey: true }),
381
+ legacy: true,
382
+ }
383
+ const attempts = record.consolidateAttempts ?? record.summaryAttempts;
384
+ const settled = Boolean(record.consolidatedAt);
385
+ const source = record.summarySource;
386
+ const reason = record.summaryReason;
387
+
388
+ // A candidate explicitly flagged for review is a known problem, whatever else it says.
389
+ if (record.needsReview) return { state: STATES.FAILED, reason: REASONS.ATTEMPTS, attempts, detail };
390
+
391
+ if (!settled) {
392
+ // Waiting work. What it is waiting FOR depends on whether material exists.
393
+ if (source === 'backfill') return { state: STATES.HELD, reason: REASONS.OPERATOR, attempts, detail };
394
+ if (!facts.digestExists) return { state: STATES.ORPHANED, reason: REASONS.DIGEST_GONE, attempts, detail };
395
+ if (facts.digestHasSummary) return { state: STATES.SUMMARIZED, attempts, detail };
396
+ return { state: STATES.PENDING, attempts, detail };
397
+ }
398
+
399
+ // Settled. The honest question is what it settled AS.
400
+ if (source === 'llm' || source === 'fallback' || source === 'backfill') {
401
+ return { state: STATES.ADDED, attempts, detail };
402
+ }
403
+ if (source === 'thin') return { state: STATES.SKIPPED, reason: REASONS.THIN, attempts, detail };
404
+ if (source === 'skipped') return { state: STATES.SKIPPED, reason: REASONS.TRIVIAL, attempts, detail };
405
+ if (source === 'none' || source === undefined) {
406
+ // Only a recorded no-durable conclusion is trusted as a decision. Anything else - including
407
+ // a legacy record that recorded no reason at all - becomes retryable work, because assuming
408
+ // it was a conclusion is exactly how content was lost before.
409
+ if (reason === REASONS.NO_DURABLE) return { state: STATES.SKIPPED, reason: REASONS.NO_DURABLE, attempts, detail };
410
+ // A KNOWN defect is retryable: retrying is the right response to it.
411
+ if (reason) return { state: STATES.FAILED, reason, attempts, detail };
412
+ // A missing digest is the operative fact, and it outranks the old flag. A digest is DERIVED,
413
+ // so its absence only matters when nothing came of the session: an `added` record whose digest
414
+ // was pruned is fine, but one that never produced entries has lost its material and is
415
+ // recoverable from the source session. Checking this only in the NOT-settled branch left 78
416
+ // recoverable candidates parked as ordinary waiting work, where nothing would ever look.
417
+ if (!facts.digestExists) return { state: STATES.ORPHANED, reason: REASONS.DIGEST_GONE, attempts, detail };
418
+ // Settled, no source, and the digest HAS content: consolidation marked it done. It stamps a
419
+ // timestamp without writing a source, so this is what a successful consolidation looks like.
420
+ if (facts.digestHasSummary) return { state: STATES.ADDED, attempts, detail };
421
+ // Material present but empty, cause never recorded. An UNKNOWN cause is parked, not retried.
422
+ // Marking these failed would put historical sessions back through summarization on a guess.
423
+ // sessions back through summarization on the strength of a guess about what went wrong -
424
+ // which is a backfill by another name, and a spend nobody asked for. The old writer recorded
425
+ // no reason, so a decision and a defect are genuinely indistinguishable here. Parked keeps
426
+ // them counted and visible and lets an operator decide, which is the conservative answer.
427
+ return { state: STATES.HELD, reason: REASONS.UNCLASSIFIED, attempts, detail };
428
+ }
429
+ // An unrecognised source is not silently trusted either.
430
+ return { state: STATES.FAILED, reason: REASONS.UNCLASSIFIED, attempts, detail };
431
+ }
432
+
433
+ export function legacyFacts(root, id, readDigest) {
434
+ const text = typeof readDigest === 'function' ? String(readDigest(root, id) ?? '') : '';
435
+ return { digestExists: text !== '', digestHasSummary: text.includes('summary (') };
436
+ }
437
+
438
+ /**
439
+ * Build the entries the migration WOULD write, without writing anything.
440
+ * @returns an array of { id, state, reason, attempts, detail }.
441
+ */
442
+ export function planMigration(root, sessions, readDigest) {
443
+ const existing = readQueue(root);
444
+ const plan = [];
445
+ for (const [id, record] of Object.entries(sessions ?? {})) {
446
+ if (existing.has(id)) continue; // idempotent: never re-migrate what is already queued
447
+ const facts = legacyFacts(root, id, readDigest);
448
+ plan.push({ id, ...classifyLegacyRecord(record, facts) });
449
+ }
450
+ return plan;
451
+ }
452
+
453
+ /**
454
+ * Apply a migration plan. Re-runnable: entries already present are skipped by planMigration,
455
+ * and a state that is already current is recorded as a refresh rather than a transition.
456
+ * @returns { written, counts } - how many entries were appended, and the state tally.
457
+ */
458
+ export function applyMigration(root, plan) {
459
+ let written = 0;
460
+ const counts = {};
461
+ for (const item of plan) {
462
+ counts[item.state] = (counts[item.state] ?? 0) + 1;
463
+ // seed, not transition: the state was reached in the OLD store, and the journal must not
464
+ // pretend it watched that happen.
465
+ seed(root, item.id, item.state, { reason: item.reason, attempts: item.attempts, detail: item.detail });
466
+ written++;
467
+ }
468
+ return { written, counts };
469
+ }
470
+
471
+ /**
472
+ * Seed an entry that did not originate here.
473
+ *
474
+ * A migration imports candidates that already reached some state in the old store. Walking
475
+ * each one through the lifecycle would FABRICATE a history it never had - a transition table
476
+ * exists to describe what happened, not to be satisfied. So an imported entry is recorded
477
+ * directly and marked `imported`, which keeps the journal honest: a reader can tell an entry
478
+ * that was born here from one that arrived with its state already decided.
479
+ *
480
+ * The reason requirement still applies, so an import cannot smuggle in an unexplained stop.
481
+ * @throws when the id already exists - seeding is not a way to overwrite live state.
482
+ */
483
+ export function seed(root, id, state, options = {}) {
484
+ if (!Object.values(STATES).includes(state)) throw new Error('queue: unknown state ' + state)
485
+ if (typeof id !== 'string' || id === '') throw new Error('queue: an entry needs an id')
486
+ const existing = readQueue(root).get(id)
487
+ if (existing) throw new Error('queue: ' + id + ' already exists in state ' + existing.state)
488
+ const stops = state === STATES.FAILED || state === STATES.SKIPPED || state === STATES.ORPHANED;
489
+ if (stops && !options.reason) throw new Error('queue: seeding ' + state + ' needs a reason')
490
+ const entry = {
491
+ v: QUEUE_FORMAT,
492
+ seq: nextSeq(root),
493
+ at: stamp(options.now),
494
+ id,
495
+ state,
496
+ imported: true,
497
+ ...(options.reason ? { reason: options.reason } : {}),
498
+ ...(options.attempts === undefined ? {} : { attempts: options.attempts }),
499
+ ...(options.detail ? { detail: options.detail } : {}),
500
+ }
501
+ mkdirSync(join(root, 'state'), { recursive: true })
502
+ appendFileSync(queuePaths(root).journal, JSON.stringify(entry) + '\n')
503
+ writeSnapshot(root, readQueue(root));
504
+ return entry;
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Reading the queue through the old shape (a temporary compatibility layer)
509
+ // ---------------------------------------------------------------------------
510
+
511
+ /**
512
+ * Project one queue entry into the legacy record shape.
513
+ *
514
+ * This is a SHIM and it is deliberately lossy in one direction only: it does not remove the
515
+ * derivation this whole change exists to delete, it CONCENTRATES it in one place. The old
516
+ * readers asked questions like is-this-settled, which used to be answered by inspecting a
517
+ * timestamp; here that question is answered once, from the state, and every legacy caller keeps
518
+ * working unchanged. Phase 8 deletes it.
519
+ *
520
+ * `consolidatedAt` is genuinely three-valued in the old shape and the difference is load-bearing:
521
+ * a timestamp means settled, null means explicitly pending, and ABSENT means no write ever set
522
+ * it. Collapsing null and absent would change what pendingDigests returns.
523
+ */
524
+ /**
525
+ * Has this candidate's work been DEALT WITH?
526
+ *
527
+ * This was a projection artefact for too long: the shim derived it from a state so that legacy
528
+ * readers could keep asking an old question. It is a real question with a real answer, so it lives
529
+ * here and both the queue-native readers and the projection use it - one rule, not two that agree
530
+ * by accident.
531
+ *
532
+ * Note what is NOT settled: PENDING and SUMMARIZED are waiting, ORPHANED needs recovery, and a
533
+ * plain FAILED is retryable. Treating any of those as done is how work disappeared.
534
+ */
535
+ export function isSettled(entry) {
536
+ if (!entry) return false;
537
+ if (entry.state === STATES.ADDED || entry.state === STATES.SKIPPED) return true;
538
+ // Giving up SETTLES: the old code stamped a timestamp when the retry cap was reached.
539
+ if (entry.state === STATES.FAILED && entry.reason === REASONS.ATTEMPTS) return true;
540
+ // A migrated record that was settled in the OLD file stays settled for the old reader's
541
+ // purposes, even though its state now says it is parked.
542
+ if (entry.state === STATES.HELD && entry.reason === REASONS.UNCLASSIFIED) return true;
543
+ if (entry.imported && entry.detail?.legacyConsolidatedAt) return true;
544
+ return false;
545
+ }
546
+
547
+ export function advance(root, id, target, options = {}) {
548
+ const current = readQueue(root).get(id);
549
+ if (current?.state === target) return 0;
550
+ const path = shortestPath(current?.state, target);
551
+ if (!path) throw new Error('queue: no legal path from ' + (current?.state ?? '(new)') + ' to ' + target + ' for ' + id);
552
+ let steps = 0;
553
+ for (const step of path) {
554
+ const last = step === path[path.length - 1];
555
+ transition(root, id, step, last ? options : {});
556
+ steps++;
557
+ }
558
+ return steps;
559
+ }
560
+
561
+ /** Breadth-first search over the transition table. The graph is tiny, so this stays cheap. */
562
+ function shortestPath(from, to) {
563
+ if (from === to) return [];
564
+ const queue = [[from, []]];
565
+ const seen = new Set([from]);
566
+ while (queue.length) {
567
+ const [at, path] = queue.shift();
568
+ for (const next of (at === undefined ? [STATES.PENDING, STATES.SUMMARIZED, STATES.HELD] : (TRANSITIONS[at] ?? []))) {
569
+ if (seen.has(next)) continue;
570
+ if (next === to) return [...path, next];
571
+ seen.add(next);
572
+ queue.push([next, [...path, next]]);
573
+ }
574
+ }
575
+ return undefined;
576
+ }
577
+
578
+ export const STAGE_TIMEOUT_MS = 30 * 60 * 1000
579
+
580
+ /**
581
+ * Return entries stranded in an in-progress state to the failure path.
582
+ *
583
+ * Called before every batch. Without it a crash during summarization or consolidation parks a
584
+ * candidate forever in a state that reads as 'busy' and is, in fact, abandoned.
585
+ * @returns the ids that were recovered.
586
+ */
587
+ export function reapStalled(root, now = Date.now(), timeoutMs = STAGE_TIMEOUT_MS) {
588
+ const recovered = [];
589
+ for (const entry of readQueue(root).values()) {
590
+ const inProgress = entry.state === STATES.SUMMARIZING || entry.state === STATES.CONSOLIDATING;
591
+ if (!inProgress) continue;
592
+ const started = Date.parse(entry.at);
593
+ if (!Number.isFinite(started) || now - started < timeoutMs) continue;
594
+ fail(root, entry.id, {
595
+ reason: REASONS.STALLED,
596
+ attempts: Number(entry.detail?.consolidateAttempts ?? entry.attempts ?? 0) + 1,
597
+ error: 'no progress for ' + Math.round((now - started) / 60000) + ' minutes',
598
+ });
599
+ recovered.push(entry.id);
600
+ }
601
+ return recovered;
602
+ }