@ngockhoale/ukit 2.6.4 → 2.6.6

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.
@@ -200,6 +200,22 @@ export function maybeSweepTelemetryDir(dir, {
200
200
  return { sampled: true, ...sweepTelemetryDir(dir, options) };
201
201
  }
202
202
 
203
+ // BUG-C23-10: a bare `catch {}` around the sweep swallowed every failure — a
204
+ // persistent EACCES/ENOSPC meant silent non-convergence of the dir bound.
205
+ // Announce the first failure and then every 25th, C22 rate-limit posture.
206
+ let sweepFailureCount = 0;
207
+ export function noteSweepFailure(error) {
208
+ sweepFailureCount += 1;
209
+ if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
210
+ const message =
211
+ `UKit hook-telemetry: latency dir sweep failed (${sweepFailureCount} failure(s) so far) — `
212
+ + `the appended row is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
213
+ try {
214
+ fs.writeSync(1, `${JSON.stringify({ systemMessage: message })}\n`);
215
+ } catch { /* stdout already gone */ }
216
+ return message;
217
+ }
218
+
203
219
  export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
204
220
  try {
205
221
  if (!row || typeof row !== 'object') return false;
@@ -211,7 +227,7 @@ export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
211
227
  fs.appendFileSync(filePath, line, 'utf8');
212
228
  // Sampled bounded dir sweep (BUG-C21-10): advisory — a sweep failure must
213
229
  // never alter the row this call just appended.
214
- try { maybeSweepTelemetryDir(dir); } catch { /* advisory */ }
230
+ try { maybeSweepTelemetryDir(dir); } catch (error) { noteSweepFailure(error); }
215
231
  return true;
216
232
  } catch {
217
233
  // Advisory: an unwritable or corrupt telemetry target must never alter a
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
+ import fsSync from 'node:fs';
3
4
  import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
4
6
  import {
5
7
  buildCompactMachineKey,
6
8
  buildRuntimePaths,
@@ -19,9 +21,42 @@ import { updateCompactPressureFromOutput } from './compact-threshold.mjs';
19
21
  // Hook-context self-deadline (2.4.1 orphan-leak class): the compress-output hook passes
20
22
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
21
23
  // budget. Non-hook usage never sets it and is never self-killed.
24
+ // SPEC §8 degrade channel: stdout is the only surface the hook consumer reliably
25
+ // shows the user; stderr is model-facing. writeSync(1) because a same-tick
26
+ // process.exit() can discard a buffered async write on a pipe.
27
+ function emitSystemMessage(text) {
28
+ try {
29
+ fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
30
+ } catch { /* stdout already gone */ }
31
+ }
32
+
33
+ // BUG-C23-10: bare `.catch(() => {})` swallowed every sweep failure — a
34
+ // persistent EACCES/ENOSPC on .ukit meant silent non-convergence of the tee/
35
+ // backlog bound (live dir observed at 2,417 files). Announce the first failure
36
+ // and then every 25th, C22 rate-limit posture.
37
+ let sweepFailureCount = 0;
38
+ export function noteSweepFailure(error) {
39
+ sweepFailureCount += 1;
40
+ if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
41
+ const message =
42
+ `UKit output-compression: tee cache sweep failed (${sweepFailureCount} failure(s) so far) — `
43
+ + `the preserved output is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
44
+ emitSystemMessage(message);
45
+ return message;
46
+ }
47
+
22
48
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
23
49
  if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
24
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
50
+ setTimeout(() => {
51
+ // BUG-C23-04: deadline expiry abandons configured compression work — a
52
+ // degrade, and §8(ii) requires it be announced before the exit.
53
+ emitSystemMessage(
54
+ `UKit output-compression exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
55
+ + 'tool output passed through uncompressed this call (likely stalled filesystem). '
56
+ + 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
57
+ );
58
+ process.exit(0);
59
+ }, HOOK_DEADLINE_MS).unref();
25
60
  }
26
61
 
27
62
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
@@ -422,7 +457,9 @@ export async function sweepTeeCache(dir, {
422
457
  // under-count and `overflow` was always 0. Expected drain: each sampled sweep
423
458
  // removes up to maxRemovals, so a backlog of B files converges after
424
459
  // ~ceil(B/maxRemovals) sweeps (~16x that many writes at the default 1/16
425
- // sampling); the 7d age cutoff drains the rest even without new writes.
460
+ // sampling); the 7d age cutoff drains the remainder only when new writes keep
461
+ // arriving — a stopped writer converges at ~ceil(B/maxRemovals) sweeps and no
462
+ // faster, since sweeps are sampled off the write path (BUG-C23-10, FR-006).
426
463
  const eligibleNames = [];
427
464
  for (const dirent of dirents) {
428
465
  if (dirent.isFile()) eligibleNames.push(dirent.name);
@@ -498,8 +535,9 @@ async function persistRawOutput(projectRoot, {
498
535
  await fs.mkdir(teeCacheDir, { recursive: true });
499
536
  await fs.writeFile(absolutePath, rawOutputText, 'utf8');
500
537
  // Bounded, sampled tee/ prune (BUG-C21-07): advisory only — a sweep failure
501
- // must never lose the preserved output this call just wrote.
502
- await maybeSweepTeeCache(teeCacheDir).catch(() => {});
538
+ // must never lose the preserved output this call just wrote. BUG-C23-10: a
539
+ // persistent failure (EACCES/ENOSPC) still surfaces as a bounded degrade.
540
+ await maybeSweepTeeCache(teeCacheDir).catch(noteSweepFailure);
503
541
 
504
542
  return {
505
543
  rawSaved: true,
@@ -1486,7 +1524,19 @@ async function main() {
1486
1524
  process.stdout.write(result.summary);
1487
1525
  }
1488
1526
 
1489
- main().catch((error) => {
1490
- console.error('[UKit] Failed to compress tool output:', error?.message || String(error));
1491
- process.exitCode = 1;
1492
- });
1527
+ // BUG-C23-13: only run main() on direct CLI invocation — an unguarded bottom
1528
+ // call executes on ANY import(), reading the importer's stdin and exiting its
1529
+ // process. Guard shape matches compact-threshold.mjs.
1530
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
1531
+ if (invokedPath && pathToFileURL(invokedPath).href === import.meta.url) {
1532
+ main().catch((error) => {
1533
+ console.error('[UKit] Failed to compress tool output:', error?.message || String(error));
1534
+ // BUG-C23-01: stderr alone is invisible to the hook consumer — the §8
1535
+ // announce contract requires a stdout systemMessage on the crash path too.
1536
+ emitSystemMessage(
1537
+ 'UKit output-compression failed before it could compress this tool output; '
1538
+ + 'the raw output was passed through uncompressed. Run `ukit doctor` if this repeats.',
1539
+ );
1540
+ process.exitCode = 1;
1541
+ });
1542
+ }
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
+ import fsSync from 'node:fs';
3
4
  import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
4
6
  import {
5
7
  buildCompactMachineKey,
6
8
  buildRuntimePaths,
@@ -22,9 +24,27 @@ import {
22
24
  // Hook-context self-deadline (2.4.1 orphan-leak class): the reinject-context hook passes
23
25
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
24
26
  // budget. Non-hook usage never sets it and is never self-killed.
27
+ // SPEC §8 degrade channel: stdout is the only surface the hook consumer reliably
28
+ // shows the user; stderr is model-facing. writeSync(1) because a same-tick
29
+ // process.exit() can discard a buffered async write on a pipe.
30
+ function emitSystemMessage(text) {
31
+ try {
32
+ fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
33
+ } catch { /* stdout already gone */ }
34
+ }
35
+
25
36
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
26
37
  if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
27
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
38
+ setTimeout(() => {
39
+ // BUG-C23-04: deadline expiry abandons the configured context reinjection —
40
+ // a degrade, and §8(ii) requires it be announced before the exit.
41
+ emitSystemMessage(
42
+ `UKit reinject-context exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
43
+ + 'post-compaction context reinjection skipped this pass (likely stalled filesystem). '
44
+ + 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
45
+ );
46
+ process.exit(0);
47
+ }, HOOK_DEADLINE_MS).unref();
28
48
  }
