instar 1.3.635 → 1.3.637
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/core/SleepWakeDetector.d.ts +42 -1
- package/dist/core/SleepWakeDetector.d.ts.map +1 -1
- package/dist/core/SleepWakeDetector.js +50 -0
- package/dist/core/SleepWakeDetector.js.map +1 -1
- package/dist/core/storage/JsonlStore.d.ts +54 -0
- package/dist/core/storage/JsonlStore.d.ts.map +1 -0
- package/dist/core/storage/JsonlStore.js +81 -0
- package/dist/core/storage/JsonlStore.js.map +1 -0
- package/dist/utils/jsonl-rotation.d.ts +37 -0
- package/dist/utils/jsonl-rotation.d.ts.map +1 -1
- package/dist/utils/jsonl-rotation.js +86 -0
- package/dist/utils/jsonl-rotation.js.map +1 -1
- package/package.json +2 -2
- package/scripts/bounded-accumulation-retention-baseline.json +81 -0
- package/scripts/bounded-accumulation-wholefile-read-baseline.json +5 -0
- package/scripts/lint-no-wholefile-sync-read.js +128 -0
- package/scripts/lint-store-retention-declared.js +93 -0
- package/src/data/builtin-manifest.json +2 -2
- package/src/data/state-coherence-registry.json +71 -10
- package/upgrades/1.3.636.md +59 -0
- package/upgrades/1.3.637.md +41 -0
- package/upgrades/side-effects/bounded-accumulation-increment-1.md +103 -0
- package/upgrades/side-effects/sleepwake-stall-not-sleep.md +102 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lint-no-wholefile-sync-read.js — Bounded Accumulation §3c (Lint 2).
|
|
4
|
+
*
|
|
5
|
+
* Forbids a whole-file SYNCHRONOUS read of a store the registry marks
|
|
6
|
+
* `access: 'streamed'` (a store that can exceed 8MB): `JSON.parse(fs.readFileSync(p))`
|
|
7
|
+
* or `fs.readFileSync(p, ...).split(...)`. Reading a multi-MB file whole on the event
|
|
8
|
+
* loop is the stall this standard exists to kill (#1239; the cartographer index,
|
|
9
|
+
* instar#1069). Such stores must be read by streaming / segment / SQLite.
|
|
10
|
+
*
|
|
11
|
+
* COVERAGE HONESTY (No Silent Degradation): this is a static guardrail, NOT complete.
|
|
12
|
+
* It resolves a `streamed` store by its path BASENAME appearing as a literal near the
|
|
13
|
+
* read. A read via a variable path (`fs.readFileSync(this.logPath)`) is NOT statically
|
|
14
|
+
* resolvable and is NOT caught here — that gap is closed by the accessor funnel (route
|
|
15
|
+
* all reads through src/core/storage/, Bounded Accumulation Increment 2). The complete
|
|
16
|
+
* runtime check is the growth-burst test; this lint is the cheap forward ratchet that
|
|
17
|
+
* stops a NEW literal whole-file read of a bounded store.
|
|
18
|
+
*
|
|
19
|
+
* Ships WARN-then-ratchet: a FROZEN baseline grandfathers today's literal hits; a NEW
|
|
20
|
+
* hit fails. Exit codes: 0 pass · 1 new violation · 2 cannot-read.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
28
|
+
// --registry / --baseline / --root override the defaults (for tests + alternate checkouts).
|
|
29
|
+
function argVal(flag) {
|
|
30
|
+
const i = process.argv.indexOf(flag);
|
|
31
|
+
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : null;
|
|
32
|
+
}
|
|
33
|
+
const REGISTRY = path.resolve(argVal('--registry') || path.join(ROOT, 'src', 'data', 'state-coherence-registry.json'));
|
|
34
|
+
const BASELINE = path.resolve(argVal('--baseline') || path.join(ROOT, 'scripts', 'bounded-accumulation-wholefile-read-baseline.json'));
|
|
35
|
+
const SRC = path.resolve(argVal('--root') || path.join(ROOT, 'src'));
|
|
36
|
+
|
|
37
|
+
// Files exempt: the accessor + rotation definitions, and the standard's own machinery.
|
|
38
|
+
const EXEMPT = [/\/core\/storage\//, /\/utils\/jsonl-rotation\.ts$/];
|
|
39
|
+
|
|
40
|
+
function streamedBasenames() {
|
|
41
|
+
const reg = JSON.parse(fs.readFileSync(REGISTRY, 'utf8'));
|
|
42
|
+
const names = new Set();
|
|
43
|
+
for (const e of reg.entries || []) {
|
|
44
|
+
if (e.retention && (e.retention.access === 'streamed')) {
|
|
45
|
+
for (const p of e.paths || []) {
|
|
46
|
+
const b = path.basename(p).replace(/\*/g, '');
|
|
47
|
+
if (b && b.endsWith('.jsonl')) names.add(b);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return names;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walk(dir, out) {
|
|
55
|
+
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
const fp = path.join(dir, f.name);
|
|
57
|
+
if (f.isDirectory()) walk(fp, out);
|
|
58
|
+
else if (f.name.endsWith('.ts') && !f.name.endsWith('.test.ts')) out.push(fp);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A whole-file sync read: JSON.parse(...readFileSync...) OR readFileSync(...).split
|
|
64
|
+
const WHOLE_READ = /(JSON\.parse\([^)]*readFileSync|readFileSync\s*\([^)]*\)\s*\.\s*split)/;
|
|
65
|
+
|
|
66
|
+
let registryBasenames;
|
|
67
|
+
try {
|
|
68
|
+
registryBasenames = streamedBasenames();
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error('lint-no-wholefile-sync-read: cannot read registry: ' + e.message);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let baseline = { violations: [] };
|
|
75
|
+
try {
|
|
76
|
+
baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
// No baseline yet → treat as empty (first run records nothing as grandfathered).
|
|
79
|
+
}
|
|
80
|
+
const baselineSet = new Set((baseline.violations || []).map((v) => v.file + '::' + v.basename));
|
|
81
|
+
|
|
82
|
+
const found = [];
|
|
83
|
+
for (const file of walk(SRC, [])) {
|
|
84
|
+
if (EXEMPT.some((re) => re.test(file))) continue;
|
|
85
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
if (!WHOLE_READ.test(line)) continue;
|
|
89
|
+
// Resolve to a streamed store by a basename literal on this or the 2 lines above.
|
|
90
|
+
const ctx = (lines[i - 2] || '') + '\n' + (lines[i - 1] || '') + '\n' + line;
|
|
91
|
+
for (const b of registryBasenames) {
|
|
92
|
+
if (ctx.includes(b)) {
|
|
93
|
+
found.push({ file: path.relative(ROOT, file), basename: b, line: i + 1 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Generate-baseline mode: record current hits as grandfathered.
|
|
100
|
+
if (process.argv.includes('--write-baseline')) {
|
|
101
|
+
fs.writeFileSync(
|
|
102
|
+
BASELINE,
|
|
103
|
+
JSON.stringify(
|
|
104
|
+
{
|
|
105
|
+
note: 'FROZEN baseline of literal whole-file-sync reads of streamed stores at Bounded Accumulation Increment 1. May only shrink. New hits fail the lint.',
|
|
106
|
+
frozenAt: '2026-06-21',
|
|
107
|
+
violations: found.map((f) => ({ file: f.file, basename: f.basename })),
|
|
108
|
+
},
|
|
109
|
+
null,
|
|
110
|
+
2,
|
|
111
|
+
) + '\n',
|
|
112
|
+
);
|
|
113
|
+
console.log(`lint-no-wholefile-sync-read: wrote baseline with ${found.length} grandfathered hit(s).`);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const newViolations = found.filter((f) => !baselineSet.has(f.file + '::' + f.basename));
|
|
118
|
+
if (newViolations.length) {
|
|
119
|
+
console.error('lint-no-wholefile-sync-read: FAIL — new whole-file sync read(s) of a streamed store:');
|
|
120
|
+
for (const v of newViolations) {
|
|
121
|
+
console.error(` • ${v.file}:${v.line} reads ${v.basename} whole. Use streaming / segment / SQLite, or route through src/core/storage/.`);
|
|
122
|
+
}
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
console.log(
|
|
126
|
+
`lint-no-wholefile-sync-read: OK — ${found.length} literal hit(s), all grandfathered (${baselineSet.size} baselined).`,
|
|
127
|
+
);
|
|
128
|
+
process.exit(0);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lint-store-retention-declared.js — Bounded Accumulation §3b (Lint 1).
|
|
4
|
+
*
|
|
5
|
+
* Every persistent store (a category in state-coherence-registry.json) MUST declare a
|
|
6
|
+
* retention policy. This lint is the RATCHET: it FAILS on a NEW category that has no
|
|
7
|
+
* `retention`, while grandfathering the frozen legacy backlog (the §4 retrofit counts
|
|
8
|
+
* that backlog down). It also enforces D6 set-monotonicity — the frozen baseline may
|
|
9
|
+
* only SHRINK: growing it (gaming the ratchet by adding a new store to the allowlist
|
|
10
|
+
* instead of giving it retention) fails.
|
|
11
|
+
*
|
|
12
|
+
* Pairs with lint-state-registry.js (which forces a write-site to be REGISTERED at all);
|
|
13
|
+
* this lint forces a registered store to also be BOUNDED.
|
|
14
|
+
*
|
|
15
|
+
* Exit codes: 0 pass · 1 violation · 2 cannot-read (fail-loud, never silently skip).
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
23
|
+
// --registry / --baseline override the defaults (for tests + alternate checkouts).
|
|
24
|
+
function argVal(flag) {
|
|
25
|
+
const i = process.argv.indexOf(flag);
|
|
26
|
+
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : null;
|
|
27
|
+
}
|
|
28
|
+
const REGISTRY = path.resolve(argVal('--registry') || path.join(ROOT, 'src', 'data', 'state-coherence-registry.json'));
|
|
29
|
+
const BASELINE = path.resolve(argVal('--baseline') || path.join(ROOT, 'scripts', 'bounded-accumulation-retention-baseline.json'));
|
|
30
|
+
|
|
31
|
+
// The category count of the frozen baseline at Increment 1. The baseline may only
|
|
32
|
+
// shrink; a larger set means the allowlist was grown to dodge the ratchet (D6).
|
|
33
|
+
const FROZEN_BASELINE_COUNT = 75;
|
|
34
|
+
|
|
35
|
+
function hasRetention(e) {
|
|
36
|
+
return !!(e && e.retention && typeof e.retention === 'object' && Object.keys(e.retention).length > 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let registry, baseline;
|
|
40
|
+
try {
|
|
41
|
+
registry = JSON.parse(fs.readFileSync(REGISTRY, 'utf8'));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error('lint-store-retention-declared: cannot read registry: ' + e.message);
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error('lint-store-retention-declared: cannot read frozen baseline: ' + e.message);
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const baselineSet = new Set(baseline.categories || []);
|
|
54
|
+
const errors = [];
|
|
55
|
+
|
|
56
|
+
// D6: the frozen baseline may only shrink.
|
|
57
|
+
if ((baseline.categories || []).length > FROZEN_BASELINE_COUNT) {
|
|
58
|
+
errors.push(
|
|
59
|
+
`Retention baseline GREW (${baseline.categories.length} > frozen ${FROZEN_BASELINE_COUNT}). ` +
|
|
60
|
+
`The baseline may only shrink (D6 set-monotonicity) — declare a retention policy on the new ` +
|
|
61
|
+
`store instead of adding it to the grandfathered allowlist.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Every no-retention category must be in the frozen baseline (grandfathered legacy).
|
|
66
|
+
for (const e of registry.entries || []) {
|
|
67
|
+
if (hasRetention(e)) continue;
|
|
68
|
+
if (!baselineSet.has(e.category)) {
|
|
69
|
+
errors.push(
|
|
70
|
+
`Store category "${e.category}" has NO retention policy and is not in the frozen baseline. ` +
|
|
71
|
+
`Every persistent store must declare a retention policy (Bounded Accumulation §2). Add a ` +
|
|
72
|
+
`"retention" field to its state-coherence-registry.json entry — one of: ` +
|
|
73
|
+
`{class:'A',access:'streamed',maxBytes,keepSegments} (rotating log), ` +
|
|
74
|
+
`{class:'C',access:'streamed',complianceHold:true} (audit/forensic — archive, never drop), ` +
|
|
75
|
+
`{class:'sqlite',access:'sqlite',maxAgeMs} (indexed store), ` +
|
|
76
|
+
`{class:'R',access:'protocol-reader',boundedBy:'replication-protocol'} (replication substrate), or ` +
|
|
77
|
+
`{class:'resolution',boundedByResolution:true,maxOpenItems} (actionable queue).`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (errors.length) {
|
|
83
|
+
console.error('lint-store-retention-declared: FAIL');
|
|
84
|
+
for (const e of errors) console.error(' • ' + e);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const retentioned = (registry.entries || []).filter(hasRetention).length;
|
|
89
|
+
console.log(
|
|
90
|
+
`lint-store-retention-declared: OK — ${retentioned} stores retentioned, ` +
|
|
91
|
+
`${baselineSet.size} grandfathered (retrofit backlog, may only shrink).`,
|
|
92
|
+
);
|
|
93
|
+
process.exit(0);
|
|
@@ -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-22T00:10:26.994Z",
|
|
5
|
+
"instarVersion": "1.3.637",
|
|
6
6
|
"entryCount": 202,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
"paths": [
|
|
15
15
|
"state/coherence-journal/"
|
|
16
16
|
],
|
|
17
|
-
"grandfathered": false
|
|
17
|
+
"grandfathered": false,
|
|
18
|
+
"retention": {
|
|
19
|
+
"class": "R",
|
|
20
|
+
"access": "protocol-reader",
|
|
21
|
+
"boundedBy": "replication-protocol",
|
|
22
|
+
"note": "CARVED OUT — bounded inside CoherenceJournal via KindRetention + the new seq-floor prune guard; never externally rotated"
|
|
23
|
+
}
|
|
18
24
|
},
|
|
19
25
|
{
|
|
20
26
|
"category": "pending-pulls",
|
|
@@ -171,7 +177,13 @@
|
|
|
171
177
|
"paths": [
|
|
172
178
|
"state/coherence-journal/*.topic-placement.jsonl"
|
|
173
179
|
],
|
|
174
|
-
"grandfathered": false
|
|
180
|
+
"grandfathered": false,
|
|
181
|
+
"retention": {
|
|
182
|
+
"class": "R",
|
|
183
|
+
"access": "protocol-reader",
|
|
184
|
+
"boundedBy": "replication-protocol",
|
|
185
|
+
"note": "CARVED OUT — replication substrate"
|
|
186
|
+
}
|
|
175
187
|
},
|
|
176
188
|
{
|
|
177
189
|
"category": "autonomous-working-artifacts",
|
|
@@ -432,7 +444,12 @@
|
|
|
432
444
|
"state/resource-ledger.db",
|
|
433
445
|
"state/resources.db"
|
|
434
446
|
],
|
|
435
|
-
"grandfathered": false
|
|
447
|
+
"grandfathered": false,
|
|
448
|
+
"retention": {
|
|
449
|
+
"class": "sqlite",
|
|
450
|
+
"access": "sqlite",
|
|
451
|
+
"maxAgeMs": 2592000000
|
|
452
|
+
}
|
|
436
453
|
},
|
|
437
454
|
{
|
|
438
455
|
"category": "lease-coordination-state",
|
|
@@ -608,7 +625,14 @@
|
|
|
608
625
|
"state/destructive-ops.jsonl",
|
|
609
626
|
"logs/destructive-ops.jsonl"
|
|
610
627
|
],
|
|
611
|
-
"grandfathered": false
|
|
628
|
+
"grandfathered": false,
|
|
629
|
+
"retention": {
|
|
630
|
+
"class": "C",
|
|
631
|
+
"access": "streamed",
|
|
632
|
+
"complianceHold": true,
|
|
633
|
+
"keepSegments": 0,
|
|
634
|
+
"note": "forensic trail — archive, never drop-delete (replaces SafeGitExecutor.maybeRotateAuditLog drop-rotate)"
|
|
635
|
+
}
|
|
612
636
|
},
|
|
613
637
|
{
|
|
614
638
|
"category": "audit-job-runs",
|
|
@@ -621,7 +645,13 @@
|
|
|
621
645
|
"state/job-runs.jsonl",
|
|
622
646
|
"logs/job-runs.jsonl"
|
|
623
647
|
],
|
|
624
|
-
"grandfathered": false
|
|
648
|
+
"grandfathered": false,
|
|
649
|
+
"retention": {
|
|
650
|
+
"class": "A",
|
|
651
|
+
"access": "streamed",
|
|
652
|
+
"maxBytes": 33554432,
|
|
653
|
+
"keepSegments": 4
|
|
654
|
+
}
|
|
625
655
|
},
|
|
626
656
|
{
|
|
627
657
|
"category": "audit-reaper",
|
|
@@ -671,7 +701,14 @@
|
|
|
671
701
|
"state/security.jsonl",
|
|
672
702
|
"logs/security.jsonl"
|
|
673
703
|
],
|
|
674
|
-
"grandfathered": false
|
|
704
|
+
"grandfathered": false,
|
|
705
|
+
"retention": {
|
|
706
|
+
"class": "C",
|
|
707
|
+
"access": "streamed",
|
|
708
|
+
"complianceHold": true,
|
|
709
|
+
"keepSegments": 0,
|
|
710
|
+
"note": "forensic trail — archive, never drop-delete (replaces SecurityLog maybeRotateJsonl keep-ratio trim)"
|
|
711
|
+
}
|
|
675
712
|
},
|
|
676
713
|
{
|
|
677
714
|
"category": "audit-decision-journal",
|
|
@@ -749,7 +786,13 @@
|
|
|
749
786
|
"state/telegram-messages.jsonl",
|
|
750
787
|
"logs/telegram-messages.jsonl"
|
|
751
788
|
],
|
|
752
|
-
"grandfathered": false
|
|
789
|
+
"grandfathered": false,
|
|
790
|
+
"retention": {
|
|
791
|
+
"class": "A",
|
|
792
|
+
"access": "streamed",
|
|
793
|
+
"maxBytes": 33554432,
|
|
794
|
+
"keepSegments": 4
|
|
795
|
+
}
|
|
753
796
|
},
|
|
754
797
|
{
|
|
755
798
|
"category": "audit-a2a-messages",
|
|
@@ -814,7 +857,13 @@
|
|
|
814
857
|
"state/feedback.jsonl",
|
|
815
858
|
"logs/feedback.jsonl"
|
|
816
859
|
],
|
|
817
|
-
"grandfathered": false
|
|
860
|
+
"grandfathered": false,
|
|
861
|
+
"retention": {
|
|
862
|
+
"class": "A",
|
|
863
|
+
"access": "streamed",
|
|
864
|
+
"maxBytes": 33554432,
|
|
865
|
+
"keepSegments": 4
|
|
866
|
+
}
|
|
818
867
|
},
|
|
819
868
|
{
|
|
820
869
|
"category": "audit-apprenticeship-decisions",
|
|
@@ -840,7 +889,13 @@
|
|
|
840
889
|
"state/token-ledger.db",
|
|
841
890
|
"state/tokens.db"
|
|
842
891
|
],
|
|
843
|
-
"grandfathered": false
|
|
892
|
+
"grandfathered": false,
|
|
893
|
+
"retention": {
|
|
894
|
+
"class": "sqlite",
|
|
895
|
+
"access": "sqlite",
|
|
896
|
+
"maxAgeMs": 2592000000,
|
|
897
|
+
"note": "incremental-vacuum + batched delete off-thread (Increment 2)"
|
|
898
|
+
}
|
|
844
899
|
},
|
|
845
900
|
{
|
|
846
901
|
"category": "derived-semantic-memory",
|
|
@@ -856,7 +911,13 @@
|
|
|
856
911
|
"state/episodes/",
|
|
857
912
|
"memory/semantic.jsonl"
|
|
858
913
|
],
|
|
859
|
-
"grandfathered": false
|
|
914
|
+
"grandfathered": false,
|
|
915
|
+
"retention": {
|
|
916
|
+
"class": "sqlite",
|
|
917
|
+
"access": "sqlite",
|
|
918
|
+
"maxAgeMs": 7776000000,
|
|
919
|
+
"note": "mixed .db + memory/semantic.jsonl; jsonl arm is A-class streamed in retrofit"
|
|
920
|
+
}
|
|
860
921
|
},
|
|
861
922
|
{
|
|
862
923
|
"category": "derived-topic-memory",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The sleep/wake watcher (`SleepWakeDetector`) used to decide "did the machine sleep?"
|
|
9
|
+
purely from a timer-drift jump plus **system load**. On a multi-core machine, one
|
|
10
|
+
internal thread can freeze for 10–60 seconds without moving the system load average — so
|
|
11
|
+
the watcher would announce a "wake after ~Ns sleep" when the machine never slept at all
|
|
12
|
+
(it was plugged in, lid open, with `caffeinate` running — sleep was impossible). Those
|
|
13
|
+
false "sleep" reports hid the real cause: short event-loop freezes.
|
|
14
|
+
|
|
15
|
+
Now the watcher also measures how much CPU **its own process** burned during the gap. A
|
|
16
|
+
truly sleeping process burns almost no CPU; a frozen-but-busy one burns CPU the whole
|
|
17
|
+
time. So a busy gap is correctly reported as a `stall` (a freeze the wedge watchers can
|
|
18
|
+
act on) instead of a phantom "sleep." The new check runs first, applies even to
|
|
19
|
+
multi-minute gaps, defaults on with no configuration, and fails safe (if the CPU reading
|
|
20
|
+
can't be taken it quietly falls back to the old behavior).
|
|
21
|
+
|
|
22
|
+
## What to Tell Your User
|
|
23
|
+
|
|
24
|
+
If you ever saw your agent claim it "woke from sleep" on a machine that was plugged in
|
|
25
|
+
and awake — that was a misread. The watcher now tells the difference between a real sleep
|
|
26
|
+
and an internal freeze by checking its own CPU use, so those phantom "sleep" messages stop
|
|
27
|
+
and genuine freezes get flagged honestly. Nothing to configure.
|
|
28
|
+
|
|
29
|
+
## Summary of New Capabilities
|
|
30
|
+
|
|
31
|
+
No new user-facing capability — a correctness fix. `SleepWakeDetector` gains a per-process
|
|
32
|
+
CPU-burn discriminator that separates an event-loop block from real sleep: it emits a
|
|
33
|
+
signal-only `stall` event (and suppresses the false `wake`) when this process burned CPU
|
|
34
|
+
through the drift gap. Tunable via the optional `cpuBlockBusyRatio` (default 0.5; set 0 to
|
|
35
|
+
disable). `getStats().suppressedByReason` gains an `event-loop-block` count. Existing
|
|
36
|
+
agents get it via the normal update; on-disk formats and API shapes are unchanged.
|
|
37
|
+
|
|
38
|
+
## Evidence
|
|
39
|
+
|
|
40
|
+
**Reproduction (live, 2026-06-21):** Echo's server was flapping (watchdog SIGKILL+respawn
|
|
41
|
+
on a cadence) from event-loop wedges. Throughout, `logs/server.log` carried
|
|
42
|
+
`[SleepWakeDetector] Wake detected after ~Ns sleep` lines — on a host that was plugged in,
|
|
43
|
+
lid open, with `caffeinate` running, where OS sleep is physically impossible. `pmset -g`
|
|
44
|
+
confirmed no sleep occurred. The drifts were single-process event-loop blocks (one Node
|
|
45
|
+
thread pinned), and the 16-core `loadavg` stayed well under `maxLoadRatio` (1.5), so the
|
|
46
|
+
existing load guard never tripped and each block printed a phantom "sleep."
|
|
47
|
+
|
|
48
|
+
**Before:** a 14s CPU-bound event-loop block under normal system load → `wake` emitted
|
|
49
|
+
(`sleepDurationSeconds ≈ 14`), credited as real sleep in `wakeHistory`; the wedge was
|
|
50
|
+
invisible (laundered into "sleep").
|
|
51
|
+
|
|
52
|
+
**After:** the same 14s CPU-bound drift → the per-process CPU check measures ~100% busy
|
|
53
|
+
across the gap → a `stall` event is emitted and the `wake` is suppressed (recorded under
|
|
54
|
+
`suppressedByReason['event-loop-block']`), so it is never credited as sleep. A genuine
|
|
55
|
+
idle/suspend gap (~0% CPU) still emits `wake` unchanged.
|
|
56
|
+
|
|
57
|
+
**Automated:** `tests/unit/SleepWakeDetector-cpu-block.test.ts` reproduces both directions
|
|
58
|
+
with injected clock + CPU providers (CPU-busy short drift → stall; idle drift → wake; long
|
|
59
|
+
CPU-busy drift → stall; throwing provider → no crash). 15/15 unit tests green; `tsc` clean.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: minor -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
Instar gains a new constitutional standard — **Bounded Accumulation: every persistent store must
|
|
9
|
+
declare a ceiling and stay under it** — and the first slice of its enforcement. This is the
|
|
10
|
+
storage-dimension twin of the existing "No Unbounded Loops" and "Bounded Notification Surface"
|
|
11
|
+
standards: it exists because an agent that runs for months accumulates data in append-only logs and
|
|
12
|
+
databases that only ever grow, and reading one of those multi-MB files all at once freezes the
|
|
13
|
+
single-threaded event loop (the cause of recurring health-check failures and restarts).
|
|
14
|
+
|
|
15
|
+
This increment is non-behavioral — it adds the substrate without changing how any current store
|
|
16
|
+
behaves: a registry that records each store's retention policy, an event-loop-safe segment-rotation
|
|
17
|
+
primitive (rename, never read-and-rewrite), a `JsonlStore` accessor that all JSONL persistence will
|
|
18
|
+
route through, a growth-burst test that proves retention actually bounds a store on disk, and two
|
|
19
|
+
build-time lints that ratchet new violations (a new store must declare a ceiling; a new whole-file
|
|
20
|
+
synchronous read of a large store is rejected) while grandfathering the existing tree.
|
|
21
|
+
|
|
22
|
+
## What to Tell Your User
|
|
23
|
+
|
|
24
|
+
Nothing changes today in how your agent behaves. This lays the foundation that stops your agent's
|
|
25
|
+
on-disk data from growing without bound and stops the kind of large-file reads that briefly freeze
|
|
26
|
+
it. The actual trimming of existing oversized files, and the one-time cleanup of data that has
|
|
27
|
+
already accumulated, come in the next increments (the cleanup is operator-gated — it asks before
|
|
28
|
+
deleting any history).
|
|
29
|
+
|
|
30
|
+
## Summary of New Capabilities
|
|
31
|
+
|
|
32
|
+
- A retention policy field on every store in the state-coherence registry (10 stores declared,
|
|
33
|
+
75 legacy categories tracked as a shrink-only backlog).
|
|
34
|
+
- `maybeRotateJsonlSegment` (event-loop-safe rename rotation) + `JsonlStore` (the accessor funnel
|
|
35
|
+
with an amortized size-check) in `src/core/storage/`.
|
|
36
|
+
- Two CI lints — `lint-store-retention-declared` (a new store must declare a retention policy) and
|
|
37
|
+
`lint-no-wholefile-sync-read` (no new literal whole-file synchronous read of a streamed store) —
|
|
38
|
+
both wired into `npm run lint` over a frozen baseline (only NEW violations fail).
|
|
39
|
+
- A growth-burst invariant test that floods a registered store and asserts the on-disk footprint
|
|
40
|
+
stays bounded by the declared policy (and that compliance-hold audit stores never drop their
|
|
41
|
+
oldest segment).
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Side-Effects Review — Bounded Accumulation Increment 1 (registry + accessor + 2 lints + rotation fix)
|
|
2
|
+
|
|
3
|
+
**Slug:** `bounded-accumulation-increment-1` · **Tier:** 2 (spec-driven; converged + operator-approved)
|
|
4
|
+
**Spec:** `docs/specs/bounded-accumulation-standard.md` (review-convergence 2026-06-21, approved)
|
|
5
|
+
|
|
6
|
+
## Summary of the change
|
|
7
|
+
|
|
8
|
+
The non-behavioral first increment of the Bounded Accumulation standard. It adds the ENFORCEMENT
|
|
9
|
+
substrate without changing any existing store's runtime behavior:
|
|
10
|
+
- `src/utils/jsonl-rotation.ts`: adds `maybeRotateJsonlSegment` — event-loop-safe segment rotation
|
|
11
|
+
(rename active → numbered segment, fresh active, unlink-oldest; O(1), NO whole-file read). The
|
|
12
|
+
existing `maybeRotateJsonl` (read-filter-rewrite) is left intact but marked non-conformant.
|
|
13
|
+
- `src/core/storage/JsonlStore.ts`: the registered accessor funnel — append + a cached-byte-counter
|
|
14
|
+
throttle so even the O(1) statSync isn't paid per append.
|
|
15
|
+
- `src/data/state-coherence-registry.json`: extends 10 in-scope entries with a `retention` policy
|
|
16
|
+
(A/C/sqlite/R classes). 75 legacy categories are frozen as the retrofit backlog.
|
|
17
|
+
- `scripts/lint-store-retention-declared.js` (Lint 1) + `scripts/lint-no-wholefile-sync-read.js`
|
|
18
|
+
(Lint 2), wired into `npm run lint`, each with a frozen baseline.
|
|
19
|
+
|
|
20
|
+
## Decision-point inventory
|
|
21
|
+
|
|
22
|
+
All decisions are frozen in the spec §6 (D1–D9): 32MB/4-segment A-class default, 30-day token-ledger,
|
|
23
|
+
compliance-hold (archive-never-delete) for audit logs, R-class carve-out for replication journals,
|
|
24
|
+
set-monotonic ratchet, 8MB streamed threshold. No new decision is introduced at the callsite.
|
|
25
|
+
|
|
26
|
+
## 1. Over-block (false positive)
|
|
27
|
+
|
|
28
|
+
- Lint 1 fails a registry entry with no `retention` that is not in the frozen baseline. False positive
|
|
29
|
+
only if a genuinely-new store is legitimately unbounded — which the standard forbids by definition;
|
|
30
|
+
the author declares a policy (incl. `boundedByResolution`). Cannot mis-fire on a legacy store (all
|
|
31
|
+
75 are baselined).
|
|
32
|
+
- Lint 2 fails a NEW literal whole-file read of a streamed store. Honest coverage limit: only literal
|
|
33
|
+
paths; dynamic paths are not flagged (no false positives, some false negatives — closed by the
|
|
34
|
+
accessor funnel in Increment 2).
|
|
35
|
+
|
|
36
|
+
## 2. Under-block (false negative)
|
|
37
|
+
|
|
38
|
+
Lint 2's dynamic-path gap (e.g. `readFileSync(this.logPath)`) is the known limit — documented in the
|
|
39
|
+
lint header and in spec §3c. The runtime growth-burst test is the complete check for stores it
|
|
40
|
+
exercises; the accessor funnel (Increment 2) closes the read-side gap. Not silently hidden.
|
|
41
|
+
|
|
42
|
+
## 4. Signal vs authority
|
|
43
|
+
|
|
44
|
+
The lints are deterministic STRUCTURAL checks (a path/registry match), permitted to block like the
|
|
45
|
+
existing funnel lints (lint-state-registry, lint-no-blocking-process-scans). The SEMANTIC judgment
|
|
46
|
+
("is this store actually actionable / which class?") stays with the author + reviewer, never the
|
|
47
|
+
regex. Ships ratchet-over-frozen-baseline (warn-then-ratchet per Maturation Path): the current tree
|
|
48
|
+
is grandfathered; only NEW violations fail.
|
|
49
|
+
|
|
50
|
+
## 5. Interactions
|
|
51
|
+
|
|
52
|
+
The new `maybeRotateJsonlSegment` is additive — no existing caller of `maybeRotateJsonl` is changed,
|
|
53
|
+
so no store's rotation behavior changes in Increment 1 (verified: the existing
|
|
54
|
+
`tests/unit/jsonl-rotation.test.ts` 11 tests still pass). The registry gains fields read only by the
|
|
55
|
+
two new lints. No existing reader of the registry breaks (additive JSON fields).
|
|
56
|
+
|
|
57
|
+
## 6. External surfaces
|
|
58
|
+
|
|
59
|
+
None. No new HTTP route, no config-contract change, no messaging, no persistence-schema change. The
|
|
60
|
+
two `scripts/*.js` lints run only at lint/CI time.
|
|
61
|
+
|
|
62
|
+
## 6b. Operator-surface quality
|
|
63
|
+
|
|
64
|
+
N/A — no operator/dashboard/approval surface is touched.
|
|
65
|
+
|
|
66
|
+
## Framework generality
|
|
67
|
+
|
|
68
|
+
N/A — `JsonlStore`/the lints are framework-agnostic infrastructure; not part of the session
|
|
69
|
+
launch/inject abstraction. Works identically regardless of the agent's framework.
|
|
70
|
+
|
|
71
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
72
|
+
|
|
73
|
+
The registry's `retention.access` and the new `coherenceScope` usage declare per-store posture.
|
|
74
|
+
Retention is MACHINE-LOCAL by design (each machine's `.instar/` stores differ in size; the rotator
|
|
75
|
+
runs per-machine). The replication substrate (`state/coherence-journal/**`) is explicitly R-class
|
|
76
|
+
(carved OUT of generic rotation — naive truncation would resurrect deleted PII); it is bounded inside
|
|
77
|
+
its own protocol (the seq-floor prune guard is Increment 2). No replicated state is introduced.
|
|
78
|
+
|
|
79
|
+
## 8. Rollback cost
|
|
80
|
+
|
|
81
|
+
Trivial. The lints ship over a frozen baseline (no current-tree failures); removing them from
|
|
82
|
+
`package.json` reverts the enforcement. `maybeRotateJsonlSegment` + `JsonlStore` are new, unused by
|
|
83
|
+
any runtime store yet (Increment 2 wires them), so reverting is a clean delete. The registry fields
|
|
84
|
+
are additive data. Nothing to migrate back.
|
|
85
|
+
|
|
86
|
+
## Evidence pointers
|
|
87
|
+
|
|
88
|
+
- `tests/unit/jsonl-segment-rotation.test.ts` (7): rename-not-rewrite, prune-beyond-keep, archive
|
|
89
|
+
never-prunes, JsonlStore amortized check + bounded-under-flood.
|
|
90
|
+
- `tests/integration/store-growth-burst-invariant.test.ts` (3): A-class on-disk bounded under a
|
|
91
|
+
20k-entry flood; C-class never drops its oldest segment; every registry A-class entry has an
|
|
92
|
+
enforceable maxBytes.
|
|
93
|
+
- `tests/unit/bounded-accumulation-lints.test.ts` (7): both lints, both sides of each boundary
|
|
94
|
+
(pass current / fail new-unretentioned / pass new-with-retention / fail grown-baseline; Lint 2
|
|
95
|
+
pass-clean / fail-new-literal-read / pass-grandfathered).
|
|
96
|
+
- Existing `tests/unit/jsonl-rotation.test.ts` (11) still green → no regression to the old path.
|
|
97
|
+
- `npm run lint` green (the two new lints in the chain).
|
|
98
|
+
|
|
99
|
+
## Conclusion
|
|
100
|
+
|
|
101
|
+
A non-behavioral enforcement substrate: the registry declares ceilings, two lints ratchet new
|
|
102
|
+
violations, and the event-loop-safe rotation primitive + accessor are ready for Increment 2 to wire
|
|
103
|
+
to real stores. Grandfathers the current tree, fails only NEW violations, trivially reversible. Ship.
|