@theronap/cortex-mcp 0.9.149 โ 0.9.151
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/lib/capture.mjs +235 -135
- package/lib/capture_queue.mjs +876 -0
- package/lib/diagnose.mjs +176 -32
- package/lib/doctor.mjs +32 -4
- package/lib/edge_extract.mjs +41 -24
- package/lib/hydrate.mjs +40 -13
- package/lib/propose_obligation.mjs +99 -0
- package/lib/redact.mjs +2 -1
- package/lib/server.mjs +89 -17
- package/package.json +1 -1
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
// The local capture queue โ D9 + the client half of D8 (eng review 2026-09-11).
|
|
2
|
+
//
|
|
3
|
+
// ๐ด WHY THIS EXISTS. Two ways a session capture used to vanish:
|
|
4
|
+
//
|
|
5
|
+
// 1. A failed /api/ingest was logged to ~/.cortex/capture.log and dropped. A transient 503, a timeout,
|
|
6
|
+
// a rate-limit 429 โ each one lost that turn's capture, and if it was the session's LAST turn, the
|
|
7
|
+
// session. Nothing retried, and nothing anyone reads said so.
|
|
8
|
+
//
|
|
9
|
+
// 2. When the person's own AI could not summarize the session locally (`claude -p` missing, its login
|
|
10
|
+
// expired, a subscription limit), the client shipped the raw transcript tail in `transcript` and
|
|
11
|
+
// the SERVER summarized it with Agnoclast's Anthropic key. D8 ends that: the transcript stays on the
|
|
12
|
+
// machine. So a turn that cannot be summarized now has to WAIT somewhere โ here โ until it can.
|
|
13
|
+
//
|
|
14
|
+
// This repo's most-repeated failure is a green status over work that did not happen. So the queue has
|
|
15
|
+
// one rule above the others: NOTHING LEAVES IT SILENTLY. An entry leaves because it was recorded,
|
|
16
|
+
// because the server made a final decision about it, or because a bound forced it out โ and the last
|
|
17
|
+
// two of those are written to a drop ledger that the next session start reports, alongside every entry
|
|
18
|
+
// that has failed FLAG_AFTER_ATTEMPTS times.
|
|
19
|
+
//
|
|
20
|
+
// Layout โ ~/.cortex/capture-queue/ (dir 0700, files 0600, every write tmp+rename so a concurrent
|
|
21
|
+
// reader never sees half a file):
|
|
22
|
+
// <session key>.json one entry per SESSION. The Stop hook fires every turn and the server keeps one
|
|
23
|
+
// record per session, so a newer turn REPLACES the older entry (keeping its
|
|
24
|
+
// attempt count), and a recorded turn removes it.
|
|
25
|
+
// _dropped.json the drop ledger: what a bound or a server rejection forced out, for 7 days.
|
|
26
|
+
//
|
|
27
|
+
// Two kinds of entry:
|
|
28
|
+
// summarize the local summarizer was unavailable. Holds the redacted transcript TAIL (โค ~7 KB, the
|
|
29
|
+
// same text `claude -p` would have been shown) plus the ingest envelope. LOCAL ONLY โ the
|
|
30
|
+
// tail is never sent anywhere; it is summarized here, later, and only the digest leaves.
|
|
31
|
+
// ingest summarized fine, the POST failed. Holds the finished body, re-posted later. A summarize
|
|
32
|
+
// entry becomes an ingest entry the moment its summary exists, so a failed POST never
|
|
33
|
+
// costs a second `claude -p` run โ and the transcript is deleted from the entry right then.
|
|
34
|
+
//
|
|
35
|
+
// Bounds: QUEUE_MAX_ENTRIES entries, QUEUE_MAX_AGE_DAYS days, QUEUE_MAX_ENTRY_BYTES per entry. Drained
|
|
36
|
+
// opportunistically by later Stop-hook runs (the detached capture worker, which blocks nobody), with
|
|
37
|
+
// equal-jittered exponential backoff per entry and a Retry-After floor.
|
|
38
|
+
|
|
39
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync, openSync, closeSync } from 'node:fs'
|
|
40
|
+
import { homedir } from 'node:os'
|
|
41
|
+
import { join } from 'node:path'
|
|
42
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
43
|
+
import { fetchCortex, classify, responseRetryClass, parseRetryAfter } from './diagnose.mjs'
|
|
44
|
+
|
|
45
|
+
export const QUEUE_MAX_ENTRIES = 50
|
|
46
|
+
export const QUEUE_MAX_AGE_DAYS = 14
|
|
47
|
+
export const QUEUE_MAX_ENTRY_BYTES = 256 * 1024
|
|
48
|
+
/** After this many failed attempts an entry is FLAGGED: kept, retried, and reported at session start. */
|
|
49
|
+
export const FLAG_AFTER_ATTEMPTS = 3
|
|
50
|
+
export const QUEUE_FIRST_RETRY_MS = 2 * 60_000
|
|
51
|
+
export const QUEUE_MAX_RETRY_MS = 6 * 60 * 60_000
|
|
52
|
+
/** How long a drop stays in the ledger (and so in the session-start line). */
|
|
53
|
+
export const DROPPED_WINDOW_DAYS = 7
|
|
54
|
+
const DROPPED_FILE = '_dropped.json'
|
|
55
|
+
const DROPPED_MAX = 100
|
|
56
|
+
const STALE_TMP_MS = 60 * 60_000
|
|
57
|
+
|
|
58
|
+
/** One drain, per Stop-hook run: a few cheap POSTs, at most one `claude -p` retry, and a time cap.
|
|
59
|
+
*
|
|
60
|
+
* ๐ด THE BUDGET BOUNDS EVERY BLOCKING STEP, NOT JUST THE GAPS BETWEEN THEM. It first only wrapped the
|
|
61
|
+
* loop, so a due summarize entry could block for the full `summaryTimeoutMs()` (300 s by default, a
|
|
62
|
+
* SYNCHRONOUS spawnSync) and then still post โ about five times the stated cap, and under
|
|
63
|
+
* CORTEX_CAPTURE_SYNC that is the hook itself waiting. Now a summarize is attempted only when the
|
|
64
|
+
* remaining budget can actually cover one, runs with `timeoutMs` set to what is left minus a reserve
|
|
65
|
+
* for its POST, and every POST is bounded by what remains after that.
|
|
66
|
+
*
|
|
67
|
+
* Sized so ONE summarization fits: a Stop-hook tail is โค ~7 KB (far smaller than the 50โ147 KB lecture
|
|
68
|
+
* transcripts whose 56โ150 s timings edge_extract.mjs records), and a session that cannot be summarized
|
|
69
|
+
* inside DRAIN_MIN_SUMMARY_MS is left for a later run rather than half-done. */
|
|
70
|
+
export const DRAIN_MAX_POSTS = 5
|
|
71
|
+
export const DRAIN_MAX_SUMMARIES = 1
|
|
72
|
+
export const DRAIN_BUDGET_MS = 180_000
|
|
73
|
+
/** Don't start a summarize unless this much budget remains (plus the post reserve). */
|
|
74
|
+
export const DRAIN_MIN_SUMMARY_MS = 90_000
|
|
75
|
+
/** Held back from a summarize's timeout so the digest it produces can still be posted. */
|
|
76
|
+
export const DRAIN_POST_RESERVE_MS = 15_000
|
|
77
|
+
/** Below this, don't start a POST at all โ leave the entry for the next run. */
|
|
78
|
+
export const DRAIN_MIN_POST_MS = 2_000
|
|
79
|
+
/** Per-POST ceiling (also the live capture's). A detached worker self-terminates in ~25 s. */
|
|
80
|
+
export const INGEST_BUDGET_MS = 25_000
|
|
81
|
+
|
|
82
|
+
// โโ Ordering: a per-session lease, and a generation per capture โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
83
|
+
//
|
|
84
|
+
// ๐ด WHY (adversarial review, 2026-09-11). The Stop hook fires per TURN and each turn runs in its own
|
|
85
|
+
// detached worker, so two workers for one session overlap routinely โ extraction alone takes tens of
|
|
86
|
+
// seconds. They finished in any order, and nothing ordered their effects:
|
|
87
|
+
//
|
|
88
|
+
// ยท turn N+1 queues after a transient 503; turn N finishes later, posts fine, and its
|
|
89
|
+
// `removeEntry(session)` deletes N+1's queued capture. The newer turn is silently lost.
|
|
90
|
+
// ยท turn N, failing later, overwrites N+1's queued entry with its older content.
|
|
91
|
+
// ยท the drain posts a digest it read minutes earlier while a live capture posts a newer one;
|
|
92
|
+
// /api/ingest dedupes by sessionId and takes the last write, so the stale replay wins.
|
|
93
|
+
//
|
|
94
|
+
// Two mechanisms, because the problem has two halves. A GENERATION orders the captures of one session;
|
|
95
|
+
// a LEASE stops two of them from racing at the server.
|
|
96
|
+
//
|
|
97
|
+
// GENERATION = [transcript characters read, capture time]. The transcript is append-only per session, so
|
|
98
|
+
// a later turn always reads at least as many characters; the timestamp only breaks ties (the Stop hook
|
|
99
|
+
// firing twice with nothing new in between). Carried in the queue entry AND in the posted payload, so a
|
|
100
|
+
// later server PR can reject an older generation for a session it already holds โ see the follow-up note
|
|
101
|
+
// in the PR. Every queue write is compare-and-set (never replace a newer generation) and every removal
|
|
102
|
+
// is compare-and-delete (only delete what the caller superseded).
|
|
103
|
+
//
|
|
104
|
+
// LEASE = one file per session under `_locks/`, held across the check-then-POST-then-update critical
|
|
105
|
+
// section by whoever is posting โ live capture or drain. It makes a session's uploads serial, so a
|
|
106
|
+
// poster can check "has a newer generation already been recorded?" (`_posted/`) and act on the answer
|
|
107
|
+
// without another poster slipping in between. Never held across a summarize: that is minutes, and the
|
|
108
|
+
// lease must stay short. Stale by pid-death or TTL, because a worker can be killed mid-post.
|
|
109
|
+
export const LEASE_TTL_MS = 60_000
|
|
110
|
+
/** A live capture will wait this long for another worker's POST of the same session to finish. */
|
|
111
|
+
export const LEASE_WAIT_MS = 30_000
|
|
112
|
+
/** A drain that has spent a `claude -p` run waits at most this long for the lease before giving up. */
|
|
113
|
+
export const DRAIN_LEASE_WAIT_MS = 5_000
|
|
114
|
+
/** A claim is abandoned after this, or as soon as the claiming process is gone (crash mid-claim). */
|
|
115
|
+
export const CLAIM_TTL_MS = 10 * 60_000
|
|
116
|
+
/** A summarize entry touched this recently belongs to a session that is probably still running โ its
|
|
117
|
+
* own next turn will capture it. A drain `claude -p` run holds the machine-wide summarizer lock for
|
|
118
|
+
* ~1โ2 min (edge_extract.mjs), which turns every concurrent session's capture into `busy`; spending
|
|
119
|
+
* that on a session about to capture itself is pure cost. */
|
|
120
|
+
export const SUMMARIZE_SETTLE_MS = 5 * 60_000
|
|
121
|
+
|
|
122
|
+
const DAY_MS = 24 * 60 * 60_000
|
|
123
|
+
|
|
124
|
+
export function queueDir(home = homedir()) {
|
|
125
|
+
return join(home, '.cortex', 'capture-queue')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A filesystem-safe, stable key per session. Claude and Codex session ids are UUIDs and pass as-is. */
|
|
129
|
+
export function entryKey(sessionId) {
|
|
130
|
+
if (typeof sessionId === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(sessionId)) return sessionId
|
|
131
|
+
if (typeof sessionId === 'string' && sessionId) return `h-${createHash('sha1').update(sessionId).digest('hex').slice(0, 24)}`
|
|
132
|
+
return `nosession-${Date.now()}-${randomBytes(4).toString('hex')}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* PURE. The generation of one capture: how much transcript it read, and when it read it.
|
|
137
|
+
* `chars` is the ordering key; `at` only breaks ties.
|
|
138
|
+
*/
|
|
139
|
+
export function makeGen(chars, at) {
|
|
140
|
+
return { chars: Number.isFinite(chars) ? Math.max(0, Math.trunc(chars)) : 0, at: at ?? 0 }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** PURE. Order two generations. A missing generation is older than any real one. */
|
|
144
|
+
export function cmpGen(a, b) {
|
|
145
|
+
const ac = a ? a.chars ?? 0 : -1
|
|
146
|
+
const bc = b ? b.chars ?? 0 : -1
|
|
147
|
+
if (ac !== bc) return ac < bc ? -1 : 1
|
|
148
|
+
const aa = a?.at ?? 0
|
|
149
|
+
const ba = b?.at ?? 0
|
|
150
|
+
return aa === ba ? 0 : aa < ba ? -1 : 1
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The generation as it travels in the posted payload (snake_case, like its neighbours there). */
|
|
154
|
+
export function genPayload(gen) {
|
|
155
|
+
return { transcript_chars: gen?.chars ?? 0, captured_at: new Date(gen?.at ?? Date.now()).toISOString() }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** PURE. Per-entry retry delay: 2 min doubling to 6 h, EQUAL jitter (half fixed, half random) so a
|
|
159
|
+
* fleet that failed together does not come back together, while no entry retries sooner than half. */
|
|
160
|
+
export function queueBackoffMs(attempts, random = Math.random) {
|
|
161
|
+
const ceiling = Math.min(QUEUE_FIRST_RETRY_MS * 2 ** Math.max(0, attempts - 1), QUEUE_MAX_RETRY_MS)
|
|
162
|
+
return Math.floor(ceiling / 2 + random() * (ceiling / 2))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const isFlagged = (entry) => (entry?.attempts ?? 0) >= FLAG_AFTER_ATTEMPTS
|
|
166
|
+
|
|
167
|
+
// โโ storage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
168
|
+
|
|
169
|
+
function ensureDir(dir) {
|
|
170
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function atomicWrite(dir, name, text) {
|
|
174
|
+
ensureDir(dir)
|
|
175
|
+
const file = join(dir, name)
|
|
176
|
+
const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`
|
|
177
|
+
writeFileSync(tmp, text, { mode: 0o600 })
|
|
178
|
+
renameSync(tmp, file) // atomic: a concurrent reader never sees half a file
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function readEntry(dir, key) {
|
|
182
|
+
try { return JSON.parse(readFileSync(join(dir, `${key}.json`), 'utf8')) } catch { return null }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Every readable entry. An unreadable one is removed AND recorded โ never silently skipped forever. */
|
|
186
|
+
export function readEntries(dir, { log = () => {}, now = Date.now } = {}) {
|
|
187
|
+
let names
|
|
188
|
+
try { names = readdirSync(dir) } catch { return [] }
|
|
189
|
+
const out = []
|
|
190
|
+
for (const name of names) {
|
|
191
|
+
if (!name.endsWith('.json') || name.startsWith('_')) continue
|
|
192
|
+
try {
|
|
193
|
+
const entry = JSON.parse(readFileSync(join(dir, name), 'utf8'))
|
|
194
|
+
if (entry && typeof entry === 'object' && entry.key) out.push(entry)
|
|
195
|
+
else throw new Error('not an entry')
|
|
196
|
+
} catch {
|
|
197
|
+
try { unlinkSync(join(dir, name)) } catch { /* raced with another reader */ }
|
|
198
|
+
recordDropped(dir, { sessionId: name.replace(/\.json$/, ''), reason: 'unreadable queue file' }, now())
|
|
199
|
+
log(`DROPPED unreadable queue file ${name}`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function writeEntry(dir, entry) {
|
|
206
|
+
const text = JSON.stringify(entry)
|
|
207
|
+
if (Buffer.byteLength(text) > QUEUE_MAX_ENTRY_BYTES) return false
|
|
208
|
+
atomicWrite(dir, `${entry.key}.json`, text)
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Remove `key` โ but only if it is still the revision we hold (a newer turn may have replaced it). */
|
|
213
|
+
export function removeEntry(dir, key, rev = null) {
|
|
214
|
+
if (rev) {
|
|
215
|
+
const cur = readEntry(dir, key)
|
|
216
|
+
if (cur && cur.rev !== rev) return false
|
|
217
|
+
}
|
|
218
|
+
try { unlinkSync(join(dir, `${key}.json`)); return true } catch { return false }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Compare-and-delete: drop this session's entry only if it is NOT newer than the generation the caller
|
|
223
|
+
* just recorded. This is what a successful post calls, and it is the whole fix for "turn N's success
|
|
224
|
+
* deleted turn N+1's queued capture" โ N+1 is newer, so it stays.
|
|
225
|
+
*/
|
|
226
|
+
export function removeIfNotNewer(dir, key, gen) {
|
|
227
|
+
const cur = readEntry(dir, key)
|
|
228
|
+
if (!cur) return false
|
|
229
|
+
if (cmpGen(cur.gen, gen) > 0) return false
|
|
230
|
+
return removeEntry(dir, key, cur.rev)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const newRev = () => randomBytes(6).toString('hex')
|
|
234
|
+
|
|
235
|
+
// โโ the session lease โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
236
|
+
|
|
237
|
+
const leaseDir = (dir) => join(dir, '_locks')
|
|
238
|
+
|
|
239
|
+
function pidAlive(pid) {
|
|
240
|
+
const n = Number(pid)
|
|
241
|
+
if (!Number.isInteger(n) || n <= 0) return false
|
|
242
|
+
try { process.kill(n, 0); return true } catch (e) { return e?.code === 'EPERM' } // EPERM = alive, not ours
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Is the lock file abandoned? Its holder is gone, or it has outlived the TTL. */
|
|
246
|
+
function leaseIsStale(file) {
|
|
247
|
+
let text
|
|
248
|
+
try { text = readFileSync(file, 'utf8') } catch (e) { return e?.code === 'ENOENT' }
|
|
249
|
+
const [pid, at] = text.trim().split(/\s+/)
|
|
250
|
+
// An EMPTY file is a lock caught between create and write โ young, and not ours to break. Judge those
|
|
251
|
+
// by mtime only, or we would steal a lease microseconds after a neighbour created it.
|
|
252
|
+
if (!pid || !Number.isFinite(Number(at))) {
|
|
253
|
+
try { return Date.now() - statSync(file).mtimeMs > LEASE_TTL_MS } catch { return false }
|
|
254
|
+
}
|
|
255
|
+
return !pidAlive(pid) || Date.now() - Number(at) > LEASE_TTL_MS
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Take this session's lease. Returns a release function, or null when someone else holds it and
|
|
260
|
+
* `waitMs` ran out. Timestamps are REAL time (a lock file outlives any injected clock).
|
|
261
|
+
*
|
|
262
|
+
* Crash recovery: a lease whose holder is dead, or older than LEASE_TTL_MS, is broken and retaken. The
|
|
263
|
+
* release only unlinks a file that still carries OUR token, so a lease broken underneath us is never
|
|
264
|
+
* deleted from under its new holder.
|
|
265
|
+
*/
|
|
266
|
+
export async function acquireLease(dir, key, { waitMs = 0, pollMs = 25 } = {}) {
|
|
267
|
+
const file = join(leaseDir(dir), `${key}.lock`)
|
|
268
|
+
try { mkdirSync(leaseDir(dir), { recursive: true, mode: 0o700 }) } catch { return null }
|
|
269
|
+
const token = `${process.pid} ${Date.now()} ${randomBytes(4).toString('hex')}`
|
|
270
|
+
const deadline = Date.now() + waitMs
|
|
271
|
+
let breaks = 0
|
|
272
|
+
for (;;) {
|
|
273
|
+
try {
|
|
274
|
+
const fd = openSync(file, 'wx', 0o600)
|
|
275
|
+
try { writeFileSync(fd, token) } finally { closeSync(fd) }
|
|
276
|
+
return () => { try { if (readFileSync(file, 'utf8') === token) unlinkSync(file) } catch { /* broken or gone */ } }
|
|
277
|
+
} catch (e) {
|
|
278
|
+
if (e?.code !== 'EEXIST') return null
|
|
279
|
+
}
|
|
280
|
+
if (leaseIsStale(file) && breaks < 3) {
|
|
281
|
+
breaks += 1
|
|
282
|
+
try { unlinkSync(file) } catch { /* raced with another breaker */ }
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
if (Date.now() >= deadline) return null
|
|
286
|
+
await new Promise((r) => setTimeout(r, pollMs))
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Run `fn` under the session lease. `fn` receives true if the lease was actually held. */
|
|
291
|
+
export async function withLease(dir, key, opts, fn) {
|
|
292
|
+
const release = await acquireLease(dir, key, opts)
|
|
293
|
+
try { return await fn(Boolean(release)) } finally { release?.() }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// โโ what has already been recorded for a session โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
297
|
+
//
|
|
298
|
+
// The high-water mark of generations this machine has successfully posted for a session, kept after the
|
|
299
|
+
// queue entry is gone. It is how a poster knows its own capture is already superseded, and it is checked
|
|
300
|
+
// under the lease so the answer cannot go stale between the check and the POST.
|
|
301
|
+
|
|
302
|
+
const postedDir = (dir) => join(dir, '_posted')
|
|
303
|
+
|
|
304
|
+
export function readPostedGen(dir, key) {
|
|
305
|
+
try { return JSON.parse(readFileSync(join(postedDir(dir), `${key}.json`), 'utf8')).gen ?? null } catch { return null }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Raise the mark. Monotonic: an older generation never lowers it. Call under the lease. */
|
|
309
|
+
export function raisePostedGen(dir, key, gen) {
|
|
310
|
+
if (!key || !gen) return
|
|
311
|
+
const cur = readPostedGen(dir, key)
|
|
312
|
+
if (cur && cmpGen(cur, gen) >= 0) return
|
|
313
|
+
try { atomicWrite(postedDir(dir), `${key}.json`, JSON.stringify({ gen, at: Date.now() })) } catch { /* best-effort */ }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Has a NEWER capture of this session already been recorded? Then this entry is finished, not pending. */
|
|
317
|
+
export function supersededByPosted(dir, entry) {
|
|
318
|
+
const posted = readPostedGen(dir, entry.key)
|
|
319
|
+
if (!posted || cmpGen(posted, entry.gen) <= 0) return false
|
|
320
|
+
removeEntry(dir, entry.key, entry.rev)
|
|
321
|
+
return true
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** A claim is live while the process that made it is alive and it has not outlived CLAIM_TTL_MS. */
|
|
325
|
+
export function claimIsLive(claim) {
|
|
326
|
+
if (!claim) return false
|
|
327
|
+
if (!pidAlive(claim.pid)) return false
|
|
328
|
+
return Date.now() - (claim.at ?? 0) < CLAIM_TTL_MS
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// โโ the drop ledger โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
332
|
+
|
|
333
|
+
export function readDropped(dir, now = Date.now()) {
|
|
334
|
+
try {
|
|
335
|
+
const list = JSON.parse(readFileSync(join(dir, DROPPED_FILE), 'utf8'))
|
|
336
|
+
return Array.isArray(list) ? list.filter((d) => now - (d?.at ?? 0) <= DROPPED_WINDOW_DAYS * DAY_MS) : []
|
|
337
|
+
} catch {
|
|
338
|
+
return []
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function recordDropped(dir, { sessionId, reason }, now = Date.now()) {
|
|
343
|
+
try {
|
|
344
|
+
const list = [...readDropped(dir, now), { at: now, sessionId: sessionId ?? null, reason: String(reason ?? 'unknown') }]
|
|
345
|
+
atomicWrite(dir, DROPPED_FILE, JSON.stringify(list.slice(-DROPPED_MAX)))
|
|
346
|
+
} catch { /* a ledger write must never cost a capture */ }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// โโ enqueue + bounds โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Queue (or re-queue) a capture that did not land. Returns the entry written, or null if it could not
|
|
353
|
+
* be stored โ in which case it is in the drop ledger, so it is still reported.
|
|
354
|
+
*
|
|
355
|
+
* COMPARE-AND-SET on the generation: if a NEWER turn of this session is already queued, this older one
|
|
356
|
+
* is not written over it, and the newer entry is returned carrying `superseded: true`. That is the fix
|
|
357
|
+
* for an out-of-order worker replacing a newer queued capture with its own staler content. Nothing is
|
|
358
|
+
* lost by refusing: the newer turn read a superset of this transcript.
|
|
359
|
+
*
|
|
360
|
+
* `countsAsFailure: false` is for a summarizer that was merely BUSY (another session held the lock):
|
|
361
|
+
* that resolves itself and is not evidence of anything wrong, so it must not march an entry toward
|
|
362
|
+
* being flagged.
|
|
363
|
+
*
|
|
364
|
+
* Call under the session lease where one is available; the read-compare-write is re-checked here, so a
|
|
365
|
+
* best-effort call without the lease is still ordered against whatever is on disk at this moment.
|
|
366
|
+
*/
|
|
367
|
+
export function enqueueCapture(dir, item, { now = Date.now(), random = Math.random, log = () => {} } = {}) {
|
|
368
|
+
const { sessionId, kind, reason, detail = null, common, transcript, body, retryAfterMs = null, countsAsFailure = true } = item
|
|
369
|
+
const key = entryKey(sessionId)
|
|
370
|
+
const gen = item.gen ?? makeGen(0, now)
|
|
371
|
+
const prev = readEntry(dir, key)
|
|
372
|
+
if (prev && cmpGen(prev.gen, gen) > 0) {
|
|
373
|
+
log(`${sessionId ?? key}: a NEWER turn of this session is already queued (${prev.kind}) โ this older capture is not written over it`)
|
|
374
|
+
return { ...prev, superseded: true }
|
|
375
|
+
}
|
|
376
|
+
const attempts = (prev?.attempts ?? 0) + (countsAsFailure ? 1 : 0)
|
|
377
|
+
const entry = {
|
|
378
|
+
v: 1,
|
|
379
|
+
key,
|
|
380
|
+
rev: newRev(),
|
|
381
|
+
gen,
|
|
382
|
+
sessionId: sessionId ?? null,
|
|
383
|
+
kind,
|
|
384
|
+
reason: String(reason ?? 'unknown'),
|
|
385
|
+
detail: detail ? String(detail).slice(0, 300) : null,
|
|
386
|
+
attempts,
|
|
387
|
+
firstQueuedAt: prev?.firstQueuedAt ?? now,
|
|
388
|
+
capturedAt: now,
|
|
389
|
+
updatedAt: now,
|
|
390
|
+
nextAttemptAt: now + Math.max(queueBackoffMs(Math.max(1, attempts), random), retryAfterMs ?? 0),
|
|
391
|
+
...(kind === 'summarize' ? { common, transcript } : { body }),
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
if (!writeEntry(dir, entry)) {
|
|
395
|
+
recordDropped(dir, { sessionId, reason: 'too large to queue' }, now)
|
|
396
|
+
log(`DROPPED ${sessionId ?? key} โ the capture is larger than ${QUEUE_MAX_ENTRY_BYTES} bytes and cannot be queued`)
|
|
397
|
+
return null
|
|
398
|
+
}
|
|
399
|
+
} catch (e) {
|
|
400
|
+
recordDropped(dir, { sessionId, reason: 'queue write failed' }, now)
|
|
401
|
+
log(`DROPPED ${sessionId ?? key} โ could not write the local queue: ${e instanceof Error ? e.message : String(e)}`)
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
if (isFlagged(entry) && !isFlagged(prev)) {
|
|
405
|
+
log(`FLAGGED ${sessionId ?? key} after ${attempts} failed attempts [${entry.reason}] โ kept, retried, and reported at the next session start`)
|
|
406
|
+
}
|
|
407
|
+
enforceBounds(dir, { now: () => now, log, keep: key })
|
|
408
|
+
return entry
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Re-schedule an entry after another failed attempt. No-op if a newer turn replaced it meanwhile.
|
|
412
|
+
* Also clears any claim: the attempt that held it is over. */
|
|
413
|
+
function bumpEntry(dir, entry, { reason, detail, retryAfterMs = null, patch = {} }, { now, random, log }) {
|
|
414
|
+
const cur = readEntry(dir, entry.key)
|
|
415
|
+
if (!cur || cur.rev !== entry.rev) return null
|
|
416
|
+
const attempts = (cur.attempts ?? 0) + 1
|
|
417
|
+
const next = {
|
|
418
|
+
...cur,
|
|
419
|
+
...patch,
|
|
420
|
+
claim: undefined,
|
|
421
|
+
rev: newRev(),
|
|
422
|
+
reason: String(reason ?? cur.reason),
|
|
423
|
+
detail: detail ? String(detail).slice(0, 300) : cur.detail,
|
|
424
|
+
attempts,
|
|
425
|
+
updatedAt: now,
|
|
426
|
+
nextAttemptAt: now + Math.max(queueBackoffMs(attempts, random), retryAfterMs ?? 0),
|
|
427
|
+
}
|
|
428
|
+
try { writeEntry(dir, next) } catch { return null }
|
|
429
|
+
if (isFlagged(next) && !isFlagged(cur)) {
|
|
430
|
+
log(`FLAGGED ${cur.sessionId ?? cur.key} after ${attempts} failed attempts [${next.reason}] โ kept, retried, and reported at the next session start`)
|
|
431
|
+
}
|
|
432
|
+
return next
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Enforce age and count. Everything forced out is RECORDED in the drop ledger and logged โ a bound is
|
|
437
|
+
* the one place this queue loses anything, so it is exactly where it must say so.
|
|
438
|
+
*/
|
|
439
|
+
export function enforceBounds(dir, { now = Date.now, log = () => {}, keep = null } = {}) {
|
|
440
|
+
const t = now()
|
|
441
|
+
// Stale temp files from a process killed mid-write.
|
|
442
|
+
try {
|
|
443
|
+
for (const name of readdirSync(dir)) {
|
|
444
|
+
if (!name.endsWith('.tmp')) continue
|
|
445
|
+
try { if (t - statSync(join(dir, name)).mtimeMs > STALE_TMP_MS) unlinkSync(join(dir, name)) } catch { /* raced */ }
|
|
446
|
+
}
|
|
447
|
+
} catch { return }
|
|
448
|
+
pruneSidecars(dir)
|
|
449
|
+
let entries = readEntries(dir, { log, now })
|
|
450
|
+
for (const e of entries) {
|
|
451
|
+
if (t - (e.capturedAt ?? e.firstQueuedAt ?? t) > QUEUE_MAX_AGE_DAYS * DAY_MS) {
|
|
452
|
+
// rev-guarded: if a live capture rewrote this entry since we listed it, leave the newer one alone.
|
|
453
|
+
if (!removeEntry(dir, e.key, e.rev)) continue
|
|
454
|
+
recordDropped(dir, { sessionId: e.sessionId, reason: `older than ${QUEUE_MAX_AGE_DAYS} days (last: ${e.reason})` }, t)
|
|
455
|
+
log(`DROPPED ${e.sessionId ?? e.key} โ queued ${QUEUE_MAX_AGE_DAYS}+ days without being recorded (last reason: ${e.reason})`)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
entries = readEntries(dir, { log, now })
|
|
459
|
+
if (entries.length <= QUEUE_MAX_ENTRIES) return
|
|
460
|
+
const evictable = entries.filter((e) => e.key !== keep).sort((a, b) => (a.firstQueuedAt ?? 0) - (b.firstQueuedAt ?? 0))
|
|
461
|
+
for (const e of evictable.slice(0, entries.length - QUEUE_MAX_ENTRIES)) {
|
|
462
|
+
if (!removeEntry(dir, e.key, e.rev)) continue
|
|
463
|
+
recordDropped(dir, { sessionId: e.sessionId, reason: `queue full (${QUEUE_MAX_ENTRIES} entries; last: ${e.reason})` }, t)
|
|
464
|
+
log(`DROPPED ${e.sessionId ?? e.key} โ the local queue is full (${QUEUE_MAX_ENTRIES}); oldest evicted (last reason: ${e.reason})`)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The two sidecar directories, pruned on REAL time (they are filesystem artifacts, not scheduled work):
|
|
470
|
+
* _posted/ one tiny mark per session, kept as long as an entry could still be queued for it
|
|
471
|
+
* _locks/ released on exit, so anything this old is a lease whose process died
|
|
472
|
+
*/
|
|
473
|
+
function pruneSidecars(dir) {
|
|
474
|
+
const real = Date.now()
|
|
475
|
+
for (const [sub, maxAge] of [[postedDir(dir), QUEUE_MAX_AGE_DAYS * DAY_MS], [leaseDir(dir), 10 * LEASE_TTL_MS]]) {
|
|
476
|
+
try {
|
|
477
|
+
for (const name of readdirSync(sub)) {
|
|
478
|
+
try { if (real - statSync(join(sub, name)).mtimeMs > maxAge) unlinkSync(join(sub, name)) } catch { /* raced */ }
|
|
479
|
+
}
|
|
480
|
+
} catch { /* no sidecar dir yet */ }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// โโ /api/ingest: what an answer means โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
485
|
+
//
|
|
486
|
+
// IDEMPOTENCY โ CONCLUSION (read against web/app/api/ingest/route.ts, 2026-09-11):
|
|
487
|
+
// /api/ingest DEDUPES a session capture on its sessionId, on every path it can take:
|
|
488
|
+
// ยท records `insert โฆ on conflict (org_id, dedupe_key) do update`, dedupe_key = `${source}:${sessionId}`.
|
|
489
|
+
// A replay rewrites the same row. The post-commit projectโactor edge strengthens only
|
|
490
|
+
// when `inserted`, so a replay does not double-count it either.
|
|
491
|
+
// ยท private the intake unit is keyed by intakeUnitKey(), which is the sessionId when present;
|
|
492
|
+
// intake a re-push answers `duplicate` / `updated` against the same unit.
|
|
493
|
+
// ยท staged unrouted captures stage under the same `${source}:${sessionId}` key.
|
|
494
|
+
// And the Stop hook has ALWAYS re-posted the same session on every turn, relying on exactly this.
|
|
495
|
+
// So a capture WITH a sessionId is idempotent: it may be retried after a 5xx, a broken connection or
|
|
496
|
+
// one timeout, in-process or from this queue.
|
|
497
|
+
//
|
|
498
|
+
// โ WITHOUT a sessionId it is NOT: the records key falls back to occurred_at (stable only if sent) and
|
|
499
|
+
// intakeUnitKey falls back to a key containing Date.now() โ a replay mints a second unit. Such a capture
|
|
500
|
+
// is replayed only after failures where the request provably never reached the server (a refused
|
|
501
|
+
// connection, 429/503, an infrastructure 403, a 401). Claude Code and Codex always send a session id,
|
|
502
|
+
// so this is the rare path, and it fails toward a REPORTED drop rather than a silent duplicate.
|
|
503
|
+
|
|
504
|
+
/** The policy for every capture POST. Worst case ~25 s in a DETACHED worker that nobody waits on โ
|
|
505
|
+
* less when the caller has less left (the drain passes its remaining budget). */
|
|
506
|
+
export function ingestPolicy(body, budgetMs = INGEST_BUDGET_MS) {
|
|
507
|
+
const idempotent = typeof body?.sessionId === 'string' && body.sessionId.length > 0
|
|
508
|
+
const budget = Math.max(1, Math.min(INGEST_BUDGET_MS, Number.isFinite(budgetMs) ? budgetMs : INGEST_BUDGET_MS))
|
|
509
|
+
return { retries: 1, idempotent, budgetMs: budget }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* PURE. Classify an /api/ingest HTTP answer:
|
|
514
|
+
* recorded a session record exists (the only success)
|
|
515
|
+
* final the server decided โ staged, skipped, discarded, filed as an intake unit. Retrying cannot
|
|
516
|
+
* change it; any older queued turn of the same session is superseded.
|
|
517
|
+
* retry not recorded, and a later replay is safe and may succeed
|
|
518
|
+
* rejected not recorded, and a replay is unsafe or pointless (a 4xx verdict). Reported as a drop.
|
|
519
|
+
*/
|
|
520
|
+
export function classifyIngestResponse(status, contentType, raw, { repo = 'general', requestId = null, idempotent = false } = {}) {
|
|
521
|
+
if (status >= 200 && status < 300) {
|
|
522
|
+
let j = null
|
|
523
|
+
try { j = JSON.parse(raw) } catch { j = null }
|
|
524
|
+
// A 2xx FROM /api/ingest DOES NOT MEAN A RECORD EXISTS. The route answers `ok: true` on at least
|
|
525
|
+
// six outcomes and only one of them writes a session record:
|
|
526
|
+
//
|
|
527
|
+
// { ok: true, id, inserted, title } -> RECORDED (the only success)
|
|
528
|
+
// { ok: true, staged: true, id, reason } -> held in staged_records, NOT recorded
|
|
529
|
+
// { ok: true, skipped: '<why>' } -> no-op session / past the ingest horizon
|
|
530
|
+
// { ok: false, skipped: '<why>' } -> connector excluded from this brain
|
|
531
|
+
// { ok: true, discarded: true } -> tombstoned by private intake
|
|
532
|
+
// { ok: true, queued: true } -> accepted for later work
|
|
533
|
+
// { ok: true, via: 'private_intake', intakeItemId } -> an intake unit, not a session record
|
|
534
|
+
//
|
|
535
|
+
// The old line read `j.inserted ? 'captured' : 'updated'`, so EVERY one of the six non-writing
|
|
536
|
+
// outcomes printed "updated" โ the word for a successful upsert. Worse, `.json().catch(() => ({}))`
|
|
537
|
+
// means an unparseable body also yields `{}` and therefore also printed "updated". Verified against
|
|
538
|
+
// prod 2026-08-19: a capture printed `cortex: updated "general" โ general` for a session that has no
|
|
539
|
+
// row in `records` and none in `staged_records` either.
|
|
540
|
+
//
|
|
541
|
+
// NOTE `staged` CARRIES AN `id`, so testing for an id alone is not enough โ that id is the
|
|
542
|
+
// staged_records row, not a record. This is the same false-success defect already fixed once in
|
|
543
|
+
// log_session ("`inserted` is merely falsy when nothing is recorded โ an agent reported a session as
|
|
544
|
+
// saved when it was not"); the fix was applied there and not here. The server route already knew:
|
|
545
|
+
// its own comment at the no_route_for_source branch says "`{ok: true}` reads as success to
|
|
546
|
+
// everything that is not looking closely" and notes 84 rows accumulating behind that wording.
|
|
547
|
+
// (Moved here from capture.mjs with the classifier, 2026-09-11.)
|
|
548
|
+
if (j && j.id && !j.staged) return { outcome: 'recorded', line: `${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" โ ${repo}` }
|
|
549
|
+
if (j && j.staged) return { outcome: 'final', line: `NOT RECORDED โ staged (${j.reason ?? 'no reason given'}); staged session logs are not drainable by /api/staged/promote` }
|
|
550
|
+
if (j && j.skipped) return { outcome: 'final', line: `NOT RECORDED โ server skipped: ${j.skipped}` }
|
|
551
|
+
if (j && j.discarded) return { outcome: 'final', line: 'NOT RECORDED โ discarded by private intake' }
|
|
552
|
+
// Accepted before anything was written, with no record id to prove it ever was. Replaying the
|
|
553
|
+
// same session is harmless (idempotent) and is the only way to find out.
|
|
554
|
+
if (j && j.queued) {
|
|
555
|
+
return idempotent
|
|
556
|
+
? { outcome: 'retry', reason: 'accepted without a record (queued)', line: 'NOT RECORDED โ queued for later processing; kept in the local queue until a record exists' }
|
|
557
|
+
: { outcome: 'final', line: 'NOT RECORDED โ queued for later processing' }
|
|
558
|
+
}
|
|
559
|
+
if (j && j.intakeItemId) return { outcome: 'final', line: `not a session record โ filed as private intake unit ${j.intakeItemId}` }
|
|
560
|
+
// Unparseable or unrecognised 2xx. Deliberately NOT success โ that is what the old code laundered
|
|
561
|
+
// into "updated".
|
|
562
|
+
return {
|
|
563
|
+
outcome: idempotent ? 'retry' : 'rejected',
|
|
564
|
+
reason: 'unrecognised response',
|
|
565
|
+
line: `NOT RECORDED โ unrecognised 2xx response: ${String(raw).slice(0, 200)}`,
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const d = classify(status, contentType, raw, requestId)
|
|
569
|
+
const klass = responseRetryClass(status, contentType)
|
|
570
|
+
const line = `NOT RECORDED โ ingest failed: ${d.message}`
|
|
571
|
+
// Refused (429/503/infra 403) and 401 were never processed: always safe to replay. A 401 is kept
|
|
572
|
+
// rather than dropped because an expired token is fixed by logging in again โ and the capture
|
|
573
|
+
// should still be here when that happens (the same call obligations_worker makes).
|
|
574
|
+
if (klass === 'refused') return { outcome: 'retry', reason: `HTTP ${status}`, line }
|
|
575
|
+
if (status === 401) return { outcome: 'retry', reason: 'token rejected (HTTP 401)', line }
|
|
576
|
+
if (klass === 'server') return idempotent ? { outcome: 'retry', reason: `HTTP ${status}`, line } : { outcome: 'rejected', reason: `HTTP ${status}, no session id to replay safely`, line }
|
|
577
|
+
return { outcome: 'rejected', reason: `rejected (HTTP ${status})`, line }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* POST one capture. Never throws. The single exit through which every capture body leaves the machine,
|
|
582
|
+
* which is why D8's rule is enforced HERE and not only at the call sites: whatever a caller passes, a
|
|
583
|
+
* `transcript` field is stripped before it is serialized.
|
|
584
|
+
*/
|
|
585
|
+
export async function postIngest({ base, token, body, repo = 'general', fetchImpl = fetchCortex, budgetMs = INGEST_BUDGET_MS }) {
|
|
586
|
+
const { transcript: _neverSent, ...safe } = body ?? {} // D8: the transcript never leaves this machine
|
|
587
|
+
const policy = ingestPolicy(safe, budgetMs)
|
|
588
|
+
let res
|
|
589
|
+
try {
|
|
590
|
+
res = await fetchImpl(`${base}/api/ingest`, {
|
|
591
|
+
method: 'POST',
|
|
592
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
593
|
+
body: JSON.stringify(safe),
|
|
594
|
+
timeoutMs: Math.min(10_000, policy.budgetMs),
|
|
595
|
+
}, policy)
|
|
596
|
+
} catch (e) {
|
|
597
|
+
// No response at all. Safe to replay if nothing reached the server, or if the body dedupes.
|
|
598
|
+
const neverSent = e?.retryClass === 'connect'
|
|
599
|
+
const reason = e?.retryClass === 'timeout' ? 'timed out' : neverSent ? 'unreachable' : 'connection failed'
|
|
600
|
+
const line = `NOT RECORDED โ ${e instanceof Error ? e.message : String(e)}`
|
|
601
|
+
return neverSent || policy.idempotent
|
|
602
|
+
? { outcome: 'retry', reason, line, retryAfterMs: null }
|
|
603
|
+
: { outcome: 'rejected', reason: `${reason}, no session id to replay safely`, line }
|
|
604
|
+
}
|
|
605
|
+
const raw = await res.text().catch(() => '')
|
|
606
|
+
const c = classifyIngestResponse(res.status, res.headers.get('content-type'), raw, {
|
|
607
|
+
repo, requestId: res.headers.get('x-vercel-id'), idempotent: policy.idempotent,
|
|
608
|
+
})
|
|
609
|
+
return c.outcome === 'retry' ? { ...c, retryAfterMs: parseRetryAfter(res.headers.get('retry-after')) } : c
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// โโ drain โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Retry what is due. Called after every detached capture, so it must be cheap when the queue is empty
|
|
616
|
+
* (one readdir) and must never throw. Health signals from the capture that just ran decide what is
|
|
617
|
+
* worth trying: if the server just failed, no POSTs; if the summarizer just failed, no `claude -p`.
|
|
618
|
+
* A summarizer that just WORKED is also why a summarize entry may skip its schedule โ the thing it was
|
|
619
|
+
* waiting for is demonstrably back.
|
|
620
|
+
*
|
|
621
|
+
* ๐ด NOTHING IS POSTED FROM THE LISTING. The listing is a snapshot, and a summarize can take minutes, so
|
|
622
|
+
* by upload time a live capture of that session may have posted something newer and rewritten or removed
|
|
623
|
+
* the entry. Every upload therefore happens under this session's LEASE, against a FRESH read, and only
|
|
624
|
+
* after checking `_posted/` โ so a digest read minutes ago can never overwrite a newer record. A
|
|
625
|
+
* summarize is additionally CLAIMED first, so two drains don't spend two `claude -p` runs on one entry.
|
|
626
|
+
*
|
|
627
|
+
* deps: { dir, now, random, log, post(body, entry, {budgetMs}) โ outcome,
|
|
628
|
+
* summarize(transcript, {timeoutMs}) โ {extracted}|{noop}|{unavailable},
|
|
629
|
+
* buildBody(common, extracted, transcript) โ body, skipKey, serverHealthy, summarizerHealthy,
|
|
630
|
+
* allowSummarize, maxPosts, maxSummaries, budgetMs }
|
|
631
|
+
*/
|
|
632
|
+
export async function drainCaptureQueue(deps) {
|
|
633
|
+
const {
|
|
634
|
+
dir, now = Date.now, random = Math.random, log = () => {},
|
|
635
|
+
post, summarize, buildBody,
|
|
636
|
+
skipKey = null, serverHealthy = null, summarizerHealthy = null, allowSummarize = false,
|
|
637
|
+
maxPosts = DRAIN_MAX_POSTS, maxSummaries = DRAIN_MAX_SUMMARIES, budgetMs = DRAIN_BUDGET_MS,
|
|
638
|
+
leaseWaitMs = DRAIN_LEASE_WAIT_MS,
|
|
639
|
+
} = deps
|
|
640
|
+
const result = { recorded: 0, final: 0, dropped: 0, failed: 0, summarized: 0, superseded: 0, deferred: 0 }
|
|
641
|
+
try {
|
|
642
|
+
enforceBounds(dir, { now, log })
|
|
643
|
+
const startedAt = now()
|
|
644
|
+
const remaining = () => budgetMs - (now() - startedAt)
|
|
645
|
+
const entries = readEntries(dir, { log, now })
|
|
646
|
+
.filter((e) => e.key !== skipKey)
|
|
647
|
+
.sort((a, b) => (a.firstQueuedAt ?? 0) - (b.firstQueuedAt ?? 0))
|
|
648
|
+
let serverOk = serverHealthy !== false
|
|
649
|
+
let summarizerOk = summarizerHealthy !== false
|
|
650
|
+
let posts = 0
|
|
651
|
+
let summaries = 0
|
|
652
|
+
const ctx = () => ({ now: now(), random, log })
|
|
653
|
+
|
|
654
|
+
/** Act on a POST outcome. Runs under the lease, against the entry that was actually posted. */
|
|
655
|
+
const settle = (out, entry, who) => {
|
|
656
|
+
if (out.outcome === 'recorded' || out.outcome === 'final') {
|
|
657
|
+
raisePostedGen(dir, entry.key, entry.gen)
|
|
658
|
+
removeIfNotNewer(dir, entry.key, entry.gen)
|
|
659
|
+
if (out.outcome === 'recorded') {
|
|
660
|
+
result.recorded += 1
|
|
661
|
+
log(`queued ${who} recorded on attempt ${(entry.attempts ?? 0) + 1}: ${out.line}`)
|
|
662
|
+
} else {
|
|
663
|
+
result.final += 1
|
|
664
|
+
log(`queued ${who} ${out.line}`)
|
|
665
|
+
}
|
|
666
|
+
} else if (out.outcome === 'rejected') {
|
|
667
|
+
removeEntry(dir, entry.key, entry.rev)
|
|
668
|
+
recordDropped(dir, { sessionId: entry.sessionId, reason: out.reason }, now())
|
|
669
|
+
result.dropped += 1
|
|
670
|
+
log(`DROPPED queued ${who} โ ${out.line}`)
|
|
671
|
+
} else {
|
|
672
|
+
serverOk = false // the server just failed: leave everything else for a later run
|
|
673
|
+
const next = bumpEntry(dir, entry, { reason: out.reason, retryAfterMs: out.retryAfterMs }, ctx())
|
|
674
|
+
result.failed += 1
|
|
675
|
+
log(`queued ${who} still ${out.line} (attempt ${next?.attempts ?? '?'})`)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for (const listed of entries) {
|
|
680
|
+
if (remaining() < DRAIN_MIN_POST_MS) break
|
|
681
|
+
if (!serverOk || posts >= maxPosts) break // every remaining path needs a POST
|
|
682
|
+
|
|
683
|
+
const who = listed.sessionId ?? listed.key
|
|
684
|
+
|
|
685
|
+
if (listed.kind === 'summarize') {
|
|
686
|
+
if (!allowSummarize || !summarizerOk || summaries >= maxSummaries) continue
|
|
687
|
+
if (now() - (listed.updatedAt ?? 0) < SUMMARIZE_SETTLE_MS) continue // session likely still live
|
|
688
|
+
if (summarizerHealthy !== true && (listed.nextAttemptAt ?? 0) > now()) continue
|
|
689
|
+
// A summarize is the one step this loop cannot interrupt, so it is only STARTED when the budget
|
|
690
|
+
// can pay for it AND for the upload that follows. Otherwise it waits for the next run.
|
|
691
|
+
if (remaining() < DRAIN_MIN_SUMMARY_MS + DRAIN_POST_RESERVE_MS) {
|
|
692
|
+
result.deferred += 1
|
|
693
|
+
log(`queued ${who}: ${Math.round(remaining() / 1000)}s of drain budget left, not enough to summarize โ left for a later run`)
|
|
694
|
+
continue
|
|
695
|
+
}
|
|
696
|
+
// CLAIM it (under the lease) so a second drain does not summarize the same entry.
|
|
697
|
+
const claimed = await claimForSummary(dir, listed, { log, now })
|
|
698
|
+
if (!claimed) continue
|
|
699
|
+
summaries += 1
|
|
700
|
+
const timeoutMs = Math.max(0, remaining() - DRAIN_POST_RESERVE_MS)
|
|
701
|
+
const s = await summarize(claimed.transcript, { timeoutMs })
|
|
702
|
+
// The summary (if any) is built OUTSIDE the lease: obligation review can spawn `claude` too.
|
|
703
|
+
const body = s?.extracted
|
|
704
|
+
? { ...(await buildBody(claimed.common, s.extracted, claimed.transcript)), occurredAt: new Date(claimed.capturedAt ?? now()).toISOString() }
|
|
705
|
+
: null
|
|
706
|
+
await withLease(dir, listed.key, { waitMs: Math.min(leaseWaitMs, Math.max(0, remaining())) }, async (held) => {
|
|
707
|
+
if (!held) { log(`queued ${who}: summarized, but this session's lease stayed busy โ left for a later run`); return }
|
|
708
|
+
// REVALIDATE against a fresh read: anything that touched this entry while we summarized wins.
|
|
709
|
+
const cur = readEntry(dir, listed.key)
|
|
710
|
+
if (!cur || cur.rev !== claimed.rev) {
|
|
711
|
+
result.superseded += 1
|
|
712
|
+
log(`queued ${who}: replaced by a newer capture while it was being summarized โ the stale digest was NOT posted`)
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
if (s?.unavailable) {
|
|
716
|
+
if (s.unavailable.reason === 'busy') {
|
|
717
|
+
writeEntry(dir, { ...cur, claim: undefined, rev: newRev() }) // release the claim, no attempt spent
|
|
718
|
+
log(`queued ${who}: summarizer busy, will retry`)
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
summarizerOk = false
|
|
722
|
+
const next = bumpEntry(dir, cur, { reason: s.unavailable.reason, detail: s.unavailable.detail }, ctx())
|
|
723
|
+
result.failed += 1
|
|
724
|
+
log(`queued ${who} still NOT RECORDED โ local summarization unavailable [${s.unavailable.reason}] (attempt ${next?.attempts ?? '?'})`)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
if (s?.noop) {
|
|
728
|
+
removeEntry(dir, cur.key, cur.rev)
|
|
729
|
+
result.final += 1
|
|
730
|
+
log(`queued ${who} NOT RECORDED โ summarizer classified it a no-op session`)
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
if (supersededByPosted(dir, cur)) {
|
|
734
|
+
result.superseded += 1
|
|
735
|
+
log(`queued ${who}: a newer capture of this session was recorded while it was being summarized โ dropped without posting`)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
result.summarized += 1
|
|
739
|
+
// Become an ingest entry BEFORE posting: the summary exists now, so a failed POST must never
|
|
740
|
+
// cost another `claude -p` run โ and the transcript tail is no longer needed, so it goes.
|
|
741
|
+
const converted = { ...cur, kind: 'ingest', body, transcript: undefined, common: undefined, claim: undefined, rev: newRev(), updatedAt: now() }
|
|
742
|
+
writeEntry(dir, converted)
|
|
743
|
+
if (remaining() < DRAIN_MIN_POST_MS) {
|
|
744
|
+
result.deferred += 1
|
|
745
|
+
log(`queued ${who}: summarized, but the drain budget is spent โ the upload is left for a later run`)
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
posts += 1
|
|
749
|
+
settle(await post(body, converted, { budgetMs: Math.min(INGEST_BUDGET_MS, remaining()) }), converted, who)
|
|
750
|
+
})
|
|
751
|
+
continue
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (listed.kind !== 'ingest') continue
|
|
755
|
+
if ((listed.nextAttemptAt ?? 0) > now()) continue
|
|
756
|
+
// try-only: a held lease means a live capture of this session is mid-upload. It will handle its
|
|
757
|
+
// own entry, and whatever it records is newer than what we hold.
|
|
758
|
+
await withLease(dir, listed.key, { waitMs: 0 }, async (held) => {
|
|
759
|
+
if (!held) return
|
|
760
|
+
const cur = readEntry(dir, listed.key)
|
|
761
|
+
if (!cur || cur.kind !== 'ingest' || (cur.nextAttemptAt ?? 0) > now()) return
|
|
762
|
+
if (claimIsLive(cur.claim)) return
|
|
763
|
+
if (supersededByPosted(dir, cur)) {
|
|
764
|
+
result.superseded += 1
|
|
765
|
+
log(`queued ${who}: a newer capture of this session is already recorded โ dropped without posting`)
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
if (remaining() < DRAIN_MIN_POST_MS) return
|
|
769
|
+
posts += 1
|
|
770
|
+
settle(await post(cur.body, cur, { budgetMs: Math.min(INGEST_BUDGET_MS, remaining()) }), cur, who)
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
} catch (e) {
|
|
774
|
+
log(`queue drain stopped: ${e instanceof Error ? e.message : String(e)}`)
|
|
775
|
+
}
|
|
776
|
+
return result
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Take an entry for summarizing: under the lease, confirm it is still the one we listed, unclaimed, and
|
|
781
|
+
* not already superseded, then stamp our claim on it. Returns the claimed entry (whose `rev` the drain
|
|
782
|
+
* revalidates after summarizing) or null to skip it.
|
|
783
|
+
*
|
|
784
|
+
* Crash mid-claim recovers by itself: `claimIsLive` is false as soon as the claiming process is gone,
|
|
785
|
+
* and in any case after CLAIM_TTL_MS, so the entry becomes claimable again with nothing to clean up.
|
|
786
|
+
*/
|
|
787
|
+
async function claimForSummary(dir, listed, { log, now }) {
|
|
788
|
+
return withLease(dir, listed.key, { waitMs: 0 }, async (held) => {
|
|
789
|
+
if (!held) return null
|
|
790
|
+
const cur = readEntry(dir, listed.key)
|
|
791
|
+
if (!cur || cur.kind !== 'summarize' || cur.rev !== listed.rev) return null
|
|
792
|
+
if (claimIsLive(cur.claim)) return null
|
|
793
|
+
if (supersededByPosted(dir, cur)) {
|
|
794
|
+
log(`queued ${cur.sessionId ?? cur.key}: a newer capture of this session is already recorded โ dropped without summarizing`)
|
|
795
|
+
return null
|
|
796
|
+
}
|
|
797
|
+
const claimed = { ...cur, claim: { pid: process.pid, at: Date.now() }, rev: newRev() }
|
|
798
|
+
return writeEntry(dir, claimed) ? claimed : null
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// โโ what the next session start says โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
803
|
+
|
|
804
|
+
const SUMMARIZE_REASONS = {
|
|
805
|
+
'auth-expired': 'its Claude login expired โ run `claude setup-token`',
|
|
806
|
+
'no-claude': '`claude` is not on PATH for the capture hook',
|
|
807
|
+
timeout: 'summarizing timed out',
|
|
808
|
+
busy: 'another session was summarizing',
|
|
809
|
+
exit: 'the `claude` summarizer exited with an error',
|
|
810
|
+
unparseable: 'the summary came back unreadable',
|
|
811
|
+
'no-output': 'the summarizer returned nothing',
|
|
812
|
+
disabled: 'CORTEX_SUMMARIZE_DISABLED is set',
|
|
813
|
+
'spawn-failed': 'the summarizer could not start',
|
|
814
|
+
threw: 'the summarizer crashed',
|
|
815
|
+
'no-result': 'the summarizer could not start',
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** The most common reason among `items`, as a human label, noting if others exist too. */
|
|
819
|
+
function dominantReason(items, labels = {}) {
|
|
820
|
+
const counts = new Map()
|
|
821
|
+
for (const it of items) counts.set(it.reason, (counts.get(it.reason) ?? 0) + 1)
|
|
822
|
+
const [top] = [...counts.entries()].sort((a, b) => b[1] - a[1])
|
|
823
|
+
const label = labels[top?.[0]] ?? top?.[0] ?? 'unknown'
|
|
824
|
+
return counts.size > 1 ? `${label}; and ${counts.size - 1} other reason${counts.size - 1 === 1 ? '' : 's'}` : label
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Read-only: what the queue holds, for the session-start line and `doctor`. Never throws. */
|
|
828
|
+
export function queueStatus(dir = queueDir(), now = Date.now()) {
|
|
829
|
+
let entries = []
|
|
830
|
+
try {
|
|
831
|
+
entries = readdirSync(dir)
|
|
832
|
+
.filter((n) => n.endsWith('.json') && !n.startsWith('_'))
|
|
833
|
+
.map((n) => { try { return JSON.parse(readFileSync(join(dir, n), 'utf8')) } catch { return null } })
|
|
834
|
+
.filter((e) => e && e.key)
|
|
835
|
+
} catch { /* no queue yet */ }
|
|
836
|
+
const flagged = entries.filter(isFlagged)
|
|
837
|
+
return {
|
|
838
|
+
pending: entries.length,
|
|
839
|
+
flaggedSummarize: flagged.filter((e) => e.kind === 'summarize'),
|
|
840
|
+
flaggedIngest: flagged.filter((e) => e.kind === 'ingest'),
|
|
841
|
+
dropped: readDropped(dir, now),
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* PURE. The session-start lines โ empty when there is nothing to report. Only FLAGGED entries and
|
|
847
|
+
* DROPS are reported: an entry on its first or second try is a retry in flight, not news.
|
|
848
|
+
*/
|
|
849
|
+
export function renderQueueNotice(status) {
|
|
850
|
+
const lines = []
|
|
851
|
+
const plural = (n, one, many) => (n === 1 ? one : many)
|
|
852
|
+
const s = status?.flaggedSummarize ?? []
|
|
853
|
+
if (s.length) {
|
|
854
|
+
lines.push(
|
|
855
|
+
`Agnoclast: โ ${s.length} ${plural(s.length, 'session', 'sessions')} not captured yet: your AI couldn't summarize ` +
|
|
856
|
+
`${plural(s.length, 'it', 'them')} (${dominantReason(s, SUMMARIZE_REASONS)}). ` +
|
|
857
|
+
`${plural(s.length, "It'll", "They'll")} retry automatically.`,
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
const i = status?.flaggedIngest ?? []
|
|
861
|
+
if (i.length) {
|
|
862
|
+
lines.push(
|
|
863
|
+
`Agnoclast: โ ${i.length} ${plural(i.length, 'session', 'sessions')} not captured yet: the upload to Agnoclast kept failing ` +
|
|
864
|
+
`(${dominantReason(i)}). ${plural(i.length, "It'll", "They'll")} retry automatically.`,
|
|
865
|
+
)
|
|
866
|
+
}
|
|
867
|
+
const d = status?.dropped ?? []
|
|
868
|
+
if (d.length) {
|
|
869
|
+
lines.push(
|
|
870
|
+
`Agnoclast: โ ${d.length} session ${plural(d.length, 'capture', 'captures')} could not be saved and ` +
|
|
871
|
+
`${plural(d.length, 'was', 'were')} dropped in the last ${DROPPED_WINDOW_DAYS} days (${dominantReason(d)}). ` +
|
|
872
|
+
`Details: ~/.cortex/capture.log`,
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
return lines
|
|
876
|
+
}
|