@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
|
@@ -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
|
-
|
|
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
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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
|
-
|
|
2295
|
-
|
|
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
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
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
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
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
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|