switchroom 0.19.17 → 0.19.19
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/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +13 -4
- package/dist/auth-broker/index.js +32 -5
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +13 -4
- package/dist/cli/switchroom.js +10614 -8104
- package/dist/host-control/main.js +12849 -11446
- package/dist/vault/approvals/kernel-server.js +90 -12
- package/dist/vault/broker/server.js +277 -94
- package/package.json +5 -3
- package/profiles/_base/start.sh.hbs +69 -5
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +25 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +28 -20
- package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
- package/telegram-plugin/dist/server.js +32 -20
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -32,11 +32,24 @@
|
|
|
32
32
|
*
|
|
33
33
|
* File format: JSON array of PersistedApprovalCard objects. Written
|
|
34
34
|
* synchronously (mode 0o600) to avoid interleaving on concurrent card posts;
|
|
35
|
-
* production rate is a handful of cards, so the file stays tiny.
|
|
35
|
+
* production rate is a handful of cards, so the file stays tiny. The write
|
|
36
|
+
* goes through `atomicWriteFileSync` (tmp + fsync + rename) so a crash
|
|
37
|
+
* mid-persist can never leave a torn file behind — the destination holds
|
|
38
|
+
* either the whole previous array or the whole new one. A file that IS
|
|
39
|
+
* corrupt (from a pre-fix write, or anything else) is quarantined and logged
|
|
40
|
+
* loudly rather than silently read as "no pending cards" — see
|
|
41
|
+
* `store-file.ts`. NOTE: atomic REPLACEMENT only — whole-old-or-whole-new, not
|
|
42
|
+
* power-loss durability; the missing parent-directory fsync is tracked in #3603.
|
|
36
43
|
*/
|
|
37
44
|
|
|
38
|
-
import {
|
|
45
|
+
import { unlinkSync } from 'node:fs'
|
|
39
46
|
import { join } from 'node:path'
|
|
47
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
48
|
+
import {
|
|
49
|
+
preserveUnreadableStoreFile,
|
|
50
|
+
quarantineCorruptStoreFile,
|
|
51
|
+
readStoreJsonSync,
|
|
52
|
+
} from './store-file.js'
|
|
40
53
|
|
|
41
54
|
/** The four agent-initiated approval-card families we persist. */
|
|
42
55
|
export type ApprovalCardFamily =
|
|
@@ -111,30 +124,47 @@ export interface PendingCardStore {
|
|
|
111
124
|
clear(): void
|
|
112
125
|
}
|
|
113
126
|
|
|
114
|
-
export function createPendingCardStore(
|
|
127
|
+
export function createPendingCardStore(
|
|
128
|
+
stateDir: string,
|
|
129
|
+
/** Log sink — defaults to stderr (the gateway's runtime log). */
|
|
130
|
+
log: (line: string) => void = l => process.stderr.write(l),
|
|
131
|
+
): PendingCardStore {
|
|
115
132
|
const filePath = join(stateDir, 'pending-approval-cards.json')
|
|
116
133
|
|
|
134
|
+
/** Set when the last read failed for a non-ENOENT reason — the next write
|
|
135
|
+
* must preserve the file it could not read instead of clobbering it. */
|
|
136
|
+
let unreadable = false
|
|
137
|
+
|
|
117
138
|
function read(): PersistedApprovalCard[] {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
const result = readStoreJsonSync(filePath, 'pending-card-store', log)
|
|
140
|
+
unreadable = result.status === 'unreadable'
|
|
141
|
+
if (result.status !== 'ok') return []
|
|
142
|
+
if (!Array.isArray(result.value)) {
|
|
143
|
+
quarantineCorruptStoreFile(
|
|
144
|
+
filePath,
|
|
145
|
+
'pending-card-store',
|
|
146
|
+
'parsed to a non-array — not a pending-card file',
|
|
147
|
+
log,
|
|
148
|
+
)
|
|
123
149
|
return []
|
|
124
150
|
}
|
|
151
|
+
return result.value as PersistedApprovalCard[]
|
|
125
152
|
}
|
|
126
153
|
|
|
127
154
|
function write(entries: PersistedApprovalCard[]): void {
|
|
128
155
|
try {
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
// Fail closed: never let an overwrite be what destroys state we merely
|
|
157
|
+
// failed to READ (flaky mount, transient EACCES) — throws if it can't.
|
|
158
|
+
if (unreadable) {
|
|
159
|
+
preserveUnreadableStoreFile(filePath, 'pending-card-store', log)
|
|
160
|
+
unreadable = false
|
|
161
|
+
}
|
|
162
|
+
// tmp + fsync + rename, mode pinned to 0600 on the tempfile fd (so an
|
|
163
|
+
// existing file can't keep laxer perms, and a crash mid-write leaves
|
|
164
|
+
// the previous good file untouched).
|
|
165
|
+
atomicWriteFileSync(filePath, JSON.stringify(entries), 0o600)
|
|
134
166
|
} catch (err) {
|
|
135
|
-
|
|
136
|
-
`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`,
|
|
137
|
-
)
|
|
167
|
+
log(`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`)
|
|
138
168
|
}
|
|
139
169
|
}
|
|
140
170
|
|
|
@@ -458,9 +458,13 @@ export function buildResumeDeferredReportInbound(
|
|
|
458
458
|
* the gateway calls this with the classified `ended_via` so the
|
|
459
459
|
* report-vs-resume policy lives in one testable place.
|
|
460
460
|
*
|
|
461
|
-
* - 'timeout'
|
|
462
|
-
* - 'restart'
|
|
463
|
-
* - 'stop'
|
|
461
|
+
* - 'timeout' → 'report' (watchdog kill)
|
|
462
|
+
* - 'restart'|'reaped_stale'|'sigterm'|'unknown' → 'resume' (clean interrupt)
|
|
463
|
+
* - 'stop' → null (finished; nothing to do)
|
|
464
|
+
*
|
|
465
|
+
* #3555: `'reaped_stale'` (mid-session stale-row sweep) is treated exactly
|
|
466
|
+
* like `'restart'` here — it is the same clean interrupt, just no longer
|
|
467
|
+
* mislabelled as a restart in the data.
|
|
464
468
|
*/
|
|
465
469
|
export function selectResumeBuilder(
|
|
466
470
|
endedVia: TurnEndedVia | null,
|
|
@@ -473,7 +477,12 @@ export function selectResumeBuilder(
|
|
|
473
477
|
): 'resume' | 'report' | null {
|
|
474
478
|
let kind: 'resume' | 'report' | null
|
|
475
479
|
if (endedVia === 'timeout') kind = 'report'
|
|
476
|
-
else if (
|
|
480
|
+
else if (
|
|
481
|
+
endedVia === 'restart' ||
|
|
482
|
+
endedVia === 'reaped_stale' ||
|
|
483
|
+
endedVia === 'sigterm' ||
|
|
484
|
+
endedVia === 'unknown'
|
|
485
|
+
) kind = 'resume'
|
|
477
486
|
else if (endedVia == null) kind = 'resume' // still-open at boot = killed mid-flight
|
|
478
487
|
else kind = null
|
|
479
488
|
if (
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* immediately. It reads as "my approval didn't stick."
|
|
9
9
|
*
|
|
10
10
|
* Fix: mirror the store to a tiny JSON file in STATE_DIR (same shape as
|
|
11
|
-
* permission-card-store.ts — synchronous writes
|
|
11
|
+
* permission-card-store.ts — synchronous ATOMIC writes (tmp + fsync + rename),
|
|
12
|
+
* mode 0o600, one small file; a corrupt file is quarantined and logged loudly
|
|
13
|
+
* rather than silently read as "no grants" — see store-file.ts). NOTE: atomic
|
|
14
|
+
* REPLACEMENT only — whole-old-or-whole-new, not power-loss durability; the
|
|
15
|
+
* missing parent-directory fsync is tracked in #3603.
|
|
12
16
|
* Write-through on every grant and on sweep-expiry removal; reload at boot,
|
|
13
17
|
* dropping entries already past their ABSOLUTE expiry.
|
|
14
18
|
*
|
|
@@ -23,8 +27,13 @@
|
|
|
23
27
|
* and never write (any pre-existing file is ignored).
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
|
-
import { readFileSync, writeFileSync } from 'node:fs'
|
|
27
30
|
import { join } from 'node:path'
|
|
31
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
32
|
+
import {
|
|
33
|
+
preserveUnreadableStoreFile,
|
|
34
|
+
quarantineCorruptStoreFile,
|
|
35
|
+
readStoreJsonSync,
|
|
36
|
+
} from './store-file.js'
|
|
28
37
|
import {
|
|
29
38
|
serializeScopedGrants,
|
|
30
39
|
deserializeScopedGrants,
|
|
@@ -50,18 +59,31 @@ export function scopedGrantPersistEnabled(
|
|
|
50
59
|
export function createScopedGrantStore(
|
|
51
60
|
stateDir: string,
|
|
52
61
|
env: Record<string, string | undefined> = process.env,
|
|
62
|
+
/** Log sink — defaults to stderr (the gateway's runtime log). */
|
|
63
|
+
log: (line: string) => void = l => process.stderr.write(l),
|
|
53
64
|
): ScopedGrantPersistence {
|
|
54
65
|
const filePath = join(stateDir, 'scoped-grants.json')
|
|
55
66
|
const enabled = scopedGrantPersistEnabled(env)
|
|
56
67
|
|
|
68
|
+
/** Set when the last read failed for a non-ENOENT reason — the next write
|
|
69
|
+
* must preserve the file it could not read instead of clobbering it. */
|
|
70
|
+
let unreadable = false
|
|
71
|
+
|
|
57
72
|
function read(): unknown[] {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
const result = readStoreJsonSync(filePath, 'scoped-grant-store', log)
|
|
74
|
+
unreadable = result.status === 'unreadable'
|
|
75
|
+
if (result.status !== 'ok') return []
|
|
76
|
+
const parsed = result.value
|
|
77
|
+
if (!Array.isArray(parsed)) {
|
|
78
|
+
quarantineCorruptStoreFile(
|
|
79
|
+
filePath,
|
|
80
|
+
'scoped-grant-store',
|
|
81
|
+
'parsed to a non-array — not a scoped-grants file',
|
|
82
|
+
log,
|
|
83
|
+
)
|
|
63
84
|
return []
|
|
64
85
|
}
|
|
86
|
+
return parsed
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
return {
|
|
@@ -75,14 +97,17 @@ export function createScopedGrantStore(
|
|
|
75
97
|
save(store) {
|
|
76
98
|
if (!enabled) return
|
|
77
99
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
// Fail closed: never let an overwrite be what destroys grants we
|
|
101
|
+
// merely failed to READ (flaky mount, transient EACCES).
|
|
102
|
+
if (unreadable) {
|
|
103
|
+
preserveUnreadableStoreFile(filePath, 'scoped-grant-store', log)
|
|
104
|
+
unreadable = false
|
|
105
|
+
}
|
|
106
|
+
// tmp + fsync + rename — a crash mid-persist leaves the previous
|
|
107
|
+
// grant set intact rather than a torn file that reads as "no grants".
|
|
108
|
+
atomicWriteFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), 0o600)
|
|
82
109
|
} catch (err) {
|
|
83
|
-
|
|
84
|
-
`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`,
|
|
85
|
-
)
|
|
110
|
+
log(`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`)
|
|
86
111
|
}
|
|
87
112
|
},
|
|
88
113
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared read side for the gateway's small JSON state stores
|
|
3
|
+
* (`pending-card-store.ts`, `scoped-grant-store.ts`,
|
|
4
|
+
* `missed-approvals-store.ts`, `always-allow-persist-queue.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Problem: each of those stores read its backing file with a bare
|
|
7
|
+
* `try { JSON.parse(readFileSync(...)) } catch { return [] }`. Combined
|
|
8
|
+
* with a non-atomic `writeFileSync` straight over the destination (fixed
|
|
9
|
+
* separately — every writer now goes through `atomicWriteFileSync`), a
|
|
10
|
+
* crash between truncate and write left a TORN file. On the next boot the
|
|
11
|
+
* parse threw, the catch swallowed it, and the store came up EMPTY: every
|
|
12
|
+
* pending approval card and every live scoped grant silently forgotten,
|
|
13
|
+
* with nothing in the logs. The operator just saw dead buttons.
|
|
14
|
+
*
|
|
15
|
+
* Losing security state must be OBSERVABLE. So a parse failure here is
|
|
16
|
+
* never silent:
|
|
17
|
+
* - the corrupt bytes are preserved by renaming them aside to
|
|
18
|
+
* `<file>.corrupt-<epoch-ms>-<seq>-<rand>` (never deleted while they are the
|
|
19
|
+
* newest few — they're the forensic record of what was lost, and the
|
|
20
|
+
* only chance of manual recovery);
|
|
21
|
+
* - a loud `telegram gateway: <store> CORRUPT …` line goes to stderr,
|
|
22
|
+
* the same channel the surrounding stores already log write failures
|
|
23
|
+
* on (the gateway's stderr is captured in the agent's runtime log);
|
|
24
|
+
* - the caller gets a non-`ok` result and starts from an empty store —
|
|
25
|
+
* the process still boots, it just no longer does so in silence.
|
|
26
|
+
*
|
|
27
|
+
* Three read outcomes, deliberately distinguished:
|
|
28
|
+
* - `missing` — the normal cold start. Silent, no quarantine.
|
|
29
|
+
* - `corrupt` — unparseable JSON (or a shape the store rejects, via
|
|
30
|
+
* `quarantineCorruptStoreFile`). Quarantine + loud log.
|
|
31
|
+
* - `unreadable` — any other fs error (EACCES, EIO, EISDIR, …). Loud log,
|
|
32
|
+
* but NO quarantine: renaming a file we merely failed to
|
|
33
|
+
* read would destroy good state over a transient fault.
|
|
34
|
+
* Instead the caller FAILS CLOSED on its next write —
|
|
35
|
+
* see `preserveUnreadableStoreFile` below.
|
|
36
|
+
*
|
|
37
|
+
* ── Known hazard: two writers ──────────────────────────────────────────
|
|
38
|
+
* Quarantine is a destructive-looking move (it renames the destination
|
|
39
|
+
* away), and it is NOT safe against a second process writing the same
|
|
40
|
+
* file: process A can parse-fail on a torn file, process B can then
|
|
41
|
+
* atomically rename GOOD state into place, and A's quarantine would move
|
|
42
|
+
* B's good file aside. Pre-fix a parse failure was inert, so this hazard is
|
|
43
|
+
* introduced here. It is accepted because the gateway is (near-)singleton
|
|
44
|
+
* per agent — see the `withLock` note in `always-allow-persist-queue.ts`
|
|
45
|
+
* for why "near", and note the window is the few microseconds between the
|
|
46
|
+
* failed parse and the rename. If a genuine multi-writer arrangement ever
|
|
47
|
+
* appears, quarantine must become an flock-guarded compare-and-rename.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { randomBytes } from 'node:crypto'
|
|
51
|
+
import { readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
|
52
|
+
import { basename, dirname, join } from 'node:path'
|
|
53
|
+
|
|
54
|
+
/** Default sink — matches the stores' existing `process.stderr.write` use. */
|
|
55
|
+
const defaultLog = (line: string): void => {
|
|
56
|
+
process.stderr.write(line)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How many `<file>.corrupt-*` forensic copies to keep per store file.
|
|
61
|
+
* They're tiny, but a store that corrupts on a loop must not fill the
|
|
62
|
+
* state dir — so the oldest are reaped past this cap. Newest wins.
|
|
63
|
+
*/
|
|
64
|
+
export const MAX_QUARANTINED_COPIES = 5
|
|
65
|
+
|
|
66
|
+
/** Outcome of a store-file read. See the module docblock. */
|
|
67
|
+
export type StoreReadResult =
|
|
68
|
+
| { status: 'ok'; value: unknown }
|
|
69
|
+
| { status: 'missing' }
|
|
70
|
+
| { status: 'corrupt' }
|
|
71
|
+
| { status: 'unreadable' }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Monotonic within this process. Purely a name-uniqueness aid now — the
|
|
75
|
+
* reaper orders by mtime, NOT by name (see `reapOldQuarantines`), because a
|
|
76
|
+
* name-based order is only monotonic within one process: a restart resets
|
|
77
|
+
* this counter to 0, and a fresh copy written in the same epoch-ms as a
|
|
78
|
+
* prior process's burst would sort as the OLDEST and be reaped first.
|
|
79
|
+
*/
|
|
80
|
+
let quarantineSeq = 0
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `<file>.corrupt-<epoch-ms>-<seq>-<rand>`.
|
|
84
|
+
*
|
|
85
|
+
* All three tail components exist to make the name UNIQUE: two quarantines
|
|
86
|
+
* in the same millisecond, or from two processes, would otherwise collide
|
|
87
|
+
* and the second `renameSync` would silently clobber the first forensic copy
|
|
88
|
+
* — same reasoning as the tempfile naming in `src/util/atomic.ts`. The name
|
|
89
|
+
* is deliberately NOT the ordering key; mtime is.
|
|
90
|
+
*/
|
|
91
|
+
function quarantinePath(filePath: string): string {
|
|
92
|
+
const seq = String(quarantineSeq++).padStart(6, '0')
|
|
93
|
+
return `${filePath}.corrupt-${Date.now()}-${seq}-${randomBytes(4).toString('hex')}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reap all but the newest {@link MAX_QUARANTINED_COPIES} forensic copies.
|
|
98
|
+
*
|
|
99
|
+
* Ordered by the filesystem's own mtime (nanosecond resolution via the
|
|
100
|
+
* bigint stat) — unlike the embedded `<epoch-ms>-<seq>`, whose sequence
|
|
101
|
+
* restarts at 0 on every boot and would make a fresh copy sort as the oldest
|
|
102
|
+
* whenever a restart lands in the same millisecond as the previous process's
|
|
103
|
+
* burst. The name is used only as a stable tie-break.
|
|
104
|
+
*
|
|
105
|
+
* Where the guarantee ends: this is exact only on filesystems that record
|
|
106
|
+
* mtime at (sub-)nanosecond granularity — ext4, xfs, apfs, btrfs all do. On a
|
|
107
|
+
* coarse-granularity filesystem (older ext3, some network/FUSE mounts, where
|
|
108
|
+
* mtime rounds to a whole second) a burst of more than MAX_QUARANTINED_COPIES
|
|
109
|
+
* quarantines inside one second all tie on `mtimeNs`, the sort falls through
|
|
110
|
+
* to the name, and the restart-reset ordering above comes back: the prior
|
|
111
|
+
* process burns seq 000001-000005 in second T, a restart quarantines at
|
|
112
|
+
* 000000, all six tie, and the freshest copy is reaped first. That costs a
|
|
113
|
+
* forensic copy on a rare filesystem and never live state, so it is not
|
|
114
|
+
* defended against here — but do not read this reaper as unconditionally
|
|
115
|
+
* restart-safe.
|
|
116
|
+
*/
|
|
117
|
+
function reapOldQuarantines(filePath: string, log: (line: string) => void): void {
|
|
118
|
+
const dir = dirname(filePath)
|
|
119
|
+
const prefix = `${basename(filePath)}.corrupt-`
|
|
120
|
+
try {
|
|
121
|
+
const copies = readdirSync(dir)
|
|
122
|
+
.filter(f => f.startsWith(prefix))
|
|
123
|
+
.map(name => {
|
|
124
|
+
let mtimeNs = 0n
|
|
125
|
+
try {
|
|
126
|
+
mtimeNs = statSync(join(dir, name), { bigint: true }).mtimeNs
|
|
127
|
+
} catch {
|
|
128
|
+
// Vanished under us (a concurrent reap) — sorts oldest; the rmSync
|
|
129
|
+
// below is `force`, so a missing file is a no-op either way.
|
|
130
|
+
}
|
|
131
|
+
return { name, mtimeNs }
|
|
132
|
+
})
|
|
133
|
+
.sort((a, b) => (a.mtimeNs === b.mtimeNs ? a.name.localeCompare(b.name) : a.mtimeNs < b.mtimeNs ? -1 : 1))
|
|
134
|
+
for (const stale of copies.slice(0, Math.max(0, copies.length - MAX_QUARANTINED_COPIES))) {
|
|
135
|
+
rmSync(join(dir, stale.name), { force: true })
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// Reaping is opportunistic — never let it mask the corruption itself.
|
|
139
|
+
log(`telegram gateway: quarantine reap failed dir=${dir}: ${(err as Error).message}\n`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Move a corrupt/unusable store file aside so its bytes survive, and log
|
|
145
|
+
* loudly. Exported for stores whose corruption is a SHAPE failure (valid
|
|
146
|
+
* JSON, wrong structure) that only the store itself can detect.
|
|
147
|
+
*/
|
|
148
|
+
export function quarantineCorruptStoreFile(
|
|
149
|
+
filePath: string,
|
|
150
|
+
store: string,
|
|
151
|
+
reason: string,
|
|
152
|
+
log: (line: string) => void = defaultLog,
|
|
153
|
+
): void {
|
|
154
|
+
const target = quarantinePath(filePath)
|
|
155
|
+
let preserved = target
|
|
156
|
+
try {
|
|
157
|
+
renameSync(filePath, target)
|
|
158
|
+
} catch (err) {
|
|
159
|
+
preserved = `NOT preserved (${(err as Error).message})`
|
|
160
|
+
}
|
|
161
|
+
log(
|
|
162
|
+
`telegram gateway: ${store} CORRUPT — ${reason}. ` +
|
|
163
|
+
`Persisted state was LOST and the store is starting EMPTY; ` +
|
|
164
|
+
`corrupt file ${preserved}\n`,
|
|
165
|
+
)
|
|
166
|
+
reapOldQuarantines(filePath, log)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* FAIL-CLOSED guard for the `unreadable` read outcome.
|
|
171
|
+
*
|
|
172
|
+
* A read that failed for a non-ENOENT reason (a flaky mount returning EIO,
|
|
173
|
+
* a transient EACCES) leaves the store in memory EMPTY while the on-disk
|
|
174
|
+
* file may still hold perfectly good state. The next `save()` would then
|
|
175
|
+
* rename a fresh, near-empty file over it and destroy that state
|
|
176
|
+
* permanently — quietly, and for a fault that may have lasted one syscall.
|
|
177
|
+
*
|
|
178
|
+
* So the stores call this immediately BEFORE their first write following an
|
|
179
|
+
* unreadable read: the existing bytes are renamed aside (same
|
|
180
|
+
* `.corrupt-<ts>-<seq>-<rand>` forensic convention) so the overwrite can never be
|
|
181
|
+
* the thing that loses them. If the rename itself fails the write is
|
|
182
|
+
* ABORTED by throwing — better a loud failed write than a silent
|
|
183
|
+
* destruction of the only copy.
|
|
184
|
+
*/
|
|
185
|
+
export function preserveUnreadableStoreFile(
|
|
186
|
+
filePath: string,
|
|
187
|
+
store: string,
|
|
188
|
+
log: (line: string) => void = defaultLog,
|
|
189
|
+
): void {
|
|
190
|
+
const target = quarantinePath(filePath)
|
|
191
|
+
try {
|
|
192
|
+
renameSync(filePath, target)
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
195
|
+
// Nothing there to lose — the read failure wasn't about existing bytes.
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
log(
|
|
199
|
+
`telegram gateway: ${store} REFUSING to overwrite an unreadable file at ` +
|
|
200
|
+
`${filePath} — could not preserve it first: ${(err as Error).message}\n`,
|
|
201
|
+
)
|
|
202
|
+
throw err
|
|
203
|
+
}
|
|
204
|
+
log(
|
|
205
|
+
`telegram gateway: ${store} could not READ its file but is about to write it. ` +
|
|
206
|
+
`Preserved the previous (unreadable) bytes as ${target} rather than ` +
|
|
207
|
+
`overwriting them; the store continues from EMPTY\n`,
|
|
208
|
+
)
|
|
209
|
+
reapOldQuarantines(filePath, log)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read + parse a store's backing JSON file. See {@link StoreReadResult} and
|
|
214
|
+
* the module docblock for what each outcome means and what it triggers.
|
|
215
|
+
*/
|
|
216
|
+
export function readStoreJsonSync(
|
|
217
|
+
filePath: string,
|
|
218
|
+
store: string,
|
|
219
|
+
log: (line: string) => void = defaultLog,
|
|
220
|
+
): StoreReadResult {
|
|
221
|
+
let raw: string
|
|
222
|
+
try {
|
|
223
|
+
raw = readFileSync(filePath, 'utf-8')
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' }
|
|
226
|
+
log(
|
|
227
|
+
`telegram gateway: ${store} read FAILED path=${filePath}: ` +
|
|
228
|
+
`${(err as Error).message} — starting EMPTY for this read; the next write ` +
|
|
229
|
+
`will preserve the unreadable file rather than overwrite it\n`,
|
|
230
|
+
)
|
|
231
|
+
return { status: 'unreadable' }
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return { status: 'ok', value: JSON.parse(raw) }
|
|
235
|
+
} catch (err) {
|
|
236
|
+
quarantineCorruptStoreFile(
|
|
237
|
+
filePath,
|
|
238
|
+
store,
|
|
239
|
+
`unparseable JSON (${(err as Error).message}) — likely a torn write from a crash mid-persist`,
|
|
240
|
+
log,
|
|
241
|
+
)
|
|
242
|
+
return { status: 'corrupt' }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -372,10 +372,6 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
372
372
|
// `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
|
|
373
373
|
// EDITS the existing card instead of opening a second one, and so the
|
|
374
374
|
// turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
|
|
375
|
-
// The handback turn also gets the turn-long typing loop it never had —
|
|
376
|
-
// whether or not a card was painted (the debounce may not have fired) —
|
|
377
|
-
// stopped by the canonical turn-end (`purgeReactionTracking →
|
|
378
|
-
// stopTurnTypingLoop`).
|
|
379
375
|
if (HANDBACK_PRETURN_ENABLED) {
|
|
380
376
|
const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
|
|
381
377
|
if (handbackAdoption != null) {
|
|
@@ -383,9 +379,32 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
383
379
|
next.activityMessageId = handbackAdoption.activityMessageId
|
|
384
380
|
next.activityEverOpened = true
|
|
385
381
|
}
|
|
386
|
-
|
|
382
|
+
// Observability (#3544): adoption is the rare, previously-silent
|
|
383
|
+
// branch — one line per adopted handback turn, not per turn.
|
|
384
|
+
process.stderr.write(
|
|
385
|
+
`telegram gateway: handback pre-turn adopted turnId=${turnId} ` +
|
|
386
|
+
`key=${handbackAdoption.statusKey} card=${handbackAdoption.activityMessageId ?? 'none'}\n`,
|
|
387
|
+
)
|
|
387
388
|
}
|
|
388
389
|
}
|
|
390
|
+
// #3544 — arm the turn-long `typing…` loop for EVERY minted turn,
|
|
391
|
+
// unconditionally. It used to hang off the handback ADOPTION above,
|
|
392
|
+
// which misses whenever the pre-turn entry was deduped (parallel
|
|
393
|
+
// workers on one topic), had no derivable turn id, or was already
|
|
394
|
+
// reaped — and the whole compose window went dark. Only the real-inbound
|
|
395
|
+
// path (`turn-start-surfaces.ts`) armed a loop, so a synthetic turn
|
|
396
|
+
// (handback / cron / wake) could have none at all. Unconditional is safe
|
|
397
|
+
// and costs nothing extra on the wire:
|
|
398
|
+
// - `turnTypingLoop.start` is restart-safe (stops any prior loop on
|
|
399
|
+
// the key first, so a real inbound's loop is replaced, not doubled);
|
|
400
|
+
// - every send goes through the SHARED per-chat-key emitter floor
|
|
401
|
+
// (`typing-emitter.ts`, TYPING_FLOOR_MS) so N arms on one chat still
|
|
402
|
+
// cost at most one chat action per floor window — the 2026-07-11
|
|
403
|
+
// flood-ban guard is what makes arming more loops free;
|
|
404
|
+
// - `turn-end.ts` (`purgeReactionTracking → stopTurnTypingLoop`) is
|
|
405
|
+
// already the single stop-owner for ALL turns, and a start is
|
|
406
|
+
// self-healing anyway, so this cannot leak an interval.
|
|
407
|
+
startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
|
|
389
408
|
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
390
409
|
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
391
410
|
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|