@ngockhoale/ukit 2.3.9 → 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 +87 -0
- package/package.json +1 -1
- package/src/core/fileOps.js +61 -7
- package/src/index/taskRouting.js +35 -2
- package/templates/.claude/hooks/auto-allow-bash.sh +44 -7
- package/templates/.claude/hooks/auto-prune-bash.sh +44 -7
- package/templates/.claude/hooks/context-hardcap-gate.sh +18 -5
- package/templates/.claude/hooks/handoff-resume.sh +10 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +47 -7
- package/templates/.claude/hooks/skill-router.sh +213 -44
- package/templates/.claude/hooks/verification-guard.sh +55 -20
- package/templates/.claude/ukit/index/route-task.mjs +36 -3
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +50 -4
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +339 -62
- package/templates/.claude/ukit/runtime/token-utils.mjs +69 -6
- package/templates/.omp/hooks/pre/ukit-bridge.js +68 -16
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,93 @@
|
|
|
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
|
+
|
|
30
|
+
## 2.3.10 - 2026-09-11
|
|
31
|
+
|
|
32
|
+
Freeze-sweep waves 7-10 (post-2.3.9): twelve verified mid-run-freeze defects fixed, each locked
|
|
33
|
+
by a regression reproducing the failure first. All affected template/live mirrors are byte-synced.
|
|
34
|
+
|
|
35
|
+
**P1 — continuation budget reset on every route re-key.** The router re-keys `requestKey` per
|
|
36
|
+
tool call (the target file is part of the key), but the Stop-gate continuation budget was keyed
|
|
37
|
+
on `requestKey` — the count reset to 0 on every re-key, the cap was unreachable, and omp looped
|
|
38
|
+
forever (no `stop_hook_active` valve). Budget identity is now the logical prompt hash
|
|
39
|
+
(`promptKey`) everywhere, and omp gained `stop_hook_active` parity via `noteStopProgress`.
|
|
40
|
+
|
|
41
|
+
**P1 — parallel execution receipts crashed and raced.** Same-process concurrent receipt writers
|
|
42
|
+
shared one temp path and crashed with ENOENT mid-hook; unlocked read-modify-write also dropped
|
|
43
|
+
receipts, and a missing/foreign route state could rebuild a session ledger from blank, erasing
|
|
44
|
+
banked evidence. Temp paths are unique, the whole receipt write runs under `withFileLock`, and
|
|
45
|
+
a missing route no longer blanks the ledger.
|
|
46
|
+
|
|
47
|
+
**P1 — ledger lock waits exceeded the hook child budget.** `withFileLock` waited up to 5s but
|
|
48
|
+
the hook chain kills children at 4s, so a contended lock got the recording hook killed and omp's
|
|
49
|
+
fail-closed Edit|Write chain then blocked the edit. Ledger lock waits now cap at 2.5s and fail
|
|
50
|
+
open — a lost receipt beats a killed hook.
|
|
51
|
+
|
|
52
|
+
**P1 — vibecode had no exit when verification kept failing identically.** Two adjacent identical
|
|
53
|
+
failed verifications (no edit between) now mint a visible `verification-loop` blocker; vibecode
|
|
54
|
+
additionally escapes after the same command fails 4× across edits.
|
|
55
|
+
|
|
56
|
+
**P1 — clean map-impact Stop loop.** A completed analysis-only `map-impact` route still demanded
|
|
57
|
+
write + verification evidence. A narrow visible release valve permits a completed impact read
|
|
58
|
+
with no mutation attempt and no failed verification; incomplete analysis and partial edits stay
|
|
59
|
+
gated.
|
|
60
|
+
|
|
61
|
+
**P1 — omp hook child timeouts falsely blocked edits.** A 4s-killed `protect-files` gate was
|
|
62
|
+
treated as its fail-closed verdict, freezing all edits on a slow machine. Timeouts now fail open
|
|
63
|
+
as infrastructure failures with a visible warning; `block-dangerous.sh` stays fail-closed. An
|
|
64
|
+
unknown mutation-shaped omp tool now normalizes to `Edit` and runs the normal safety chain
|
|
65
|
+
instead of emitting an impossible "add host mapping" block.
|
|
66
|
+
|
|
67
|
+
**P1 — hard-cap grace slots crossed session boundaries.** Global grace slots let one session
|
|
68
|
+
spend or reset another session's landing allowance, stranding it mid-edit. Slots and advisory
|
|
69
|
+
state are now session-keyed.
|
|
70
|
+
|
|
71
|
+
**P2 — transcript fallback rescanned up to 4MB per mutation.** A persisted, session-bound
|
|
72
|
+
2-second boundary-probe cache coalesces mutation bursts to one scan; `skill-router.sh` also
|
|
73
|
+
skips its redundant prompt-only Node spawn on tool payloads.
|
|
74
|
+
|
|
75
|
+
**P1 — lock stale-reclaim could steal a live owner.** The shared runtime lock used mtime-only
|
|
76
|
+
reclaim: a slow-but-alive holder was deleted after 10s, then deleted its successor's lock in
|
|
77
|
+
`finally` — lost receipts re-triggered completion debt. All lock implementations (runtime
|
|
78
|
+
mirror, verification guard, auto-allow, auto-prune, compact-pressure reset) now stamp
|
|
79
|
+
PID + random owner tokens, reclaim only provably-dead owners, and release only their own token.
|
|
80
|
+
|
|
81
|
+
**P3 — stale verification-guard enforcement env var froze runs.** An inherited
|
|
82
|
+
`UKIT_VERIFICATION_GUARD_ENFORCE=1` turned an advisory test-order recommendation into an
|
|
83
|
+
exit-2 block. The guard is now exclusively advisory; real safety gates keep enforcement.
|
|
84
|
+
|
|
85
|
+
Also in wave 7: piped/redirected verification (`yarn test 2>&1 | tail -6`) counts as targeted,
|
|
86
|
+
and delivery-only requests ("push this to git") route `informational` with no fabricated
|
|
87
|
+
mutation debt in all three routing copies.
|
|
88
|
+
|
|
89
|
+
Full suite: 81 files, 1,439 tests green (plus wave 9-10 focused regressions). No npm tag
|
|
90
|
+
policy change; artifact unpacked ceiling raised once to 5,785,000 with dated rationale.
|
|
91
|
+
|
|
5
92
|
## 2.3.9 - 2026-09-10
|
|
6
93
|
|
|
7
94
|
Freeze-sweep wave 5: fixes for completion-gate loops, worker-agent dead ends, and malformed
|
package/package.json
CHANGED
package/src/core/fileOps.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
@@ -142,17 +143,46 @@ function sleep(ms) {
|
|
|
142
143
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
function isPidAlive(pid) {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(pid, 0);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// EPERM: the process exists but belongs to another user — still alive.
|
|
152
|
+
return error?.code === 'EPERM';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readLockOwner(lockPath) {
|
|
157
|
+
try {
|
|
158
|
+
const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
|
|
159
|
+
const pid = Number(raw?.pid);
|
|
160
|
+
return Number.isInteger(pid) && pid > 0
|
|
161
|
+
? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
|
|
162
|
+
: null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Same-pid holders are parallel async flows whose liveness a pid probe cannot prove.
|
|
169
|
+
const inProcessLockHolders = new Map();
|
|
170
|
+
|
|
145
171
|
/**
|
|
146
172
|
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
147
173
|
* (hook invocations run as separate node processes) and across concurrent async
|
|
148
174
|
* flows in one process (parallel subagents). The lock is a directory created next
|
|
149
175
|
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
150
|
-
*
|
|
176
|
+
* Ownership is recorded in an `owner` file inside the lock dir: stale reclaim first
|
|
177
|
+
* proves the recorded holder is gone (dead pid, or no in-process holder for our own
|
|
178
|
+
* pid — no owner file keeps legacy mtime-only recovery). Release only removes a dir
|
|
179
|
+
* this acquisition still owns, so a reclaimed-then-reacquired lock is never deleted
|
|
180
|
+
* out from under its successor.
|
|
151
181
|
* Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
|
|
152
182
|
* the callback runs anyway (the pre-lock behaviour) — these state files are
|
|
153
183
|
* advisory caches, and losing an update beats freezing a hook mid-flight.
|
|
154
|
-
*
|
|
155
|
-
*
|
|
184
|
+
* Protocol-compatible with the runtime token-utils.mjs lock (same `<file>.lock`
|
|
185
|
+
* path and owner-file format), so CLI and hook processes serialize against each other.
|
|
156
186
|
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
157
187
|
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
158
188
|
* @returns {Promise<*>} whatever fn resolves with
|
|
@@ -160,24 +190,44 @@ function sleep(ms) {
|
|
|
160
190
|
export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
161
191
|
const lockPath = `${filePath}.lock`;
|
|
162
192
|
const startedAt = Date.now();
|
|
193
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
163
194
|
let locked = false;
|
|
195
|
+
let ownerStamped = false;
|
|
164
196
|
|
|
165
197
|
while (!locked) {
|
|
166
198
|
try {
|
|
167
199
|
await ensureDir(path.dirname(lockPath));
|
|
168
200
|
await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
|
|
169
201
|
locked = true;
|
|
202
|
+
inProcessLockHolders.set(lockPath, ownerToken);
|
|
203
|
+
try {
|
|
204
|
+
await fs.writeFile(
|
|
205
|
+
path.join(lockPath, 'owner'),
|
|
206
|
+
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
|
|
207
|
+
'utf8',
|
|
208
|
+
);
|
|
209
|
+
ownerStamped = true;
|
|
210
|
+
} catch {
|
|
211
|
+
ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
|
|
212
|
+
}
|
|
170
213
|
break;
|
|
171
214
|
} catch (error) {
|
|
172
215
|
if (error?.code !== 'EEXIST') throw error;
|
|
173
216
|
}
|
|
174
217
|
|
|
175
|
-
// Someone holds the lock. Reclaim it when
|
|
218
|
+
// Someone holds the lock. Reclaim it only when the holder is provably gone.
|
|
176
219
|
try {
|
|
177
220
|
const stat = await fs.stat(lockPath);
|
|
178
221
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
179
|
-
await
|
|
180
|
-
|
|
222
|
+
const owner = await readLockOwner(lockPath);
|
|
223
|
+
const liveInProcess = inProcessLockHolders.has(lockPath);
|
|
224
|
+
const reclaimable = !owner || owner.pid === process.pid
|
|
225
|
+
? !liveInProcess
|
|
226
|
+
: !isPidAlive(owner.pid);
|
|
227
|
+
if (reclaimable) {
|
|
228
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
229
|
+
continue; // the slot is free now — retry immediately
|
|
230
|
+
}
|
|
181
231
|
}
|
|
182
232
|
} catch {
|
|
183
233
|
continue; // lock vanished between mkdir and stat — retry immediately
|
|
@@ -192,7 +242,11 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
|
|
|
192
242
|
} finally {
|
|
193
243
|
if (locked) {
|
|
194
244
|
try {
|
|
195
|
-
await
|
|
245
|
+
const current = ownerStamped ? await readLockOwner(lockPath) : null;
|
|
246
|
+
if (current && current.token === ownerToken) {
|
|
247
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
|
|
196
250
|
} catch {
|
|
197
251
|
// best-effort release; a stale lock is reclaimed by the next waiter
|
|
198
252
|
}
|
package/src/index/taskRouting.js
CHANGED
|
@@ -78,6 +78,7 @@ export async function deriveTaskRoute({
|
|
|
78
78
|
intentMode,
|
|
79
79
|
executionScores,
|
|
80
80
|
executionCandidates,
|
|
81
|
+
signalText: buildRouteSignalText(normalizedPrompt, normalizedCommand),
|
|
81
82
|
});
|
|
82
83
|
const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
|
|
83
84
|
const degradedWarnings = [];
|
|
@@ -412,8 +413,10 @@ function deriveExecutionMode({
|
|
|
412
413
|
intentMode = null,
|
|
413
414
|
executionScores = {},
|
|
414
415
|
executionCandidates = null,
|
|
416
|
+
signalText = '',
|
|
415
417
|
} = {}) {
|
|
416
418
|
const raw = `${promptText ?? ''}\n${commandText ?? ''}`.toLowerCase();
|
|
419
|
+
const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
|
|
417
420
|
const scores = executionScores;
|
|
418
421
|
const candidates = executionCandidates ?? buildExecutionModeCandidates({
|
|
419
422
|
promptText,
|
|
@@ -423,14 +426,44 @@ function deriveExecutionMode({
|
|
|
423
426
|
executionScores,
|
|
424
427
|
});
|
|
425
428
|
const explicitReviewLead = /\b(review this|audit this|review\b.*\brelease readiness|verify\b.*\brelease readiness)\b/.test(raw);
|
|
429
|
+
const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
|
|
430
|
+
&& /\b(?:release|tag|publish|push|report)\b/.test(raw);
|
|
426
431
|
const strongImpactLead = scores.sharedRisk
|
|
427
432
|
&& (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
|
|
428
433
|
const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
|
|
434
|
+
// A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
|
|
435
|
+
// repository mutation the ledger could ever receipt. With no edit/review/debug/
|
|
436
|
+
// build signal present, routing it to an investigation lane fabricates write debt
|
|
437
|
+
// and the completion gate then demands an edit that cannot exist. Delivery-only
|
|
438
|
+
// requests carry no completion contract instead.
|
|
439
|
+
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
440
|
+
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
441
|
+
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
442
|
+
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
443
|
+
const deliveryOnlySignal = deliveryWordSignal
|
|
444
|
+
&& scores.editCertainty === 0
|
|
445
|
+
&& !scores.implementSignal
|
|
446
|
+
&& !scores.reviewSignal
|
|
447
|
+
&& !scores.debugSignal
|
|
448
|
+
&& !scores.failureSignal
|
|
449
|
+
&& !scores.impactSignal
|
|
450
|
+
&& !scores.buildSignal
|
|
451
|
+
&& !scores.directTransformSignal
|
|
452
|
+
&& !scores.smallFixSignal
|
|
453
|
+
&& !scores.sharedRisk
|
|
454
|
+
&& !targetFile;
|
|
429
455
|
|
|
430
|
-
if (
|
|
456
|
+
if (
|
|
457
|
+
releaseVerificationContinuation
|
|
458
|
+
|| ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
|
|
459
|
+
) {
|
|
431
460
|
return 'review-release';
|
|
432
461
|
}
|
|
433
462
|
|
|
463
|
+
if (deliveryOnlySignal) {
|
|
464
|
+
return 'informational';
|
|
465
|
+
}
|
|
466
|
+
|
|
434
467
|
if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
|
|
435
468
|
return 'map-impact';
|
|
436
469
|
}
|
|
@@ -530,7 +563,7 @@ function buildExecutionModeCandidates({
|
|
|
530
563
|
),
|
|
531
564
|
'map-impact': (
|
|
532
565
|
(scores.sharedRisk ? 7 : 0)
|
|
533
|
-
+ (scores.impactSignal ? 5 :
|
|
566
|
+
+ (scores.impactSignal ? 5 : -5)
|
|
534
567
|
+ (scores.ambiguity * 2)
|
|
535
568
|
+ scores.investigationNeed
|
|
536
569
|
),
|
|
@@ -78,6 +78,7 @@ MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
|
|
|
78
78
|
# Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
|
|
79
79
|
# runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
|
|
80
80
|
node -e '
|
|
81
|
+
const crypto = require("crypto");
|
|
81
82
|
const fs = require("fs");
|
|
82
83
|
const path = require("path");
|
|
83
84
|
|
|
@@ -115,24 +116,57 @@ function appendAudit(event, payload) {
|
|
|
115
116
|
fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
function isPidAlive(pid) {
|
|
120
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
return true;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return error?.code === "EPERM";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readLockOwner(lockPath) {
|
|
130
|
+
const owner = readJson(path.join(lockPath, "owner"), null);
|
|
131
|
+
return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
|
|
132
|
+
? owner
|
|
133
|
+
: null;
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
async function withLock(lockPath, fn) {
|
|
119
137
|
const staleMs = 10000;
|
|
120
138
|
const maxWaitMs = 5000;
|
|
121
139
|
const startedAt = Date.now();
|
|
140
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
|
|
122
141
|
let held = false;
|
|
123
142
|
while (!held) {
|
|
124
143
|
try {
|
|
125
144
|
fs.mkdirSync(lockPath);
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
|
|
147
|
+
pid: process.pid,
|
|
148
|
+
token: ownerToken,
|
|
149
|
+
ts: Date.now(),
|
|
150
|
+
}));
|
|
151
|
+
} catch (error) {
|
|
152
|
+
try {
|
|
153
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
154
|
+
} catch {}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
126
157
|
held = true;
|
|
127
158
|
} catch (error) {
|
|
128
159
|
if (!error || error.code !== "EEXIST") return fn();
|
|
129
160
|
try {
|
|
130
161
|
const stat = fs.statSync(lockPath);
|
|
131
162
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
163
|
+
const owner = readLockOwner(lockPath);
|
|
164
|
+
if (!owner || !isPidAlive(owner.pid)) {
|
|
165
|
+
try {
|
|
166
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
167
|
+
continue;
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
136
170
|
}
|
|
137
171
|
} catch {}
|
|
138
172
|
if (Date.now() - startedAt > maxWaitMs) break;
|
|
@@ -143,9 +177,12 @@ async function withLock(lockPath, fn) {
|
|
|
143
177
|
return await fn();
|
|
144
178
|
} finally {
|
|
145
179
|
if (held) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
180
|
+
const owner = readLockOwner(lockPath);
|
|
181
|
+
if (owner?.token === ownerToken) {
|
|
182
|
+
try {
|
|
183
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
149
186
|
}
|
|
150
187
|
}
|
|
151
188
|
}
|
|
@@ -14,6 +14,7 @@ fi
|
|
|
14
14
|
NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
15
15
|
|
|
16
16
|
node -e '
|
|
17
|
+
const crypto = require("crypto");
|
|
17
18
|
const fs = require("fs");
|
|
18
19
|
const path = require("path");
|
|
19
20
|
|
|
@@ -55,25 +56,58 @@ function appendAudit(event, payload) {
|
|
|
55
56
|
// writes because Claude Code re-reads settings.local.json — a torn write would drop the
|
|
56
57
|
// ENTIRE allow list and every command would prompt again. Fail-open after 5s: a hook
|
|
57
58
|
// must never hang the chain.
|
|
59
|
+
function isPidAlive(pid) {
|
|
60
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return error?.code === "EPERM";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readLockOwner(lockPath) {
|
|
70
|
+
const owner = readJson(path.join(lockPath, "owner"), null);
|
|
71
|
+
return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
|
|
72
|
+
? owner
|
|
73
|
+
: null;
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
async function withLock(lockPath, fn) {
|
|
59
77
|
const staleMs = 10000;
|
|
60
78
|
const maxWaitMs = 5000;
|
|
61
79
|
const startedAt = Date.now();
|
|
80
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
|
|
62
81
|
let held = false;
|
|
63
82
|
while (!held) {
|
|
64
83
|
try {
|
|
65
84
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
66
85
|
fs.mkdirSync(lockPath);
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
|
|
88
|
+
pid: process.pid,
|
|
89
|
+
token: ownerToken,
|
|
90
|
+
ts: Date.now(),
|
|
91
|
+
}));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
95
|
+
} catch {}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
67
98
|
held = true;
|
|
68
99
|
} catch (error) {
|
|
69
100
|
if (!error || error.code !== "EEXIST") return fn();
|
|
70
101
|
try {
|
|
71
102
|
const stat = fs.statSync(lockPath);
|
|
72
103
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
104
|
+
const owner = readLockOwner(lockPath);
|
|
105
|
+
if (!owner || !isPidAlive(owner.pid)) {
|
|
106
|
+
try {
|
|
107
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
108
|
+
continue;
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
77
111
|
}
|
|
78
112
|
} catch {}
|
|
79
113
|
if (Date.now() - startedAt > maxWaitMs) break;
|
|
@@ -84,9 +118,12 @@ async function withLock(lockPath, fn) {
|
|
|
84
118
|
return await fn();
|
|
85
119
|
} finally {
|
|
86
120
|
if (held) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
121
|
+
const owner = readLockOwner(lockPath);
|
|
122
|
+
if (owner?.token === ownerToken) {
|
|
123
|
+
try {
|
|
124
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
90
127
|
}
|
|
91
128
|
}
|
|
92
129
|
}
|
|
@@ -128,7 +128,9 @@ function readRunCursor() {
|
|
|
128
128
|
let state = mod.buildCompactPressureState(rawState, sessionConfig);
|
|
129
129
|
try {
|
|
130
130
|
const synced = await mod.syncCompactPressureStateWithTranscript(state, sessionConfig, payload.transcript_path);
|
|
131
|
-
if (synced.reset) {
|
|
131
|
+
if (synced.reset || synced.probed) {
|
|
132
|
+
// Persist a clean probe too: hook processes are ephemeral, so otherwise every
|
|
133
|
+
// mutation would reopen and parse the same 4MB transcript tail.
|
|
132
134
|
state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
|
|
133
135
|
}
|
|
134
136
|
} catch {
|
|
@@ -141,13 +143,24 @@ function readRunCursor() {
|
|
|
141
143
|
// both write 4, silently multiplying the budget), so each spent call is claimed with an
|
|
142
144
|
// exclusive-create sentinel file (openSync 'wx'): the sentinels present ARE the number
|
|
143
145
|
// of grace calls spent, and one claim can never be counted twice.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
146
|
+
// Grace is a landing allowance for THIS session's over-cap episode. The old global
|
|
147
|
+
// `hardcap-grace.json.N` slot pool let session A consume session B's allowance (and A
|
|
148
|
+
// clearing it refilled B's spent pool), so parallel sessions could be hard-blocked mid-
|
|
149
|
+
// landing for no reason. Keep the fallback `default` for hosts that omit session_id: in
|
|
150
|
+
// that legacy shape sibling workers still deliberately share one bounded allowance.
|
|
151
|
+
const rawGraceSessionId = typeof payload.session_id === 'string' && payload.session_id.trim()
|
|
152
|
+
? payload.session_id.trim()
|
|
153
|
+
: 'default';
|
|
154
|
+
const graceSessionKey = rawGraceSessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default';
|
|
155
|
+
const graceDir = path.join(projectRoot, '.ukit', 'storage', 'cache');
|
|
156
|
+
const graceBaseName = `hardcap-grace.${graceSessionKey}.json`;
|
|
157
|
+
const gracePath = path.join(graceDir, graceBaseName);
|
|
158
|
+
const slotPath = (n) => path.join(graceDir, `${graceBaseName}.${n}`);
|
|
159
|
+
const slotPattern = new RegExp(`^${graceBaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(\\d+)$`);
|
|
147
160
|
const readSlots = () => {
|
|
148
161
|
try {
|
|
149
162
|
return fs.readdirSync(graceDir)
|
|
150
|
-
.map((name) =>
|
|
163
|
+
.map((name) => slotPattern.exec(name))
|
|
151
164
|
.filter(Boolean)
|
|
152
165
|
.map((m) => Number(m[1]))
|
|
153
166
|
.sort((a, b) => a - b);
|
|
@@ -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();
|
|
@@ -26,26 +26,63 @@ fi
|
|
|
26
26
|
|
|
27
27
|
# The lock directory lives beside the state file; its parent exists because the file does.
|
|
28
28
|
node -e '
|
|
29
|
+
const crypto = require("crypto");
|
|
29
30
|
const fs = require("fs");
|
|
31
|
+
const path = require("path");
|
|
32
|
+
|
|
33
|
+
function isPidAlive(pid) {
|
|
34
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return error?.code === "EPERM";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readLockOwner(lockPath) {
|
|
44
|
+
try {
|
|
45
|
+
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner"), "utf8"));
|
|
46
|
+
return Number.isInteger(owner?.pid) && typeof owner.token === "string" ? owner : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
30
51
|
|
|
31
52
|
async function withLock(lockPath, fn) {
|
|
32
53
|
const staleMs = 10000;
|
|
33
54
|
const maxWaitMs = 5000;
|
|
34
55
|
const startedAt = Date.now();
|
|
56
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
|
|
35
57
|
let held = false;
|
|
36
58
|
while (!held) {
|
|
37
59
|
try {
|
|
38
60
|
fs.mkdirSync(lockPath);
|
|
61
|
+
try {
|
|
62
|
+
fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
token: ownerToken,
|
|
65
|
+
ts: Date.now(),
|
|
66
|
+
}));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
try {
|
|
69
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
70
|
+
} catch {}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
39
73
|
held = true;
|
|
40
74
|
} catch (error) {
|
|
41
75
|
if (!error || error.code !== "EEXIST") return fn();
|
|
42
76
|
try {
|
|
43
77
|
const stat = fs.statSync(lockPath);
|
|
44
78
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
79
|
+
const owner = readLockOwner(lockPath);
|
|
80
|
+
if (!owner || !isPidAlive(owner.pid)) {
|
|
81
|
+
try {
|
|
82
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
83
|
+
continue;
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
49
86
|
}
|
|
50
87
|
} catch {}
|
|
51
88
|
if (Date.now() - startedAt > maxWaitMs) break;
|
|
@@ -56,9 +93,12 @@ async function withLock(lockPath, fn) {
|
|
|
56
93
|
return await fn();
|
|
57
94
|
} finally {
|
|
58
95
|
if (held) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
96
|
+
const owner = readLockOwner(lockPath);
|
|
97
|
+
if (owner?.token === ownerToken) {
|
|
98
|
+
try {
|
|
99
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
62
102
|
}
|
|
63
103
|
}
|
|
64
104
|
}
|