@ngockhoale/ukit 2.7.7 → 2.7.9

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +1 -1
  3. package/src/context/detectProjectContext.js +5 -0
  4. package/src/core/codeintel/invalidation.js +4 -0
  5. package/src/core/diffPlan.js +60 -1
  6. package/src/core/fileOps.js +46 -119
  7. package/src/render/buildVariables.js +10 -0
  8. package/templates/.claude/agents/bug-debugger.md +1 -1
  9. package/templates/.claude/agents/feature-implementer.md +2 -2
  10. package/templates/.claude/commands/ukit/handoff-clear.md +11 -0
  11. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  12. package/templates/.claude/commands/ukit/handoff-fullstack.md +26 -1
  13. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  14. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  15. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  16. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  17. package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
  18. package/templates/.claude/hooks/skill-router.sh +15 -8
  19. package/templates/.claude/hooks/verification-guard.sh +3 -0
  20. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  21. package/templates/.claude/ukit/index/stale-spec-check.mjs +38 -3
  22. package/templates/.claude/ukit/runtime/async-lock.mjs +240 -42
  23. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  24. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  25. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
  26. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +131 -0
  27. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  28. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  29. package/templates/.codex/settings.json +1 -5
  30. package/templates/.omp/agents/bug-debugger.md +1 -1
  31. package/templates/.omp/agents/feature-implementer.md +2 -2
  32. package/templates/.omp/hooks/pre/ukit-bridge.js +216 -51
  33. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  34. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  35. package/templates/ukit/storage/config.json +2 -2
@@ -2,7 +2,10 @@
2
2
  // TASK-028: the one async lock utility for hook processes. Every hook lock used to
3
3
  // carry its own inline mkdir/owner/backoff copy (auto-allow-bash, verification-guard,
4
4
  // token-utils withFileLock, src/core/fileOps) with slightly divergent policies; this
5
- // module is the shared protocol-compatible implementation for hook-side callers:
5
+ // module is now the single implementation for all of them — the two withFileLock
6
+ // twins (token-utils.mjs and src/core/fileOps.js) delegate here, so the lock
7
+ // protocol, pstart/recycled-pid validation, and claim+quarantine reclaim exist
8
+ // exactly once and cannot drift between copies. For hook-side callers:
6
9
  //
7
10
  // - Acquisition is an atomic `mkdir` of `<target>.lock`; the owner is stamped into
8
11
  // `owner` (pid + random token + ts) so stale reclaim can be validated instead of
@@ -26,6 +29,7 @@
26
29
  // ownerless lock: a contender reads a missing owner as reclaimable after `staleMs`,
27
30
  // so an unstamped live holder could have its lock deleted mid-critical-section.
28
31
  import crypto from 'node:crypto';
32
+ import { spawnSync } from 'node:child_process';
29
33
  import fs from 'node:fs/promises';
30
34
  import path from 'node:path';
31
35
 
@@ -40,6 +44,10 @@ export const LOCK_MAX_SLICE_MS = 1_200;
40
44
  const BACKOFF_MIN_MS = 3;
41
45
  const BACKOFF_SPREAD_MS = 9;
42
46
 
