@ngockhoale/ukit 2.7.8 → 2.7.10
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 +12 -0
- package/package.json +1 -1
- package/src/core/diffPlan.js +60 -1
- package/src/core/fileOps.js +10 -6
- package/templates/.claude/commands/ukit/handoff-clear.md +11 -0
- package/templates/.claude/commands/ukit/handoff-fullstack.md +25 -0
- package/templates/.claude/ukit/index/stale-spec-check.mjs +38 -3
- package/templates/.claude/ukit/runtime/async-lock.mjs +96 -32
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +75 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +59 -25
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
|
|
6
|
+
## 2.7.10 - 2026-09-22
|
|
7
|
+
|
|
8
|
+
- **User permission mode is never overwritten**: `ukit install`/`ukit update`
|
|
9
|
+
used to rewrite `.claude/settings.json` wholesale, so the template's
|
|
10
|
+
`permissions.defaultMode: "bypassPermissions"` silently clobbered a
|
|
11
|
+
user-chosen mode (e.g. `plan`) on every install. `defaultMode` is now
|
|
12
|
+
user-owned in `merge_env_overwrite_with_backup`: excluded from the drift
|
|
13
|
+
comparison and merged back into the written content. The template default
|
|
14
|
+
still applies on fresh installs or when the user never set a mode.
|
|
15
|
+
(`src/core/diffPlan.js`, +4 tests in `tests/core/diffPlan.test.js`)
|
|
16
|
+
|
|
5
17
|
## 2.7.8 - 2026-09-22
|
|
6
18
|
|
|
7
19
|
Stall/hang fixes — cycle C43 (TASK-001..008): the indefinite-stall producers
|
package/package.json
CHANGED
package/src/core/diffPlan.js
CHANGED
|
@@ -34,6 +34,54 @@ async function checkLinkStatus(targetPath, linkTarget) {
|
|
|
34
34
|
// template, so a template-side change to e.g. CLAUDE_CODE_AUTO_COMPACT_WINDOW still triggers
|
|
35
35
|
// a real reinstall. Without this carve-out, the managed gateway keys written by the post-apply
|
|
36
36
|
// step would cause a spurious "update" on every subsequent install.
|
|
37
|
+
//
|
|
38
|
+
// `permissions.defaultMode` is user-owned: the user picks their permission mode
|
|
39
|
+
// (bypassPermissions / plan / acceptEdits / default) and UKit must never change it.
|
|
40
|
+
// It is excluded from the drift comparison AND re-injected into the rendered content
|
|
41
|
+
// before write (mergeSettingsJson), so an install/update can never flip the user's mode.
|
|
42
|
+
// When the user has NOT set it, the template's shipped default applies on create/update.
|
|
43
|
+
const USER_OWNED_PERMISSION_KEYS = new Set(['defaultMode']);
|
|
44
|
+
|
|
45
|
+
function parseSettingsJson(content) {
|
|
46
|
+
if (typeof content !== 'string') return null;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(content);
|
|
49
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Strip user-owned permission keys from a parsed permissions object for comparison.
|
|
56
|
+
// Returns a new object; never mutates the input.
|
|
57
|
+
function stripUserOwnedPermissionKeys(permissions) {
|
|
58
|
+
if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) {
|
|
59
|
+
return permissions;
|
|
60
|
+
}
|
|
61
|
+
const stripped = { ...permissions };
|
|
62
|
+
for (const key of USER_OWNED_PERMISSION_KEYS) delete stripped[key];
|
|
63
|
+
return stripped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Merge rendered settings.json with the user's existing file: the rendered template
|
|
67
|
+
// wins every key EXCEPT user-owned permission keys (currently permissions.defaultMode),
|
|
68
|
+
// which keep the value the user set. Returns the merged JSON as a string, or the
|
|
69
|
+
// rendered content unchanged when either side is unparseable.
|
|
70
|
+
export function mergeSettingsJson(existingContent, renderedContent) {
|
|
71
|
+
const existing = parseSettingsJson(existingContent);
|
|
72
|
+
const rendered = parseSettingsJson(renderedContent);
|
|
73
|
+
if (!existing || !rendered) return renderedContent;
|
|
74
|
+
const merged = { ...rendered };
|
|
75
|
+
if (existing.permissions && typeof existing.permissions === 'object' && !Array.isArray(existing.permissions)) {
|
|
76
|
+
const mergedPermissions = { ...(rendered.permissions && typeof rendered.permissions === 'object' ? rendered.permissions : {}) };
|
|
77
|
+
for (const key of USER_OWNED_PERMISSION_KEYS) {
|
|
78
|
+
if (key in existing.permissions) mergedPermissions[key] = existing.permissions[key];
|
|
79
|
+
}
|
|
80
|
+
merged.permissions = mergedPermissions;
|
|
81
|
+
}
|
|
82
|
+
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
37
85
|
function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
|
|
38
86
|
if (typeof existingContent !== 'string' || typeof renderedContent !== 'string') {
|
|
39
87
|
return false;
|
|
@@ -56,7 +104,13 @@ function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
|
|
|
56
104
|
for (const key of Object.keys(parsedRendered)) {
|
|
57
105
|
if (key === 'env') continue;
|
|
58
106
|
if (!(key in parsedExisting)) return false;
|
|
59
|
-
|
|
107
|
+
const renderedValue = key === 'permissions'
|
|
108
|
+
? stripUserOwnedPermissionKeys(parsedRendered[key])
|
|
109
|
+
: parsedRendered[key];
|
|
110
|
+
const existingValue = key === 'permissions'
|
|
111
|
+
? stripUserOwnedPermissionKeys(parsedExisting[key])
|
|
112
|
+
: parsedExisting[key];
|
|
113
|
+
if (JSON.stringify(existingValue) !== JSON.stringify(renderedValue)) {
|
|
60
114
|
return false;
|
|
61
115
|
}
|
|
62
116
|
}
|
|
@@ -128,7 +182,12 @@ function resolveFileAction(entry, existingContent) {
|
|
|
128
182
|
// Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
|
|
129
183
|
// and may carry user overrides). All other top-level keys — and the rest of the env
|
|
130
184
|
// block — must still match the template, so genuine template updates still reinstall.
|
|
185
|
+
// User-owned permission keys (permissions.defaultMode) are excluded from the drift
|
|
186
|
+
// check AND merged back into the content that gets written, so install/update can
|
|
187
|
+
// never flip the user's chosen permission mode.
|
|
188
|
+
const merged = mergeSettingsJson(existingContent, entry.renderedContent);
|
|
131
189
|
action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent) ? 'unchanged' : 'update';
|
|
190
|
+
return { ...entry, renderedContent: merged, exists, action, existingContent };
|
|
132
191
|
} else if (existingContent === entry.renderedContent) {
|
|
133
192
|
action = 'unchanged';
|
|
134
193
|
} else if (entry.mergeStrategy === 'skip') {
|
package/src/core/fileOps.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
// The shipped runtime carries the single lock implementation; src and the
|
|
5
5
|
// installed package always ship templates/ together (package.json files list),
|
|
6
6
|
// so the protocol twin delegates instead of keeping a driftable copy.
|
|
7
|
-
import { journalDroppedLockMutation, withAsyncLock } from '../../templates/.claude/ukit/runtime/async-lock.mjs';
|
|
7
|
+
import { journalDroppedLockMutation, withAsyncLock, withTransientFsRetry } from '../../templates/.claude/ukit/runtime/async-lock.mjs';
|
|
8
8
|
|
|
9
9
|
export async function pathExists(targetPath) {
|
|
10
10
|
try {
|
|
@@ -103,21 +103,25 @@ export async function writeFileAtomic(filePath, content) {
|
|
|
103
103
|
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
104
104
|
|
|
105
105
|
try {
|
|
106
|
+
// C44: temp write, rename, and the EXDEV copy+rm fallback each retry
|
|
107
|
+
// transient kernel errors (EAGAIN/EBUSY/EMFILE/ENFILE/ESTALE) — the same
|
|
108
|
+
// transient-open EAGAIN observed on external APFS volumes under metadata
|
|
109
|
+
// churn. Non-transient codes (EXDEV, EISDIR) throw immediately, unchanged.
|
|
106
110
|
if (Buffer.isBuffer(content)) {
|
|
107
|
-
await fs.writeFile(tempPath, content);
|
|
111
|
+
await withTransientFsRetry(() => fs.writeFile(tempPath, content));
|
|
108
112
|
} else {
|
|
109
|
-
await fs.writeFile(tempPath, content, 'utf8');
|
|
113
|
+
await withTransientFsRetry(() => fs.writeFile(tempPath, content, 'utf8'));
|
|
110
114
|
}
|
|
111
115
|
try {
|
|
112
|
-
await fs.rename(tempPath, filePath);
|
|
116
|
+
await withTransientFsRetry(() => fs.rename(tempPath, filePath));
|
|
113
117
|
} catch (renameError) {
|
|
114
118
|
// EXDEV: the tmp file and the destination sit on different mounts (union
|
|
115
119
|
// mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
|
|
116
120
|
// The payload is already fully written — copy it over and unlink the tmp.
|
|
117
121
|
// Less atomic than rename, but the update must not be silently lost.
|
|
118
122
|
if (renameError?.code !== 'EXDEV') throw renameError;
|
|
119
|
-
await fs.copyFile(tempPath, filePath);
|
|
120
|
-
await fs.rm(tempPath, { force: true });
|
|
123
|
+
await withTransientFsRetry(() => fs.copyFile(tempPath, filePath));
|
|
124
|
+
await withTransientFsRetry(() => fs.rm(tempPath, { force: true }));
|
|
121
125
|
}
|
|
122
126
|
} catch (error) {
|
|
123
127
|
try {
|
|
@@ -42,6 +42,11 @@ Write `docs/AI_HANDOFF/archive/cycle-NNN.md` (if there is anything worth archivi
|
|
|
42
42
|
```
|
|
43
43
|
If `archive/` has > 3 files → delete oldest, append 1-line summary to `HISTORY.md`.
|
|
44
44
|
|
|
45
|
+
After writing the archive file, **verify the write** — read it back and confirm
|
|
46
|
+
the `ABORTED` heading is present; on mismatch or read failure retry once, on
|
|
47
|
+
second failure stop and report (same rule as handoff-fullstack's "State-file
|
|
48
|
+
write verification").
|
|
49
|
+
|
|
45
50
|
## Step 4 — Reset state files
|
|
46
51
|
|
|
47
52
|
```
|
|
@@ -54,6 +59,12 @@ RUN.md → set `Phase: done` (or delete) — a live cursor that is neither `do
|
|
|
54
59
|
tasks/TASK-*.md → delete all (keep _TEMPLATE.md)
|
|
55
60
|
```
|
|
56
61
|
|
|
62
|
+
Every file rewritten above is a state file — **verify the write** on each:
|
|
63
|
+
read it back and confirm the marker you just wrote (empty INDEX header,
|
|
64
|
+
`_(no active cycle)_`, `Phase: done`). On mismatch or read failure retry the
|
|
65
|
+
write once; on second failure STOP and report — never leave unverified state
|
|
66
|
+
files behind, or the next cycle resumes from stale bytes.
|
|
67
|
+
|
|
57
68
|
## Step 5 — Report
|
|
58
69
|
|
|
59
70
|
```
|
|
@@ -75,6 +75,23 @@ QuietScans: <n>/<required> # only while sweeping for stragglers near the end
|
|
|
75
75
|
This file is the resume contract. It costs one small write per step and is what turns an
|
|
76
76
|
interrupted run into a continuable one.
|
|
77
77
|
|
|
78
|
+
### State-file write verification — "verify the write"
|
|
79
|
+
|
|
80
|
+
Every Write/Edit to a handoff state file — `INDEX.md`, `ACTIVE.md`, `RUN.md`,
|
|
81
|
+
`SPEC.md`, `PLAN.md`, `tasks/TASK-*.md` — can be silently lost to a transient
|
|
82
|
+
filesystem error (the Write tool's own `open()` has been observed to fail with
|
|
83
|
+
EAGAIN while the write is reported as done). After each such write:
|
|
84
|
+
|
|
85
|
+
1. **Read it back** and confirm the marker you just wrote is present — the
|
|
86
|
+
`Status:` row, `Phase:`/`Cursor:` line, or section heading the write was
|
|
87
|
+
supposed to change.
|
|
88
|
+
2. **On mismatch or read failure, retry the write once.**
|
|
89
|
+
3. **On second failure, STOP the pipeline** and report the file plus the error —
|
|
90
|
+
never continue on unverified state. A stale `INDEX.md`/`RUN.md` is how a run
|
|
91
|
+
ships silent corruption.
|
|
92
|
+
|
|
93
|
+
Wherever a step below says **verify the write**, it means this rule.
|
|
94
|
+
|
|
78
95
|
### Resume — when the run is re-invoked mid-flight
|
|
79
96
|
|
|
80
97
|
If `docs/AI_HANDOFF/RUN.md` exists with `Phase:` not `done` or `blocked`, this is a
|
|
@@ -240,6 +257,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
|
|
|
240
257
|
6. **Update `docs/AI_HANDOFF/INDEX.md`** — one row per task, `status=ready`. Every
|
|
241
258
|
unfinished item from the Phase 0 sweep is either a row here or superseded by a
|
|
242
259
|
`-R<n>` recovery row.
|
|
260
|
+
Then **verify the write** (read back the new rows).
|
|
243
261
|
|
|
244
262
|
7. **Update `docs/AI_HANDOFF/ACTIVE.md`:**
|
|
245
263
|
```
|
|
@@ -249,6 +267,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
|
|
|
249
267
|
Tasks: <N> total
|
|
250
268
|
Status: planning_done — ready for executor
|
|
251
269
|
```
|
|
270
|
+
Then **verify the write** (read back the `Status:` line).
|
|
252
271
|
|
|
253
272
|
8. **Report:** task IDs, dependency graph, recovery/superseded tasks, any
|
|
254
273
|
`needs_breakdown` tasks + reason.
|
|
@@ -444,6 +463,7 @@ already on disk (task files hold the full logs, git holds the code). Do all four
|
|
|
444
463
|
|
|
445
464
|
2. **Update the run cursor** — rewrite `docs/AI_HANDOFF/RUN.md` with `Phase: I3`,
|
|
446
465
|
`Cursor: wave <N> done`, `Next: wave <N+1>` (or `I4` if that was the last wave).
|
|
466
|
+
Then **verify the write** (read back the `Phase:`/`Cursor:` lines).
|
|
447
467
|
|
|
448
468
|
3. **Collapse the wave in working memory.** From this point on, refer to the finished wave
|
|
449
469
|
only by its one-line-per-task summary (`TASK-xxx PASS <files>`). Do not re-read the task
|
|
@@ -469,6 +489,7 @@ logs used to cost. That difference is what makes a multi-wave cycle finish in on
|
|
|
469
489
|
- PASS tasks → `pending_review`
|
|
470
490
|
- FAIL tasks → `blocked`
|
|
471
491
|
- EXECUTOR_MODEL missing → `needs_executor_report`
|
|
492
|
+
Then **verify the write** (read back the updated status column).
|
|
472
493
|
|
|
473
494
|
2. Verify cleanup:
|
|
474
495
|
```bash
|
|
@@ -647,6 +668,8 @@ conventions so a future session needs no transcript to understand the cycle:
|
|
|
647
668
|
summary in `HISTORY.md` and remove it.
|
|
648
669
|
3. Reset `ACTIVE.md` to its empty template; clear `INDEX.md` rows to a fresh header;
|
|
649
670
|
remove `tasks/TASK-*.md`; clear `PLAN.md`/`SPEC.md` (templates stay).
|
|
671
|
+
Then **verify the write** on each reset file (read back the empty header /
|
|
672
|
+
template marker).
|
|
650
673
|
4. Commit the docs + archive changes:
|
|
651
674
|
`git add -A && git commit -m "handoff: finalize + archive cycle <ID>"`
|
|
652
675
|
|
|
@@ -654,6 +677,8 @@ conventions so a future session needs no transcript to understand the cycle:
|
|
|
654
677
|
|
|
655
678
|
Set `Phase: done` in `docs/AI_HANDOFF/RUN.md`, disarm any watchdog job armed at the
|
|
656
679
|
start, and emit the Final Report below.
|
|
680
|
+
Then **verify the write** (read back `Phase: done`) — a lost cursor write
|
|
681
|
+
leaves the next session resuming a finished run.
|
|
657
682
|
|
|
658
683
|
---
|
|
659
684
|
|
|
@@ -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;
|
|
@@ -94,6 +94,58 @@ function sleepWithAbort(ms, signal) {
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
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
|
+
|
|
97
149
|
function isPidAlive(pid) {
|
|
98
150
|
try {
|
|
99
151
|
process.kill(pid, 0);
|
|
@@ -153,9 +205,9 @@ function recordedProcessGone(owner) {
|
|
|
153
205
|
return Math.abs(actual - stamped) > PID_START_TOLERANCE_MS;
|
|
154
206
|
}
|
|
155
207
|
|
|
156
|
-
async function readLockOwner(lockPath) {
|
|
208
|
+
async function readLockOwner(lockPath, retry = withTransientFsRetry) {
|
|
157
209
|
try {
|
|
158
|
-
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')));
|
|
159
211
|
const pid = Number(raw?.pid);
|
|
160
212
|
const pstart = Number(raw?.pstart);
|
|
161
213
|
return Number.isInteger(pid) && pid > 0
|
|
@@ -177,9 +229,9 @@ function sameLockOwner(left, right) {
|
|
|
177
229
|
return left.pid === right.pid && left.token === right.token;
|
|
178
230
|
}
|
|
179
231
|
|
|
180
|
-
async function readReclaimOwner(lockPath) {
|
|
232
|
+
async function readReclaimOwner(lockPath, retry = withTransientFsRetry) {
|
|
181
233
|
try {
|
|
182
|
-
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')));
|
|
183
235
|
const pid = Number(raw?.pid);
|
|
184
236
|
const pstart = Number(raw?.pstart);
|
|
185
237
|
return Number.isInteger(pid) && pid > 0
|
|
@@ -194,19 +246,20 @@ async function readReclaimOwner(lockPath) {
|
|
|
194
246
|
}
|
|
195
247
|
}
|
|
196
248
|
|
|
249
|
+
|
|
197
250
|
// Stale observers must claim the existing lock before removing it. The claim lives
|
|
198
251
|
// inside the old generation, so a second observer cannot remove that generation while
|
|
199
252
|
// the first observer is between its owner check and rm(). This closes the TOCTOU race
|
|
200
253
|
// where a delayed stale observer deleted a freshly acquired successor lock.
|
|
201
|
-
async function claimReclaim(lockPath, ownerToken, staleMs) {
|
|
254
|
+
async function claimReclaim(lockPath, ownerToken, staleMs, retry = withTransientFsRetry) {
|
|
202
255
|
const reclaimPath = path.join(lockPath, RECLAIM_FILE);
|
|
203
256
|
try {
|
|
204
|
-
const handle = await fs.open(reclaimPath, 'wx');
|
|
257
|
+
const handle = await retry(() => fs.open(reclaimPath, 'wx'));
|
|
205
258
|
try {
|
|
206
|
-
await handle.writeFile(
|
|
259
|
+
await retry(() => handle.writeFile(
|
|
207
260
|
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now(), pstart: PROCESS_START_MS })}\n`,
|
|
208
261
|
'utf8',
|
|
209
|
-
);
|
|
262
|
+
));
|
|
210
263
|
} finally {
|
|
211
264
|
await handle.close();
|
|
212
265
|
}
|
|
@@ -217,11 +270,11 @@ async function claimReclaim(lockPath, ownerToken, staleMs) {
|
|
|
217
270
|
// A killed reclaimer can leave its claim behind. Only clear an old claim whose
|
|
218
271
|
// recorded process is gone; a live claim remains the exclusive reclaim authority.
|
|
219
272
|
try {
|
|
220
|
-
const stat = await fs.stat(reclaimPath);
|
|
273
|
+
const stat = await retry(() => fs.stat(reclaimPath));
|
|
221
274
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
222
|
-
const claim = await readReclaimOwner(lockPath);
|
|
275
|
+
const claim = await readReclaimOwner(lockPath, retry);
|
|
223
276
|
if (!claim || recordedProcessGone(claim)) {
|
|
224
|
-
await fs.rm(reclaimPath, { force: true });
|
|
277
|
+
await retry(() => fs.rm(reclaimPath, { force: true }));
|
|
225
278
|
}
|
|
226
279
|
}
|
|
227
280
|
} catch {
|
|
@@ -231,38 +284,38 @@ async function claimReclaim(lockPath, ownerToken, staleMs) {
|
|
|
231
284
|
}
|
|
232
285
|
}
|
|
233
286
|
|
|
234
|
-
async function releaseReclaimClaim(lockPath, ownerToken) {
|
|
287
|
+
async function releaseReclaimClaim(lockPath, ownerToken, retry = withTransientFsRetry) {
|
|
235
288
|
try {
|
|
236
|
-
const claim = await readReclaimOwner(lockPath);
|
|
289
|
+
const claim = await readReclaimOwner(lockPath, retry);
|
|
237
290
|
if (claim?.token === ownerToken) {
|
|
238
|
-
await fs.rm(path.join(lockPath, RECLAIM_FILE), { force: true });
|
|
291
|
+
await retry(() => fs.rm(path.join(lockPath, RECLAIM_FILE), { force: true }));
|
|
239
292
|
}
|
|
240
293
|
} catch {
|
|
241
294
|
// best-effort claim release; the stale-claim path handles an interrupted cleanup
|
|
242
295
|
}
|
|
243
296
|
}
|
|
244
297
|
|
|
245
|
-
async function quarantineReclaim(lockPath, owner, ownerToken) {
|
|
298
|
+
async function quarantineReclaim(lockPath, owner, ownerToken, retry = withTransientFsRetry) {
|
|
246
299
|
const quarantinePath = `${lockPath}.reclaim-${ownerToken}`;
|
|
247
300
|
try {
|
|
248
301
|
// The claim serializes stale observers. Re-read before the atomic rename so an
|
|
249
302
|
// observer never detaches a generation different from the one it validated.
|
|
250
|
-
const current = await readLockOwner(lockPath);
|
|
303
|
+
const current = await readLockOwner(lockPath, retry);
|
|
251
304
|
if (!sameLockOwner(current, owner)) {
|
|
252
|
-
await releaseReclaimClaim(lockPath, ownerToken);
|
|
305
|
+
await releaseReclaimClaim(lockPath, ownerToken, retry);
|
|
253
306
|
return false;
|
|
254
307
|
}
|
|
255
308
|
// Rename is atomic within the lock's parent directory: the old generation is
|
|
256
309
|
// detached as one filesystem operation, so a successor created at lockPath can
|
|
257
310
|
// never be reached by cleanup of this quarantined generation.
|
|
258
|
-
await fs.rename(lockPath, quarantinePath);
|
|
259
|
-
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 }));
|
|
260
313
|
return true;
|
|
261
314
|
} catch {
|
|
262
315
|
// Never recursively remove lockPath here. If rename lost a race or failed, the
|
|
263
316
|
// original path belongs to whoever currently holds it; a later poll can retry.
|
|
264
317
|
try {
|
|
265
|
-
await fs.rm(quarantinePath, { recursive: true, force: true });
|
|
318
|
+
await retry(() => fs.rm(quarantinePath, { recursive: true, force: true }));
|
|
266
319
|
} catch {
|
|
267
320
|
// best-effort cleanup of only this acquisition's quarantine path
|
|
268
321
|
}
|
|
@@ -356,20 +409,31 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
356
409
|
// True only when THIS acquisition holds a lock it can prove it owns: the atomic
|
|
357
410
|
// mkdir succeeded AND the owner stamp is on disk. Release is verified against it.
|
|
358
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 });
|
|
359
423
|
|
|
360
424
|
while (true) {
|
|
361
425
|
try {
|
|
362
426
|
// The lock parent must exist before the atomic acquire — a first-ever run in a
|
|
363
427
|
// fresh project would otherwise fail mkdir with ENOENT.
|
|
364
|
-
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
365
|
-
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
|
|
366
430
|
inProcessLockHolders.set(lockPath, ownerToken);
|
|
367
431
|
try {
|
|
368
|
-
await fs.writeFile(
|
|
432
|
+
await retry(() => fs.writeFile(
|
|
369
433
|
path.join(lockPath, 'owner'),
|
|
370
434
|
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now(), pstart: PROCESS_START_MS })}\n`,
|
|
371
435
|
'utf8',
|
|
372
|
-
);
|
|
436
|
+
));
|
|
373
437
|
} catch {
|
|
374
438
|
// Fail closed: an unstamped lock is not ours to enter. Running the callback
|
|
375
439
|
// anyway would leave an ownerless directory, and every other process reads a
|
|
@@ -377,11 +441,11 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
377
441
|
// lock mid-critical-section and letting two mutations interleave. Undo the
|
|
378
442
|
// acquire and report the typed busy outcome callers already handle.
|
|
379
443
|
try {
|
|
380
|
-
const current = await readLockOwner(lockPath);
|
|
444
|
+
const current = await readLockOwner(lockPath, retry);
|
|
381
445
|
// Never remove a directory some other holder has since stamped (only
|
|
382
446
|
// possible if this one was reclaimed in the window above).
|
|
383
447
|
if (!current || current.token === ownerToken) {
|
|
384
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
448
|
+
await retry(() => fs.rm(lockPath, { recursive: true, force: true }));
|
|
385
449
|
}
|
|
386
450
|
} catch {
|
|
387
451
|
// best-effort undo; a leftover dir is unheld and reclaimed by the next waiter
|
|
@@ -408,12 +472,12 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
408
472
|
// (stamped pstart no longer matches the running process).
|
|
409
473
|
let reclaimed = false;
|
|
410
474
|
try {
|
|
411
|
-
const stat = await fs.stat(lockPath);
|
|
475
|
+
const stat = await retry(() => fs.stat(lockPath));
|
|
412
476
|
const ageMs = Date.now() - stat.mtimeMs;
|
|
413
477
|
const remainingMs = budget - (Date.now() - startedAt);
|
|
414
478
|
const effectiveStaleMs = remainingMs > 0 ? Math.min(stale, remainingMs) : stale;
|
|
415
479
|
if (ageMs > effectiveStaleMs) {
|
|
416
|
-
const owner = await readLockOwner(lockPath);
|
|
480
|
+
const owner = await readLockOwner(lockPath, retry);
|
|
417
481
|
const liveInProcess = inProcessLockHolders.has(lockPath);
|
|
418
482
|
// An ownerless lock may be a holder mid-stamp — only the FULL stale
|
|
419
483
|
// threshold proves abandonment there. A stamped owner provably gone
|
|
@@ -424,11 +488,11 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
424
488
|
: owner.pid === process.pid
|
|
425
489
|
? !liveInProcess
|
|
426
490
|
: recordedProcessGone(owner);
|
|
427
|
-
if (reclaimable && await claimReclaim(lockPath, ownerToken, stale)) {
|
|
491
|
+
if (reclaimable && await claimReclaim(lockPath, ownerToken, stale, retry)) {
|
|
428
492
|
// Detach and clean only the generation that was validated. The atomic rename
|
|
429
493
|
// makes this safe even when another process acquires lockPath immediately
|
|
430
494
|
// after the stale generation is removed.
|
|
431
|
-
reclaimed = await quarantineReclaim(lockPath, owner, ownerToken);
|
|
495
|
+
reclaimed = await quarantineReclaim(lockPath, owner, ownerToken, retry);
|
|
432
496
|
}
|
|
433
497
|
}
|
|
434
498
|
} catch {
|
|
@@ -461,9 +525,9 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
461
525
|
// Remove the lock only if THIS acquisition still owns it: after a stale
|
|
462
526
|
// reclaim another holder may already own the dir, and deleting it would
|
|
463
527
|
// unlock their critical section for a third waiter.
|
|
464
|
-
const current = await readLockOwner(lockPath);
|
|
528
|
+
const current = await readLockOwner(lockPath, releaseRetry);
|
|
465
529
|
if (current && current.token === ownerToken) {
|
|
466
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
530
|
+
await releaseRetry(() => fs.rm(lockPath, { recursive: true, force: true }));
|
|
467
531
|
}
|
|
468
532
|
} catch {
|
|
469
533
|
// best-effort release; a leaked dir is reclaimed by the next waiter
|
|
@@ -42,6 +42,7 @@ function inlineReference(payloadText, bytes) {
|
|
|
42
42
|
path: null,
|
|
43
43
|
bytes,
|
|
44
44
|
cleanup() { /* nothing was staged — O(1) by construction */ },
|
|
45
|
+
async cleanupAsync() { /* nothing was staged — O(1) by construction */ },
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -125,8 +126,15 @@ export async function createPayloadReferenceAsync(text, {
|
|
|
125
126
|
text: payloadText,
|
|
126
127
|
path: finalPath,
|
|
127
128
|
bytes,
|
|
129
|
+
// TASK-002 (OMP-4): the bridge request path awaits cleanupAsync(); the
|
|
130
|
+
// sync-named cleanup() is kept for tests/CLI but must not run a blocking
|
|
131
|
+
// fs call on the omp host loop either — it delegates to the same async
|
|
132
|
+
// removal fire-and-forget.
|
|
128
133
|
cleanup() {
|
|
129
|
-
|
|
134
|
+
fsp.rm(finalPath, { force: true }).catch(() => { /* best effort */ });
|
|
135
|
+
},
|
|
136
|
+
async cleanupAsync() {
|
|
137
|
+
try { await fsp.rm(finalPath, { force: true }); } catch { /* best effort */ }
|
|
130
138
|
},
|
|
131
139
|
};
|
|
132
140
|
})();
|
|
@@ -163,6 +171,72 @@ export function probePayloadIntegrity(reference) {
|
|
|
163
171
|
}
|
|
164
172
|
}
|
|
165
173
|
|
|
174
|
+
// probePayloadIntegrityAsync(reference) -> Promise<null | 'missing' | 'partial'>
|
|
175
|
+
//
|
|
176
|
+
// TASK-002 (OMP-4): async twin of probePayloadIntegrity. The sync variant's
|
|
177
|
+
// statSync runs on the CALLER's event loop — on the omp host a stalled mount
|
|
178
|
+
// freezes the whole app (the socket-closed bug class). Identical verdicts.
|
|
179
|
+
export async function probePayloadIntegrityAsync(reference) {
|
|
180
|
+
if (!reference || reference.mode !== 'file' || !reference.path) return null;
|
|
181
|
+
try {
|
|
182
|
+
const stats = await fsp.stat(reference.path);
|
|
183
|
+
return stats.size === reference.bytes ? null : 'partial';
|
|
184
|
+
} catch {
|
|
185
|
+
return 'missing';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// sweepStalePayloadsAsync(dir, {now, maxAgeMs, maxEntries}) ->
|
|
190
|
+
// Promise<{sampled, scanned, removed}>
|
|
191
|
+
//
|
|
192
|
+
// TASK-002 (OMP-4): async twin of sweepStalePayloads — same bounded semantics
|
|
193
|
+
// through fs.promises so the omp host loop never blocks on directory work.
|
|
194
|
+
export async function sweepStalePayloadsAsync(dir, {
|
|
195
|
+
now = Date.now,
|
|
196
|
+
maxAgeMs = SWEEP_MAX_AGE_MS,
|
|
197
|
+
maxEntries = SWEEP_MAX_ENTRIES,
|
|
198
|
+
} = {}) {
|
|
199
|
+
let names;
|
|
200
|
+
try {
|
|
201
|
+
names = await fsp.readdir(dir);
|
|
202
|
+
} catch {
|
|
203
|
+
return { sampled: true, scanned: 0, removed: 0 };
|
|
204
|
+
}
|
|
205
|
+
const cutoff = now() - maxAgeMs;
|
|
206
|
+
let scanned = 0;
|
|
207
|
+
let removed = 0;
|
|
208
|
+
for (const name of names) {
|
|
209
|
+
if (scanned >= maxEntries) break;
|
|
210
|
+
scanned += 1;
|
|
211
|
+
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
|
212
|
+
const filePath = path.join(dir, name);
|
|
213
|
+
try {
|
|
214
|
+
if ((await fsp.stat(filePath)).mtimeMs < cutoff) {
|
|
215
|
+
await fsp.rm(filePath, { force: true });
|
|
216
|
+
removed += 1;
|
|
217
|
+
}
|
|
218
|
+
} catch { /* raced away — fine */ }
|
|
219
|
+
}
|
|
220
|
+
return { sampled: true, scanned, removed };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// maybeSweepStalePayloadsAsync(dir, {probability, random, ...}) ->
|
|
224
|
+
// Promise<sweep report>
|
|
225
|
+
//
|
|
226
|
+
// TASK-002 (OMP-4): async twin of maybeSweepStalePayloads — identical sampled
|
|
227
|
+
// gate, delegating to sweepStalePayloadsAsync.
|
|
228
|
+
export async function maybeSweepStalePayloadsAsync(dir, {
|
|
229
|
+
probability,
|
|
230
|
+
random = Math.random,
|
|
231
|
+
now,
|
|
232
|
+
maxAgeMs,
|
|
233
|
+
maxEntries,
|
|
234
|
+
} = {}) {
|
|
235
|
+
const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
|
|
236
|
+
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
237
|
+
return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries });
|
|
238
|
+
}
|
|
239
|
+
|
|
166
240
|
// sweepStalePayloads(dir, {now, maxAgeMs, maxEntries}) -> {sampled, scanned, removed}
|
|
167
241
|
//
|
|
168
242
|
// Bounded: processes at most maxEntries directory entries regardless of how many
|
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
PAYLOAD_INLINE_MAX_BYTES,
|
|
22
22
|
createPayloadReferenceAsync,
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
maybeSweepStalePayloadsAsync,
|
|
24
|
+
probePayloadIntegrityAsync,
|
|
25
25
|
} from '../../../.claude/ukit/runtime/hook-payload-store.mjs';
|
|
26
26
|
// TASK-018 review fix round 1: the chain budget is resolved by ONE shared module,
|
|
27
27
|
// so the runner's inner deadline and this bridge's outer pi.exec timeout can never
|
|
@@ -146,6 +146,17 @@ function classifyFailure(scriptName) {
|
|
|
146
146
|
// closed (the .sh thin wrapper is still invocable as a fallback path).
|
|
147
147
|
const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh', 'block-dangerous.mjs']);
|
|
148
148
|
|
|
149
|
+
// TASK-002 (OMP-4): a hook crash or transport failure whose stderr is a
|
|
150
|
+
// transient filesystem error (EAGAIN/EBUSY/EMFILE/ENFILE/ESTALE — the
|
|
151
|
+
// stalled-external-mount class) produced NO verdict, so it must degrade to a
|
|
152
|
+
// loud "could not verify" warning instead of a block showing raw errno text.
|
|
153
|
+
// The match is deliberately conservative: the errno code must appear in an
|
|
154
|
+
// fs-syscall context (open/read/write/mkdir/stat/rename/unlink/rm), in either
|
|
155
|
+
// order, or the Node "resource temporarily unavailable" phrasing — a genuine
|
|
156
|
+
// block reason containing e.g. "again" in prose never downgrades. ONE pattern
|
|
157
|
+
// shared by translateExecResult and runScriptChain.
|
|
158
|
+
const TRANSIENT_INFRA_STDERR_RE = /\bresource temporarily unavailable\b|\b(?:EAGAIN|EBUSY|EMFILE|ENFILE|ESTALE)\b[\s\S]*?\b(?:open|read|write|mkdir|stat|rename|unlink|rm)\b|\b(?:open|read|write|mkdir|stat|rename|unlink|rm)\b[\s\S]*?\b(?:EAGAIN|EBUSY|EMFILE|ENFILE|ESTALE)\b/i;
|
|
159
|
+
|
|
149
160
|
// TASK-018: the hook-chain-runner's failure taxonomy. Infrastructure outcomes
|
|
150
161
|
// (overflow / timeout / signal / budget-exhausted) produced NO verdict, so their
|
|
151
162
|
// captured output is untrustworthy and never reaches the model context, a block
|
|
@@ -337,6 +348,18 @@ function translateExecResult(scriptName, execResult) {
|
|
|
337
348
|
}
|
|
338
349
|
return { block: true, reason: stderr || `${scriptName} exited 2 (blocked)`, stdout, stderr };
|
|
339
350
|
}
|
|
351
|
+
// TASK-002 (OMP-4): a crash whose stderr is a transient fs error produced no
|
|
352
|
+
// verdict — it is an infrastructure event, not a safety decision. Fail open
|
|
353
|
+
// loudly instead of blocking on raw errno text. TIMEOUT_STAYS_CLOSED scripts
|
|
354
|
+
// (block-dangerous .sh/.mjs) keep their never-fail-open contract.
|
|
355
|
+
if (TRANSIENT_INFRA_STDERR_RE.test(stderr) && !TIMEOUT_STAYS_CLOSED.has(scriptName)) {
|
|
356
|
+
return {
|
|
357
|
+
block: false,
|
|
358
|
+
warning: `${scriptName} exited ${code} with a transient filesystem error — treated as "could not verify", not as a block (an infrastructure event, not a verdict): ${redactDiagnosticText(stderr) || 'no stderr'}`,
|
|
359
|
+
stdout,
|
|
360
|
+
stderr,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
340
363
|
if (classifyFailure(scriptName) === 'closed') {
|
|
341
364
|
return {
|
|
342
365
|
block: true,
|
|
@@ -380,17 +403,18 @@ function hookErrorsDirFor(projectRoot) {
|
|
|
380
403
|
}
|
|
381
404
|
|
|
382
405
|
// Bounded work on THIS session's file only — identical keep-newest-half shape
|
|
383
|
-
// as hook-telemetry's rotateIfNeeded.
|
|
384
|
-
|
|
406
|
+
// as hook-telemetry's rotateIfNeeded. TASK-002 (OMP-4): async fs only — a
|
|
407
|
+
// stalled mount must never freeze the omp host loop.
|
|
408
|
+
async function rotateHookErrorFileIfNeeded(filePath, incomingBytes, maxBytes) {
|
|
385
409
|
let size = 0;
|
|
386
410
|
try {
|
|
387
|
-
size = fs.
|
|
411
|
+
size = (await fs.promises.stat(filePath)).size;
|
|
388
412
|
} catch {
|
|
389
413
|
return; // first row for this session
|
|
390
414
|
}
|
|
391
415
|
if (size + incomingBytes <= maxBytes) return;
|
|
392
416
|
try {
|
|
393
|
-
const lines = fs.
|
|
417
|
+
const lines = (await fs.promises.readFile(filePath, 'utf8')).split('\n');
|
|
394
418
|
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
395
419
|
const keepBudget = Math.floor(maxBytes / 2);
|
|
396
420
|
const keep = [];
|
|
@@ -401,16 +425,17 @@ function rotateHookErrorFileIfNeeded(filePath, incomingBytes, maxBytes) {
|
|
|
401
425
|
keep.unshift(lines[i]);
|
|
402
426
|
kept += lineBytes;
|
|
403
427
|
}
|
|
404
|
-
fs.
|
|
428
|
+
await fs.promises.writeFile(filePath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
|
|
405
429
|
} catch {
|
|
406
430
|
// Rotation failed; drop this row rather than grow past the cap.
|
|
407
431
|
}
|
|
408
432
|
}
|
|
409
433
|
|
|
410
434
|
// sweepHookErrorsDir(dir, {now, maxAgeMs, maxFiles, maxEntries, maxRemovals}) ->
|
|
411
|
-
// { scanned, removed } — bounded: never scans or removes more than
|
|
412
|
-
// so a pre-existing oversized dir is amortized down across sampled
|
|
413
|
-
|
|
435
|
+
// Promise<{ scanned, removed }> — bounded: never scans or removes more than
|
|
436
|
+
// the caps, so a pre-existing oversized dir is amortized down across sampled
|
|
437
|
+
// sweeps. TASK-002 (OMP-4): async fs only — host loop must never block.
|
|
438
|
+
async function sweepHookErrorsDir(dir, {
|
|
414
439
|
now = Date.now,
|
|
415
440
|
maxAgeMs = HOOK_ERROR_MAX_AGE_MS,
|
|
416
441
|
maxFiles = HOOK_ERROR_MAX_FILES,
|
|
@@ -419,7 +444,7 @@ function sweepHookErrorsDir(dir, {
|
|
|
419
444
|
} = {}) {
|
|
420
445
|
let names;
|
|
421
446
|
try {
|
|
422
|
-
names = fs.
|
|
447
|
+
names = await fs.promises.readdir(dir);
|
|
423
448
|
} catch {
|
|
424
449
|
return { scanned: 0, removed: 0 };
|
|
425
450
|
}
|
|
@@ -435,7 +460,7 @@ function sweepHookErrorsDir(dir, {
|
|
|
435
460
|
if (scanned >= maxEntries) break;
|
|
436
461
|
scanned += 1;
|
|
437
462
|
try {
|
|
438
|
-
const stats = fs.
|
|
463
|
+
const stats = await fs.promises.stat(path.join(dir, name));
|
|
439
464
|
if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
|
|
440
465
|
} catch { /* raced away — fine */ }
|
|
441
466
|
}
|
|
@@ -449,7 +474,7 @@ function sweepHookErrorsDir(dir, {
|
|
|
449
474
|
if (removed >= maxRemovals) break;
|
|
450
475
|
if (removed < overflow || entry.mtimeMs < cutoff) {
|
|
451
476
|
try {
|
|
452
|
-
fs.
|
|
477
|
+
await fs.promises.rm(path.join(dir, entry.name), { force: true });
|
|
453
478
|
removed += 1;
|
|
454
479
|
} catch { /* raced away — fine */ }
|
|
455
480
|
}
|
|
@@ -463,18 +488,18 @@ function hookErrorsSweepProbabilityFromEnv() {
|
|
|
463
488
|
return Math.min(1, Math.max(0, raw));
|
|
464
489
|
}
|
|
465
490
|
|
|
466
|
-
function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic, { maxBytes = HOOK_ERROR_MAX_BYTES } = {}) {
|
|
491
|
+
async function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic, { maxBytes = HOOK_ERROR_MAX_BYTES } = {}) {
|
|
467
492
|
try {
|
|
468
493
|
const dir = hookErrorsDirFor(projectRoot);
|
|
469
|
-
fs.
|
|
494
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
470
495
|
const safeSession = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
|
|
471
496
|
const filePath = path.join(dir, `${safeSession}.jsonl`);
|
|
472
497
|
const line = `${JSON.stringify(diagnostic)}\n`;
|
|
473
|
-
rotateHookErrorFileIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), maxBytes);
|
|
474
|
-
fs.
|
|
498
|
+
await rotateHookErrorFileIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), maxBytes);
|
|
499
|
+
await fs.promises.appendFile(filePath, line, 'utf8');
|
|
475
500
|
// Sampled bounded dir sweep — amortizes down any pre-existing oversized dir.
|
|
476
501
|
if (Math.random() < hookErrorsSweepProbabilityFromEnv()) {
|
|
477
|
-
sweepHookErrorsDir(dir);
|
|
502
|
+
await sweepHookErrorsDir(dir);
|
|
478
503
|
}
|
|
479
504
|
} catch {
|
|
480
505
|
// Diagnostics are advisory and must never block or throw.
|
|
@@ -519,11 +544,11 @@ function payloadsDirFor(projectRoot) {
|
|
|
519
544
|
}
|
|
520
545
|
|
|
521
546
|
function schedulePayloadSweep(projectRoot) {
|
|
522
|
-
// Deferred: runs after the current turn settles, never inside the chain
|
|
547
|
+
// Deferred: runs after the current turn settles, never inside the chain
|
|
548
|
+
// request. Async fs only — a stalled mount must not freeze the host loop.
|
|
523
549
|
const timer = setTimeout(() => {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
} catch { /* deferred sweeping is best effort */ }
|
|
550
|
+
maybeSweepStalePayloadsAsync(payloadsDirFor(projectRoot))
|
|
551
|
+
.catch(() => { /* deferred sweeping is best effort */ });
|
|
527
552
|
}, 0);
|
|
528
553
|
timer.unref?.();
|
|
529
554
|
}
|
|
@@ -563,8 +588,8 @@ export async function runScriptChain(
|
|
|
563
588
|
// TASK-031: verify the staged payload survived the chain intact BEFORE removing it —
|
|
564
589
|
// a file that vanished or was truncated mid-flight means the scripts ran against a
|
|
565
590
|
// different payload than the host captured, so their verdicts are void.
|
|
566
|
-
payloadProbe =
|
|
567
|
-
payloadReference.
|
|
591
|
+
payloadProbe = await probePayloadIntegrityAsync(payloadReference);
|
|
592
|
+
await payloadReference.cleanupAsync();
|
|
568
593
|
if (payloadReference.mode === 'file') schedulePayloadSweep(projectRoot);
|
|
569
594
|
}
|
|
570
595
|
const elapsedMs = Date.now() - startedAt;
|
|
@@ -609,7 +634,7 @@ export async function runScriptChain(
|
|
|
609
634
|
parseError: parseError?.message || null,
|
|
610
635
|
wrapperError: chainResult?.wrapperError || null,
|
|
611
636
|
};
|
|
612
|
-
recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
|
|
637
|
+
await recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
|
|
613
638
|
// TASK-031: a staged payload that was lost or corrupted mid-chain voids every
|
|
614
639
|
// verdict below it. Chains that must not fail open on an unverifiable verdict
|
|
615
640
|
// (Edit|Write transport policy, and block-dangerous's never-fail-open rule)
|
|
@@ -640,6 +665,15 @@ export async function runScriptChain(
|
|
|
640
665
|
+ `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
|
|
641
666
|
+ `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
|
|
642
667
|
+ `See .ukit/storage/cache/hook-errors/.${nodePathHint}`;
|
|
668
|
+
// TASK-002 (OMP-4): a transport failure whose stderr is a transient fs
|
|
669
|
+
// error produced no verdict — an infrastructure event, not a safety
|
|
670
|
+
// decision. Even on a fail-closed chain it degrades to a loud warning
|
|
671
|
+
// instead of a block showing raw errno text. A staged-payload integrity
|
|
672
|
+
// failure (payloadProbe !== null) already returned above and stays closed.
|
|
673
|
+
if (failClosedOnTransportError && TRANSIENT_INFRA_STDERR_RE.test(diagnostic.stderrExcerpt)) {
|
|
674
|
+
pi.logger?.warn?.(`[UKit] ${reason} Transient filesystem error — treated as "could not verify", not as a block.`);
|
|
675
|
+
return { block: false, context, invoked };
|
|
676
|
+
}
|
|
643
677
|
if (failClosedOnTransportError) {
|
|
644
678
|
return { block: true, reason, context, invoked };
|
|
645
679
|
}
|