@ngockhoale/ukit 2.7.6 → 2.7.8
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 +105 -0
- package/package.json +1 -1
- package/scripts/install/sync-installed-mirror.mjs +250 -0
- package/scripts/perf/audit-perf.mjs +287 -36
- package/scripts/perf/diff-perf-findings.mjs +136 -0
- package/scripts/perf/perf-findings.json +260 -206
- package/scripts/perf/perf-measure.md +271 -0
- package/src/context/detectProjectContext.js +5 -0
- package/src/core/codeintel/invalidation.js +4 -0
- package/src/core/fileOps.js +40 -117
- package/src/core/hookChainDoctor.js +65 -2
- package/src/core/memory/store.js +22 -1
- package/src/core/taskBudgetValidator.js +9 -6
- package/src/core/unattendedDoctor.js +8 -1
- 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-create.md +1 -1
- package/templates/.claude/commands/ukit/handoff-fullstack.md +1 -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/auto-prune-bash.sh +19 -0
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
- package/templates/.claude/hooks/reinject-context.sh +22 -0
- package/templates/.claude/hooks/reset-compact-pressure.sh +29 -0
- package/templates/.claude/hooks/session-episode.sh +20 -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/task-budget-validator.mjs +6 -2
- package/templates/.claude/ukit/runtime/async-lock.mjs +144 -10
- 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 +156 -24
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +84 -12
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +50 -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 +157 -26
- 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
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// Readers (hook-chain-runner.mjs) keep accepting the `@path` argv form unchanged.
|
|
17
17
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
|
+
import fsp from 'node:fs/promises';
|
|
19
20
|
import path from 'node:path';
|
|
20
21
|
import crypto from 'node:crypto';
|
|
21
22
|
|
|
@@ -90,6 +91,62 @@ export function createPayloadReference(text, {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
// createPayloadReferenceAsync(text, {maxBytes, deadlineMs, dir}) -> Promise<reference>
|
|
95
|
+
//
|
|
96
|
+
// TASK-008 (SPEC §S8, OMP-6): the sync variant's mkdirSync/writeFileSync/renameSync
|
|
97
|
+
// run deadline-free on the CALLER's event loop — on the omp host a stalled mount
|
|
98
|
+
// freezes the whole app, and the deadlineMs check at t≈0 is decorative because a
|
|
99
|
+
// stalled sync call never returns to re-check it. This variant does the same
|
|
100
|
+
// atomic staging through async fs raced against a real deadline; on expiry or
|
|
101
|
+
// any failure it degrades to inline transport exactly like the sync path.
|
|
102
|
+
// The losing fs promise is left to settle in the background — it is unref'd by
|
|
103
|
+
// the race and its result discarded; worst case it completes a tmp write that
|
|
104
|
+
// the sampled sweep later reclaims.
|
|
105
|
+
export async function createPayloadReferenceAsync(text, {
|
|
106
|
+
maxBytes = PAYLOAD_INLINE_MAX_BYTES,
|
|
107
|
+
deadlineMs = 250,
|
|
108
|
+
dir,
|
|
109
|
+
} = {}) {
|
|
110
|
+
const payloadText = String(text ?? '');
|
|
111
|
+
const bytes = Buffer.byteLength(payloadText, 'utf8');
|
|
112
|
+
if (bytes <= maxBytes) return inlineReference(payloadText, bytes);
|
|
113
|
+
if (!(deadlineMs > 0) || !dir) return inlineReference(payloadText, bytes);
|
|
114
|
+
|
|
115
|
+
const stage = (async () => {
|
|
116
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
117
|
+
const name = `${Date.now().toString(36)}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.json`;
|
|
118
|
+
const finalPath = path.join(dir, name);
|
|
119
|
+
const tmpPath = path.join(dir, `.${name}.tmp`);
|
|
120
|
+
await fsp.writeFile(tmpPath, payloadText, { encoding: 'utf8', mode: 0o600 });
|
|
121
|
+
await fsp.rename(tmpPath, finalPath);
|
|
122
|
+
return {
|
|
123
|
+
mode: 'file',
|
|
124
|
+
arg: `@${finalPath}`,
|
|
125
|
+
text: payloadText,
|
|
126
|
+
path: finalPath,
|
|
127
|
+
bytes,
|
|
128
|
+
cleanup() {
|
|
129
|
+
try { fs.rmSync(finalPath, { force: true }); } catch { /* best effort */ }
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
})();
|
|
133
|
+
// Fail-open contract (TASK-008 fix): the race must resolve to inline on ANY
|
|
134
|
+
// staging outcome that is not a completed file — deadline expiry AND fs
|
|
135
|
+
// rejection (ENOTDIR/EACCES/EROFS on a read-only or wedged mount). Racing the
|
|
136
|
+
// raw stage promise let a fast rejection propagate through runScriptChain and
|
|
137
|
+
// skip the whole hook chain; .catch(() => null) consumes it so the winner is
|
|
138
|
+
// always a reference or null. The same catch also swallows a rejection that
|
|
139
|
+
// lands after the deadline already won, so no unhandled rejection escapes.
|
|
140
|
+
let timer;
|
|
141
|
+
const deadline = new Promise((resolve) => {
|
|
142
|
+
timer = setTimeout(() => resolve(null), deadlineMs);
|
|
143
|
+
timer.unref?.();
|
|
144
|
+
});
|
|
145
|
+
const winner = await Promise.race([stage.catch(() => null), deadline]);
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
return winner ?? inlineReference(payloadText, bytes);
|
|
148
|
+
}
|
|
149
|
+
|
|
93
150
|
// probePayloadIntegrity(reference) -> null | 'missing' | 'partial'
|
|
94
151
|
//
|
|
95
152
|
// O(1): one stat of the file this bridge staged. Called by the bridge after the
|
|
@@ -69,7 +69,13 @@ function normalizeElapsedMs(value) {
|
|
|
69
69
|
// Allowlist builder — the ONLY place row fields are chosen. `elapsedMs` may be
|
|
70
70
|
// null when the caller genuinely could not measure it (e.g. the hook exited
|
|
71
71
|
// before its stdin envelope was staged); unknown stays unknown, never zero.
|
|
72
|
+
//
|
|
73
|
+
// O3 (SPEC §FR-009): `stageMs` splits that elapsed window into the part the hook
|
|
74
|
+
// spent waiting on stdin staging and the part it spent executing. It is OPTIONAL
|
|
75
|
+
// — omitted entirely when unmeasurable — so a v1 reader's key set is unchanged
|
|
76
|
+
// for a row that has no stage data, and `TELEMETRY_VERSION` stays 1.
|
|
72
77
|
export function buildTimingRow(entry = {}) {
|
|
78
|
+
const stageMs = normalizeElapsedMs(entry.stageMs);
|
|
73
79
|
return {
|
|
74
80
|
v: TELEMETRY_VERSION,
|
|
75
81
|
ts: Number.isFinite(entry.ts) ? entry.ts : Date.now(),
|
|
@@ -79,6 +85,7 @@ export function buildTimingRow(entry = {}) {
|
|
|
79
85
|
hook: shortString(entry.hook, 128),
|
|
80
86
|
elapsedMs: normalizeElapsedMs(entry.elapsedMs),
|
|
81
87
|
outcome: shortString(entry.outcome, 32) || 'ok',
|
|
88
|
+
...(stageMs === null ? {} : { stageMs }),
|
|
82
89
|
};
|
|
83
90
|
}
|
|
84
91
|
|
|
@@ -242,13 +249,14 @@ export function recordHookTiming({
|
|
|
242
249
|
tool,
|
|
243
250
|
hook,
|
|
244
251
|
elapsedMs,
|
|
252
|
+
stageMs,
|
|
245
253
|
outcome,
|
|
246
254
|
toolUseId,
|
|
247
255
|
ts,
|
|
248
256
|
projectRoot,
|
|
249
257
|
maxBytes,
|
|
250
258
|
} = {}) {
|
|
251
|
-
const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome });
|
|
259
|
+
const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome, stageMs });
|
|
252
260
|
const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
253
261
|
return appendTelemetryRow(root, sessionId, row, { maxBytes });
|
|
254
262
|
}
|
|
@@ -256,6 +264,13 @@ export function recordHookTiming({
|
|
|
256
264
|
// --- `--finish` CLI: one short-lived node process per direct hook exit ------
|
|
257
265
|
// Invoked by ukit_hook_telemetry_finish (hook-telemetry.sh) from the shared
|
|
258
266
|
// cleanup path in hook-input.sh, while the staged payload file still exists.
|
|
267
|
+
//
|
|
268
|
+
// O1 (TASK-008): the same CLI serves hooks that deliberately never stage stdin
|
|
269
|
+
// (gate-first fast paths). Those have no payload mtime to measure from, so the
|
|
270
|
+
// arm helper hands over an explicit start plus the envelope's identifying
|
|
271
|
+
// fields: UKIT_TEL_START_MS (epoch ms), UKIT_TEL_SESSION_ID, UKIT_TEL_EVENT.
|
|
272
|
+
// Every override is optional and falsy-safe, so the 18 staging hooks that set
|
|
273
|
+
// none of them keep byte-for-byte today's behaviour.
|
|
259
274
|
|
|
260
275
|
const FINISH_PARSE_LIMIT = 2 * 1024 * 1024; // all hooks except record-execution stage <= 2 MiB
|
|
261
276
|
const FINISH_HEAD_BYTES = 256 * 1024; // bounded head scan for oversized payloads
|
|
@@ -318,32 +333,89 @@ function readEnvelope(inputFile) {
|
|
|
318
333
|
}
|
|
319
334
|
}
|
|
320
335
|
|
|
336
|
+
// Explicit arm start. Non-finite, zero, or negative values are ignored: a
|
|
337
|
+
// malformed marker must degrade to the staged-payload mtime (or to the honest
|
|
338
|
+
// null), never to a bogus "0ms" measurement.
|
|
339
|
+
function explicitStartMs(raw) {
|
|
340
|
+
const parsed = Number.parseInt(String(raw ?? ''), 10);
|
|
341
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
342
|
+
return parsed;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Start reference, most explicit first: the arm helper's epoch-ms reading, the
|
|
346
|
+
// arm marker's mtime (the shell could not express sub-second precision on this
|
|
347
|
+
// host), then the staged payload's mtime. None present stays null — an armed
|
|
348
|
+
// hook that lost its marker must not be reported as a zero-millisecond hook.
|
|
349
|
+
//
|
|
350
|
+
// O3 (SPEC §FR-009) needs the two ends SEPARATELY, not just their difference:
|
|
351
|
+
// when the payload's own mtime is the start, `elapsedMs` already begins where
|
|
352
|
+
// staging ended, so `source` is what tells the stage split that no window is
|
|
353
|
+
// left to measure.
|
|
354
|
+
function resolveStartRef() {
|
|
355
|
+
const explicit = explicitStartMs(process.env.UKIT_TEL_START_MS);
|
|
356
|
+
if (explicit !== null) return { startMs: explicit, source: 'explicit' };
|
|
357
|
+
const marker = process.env.UKIT_TEL_START_FILE || '';
|
|
358
|
+
if (marker) {
|
|
359
|
+
try {
|
|
360
|
+
return { startMs: fs.statSync(marker).mtimeMs, source: 'marker' };
|
|
361
|
+
} catch {
|
|
362
|
+
// Marker already reaped — fall through to the staged payload.
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const inputFile = process.env.UKIT_INPUT_FILE || '';
|
|
366
|
+
if (!inputFile) return { startMs: null, source: null };
|
|
367
|
+
try {
|
|
368
|
+
return { startMs: fs.statSync(inputFile).mtimeMs, source: 'payload' };
|
|
369
|
+
} catch {
|
|
370
|
+
return { startMs: null, source: null }; // never staged (refuse path) — unknown, not zero
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// O3 (SPEC §FR-009): the stage window = the staged payload's mtime minus the arm
|
|
375
|
+
// start, and only the two ARM sources qualify. A hook that deliberately skips
|
|
376
|
+
// stdin staging (gate-first fast paths, SPEC §14) has no payload at all, and an
|
|
377
|
+
// unarmed hook's start IS the payload mtime — reporting a stage from either
|
|
378
|
+
// would invent a window that never existed, so unmeasurable stays ABSENT.
|
|
379
|
+
// Clamped to the row's own elapsed window: the split attributes time inside that
|
|
380
|
+
// whole, it never exceeds it.
|
|
381
|
+
function resolveStageMs(startRef, elapsedMs) {
|
|
382
|
+
if (startRef.source !== 'explicit' && startRef.source !== 'marker') return null;
|
|
383
|
+
const inputFile = process.env.UKIT_INPUT_FILE || '';
|
|
384
|
+
if (!inputFile) return null;
|
|
385
|
+
let stagedAt;
|
|
386
|
+
try {
|
|
387
|
+
stagedAt = fs.statSync(inputFile).mtimeMs;
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const raw = stagedAt - startRef.startMs;
|
|
392
|
+
if (!Number.isFinite(raw) || raw < 0) return null;
|
|
393
|
+
return Math.min(raw, normalizeElapsedMs(elapsedMs) ?? raw);
|
|
394
|
+
}
|
|
395
|
+
|
|
321
396
|
function finishMain() {
|
|
322
397
|
try {
|
|
323
398
|
// process.uptime() covers this spawn itself so the row measures the hook,
|
|
324
399
|
// not the telemetry process startup.
|
|
325
400
|
const spawnOverheadMs = process.uptime() * 1000;
|
|
326
401
|
const inputFile = process.env.UKIT_INPUT_FILE || '';
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
} catch {
|
|
332
|
-
startMs = null; // envelope never staged (refuse path) — unknown, not zero
|
|
333
|
-
}
|
|
334
|
-
}
|
|
402
|
+
const startRef = resolveStartRef();
|
|
403
|
+
const elapsedMs = startRef.startMs === null
|
|
404
|
+
? null
|
|
405
|
+
: Date.now() - startRef.startMs - spawnOverheadMs;
|
|
335
406
|
const envelope = inputFile ? readEnvelope(inputFile) : {};
|
|
336
407
|
const rc = Number.parseInt(process.env.UKIT_TEL_RC ?? '', 10);
|
|
337
408
|
// The wrapper resolves PROJECT_ROOT before arming; fall back to the hook
|
|
338
409
|
// env's CLAUDE_PROJECT_DIR, then cwd (same resolution as the wrappers).
|
|
339
410
|
recordHookTiming({
|
|
340
411
|
projectRoot: process.env.PROJECT_ROOT || process.env.CLAUDE_PROJECT_DIR || undefined,
|
|
341
|
-
sessionId: envelope.session_id,
|
|
342
|
-
event: envelope.hook_event_name,
|
|
412
|
+
sessionId: process.env.UKIT_TEL_SESSION_ID || envelope.session_id,
|
|
413
|
+
event: process.env.UKIT_TEL_EVENT || envelope.hook_event_name,
|
|
343
414
|
tool: envelope.tool_name,
|
|
344
415
|
toolUseId: envelope.tool_use_id,
|
|
345
416
|
hook: process.env.UKIT_TEL_HOOK,
|
|
346
|
-
elapsedMs
|
|
417
|
+
elapsedMs,
|
|
418
|
+
stageMs: resolveStageMs(startRef, elapsedMs),
|
|
347
419
|
// Direct rows reuse the chain taxonomy (TASK-018): a non-zero exit is a
|
|
348
420
|
// verdict ('exit-code'), not a guess. Timeout/overflow kills bypass the
|
|
349
421
|
// EXIT trap, so those kinds stay chain-runner-reported.
|
|
@@ -11,12 +11,54 @@
|
|
|
11
11
|
# payload. Finish spawns at most one bounded node process, discards all output,
|
|
12
12
|
# and never changes the hook's exit status or verdict.
|
|
13
13
|
#
|
|
14
|
+
# O1 (TASK-008): hooks that never stage stdin (the gate-first fast paths wired
|
|
15
|
+
# in TASK-009) have no payload mtime to measure from. They call
|
|
16
|
+
# ukit_hook_telemetry_arm instead, which creates the same kind of start marker
|
|
17
|
+
# without adding a node spawn to the hot path — and trap
|
|
18
|
+
# ukit_hook_telemetry_finish on EXIT themselves.
|
|
19
|
+
#
|
|
14
20
|
# Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
|
|
15
21
|
# source this file with `|| true` and the armed flag stays unset.
|
|
16
22
|
|
|
17
23
|
UKIT_TEL_ARMED=1
|
|
18
24
|
UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
|
|
19
25
|
UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
|
+
UKIT_TEL_START_FILE=""
|
|
27
|
+
UKIT_TEL_START_MS=""
|
|
28
|
+
|
|
29
|
+
# Epoch milliseconds from a file's mtime, or empty when this host's stat cannot
|
|
30
|
+
# express sub-second precision. GNU coreutils is probed first: it rejects the
|
|
31
|
+
# BSD format string outright, while BSD stat would read a GNU format string as
|
|
32
|
+
# a list of directives and print something plausible-but-wrong.
|
|
33
|
+
ukit_tel_file_ms() {
|
|
34
|
+
local raw seconds frac
|
|
35
|
+
raw="$(/usr/bin/stat -c '%.3Y' "$1" 2>/dev/null)" || raw=""
|
|
36
|
+
case "$raw" in
|
|
37
|
+
''|*[!0-9.]*) raw="$(/usr/bin/stat -f '%Fm' "$1" 2>/dev/null)" || raw="" ;;
|
|
38
|
+
esac
|
|
39
|
+
case "$raw" in
|
|
40
|
+
*.*) seconds="${raw%%.*}"; frac="${raw#*.}" ;;
|
|
41
|
+
*) seconds="$raw"; frac="" ;;
|
|
42
|
+
esac
|
|
43
|
+
# Unknown stays unknown: an unparseable mtime yields empty, never a bogus 0.
|
|
44
|
+
case "$seconds" in
|
|
45
|
+
''|*[!0-9]*) return 0 ;;
|
|
46
|
+
esac
|
|
47
|
+
frac="${frac}000"
|
|
48
|
+
printf '%s' "$(( seconds * 1000 + 10#${frac:0:3} ))"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# No-staging arm path. A marker file whose mtime is the start reference costs
|
|
52
|
+
# one mktemp; a `node -e` clock read would cost ~30ms to measure milliseconds.
|
|
53
|
+
# Always returns 0 — an unarmed hook must behave exactly like an unmeasured one.
|
|
54
|
+
ukit_hook_telemetry_arm() {
|
|
55
|
+
UKIT_TEL_START_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-tel-start.XXXXXX" 2>/dev/null)" || {
|
|
56
|
+
UKIT_TEL_START_FILE=""
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
UKIT_TEL_START_MS="$(ukit_tel_file_ms "$UKIT_TEL_START_FILE")"
|
|
60
|
+
return 0
|
|
61
|
+
}
|
|
20
62
|
|
|
21
63
|
ukit_hook_telemetry_finish() {
|
|
22
64
|
# The hook's exit status arrives via UKIT_TEL_RC (ukit_cleanup_hook_input
|
|
@@ -44,6 +86,8 @@ ukit_hook_telemetry_finish() {
|
|
|
44
86
|
|
|
45
87
|
UKIT_TEL_RC="$rc" \
|
|
46
88
|
UKIT_TEL_HOOK="$UKIT_TEL_HOOK" \
|
|
89
|
+
UKIT_TEL_START_MS="${UKIT_TEL_START_MS:-}" \
|
|
90
|
+
UKIT_TEL_START_FILE="${UKIT_TEL_START_FILE:-}" \
|
|
47
91
|
UKIT_INPUT_FILE="${UKIT_INPUT_FILE:-}" \
|
|
48
92
|
PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-}}" \
|
|
49
93
|
node "$UKIT_TEL_RUNTIME_DIR/hook-telemetry.mjs" --finish </dev/null >/dev/null 2>&1 &
|
|
@@ -56,5 +100,11 @@ ukit_hook_telemetry_finish() {
|
|
|
56
100
|
if wait "$child" 2>/dev/null; then :; fi
|
|
57
101
|
kill "$watchdog" 2>/dev/null || true
|
|
58
102
|
if wait "$watchdog" 2>/dev/null; then :; fi
|
|
103
|
+
# The marker existed only to be stat'd by the child above; dropping it here
|
|
104
|
+
# keeps a long session from accumulating one temp file per hooked event.
|
|
105
|
+
if [ -n "${UKIT_TEL_START_FILE:-}" ]; then
|
|
106
|
+
rm -f "$UKIT_TEL_START_FILE" 2>/dev/null || true
|
|
107
|
+
UKIT_TEL_START_FILE=""
|
|
108
|
+
fi
|
|
59
109
|
return 0
|
|
60
110
|
}
|
|
@@ -168,7 +168,7 @@ export const DEDUPE_WINDOW_MS = 2000;
|
|
|
168
168
|
// Fixed budget slices inside the default 3000ms hook deadline: the ledger child
|
|
169
169
|
// gets the larger slice, each watchdog lock acquisition gets a short one.
|
|
170
170
|
const LEDGER_BUDGET_MS = 1500;
|
|
171
|
-
const LOCK_BUDGET_MS = 600;
|
|
171
|
+
export const LOCK_BUDGET_MS = 600;
|
|
172
172
|
|
|
173
173
|
const WATCHDOG_PROVENANCE_PREFIX = '[ukit-stop-coordinator] task-watchdog also requested a block: ';
|
|
174
174
|
const WATCHDOG_FAILURE_PREFIX = '[ukit-stop-coordinator] task-watchdog evaluator failed (advisory lane, reported verbatim): ';
|
|
@@ -453,28 +453,43 @@ function parseRunCursor(text) {
|
|
|
453
453
|
/**
|
|
454
454
|
* Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
|
|
455
455
|
* { signature, count }. Same signature as last time → count+1; a moved cursor
|
|
456
|
-
* resets the streak. Returns the committed streak count
|
|
457
|
-
*
|
|
456
|
+
* resets the streak. Returns the committed streak count.
|
|
457
|
+
*
|
|
458
|
+
* RC-4: a lock-busy acquisition used to return null, and the release branch
|
|
459
|
+
* required `streak !== null` — so a leaked state lock froze the
|
|
460
|
+
* stopGateMaxStalledBlocks breaker forever and the handoff-cursor lane became an
|
|
461
|
+
* unbounded bounce loop. The breaker must ALWAYS advance: on lock-busy (or any
|
|
462
|
+
* lock error) the same read-modify-write runs best-effort unlocked. A lost tick
|
|
463
|
+
* under a racing writer only delays the cap by one stop; a frozen breaker loops
|
|
464
|
+
* forever. Only a genuinely unwritable state file still returns null.
|
|
458
465
|
*/
|
|
459
466
|
async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
|
|
460
467
|
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
|
|
468
|
+
const bump = async () => {
|
|
469
|
+
let current = {};
|
|
470
|
+
try {
|
|
471
|
+
current = JSON.parse(await fs.readFile(statePath, 'utf8')) || {};
|
|
472
|
+
} catch {}
|
|
473
|
+
const prev = current.handoff || {};
|
|
474
|
+
const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
|
|
475
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
476
|
+
await fs.writeFile(
|
|
477
|
+
statePath,
|
|
478
|
+
`${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
|
|
479
|
+
'utf8',
|
|
480
|
+
);
|
|
481
|
+
return count;
|
|
482
|
+
};
|
|
461
483
|
try {
|
|
462
|
-
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs },
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
statePath,
|
|
472
|
-
`${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
|
|
473
|
-
'utf8',
|
|
474
|
-
);
|
|
475
|
-
return count;
|
|
476
|
-
});
|
|
477
|
-
return outcome.ok ? outcome.value : null;
|
|
484
|
+
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, bump);
|
|
485
|
+
if (outcome.ok) return outcome.value;
|
|
486
|
+
} catch {
|
|
487
|
+
// fall through to the best-effort tick below
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
// Lock-busy: count the stop as a stall tick anyway so the liveness breaker
|
|
491
|
+
// keeps advancing behind a wedged or leaked lock.
|
|
492
|
+
return await bump();
|
|
478
493
|
} catch {
|
|
479
494
|
return null;
|
|
480
495
|
}
|
|
@@ -547,7 +562,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
|
|
|
547
562
|
* invocation is a same-session duplicate inside the window. Fail-open — an
|
|
548
563
|
* unreadable/unlockable dedupe state must never disable the Stop coordinator.
|
|
549
564
|
*/
|
|
550
|
-
async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs }) {
|
|
565
|
+
export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs }) {
|
|
551
566
|
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
|
|
552
567
|
try {
|
|
553
568
|
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, async () => {
|
|
@@ -3,6 +3,8 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
|
+
import { journalDroppedLockMutation, withAsyncLock } from './async-lock.mjs';
|
|
7
|
+
|
|
6
8
|
export const DEFAULT_PROMPT_CACHE_MAX_ENTRIES = 20;
|
|
7
9
|
export const DEFAULT_COMPACT_HISTORY_MAX_ENTRIES = 50;
|
|
8
10
|
const DEFAULT_MAX_COMPACT_ANCHORS = 3;
|
|
@@ -60,7 +62,17 @@ export async function writeJson(filePath, value) {
|
|
|
60
62
|
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
61
63
|
try {
|
|
62
64
|
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
63
|
-
|
|
65
|
+
try {
|
|
66
|
+
await fs.rename(tempPath, filePath);
|
|
67
|
+
} catch (renameError) {
|
|
68
|
+
// EXDEV: the tmp file and the destination sit on different mounts (union
|
|
69
|
+
// mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
|
|
70
|
+
// The payload is already fully written — copy it over and unlink the tmp.
|
|
71
|
+
// Less atomic than rename, but the update must not be silently lost.
|
|
72
|
+
if (renameError?.code !== 'EXDEV') throw renameError;
|
|
73
|
+
await fs.copyFile(tempPath, filePath);
|
|
74
|
+
await fs.rm(tempPath, { force: true });
|
|
75
|
+
}
|
|
64
76
|
} catch (error) {
|
|
65
77
|
try {
|
|
66
78
|
await fs.rm(tempPath, { force: true });
|
|
@@ -74,140 +86,39 @@ export async function writeJson(filePath, value) {
|
|
|
74
86
|
const LOCK_STALE_MS = 10_000;
|
|
75
87
|
const LOCK_MAX_WAIT_MS = 5_000;
|
|
76
88
|
|
|
77
|
-
function lockBackoffDelayMs() {
|
|
78
|
-
return 3 + Math.floor(Math.random() * 9);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function sleep(ms) {
|
|
82
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function isPidAlive(pid) {
|
|
86
|
-
try {
|
|
87
|
-
process.kill(pid, 0);
|
|
88
|
-
return true;
|
|
89
|
-
} catch (error) {
|
|
90
|
-
// EPERM: the process exists but belongs to another user — still alive.
|
|
91
|
-
return error?.code === 'EPERM';
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function readLockOwner(lockPath) {
|
|
96
|
-
try {
|
|
97
|
-
const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
|
|
98
|
-
const pid = Number(raw?.pid);
|
|
99
|
-
return Number.isInteger(pid) && pid > 0
|
|
100
|
-
? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
|
|
101
|
-
: null;
|
|
102
|
-
} catch {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// In-process holder registry: same-pid holders are parallel async flows whose liveness a
|
|
108
|
-
// pid probe cannot prove, so the module tracks them itself.
|
|
109
|
-
const inProcessLockHolders = new Map();
|
|
110
|
-
|
|
111
89
|
/**
|
|
112
90
|
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
113
91
|
* (hook invocations run as separate node processes) and across concurrent async
|
|
114
92
|
* flows in one process (parallel subagents). The lock is a directory created next
|
|
115
93
|
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* pid
|
|
119
|
-
* reclaim
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
94
|
+
*
|
|
95
|
+
* The entire lock protocol lives in async-lock.mjs (TASK-004 fix round 1): owner
|
|
96
|
+
* stamping (pid + token + pstart), recycled-pid detection via recordedProcessGone,
|
|
97
|
+
* claim+quarantine stale reclaim, and verified release exist exactly once there —
|
|
98
|
+
* this function and the src/core/fileOps.js twin both delegate to it so the two
|
|
99
|
+
* protocol copies can never drift apart again.
|
|
100
|
+
*
|
|
101
|
+
* FAIL-CLOSED (TASK-004, unified with async-lock/ledger policy): if the lock cannot
|
|
102
|
+
* be acquired within maxWaitMs the callback is SKIPPED — never run unlocked — and
|
|
103
|
+
* the drop is journaled to `<file>.lock-drops.jsonl`. These state files are
|
|
104
|
+
* advisory caches: losing an update was already the accepted outcome of the old
|
|
105
|
+
* fail-open race; now it is explicit and journaled instead of a silent torn write.
|
|
128
106
|
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
129
107
|
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
130
|
-
* @returns {Promise
|
|
108
|
+
* @returns {Promise<*|undefined>} whatever fn resolves with, or undefined when the
|
|
109
|
+
* lock wait expired and the mutation was skipped (journaled)
|
|
131
110
|
*/
|
|
132
111
|
export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
locked = true;
|
|
144
|
-
inProcessLockHolders.set(lockPath, ownerToken);
|
|
145
|
-
try {
|
|
146
|
-
await fs.writeFile(
|
|
147
|
-
path.join(lockPath, 'owner'),
|
|
148
|
-
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
|
|
149
|
-
'utf8',
|
|
150
|
-
);
|
|
151
|
-
ownerStamped = true;
|
|
152
|
-
} catch {
|
|
153
|
-
ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
|
|
154
|
-
}
|
|
155
|
-
break;
|
|
156
|
-
} catch (error) {
|
|
157
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Someone holds the lock. Reclaim it only when the holder is provably gone.
|
|
161
|
-
try {
|
|
162
|
-
const stat = await fs.stat(lockPath);
|
|
163
|
-
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
164
|
-
const owner = await readLockOwner(lockPath);
|
|
165
|
-
const liveInProcess = inProcessLockHolders.has(lockPath);
|
|
166
|
-
// Stealing a live holder reintroduces the exact interleaved-write race this
|
|
167
|
-
// lock exists to prevent, and the stolen holder's release then deleted the
|
|
168
|
-
// successor's lock. Only a dead pid (or a leaked same-pid dir with no live
|
|
169
|
-
// registered flow) may be reclaimed.
|
|
170
|
-
const reclaimable = !owner || owner.pid === process.pid
|
|
171
|
-
? !liveInProcess
|
|
172
|
-
: !isPidAlive(owner.pid);
|
|
173
|
-
if (reclaimable) {
|
|
174
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
175
|
-
continue; // the slot is free now — retry immediately
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
} catch (statError) {
|
|
179
|
-
// BUG-C22-16: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
|
|
180
|
-
// mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
|
|
181
|
-
// EEXIST) must not busy-spin — a bare `continue` skipped both the
|
|
182
|
-
// maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
|
|
183
|
-
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
184
|
-
if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
|
|
185
|
-
await sleep(lockBackoffDelayMs());
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
190
|
-
await sleep(lockBackoffDelayMs());
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
return await fn();
|
|
195
|
-
} finally {
|
|
196
|
-
if (locked) {
|
|
197
|
-
try {
|
|
198
|
-
// Remove the lock only if THIS acquisition still owns it: after a stale reclaim
|
|
199
|
-
// another holder may already own the dir, and deleting it would unlock their
|
|
200
|
-
// critical section for a third waiter.
|
|
201
|
-
const current = ownerStamped ? await readLockOwner(lockPath) : null;
|
|
202
|
-
if (current && current.token === ownerToken) {
|
|
203
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
204
|
-
}
|
|
205
|
-
if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
|
|
206
|
-
} catch {
|
|
207
|
-
// best-effort release; a stale lock is reclaimed by the next waiter
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
112
|
+
const outcome = await withAsyncLock(filePath, { deadlineMs: maxWaitMs, staleMs }, fn);
|
|
113
|
+
if (outcome?.ok === true) return outcome.value;
|
|
114
|
+
// Fail closed: the mutation is dropped, never run unlocked. The drop is
|
|
115
|
+
// journaled so the lost update is auditable; callers treat undefined as
|
|
116
|
+
// "update skipped" (they already tolerated losing it silently).
|
|
117
|
+
await journalDroppedLockMutation(filePath, {
|
|
118
|
+
reason: 'lock-wait-expired',
|
|
119
|
+
waitedMs: outcome?.waitedMs ?? maxWaitMs,
|
|
120
|
+
});
|
|
121
|
+
return undefined;
|
|
211
122
|
}
|
|
212
123
|
|
|
213
124
|
export function buildCompactMachineKey(prefix, payload = {}) {
|
|
@@ -358,10 +358,6 @@
|
|
|
358
358
|
"non-trivial": "full test + lint + typecheck"
|
|
359
359
|
},
|
|
360
360
|
"verification": {
|
|
361
|
-
"requiredBeforeCompletion":
|
|
362
|
-
"{{runtime.packageManager}} test",
|
|
363
|
-
"{{runtime.packageManager}} lint",
|
|
364
|
-
"{{runtime.packageManager}} typecheck"
|
|
365
|
-
]
|
|
361
|
+
"requiredBeforeCompletion": {{verification.requiredBeforeCompletion}}
|
|
366
362
|
}
|
|
367
363
|
}
|
|
@@ -49,7 +49,7 @@ Systematic debugging — understand before fixing.
|
|
|
49
49
|
|
|
50
50
|
```
|
|
51
51
|
STATUS: DONE | BLOCKED | PARTIAL
|
|
52
|
-
EXECUTOR_TOOL: [claude-code |
|
|
52
|
+
EXECUTOR_TOOL: [claude-code | codex | omp | other]
|
|
53
53
|
EXECUTOR_MODEL: [exact model name you are running as. "unknown" if you cannot tell.]
|
|
54
54
|
EXECUTOR_SUBAGENT: [subagent name within your host, if any, else "-"]
|
|
55
55
|
SUMMARY: [1-2 sentences — root cause and fix]
|
|
@@ -71,9 +71,9 @@ and even then, report it, don't ask about it.
|
|
|
71
71
|
|
|
72
72
|
```
|
|
73
73
|
STATUS: DONE | BLOCKED | PARTIAL
|
|
74
|
-
EXECUTOR_TOOL: [claude-code |
|
|
74
|
+
EXECUTOR_TOOL: [claude-code | codex | omp | other]
|
|
75
75
|
EXECUTOR_MODEL: [exact model name you are running as — e.g. unic-code, claude-sonnet-4-5, gpt-5-mini. If you truly cannot tell, write "unknown" — reviewer treats unknown as suspicious and asks the human to confirm.]
|
|
76
|
-
EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "
|
|
76
|
+
EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Claude:feature-implementer", "omp:task". Otherwise "-".]
|
|
77
77
|
SUMMARY: [1-2 sentences of what was implemented]
|
|
78
78
|
TEST_PLAN_FOLLOWED: [task §4 / inline / N/A — reason]
|
|
79
79
|
FILES_CHANGED:
|