@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.
- package/LICENSE +21 -21
- package/README.md +585 -585
- package/STATUS.md +294 -287
- package/cordis.patch.yml +69 -69
- package/package.json +1 -1
- package/scripts/check-package.mjs +76 -76
- package/scripts/inspect-log.ts +40 -0
- package/scripts/repair-ignorable.ts +130 -0
- package/scripts/scan-sessions.ts +43 -0
- package/scripts/verify-log.ts +26 -0
- package/src/SYSTEM.template.md +311 -311
- package/src/capture.js +242 -242
- package/src/client/index.js +886 -886
- package/src/config.js +57 -57
- package/src/consolidate-run.js +132 -132
- package/src/consolidate.js +330 -330
- package/src/context.js +88 -88
- package/src/decisions.js +106 -106
- package/src/defaults.js +93 -93
- package/src/digest.js +255 -255
- package/src/entry.js +179 -179
- package/src/eval.js +72 -72
- package/src/files.js +60 -60
- package/src/fixture.js +56 -56
- package/src/forget.js +71 -71
- package/src/freshness.js +65 -65
- package/src/fts.js +166 -166
- package/src/git.js +53 -53
- package/src/history.js +226 -226
- package/src/host.js +884 -866
- package/src/index.js +115 -94
- package/src/inject.js +108 -108
- package/src/layers.js +178 -178
- package/src/ledger.js +19 -19
- package/src/map.js +184 -184
- package/src/models.js +136 -136
- package/src/queue.js +602 -602
- package/src/rank.js +31 -31
- package/src/recover.js +91 -91
- package/src/refresh.js +80 -80
- package/src/remember.js +58 -58
- package/src/rerank.js +90 -90
- package/src/search.js +75 -75
- package/src/select.js +57 -57
- package/src/session.js +60 -60
- package/src/state.js +240 -240
- package/src/store.js +128 -128
- package/src/summarize.js +195 -195
- package/src/system-doc.js +171 -171
- package/src/tasks.js +617 -617
- package/src/telemetry.js +122 -122
- package/src/tools.js +828 -818
- package/src/working.js +288 -288
- package/src/workspace-key.js +68 -68
- 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
|
+
}
|