@ngockhoale/ukit 2.3.10 → 2.3.11

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 CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.3.11 - 2026-09-11
6
+
7
+ Freeze-sweep wave 11 (post-2.3.10): the two deferred liveness candidates fixed via TDD, plus
8
+ a self-refresh downgrade trap repaired during verification. All template/live mirrors are
9
+ byte-synced.
10
+
11
+ **P1 — SessionStart hook could hang the session forever.** `handoff-resume.sh`'s "advisory,
12
+ always exit 0" rested on `|| true`, which never fires when the embedded node process wedges
13
+ (hung runtime import, unresolved lock wait, stalled file read) — session start stalls mid-run
14
+ with no visible reason. A 3s wall-clock watchdog (`UKIT_HOOK_DEADLINE_MS`, env-tunable) now
15
+ exits 0 unconditionally, and the RUN.md read is async so sync I/O cannot block the timer.
16
+
17
+ **P1 — router state writes could tear; route-audit merge lost entries.** All five
18
+ `skill-router-state.json` / `route-audit.json` writes were direct `writeFileSync`: a torn or
19
+ empty state parses as `{}` downstream and completion-gate.sh then releases a premature Stop
20
+ with no message. Writes now go through tmp+rename (`writeJsonFileAtomic`), and the audit
21
+ append runs under the standard PID + owner-token lock (5s bounded, fail-open). 16 concurrent
22
+ router runs previously kept only 13/16 audit entries; now 16/16.
23
+
24
+ **Incident — post-release self-refresh downgraded live hooks.** Running `ukit install` from a
25
+ global CLI older than the published release rewrote six live hook mirrors with pre-release
26
+ content, resurrecting just-fixed lock-theft bugs in the development workspace. Recovered by
27
+ upgrading the global CLI and re-installing; the deferred guard candidate is a stale-CLI
28
+ warning in `ukit install`.
29
+
5
30
  ## 2.3.10 - 2026-09-11
6
31
 
7
32
  Freeze-sweep waves 7-10 (post-2.3.9): twelve verified mid-run-freeze defects fixed, each locked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.10",
3
+ "version": "2.3.11",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,14 @@ const fs = require('fs');
27
27
  const path = require('path');
28
28
  const { pathToFileURL } = require('url');
29
29
 
