@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.
- package/CHANGELOG.md +54 -0
- package/package.json +1 -1
- package/src/context/detectProjectContext.js +5 -0
- package/src/core/codeintel/invalidation.js +4 -0
- package/src/core/diffPlan.js +60 -1
- package/src/core/fileOps.js +46 -119
- package/src/render/buildVariables.js +10 -0
- package/templates/.claude/agents/bug-debugger.md +1 -1
- package/templates/.claude/agents/feature-implementer.md +2 -2
- package/templates/.claude/commands/ukit/handoff-clear.md +11 -0
- package/templates/.claude/commands/ukit/handoff-create.md +1 -1
- package/templates/.claude/commands/ukit/handoff-fullstack.md +26 -1
- package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
- package/templates/.claude/commands/ukit/handoff-review.md +1 -1
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
- package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
- package/templates/.claude/hooks/skill-router.sh +15 -8
- package/templates/.claude/hooks/verification-guard.sh +3 -0
- package/templates/.claude/ukit/index/route-task.mjs +237 -32
- package/templates/.claude/ukit/index/stale-spec-check.mjs +38 -3
- package/templates/.claude/ukit/runtime/async-lock.mjs +240 -42
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +131 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
- package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
- package/templates/.codex/settings.json +1 -5
- package/templates/.omp/agents/bug-debugger.md +1 -1
- package/templates/.omp/agents/feature-implementer.md +2 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +216 -51
- package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
- package/templates/docs/AI_HANDOFF/RULES.md +6 -6
- 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
|
|
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
|
-
|
|
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
|
-
? {
|
|
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
|
-
? {
|
|
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 ||
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
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',
|