@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.9",
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",
@@ -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
- * A crashed holder is reclaimed once the directory's mtime exceeds staleMs.
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
- * Holders must keep their critical section far below staleMs; nothing refreshes
155
- * the lock mtime, so a section that somehow runs longer can have its lock stolen.
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 it looks abandoned; otherwise back off.
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 fs.rm(lockPath, { recursive: true, force: true });
180
- continue; // the slot is free now — retry immediately
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 fs.rm(lockPath, { recursive: true, force: true });
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
  }
@@ -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 ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
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 : 0)
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
- try {
133
- fs.rmSync(lockPath, { recursive: true, force: true });
134
- continue;
135
- } catch {}
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
- try {
147
- fs.rmdirSync(lockPath);
148
- } catch {}
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
- try {
74
- fs.rmSync(lockPath, { recursive: true, force: true });
75
- continue;
76
- } catch {}
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
- try {
88
- fs.rmdirSync(lockPath);
89
- } catch {}
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
- const gracePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hardcap-grace.json');
145
- const graceDir = path.dirname(gracePath);
146
- const slotPath = (n) => path.join(graceDir, `hardcap-grace.json.${n}`);
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) => /^hardcap-grace\.json\.(\d+)$/.exec(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
- 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();
@@ -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
- try {
46
- fs.rmSync(lockPath, { recursive: true, force: true });
47
- continue;
48
- } catch {}
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
- try {
60
- fs.rmdirSync(lockPath);
61
- } catch {}
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
  }