29
49
 
30
50
  const STATE_TTL_MS = 30 * 60 * 1000;
@@ -1047,7 +1067,19 @@ async function buildPreviousContextLines(projectRoot, state, config) {
1047
1067
  return [`- Previous context: ${rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | ')}`];
1048
1068
  }
1049
1069
 
1050
- main().catch((error) => {
1051
- console.error('[UKit] Failed to build compact reinject context:', error?.message || String(error));
1052
- process.exitCode = 1;
1053
- });
1070
+ // BUG-C23-13: only run main() on direct CLI invocation — an unguarded bottom
1071
+ // call executes on ANY import(), reading the importer's stdin and exiting its
1072
+ // process. Guard shape matches compact-threshold.mjs.
1073
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
1074
+ if (invokedPath && pathToFileURL(invokedPath).href === import.meta.url) {
1075
+ main().catch((error) => {
1076
+ console.error('[UKit] Failed to build compact reinject context:', error?.message || String(error));
1077
+ // BUG-C23-01: stderr alone is invisible to the hook consumer — the §8
1078
+ // announce contract requires a stdout systemMessage on the crash path too.
1079
+ emitSystemMessage(
1080
+ 'UKit reinject-context failed before it could rebuild the compact context block; '
1081
+ + 'the session continues without reinjected context this pass. Run `ukit doctor` if this repeats.',
1082
+ );
1083
+ process.exitCode = 1;
1084
+ });
1085
+ }
@@ -561,7 +561,11 @@ async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupe
561
561
  return true;
