@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
|
@@ -151,6 +151,13 @@ process.stdin.on("end", () => {
|
|
|
151
151
|
}
|
|
152
152
|
})();
|
|
153
153
|
const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
|
|
154
|
+
// TASK-008 fix round: the pressure writer keys records by payload.session_id,
|
|
155
|
+
// and hosts that omit session_id on prompt/tool events (omp) now send the
|
|
156
|
+
// transcript path as the session key. When session_start DOES carry a real id
|
|
157
|
+
// but the writers did not, the record lives under the transcript key — delete
|
|
158
|
+
// both so the targeted reset cannot silently miss. Transcript paths are
|
|
159
|
+
// unique per session, so this can never touch a sibling record.
|
|
160
|
+
const transcriptKey = typeof payload.transcript_path === "string" ? payload.transcript_path.trim() : "";
|
|
154
161
|
const pressurePath = process.argv[1];
|
|
155
162
|
|
|
156
163
|
withLock(`${pressurePath}.lock`, async () => {
|
|
@@ -179,6 +186,9 @@ process.stdin.on("end", () => {
|
|
|
179
186
|
|
|
180
187
|
const sessions = { ...raw.sessions };
|
|
181
188
|
delete sessions[sessionId];
|
|
189
|
+
if (transcriptKey && transcriptKey !== sessionId) {
|
|
190
|
+
delete sessions[transcriptKey];
|
|
191
|
+
}
|
|
182
192
|
const newest = Object.entries(sessions)
|
|
183
193
|
.sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
|
|
184
194
|
const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
@@ -2523,6 +2523,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2523
2523
|
if (!entry || typeof entry !== 'object') {
|
|
2524
2524
|
return;
|
|
2525
2525
|
}
|
|
2526
|
+
// The sandbox starts without .ukit/storage/cache. Create it before the
|
|
2527
|
+
// sidecar append and lock acquisition; otherwise both operations fail with
|
|
2528
|
+
// ENOENT and withStateLock falls through to an unlocked read-modify-write.
|
|
2529
|
+
await ensureDir(filePath);
|
|
2526
2530
|
|
|
2527
2531
|
// BUG-C23-15: the entry is STAGED to an append-only sidecar before the lock is
|
|
2528
2532
|
// attempted (small appendFile is atomic — no read-modify-write, so no lost
|
|
@@ -2605,14 +2609,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2605
2609
|
if (!error || error.code !== 'EEXIST') return;
|
|
2606
2610
|
try {
|
|
2607
2611
|
const stat = fsSync.statSync(lockPath);
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2612
|
+
let owner = null;
|
|
2613
|
+
try {
|
|
2614
|
+
owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
|
|
2615
|
+
} catch {}
|
|
2616
|
+
// The watchdog may call process.exit while this process is in the
|
|
2617
|
+
// async merge. Its finally block cannot run after that synchronous
|
|
2618
|
+
// exit, so the exit fold must reclaim its own abandoned lock before
|
|
2619
|
+
// folding the staged sidecar. A live foreign owner remains protected.
|
|
2620
|
+
const ownInterruptedLock = owner?.pid === process.pid;
|
|
2621
|
+
if (ownInterruptedLock || Date.now() - stat.mtimeMs > 10000) {
|
|
2622
|
+
if (ownInterruptedLock || !owner || !isPidAlive(owner.pid)) {
|
|
2616
2623
|
fsSync.rmSync(lockPath, { recursive: true, force: true });
|
|
2617
2624
|
continue;
|
|
2618
2625
|
}
|
|
@@ -215,6 +215,9 @@ function isVerificationCommand(command) {
|
|
|
215
215
|
].some((pattern) => pattern.test(command));
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// F-15: execution-ledger.mjs mirrors this list in its own
|
|
219
|
+
// isExplicitBroadVerificationRequest — the completion gate honors the same
|
|
220
|
+
// explicit-broad signal when classifying a scope='broad' receipt. Keep both in sync.
|
|
218
221
|
function isExplicitBroadVerificationRequest(promptText) {
|
|
219
222
|
const text = String(promptText || '').toLowerCase();
|
|
220
223
|
return [
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
-
|
|
3
|
+
// `fsSync` aliases the SAME fs module — used ONLY by the process.on('exit')
|
|
4
|
+
// audit-sidecar fold below (event loop is gone; async cannot run) and by the
|
|
5
|
+
// module-bottom direct-CLI check. Normal-path fs stays async.
|
|
6
|
+
import * as fsSync from 'node:fs';
|
|
4
7
|
import path from 'node:path';
|
|
5
8
|
import crypto from 'node:crypto';
|
|
6
9
|
import { spawnSync } from 'node:child_process';
|
|
@@ -13,6 +16,7 @@ import {
|
|
|
13
16
|
} from './cache-utils.mjs';
|
|
14
17
|
import { ROUTE_CATALOG } from './route-catalog.mjs';
|
|
15
18
|
import { detectUnicGateway } from './unic-gateway.mjs';
|
|
19
|
+
import { withAsyncLock } from '../runtime/async-lock.mjs';
|
|
16
20
|
|
|
17
21
|
const {
|
|
18
22
|
resolveContext,
|
|
@@ -946,7 +950,7 @@ async function main() {
|
|
|
946
950
|
};
|
|
947
951
|
sharedState.fingerprint = buildRouteStateFingerprint(sharedState);
|
|
948
952
|
if (canPersistCachedRouteState) {
|
|
949
|
-
await
|
|
953
|
+
await persistSharedRouteState(sharedStatePath, sharedState);
|
|
950
954
|
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
951
955
|
state: sharedState,
|
|
952
956
|
}));
|
|
@@ -991,7 +995,7 @@ async function main() {
|
|
|
991
995
|
sessionId,
|
|
992
996
|
});
|
|
993
997
|
if (canPersistRouteState) {
|
|
994
|
-
await
|
|
998
|
+
await persistSharedRouteState(sharedStatePath, sharedState);
|
|
995
999
|
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
996
1000
|
route,
|
|
997
1001
|
state: sharedState,
|
|
@@ -3816,35 +3820,241 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
|
|
|
3816
3820
|
};
|
|
3817
3821
|
}
|
|
3818
3822
|
|
|
3819
|
-
|
|
3823
|
+
// CX-8: the shared route-state/audit writes must obey the same protocol as the
|
|
3824
|
+
// claude/omp writers (skill-router.sh + token-utils withFileLock): tmp+rename
|
|
3825
|
+
// atomicity AND the owner-token `<file>.lock` serialization. The pre-fix bare
|
|
3826
|
+
// fs.writeFile raced the locked writers — a torn skill-router-state.json parses
|
|
3827
|
+
// as `{}` downstream and releases a premature Stop; the unlocked audit
|
|
3828
|
+
// read-modify-write lost entries under concurrent routers.
|
|
3829
|
+
export async function writeJsonAtomic(filePath, value) {
|
|
3830
|
+
// tmp+rename: readers see old-or-new, never a torn/half file.
|
|
3831
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
3832
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
3833
|
+
try {
|
|
3834
|
+
await fs.writeFile(tempPath, JSON.stringify(value), 'utf8');
|
|
3835
|
+
await fs.rename(tempPath, filePath);
|
|
3836
|
+
} catch (error) {
|
|
3837
|
+
try {
|
|
3838
|
+
await fs.rm(tempPath, { force: true });
|
|
3839
|
+
} catch {
|
|
3840
|
+
// ignore cleanup errors
|
|
3841
|
+
}
|
|
3842
|
+
throw error;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
// The state write is a pure overwrite, but it must still serialize under the
|
|
3847
|
+
// owner-token lock so a codex writer cannot interleave with a locked claude/omp
|
|
3848
|
+
// writer mid-flight. Fail-closed (CX-8): a busy lock skips the write — the next
|
|
3849
|
+
// route recomputes and overwrites the whole document anyway, so a skipped
|
|
3850
|
+
// overwrite loses nothing; an unlocked one can interleave with a live holder.
|
|
3851
|
+
export async function persistSharedRouteState(filePath, state) {
|
|
3852
|
+
const result = await withAsyncLock(filePath, {}, () => writeJsonAtomic(filePath, state));
|
|
3853
|
+
if (!result.ok) {
|
|
3854
|
+
console.error(`route-task: skill-router-state lock ${result.reason} after ${result.waitedMs}ms — state write skipped (fail-closed)`);
|
|
3855
|
+
}
|
|
3856
|
+
return result;
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
// Ported from skill-router.sh entryDedupeKey — `|| null` (not `?? null`) so
|
|
3860
|
+
// falsy fields like repeatCount: 0 produce the same digest in both engines and
|
|
3861
|
+
// cross-engine dedupe of the shared route-audit.json cannot duplicate entries.
|
|
3862
|
+
function routeAuditEntryDedupeKey(item) {
|
|
3863
|
+
return stableMachineDigest({
|
|
3864
|
+
requestKey: item?.requestKey || null,
|
|
3865
|
+
executionMode: item?.executionMode || null,
|
|
3866
|
+
nextActionType: item?.nextActionType || null,
|
|
3867
|
+
nextMilestone: item?.nextMilestone || null,
|
|
3868
|
+
repeatCount: item?.repeatCount || null,
|
|
3869
|
+
rescueMode: item?.rescueMode || null,
|
|
3870
|
+
});
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
// Merge order: the fresh entry first, then staged sidecar lines, then the
|
|
3874
|
+
// existing file. A staged/existing row with the new entry's dedupe key is
|
|
3875
|
+
// replaced by the fresh entry; every other key keeps its first occurrence.
|
|
3876
|
+
function mergeRouteAuditEntries(staged, parsed, newEntry) {
|
|
3877
|
+
const dedupeKey = newEntry ? routeAuditEntryDedupeKey(newEntry) : null;
|
|
3878
|
+
const merged = [];
|
|
3879
|
+
const seen = new Set();
|
|
3880
|
+
for (const item of [newEntry, ...staged, ...(Array.isArray(parsed?.entries) ? parsed.entries : [])]) {
|
|
3881
|
+
if (!item || typeof item !== 'object') continue;
|
|
3882
|
+
const key = routeAuditEntryDedupeKey(item);
|
|
3883
|
+
if (dedupeKey && key === dedupeKey && item !== newEntry) continue;
|
|
3884
|
+
if (seen.has(key)) continue;
|
|
3885
|
+
seen.add(key);
|
|
3886
|
+
merged.push(item);
|
|
3887
|
+
}
|
|
3888
|
+
return merged.slice(0, 40);
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
function parseRouteAuditSidecar(raw) {
|
|
3892
|
+
const recovered = [];
|
|
3893
|
+
for (const line of String(raw || '').split('\n')) {
|
|
3894
|
+
if (!line.trim()) continue;
|
|
3895
|
+
try {
|
|
3896
|
+
const item = JSON.parse(line);
|
|
3897
|
+
if (item && typeof item === 'object') recovered.push(item);
|
|
3898
|
+
} catch {
|
|
3899
|
+
// skip unparseable sidecar lines
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
return recovered;
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
function isPidAlive(pid) {
|
|
3906
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
3907
|
+
try {
|
|
3908
|
+
process.kill(pid, 0);
|
|
3909
|
+
return true;
|
|
3910
|
+
} catch (error) {
|
|
3911
|
+
// EPERM: the process exists but belongs to another user — still alive.
|
|
3912
|
+
return error?.code === 'EPERM';
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
|
|
3916
|
+
// Synchronous last-chance fold for the process-exit path: process.on('exit')
|
|
3917
|
+
// handlers run without the event loop, so sync fs is legal here (fsSync alias).
|
|
3918
|
+
// Bounded spin-retry on the mkdir lock so the LAST exiting process still folds
|
|
3919
|
+
// staged entries after the previous holder releases. The sidecar is claimed by
|
|
3920
|
+
// rename (atomic): appends in flight land on the claimed inode and are read,
|
|
3921
|
+
// appends after the claim create a fresh sidecar for the next fold.
|
|
3922
|
+
function foldRouteAuditSidecarSync(filePath) {
|
|
3923
|
+
const sidecarPath = `${filePath}.unlocked.jsonl`;
|
|
3924
|
+
const lockPath = `${filePath}.lock`;
|
|
3925
|
+
const deadline = Date.now() + 300;
|
|
3926
|
+
let held = false;
|
|
3927
|
+
while (!held && Date.now() < deadline) {
|
|
3928
|
+
try {
|
|
3929
|
+
fsSync.mkdirSync(lockPath);
|
|
3930
|
+
held = true;
|
|
3931
|
+
} catch (error) {
|
|
3932
|
+
if (!error || error.code !== 'EEXIST') return;
|
|
3933
|
+
try {
|
|
3934
|
+
const stat = fsSync.statSync(lockPath);
|
|
3935
|
+
let owner = null;
|
|
3936
|
+
try {
|
|
3937
|
+
owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
|
|
3938
|
+
} catch {
|
|
3939
|
+
// unreadable owner — treated as no owner below
|
|
3940
|
+
}
|
|
3941
|
+
// An exit during our own async merge abandons our lock; reclaim it. A
|
|
3942
|
+
// live foreign owner stays protected; a dead/stale one is reclaimed.
|
|
3943
|
+
const ownInterruptedLock = owner?.pid === process.pid;
|
|
3944
|
+
if (ownInterruptedLock || Date.now() - stat.mtimeMs > 10000) {
|
|
3945
|
+
if (ownInterruptedLock || !owner || !isPidAlive(owner.pid)) {
|
|
3946
|
+
fsSync.rmSync(lockPath, { recursive: true, force: true });
|
|
3947
|
+
continue;
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
} catch {
|
|
3951
|
+
// stat failed — fall through to the bounded spin
|
|
3952
|
+
}
|
|
3953
|
+
const until = Date.now() + 5;
|
|
3954
|
+
while (Date.now() < until) { /* bounded spin */ }
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
if (!held) return;
|
|
3958
|
+
try {
|
|
3959
|
+
const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
|
|
3960
|
+
try {
|
|
3961
|
+
fsSync.renameSync(sidecarPath, claimedPath);
|
|
3962
|
+
} catch {
|
|
3963
|
+
return; // no staged sidecar — nothing to fold
|
|
3964
|
+
}
|
|
3965
|
+
let staged = [];
|
|
3966
|
+
try {
|
|
3967
|
+
staged = parseRouteAuditSidecar(fsSync.readFileSync(claimedPath, 'utf8'));
|
|
3968
|
+
} catch {
|
|
3969
|
+
// unreadable claim — treated as empty below
|
|
3970
|
+
}
|
|
3971
|
+
try {
|
|
3972
|
+
fsSync.rmSync(claimedPath, { force: true });
|
|
3973
|
+
} catch {
|
|
3974
|
+
// best-effort cleanup
|
|
3975
|
+
}
|
|
3976
|
+
if (!staged.length) return;
|
|
3977
|
+
let parsed = { entries: [] };
|
|
3978
|
+
try {
|
|
3979
|
+
parsed = JSON.parse(fsSync.readFileSync(filePath, 'utf8'));
|
|
3980
|
+
} catch {
|
|
3981
|
+
parsed = { entries: [] };
|
|
3982
|
+
}
|
|
3983
|
+
const tmpPath = `${filePath}.exitfold-${process.pid}`;
|
|
3984
|
+
fsSync.writeFileSync(tmpPath, JSON.stringify({
|
|
3985
|
+
entries: mergeRouteAuditEntries(staged, parsed, null),
|
|
3986
|
+
}));
|
|
3987
|
+
fsSync.renameSync(tmpPath, filePath);
|
|
3988
|
+
} finally {
|
|
3989
|
+
try {
|
|
3990
|
+
fsSync.rmSync(lockPath, { recursive: true, force: true });
|
|
3991
|
+
} catch {
|
|
3992
|
+
// best-effort release
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
const armedRouteAuditFolds = new Set();
|
|
3998
|
+
|
|
3999
|
+
export async function appendRouteAuditEntry(filePath, entry) {
|
|
3820
4000
|
if (!entry || typeof entry !== 'object') {
|
|
3821
4001
|
return;
|
|
3822
4002
|
}
|
|
4003
|
+
// The sandbox starts without .ukit/storage/cache. Create it before the
|
|
4004
|
+
// sidecar append and lock acquisition; otherwise both fail with ENOENT.
|
|
4005
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
3823
4006
|
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
}
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
4007
|
+
// The entry is STAGED to an append-only sidecar before the lock is attempted
|
|
4008
|
+
// (small appendFile is atomic — no read-modify-write, so no lost updates even
|
|
4009
|
+
// under full contention). Every lock holder folds all staged entries into
|
|
4010
|
+
// route-audit.json inside its merge; a synchronous last-chance fold is also
|
|
4011
|
+
// armed on process exit — whichever process exits last folds every staged
|
|
4012
|
+
// entry. The audit entry can never be silently dropped.
|
|
4013
|
+
const sidecarPath = `${filePath}.unlocked.jsonl`;
|
|
4014
|
+
try {
|
|
4015
|
+
await fs.appendFile(sidecarPath, `${JSON.stringify(entry)}\n`);
|
|
4016
|
+
} catch {
|
|
4017
|
+
// staging failed — still attempt the locked merge so the entry is not lost
|
|
4018
|
+
}
|
|
4019
|
+
if (!armedRouteAuditFolds.has(filePath)) {
|
|
4020
|
+
armedRouteAuditFolds.add(filePath);
|
|
4021
|
+
process.on('exit', () => foldRouteAuditSidecarSync(filePath));
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
const result = await withAsyncLock(filePath, {}, async () => {
|
|
4025
|
+
// Claim staged lines atomically inside the lock: rename detaches the inode,
|
|
4026
|
+
// so lines appended during the fold land in a fresh sidecar for the next
|
|
4027
|
+
// pass instead of being deleted unread.
|
|
4028
|
+
const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
|
|
4029
|
+
let claimed = false;
|
|
4030
|
+
try {
|
|
4031
|
+
await fs.rename(sidecarPath, claimedPath);
|
|
4032
|
+
claimed = true;
|
|
4033
|
+
} catch {
|
|
4034
|
+
// no sidecar — nothing staged
|
|
4035
|
+
}
|
|
4036
|
+
const sidecarEntries = claimed
|
|
4037
|
+
? parseRouteAuditSidecar(await fs.readFile(claimedPath, 'utf8').catch(() => ''))
|
|
4038
|
+
: [];
|
|
4039
|
+
if (claimed) {
|
|
4040
|
+
try {
|
|
4041
|
+
await fs.rm(claimedPath, { force: true });
|
|
4042
|
+
} catch {
|
|
4043
|
+
// best-effort cleanup
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
const parsed = await readJson(filePath, { entries: [] });
|
|
4048
|
+
await writeJsonAtomic(filePath, {
|
|
4049
|
+
entries: mergeRouteAuditEntries(sidecarEntries, parsed, entry),
|
|
3842
4050
|
});
|
|
3843
|
-
return itemKey !== dedupeKey;
|
|
3844
|
-
});
|
|
3845
|
-
await writeJson(filePath, {
|
|
3846
|
-
entries: [entry, ...filtered].slice(0, 40),
|
|
3847
4051
|
});
|
|
4052
|
+
if (!result.ok) {
|
|
4053
|
+
// Fail-closed on the merge (CX-8): the entry stays staged in the sidecar —
|
|
4054
|
+
// the next locked merge or the exit fold picks it up.
|
|
4055
|
+
console.error(`route-task: route-audit lock ${result.reason} after ${result.waitedMs}ms — audit entry staged to the append-only sidecar`);
|
|
4056
|
+
}
|
|
4057
|
+
return result;
|
|
3848
4058
|
}
|
|
3849
4059
|
|
|
3850
4060
|
async function readJson(filePath, fallback = null) {
|
|
@@ -3855,11 +4065,6 @@ async function readJson(filePath, fallback = null) {
|
|
|
3855
4065
|
}
|
|
3856
4066
|
}
|
|
3857
4067
|
|
|
3858
|
-
async function writeJson(filePath, value) {
|
|
3859
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
3860
|
-
await fs.writeFile(filePath, JSON.stringify(value), 'utf8');
|
|
3861
|
-
}
|
|
3862
|
-
|
|
3863
4068
|
function normalize(text) {
|
|
3864
4069
|
return String(text ?? '')
|
|
3865
4070
|
.toLowerCase()
|
|
@@ -4233,7 +4438,7 @@ function isDirectCliInvocation() {
|
|
|
4233
4438
|
if (import.meta.url === pathToFileURL(entry).href) {
|
|
4234
4439
|
return true;
|
|
4235
4440
|
}
|
|
4236
|
-
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
4441
|
+
return import.meta.url === pathToFileURL(fsSync.realpathSync(entry)).href;
|
|
4237
4442
|
} catch {
|
|
4238
4443
|
return false;
|
|
4239
4444
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
|
|
2
|
+
import { TRANSIENT_FS_CODES, withTransientFsRetry } from '../runtime/async-lock.mjs';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import fsSync from 'node:fs';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import {
|
|
6
8
|
classifySafePatchRisk,
|
|
@@ -8,7 +10,6 @@ import {
|
|
|
8
10
|
isSafePatchAdvisoryOnly,
|
|
9
11
|
lineNumberForIndex,
|
|
10
12
|
parseJsonInput,
|
|
11
|
-
pathExists,
|
|
12
13
|
readRuntimeSafePatchConfig,
|
|
13
14
|
resolveProjectFile,
|
|
14
15
|
summarizeSnippet,
|
|
@@ -40,6 +41,34 @@ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
|
40
41
|
}, HOOK_DEADLINE_MS).unref();
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// C44: the target reads below can surface a transient kernel EAGAIN on external
|
|
45
|
+
// volumes. Retry them inside a bounded slice of the hook deadline; on final
|
|
46
|
+
// failure rethrow with a transient-fs classification so the crash handler's
|
|
47
|
+
// stderr is identifiable (and the bridge's fail-open translation can match it).
|
|
48
|
+
const TRANSIENT_READ_DEADLINE_MS = 800;
|
|
49
|
+
|
|
50
|
+
function classifyTransientFsError(error) {
|
|
51
|
+
if (TRANSIENT_FS_CODES.has(error?.code)) {
|
|
52
|
+
throw new Error(`transient-fs ${error.code} on target read: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Existence probe that surfaces transient codes to the retry wrapper. The
|
|
58
|
+
// shared pathExists() helper swallows every error into `false`, which would
|
|
59
|
+
// silently degrade a transient EAGAIN into "new file" and skip the stale-spec
|
|
60
|
+
// check on a file that is actually there — non-transient misses keep that
|
|
61
|
+
// same false mapping here.
|
|
62
|
+
async function probeExists(filePath) {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(filePath);
|
|
65
|
+
return true;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (TRANSIENT_FS_CODES.has(error?.code)) throw error;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
function getToolName(payload = {}) {
|
|
44
73
|
return String(payload.tool_name || payload.tool || payload.name || '').trim();
|
|
45
74
|
}
|
|
@@ -66,12 +95,18 @@ export async function checkStaleSpec({ projectRoot = process.cwd(), payload = {}
|
|
|
66
95
|
const filePath = getFilePath(payload);
|
|
67
96
|
if (!filePath) return { status: 'skipped', reason: 'no-file' };
|
|
68
97
|
const resolved = resolveProjectFile(projectRoot, filePath);
|
|
69
|
-
const exists = await
|
|
98
|
+
const exists = await withTransientFsRetry(
|
|
99
|
+
() => probeExists(resolved.absolute),
|
|
100
|
+
{ deadlineMs: TRANSIENT_READ_DEADLINE_MS },
|
|
101
|
+
).catch(classifyTransientFsError);
|
|
70
102
|
|
|
71
103
|
let profile = null;
|
|
72
104
|
let risk = classifySafePatchRisk(resolved.relative, null, config);
|
|
73
105
|
if (exists) {
|
|
74
|
-
profile = await
|
|
106
|
+
profile = await withTransientFsRetry(
|
|
107
|
+
() => analyzeTextFile(resolved.absolute),
|
|
108
|
+
{ deadlineMs: TRANSIENT_READ_DEADLINE_MS },
|
|
109
|
+
).catch(classifyTransientFsError);
|
|
75
110
|
risk = classifySafePatchRisk(resolved.relative, profile, config);
|
|
76
111
|
}
|
|
77
112
|
const strict = Boolean(config.strictSharedRisk) && risk.strict;
|