switchroom 0.19.22 → 0.19.23
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/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +68 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +552 -320
- package/dist/host-control/main.js +69 -2
- package/dist/vault/approvals/kernel-server.js +71 -4
- package/dist/vault/broker/server.js +71 -4
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +13 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +90 -107
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +108 -16
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +9 -7
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -271,6 +271,110 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
271
271
|
expect(oblLedger.isOpen('#t1')).toBe(false)
|
|
272
272
|
})
|
|
273
273
|
|
|
274
|
+
// #3702 L3 — the close condition is LANDED, not confirmed. A snapshot whose
|
|
275
|
+
// chunks all landed but were NEVER corroborated (the read-back is 100% shed in
|
|
276
|
+
// production, #3703) is resolved on HYDRATION alone: the resume re-probes,
|
|
277
|
+
// resolves nothing again, sends zero messages, and closes.
|
|
278
|
+
//
|
|
279
|
+
// This is the case the durable outbound-text oracle CANNOT reach — history
|
|
280
|
+
// disabled here, so `hasOutboundWithText` never fires and the old
|
|
281
|
+
// confirmation-gated verdict would have kept the obligation OPEN, re-running
|
|
282
|
+
// the same shed probe on every sweep with no send available to it, until the
|
|
283
|
+
// represent cap escalated a false "never answered you" to the operator.
|
|
284
|
+
it('fully-landed-but-UNCONFIRMED snapshot, no oracle ⇒ ZERO sends and the obligation CLOSES', async () => {
|
|
285
|
+
const chunks = ['c0', 'c1']
|
|
286
|
+
const snapshot = {
|
|
287
|
+
chunks,
|
|
288
|
+
chunkStates: [
|
|
289
|
+
{ index: 0, messageIds: [5000], confirmed: false },
|
|
290
|
+
{ index: 1, messageIds: [5001], confirmed: false },
|
|
291
|
+
],
|
|
292
|
+
}
|
|
293
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
294
|
+
const sentIdx: number[] = []
|
|
295
|
+
const oblLedger = new ObligationLedger(2)
|
|
296
|
+
oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
|
|
297
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
298
|
+
|
|
299
|
+
// No durable corroboration is available from EITHER source: the snapshot
|
|
300
|
+
// flags are false, history is off, and the re-probe sheds (ambiguous).
|
|
301
|
+
mockOracle = () => false
|
|
302
|
+
|
|
303
|
+
const lines: string[] = []
|
|
304
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
305
|
+
deliverAnswer: async (a) => {
|
|
306
|
+
a.resume.hydrate(resumeLedger, a.turnId)
|
|
307
|
+
const res = await runBackstopDelivery(
|
|
308
|
+
resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
|
|
309
|
+
{
|
|
310
|
+
sendChunk: async (i: number) => { sentIdx.push(i); return [7000 + i] },
|
|
311
|
+
readBack: async (): Promise<ReadBackResult> => 'ambiguous',
|
|
312
|
+
},
|
|
313
|
+
3,
|
|
314
|
+
)
|
|
315
|
+
expect(res.confirmed).toBe(false) // nothing corroborated it, at any point
|
|
316
|
+
expect(res.landedUnconfirmedIds).toEqual([5000, 5001]) // ...and it is counted
|
|
317
|
+
return { delivered: res.delivered, sentIds: res.sentIds }
|
|
318
|
+
},
|
|
319
|
+
obligationLedger: oblLedger,
|
|
320
|
+
backstopDeliveryLedger: resumeLedger,
|
|
321
|
+
flushedTurnSupersede: { record: () => {} },
|
|
322
|
+
historyEnabled: false,
|
|
323
|
+
stderr: (s) => lines.push(s),
|
|
324
|
+
})
|
|
325
|
+
dispatcher.dispatch(oblLedger.list()[0])
|
|
326
|
+
await flush(dispatcher)
|
|
327
|
+
|
|
328
|
+
expect(sentIdx).toEqual([]) // nothing re-posted — the user never sees a duplicate
|
|
329
|
+
expect(oblLedger.isOpen('#t1')).toBe(false) // closed on the landed-id evidence
|
|
330
|
+
// ...and the log must say what actually happened. These ids are LANDED, not
|
|
331
|
+
// confirmed: calling them "confirmed" is the overstatement #3702 removed from
|
|
332
|
+
// the verdict, and a log that reintroduces it re-lies to the next debugger.
|
|
333
|
+
const closed = lines.find((l) => l.includes('resume delivered'))
|
|
334
|
+
expect(closed).toBeDefined()
|
|
335
|
+
expect(closed).toContain('2 message id(s) landed')
|
|
336
|
+
expect(closed).not.toContain('confirmed')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// The other half of that decision: a positive ABSENCE still re-opens the send
|
|
340
|
+
// path, so "landed" is not a rubber stamp. (Inert in production until #3703
|
|
341
|
+
// wakes the probe, but the mechanism must remain correct.)
|
|
342
|
+
it('a landed chunk the re-probe finds ABSENT is demoted and RE-SENT, not closed on the stale id', async () => {
|
|
343
|
+
const chunks = ['c0']
|
|
344
|
+
const snapshot = { chunks, chunkStates: [{ index: 0, messageIds: [5000], confirmed: false }] }
|
|
345
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
346
|
+
const sentIdx: number[] = []
|
|
347
|
+
const oblLedger = new ObligationLedger(2)
|
|
348
|
+
oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
|
|
349
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
350
|
+
mockOracle = () => false
|
|
351
|
+
|
|
352
|
+
let probes = 0
|
|
353
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
354
|
+
deliverAnswer: async (a) => {
|
|
355
|
+
a.resume.hydrate(resumeLedger, a.turnId)
|
|
356
|
+
const res = await runBackstopDelivery(
|
|
357
|
+
resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
|
|
358
|
+
{
|
|
359
|
+
sendChunk: async (i: number) => { sentIdx.push(i); return [7000 + i] },
|
|
360
|
+
readBack: async (): Promise<ReadBackResult> => (++probes === 1 ? 'absent' : 'exists'),
|
|
361
|
+
},
|
|
362
|
+
3,
|
|
363
|
+
)
|
|
364
|
+
return { delivered: res.delivered, sentIds: res.sentIds }
|
|
365
|
+
},
|
|
366
|
+
obligationLedger: oblLedger,
|
|
367
|
+
backstopDeliveryLedger: resumeLedger,
|
|
368
|
+
flushedTurnSupersede: { record: () => {} },
|
|
369
|
+
historyEnabled: false,
|
|
370
|
+
})
|
|
371
|
+
dispatcher.dispatch(oblLedger.list()[0])
|
|
372
|
+
await flush(dispatcher)
|
|
373
|
+
|
|
374
|
+
expect(sentIdx).toEqual([0]) // the silently-dropped chunk IS re-sent
|
|
375
|
+
expect(oblLedger.isOpen('#t1')).toBe(false)
|
|
376
|
+
})
|
|
377
|
+
|
|
274
378
|
it('a partial RESUME (tail still fails) leaves the obligation OPEN + consumes represent budget', async () => {
|
|
275
379
|
const chunks = ['c0', 'c1']
|
|
276
380
|
const snapshot = {
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
1
5
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
6
|
|
|
3
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
maybeRotate,
|
|
9
|
+
resolveAgentStateDir,
|
|
10
|
+
resolveTurnsJsonlPath,
|
|
11
|
+
DEFAULT_AGENT_STATE_DIR,
|
|
12
|
+
TURNS_JSONL_MAX_BYTES,
|
|
13
|
+
type RotateFs,
|
|
14
|
+
} from '../gateway/turns-jsonl-rotate.js'
|
|
15
|
+
|
|
16
|
+
const GATEWAY_SRC = readFileSync(
|
|
17
|
+
resolve(fileURLToPath(new URL('.', import.meta.url)), '..', 'gateway', 'gateway.ts'),
|
|
18
|
+
'utf-8',
|
|
19
|
+
)
|
|
4
20
|
|
|
5
21
|
describe('maybeRotate — turns.jsonl size cap', () => {
|
|
6
22
|
const mkFs = (size: number | undefined) => {
|
|
@@ -37,3 +53,78 @@ describe('maybeRotate — turns.jsonl size cap', () => {
|
|
|
37
53
|
expect(rename).toHaveBeenNthCalledWith(2, '/a/turns.jsonl', '/a/turns.jsonl.1')
|
|
38
54
|
})
|
|
39
55
|
})
|
|
56
|
+
|
|
57
|
+
describe('resolveTurnsJsonlPath — the turn record must follow the state dir', () => {
|
|
58
|
+
// Regression: the path was hard-coded to `/state/agent/turns.jsonl`, so a test
|
|
59
|
+
// that isolated TELEGRAM_STATE_DIR / SWITCHROOM_AGENT_STATE_DIR into a tmpdir
|
|
60
|
+
// still appended its synthetic turn rows into the PRODUCTION turn record of
|
|
61
|
+
// whichever agent container it ran in — which the fleet-health L0 sensor then
|
|
62
|
+
// scored as that agent's real production failures.
|
|
63
|
+
it('honours SWITCHROOM_AGENT_STATE_DIR', () => {
|
|
64
|
+
expect(resolveTurnsJsonlPath({ SWITCHROOM_AGENT_STATE_DIR: '/tmp/iso-123' })).toBe(
|
|
65
|
+
'/tmp/iso-123/turns.jsonl',
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('strips a trailing slash rather than doubling it', () => {
|
|
70
|
+
expect(resolveTurnsJsonlPath({ SWITCHROOM_AGENT_STATE_DIR: '/tmp/iso-123/' })).toBe(
|
|
71
|
+
'/tmp/iso-123/turns.jsonl',
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('falls back to the container default when unset or blank', () => {
|
|
76
|
+
expect(resolveTurnsJsonlPath({})).toBe(`${DEFAULT_AGENT_STATE_DIR}/turns.jsonl`)
|
|
77
|
+
expect(resolveTurnsJsonlPath({ SWITCHROOM_AGENT_STATE_DIR: ' ' })).toBe(
|
|
78
|
+
`${DEFAULT_AGENT_STATE_DIR}/turns.jsonl`,
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('resolveAgentStateDir — ONE normaliser for every writer in the state dir', () => {
|
|
84
|
+
// The gateway had two writers into this dir 26 lines apart reading the env
|
|
85
|
+
// var two different ways: the turn record via the normaliser, the
|
|
86
|
+
// context-occupancy snapshot via a bare `process.env.X ?? '/state/agent'`.
|
|
87
|
+
// For a value like `/x/ ` those resolve to different directories, so the two
|
|
88
|
+
// artifacts of the same turn land in two places.
|
|
89
|
+
it('trims, strips a trailing slash, and treats blank as unset', () => {
|
|
90
|
+
expect(resolveAgentStateDir({ SWITCHROOM_AGENT_STATE_DIR: '/x' })).toBe('/x')
|
|
91
|
+
expect(resolveAgentStateDir({ SWITCHROOM_AGENT_STATE_DIR: ' /x/ ' })).toBe('/x')
|
|
92
|
+
expect(resolveAgentStateDir({ SWITCHROOM_AGENT_STATE_DIR: '/x///' })).toBe('/x')
|
|
93
|
+
expect(resolveAgentStateDir({ SWITCHROOM_AGENT_STATE_DIR: ' ' })).toBe(DEFAULT_AGENT_STATE_DIR)
|
|
94
|
+
expect(resolveAgentStateDir({})).toBe(DEFAULT_AGENT_STATE_DIR)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('the turn record and the state dir agree for any messy value', () => {
|
|
98
|
+
const env = { SWITCHROOM_AGENT_STATE_DIR: ' /tmp/iso-9/ ' }
|
|
99
|
+
expect(resolveTurnsJsonlPath(env)).toBe(`${resolveAgentStateDir(env)}/turns.jsonl`)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('gateway.ts call sites — the defect site itself, not just the helper', () => {
|
|
104
|
+
// The bug was a hard-coded literal at the CALLSITE. A unit test of the pure
|
|
105
|
+
// helper passes with the literal re-inlined, so these read the real source.
|
|
106
|
+
// Source-text assertions are the repo's cheap deterministic pattern for
|
|
107
|
+
// pinning a callsite (see per-topic-current-turn.test.ts).
|
|
108
|
+
it('emitTurnRecord resolves the path — the hard-coded literal is gone', () => {
|
|
109
|
+
const body = GATEWAY_SRC.split('function emitTurnRecord(')[1]?.split('\n}')[0] ?? ''
|
|
110
|
+
expect(body.length).toBeGreaterThan(50)
|
|
111
|
+
expect(body).toMatch(/const turnsPath = resolveTurnsJsonlPath\(\)/)
|
|
112
|
+
// The append + the rotate both use the RESOLVED path, not a literal.
|
|
113
|
+
expect(body).toMatch(/appendFileSync\(turnsPath, /)
|
|
114
|
+
expect(body).toMatch(/maybeRotate\(turnsPath, /)
|
|
115
|
+
expect(body).not.toMatch(/turns\.jsonl/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('no hard-coded turns.jsonl path survives anywhere in gateway.ts', () => {
|
|
119
|
+
const hits = GATEWAY_SRC.match(/['"`][^'"`\n]*\/turns\.jsonl['"`]/g) ?? []
|
|
120
|
+
expect(hits, `hard-coded turns.jsonl path(s): ${JSON.stringify(hits)}`).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('every state-dir read in gateway.ts goes through resolveAgentStateDir', () => {
|
|
124
|
+
// Consistency, not style: a second inline `process.env.X ?? '/state/agent'`
|
|
125
|
+
// silently opts that writer out of trim + trailing-slash normalisation.
|
|
126
|
+
const inline = GATEWAY_SRC.match(/process\.env\.SWITCHROOM_AGENT_STATE_DIR/g) ?? []
|
|
127
|
+
expect(inline, `inline state-dir read(s) in gateway.ts: ${inline.length}`).toEqual([])
|
|
128
|
+
expect(GATEWAY_SRC).toMatch(/resolveAgentStateDir\(\)/)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -5,8 +5,12 @@ SessionStart calls into ``drain()`` to retry any retain payloads that
|
|
|
5
5
|
``session_end.py`` queued on failure (#1071). Each entry is retried up
|
|
6
6
|
to ``MAX_ATTEMPTS`` (5) times; after that a **permanently** failing entry
|
|
7
7
|
(a 4xx that a re-POST cannot fix — see ``pending.is_permanent_failure``)
|
|
8
|
-
is
|
|
9
|
-
can still inspect via ``switchroom doctor``.
|
|
8
|
+
is retired into the ``pending-dead/`` archive so the queue no longer
|
|
9
|
+
drains it but the operator can still inspect via ``switchroom doctor``.
|
|
10
|
+
The marker deliberately does NOT stay in the live queue directory: it is
|
|
11
|
+
the only remaining copy of that memory, and leaving it among the live
|
|
12
|
+
entries put it in the path of every janitor that sweeps that
|
|
13
|
+
directory. An entry failing on anything
|
|
10
14
|
else — a 5xx, a timeout, a connection error — stays queued past the
|
|
11
15
|
attempt budget: a transient upstream is never evidence that the memory
|
|
12
16
|
is unsaveable, and retiring it would lose content the user believes was
|
|
@@ -53,8 +57,30 @@ of that loop, not of lost memory: a full sweep of 5,751 queued entries on
|
|
|
53
57
|
this fleet (2026-07-25) found **4,048 (70.4%) already existed as
|
|
54
58
|
documents**, 3,815 of them with facts extracted.
|
|
55
59
|
|
|
56
|
-
``--backlog`` is therefore a
|
|
57
|
-
|
|
60
|
+
``--backlog`` is therefore a three-phase, out-of-hook replay:
|
|
61
|
+
|
|
62
|
+
* **Phase 0 — collapse duplicates (free, no network).** Queued entries
|
|
63
|
+
sharing ``(bank_id, part_position, sha256(content))`` are the same
|
|
64
|
+
memory; the redundant copies are archived so the phases below never pay
|
|
65
|
+
for one memory twice. Measured 2026-07-26: 1,060 queued files across 11
|
|
66
|
+
agents fell into ~368 distinct groups — ~65% of the queue was duplicate,
|
|
67
|
+
with one group repeated 32 times. At ~168 s per phase-2 extraction that
|
|
68
|
+
one group alone was 90 minutes of LLM lane time for a single memory.
|
|
69
|
+
* **Phase 0b — relocate legacy ``.dead`` markers (free, no network).**
|
|
70
|
+
Markers written by an older build into the live queue directory are moved
|
|
71
|
+
into ``pending-dead/``. ``mark_dead`` no longer produces such a marker, so
|
|
72
|
+
after this phase has run once the live queue holds only live entries and no
|
|
73
|
+
janitor glob over it can match a memory. Note the CONDITION: phases 0b and
|
|
74
|
+
0c run in BACKLOG mode only (``drain_backlog``, and not under
|
|
75
|
+
``--dry-run``). The SessionStart ``drain()`` never calls them, so on a host
|
|
76
|
+
where the backlog drain has not run, legacy markers are still sitting in
|
|
77
|
+
the queue directory.
|
|
78
|
+
* **Phase 0c — re-split over-bound entries (free, no network).** An entry
|
|
79
|
+
whose content exceeds ``retain_content_limit()`` needs more sequential
|
|
80
|
+
extraction calls than fit the client deadline, so it can never be drained
|
|
81
|
+
as-is; splitting it makes every part drainable. Measured 2026-07-26: 18 of
|
|
82
|
+
211 queued entries exceeded 100,000 chars, the largest 744,546. Backlog
|
|
83
|
+
mode only, same as 0b.
|
|
58
84
|
* **Phase 1 — reconcile (free).** GET the document. If it exists, the
|
|
59
85
|
memory is already durable; retire the queue entry without a POST. No
|
|
60
86
|
LLM work, no cost, idempotent, resumable at any point. Only for
|
|
@@ -84,7 +110,7 @@ reports the upstream is already slow.
|
|
|
84
110
|
Standalone usage::
|
|
85
111
|
|
|
86
112
|
python3 drain_pending.py # bounded in-hook drain
|
|
87
|
-
python3 drain_pending.py --backlog #
|
|
113
|
+
python3 drain_pending.py --backlog # three-phase backlog replay
|
|
88
114
|
python3 drain_pending.py --backlog --phase reconcile # free pass only
|
|
89
115
|
python3 drain_pending.py --backlog --dry-run
|
|
90
116
|
"""
|
|
@@ -105,13 +131,16 @@ from lib.config import debug_log, load_config
|
|
|
105
131
|
from lib.pending import (
|
|
106
132
|
MAX_ATTEMPTS,
|
|
107
133
|
archive_reconciled,
|
|
134
|
+
collapse_duplicates,
|
|
108
135
|
is_content_derived_document_id,
|
|
109
136
|
is_permanent_failure,
|
|
110
137
|
iter_entries,
|
|
111
138
|
mark_dead,
|
|
139
|
+
resplit_over_bound_entries,
|
|
140
|
+
sweep_legacy_dead_markers,
|
|
112
141
|
update_attempt,
|
|
113
142
|
)
|
|
114
|
-
from lib.retain_split import retain_client_deadline
|
|
143
|
+
from lib.retain_split import retain_client_deadline, retain_content_limit
|
|
115
144
|
|
|
116
145
|
|
|
117
146
|
STALL_THRESHOLD = 3
|
|
@@ -531,17 +560,24 @@ def _new_summary() -> dict:
|
|
|
531
560
|
# so it must not be counted as drained/reconciled, which would
|
|
532
561
|
# report a retire that did not happen.
|
|
533
562
|
"archive_failed": 0,
|
|
563
|
+
# Redundant copies retired by `pending.collapse_duplicates` before
|
|
564
|
+
# any network work. Backlog mode only — see `_drain_backlog_impl`.
|
|
565
|
+
"collapsed": 0,
|
|
566
|
+
"dead_relocated": 0,
|
|
567
|
+
"resplit": 0,
|
|
568
|
+
"resplit_parts": 0,
|
|
534
569
|
"stalled": False,
|
|
535
570
|
"budget_exceeded": False,
|
|
536
571
|
}
|
|
537
572
|
|
|
538
573
|
|
|
539
574
|
def drain_backlog(config: dict | None = None, **kw) -> dict:
|
|
540
|
-
"""
|
|
575
|
+
"""Three-phase backlog replay, off the SessionStart budget entirely.
|
|
541
576
|
|
|
542
577
|
See the module docstring. Summary shape is ``drain()``'s plus
|
|
543
|
-
``reconciled`` (already durable — no POST issued)
|
|
544
|
-
(presence could not be established; left queued)
|
|
578
|
+
``reconciled`` (already durable — no POST issued), ``unknown``
|
|
579
|
+
(presence could not be established; left queued) and ``collapsed``
|
|
580
|
+
(redundant duplicate copies archived before any network work).
|
|
545
581
|
"""
|
|
546
582
|
return drain(config, backlog=True, **kw)
|
|
547
583
|
|
|
@@ -566,6 +602,10 @@ def drain(
|
|
|
566
602
|
"unknown": int, # presence unknown, left queued
|
|
567
603
|
"archive_failed": int, # durable, but the archive was unwritable
|
|
568
604
|
# so the entry is STILL QUEUED
|
|
605
|
+
"collapsed": int, # duplicate copies archived (backlog mode only)
|
|
606
|
+
"dead_relocated": int, # legacy .dead markers moved out of the queue dir
|
|
607
|
+
"resplit": int, # over-bound entries split into drainable parts
|
|
608
|
+
"resplit_parts": int, # parts those entries became
|
|
569
609
|
"stalled": bool, # stall guard tripped
|
|
570
610
|
"budget_exceeded": bool}
|
|
571
611
|
"""
|
|
@@ -784,9 +824,65 @@ def _wait_for_upstream(backoff_ms: int, started: float, budget: float) -> bool:
|
|
|
784
824
|
def _drain_backlog_impl(
|
|
785
825
|
config: dict, phase: str = "both", dry_run: bool = False
|
|
786
826
|
) -> dict:
|
|
787
|
-
"""Concurrent-capable, long-budget,
|
|
827
|
+
"""Concurrent-capable, long-budget, three-phase backlog replay."""
|
|
788
828
|
summary = _new_summary()
|
|
789
829
|
|
|
830
|
+
# PHASE 0 — collapse duplicates (local, free, no network at all).
|
|
831
|
+
#
|
|
832
|
+
# Runs FIRST because every later phase is per-entry: a GET in phase 1
|
|
833
|
+
# and, worse, a ~168 s LLM-backed extraction in phase 2. On the measured
|
|
834
|
+
# 2026-07-26 fleet backlog ~65% of queued files were byte-identical
|
|
835
|
+
# copies of another queued file (top group 32x), so skipping this pass
|
|
836
|
+
# means paying phase 2 up to 32 times over for one memory.
|
|
837
|
+
#
|
|
838
|
+
# DELIBERATELY NOT run by the in-hook drain. It reads every queued entry
|
|
839
|
+
# to recompute identity from content — bounded, but not instant — and
|
|
840
|
+
# the SessionStart drain's whole contract is a hard wall-clock ceiling
|
|
841
|
+
# on hook latency. New duplicates cannot accumulate there anyway:
|
|
842
|
+
# `pending.enqueue`'s filename-keyed guard stops those at the producer.
|
|
843
|
+
if not dry_run:
|
|
844
|
+
summary["collapsed"] = collapse_duplicates()
|
|
845
|
+
if summary["collapsed"]:
|
|
846
|
+
_blog(
|
|
847
|
+
f"phase 0: collapsed {summary['collapsed']} duplicate entries "
|
|
848
|
+
f"(byte-identical content already queued under another entry); "
|
|
849
|
+
f"archived, not deleted"
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# PHASE 0b — relocate any legacy `.dead` markers out of the live
|
|
853
|
+
# queue directory. Free, local, and an UPGRADE step: markers written
|
|
854
|
+
# by an older build sit in the directory external janitors sweep, and
|
|
855
|
+
# a marker is the only remaining copy of its memory.
|
|
856
|
+
moved = sweep_legacy_dead_markers()
|
|
857
|
+
if moved:
|
|
858
|
+
summary["dead_relocated"] = moved
|
|
859
|
+
_blog(
|
|
860
|
+
f"phase 0b: relocated {moved} legacy .dead marker(s) out of "
|
|
861
|
+
f"the live queue directory; the queue now holds only live "
|
|
862
|
+
f"entries, so no janitor glob over it can match a memory"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# PHASE 0c — re-split entries the drain provably CANNOT retain.
|
|
866
|
+
#
|
|
867
|
+
# An entry over `retain_content_limit()` needs more sequential
|
|
868
|
+
# extraction calls than fit the deadline, so every POST is guaranteed
|
|
869
|
+
# waste; if the server rejects the body as a 4xx it is classified
|
|
870
|
+
# permanent and the memory goes `.dead`. Splitting it makes every
|
|
871
|
+
# part drainable, which is the difference between a lost memory and a
|
|
872
|
+
# slow one. Runs after the duplicate collapse so a duplicated
|
|
873
|
+
# over-bound entry is split ONCE, not once per copy.
|
|
874
|
+
entries_split, parts_written = resplit_over_bound_entries()
|
|
875
|
+
if entries_split:
|
|
876
|
+
summary["resplit"] = entries_split
|
|
877
|
+
summary["resplit_parts"] = parts_written
|
|
878
|
+
_blog(
|
|
879
|
+
f"phase 0c: re-split {entries_split} entr"
|
|
880
|
+
f"{'y' if entries_split == 1 else 'ies'} over the "
|
|
881
|
+
f"{retain_content_limit()}-char retain bound into "
|
|
882
|
+
f"{parts_written} drainable part(s); the originals are "
|
|
883
|
+
f"archived, not deleted"
|
|
884
|
+
)
|
|
885
|
+
|
|
790
886
|
if phase in ("reconcile", "both"):
|
|
791
887
|
_reconcile_phase(config, summary, dry_run)
|
|
792
888
|
if phase == "reconcile":
|
|
@@ -935,7 +1031,7 @@ def _parse_args(argv: list[str] | None):
|
|
|
935
1031
|
ap.add_argument(
|
|
936
1032
|
"--backlog",
|
|
937
1033
|
action="store_true",
|
|
938
|
-
help="
|
|
1034
|
+
help="three-phase backlog replay, off the SessionStart budget",
|
|
939
1035
|
)
|
|
940
1036
|
ap.add_argument(
|
|
941
1037
|
"--phase",
|
|
@@ -967,6 +1063,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
967
1063
|
if any(
|
|
968
1064
|
summary[k]
|
|
969
1065
|
for k in (
|
|
1066
|
+
"collapsed",
|
|
1067
|
+
"dead_relocated",
|
|
1068
|
+
"resplit",
|
|
970
1069
|
"drained",
|
|
971
1070
|
"retried",
|
|
972
1071
|
"dead",
|
|
@@ -978,6 +1077,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
978
1077
|
print(
|
|
979
1078
|
f"[Hindsight] drain_pending{'(backlog)' if args.backlog else ''}: "
|
|
980
1079
|
f"drained={summary['drained']} reconciled={summary['reconciled']} "
|
|
1080
|
+
f"collapsed={summary['collapsed']} "
|
|
1081
|
+
f"dead_relocated={summary['dead_relocated']} "
|
|
1082
|
+
f"resplit={summary['resplit']}(+{summary['resplit_parts']} parts) "
|
|
981
1083
|
f"retried={summary['retried']} dead={summary['dead']} "
|
|
982
1084
|
f"unknown={summary['unknown']} "
|
|
983
1085
|
f"archive_failed={summary['archive_failed']} "
|