562
562
  }
563
563
  await fs.mkdir(path.dirname(statePath), { recursive: true });
564
- await fs.writeFile(statePath, `${JSON.stringify({ lastStop: { key: sessionKey, ts: now } }, null, 1)}\n`, 'utf8');
564
+ // BUG-C23-08: spread `current` — a bare { lastStop } write erased the
565
+ // `handoff` stall-streak slot written by bumpHandoffStallCount, which ran
566
+ // AFTER this dedupe write each invocation → count always read 1 and the
567
+ // FR-004 liveness breaker could never fire.
568
+ await fs.writeFile(statePath, `${JSON.stringify({ ...current, lastStop: { key: sessionKey, ts: now } }, null, 1)}\n`, 'utf8');
565
569
  return false;
566
570
  });
567
571
  return outcome.ok ? outcome.value === true : false;
@@ -80,7 +80,7 @@ For clearly non-code specialist lanes (docs-only, status, task queue), skip the
80
80
  - If verification is needed, prefer `node .codex/ukit/index/verify-context.mjs ...`.
81
81
  - **Treat helper commands as internal orchestration.** Run them yourself when needed; never ask end users to run them.
82
82
 
83
- ## UKit v1.5.0 Shared Runtime
83
+ ## UKit Shared Runtime
84
84
 
85
85
  - Shared runtime state lives in `.ukit/storage/`.
86
86
  - Treat `.ukit/storage/config.json` as the source of compact, token-pipeline, router, memory, validation, and Safe Patch guardrail toggles.
@@ -1,6 +1,6 @@
1
1
  # UKit Shared Runtime
2
2
 
3
- This folder stores shared UKit runtime state for v1.5.0 features.
3
+ This folder stores shared UKit runtime state.
4
4
 
5
5
  - `storage/config.json` — runtime feature flags and defaults
6
6
  - `storage/cache/` — prompt-cache, compact history, compact pressure state, output summaries, and preserved raw tool outputs under `storage/cache/tee/`