@ngockhoale/ukit 2.6.3 → 2.6.5
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/CHANGELOG.md +123 -0
- package/bin/ukit +30 -10
- package/package.json +1 -1
- package/scripts/bench/parallel-agents.mjs +42 -4
- package/scripts/release/verify-release.mjs +43 -6
- package/src/core/applyPlan.js +65 -0
- package/src/core/compact/threshold.js +33 -30
- package/src/core/fileOps.js +9 -2
- package/src/core/memory/store.js +10 -3
- package/src/core/runInstallPipeline.js +10 -1
- package/src/core/runtimeConfig.js +2 -0
- package/src/core/status.js +1 -1
- package/templates/.claude/hooks/auto-allow-bash.sh +29 -20
- package/templates/.claude/hooks/auto-prune-bash.sh +81 -30
- package/templates/.claude/hooks/completion-gate.sh +42 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +26 -4
- package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
- package/templates/.claude/hooks/handoff-resume.sh +32 -17
- package/templates/.claude/hooks/post-edit-verify.sh +1 -0
- package/templates/.claude/hooks/pre-edit-backup.sh +1 -0
- package/templates/.claude/hooks/reset-compact-pressure.sh +39 -22
- package/templates/.claude/hooks/skill-router.sh +180 -27
- package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
- package/templates/.claude/hooks/task-watchdog.sh +30 -5
- package/templates/.claude/hooks/verification-guard.sh +17 -1
- package/templates/.claude/hooks/vision-router.sh +14 -0
- package/templates/.claude/settings.json +4 -4
- package/templates/.claude/ukit/index/extract-image.mjs +49 -0
- package/templates/.claude/ukit/index/post-edit-verify.mjs +33 -1
- package/templates/.claude/ukit/index/stale-spec-check.mjs +32 -1
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +70 -40
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +37 -6
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +24 -4
- package/templates/.claude/ukit/runtime/output-compression.mjs +73 -12
- package/templates/.claude/ukit/runtime/reinject-context.mjs +39 -7
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +8 -1
- package/templates/.claude/ukit/runtime/token-utils.mjs +9 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +72 -4
- package/templates/ukit/storage/config.json +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,129 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.6.5 - 2026-09-19
|
|
6
|
+
|
|
7
|
+
Stability release — third full audit pass on the 2.6.4 base (cycle C23). 15
|
|
8
|
+
confirmed hang / freeze / silent-idle / silent-hook defects fixed with root
|
|
9
|
+
causes (full register: `docs/AI_HANDOFF/BUGS-2.6.5.md`). Headline: the
|
|
10
|
+
cycle-lockout mechanism observed live — stall-breaker streak erased on every
|
|
11
|
+
Stop (BUG-C23-08) — is fixed, plus every blocking/degrading hook exit now
|
|
12
|
+
announces on stdout per the §8 contract.
|
|
13
|
+
|
|
14
|
+
- **Handoff stall-breaker streak erased every Stop (BUG-C23-08)**: the
|
|
15
|
+
stop-coordinator dedupe write dropped the `handoff` streak slot, so a wedged
|
|
16
|
+
RUN.md blocked forever; the merge now preserves the slot.
|
|
17
|
+
- **Silent non-zero hook failures (BUG-C23-01, -03, -07)**: stderr-only crash
|
|
18
|
+
reasons in `output-compression`/`reinject-context`/`post-edit-verify`/
|
|
19
|
+
`stale-spec-check` + safe-patch exit-2 blocks + skill-router/vision-router/
|
|
20
|
+
task-watchdog crash paths now emit a stdout `systemMessage` before exit —
|
|
21
|
+
stderr reaches the model, not reliably the user.
|
|
22
|
+
- **Lost/silent deadline emissions (BUG-C23-02, -04)**: compact-threshold's
|
|
23
|
+
same-tick `stdout.write` + `process.exit(0)` discarded the announce on pipes
|
|
24
|
+
(now `writeSync(1)`); deadline-expiry `exit 0` in four called `.mjs` modules
|
|
25
|
+
and `handoff-resume.sh` now announces the abandoned work.
|
|
26
|
+
- **Fail-open/crash-posture defects (BUG-C23-05, -06)**: context-hardcap-gate's
|
|
27
|
+
internal-error catch now announces its fail-open degrade on stdout;
|
|
28
|
+
completion-gate's stop-coordinator spawn is deadline-bounded with forced
|
|
29
|
+
kill routed to the crash-streak path.
|
|
30
|
+
- **Silent data loss + hot-path waste (BUG-C23-09, -10, -13, -15)**:
|
|
31
|
+
compact-threshold double capacity read collapsed to one; sweep failures
|
|
32
|
+
announce (bounded) instead of `.catch(() => {})` swallow; unguarded `main()`
|
|
33
|
+
no longer runs on `import()`; route-audit merges stage to an append-only
|
|
34
|
+
sidecar with a locked + exit fold instead of silently dropping entries.
|
|
35
|
+
- **Unbounded growth + unbounded stalls (BUG-C23-11, -12, -14)**: memory
|
|
36
|
+
project archives capped; `permission-audit.log` rotates inside the prune
|
|
37
|
+
pass; `bin/ukit` runCli got an overall deadline watchdog.
|
|
38
|
+
|
|
39
|
+
Regression gate: 138 files / 2360 tests green, liveness 120/120, §8 sweep
|
|
40
|
+
clean, template↔installed parity byte-identical, async-caller census clean,
|
|
41
|
+
all 25 C22 verdicts re-confirmed.
|
|
42
|
+
|
|
43
|
+
## 2.6.4 - 2026-09-19
|
|
44
|
+
|
|
45
|
+
Stability release — second full audit pass on the 2.6.3 base (cycle C22). 25
|
|
46
|
+
confirmed hang / freeze / silent-idle / silent-hook defects fixed with root causes
|
|
47
|
+
(full register: `docs/AI_HANDOFF/BUGS-2.6.4.md`), including the two carried C21
|
|
48
|
+
residual lineages: **R1** (sync fs inside the hardcap-gate deadline → BUG-C22-01)
|
|
49
|
+
and **R2** (sync fs inside handoff-resume deadline → BUG-C22-02).
|
|
50
|
+
|
|
51
|
+
- **Stalled-mount freeze in the compact/hardcap chain (R1, BUG-C22-01)**: the entire
|
|
52
|
+
compact-threshold fs path (`readNegotiatedCapacity`, `buildCompactThresholds`,
|
|
53
|
+
`resolveCompactPhase`, `buildCompactPressureState`, pressure registration, compact
|
|
54
|
+
plan) converted to async `fsp`; every caller in hooks, `src/core/compact`, and
|
|
55
|
+
`src/core/status.js` now awaits; `isDirectRun` in `execution-ledger.mjs` /
|
|
56
|
+
`stop-coordinator.mjs` guards `realpathSync` behind a basename check so module
|
|
57
|
+
import no longer does sync fs on the deadline path.
|
|
58
|
+
- **handoff-resume sync fs inside its deadline (R2, BUG-C22-02)** plus the same
|
|
59
|
+
sync-fs-under-deadline class in `reset-compact-pressure` (BUG-C22-03),
|
|
60
|
+
`auto-prune-bash` (BUG-C22-04) and `auto-allow-bash` (BUG-C22-06) — sync calls
|
|
61
|
+
inside armed unref'd hook deadlines parked the event loop so the deadline itself
|
|
62
|
+
could never fire.
|
|
63
|
+
- **Stop-gate crash → block-forever (BUG-C22-05)**: a `completion-gate` coordinator
|
|
64
|
+
crash used to produce an unconditional `decision:block` on every subsequent stop.
|
|
65
|
+
New crash-streak breaker (`.ukit/storage/cache/completion-gate-crash.streak`,
|
|
66
|
+
byte-append counter, cap `handoff.fullstack.stopGateMaxCrashStreaks` default 3)
|
|
67
|
+
fails closed up to the cap then releases *loud*; omp bridge got the identical
|
|
68
|
+
try/catch + streak parity.
|
|
69
|
+
- **Silent `exit 0` / `exit(0)` clusters now announce (§8 contract)**: missing-script
|
|
70
|
+
silent exits in 4 hooks (BUG-C22-07), deadline-expiry silent exits on 3 gated
|
|
71
|
+
hooks plus the hardcap-gate sites (BUG-C22-08), and `verification-guard`
|
|
72
|
+
stderr-only catch-all (BUG-C22-09) all emit the fail-open §8 JSON + stderr
|
|
73
|
+
announcement instead of going quiet.
|
|
74
|
+
- **Unbounded spawned children get self-deadlines**: `compact-threshold.mjs`
|
|
75
|
+
`runCli` (BUG-C22-15, `UKIT_COMPACT_CLI_DEADLINE_MS` default 4s — bounds the
|
|
76
|
+
skill-router spawn too), `verify-release.mjs` spawn watchdog + tree-kill
|
|
77
|
+
(BUG-C22-20), `parallel-agents.mjs` per-level `UKIT_BENCH_LEVEL_TIMEOUT_MS`
|
|
78
|
+
30 min deadline with detached process-group SIGKILL (BUG-C22-21).
|
|
79
|
+
- **`withFileLock` busy-spin on persistent stat errors (BUG-C22-16 runtime twin /
|
|
80
|
+
BUG-C22-17 core twin)**: a stat error inside the retry loop skipped both the
|
|
81
|
+
`maxWaitMs` check and the backoff sleep → tight spin for the process lifetime.
|
|
82
|
+
The budget check now lives inside the catch path in both copies.
|
|
83
|
+
- **Sweep caps that could never fire (BUG-C22-10/11/12/13)**: tee sweep,
|
|
84
|
+
hook-telemetry sweep, exec-ledger sweep and omp hook-errors sweep computed the
|
|
85
|
+
overflow from the scanned *sample* instead of the eligible count, so configured
|
|
86
|
+
max-file caps (e.g. `TEE_MAX_FILES=500`) were unreachable.
|
|
87
|
+
- **Vision receipts eaten / orphaned (BUG-C22-14/18)**: the vision marker-dir sweep
|
|
88
|
+
deleted `analyzed-*` receipts early → vision gate re-fired on every image; legacy
|
|
89
|
+
top-level `vision/analyzed-*.json` orphans had no pruner at all. Both prunes are
|
|
90
|
+
now TTL-coordinated with `isAlreadyAnalyzed`.
|
|
91
|
+
- **Unbounded `.bak` backup writer (BUG-C22-19)**: `.claude/ukit/.ukit/backups/`
|
|
92
|
+
had no pruner; `pruneOldBackups` is now bounded.
|
|
93
|
+
- **Registered timeout < internal worst case (BUG-C22-22/23/24/25)**:
|
|
94
|
+
`auto-allow-bash` 12→15s (internal worst 13s), `skill-router` 8→20s (worst
|
|
95
|
+
~18s), `stale-spec-guard` 8→11s, and omp chain child budget 4000→12000ms with
|
|
96
|
+
ceiling 16000→52000ms — a hook can no longer be killed mid-work by a budget
|
|
97
|
+
smaller than its own declared worst case.
|
|
98
|
+
|
|
99
|
+
Full fixed-bug enumeration (register `docs/AI_HANDOFF/BUGS-2.6.4.md`, all rows
|
|
100
|
+
`verified`):
|
|
101
|
+
|
|
102
|
+
- BUG-C22-01 (R1) — sync fs inside hardcap-gate deadline (compact-threshold + execution-ledger)
|
|
103
|
+
- BUG-C22-02 (R2) — sync fs inside handoff-resume deadline
|
|
104
|
+
- BUG-C22-03 — reset-compact-pressure: swallow-all + sync fs inside deadline
|
|
105
|
+
- BUG-C22-04 — auto-prune-bash: invisible prune failure + sync fs inside deadline
|
|
106
|
+
- BUG-C22-05 — completion-gate: persistent coordinator crash → block-forever (crash-streak breaker)
|
|
107
|
+
- BUG-C22-06 — auto-allow-bash: sync fs inside deadline
|
|
108
|
+
- BUG-C22-07 — missing-script silent `exit 0` cluster (4 hooks)
|
|
109
|
+
- BUG-C22-08 — deadline-expiry silent `exit(0)` on gated hooks (+ hardcap sites)
|
|
110
|
+
- BUG-C22-09 — verification-guard catch-all degrade on stderr only
|
|
111
|
+
- BUG-C22-10 — tee sweep count cap unreachable (`TEE_MAX_FILES=500` never enforced)
|
|
112
|
+
- BUG-C22-11 — hook-telemetry sweep count cap unreachable
|
|
113
|
+
- BUG-C22-12 — exec-ledger sweep count cap unreachable
|
|
114
|
+
- BUG-C22-13 — omp hook-errors sweep count cap unreachable
|
|
115
|
+
- BUG-C22-14 — vision marker-dir sweep deletes `analyzed-*` receipts → vision gate re-fires
|
|
116
|
+
- BUG-C22-15 — skill-router spawns `node compact-threshold.mjs prompt` unbounded (CLI self-deadline)
|
|
117
|
+
- BUG-C22-16 — `withFileLock` busy-spin, runtime twin (token-utils.mjs)
|
|
118
|
+
- BUG-C22-17 — `withFileLock` busy-spin, core twin (fileOps.js)
|
|
119
|
+
- BUG-C22-18 — legacy top-level `vision/analyzed-*.json` orphans never pruned
|
|
120
|
+
- BUG-C22-19 — `.claude/ukit/.ukit/backups/` `.bak` writer had no pruner
|
|
121
|
+
- BUG-C22-20 — `verify-release.mjs` spawns with no timeout (watchdog + tree-kill)
|
|
122
|
+
- BUG-C22-21 — `parallel-agents.mjs` N children, no timeout, partial-settle hang
|
|
123
|
+
- BUG-C22-22 — auto-allow-bash registered timeout < internal worst case (12→15s)
|
|
124
|
+
- BUG-C22-23 — skill-router registered timeout < internal worst case (8→20s)
|
|
125
|
+
- BUG-C22-24 — stale-spec-guard registered timeout zero margin (8→11s)
|
|
126
|
+
- BUG-C22-25 — omp chain child budget < every staged hook's worst case (4000→12000ms, ceiling 16000→52000ms)
|
|
127
|
+
|
|
5
128
|
## 2.6.3 - 2026-09-18
|
|
6
129
|
|
|
7
130
|
- **Informational side-questions no longer self-block at the Stop gate.** A no-route
|
package/bin/ukit
CHANGED
|
@@ -11,17 +11,37 @@ async function readPackageVersion(packageRoot) {
|
|
|
11
11
|
return packageJson.version;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const DEFAULT_CLI_DEADLINE_MS = 120_000;
|
|
15
|
+
|
|
16
|
+
function resolveCliDeadlineMs() {
|
|
17
|
+
const raw = Number(process.env.UKIT_CLI_DEADLINE_MS);
|
|
18
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CLI_DEADLINE_MS;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
async function main() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
// Overall wall-clock watchdog: individual fs/readline awaits inside runCli are
|
|
23
|
+
// unbounded, so a wedged .ukit/external mount would park the CLI forever.
|
|
24
|
+
const deadlineMs = resolveCliDeadlineMs();
|
|
25
|
+
const watchdog = setTimeout(() => {
|
|
26
|
+
console.error(`[UKit] timed out after ${deadlineMs}ms — likely wedged filesystem; retry`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}, deadlineMs);
|
|
29
|
+
watchdog.unref();
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
33
|
+
const packageRoot = path.resolve(path.dirname(currentFile), '..');
|
|
34
|
+
const packageVersion = await readPackageVersion(packageRoot);
|
|
35
|
+
|
|
36
|
+
await runCli({
|
|
37
|
+
argv: process.argv.slice(2),
|
|
38
|
+
packageRoot,
|
|
39
|
+
projectRoot: process.cwd(),
|
|
40
|
+
packageVersion,
|
|
41
|
+
});
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(watchdog);
|
|
44
|
+
}
|
|
25
45
|
}
|
|
26
46
|
|
|
27
47
|
main().catch((error) => {
|
package/package.json
CHANGED
|
@@ -18,6 +18,14 @@ import { spawn } from 'node:child_process';
|
|
|
18
18
|
|
|
19
19
|
const SLOWDOWN_LIMIT = 2.0;
|
|
20
20
|
|
|
21
|
+
// BUG-C22-21: a level must never wait forever — one wedged `shell:true` child
|
|
22
|
+
// used to leave `settled < n` permanently and hang the whole benchmark
|
|
23
|
+
// mid-level. A per-level deadline SIGKILLs unsettled children (their whole
|
|
24
|
+
// process group — detached:true puts each `sh -c` in its own group so
|
|
25
|
+
// grandchildren die too) and resolves the level marked timedOut.
|
|
26
|
+
// UKIT_BENCH_LEVEL_TIMEOUT_MS overrides the budget for tests.
|
|
27
|
+
const LEVEL_TIMEOUT_MS = Number(process.env.UKIT_BENCH_LEVEL_TIMEOUT_MS) || 30 * 60 * 1000; // 30 min
|
|
28
|
+
|
|
21
29
|
function parseArgs(argv) {
|
|
22
30
|
const opts = { levels: '1,3,5,10', cmd: 'yarn test:release-core', out: '.cache/bench/parallel-agents.json' };
|
|
23
31
|
for (let i = 0; i < argv.length; i += 2) {
|
|
@@ -46,19 +54,43 @@ function runLevel(n, cmd) {
|
|
|
46
54
|
let highWater = 0;
|
|
47
55
|
let failures = 0;
|
|
48
56
|
let settled = 0;
|
|
57
|
+
let timedOut = false;
|
|
58
|
+
let finished = false;
|
|
49
59
|
const children = [];
|
|
60
|
+
// BUG-C22-21: per-level deadline — kill stragglers and resolve marked-failed.
|
|
61
|
+
const deadline = setTimeout(() => {
|
|
62
|
+
timedOut = true;
|
|
63
|
+
for (const child of children) {
|
|
64
|
+
if (!child.ukitSettled) {
|
|
65
|
+
child.ukitSettled = true;
|
|
66
|
+
settled += 1;
|
|
67
|
+
failures += 1;
|
|
68
|
+
try {
|
|
69
|
+
process.kill(-child.pid, 'SIGKILL'); // kill the whole process tree
|
|
70
|
+
} catch {
|
|
71
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
maybeFinish();
|
|
76
|
+
}, LEVEL_TIMEOUT_MS);
|
|
77
|
+
deadline.unref();
|
|
50
78
|
for (let i = 0; i < n; i += 1) {
|
|
51
|
-
const child = spawn(cmd, { shell: true, stdio: 'ignore' });
|
|
79
|
+
const child = spawn(cmd, { shell: true, stdio: 'ignore', detached: true });
|
|
52
80
|
live += 1;
|
|
53
81
|
highWater = Math.max(highWater, live);
|
|
54
82
|
children.push(child);
|
|
55
83
|
child.on('error', () => {
|
|
84
|
+
if (child.ukitSettled) return;
|
|
85
|
+
child.ukitSettled = true;
|
|
56
86
|
live -= 1;
|
|
57
87
|
settled += 1;
|
|
58
88
|
failures += 1;
|
|
59
89
|
maybeFinish();
|
|
60
90
|
});
|
|
61
91
|
child.on('exit', (code) => {
|
|
92
|
+
if (child.ukitSettled) return;
|
|
93
|
+
child.ukitSettled = true;
|
|
62
94
|
live -= 1;
|
|
63
95
|
settled += 1;
|
|
64
96
|
if (code !== 0) failures += 1;
|
|
@@ -66,8 +98,14 @@ function runLevel(n, cmd) {
|
|
|
66
98
|
});
|
|
67
99
|
}
|
|
68
100
|
function maybeFinish() {
|
|
101
|
+
if (finished) return;
|
|
69
102
|
if (settled === n) {
|
|
70
|
-
|
|
103
|
+
finished = true;
|
|
104
|
+
clearTimeout(deadline);
|
|
105
|
+
if (timedOut) {
|
|
106
|
+
console.error(`[bench] level n=${n}: deadline ${LEVEL_TIMEOUT_MS}ms exceeded — unsettled children killed and counted as failures`);
|
|
107
|
+
}
|
|
108
|
+
resolve({ n, wallClockMs: Date.now() - t0, failures, concurrencyHighWaterMark: highWater, timedOut });
|
|
71
109
|
}
|
|
72
110
|
}
|
|
73
111
|
});
|
|
@@ -110,8 +148,8 @@ async function main() {
|
|
|
110
148
|
|
|
111
149
|
const result = {
|
|
112
150
|
cpuCount,
|
|
113
|
-
levels: rows.map(({ n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark }) => ({
|
|
114
|
-
n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark,
|
|
151
|
+
levels: rows.map(({ n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark, timedOut }) => ({
|
|
152
|
+
n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark, timedOut,
|
|
115
153
|
})),
|
|
116
154
|
recommended,
|
|
117
155
|
measurementConditions: { loadAvgStart, loadAvgEnd, startedAt, finishedAt, cpuCount },
|
|
@@ -6,6 +6,14 @@ import path from 'node:path';
|
|
|
6
6
|
const rootDir = process.cwd();
|
|
7
7
|
const npmCacheDir = path.join(os.tmpdir(), 'ukit-npm-cache');
|
|
8
8
|
|
|
9
|
+
// BUG-C22-20: every spawned child carries an explicit timeout + killSignal so a
|
|
10
|
+
// wedged npm/yarn child (network partition, hung credential helper) fails the
|
|
11
|
+
// step instead of hanging the verifier forever. Per-step budgets: release test
|
|
12
|
+
// steps are the slowest (full suite); the registry read is a single network call.
|
|
13
|
+
// UKIT_VERIFY_STEP_TIMEOUT_MS overrides the step budget for tests.
|
|
14
|
+
const STEP_TIMEOUT_MS = Number(process.env.UKIT_VERIFY_STEP_TIMEOUT_MS) || 10 * 60 * 1000; // 10 min
|
|
15
|
+
const REGISTRY_TIMEOUT_MS = Number(process.env.UKIT_VERIFY_REGISTRY_TIMEOUT_MS) || 60 * 1000; // 1 min
|
|
16
|
+
|
|
9
17
|
// Opt-in post-publish registry-parity check (Release Policy invariant: npm and git always hold
|
|
10
18
|
// the same latest version). Only meaningful AFTER `npm publish` — before publish the registry is
|
|
11
19
|
// behind by definition — so it is gated behind --post-publish and never runs by default.
|
|
@@ -67,32 +75,61 @@ for (const step of steps) {
|
|
|
67
75
|
|
|
68
76
|
console.log('\n[release:verify] All release checks passed.');
|
|
69
77
|
|
|
78
|
+
// BUG-C22-20: bounded spawn — an armed watchdog SIGKILLs the child's whole
|
|
79
|
+
// process group on expiry (detached: true puts the child in its own group so
|
|
80
|
+
// grandchildren like `sh -c "sleep …"` can't keep our stdio pipes open).
|
|
81
|
+
function spawnBounded(command, args, opts, timeoutMs) {
|
|
82
|
+
const child = spawn(command, args, { ...opts, detached: true });
|
|
83
|
+
child.ukitTimedOut = false;
|
|
84
|
+
const watchdog = setTimeout(() => {
|
|
85
|
+
child.ukitTimedOut = true;
|
|
86
|
+
try {
|
|
87
|
+
process.kill(-child.pid, 'SIGKILL'); // kill the whole tree
|
|
88
|
+
} catch {
|
|
89
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
90
|
+
}
|
|
91
|
+
}, timeoutMs);
|
|
92
|
+
watchdog.unref();
|
|
93
|
+
child.on('exit', () => clearTimeout(watchdog));
|
|
94
|
+
return child;
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
function runStep({ command, args, env }) {
|
|
71
98
|
return new Promise((resolve) => {
|
|
72
|
-
const child =
|
|
99
|
+
const child = spawnBounded(command, args, {
|
|
73
100
|
cwd: rootDir,
|
|
74
101
|
env,
|
|
75
102
|
stdio: 'inherit',
|
|
76
|
-
});
|
|
103
|
+
}, STEP_TIMEOUT_MS);
|
|
77
104
|
|
|
78
|
-
child.on('
|
|
105
|
+
child.on('exit', (code) => {
|
|
106
|
+
if (child.ukitTimedOut) {
|
|
107
|
+
console.error(`[release:verify] step timed out after ${STEP_TIMEOUT_MS}ms — child killed (check for a wedged ${command}/network)`);
|
|
108
|
+
}
|
|
109
|
+
resolve(code ?? 1);
|
|
110
|
+
});
|
|
79
111
|
child.on('error', () => resolve(1));
|
|
80
112
|
});
|
|
81
113
|
}
|
|
82
114
|
|
|
83
115
|
function readRegistryVersion() {
|
|
84
116
|
return new Promise((resolve) => {
|
|
85
|
-
const child =
|
|
117
|
+
const child = spawnBounded('npm', ['view', '@ngockhoale/ukit', 'version'], {
|
|
86
118
|
cwd: rootDir,
|
|
87
119
|
env: { ...process.env, npm_config_cache: npmCacheDir },
|
|
88
120
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
89
|
-
});
|
|
121
|
+
}, REGISTRY_TIMEOUT_MS);
|
|
90
122
|
|
|
91
123
|
let stdout = '';
|
|
92
124
|
child.stdout.on('data', (chunk) => {
|
|
93
125
|
stdout += chunk;
|
|
94
126
|
});
|
|
95
|
-
child.on('
|
|
127
|
+
child.on('exit', () => {
|
|
128
|
+
if (child.ukitTimedOut) {
|
|
129
|
+
console.error(`[release:verify] registry version check timed out after ${REGISTRY_TIMEOUT_MS}ms — child killed (check npm/network)`);
|
|
130
|
+
resolve('<unavailable>');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
96
133
|
const version = stdout.trim();
|
|
97
134
|
resolve(version.length > 0 ? version : '<unavailable>');
|
|
98
135
|
});
|
package/src/core/applyPlan.js
CHANGED
|
@@ -15,6 +15,71 @@ export function isTccProtectedPath(filePath) {
|
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// BUG-C22-19: every `overwrite_with_backup` write adds a timestamped `.bak` under
|
|
19
|
+
// backupRoot and nothing ever removed them (239 live files at census, still
|
|
20
|
+
// growing). Bound the dir: keep at most BACKUP_MAX_FILES of the newest `.bak`
|
|
21
|
+
// files and nothing older than BACKUP_MAX_AGE_MS. The listing itself is capped
|
|
22
|
+
// (BACKUP_SCAN_LIMIT) so a huge pre-existing dir can't stall install on a slow
|
|
23
|
+
// external mount; a backlog beyond the cap drains over successive installs.
|
|
24
|
+
export const BACKUP_MAX_FILES = 100;
|
|
25
|
+
export const BACKUP_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
26
|
+
export const BACKUP_SCAN_LIMIT = 5000;
|
|
27
|
+
|
|
28
|
+
async function collectBackupFiles(dir, out, budget) {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
32
|
+
} catch {
|
|
33
|
+
return; // unreadable/missing dir — nothing to prune here
|
|
34
|
+
}
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (out.length >= budget) return;
|
|
37
|
+
const entryPath = path.join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
await collectBackupFiles(entryPath, out, budget);
|
|
40
|
+
} else if (entry.isFile() && entry.name.endsWith('.bak')) {
|
|
41
|
+
let mtimeMs = 0;
|
|
42
|
+
try {
|
|
43
|
+
({ mtimeMs } = await fs.stat(entryPath));
|
|
44
|
+
} catch {
|
|
45
|
+
// Vanished or unstat-able — keep mtimeMs 0 so it is treated as oldest.
|
|
46
|
+
}
|
|
47
|
+
out.push({ path: entryPath, mtimeMs });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function pruneOldBackups(backupRoot, {
|
|
53
|
+
maxFiles = BACKUP_MAX_FILES,
|
|
54
|
+
maxAgeMs = BACKUP_MAX_AGE_MS,
|
|
55
|
+
scanLimit = BACKUP_SCAN_LIMIT,
|
|
56
|
+
now = Date.now(),
|
|
57
|
+
} = {}) {
|
|
58
|
+
const files = [];
|
|
59
|
+
await collectBackupFiles(backupRoot, files, scanLimit);
|
|
60
|
+
|
|
61
|
+
// Newest first — survivors are the newest maxFiles that are also within the age bound.
|
|
62
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
63
|
+
let removed = 0;
|
|
64
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
65
|
+
const file = files[i];
|
|
66
|
+
const overCount = i >= maxFiles;
|
|
67
|
+
const overAge = now - file.mtimeMs > maxAgeMs;
|
|
68
|
+
if (!overCount && !overAge) continue;
|
|
69
|
+
try {
|
|
70
|
+
await fs.rm(file.path, { force: true }); // force:true — ENOENT under a concurrent reader is fine
|
|
71
|
+
removed += 1;
|
|
72
|
+
} catch {
|
|
73
|
+
// Advisory prune — a stubborn file is retried next install, never a hard failure.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (removed > 0) {
|
|
78
|
+
console.log(`[UKit] Pruned ${removed} old backup file(s) under ${backupRoot} (kept ≤${maxFiles}, ≤${Math.round(maxAgeMs / 86400000)}d old).`);
|
|
79
|
+
}
|
|
80
|
+
return { scanned: files.length, removed };
|
|
81
|
+
}
|
|
82
|
+
|
|
18
83
|
export async function applyDiffResults(diffResults, { backupRoot, projectRoot } = {}) {
|
|
19
84
|
const writes = [];
|
|
20
85
|
let skippedUpdates = 0;
|
|
@@ -445,8 +445,10 @@ function computeEstimatedTotalTokens({
|
|
|
445
445
|
|
|
446
446
|
// The record the advisory guard published for this project, or null when nothing has been
|
|
447
447
|
// negotiated yet (fresh install, non-project caller, tests).
|
|
448
|
-
|
|
449
|
-
|
|
448
|
+
// BUG-C22-01: async on purpose — readContextCapacityRecord now uses fsp so a
|
|
449
|
+
// stalled .ukit mount cannot park the event loop inside an armed hook deadline.
|
|
450
|
+
async function readNegotiatedCapacity(config = {}) {
|
|
451
|
+
const record = await readContextCapacityRecord(config?.projectRoot);
|
|
450
452
|
if (!record) return null;
|
|
451
453
|
return resolveContextCapTokens({
|
|
452
454
|
env: process.env,
|
|
@@ -455,7 +457,7 @@ function readNegotiatedCapacity(config = {}) {
|
|
|
455
457
|
});
|
|
456
458
|
}
|
|
457
459
|
|
|
458
|
-
export function buildCompactThresholds(config = {}) {
|
|
460
|
+
export async function buildCompactThresholds(config = {}) {
|
|
459
461
|
const shippedHardCap = positiveInteger(
|
|
460
462
|
config?.compact?.hardCapTokens,
|
|
461
463
|
loadShippedCompactBudget().hardCapTokens,
|
|
@@ -463,7 +465,7 @@ export function buildCompactThresholds(config = {}) {
|
|
|
463
465
|
const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
|
|
464
466
|
// Negotiated capacity (H22): the guard publishes what it derived from the live route and
|
|
465
467
|
// this shared path consumes it; without a record the shipped tuning applies unchanged.
|
|
466
|
-
const negotiated = readNegotiatedCapacity(config);
|
|
468
|
+
const negotiated = await readNegotiatedCapacity(config);
|
|
467
469
|
const hardCapTokens = negotiated
|
|
468
470
|
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
469
471
|
: shippedHardCap;
|
|
@@ -485,7 +487,7 @@ export function buildCompactThresholds(config = {}) {
|
|
|
485
487
|
};
|
|
486
488
|
}
|
|
487
489
|
|
|
488
|
-
function buildReleaseThresholds(thresholds
|
|
490
|
+
function buildReleaseThresholds(thresholds) {
|
|
489
491
|
const softReleaseThreshold = Math.max(
|
|
490
492
|
Math.min(thresholds.softThreshold, Math.round(thresholds.softThreshold * SOFT_RELEASE_RATIO)),
|
|
491
493
|
1,
|
|
@@ -501,11 +503,12 @@ function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
|
|
|
501
503
|
};
|
|
502
504
|
}
|
|
503
505
|
|
|
504
|
-
export function resolveCompactPhase(
|
|
506
|
+
export async function resolveCompactPhase(
|
|
505
507
|
estimatedTotalTokens,
|
|
506
|
-
thresholds =
|
|
508
|
+
thresholds = null,
|
|
507
509
|
previousState = {},
|
|
508
510
|
) {
|
|
511
|
+
thresholds = thresholds ?? await buildCompactThresholds();
|
|
509
512
|
const previousPhase = normalizePhase(previousState?.phase ?? previousState);
|
|
510
513
|
const releaseThresholds = buildReleaseThresholds(thresholds);
|
|
511
514
|
if (estimatedTotalTokens >= thresholds.hardThreshold) {
|
|
@@ -527,7 +530,7 @@ function resolvePhaseCooldownMs(phase) {
|
|
|
527
530
|
return PHASE_COOLDOWN_MS[normalizePhase(phase)] ?? 0;
|
|
528
531
|
}
|
|
529
532
|
|
|
530
|
-
function resolvePlanDeltaThreshold(phase, thresholds
|
|
533
|
+
function resolvePlanDeltaThreshold(phase, thresholds) {
|
|
531
534
|
return normalizePhase(phase) === 'hard'
|
|
532
535
|
? Math.max(60, Math.round(thresholds.hardThreshold * 0.14))
|
|
533
536
|
: Math.max(40, Math.round(thresholds.softThreshold * 0.12));
|
|
@@ -660,9 +663,9 @@ function projectPressureDocument(sessions) {
|
|
|
660
663
|
};
|
|
661
664
|
}
|
|
662
665
|
|
|
663
|
-
export function buildCompactPressureState(rawState = null, config = {}) {
|
|
666
|
+
export async function buildCompactPressureState(rawState = null, config = {}) {
|
|
664
667
|
rawState = resolvePressureRecord(rawState, config);
|
|
665
|
-
const thresholds = buildCompactThresholds(config);
|
|
668
|
+
const thresholds = await buildCompactThresholds(config);
|
|
666
669
|
const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
|
|
667
670
|
const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
|
|
668
671
|
if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
|
|
@@ -711,7 +714,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
711
714
|
estimatedContextTokens,
|
|
712
715
|
sessionTokens,
|
|
713
716
|
estimatedTotalTokens,
|
|
714
|
-
phase: resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
|
|
717
|
+
phase: await resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
|
|
715
718
|
taskMode,
|
|
716
719
|
cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
|
|
717
720
|
routingContext: rawState?.routingContext && typeof rawState.routingContext === 'object'
|
|
@@ -725,14 +728,14 @@ export function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
725
728
|
};
|
|
726
729
|
}
|
|
727
730
|
|
|
728
|
-
export function registerPromptPressure(state, {
|
|
731
|
+
export async function registerPromptPressure(state, {
|
|
729
732
|
promptText = '',
|
|
730
733
|
routingContext = {},
|
|
731
734
|
previousContext = null,
|
|
732
735
|
routeSummary = null,
|
|
733
736
|
timestamp = Date.now(),
|
|
734
737
|
} = {}, config = {}) {
|
|
735
|
-
const current = buildCompactPressureState(state, config);
|
|
738
|
+
const current = await buildCompactPressureState(state, config);
|
|
736
739
|
const normalizedPrompt = String(promptText || routingContext?.lastExplicitUserPromptText || '').trim();
|
|
737
740
|
if (!normalizedPrompt) {
|
|
738
741
|
return current;
|
|
@@ -753,7 +756,7 @@ export function registerPromptPressure(state, {
|
|
|
753
756
|
})
|
|
754
757
|
: current.recentPrompts;
|
|
755
758
|
|
|
756
|
-
return buildCompactPressureState({
|
|
759
|
+
return await buildCompactPressureState({
|
|
757
760
|
...current,
|
|
758
761
|
updatedAt: timestamp,
|
|
759
762
|
routingContext,
|
|
@@ -773,7 +776,7 @@ export function registerPromptPressure(state, {
|
|
|
773
776
|
}, config);
|
|
774
777
|
}
|
|
775
778
|
|
|
776
|
-
export function registerOutputPressure(state, {
|
|
779
|
+
export async function registerOutputPressure(state, {
|
|
777
780
|
command = '',
|
|
778
781
|
profile = 'generic',
|
|
779
782
|
summary = '',
|
|
@@ -783,7 +786,7 @@ export function registerOutputPressure(state, {
|
|
|
783
786
|
exitCode = null,
|
|
784
787
|
timestamp = Date.now(),
|
|
785
788
|
} = {}, config = {}) {
|
|
786
|
-
const current = buildCompactPressureState(state, config);
|
|
789
|
+
const current = await buildCompactPressureState(state, config);
|
|
787
790
|
const normalizedSummary = summarizeOutputText(summary, command);
|
|
788
791
|
if (!normalizedSummary) {
|
|
789
792
|
return current;
|
|
@@ -805,7 +808,7 @@ export function registerOutputPressure(state, {
|
|
|
805
808
|
keyBuilder: (candidate) => `${candidate.command ?? ''}|${candidate.summary ?? ''}`,
|
|
806
809
|
});
|
|
807
810
|
|
|
808
|
-
return buildCompactPressureState({
|
|
811
|
+
return await buildCompactPressureState({
|
|
809
812
|
...current,
|
|
810
813
|
updatedAt: timestamp,
|
|
811
814
|
recentOutputs,
|
|
@@ -819,8 +822,8 @@ export function registerOutputPressure(state, {
|
|
|
819
822
|
}, config);
|
|
820
823
|
}
|
|
821
824
|
|
|
822
|
-
export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
823
|
-
const current = buildCompactPressureState(state, config);
|
|
825
|
+
export async function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
826
|
+
const current = await buildCompactPressureState(state, config);
|
|
824
827
|
if (!plan?.active) {
|
|
825
828
|
return current;
|
|
826
829
|
}
|
|
@@ -836,7 +839,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
836
839
|
lines: plan.lines,
|
|
837
840
|
});
|
|
838
841
|
if (current.latestPlan?.fingerprint === fingerprint) {
|
|
839
|
-
return buildCompactPressureState({
|
|
842
|
+
return await buildCompactPressureState({
|
|
840
843
|
...current,
|
|
841
844
|
updatedAt: now,
|
|
842
845
|
latestPlan: {
|
|
@@ -854,7 +857,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
854
857
|
const materialChange = isMaterialThresholdPlanChange(current.latestPlan, plan, thresholds);
|
|
855
858
|
|
|
856
859
|
if (withinCooldown && !materialChange) {
|
|
857
|
-
return buildCompactPressureState({
|
|
860
|
+
return await buildCompactPressureState({
|
|
858
861
|
...current,
|
|
859
862
|
updatedAt: now,
|
|
860
863
|
latestPlan: {
|
|
@@ -889,7 +892,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
889
892
|
? Math.min(current.sessionTokens, decayTarget)
|
|
890
893
|
: current.sessionTokens;
|
|
891
894
|
|
|
892
|
-
return buildCompactPressureState({
|
|
895
|
+
return await buildCompactPressureState({
|
|
893
896
|
...current,
|
|
894
897
|
updatedAt: now,
|
|
895
898
|
sessionTokens: decayedSessionTokens,
|
|
@@ -910,7 +913,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
910
913
|
}, config);
|
|
911
914
|
}
|
|
912
915
|
|
|
913
|
-
export function buildThresholdCompactPlan({
|
|
916
|
+
export async function buildThresholdCompactPlan({
|
|
914
917
|
state,
|
|
915
918
|
config = {},
|
|
916
919
|
routeState = {},
|
|
@@ -918,8 +921,8 @@ export function buildThresholdCompactPlan({
|
|
|
918
921
|
dynamicLines = [],
|
|
919
922
|
maxTokens = 280,
|
|
920
923
|
} = {}) {
|
|
921
|
-
const normalizedState = buildCompactPressureState(state, config);
|
|
922
|
-
const thresholds = buildCompactThresholds(config);
|
|
924
|
+
const normalizedState = await buildCompactPressureState(state, config);
|
|
925
|
+
const thresholds = await buildCompactThresholds(config);
|
|
923
926
|
const phase = normalizedState.phase;
|
|
924
927
|
if (phase === 'monitor') {
|
|
925
928
|
return {
|
|
@@ -1042,7 +1045,7 @@ export function buildThresholdCompactPlan({
|
|
|
1042
1045
|
|
|
1043
1046
|
export async function readCompactPressureState(projectRoot, config = {}) {
|
|
1044
1047
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1045
|
-
return buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
|
|
1048
|
+
return await buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
|
|
1046
1049
|
}
|
|
1047
1050
|
|
|
1048
1051
|
// All compact-pressure mutations share one lock on the state file: without it,
|
|
@@ -1055,9 +1058,9 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
|
1055
1058
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1056
1059
|
const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
|
|
1057
1060
|
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1058
|
-
const current = buildCompactPressureState(record, config);
|
|
1059
|
-
const next = mutator(current);
|
|
1060
|
-
const normalized = buildCompactPressureState(next, config);
|
|
1061
|
+
const current = await buildCompactPressureState(record, config);
|
|
1062
|
+
const next = await mutator(current);
|
|
1063
|
+
const normalized = await buildCompactPressureState(next, config);
|
|
1061
1064
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1062
1065
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1063
1066
|
return normalized;
|
|
@@ -1069,7 +1072,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
|
|
|
1069
1072
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1070
1073
|
const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
|
|
1071
1074
|
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1072
|
-
const normalized = buildCompactPressureState(state, config);
|
|
1075
|
+
const normalized = await buildCompactPressureState(state, config);
|
|
1073
1076
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1074
1077
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1075
1078
|
return normalized;
|
package/src/core/fileOps.js
CHANGED
|
@@ -255,8 +255,15 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
|
|
|
255
255
|
continue; // the slot is free now — retry immediately
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
|
-
} catch {
|
|
259
|
-
|
|
258
|
+
} catch (statError) {
|
|
259
|
+
// BUG-C22-17: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
|
|
260
|
+
// mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
|
|
261
|
+
// EEXIST) must not busy-spin — a bare `continue` skipped both the
|
|
262
|
+
// maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
|
|
263
|
+
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
264
|
+
if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
|
|
265
|
+
await sleep(lockBackoffDelayMs());
|
|
266
|
+
continue;
|
|
260
267
|
}
|
|
261
268
|
|
|
262
269
|
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|