47
+ // Tolerance for comparing a stamped process start time against `ps`-reported start:
48
+ // etimes has 1s granularity and the stamp is taken a few ms after the real start.
49
+ const PID_START_TOLERANCE_MS = 3_000;
50
+
43
51
  /**
44
52
  * Derive the lock acquisition budget from a hook's wall-clock deadline: the time
45
53
  * remaining before that deadline, minus a reserve for release/cleanup, capped at the
@@ -86,6 +94,58 @@ function sleepWithAbort(ms, signal) {
86
94
  });
87
95
  }
88
96
 
97
+ // Transient kernel-level fs failures worth a bounded retry (C44): observed on
98
+ // external APFS volumes under metadata churn — `open()`/`mkdir()`/`rename()`
99
+ // can return EAGAIN once and succeed on immediate retry. EEXIST is deliberately
100
+ // absent: it is the lock-contend signal, not a flake.
101
+ export const TRANSIENT_FS_CODES = new Set(['EAGAIN', 'EBUSY', 'EMFILE', 'ENFILE', 'ESTALE']);
102
+
103
+ /**
104
+ * Retry an fs operation that can surface a transient kernel error
105
+ * (TRANSIENT_FS_CODES) with jittered exponential backoff. Non-transient codes
106
+ * throw immediately — EEXIST contention and EISDIR mapping are unaffected.
107
+ * The LAST error rethrows on retry exhaustion, deadline, or abort: a retry
108
+ * never extends the caller past `deadlineMs` and never swallows the failure.
109
+ * @param {() => Promise<*>} op
110
+ * @param {{
111
+ * retries?: number,
112
+ * baseDelayMs?: number,
113
+ * maxDelayMs?: number,
114
+ * deadlineMs?: number,
115
+ * signal?: AbortSignal,
116
+ * }} [options]
117
+ * @returns {Promise<*>} op's resolved value
118
+ */
119
+ export async function withTransientFsRetry(op, {
120
+ retries = 3,
121
+ baseDelayMs = 15,
122
+ maxDelayMs = 150,
123
+ deadlineMs = Infinity,
124
+ signal,
125
+ } = {}) {
126
+ const startedAt = Date.now();
127
+ const maxAttempts = Number.isFinite(retries) && retries >= 0 ? Math.floor(retries) + 1 : 1;
128
+ let lastError = null;
129
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
130
+ if (signal?.aborted) {
131
+ throw lastError ?? signal.reason ?? new Error('withTransientFsRetry aborted');
132
+ }
133
+ try {
134
+ return await op();
135
+ } catch (error) {
136
+ lastError = error;
137
+ if (!TRANSIENT_FS_CODES.has(error?.code) || attempt + 1 >= maxAttempts) throw error;
138
+ const remaining = deadlineMs - (Date.now() - startedAt);
139
+ if (remaining <= 0) throw error;
140
+ const backoff = Math.min(baseDelayMs * (2 ** attempt), maxDelayMs);
141
+ const waitMs = Math.min(backoff * (0.5 + Math.random()), remaining);
142
+ const sleptFully = await sleepWithAbort(Math.max(0, waitMs), signal);
143
+ if (!sleptFully || Date.now() - startedAt >= deadlineMs) throw error;
144
+ }
145
+ }
146
+ throw lastError;
147
+ }
148
+
89
149
  function isPidAlive(pid) {
90
150
  try {
91
151
  process.kill(pid, 0);
@@ -96,12 +156,66 @@ function isPidAlive(pid) {
96
156
  }
97
157
  }
98
158
 
99
- async function readLockOwner(lockPath) {
159
+ // Wall-clock ms when `pid` started, or null when it cannot be determined. `lstart`
160
+ // is POSIX-portable across macOS and Linux ps (BSD ps has no `etimes`); the ctime
161
+ // shape is parsed manually so no Date.parse implementation quirk can misread it.
162
+ // A failed probe is treated as "unknown", never as "dead".
163
+ const LSTART_MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
164
+ const LSTART_RE = /^[A-Za-z]{3}\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(\d{4})$/;
165
+ function processStartMs(pid) {
166
+ try {
167
+ const result = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
168
+ encoding: 'utf8',
169
+ timeout: 2_000,
170
+ });
171
+ if (result.status !== 0) return null;
172
+ const match = String(result.stdout || '').replace(/\s+/g, ' ').trim().match(LSTART_RE);
173
+ if (!match) return null;
174
+ const month = LSTART_MONTHS[match[1]];
175
+ if (month === undefined) return null;
176
+ const ms = new Date(
177
+ Number(match[6]), month, Number(match[2]),
178
+ Number(match[3]), Number(match[4]), Number(match[5]),
179
+ ).getTime();
180
+ return Number.isFinite(ms) ? ms : null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ // This process's own start time, stamped into every owner/reclaim record so a later
187
+ // observer can tell a recycled pid (same number, different process) from the real
188
+ // holder. `process.kill(pid, 0)` alone cannot: a reused pid probes alive forever and
189
+ // used to pin every stale lock unreclaimable (RC-4 PID-reuse residual).
190
+ const PROCESS_START_MS = Date.now() - Math.floor(process.uptime() * 1000);
191
+
192
+ // Provably-gone check for a recorded owner/claim { pid, token, pstart? }:
193
+ // - dead pid probe → gone;
194
+ // - live pid + stamped pstart that does not match the running process's start → the
195
+ // pid was recycled after the holder died → gone;
196
+ // - live pid with no stamp (legacy record) or an undeterminable start → alive
197
+ // (conservative: a live holder's lock is never stolen).
198
+ function recordedProcessGone(owner) {
199
+ if (!owner) return true;
200
+ if (!isPidAlive(owner.pid)) return true;
201
+ const stamped = Number(owner.pstart);
202
+ if (!Number.isFinite(stamped) || stamped <= 0) return false;
203
+ const actual = processStartMs(owner.pid);
204
+ if (actual === null) return false;
205
+ return Math.abs(actual - stamped) > PID_START_TOLERANCE_MS;
206
+ }
207
+
208
+ async function readLockOwner(lockPath, retry = withTransientFsRetry) {
100
209
  try {
101
- const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
210
+ const raw = JSON.parse(await retry(() => fs.readFile(path.join(lockPath, 'owner'), 'utf8')));
102
211
  const pid = Number(raw?.pid);
212
+ const pstart = Number(raw?.pstart);
103
213
  return Number.isInteger(pid) && pid > 0
104
- ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
214
+ ? {
215
+ pid,
216
+ token: typeof raw?.token === 'string' ? raw.token : null,
217
+ pstart: Number.isFinite(pstart) && pstart > 0 ? pstart : null,
218
+ }
105
219
  : null;
106
220
  } catch {
107
221
  return null;
@@ -115,31 +229,37 @@ function sameLockOwner(left, right) {
115
229
  return left.pid === right.pid && left.token === right.token;
116
230
  }
117
231
 
118
- async function readReclaimOwner(lockPath) {
232
+ async function readReclaimOwner(lockPath, retry = withTransientFsRetry) {
119
233
  try {
120
- const raw = JSON.parse(await fs.readFile(path.join(lockPath, RECLAIM_FILE), 'utf8'));
234
+ const raw = JSON.parse(await retry(() => fs.readFile(path.join(lockPath, RECLAIM_FILE), 'utf8')));
121
235
  const pid = Number(raw?.pid);
236
+ const pstart = Number(raw?.pstart);
122
237
  return Number.isInteger(pid) && pid > 0
123
- ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
238
+ ? {
239
+ pid,
240
+ token: typeof raw?.token === 'string' ? raw.token : null,
241
+ pstart: Number.isFinite(pstart) && pstart > 0 ? pstart : null,
242
+ }
124
243
  : null;
125
244
  } catch {
126
245
  return null;
127
246
  }
128
247
  }
129
248
 
249
+
130
250
  // Stale observers must claim the existing lock before removing it. The claim lives
131
251
  // inside the old generation, so a second observer cannot remove that generation while
132
252
  // the first observer is between its owner check and rm(). This closes the TOCTOU race
133
253
  // where a delayed stale observer deleted a freshly acquired successor lock.
134
- async function claimReclaim(lockPath, ownerToken, staleMs) {
254
+ async function claimReclaim(lockPath, ownerToken, staleMs, retry = withTransientFsRetry) {
135
255
  const reclaimPath = path.join(lockPath, RECLAIM_FILE);
136
256
  try {
137
- const handle = await fs.open(reclaimPath, 'wx');
257
+ const handle = await retry(() => fs.open(reclaimPath, 'wx'));
138
258
  try {
139
- await handle.writeFile(
140
- `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
259
+ await retry(() => handle.writeFile(
260
+ `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now(), pstart: PROCESS_START_MS })}\n`,
141
261
  'utf8',
142
- );
262
+ ));
143
263
  } finally {
144
264
  await handle.close();
145
265
  }
@@ -150,11 +270,11 @@ async function claimReclaim(lockPath, ownerToken, staleMs) {
150
270
  // A killed reclaimer can leave its claim behind. Only clear an old claim whose
151
271
  // recorded process is gone; a live claim remains the exclusive reclaim authority.
152
272
  try {
153
- const stat = await fs.stat(reclaimPath);
273
+ const stat = await retry(() => fs.stat(reclaimPath));
154
274
  if (Date.now() - stat.mtimeMs > staleMs) {
155
- const claim = await readReclaimOwner(lockPath);
156
- if (!claim || !isPidAlive(claim.pid)) {
157
- await fs.rm(reclaimPath, { force: true });
275
+ const claim = await readReclaimOwner(lockPath, retry);
276
+ if (!claim || recordedProcessGone(claim)) {
277
+ await retry(() => fs.rm(reclaimPath, { force: true }));
158
278
  }
159
279
  }
160
280
  } catch {
@@ -164,38 +284,38 @@ async function claimReclaim(lockPath, ownerToken, staleMs) {
164
284
  }
165
285
  }
166
286
 
167
- async function releaseReclaimClaim(lockPath, ownerToken) {
287
+ async function releaseReclaimClaim(lockPath, ownerToken, retry = withTransientFsRetry) {
168
288
  try {
169
- const claim = await readReclaimOwner(lockPath);
289
+ const claim = await readReclaimOwner(lockPath, retry);
170
290
  if (claim?.token === ownerToken) {
171
- await fs.rm(path.join(lockPath, RECLAIM_FILE), { force: true });
291
+ await retry(() => fs.rm(path.join(lockPath, RECLAIM_FILE), { force: true }));
172
292
  }
173
293
  } catch {
174
294
  // best-effort claim release; the stale-claim path handles an interrupted cleanup
175
295
  }
176
296
  }
177
297
 
178
- async function quarantineReclaim(lockPath, owner, ownerToken) {
298
+ async function quarantineReclaim(lockPath, owner, ownerToken, retry = withTransientFsRetry) {
179
299
  const quarantinePath = `${lockPath}.reclaim-${ownerToken}`;
180
300
  try {
181
301
  // The claim serializes stale observers. Re-read before the atomic rename so an
182
302
  // observer never detaches a generation different from the one it validated.
183
- const current = await readLockOwner(lockPath);
303
+ const current = await readLockOwner(lockPath, retry);
184
304
  if (!sameLockOwner(current, owner)) {
185
- await releaseReclaimClaim(lockPath, ownerToken);
305
+ await releaseReclaimClaim(lockPath, ownerToken, retry);
186
306
  return false;
187
307
  }
188
308
  // Rename is atomic within the lock's parent directory: the old generation is
189
309
  // detached as one filesystem operation, so a successor created at lockPath can
190
310
  // never be reached by cleanup of this quarantined generation.
191
- await fs.rename(lockPath, quarantinePath);
192
- await fs.rm(quarantinePath, { recursive: true, force: true });
311
+ await retry(() => fs.rename(lockPath, quarantinePath));
312
+ await retry(() => fs.rm(quarantinePath, { recursive: true, force: true }));
193
313
  return true;
194
314
  } catch {
195
315
  // Never recursively remove lockPath here. If rename lost a race or failed, the
196
316
  // original path belongs to whoever currently holds it; a later poll can retry.
197
317
  try {
198
- await fs.rm(quarantinePath, { recursive: true, force: true });
318
+ await retry(() => fs.rm(quarantinePath, { recursive: true, force: true }));
199
319
  } catch {
200
320
  // best-effort cleanup of only this acquisition's quarantine path
201
321
  }
@@ -207,6 +327,56 @@ async function quarantineReclaim(lockPath, owner, ownerToken) {
207
327
  // a pid probe cannot prove, so the module tracks them itself (same as withFileLock).
208
328
  const inProcessLockHolders = new Map();
209
329
 
330
+ // --- TASK-004: dropped-update journal -------------------------------------------
331
+ // Fail-closed means a skipped mutation is LOST — the journal makes that loss
332
+ // explicit and auditable instead of a silent unlocked write. One JSONL record per
333
+ // drop, appended under a journal-local async lock (the main lock is unavailable —
334
+ // that is why this path runs). Bounded: a full journal applies backpressure and
335
+ // rejects the record rather than rewriting history, same as the ledger journal.
336
+ // Shared by both withFileLock twins (token-utils.mjs and src/core/fileOps.js) so
337
+ // the drop contract can never diverge between the protocol copies.
338
+ const LOCK_DROP_JOURNAL_MAX_RECORDS = 128;
339
+ const LOCK_DROP_JOURNAL_BUDGET_MS = 400;
340
+
341
+ export function lockDropJournalPath(filePath) {
342
+ return `${filePath}.lock-drops.jsonl`;
343
+ }
344
+
345
+ export async function journalDroppedLockMutation(filePath, { reason, waitedMs }) {
346
+ const journalPath = lockDropJournalPath(filePath);
347
+ const record = {
348
+ v: 1,
349
+ ts: Date.now(),
350
+ pid: process.pid,
351
+ file: path.basename(filePath),
352
+ reason,
353
+ waitedMs: Math.max(0, Math.round(waitedMs)),
354
+ };
355
+ try {
356
+ const outcome = await withAsyncLock(
357
+ journalPath,
358
+ { deadlineMs: LOCK_DROP_JOURNAL_BUDGET_MS },
359
+ async () => {
360
+ let existing = null;
361
+ try {
362
+ existing = await fs.readFile(journalPath, 'utf8');
363
+ } catch (error) {
364
+ if (error?.code !== 'ENOENT') return false;
365
+ }
366
+ const lines = existing === null
367
+ ? []
368
+ : existing.split('\n').filter((line) => line.trim());
369
+ if (lines.length >= LOCK_DROP_JOURNAL_MAX_RECORDS) return false;
370
+ await fs.appendFile(journalPath, `${JSON.stringify(record)}\n`, 'utf8');
371
+ return true;
372
+ },
373
+ );
374
+ return outcome?.ok === true && outcome.value === true;
375
+ } catch {
376
+ return false; // journaling is best-effort; never resurrect the mutation over it
377
+ }
378
+ }
379
+
210
380
  /**
211
381
  * Serialize a mutation of a shared state file across hook processes and concurrent
212
382
  * async flows. Protocol-compatible with token-utils withFileLock (`<file>.lock`
@@ -239,20 +409,31 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
239
409
  // True only when THIS acquisition holds a lock it can prove it owns: the atomic
240
410
  // mkdir succeeded AND the owner stamp is on disk. Release is verified against it.
241
411
  let owned = false;
412
+ // C44: every lock-protocol fs op retries transient kernel errors (EAGAIN et
413
+ // al.) inside the caller's remaining acquisition budget — a kernel flake must
414
+ // never escape as an untyped throw while budget remains, and a retry must
415
+ // never extend the wait past it.
416
+ const retry = (op) => withTransientFsRetry(op, {
417
+ deadlineMs: Math.max(0, budget - (Date.now() - startedAt)),
418
+ signal,
419
+ });
420
+ // Post-acquisition ops (release, claim cleanup) run after the budget is spent;
421
+ // they get the fixed cleanup reserve instead of the acquisition slice.
422
+ const releaseRetry = (op) => withTransientFsRetry(op, { deadlineMs: LOCK_RESERVE_MS });
242
423
 
243
424
  while (true) {
244
425
  try {
245
426
  // The lock parent must exist before the atomic acquire — a first-ever run in a
246
427
  // fresh project would otherwise fail mkdir with ENOENT.
247
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
248
- await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
428
+ await retry(() => fs.mkdir(path.dirname(lockPath), { recursive: true }));
429
+ await retry(() => fs.mkdir(lockPath)); // atomic acquire — EEXIST means another holder exists
249
430
  inProcessLockHolders.set(lockPath, ownerToken);
250
431
  try {
251
- await fs.writeFile(
432
+ await retry(() => fs.writeFile(
252
433
  path.join(lockPath, 'owner'),
253
- `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
434
+ `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now(), pstart: PROCESS_START_MS })}\n`,
254
435
  'utf8',
255
- );
436
+ ));
256
437
  } catch {
257
438
  // Fail closed: an unstamped lock is not ours to enter. Running the callback
258
439
  // anyway would leave an ownerless directory, and every other process reads a
@@ -260,11 +441,11 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
260
441
  // lock mid-critical-section and letting two mutations interleave. Undo the
261
442
  // acquire and report the typed busy outcome callers already handle.
262
443
  try {
263
- const current = await readLockOwner(lockPath);
444
+ const current = await readLockOwner(lockPath, retry);
264
445
  // Never remove a directory some other holder has since stamped (only
265
446
  // possible if this one was reclaimed in the window above).
266
447
  if (!current || current.token === ownerToken) {
267
- await fs.rm(lockPath, { recursive: true, force: true });
448
+ await retry(() => fs.rm(lockPath, { recursive: true, force: true }));
268
449
  }
269
450
  } catch {
270
451
  // best-effort undo; a leftover dir is unheld and reclaimed by the next waiter
@@ -281,20 +462,37 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
281
462
  // Someone holds the lock. Reclaim it only after the stale threshold AND only when
282
463
  // the holder is provably gone — stealing a live holder reintroduces the exact
283
464
  // interleaved-write race this lock exists to prevent.
465
+ //
466
+ // RC-4: the effective stale threshold for a budgeted waiter is
467
+ // min(staleMs, its remaining budget) — a waiter that cannot outlast the full
468
+ // LOCK_STALE_MS still reclaims a genuinely orphaned lock instead of freezing the
469
+ // liveness breakers behind it. A zero/negative remaining budget keeps the full
470
+ // threshold (a no-wait caller never steals). A lock whose recorded owner is a
471
+ // LIVE process is never reclaimed early; "provably gone" includes a recycled pid
472
+ // (stamped pstart no longer matches the running process).
284
473
  let reclaimed = false;
285
474
  try {
286
- const stat = await fs.stat(lockPath);
287
- if (Date.now() - stat.mtimeMs > stale) {
288
- const owner = await readLockOwner(lockPath);
475
+ const stat = await retry(() => fs.stat(lockPath));
476
+ const ageMs = Date.now() - stat.mtimeMs;
477
+ const remainingMs = budget - (Date.now() - startedAt);
478
+ const effectiveStaleMs = remainingMs > 0 ? Math.min(stale, remainingMs) : stale;
479
+ if (ageMs > effectiveStaleMs) {
480
+ const owner = await readLockOwner(lockPath, retry);
289
481
  const liveInProcess = inProcessLockHolders.has(lockPath);
290
- const reclaimable = !owner || owner.pid === process.pid
291
- ? !liveInProcess
292
- : !isPidAlive(owner.pid);
293
- if (reclaimable && await claimReclaim(lockPath, ownerToken, stale)) {
482
+ // An ownerless lock may be a holder mid-stamp — only the FULL stale
483
+ // threshold proves abandonment there. A stamped owner provably gone
484
+ // (dead pid, recycled pid, or a same-pid record with no live in-process
485
+ // holder) is reclaimable at the budgeted threshold.
486
+ const reclaimable = !owner
487
+ ? (!liveInProcess && ageMs > stale)
488
+ : owner.pid === process.pid
489
+ ? !liveInProcess
490
+ : recordedProcessGone(owner);
491
+ if (reclaimable && await claimReclaim(lockPath, ownerToken, stale, retry)) {
294
492
  // Detach and clean only the generation that was validated. The atomic rename
295
493
  // makes this safe even when another process acquires lockPath immediately
296
494
  // after the stale generation is removed.
297
- reclaimed = await quarantineReclaim(lockPath, owner, ownerToken);
495
+ reclaimed = await quarantineReclaim(lockPath, owner, ownerToken, retry);
298
496
  }
299
497
  }
300
498
  } catch {
@@ -327,9 +525,9 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
327
525
  // Remove the lock only if THIS acquisition still owns it: after a stale
328
526
  // reclaim another holder may already own the dir, and deleting it would
329
527
  // unlock their critical section for a third waiter.
330
- const current = await readLockOwner(lockPath);
528
+ const current = await readLockOwner(lockPath, releaseRetry);
331
529
  if (current && current.token === ownerToken) {
332
- await fs.rm(lockPath, { recursive: true, force: true });
530
+ await releaseRetry(() => fs.rm(lockPath, { recursive: true, force: true }));
333
531
  }
334
532
  } catch {
335
533
  // best-effort release; a leaked dir is reclaimed by the next waiter
@@ -1398,13 +1398,16 @@ async function runCli() {
1398
1398
  routeSummary: routeState?.routeSummary ?? null,
1399
1399
  }, sessionConfig);
1400
1400
 
1401
- if (nextState.phase === 'hard') {
1401
+ // TASK-004: withFileLock is fail-closed — a contended pressure lock skips the
1402
+ // mutation and resolves undefined (journaled). Skip the advisory notice too;
1403
+ // the next prompt re-evaluates pressure from the last committed state.
1404
+ if (nextState?.phase === 'hard') {
1402
1405
  process.stdout.write(
1403
1406
  `[ukit-skill-router] Context pressure: hard (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). `
1404
1407
  + 'FIRST THING IN YOUR REPLY, tell the user plainly — do not stay silent: "Context sắp đầy — hãy gõ /compact ngay bây giờ (run /compact now)". '
1405
1408
  + 'Then hold heavy work: no new investigations, subagents, or large edits until the user has compacted.\n',
1406
1409
  );
1407
- } else if (nextState.phase === 'soft') {
1410
+ } else if (nextState?.phase === 'soft') {
1408
1411
  process.stdout.write(
1409
1412
  `[ukit-skill-router] Context pressure: soft (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). `
1410
1413
  + 'Finish the current step, then tell the user to run /compact at the next natural pause instead of continuing indefinitely.\n',