instar 1.3.645 → 1.3.647
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/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +6 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/feedback-factory/store/JsonlFeedbackStore.d.ts +2 -0
- package/dist/feedback-factory/store/JsonlFeedbackStore.d.ts.map +1 -1
- package/dist/feedback-factory/store/JsonlFeedbackStore.js +36 -2
- package/dist/feedback-factory/store/JsonlFeedbackStore.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +6 -2
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/monitoring/CoherenceMonitor.d.ts.map +1 -1
- package/dist/monitoring/CoherenceMonitor.js +6 -1
- package/dist/monitoring/CoherenceMonitor.js.map +1 -1
- package/dist/monitoring/CommitmentSentinel.d.ts.map +1 -1
- package/dist/monitoring/CommitmentSentinel.js +7 -2
- package/dist/monitoring/CommitmentSentinel.js.map +1 -1
- package/dist/scheduler/JobRunHistory.d.ts +22 -0
- package/dist/scheduler/JobRunHistory.d.ts.map +1 -1
- package/dist/scheduler/JobRunHistory.js +141 -10
- package/dist/scheduler/JobRunHistory.js.map +1 -1
- package/dist/utils/jsonl-tail.d.ts +50 -0
- package/dist/utils/jsonl-tail.d.ts.map +1 -0
- package/dist/utils/jsonl-tail.js +96 -0
- package/dist/utils/jsonl-tail.js.map +1 -0
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.646.md +39 -0
- package/upgrades/1.3.647.md +56 -0
- package/upgrades/eli16/eventloop-bounded-jsonl-reads.md +47 -0
- package/upgrades/eli16/jobrunhistory-incremental-cache.md +43 -0
- package/upgrades/side-effects/eventloop-bounded-jsonl-reads.md +96 -0
- package/upgrades/side-effects/jobrunhistory-incremental-cache.md +61 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded JSONL tail reader — read only the LAST window of an append-only log,
|
|
3
|
+
* never the whole file.
|
|
4
|
+
*
|
|
5
|
+
* Born from the 2026-06-22 event-loop-freeze batch: several timers and event
|
|
6
|
+
* handlers (CommitmentSentinel, CoherenceMonitor, PresenceProxy's
|
|
7
|
+
* checkLogForAgentResponse, TelegramAdapter.getMessageLog) each called
|
|
8
|
+
* `fs.readFileSync(messageLogPath, 'utf-8')` on the 12MB telegram-messages.jsonl
|
|
9
|
+
* just to look at the last ~50–200 lines. A 12MB synchronous read+split on a
|
|
10
|
+
* 5-minute timer froze the event loop for up to 20s per pass. These callers do
|
|
11
|
+
* not need the whole file — only its tail.
|
|
12
|
+
*
|
|
13
|
+
* Design (mirrors CoherenceJournal.readTailTolerant, generalized off the
|
|
14
|
+
* journal's typed entry shape):
|
|
15
|
+
* - statSync the file (O(1)) — read at most `maxBytes` from the END.
|
|
16
|
+
* - openSync + readSync the trailing window into a fixed buffer; never load
|
|
17
|
+
* the whole file. O(maxBytes), not O(file).
|
|
18
|
+
* - Discard the first (partial) line when the window started mid-file so a
|
|
19
|
+
* truncated record is never mis-parsed.
|
|
20
|
+
* - Return the raw trailing lines (newest LAST, file order preserved) — the
|
|
21
|
+
* caller parses + filters exactly as it did over the full split before.
|
|
22
|
+
*
|
|
23
|
+
* Never throws — a read failure returns an empty result, matching the
|
|
24
|
+
* @silent-fallback-ok behavior of every former full-file caller. Observability
|
|
25
|
+
* / housekeeping reads must never endanger the observed operation.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
/** Default trailing window: 512KB. At ~200 bytes/line that is ~2,600 recent
|
|
29
|
+
* lines — far more than any caller's last-50/last-200 need, with headroom for
|
|
30
|
+
* long messages, while staying a small bounded read regardless of file size. */
|
|
31
|
+
export const DEFAULT_TAIL_BYTES = 512 * 1024;
|
|
32
|
+
/**
|
|
33
|
+
* Read the last `maxBytes` of `filePath` and return its non-empty lines in file
|
|
34
|
+
* order. Reads at most `maxBytes` from disk regardless of total file size.
|
|
35
|
+
*/
|
|
36
|
+
export function readJsonlTailLines(filePath, maxBytes = DEFAULT_TAIL_BYTES) {
|
|
37
|
+
const empty = { lines: [], truncated: false };
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.existsSync(filePath))
|
|
40
|
+
return empty;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return empty;
|
|
44
|
+
}
|
|
45
|
+
let size;
|
|
46
|
+
try {
|
|
47
|
+
size = fs.statSync(filePath).size;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return empty;
|
|
51
|
+
}
|
|
52
|
+
if (size === 0)
|
|
53
|
+
return empty;
|
|
54
|
+
const readBytes = Math.min(size, maxBytes);
|
|
55
|
+
const truncated = readBytes < size;
|
|
56
|
+
const start = size - readBytes;
|
|
57
|
+
let buf;
|
|
58
|
+
let fd = null;
|
|
59
|
+
try {
|
|
60
|
+
fd = fs.openSync(filePath, 'r');
|
|
61
|
+
buf = Buffer.alloc(readBytes);
|
|
62
|
+
fs.readSync(fd, buf, 0, readBytes, start);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return empty;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
if (fd !== null) {
|
|
69
|
+
try {
|
|
70
|
+
fs.closeSync(fd);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* best-effort */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let text = buf.toString('utf-8');
|
|
78
|
+
// If the window started mid-file, the first line is a partial record — drop it.
|
|
79
|
+
if (start > 0) {
|
|
80
|
+
const nl = text.indexOf('\n');
|
|
81
|
+
text = nl >= 0 ? text.slice(nl + 1) : '';
|
|
82
|
+
}
|
|
83
|
+
const lines = text.split('\n').filter((l) => l.length > 0);
|
|
84
|
+
return { lines, truncated };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Convenience: read the tail and return at most the last `limit` non-empty
|
|
88
|
+
* lines (file order). Equivalent to the common
|
|
89
|
+
* `readFileSync(...).split('\n').filter(Boolean).slice(-limit)` pattern, but
|
|
90
|
+
* bounded to a trailing `maxBytes` window instead of the whole file.
|
|
91
|
+
*/
|
|
92
|
+
export function readJsonlTailLastLines(filePath, limit, maxBytes = DEFAULT_TAIL_BYTES) {
|
|
93
|
+
const { lines } = readJsonlTailLines(filePath, maxBytes);
|
|
94
|
+
return limit >= lines.length ? lines : lines.slice(-limit);
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=jsonl-tail.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jsonl-tail.js","sourceRoot":"","sources":["../../src/utils/jsonl-tail.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAE,MAAM,SAAS,CAAC;AAEzB;;iFAEiF;AACjF,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAU7C;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAChC,QAAgB,EAChB,WAAmB,kBAAkB;IAErC,MAAM,KAAK,GAAmB,EAAE,KAAK,EAAE,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IAC9D,IAAI,CAAC;QACH,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC;YAAE,OAAO,KAAK,CAAC;IAC7C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,IAAI,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAE7B,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC3C,MAAM,SAAS,GAAG,SAAS,GAAG,IAAI,CAAC;IACnC,MAAM,KAAK,GAAG,IAAI,GAAG,SAAS,CAAC;IAE/B,IAAI,GAAW,CAAC;IAChB,IAAI,EAAE,GAAkB,IAAI,CAAC;IAC7B,IAAI,CAAC;QACH,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;QAChC,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;QAC9B,EAAE,CAAC,QAAQ,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IAC5C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;YAAS,CAAC;QACT,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;YAChB,IAAI,CAAC;gBACH,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;YACnB,CAAC;YAAC,MAAM,CAAC;gBACP,iBAAiB;YACnB,CAAC;QACH,CAAC;IACH,CAAC;IAED,IAAI,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IACjC,gFAAgF;IAChF,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACd,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAC9B,IAAI,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC3C,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC3D,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;AAC9B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CACpC,QAAgB,EAChB,KAAa,EACb,WAAmB,kBAAkB;IAErC,MAAM,EAAE,KAAK,EAAE,GAAG,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IACzD,OAAO,KAAK,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;AAC7D,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-06-
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-06-22T20:47:55.471Z",
|
|
5
|
+
"instarVersion": "1.3.647",
|
|
6
6
|
"entryCount": 202,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
`JobRunHistory.readLines()` did a synchronous `readFileSync` + per-line `JSON.parse` of the entire
|
|
9
|
+
`.instar/ledger/job-runs.jsonl` (~13MB) on EVERY call — on the hot path of `findRun` /
|
|
10
|
+
`recordCompletion` / `recordHandoff` / `query` / `stats` (every job completion/spawn, ~60s cadence).
|
|
11
|
+
A live `/usr/bin/sample` caught the event loop frozen 13–16s in `ReadFileUtf8 → ParseJson →
|
|
12
|
+
WriteFileUtf8`, misreported by SleepWakeDetector as a ~15s "wake". This is the THIRD distinct
|
|
13
|
+
event-loop blocker behind the dashboard "disconnected" flapping (after sync tmux and sync keychain
|
|
14
|
+
reads). The read path now uses an incremental in-memory cache keyed on `(size, mtimeMs)`: unchanged
|
|
15
|
+
file → cache hit (zero IO); append → tail-read only (O(delta)); shrink/compaction → one full re-read.
|
|
16
|
+
|
|
17
|
+
## What to Tell Your User
|
|
18
|
+
|
|
19
|
+
If your dashboard kept dropping its connection even after the tmux and keychain fixes, this was the
|
|
20
|
+
remaining cause: a 13MB job-history file re-read whole, every minute, on the server's main thread.
|
|
21
|
+
After this update that freeze is gone. (A separate cleanup — capping that file's unbounded growth —
|
|
22
|
+
is tracked separately.)
|
|
23
|
+
|
|
24
|
+
## Summary of New Capabilities
|
|
25
|
+
|
|
26
|
+
- Incremental `(size, mtimeMs)`-keyed in-memory cache in `JobRunHistory`; unchanged file = zero-IO
|
|
27
|
+
cache hit, append = tail-read only, compaction = one full re-read. Existing semantics (append-only,
|
|
28
|
+
dedup-last-wins, torn-line skip, corrupt-file recovery) preserved exactly.
|
|
29
|
+
- Removes the third event-loop blocker behind the dashboard flapping + the associated SleepWakeDetector
|
|
30
|
+
false-wakes.
|
|
31
|
+
|
|
32
|
+
## Evidence
|
|
33
|
+
|
|
34
|
+
Root cause diagnosed by a live `/usr/bin/sample` (main thread in ReadFileUtf8 → ParseJson →
|
|
35
|
+
WriteFileUtf8) + an mtime-watch catching the 13MB rewrite, correlated with 15–20s freezes every
|
|
36
|
+
~20–60s. Fix covered by 4 new regression tests (cache-hit does zero full-file reads across 75 ops;
|
|
37
|
+
external append via tail-read; torn-line skip; full re-read on shrink) on top of 30 existing; full
|
|
38
|
+
scheduler suite green (346 tests total); tsc clean; no-silent-fallbacks + bounded-accumulation +
|
|
39
|
+
no-wholefile-sync-read lints green.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
FOUR callers each did a synchronous `fs.readFileSync` of the ~12MB `.instar/telegram-messages.jsonl`
|
|
9
|
+
just to inspect its last 50–200 lines: `CommitmentSentinel.parseRecentMessages` (5-min timer),
|
|
10
|
+
`CoherenceMonitor`'s output-sanity check (5-min timer), `checkLogForAgentResponse` (PresenceProxy ack
|
|
11
|
+
path, event-driven), and `TelegramAdapter.getMessageLog` (analysis route). A 12MB sync read + split
|
|
12
|
+
on a 5-minute timer froze the event loop up to ~20s per pass — the same ~0-CPU false-sleep signature
|
|
13
|
+
the JobRunHistory fix removed. Separately, `JsonlFeedbackStore` re-read + re-`JSON.parse`d the entire
|
|
14
|
+
~14MB `feedback.jsonl` at the start of every processing pass even when nothing had changed. This is
|
|
15
|
+
the THIRD + FOURTH event-loop-blocker class behind the dashboard "disconnected" flapping (after sync
|
|
16
|
+
tmux, sync keychain, and the JobRunHistory 13MB re-read).
|
|
17
|
+
|
|
18
|
+
The fix is two behavior-preserving mechanisms: (A) a shared bounded tail-read utility
|
|
19
|
+
(`src/utils/jsonl-tail.ts`) that reads only the last 512KB via `openSync`/`readSync` — mirroring
|
|
20
|
+
`CoherenceJournal.readTailTolerant` — used by the four telegram-log readers; and (B) a
|
|
21
|
+
`(size, mtimeMs)` load cache in `JsonlFeedbackStore` (mirroring the JobRunHistory cache) that serves a
|
|
22
|
+
clone of the prior parse when the file is byte-for-byte unchanged.
|
|
23
|
+
|
|
24
|
+
## What to Tell Your User
|
|
25
|
+
|
|
26
|
+
If your dashboard kept dropping its connection even after the tmux, keychain, and job-history fixes,
|
|
27
|
+
these two large files were the remaining cause: a 12MB message log re-read whole on a 5-minute timer,
|
|
28
|
+
and a 14MB feedback log re-parsed from scratch every pass. After this update both freezes are gone —
|
|
29
|
+
the message-log checks read only the last chunk of the file (instant regardless of size), and the
|
|
30
|
+
feedback log is served from memory when nothing changed. (A separate cleanup — capping those files'
|
|
31
|
+
unbounded growth — is tracked separately.)
|
|
32
|
+
|
|
33
|
+
## Summary of New Capabilities
|
|
34
|
+
|
|
35
|
+
- New `src/utils/jsonl-tail.ts` bounded tail-reader (`readJsonlTailLines` / `readJsonlTailLastLines`):
|
|
36
|
+
reads only the last `maxBytes` (default 512KB ≈ 2,600 lines) via `statSync`/`openSync`/`readSync`,
|
|
37
|
+
drops the partial first line, never loads the whole file, never throws.
|
|
38
|
+
- The four telegram-log tail readers (CommitmentSentinel, CoherenceMonitor, PresenceProxy ack path,
|
|
39
|
+
TelegramAdapter.getMessageLog) converted from whole-12MB-file sync reads to bounded tail-reads;
|
|
40
|
+
parsed/filtered results are identical to the old full-split-then-slice.
|
|
41
|
+
- `JsonlFeedbackStore` gains a `(size, mtimeMs)`-keyed in-memory load cache: unchanged file = zero-IO
|
|
42
|
+
clone-on-serve, any change = full re-read. Existing load semantics (dedup-last-wins, torn-line skip,
|
|
43
|
+
corrupt-file recovery) preserved exactly.
|
|
44
|
+
- Removes the third + fourth event-loop-blocker class behind the dashboard flapping and the
|
|
45
|
+
associated SleepWakeDetector false-wakes.
|
|
46
|
+
|
|
47
|
+
## Evidence
|
|
48
|
+
|
|
49
|
+
Root cause diagnosed by a live profiler catching the event loop frozen in a whole-file
|
|
50
|
+
ReadFileUtf8 → split on a 5-minute timer (the ~0-CPU false-sleep signature). Fix covered by 48 tests
|
|
51
|
+
across four files: `jsonl-tail` (9 — tail correctness, partial-line drop, missing/empty file,
|
|
52
|
+
truncation flag), `CoherenceMonitor-bounded-read` (2 — a `fs.readFileSync` spy FAILS if the whole
|
|
53
|
+
12MB log is read, AND a bad pattern in a RECENT agent message at the END of a multi-MB log is still
|
|
54
|
+
detected via the tail), `CommitmentSentinel` (26), `jsonl-feedback-store` (11 — including cache-hit
|
|
55
|
+
serves zero-IO and any change re-reads). Cross-impact suites green; tsc exit 0; no-silent-fallbacks +
|
|
56
|
+
bounded-accumulation lints green.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Bounded tail-reads for the 12MB telegram-log + 14MB feedback-store — ELI16
|
|
2
|
+
|
|
3
|
+
## What this is
|
|
4
|
+
|
|
5
|
+
Your agent's server runs everything on one main thread. If any single operation makes that thread
|
|
6
|
+
wait, *everything* waits — the dashboard, your messages, every background check. When that wait is
|
|
7
|
+
long enough, the dashboard's live connection drops (the "Disconnected" flapping you may have seen).
|
|
8
|
+
|
|
9
|
+
This fixes the THIRD and FOURTH freezes behind that flapping. (The first was a slow shared terminal
|
|
10
|
+
manager; the second was reading the macOS keychain the blocking way; the third was a 13MB job-history
|
|
11
|
+
file re-read whole every minute — all already fixed.) This one is about two more big files.
|
|
12
|
+
|
|
13
|
+
## What was wrong
|
|
14
|
+
|
|
15
|
+
Your agent keeps a running log of every Telegram message it has ever sent or received, in a file
|
|
16
|
+
called `telegram-messages.jsonl`. That file has grown to about **12 megabytes**. Several background
|
|
17
|
+
checks only ever need to glance at the LAST few messages in that log — the last 50, or the last 100.
|
|
18
|
+
But the way they were written, they read the **entire 12MB file** from disk and chopped it up, just
|
|
19
|
+
to look at the tail. And some of these checks run on a **5-minute timer**, every few minutes, forever.
|
|
20
|
+
Each one froze the server for up to **20 seconds**. (A second large file, a 14MB feedback log, had
|
|
21
|
+
the same problem: it was re-read and re-parsed from scratch every processing pass even when nothing
|
|
22
|
+
had changed.)
|
|
23
|
+
|
|
24
|
+
A frozen main thread uses almost no CPU, so the freeze even looked to the agent like the laptop had
|
|
25
|
+
briefly gone to sleep — a misleading signal on top of a real problem.
|
|
26
|
+
|
|
27
|
+
## What's new
|
|
28
|
+
|
|
29
|
+
Two simple changes, both behavior-preserving:
|
|
30
|
+
|
|
31
|
+
- The background checks that only want the tail of the message log now read just the **last chunk**
|
|
32
|
+
of the file (a 512-kilobyte window — far more than enough to hold the last few hundred messages),
|
|
33
|
+
not the whole thing. So they're instant no matter how big the file gets.
|
|
34
|
+
- The 14MB feedback log now remembers the file's size and last-modified time. If nothing changed
|
|
35
|
+
since it last read it, it returns the copy it already has, reading nothing from disk.
|
|
36
|
+
|
|
37
|
+
So those per-call whole-file reads are gone from every one of these hot paths, and the freezes they
|
|
38
|
+
caused are eliminated. Everything these checks actually do — what they look for, how they parse,
|
|
39
|
+
how they recover from a corrupt file — is unchanged, and proven by tests. One of the new tests even
|
|
40
|
+
plants a problem message at the very END of a multi-megabyte log to prove the tail-read still catches
|
|
41
|
+
recent entries.
|
|
42
|
+
|
|
43
|
+
## What you need to decide
|
|
44
|
+
|
|
45
|
+
Nothing. It's self-contained and low-risk, fully covered by tests. One honest note: the log files
|
|
46
|
+
themselves still grow large over time — putting a size cap on them is a separate cleanup I've flagged
|
|
47
|
+
to track, not fixed here. Reading them is no longer a freeze; capping their growth comes next.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# JobRunHistory incremental cache — event-loop freeze fix — ELI16
|
|
2
|
+
|
|
3
|
+
## What this is
|
|
4
|
+
|
|
5
|
+
Your agent's server runs everything on one main thread. If any single operation makes that thread
|
|
6
|
+
wait, *everything* waits — the dashboard, your messages, every background check. That's a "freeze,"
|
|
7
|
+
and a long-enough freeze drops the dashboard's live connection (the "Disconnected" flapping).
|
|
8
|
+
|
|
9
|
+
This fixes the THIRD distinct freeze behind that flapping. (The first was a slow shared terminal
|
|
10
|
+
manager; the second was reading the macOS keychain the blocking way — both already fixed.) This one:
|
|
11
|
+
the agent keeps a log of every background job it has ever run, in a file called `job-runs.jsonl`. That
|
|
12
|
+
file has grown to about **13 megabytes**. And every time a job finished or started — which happens
|
|
13
|
+
about every minute — the server read the WHOLE 13MB file from disk and re-parsed all of it, on the
|
|
14
|
+
main thread. That single operation froze the server for **13 to 16 seconds**, every minute or so. It
|
|
15
|
+
even looked to the agent like the laptop had gone to sleep (a blocked main thread uses almost no CPU,
|
|
16
|
+
so the sleep-detector misreads it).
|
|
17
|
+
|
|
18
|
+
## What already exists
|
|
19
|
+
|
|
20
|
+
The agent already uses this "keep a cached copy instead of re-reading from scratch" pattern elsewhere
|
|
21
|
+
(the cartographer code map serves a cached snapshot instead of recomputing live). This job-history
|
|
22
|
+
file just never got that treatment — it re-read the whole thing every single time.
|
|
23
|
+
|
|
24
|
+
## What's new
|
|
25
|
+
|
|
26
|
+
The job-run history now keeps a **smart in-memory cache**. It remembers the file's size and
|
|
27
|
+
last-modified time:
|
|
28
|
+
- If nothing changed since last time → it returns the cached copy instantly, reading nothing from
|
|
29
|
+
disk.
|
|
30
|
+
- If a new line was appended (the normal case) → it reads ONLY the new bit at the end, not the whole
|
|
31
|
+
13MB.
|
|
32
|
+
- If the file was compacted (shrunk) → it does one full re-read to stay correct.
|
|
33
|
+
|
|
34
|
+
So the per-call full-file read+parse is gone from every hot path. The freeze it caused is eliminated.
|
|
35
|
+
All the existing behavior (how it dedupes, recovers from a corrupt file, etc.) is preserved exactly,
|
|
36
|
+
proven by the test suite.
|
|
37
|
+
|
|
38
|
+
## What you need to decide
|
|
39
|
+
|
|
40
|
+
Nothing. It's a self-contained, low-risk read-path cache, fully covered by tests. One honest note:
|
|
41
|
+
the `job-runs.jsonl` file (and a separate 91MB cartographer file) keep growing without a size limit —
|
|
42
|
+
the cache makes reading them cheap regardless, but capping their growth is a separate cleanup item
|
|
43
|
+
I've flagged to track, not fixed here.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Side-Effects Review — bounded tail-reads for the 12MB telegram-log + 14MB feedback-store (event-loop freeze fix)
|
|
2
|
+
|
|
3
|
+
**Slug:** `eventloop-bounded-jsonl-reads` · **Tier:** 1 (focused low-risk bounded-read class fix, no
|
|
4
|
+
spec; live-profiler root-cause). Parent principles: **Structure beats Willpower** — never block the
|
|
5
|
+
event loop (the THIRD + FOURTH distinct event-loop-blocker class behind the dashboard "disconnected"
|
|
6
|
+
flapping, after sync tmux + sync keychain + the JobRunHistory 13MB re-read); and **Bounded
|
|
7
|
+
Accumulation** (the size of an append-only log must never determine the cost of reading it).
|
|
8
|
+
|
|
9
|
+
## Summary of the change
|
|
10
|
+
|
|
11
|
+
FOUR callers each did `fs.readFileSync(messageLogPath, 'utf-8')` on the ~12MB
|
|
12
|
+
`.instar/telegram-messages.jsonl` just to inspect the last 50–200 lines:
|
|
13
|
+
- `CommitmentSentinel.parseRecentMessages` (`src/monitoring/CommitmentSentinel.ts`) — 5-minute timer,
|
|
14
|
+
only ever looks at the last `maxMessagesPerScan*10` lines.
|
|
15
|
+
- `CoherenceMonitor` output-sanity check (`src/monitoring/CoherenceMonitor.ts`) — 5-minute timer,
|
|
16
|
+
only ever looks at the last 50 agent messages.
|
|
17
|
+
- `checkLogForAgentResponse` (PresenceProxy ack path, `src/commands/server.ts`) — event-driven on
|
|
18
|
+
every PresenceProxy tier verdict, only ever looks at the last 50 lines.
|
|
19
|
+
- `TelegramAdapter.getMessageLog` (`src/messaging/TelegramAdapter.ts`) — analysis route, only ever
|
|
20
|
+
returns the last `limit` (default 100) entries.
|
|
21
|
+
|
|
22
|
+
A 12MB synchronous read + `.split('\n')` on a 5-minute timer froze the event loop for up to ~20s per
|
|
23
|
+
pass — the same ~0-CPU false-sleep signature the JobRunHistory fix removed. PLUS `JsonlFeedbackStore`
|
|
24
|
+
(`src/feedback-factory/store/JsonlFeedbackStore.ts`) re-read + re-`JSON.parse`d the entire ~14MB
|
|
25
|
+
`feedback.jsonl` at the start of every processing pass even when nothing had changed.
|
|
26
|
+
|
|
27
|
+
The fix is two behavior-preserving mechanisms:
|
|
28
|
+
|
|
29
|
+
**(A) A shared bounded tail-read utility** — `src/utils/jsonl-tail.ts` (`readJsonlTailLines` /
|
|
30
|
+
`readJsonlTailLastLines`). It `statSync`s the file (O(1)), `openSync` + `readSync`s only the trailing
|
|
31
|
+
`maxBytes` (default 512KB ≈ ~2,600 recent lines, far more than any caller needs) into a fixed buffer,
|
|
32
|
+
drops the first partial line when the window started mid-file, and returns the trailing lines in file
|
|
33
|
+
order. It NEVER loads the whole file and NEVER throws (a read failure returns an empty result,
|
|
34
|
+
matching the `@silent-fallback-ok` behavior of every former full-file caller). It mirrors
|
|
35
|
+
`CoherenceJournal.readTailTolerant`, generalized off that journal's typed-entry shape. The four
|
|
36
|
+
telegram-log readers above now call it instead of `readFileSync`.
|
|
37
|
+
|
|
38
|
+
**(B) A `(size, mtimeMs)` load cache in `JsonlFeedbackStore.loadJsonl`** — when the on-disk file is
|
|
39
|
+
byte-for-byte the one last folded, it serves a clone of the prior parse with ZERO IO/parse; any
|
|
40
|
+
change (append, shrink, delete) re-reads from disk. Mirrors the JobRunHistory cache pattern. The
|
|
41
|
+
clone-on-serve guarantees a caller mutating the returned Map can never poison the cache.
|
|
42
|
+
|
|
43
|
+
## 1. Correctness / behavioral equivalence
|
|
44
|
+
|
|
45
|
+
The telegram-log readers each consumed only a fixed tail (last 50 / last 200 / last `limit`) of the
|
|
46
|
+
log, so the 512KB window (≈ 2,600 lines) is strictly a superset of every caller's need — the parsed +
|
|
47
|
+
filtered result is identical to the old full-split-then-slice. The partial-first-line drop guarantees
|
|
48
|
+
a truncated record at the window boundary is never mis-parsed. The feedback store's load semantics
|
|
49
|
+
(per-line parse, dedup-last-wins-by-id, torn-line skip, corrupt-file recovery) are preserved exactly;
|
|
50
|
+
the cache is only consulted when the fingerprint matches and is invalidated on any change. Verified:
|
|
51
|
+
48/48 across the four touched test files (`jsonl-tail` 9, `CoherenceMonitor-bounded-read` 2,
|
|
52
|
+
`CommitmentSentinel` 26, `jsonl-feedback-store` 11) plus the cross-impact suites; tsc exit 0; lints
|
|
53
|
+
green. The two new regression tests spy on `fs.readFileSync` to FAIL if the whole telegram log is
|
|
54
|
+
read, AND prove a bad pattern in a RECENT agent message at the END of a multi-MB log is still
|
|
55
|
+
detected via the tail.
|
|
56
|
+
|
|
57
|
+
## 2. Cache-coherence / multi-writer safety
|
|
58
|
+
|
|
59
|
+
The telegram-log tail-read holds no state — every call re-reads the live tail, so a concurrent
|
|
60
|
+
appender is always reflected. The feedback-store `(size, mtimeMs)` key invalidates on any byte/mtime
|
|
61
|
+
change (append, compaction rewrite, delete-then-recreate), so an unchanged file is the only cache-hit
|
|
62
|
+
case and a stale fold can never be served. The cache is per-process in-memory (not shared) — no
|
|
63
|
+
cross-process coherence concern (each process reads its own view, same as before). `loadCache` is
|
|
64
|
+
keyed per absolute path and dropped on `existsSync` failure.
|
|
65
|
+
|
|
66
|
+
## 3. Fail-safe
|
|
67
|
+
|
|
68
|
+
`jsonl-tail.ts` never throws: missing file, stat failure, open/read failure each return an empty
|
|
69
|
+
result — exactly the prior `@silent-fallback-ok` degradation of the full-file callers (an
|
|
70
|
+
observability/housekeeping read must never endanger the observed operation). The feedback store's
|
|
71
|
+
stat-failure path falls through to a full read (`/* fall through to a full read — never let stat
|
|
72
|
+
failure skip the load */`), so a stat error degrades to the correct (slow) result, never wrong data.
|
|
73
|
+
|
|
74
|
+
## 4. Blast radius
|
|
75
|
+
|
|
76
|
+
One new utility module + its test; four read-site one-liner swaps (CommitmentSentinel, CoherenceMonitor,
|
|
77
|
+
server.ts PresenceProxy ack, TelegramAdapter.getMessageLog); one read-path cache in JsonlFeedbackStore
|
|
78
|
+
+ a test-only cache-clear export. No API/route change, no schema change, no write-path semantics
|
|
79
|
+
change, no new external surface.
|
|
80
|
+
|
|
81
|
+
## 5. Residual (tracked, NOT fixed here — out of scope)
|
|
82
|
+
|
|
83
|
+
The existing `lint-no-wholefile-sync-read` ratchet only catches LITERAL path basenames in the read
|
|
84
|
+
call, but these four sites read VARIABLE paths (`this.messageLogPath`, a `logPath` param), so the
|
|
85
|
+
lint could never have flagged them — which is why they survived the earlier sweeps. The durable
|
|
86
|
+
structural prevention is an **accessor funnel** (route all append-log reads through a single bounded
|
|
87
|
+
accessor that the lint can enforce), which is **Bounded Accumulation Increment 2** — a tracked
|
|
88
|
+
follow-up named here so it is not silently dropped. Separately, the telegram-messages.jsonl and
|
|
89
|
+
feedback.jsonl files themselves still grow UNBOUNDED — a size/retention cap is the same Bounded
|
|
90
|
+
Accumulation track. This change makes READS cheap regardless of size; it does not cap the files.
|
|
91
|
+
|
|
92
|
+
## 6. Rollback
|
|
93
|
+
|
|
94
|
+
Revert the source files. The change is purely read-path (a bounded tail-read + a fingerprint cache);
|
|
95
|
+
reverting restores the (slow but correct) full-read behavior. No data migration, no on-disk format
|
|
96
|
+
change to undo.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Side-Effects Review — JobRunHistory incremental cache (event-loop freeze fix)
|
|
2
|
+
|
|
3
|
+
**Slug:** `jobrunhistory-incremental-cache` · **Tier:** 1 (focused bug fix, no spec; live-profiler
|
|
4
|
+
root-cause). Parent principle: **Structure beats Willpower** — never block the event loop (the third
|
|
5
|
+
distinct event-loop blocker behind the dashboard "disconnected" flapping, after sync tmux + sync
|
|
6
|
+
keychain reads).
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
`JobRunHistory.readLines()` (`src/scheduler/JobRunHistory.ts`) did `fs.readFileSync` + per-line
|
|
11
|
+
`JSON.parse` of the ENTIRE `.instar/ledger/job-runs.jsonl` (~13MB) on EVERY call — and it is on the
|
|
12
|
+
hot path of `findRun` / `recordCompletion` / `recordReflection` / `recordHandoff` / `query` / `stats`
|
|
13
|
+
/ `allStats` / `getLastHandoff`, all driven by the scheduler every job completion/spawn (~60s
|
|
14
|
+
cadence: health-check, commitment-detection, mentor jobs). A live `/usr/bin/sample` caught the main
|
|
15
|
+
thread in `ReadFileUtf8 → ParseJson → WriteFileUtf8` — a 13–16s event-loop freeze misreported by
|
|
16
|
+
SleepWakeDetector as a ~15s "wake" (the ~0-CPU false-sleep signature). This adds an **incremental
|
|
17
|
+
in-memory cache** of parsed runs keyed on the file's `(size, mtimeMs)`: an unchanged file returns the
|
|
18
|
+
cache with ZERO IO/parse; an append-only growth reads ONLY the appended tail via `fs.readSync` from
|
|
19
|
+
the last offset (O(delta), not O(13MB)); a shrink/rewrite (compaction) triggers one full re-read.
|
|
20
|
+
|
|
21
|
+
## 1. Correctness / behavioral equivalence
|
|
22
|
+
|
|
23
|
+
Existing semantics preserved exactly: append-only, dedup-last-wins, torn-line skip, corrupt-file
|
|
24
|
+
recovery. `appendLine` keeps the cache coherent so the read after a completion is also free;
|
|
25
|
+
`compact()` re-seeds the cache after its rewrite. Verified: 30 original `JobRunHistory` tests + 4 new
|
|
26
|
+
regression tests (cache-hit does zero full-file reads across 75 ops; external append picked up via
|
|
27
|
+
tail-read; torn-line skipped; full re-read on shrink); the full scheduler suite (incl. MigrationLedger,
|
|
28
|
+
reaper, run-record) = 325; 346 total green.
|
|
29
|
+
|
|
30
|
+
## 2. Cache-coherence / multi-writer safety
|
|
31
|
+
|
|
32
|
+
The file has two in-process append writers (this instance + `MigrationLedger.appendMigrationEvent`),
|
|
33
|
+
both append-only. The `(size, mtimeMs)` key + intact-prefix tail-read handles an external append
|
|
34
|
+
correctly (picks up appended rows via tail-read). A shrink (only `compact()` rewrites) forces a full
|
|
35
|
+
re-read, so a compaction can never serve a stale cache. The cache is per-process in-memory (not
|
|
36
|
+
shared) — no cross-process coherence concern (each process reads its own view, same as before).
|
|
37
|
+
|
|
38
|
+
## 3. Fail-safe
|
|
39
|
+
|
|
40
|
+
The new fail paths (cache read, tail-read, stat) fall back to a full re-read or to the prior
|
|
41
|
+
behavior; each genuinely fail-safe catch is tagged `@silent-fallback-ok`. A stat/read error degrades
|
|
42
|
+
to a full re-read (correct result, just not the fast path) — never wrong data, never a crash.
|
|
43
|
+
|
|
44
|
+
## 4. Blast radius
|
|
45
|
+
|
|
46
|
+
One module (`JobRunHistory.ts`) + its test. No API/route change, no schema change, no write-path
|
|
47
|
+
semantics change (`compact()` still rewrites on its existing cadence — see the residual note). No
|
|
48
|
+
new external surface.
|
|
49
|
+
|
|
50
|
+
## 5. Residual (tracked, NOT fixed here — out of scope)
|
|
51
|
+
|
|
52
|
+
`job-runs.jsonl` grows UNBOUNDED to 13MB (and the cartographer `index.json` is 91MB) — a genuine
|
|
53
|
+
Bounded-Accumulation issue. The cache makes READS cheap regardless of size, but `compact()` still
|
|
54
|
+
does a full synchronous ~13MB WRITE on startup, and the file keeps growing. A retention/rotation cap
|
|
55
|
+
on `job-runs.jsonl` (and the cartographer index) is a separate Bounded-Accumulation follow-up worth
|
|
56
|
+
filing — named here so it is not silently dropped.
|
|
57
|
+
|
|
58
|
+
## 6. Rollback
|
|
59
|
+
|
|
60
|
+
Revert the one source file. The change is purely a read-path cache; reverting restores the (slow but
|
|
61
|
+
correct) full-read behavior.
|