30
+ // Wall-clock watchdog. This hook is ADVISORY ONLY, so any hang — a wedged runtime
31
+ // import, a lock wait that never resolves, a stalled filesystem read — must end in a
32
+ // clean exit 0 instead of stalling session start mid-run with no visible reason.
33
+ // Every read below is async on purpose: sync I/O would block the loop and this timer
34
+ // could never fire. Keep it that way.
35
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
36
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
37
+
30
38
  const payload = (() => {
31
39
  try {
32
40
  const parsed = JSON.parse(process.env.INPUT || '');
@@ -68,7 +76,8 @@ async function emitOrdinaryResume() {
68
76
  (async () => {
69
77
  let text;
70
78
  try {
71
- text = fs.readFileSync(runPath, 'utf8');
79
+ // Async on purpose — a sync read on a stalled mount would block the watchdog timer.
80
+ text = await fs.promises.readFile(runPath, 'utf8');
72
81
  } catch {
73
82
  // No handoff cursor: an ordinary routed-task intent may still be resumable.
74
83
  await emitOrdinaryResume();
@@ -121,6 +121,23 @@ const { pathToFileURL } = require('url');
121
121
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
122
122
  }
123
123
 
124
+ function writeJsonFileAtomic(filePath, text) {
125
+ // tmp+rename: readers see old-or-new, never a torn/half file. A truncated
126
+ // skill-router-state.json parses as `{}` downstream and completion-gate.sh then
127
+ // releases a premature Stop with no message — the mid-run silent-stop class.
128
+ ensureDir(filePath);
129
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
130
+ try {
131
+ fs.writeFileSync(tempPath, text);
132
+ fs.renameSync(tempPath, filePath);
133
+ } catch (error) {
134
+ try {
135
+ fs.rmSync(tempPath, { force: true });
136
+ } catch {}
137
+ throw error;
138
+ }
139
+ }
140
+
124
141
  function defaultRuntimeConfig() {
125
142
  return {
126
143
  memory: {
@@ -2286,45 +2303,124 @@ const { pathToFileURL } = require('url');
2286
2303
  };
2287
2304
  }
2288
2305
 
2289
- function appendRouteAuditEntry(filePath, entry) {
2290
- if (!entry || typeof entry !== 'object') {
2291
- return;
2306
+ // Owner-token lock protocol, protocol-compatible with runtime token-utils withFileLock
2307
+ // and the advisory permission hooks (mkdir-based `<file>.lock` + owner liveness).
2308
+ // route-audit.json is a read-modify-write merge: without the lock, 16 concurrent
2309
+ // router invocations were verified to keep only 13/16 entries (lost updates).
2310
+ function isPidAlive(pid) {
2311
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2312
+ try {
2313
+ process.kill(pid, 0);
2314
+ return true;
2315
+ } catch (error) {
2316
+ return error?.code === 'EPERM';
2292
2317
  }
2318
+ }
2293
2319
 
2294
- let parsed = { entries: [] };
2295
- if (fs.existsSync(filePath)) {
2320
+ function readLockOwner(lockPath) {
2321
+ const owner = readJson(path.join(lockPath, 'owner'), null);
2322
+ return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
2323
+ ? owner
2324
+ : null;
2325
+ }
2326
+
2327
+ async function withStateLock(lockPath, fn) {
2328
+ // Advisory bookkeeping: liveness over strictness — if the lock cannot be acquired
2329
+ // within maxWaitMs the merge runs unlocked (pre-lock behaviour) rather than hanging
2330
+ // or dropping the entry.
2331
+ const staleMs = 10000;
2332
+ const maxWaitMs = 5000;
2333
+ const startedAt = Date.now();
2334
+ const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
2335
+ let held = false;
2336
+ while (!held) {
2296
2337
  try {
2297
- parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
2298
- } catch {
2299
- parsed = { entries: [] };
2338
+ fs.mkdirSync(lockPath);
2339
+ try {
2340
+ fs.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
2341
+ pid: process.pid,
2342
+ token: ownerToken,
2343
+ ts: Date.now(),
2344
+ }));
2345
+ } catch (error) {
2346
+ try {
2347
+ fs.rmSync(lockPath, { recursive: true, force: true });
2348
+ } catch {}
2349
+ throw error;
2350
+ }
2351
+ held = true;
2352
+ } catch (error) {
2353
+ if (!error || error.code !== 'EEXIST') return fn();
2354
+ try {
2355
+ const stat = fs.statSync(lockPath);
2356
+ if (Date.now() - stat.mtimeMs > staleMs) {
2357
+ const owner = readLockOwner(lockPath);
2358
+ if (!owner || !isPidAlive(owner.pid)) {
2359
+ try {
2360
+ fs.rmSync(lockPath, { recursive: true, force: true });
2361
+ continue;
2362
+ } catch {}
2363
+ }
2364
+ }
2365
+ } catch {}
2366
+ if (Date.now() - startedAt > maxWaitMs) break;
2367
+ await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
2368
+ }
2369
+ }
2370
+ try {
2371
+ return await fn();
2372
+ } finally {
2373
+ if (held) {
2374
+ const owner = readLockOwner(lockPath);
2375
+ if (owner?.token === ownerToken) {
2376
+ try {
2377
+ fs.rmSync(lockPath, { recursive: true, force: true });
2378
+ } catch {}
2379
+ }
2300
2380
  }
2301
2381
  }
2382
+ }
2302
2383
 
2303
- const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
2304
- const dedupeKey = stableMachineDigest({
2305
- requestKey: entry.requestKey,
2306
- executionMode: entry.executionMode,
2307
- nextActionType: entry.nextActionType,
2308
- nextMilestone: entry.nextMilestone,
2309
- repeatCount: entry.repeatCount,
2310
- rescueMode: entry.rescueMode,
2311
- });
2312
- const filtered = entries.filter((item) => {
2313
- const itemKey = stableMachineDigest({
2314
- requestKey: item?.requestKey || null,
2315
- executionMode: item?.executionMode || null,
2316
- nextActionType: item?.nextActionType || null,
2317
- nextMilestone: item?.nextMilestone || null,
2318
- repeatCount: item?.repeatCount || null,
2319
- rescueMode: item?.rescueMode || null,
2384
+ async function appendRouteAuditEntry(filePath, entry) {
2385
+ if (!entry || typeof entry !== 'object') {
2386
+ return;
2387
+ }
2388
+
2389
+ await withStateLock(`${filePath}.lock`, () => {
2390
+ let parsed = { entries: [] };
2391
+ if (fs.existsSync(filePath)) {
2392
+ try {
2393
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
2394
+ } catch {
2395
+ parsed = { entries: [] };
2396
+ }
2397
+ }
2398
+
2399
+ const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
2400
+ const dedupeKey = stableMachineDigest({
2401
+ requestKey: entry.requestKey,
2402
+ executionMode: entry.executionMode,
2403
+ nextActionType: entry.nextActionType,
2404
+ nextMilestone: entry.nextMilestone,
2405
+ repeatCount: entry.repeatCount,
2406
+ rescueMode: entry.rescueMode,
2407
+ });
2408
+ const filtered = entries.filter((item) => {
2409
+ const itemKey = stableMachineDigest({
2410
+ requestKey: item?.requestKey || null,
2411
+ executionMode: item?.executionMode || null,
2412
+ nextActionType: item?.nextActionType || null,
2413
+ nextMilestone: item?.nextMilestone || null,
2414
+ repeatCount: item?.repeatCount || null,
2415
+ rescueMode: item?.rescueMode || null,
2416
+ });
2417
+ return itemKey !== dedupeKey;
2320
2418
  });
2321
- return itemKey !== dedupeKey;
2322
- });
2323
2419
 
2324
- ensureDir(filePath);
2325
- fs.writeFileSync(filePath, JSON.stringify({
2326
- entries: [entry, ...filtered].slice(0, 40),
2327
- }));
2420
+ writeJsonFileAtomic(filePath, JSON.stringify({
2421
+ entries: [entry, ...filtered].slice(0, 40),
2422
+ }));
2423
+ });
2328
2424
  }
2329
2425
 
2330
2426
  function shouldIncludePreviousContext({ routingContext = {}, useIndexedContext = true } = {}) {
@@ -2551,7 +2647,7 @@ const { pathToFileURL } = require('url');
2551
2647
  // route: completion-gate.sh reads routeSummary/requestKey from this state to decide
2552
2648
  // whether a Stop is premature. Downgrading to a route-less state here silently disarms
2553
2649
  // the gate mid-task, so carry the previous route forward until a real route replaces it.
2554
- fs.writeFileSync(statePath, JSON.stringify({
2650
+ writeJsonFileAtomic(statePath, JSON.stringify({
2555
2651
  ...(sessionId ? { sessionId } : {}),
2556
2652
  fingerprint,
2557
2653
  ts: now,
@@ -2672,7 +2768,7 @@ const { pathToFileURL } = require('url');
2672
2768
  };
2673
2769
  provisionalState.fingerprint = buildRouteStateFingerprint(provisionalState);
2674
2770
  ensureDir(statePath);
2675
- fs.writeFileSync(statePath, JSON.stringify(provisionalState));
2771
+ writeJsonFileAtomic(statePath, JSON.stringify(provisionalState));
2676
2772
 
2677
2773
  let cacheUtils = null;
2678
2774
  if (fs.existsSync(cacheUtilsPath)) {
@@ -2733,8 +2829,8 @@ const { pathToFileURL } = require('url');
2733
2829
  }
2734
2830
 
2735
2831
  ensureDir(statePath);
2736
- fs.writeFileSync(statePath, JSON.stringify(reusedState));
2737
- appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2832
+ writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
2833
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2738
2834
  state: reusedState,
2739
2835
  }));
2740
2836
 
@@ -2822,8 +2918,8 @@ const { pathToFileURL } = require('url');
2822
2918
  ...(helpers ? { helpers } : {}),
2823
2919
  routeSummary: compactRouteSummary(routeSummary),
2824
2920
  };
2825
- fs.writeFileSync(statePath, JSON.stringify(sharedState));
2826
- appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2921
+ writeJsonFileAtomic(statePath, JSON.stringify(sharedState));
2922
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2827
2923
  routingContext,
2828
2924
  routeSummary,
2829
2925
  state: sharedState,