@ngockhoale/ukit 3.0.10 → 3.0.11
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 +10 -0
- package/bin/ukit +5 -9
- package/package.json +1 -1
- package/scripts/bench/memory-baseline.mjs +1 -1
- package/scripts/bench/memory-bench.mjs +1 -1
- package/scripts/bench/memory-canary.mjs +12 -2
- package/scripts/bench/recorder-overhead.mjs +1 -1
- package/scripts/bench/sqlite-spike.mjs +1 -1
- package/scripts/measure-decision-gateway.mjs +1 -1
- package/src/cli/commands/memory.js +3 -0
- package/src/cli/deadline.js +19 -0
- package/src/core/agentRuntime/eventStore.js +38 -10
- package/src/core/agentRuntime/supervisor.js +41 -9
- package/src/core/agentRuntime/vmEngine.js +33 -2
- package/src/core/codeintel/freshness.js +13 -9
- package/src/core/codeintel/retriever.js +25 -20
- package/src/core/fileOps.js +19 -4
- package/src/core/memory/memoryFlags.js +14 -6
- package/src/core/metadata.js +111 -81
- package/src/core/token/index.js +69 -28
- package/src/index/buildIndex.js +7 -0
- package/src/index/impactContext.js +14 -5
- package/src/render/instructionRenderer.js +71 -19
- package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
- package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
- package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
- package/template_project/.claude/skills/docx/scripts/document.py +31 -8
- package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
- package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
- package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
- package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
- package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
- package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
- package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
- package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
- package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
- package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
- package/template_project/.claude/ukit/index/query-index.mjs +13 -11
- package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
- package/template_project/.claude/ukit/index/route-task.mjs +44 -25
- package/template_project/.claude/ukit/index/triage.mjs +12 -11
- package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
- package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
- package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.0.11 - 2026-09-26
|
|
6
|
+
|
|
7
|
+
- **C80 bug-sweep fix cycle — all 23 confirmed/high-confidence execution-path bugs fixed** (`docs/AI_HANDOFF/REPORT-C80-bug-sweep.md`):
|
|
8
|
+
- Index CLIs (`query-index`, `triage`, `resolve-context`, `verify-context`, `impact-context`, `route-task`): top-level try/catch error boundary — unhandled-rejection crashes become clean exit(1) (C80-01..06).
|
|
9
|
+
- `cache-utils.mjs` `readRecentCacheEntry(touch:true)`: touch-write failures degrade to in-memory entry, matching the token-utils prompt-cache contract — a full/unwritable cache can no longer reject a read (C80-02).
|
|
10
|
+
- `index-core.mjs`: new `ensureIndexFresh` — cross-process `withFileLock` build lock + staleness re-check inside the lock; artifact caches invalidated before publish writes, not after (C80-07/08).
|
|
11
|
+
- `frontend-vue` composables: `useRequest` ×3 `finally` decrement of `loading_count` (stuck spinner); `useWebSocket` retry dedup + cancellable timer + disposed guard; `useSession.getSession` corrupt-blob guard; `indexDBStore` promises now settle on transaction/clear errors (C80-09..16).
|
|
12
|
+
- Skill scripts: `with_server.py` pipe deadlock + process-group kill; `init-project-docs.sh` sed escaping + backup-instead-of-rm; `document.py` deterministic `close()`; `rearrange.py` samefile compare; `find-polluter.sh` glob/quoting fixes (C80-17..22).
|
|
13
|
+
- `supervisor.js`: listeners attach before awaited work post-spawn; liveness via injectable `probeImpl` (kill(pid,0)); `killGroup` revalidates ownership before signaling a possibly-recycled pgid (C80-23).
|
|
14
|
+
|
|
5
15
|
## 3.0.10 - 2026-09-25
|
|
6
16
|
|
|
7
17
|
- Re-publish of the 3.0.9 content: npm staged-publish left 3.0.9 in limbo (E409 "previously staged version"); re-published as 3.0.10 — same tree, version bump only.
|
package/bin/ukit
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { runCli } from '../src/cli/index.js';
|
|
7
|
+
import { resolveCliDeadlineMs } from '../src/cli/deadline.js';
|
|
7
8
|
|
|
8
9
|
async function readPackageVersion(packageRoot) {
|
|
9
10
|
const packageJsonPath = path.join(packageRoot, 'package.json');
|
|
@@ -15,19 +16,14 @@ async function readPackageVersion(packageRoot) {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
const DEFAULT_CLI_DEADLINE_MS = 120_000;
|
|
19
|
-
|
|
20
|
-
function resolveCliDeadlineMs() {
|
|
21
|
-
const raw = Number(process.env.UKIT_CLI_DEADLINE_MS);
|
|
22
|
-
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CLI_DEADLINE_MS;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
19
|
async function main() {
|
|
26
20
|
// Overall wall-clock watchdog: individual fs/readline awaits inside runCli are
|
|
27
21
|
// unbounded, so a wedged .ukit/external mount would park the CLI forever.
|
|
28
|
-
const
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const deadlineMs = resolveCliDeadlineMs(argv);
|
|
24
|
+
const commandLabel = argv.length > 0 ? argv.join(' ') : 'install';
|
|
29
25
|
const watchdog = setTimeout(() => {
|
|
30
|
-
console.error(`[UKit] timed out after ${deadlineMs}ms — likely wedged filesystem; retry`);
|
|
26
|
+
console.error(`[UKit] 'ukit ${commandLabel}' timed out after ${deadlineMs}ms — likely wedged filesystem or slow network; retry`);
|
|
31
27
|
process.exit(1);
|
|
32
28
|
}, deadlineMs);
|
|
33
29
|
// NOT unref'd: a hung runCli that holds no event-loop handles (e.g. a promise
|
package/package.json
CHANGED
|
@@ -520,7 +520,7 @@ async function main() {
|
|
|
520
520
|
};
|
|
521
521
|
|
|
522
522
|
try {
|
|
523
|
-
report.meta.sha = execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
|
|
523
|
+
report.meta.sha = execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8', timeout: 10_000 }).trim();
|
|
524
524
|
} catch { /* leave 'unknown' */ }
|
|
525
525
|
try {
|
|
526
526
|
report.meta.version = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).version ?? 'unknown';
|
|
@@ -253,7 +253,7 @@ function envBlock() {
|
|
|
253
253
|
os: process.platform, arch: process.arch, fs: 'local',
|
|
254
254
|
};
|
|
255
255
|
try {
|
|
256
|
-
env.sha = execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
|
|
256
|
+
env.sha = execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8', timeout: 10_000 }).trim();
|
|
257
257
|
} catch { /* leave 'unknown' */ }
|
|
258
258
|
try {
|
|
259
259
|
env.package = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).version ?? 'unknown';
|
|
@@ -91,8 +91,18 @@ function ensureCorpus(opts, workRoot) {
|
|
|
91
91
|
const dir = path.join(workRoot, 'corpus');
|
|
92
92
|
const args = [CORPUS_GEN, '--tier', opts.tier, '--out', dir];
|
|
93
93
|
if (opts.seed != null) args.push('--seed', String(opts.seed));
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
// The corpus child gets an explicit lifetime bound (FR-022): a hung
|
|
95
|
+
// generator must never park the canary forever. killSignal SIGKILL, not the
|
|
96
|
+
// default SIGTERM — see src/core/update.js:18-24: a signal-ignoring child
|
|
97
|
+
// keeps spawnSync waiting past the timeout under SIGTERM; SIGKILL returns
|
|
98
|
+
// ~immediately. UKIT_CANARY_CORPUS_TIMEOUT_MS lets tests shrink the bound.
|
|
99
|
+
const corpusTimeoutMs = Number(process.env.UKIT_CANARY_CORPUS_TIMEOUT_MS) || 120_000;
|
|
100
|
+
const res = spawnSync(process.execPath, args, {
|
|
101
|
+
encoding: 'utf8', timeout: corpusTimeoutMs, killSignal: 'SIGKILL',
|
|
102
|
+
});
|
|
103
|
+
if (res.status !== 0 || res.error) {
|
|
104
|
+
throw new Error(`corpus generation failed: ${res.error?.message ?? res.stderr ?? res.stdout ?? 'unknown error'}`);
|
|
105
|
+
}
|
|
96
106
|
return {
|
|
97
107
|
recordsPath: path.join(dir, 'records.json'),
|
|
98
108
|
manifest: JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8')),
|
|
@@ -77,7 +77,7 @@ function dirBytes(dir) {
|
|
|
77
77
|
|
|
78
78
|
function gitRevision() {
|
|
79
79
|
try {
|
|
80
|
-
return execSync('git rev-parse --short HEAD', { cwd: repoRoot, encoding: 'utf8' }).trim();
|
|
80
|
+
return execSync('git rev-parse --short HEAD', { cwd: repoRoot, encoding: 'utf8', timeout: 10_000 }).trim();
|
|
81
81
|
} catch {
|
|
82
82
|
return 'unknown';
|
|
83
83
|
}
|
|
@@ -391,7 +391,7 @@ async function main() {
|
|
|
391
391
|
walDir,
|
|
392
392
|
};
|
|
393
393
|
try {
|
|
394
|
-
env.sha = execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
|
|
394
|
+
env.sha = execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8', timeout: 10_000 }).trim();
|
|
395
395
|
} catch { /* leave 'unknown' */ }
|
|
396
396
|
try {
|
|
397
397
|
env.package = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).version ?? 'unknown';
|
|
@@ -165,7 +165,7 @@ export function writeMeasurementArtifacts(artifact, outDir) {
|
|
|
165
165
|
|
|
166
166
|
function gitSha() {
|
|
167
167
|
try {
|
|
168
|
-
return execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
|
|
168
|
+
return execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8', timeout: 10_000 }).trim();
|
|
169
169
|
} catch {
|
|
170
170
|
return null;
|
|
171
171
|
}
|
|
@@ -195,6 +195,9 @@ async function runMemoryV2(projectRoot, args) {
|
|
|
195
195
|
type: 'project_rule',
|
|
196
196
|
scope: 'repo',
|
|
197
197
|
provenance: `${record.provenance ?? 'manual'};promoted-from:${record.id}`,
|
|
198
|
+
// FR-018: episodes carry a ~90d valid_until — a promoted rule must
|
|
199
|
+
// not inherit it or it silently expires from recall.
|
|
200
|
+
valid_until: null,
|
|
198
201
|
},
|
|
199
202
|
},
|
|
200
203
|
expectedRevision: record.revision,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// C79-07: per-subcommand CLI watchdog budgets. `ukit update` shells out to
|
|
2
|
+
// `npm install -g` whose own budget (NPM_INSTALL_TIMEOUT_MS in
|
|
3
|
+
// src/core/update.js) is 10 minutes — killing the CLI at the flat 120s used to
|
|
4
|
+
// fire mid-install, printing a false "wedged filesystem" diagnostic and
|
|
5
|
+
// orphaning the npm child. `update` therefore gets a deadline aligned with
|
|
6
|
+
// that budget; every other command keeps 120s. The constant is duplicated
|
|
7
|
+
// inline (mirrored value) instead of importing update.js so this module stays
|
|
8
|
+
// dependency-free — audit fixtures lift bin/ukit into stub package roots that
|
|
9
|
+
// carry only src/cli/*.
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CLI_DEADLINE_MS = 120_000;
|
|
12
|
+
export const UPDATE_CLI_DEADLINE_MS = 10 * 60 * 1000; // mirrors NPM_INSTALL_TIMEOUT_MS
|
|
13
|
+
|
|
14
|
+
export function resolveCliDeadlineMs(argv = []) {
|
|
15
|
+
const raw = Number(process.env.UKIT_CLI_DEADLINE_MS);
|
|
16
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
17
|
+
const command = (argv[0] ?? '').toLowerCase();
|
|
18
|
+
return command === 'update' ? UPDATE_CLI_DEADLINE_MS : DEFAULT_CLI_DEADLINE_MS;
|
|
19
|
+
}
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
import { promises as fs } from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
|
|
32
|
+
import { withFileLock } from '../fileOps.js';
|
|
33
|
+
|
|
32
34
|
import {
|
|
33
35
|
CONTRACT_VERSION,
|
|
34
36
|
validateSemanticEvent,
|
|
@@ -156,27 +158,53 @@ async function loadCursor(dir, operationId) {
|
|
|
156
158
|
|
|
157
159
|
/**
|
|
158
160
|
* Append a validated SemanticEvent to the operation journal. Envelope and
|
|
159
|
-
* ordering are validated against contract.js; duplicate eventId
|
|
160
|
-
* idempotent no-op; a
|
|
161
|
+
* ordering are validated against contract.js; duplicate eventId is an
|
|
162
|
+
* idempotent no-op; a seq collision with a DIFFERENT eventId rejects
|
|
163
|
+
* out_of_order; a future seq rejects out_of_order. The line is fsync'd
|
|
161
164
|
* before returning — once this resolves, the event survives a crash.
|
|
162
165
|
*
|
|
166
|
+
* The loadCursor→appendLine pair runs under a per-journal `withFileLock`:
|
|
167
|
+
* without it two writers can both validate against the same cursor and
|
|
168
|
+
* commit two records at one seq, permanently corrupting the journal.
|
|
169
|
+
* Lock-wait expiry fails closed with `journal_lock_timeout` — the append is
|
|
170
|
+
* never run unlocked.
|
|
171
|
+
*
|
|
163
172
|
* @param {string} dir store root
|
|
164
173
|
* @param {object} event SemanticEvent (contract v1)
|
|
165
|
-
* @param {object} [opts] `{ hooks: { afterAppend } }`
|
|
174
|
+
* @param {object} [opts] `{ hooks: { afterAppend }, lockStaleMs, lockMaxWaitMs }`
|
|
175
|
+
* — hooks for test fault injection; lock knobs default to fileOps policy
|
|
166
176
|
* @returns {Promise<{seq:number, duplicate?:boolean}>}
|
|
167
177
|
*/
|
|
168
178
|
export async function appendEvent(dir, event, opts = {}) {
|
|
169
179
|
const shape = validateSemanticEvent(event);
|
|
170
180
|
if (!shape.ok) throw new EventStoreError(shape.code, shape.code);
|
|
171
181
|
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
const jPath = journalPath(dir, event.operationId);
|
|
183
|
+
const result = await withFileLock(jPath, async () => {
|
|
184
|
+
const cursor = await loadCursor(dir, event.operationId);
|
|
185
|
+
const order = validateEventOrder(event, cursor);
|
|
186
|
+
if (!order.ok) throw new EventStoreError(order.code, order.code);
|
|
187
|
+
if (order.duplicate) {
|
|
188
|
+
// contract.js pins `seq <= lastSeq` → duplicate for journal replay, but
|
|
189
|
+
// for appends a seq collision with an UNSEEN eventId is a conflicting
|
|
190
|
+
// write, not an idempotent re-delivery — reject it instead of letting
|
|
191
|
+
// a second writer believe its (uncommitted) event landed
|
|
192
|
+
if (!cursor.seenEventIds.has(event.eventId)) {
|
|
193
|
+
throw new EventStoreError('out_of_order', `seq ${event.seq} already committed`);
|
|
194
|
+
}
|
|
195
|
+
return { seq: event.seq, duplicate: true };
|
|
196
|
+
}
|
|
176
197
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
198
|
+
await appendLine(jPath, event);
|
|
199
|
+
return { seq: event.seq };
|
|
200
|
+
}, { staleMs: opts.lockStaleMs, maxWaitMs: opts.lockMaxWaitMs });
|
|
201
|
+
if (result === undefined) {
|
|
202
|
+
// withFileLock fail-closed: the wait expired and the append was skipped —
|
|
203
|
+
// surface it typed so callers never mistake a dropped write for success
|
|
204
|
+
throw new EventStoreError('journal_lock_timeout', event.operationId);
|
|
205
|
+
}
|
|
206
|
+
if (!result.duplicate) await opts.hooks?.afterAppend?.(event);
|
|
207
|
+
return result;
|
|
180
208
|
}
|
|
181
209
|
|
|
182
210
|
/**
|
|
@@ -63,6 +63,11 @@ const defaultFsyncImpl = async (handle) => {
|
|
|
63
63
|
|
|
64
64
|
const defaultSpawnImpl = (argv, opts) => spawn(argv[0], argv.slice(1), opts);
|
|
65
65
|
const defaultKillImpl = (signal, target) => process.kill(target, signal);
|
|
66
|
+
// Liveness probe — separate injectable from killImpl so sig-0 checks never
|
|
67
|
+
// count as kill calls (C80-23).
|
|
68
|
+
const defaultProbeImpl = (pid) => {
|
|
69
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
70
|
+
};
|
|
66
71
|
|
|
67
72
|
function specIsValid(spec) {
|
|
68
73
|
if (spec === null || typeof spec !== 'object') return false;
|
|
@@ -86,6 +91,7 @@ function specIsValid(spec) {
|
|
|
86
91
|
* @param {number} [opts.ownerEpoch] epoch floor for transition validation
|
|
87
92
|
* @param {object} [opts.limits] {pollIntervalMs,killGraceMs,closeWaitMs,artifact caps}
|
|
88
93
|
* @param {Function} [opts.spawnImpl] (argv, spawnOpts) → child-like proc
|
|
94
|
+
* @param {Function} [opts.probeImpl] (pid) → boolean — liveness probe (kill(pid,0))
|
|
89
95
|
* @param {Function} [opts.killImpl] (signal, target) → void (target = -pgid)
|
|
90
96
|
* @param {Function} [opts.fsyncImpl] artifact fsync
|
|
91
97
|
* @param {Function} [opts.appendEventImpl] journal append (test injection)
|
|
@@ -147,6 +153,7 @@ export function createSupervisor(opts = {}) {
|
|
|
147
153
|
|
|
148
154
|
const spawnImpl = opts.spawnImpl ?? defaultSpawnImpl;
|
|
149
155
|
const killImpl = opts.killImpl ?? defaultKillImpl;
|
|
156
|
+
const probeImpl = opts.probeImpl ?? defaultProbeImpl;
|
|
150
157
|
const append = opts.appendEventImpl ?? appendEvent;
|
|
151
158
|
const clock = opts.clock && typeof opts.clock.setTimeout === 'function'
|
|
152
159
|
? opts.clock
|
|
@@ -226,7 +233,21 @@ export function createSupervisor(opts = {}) {
|
|
|
226
233
|
emitEvent(op, 'operation.observation', { class: cls });
|
|
227
234
|
}
|
|
228
235
|
|
|
236
|
+
// C80-23: probe the real pid — liveness must come from the OS, not the
|
|
237
|
+
// exited flag (a missed 'exit' event used to look alive forever).
|
|
238
|
+
function procAlive(op) {
|
|
239
|
+
if (op.exited) return false;
|
|
240
|
+
const pid = op.proc?.pid ?? op.pgid;
|
|
241
|
+
if (pid == null) return false;
|
|
242
|
+
return probeImpl(pid) === true;
|
|
243
|
+
}
|
|
244
|
+
|
|
229
245
|
function killGroup(op, signal) {
|
|
246
|
+
// Revalidate ownership: if we believe the child already exited, do not
|
|
247
|
+
// signal a possibly-recycled pgid we no longer own.
|
|
248
|
+
if (op.exited || signal === 0) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
230
251
|
try {
|
|
231
252
|
killImpl(signal, -op.pgid);
|
|
232
253
|
} catch {
|
|
@@ -272,8 +293,16 @@ export function createSupervisor(opts = {}) {
|
|
|
272
293
|
function tick(op) {
|
|
273
294
|
if (op.exited || isTerminal(op.state)) return;
|
|
274
295
|
const now = nowMs();
|
|
296
|
+
const alive = procAlive(op);
|
|
297
|
+
if (!alive && !op.exited) {
|
|
298
|
+
// C80-23: the 'exit' event was missed (or libuv dropped it) but the OS
|
|
299
|
+
// says the pid is gone — resolve as exited so op state, timers, and any
|
|
300
|
+
// later killGroup() see the truth instead of wall-timing a dead child.
|
|
301
|
+
onExit(op, op.proc?.exitCode ?? 1, null);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
275
304
|
const sample = {
|
|
276
|
-
processAlive:
|
|
305
|
+
processAlive: alive,
|
|
277
306
|
bytesWritten: op.bytesWritten,
|
|
278
307
|
lastOutputAt: op.lastOutputAt,
|
|
279
308
|
startedAt: op.startedAt,
|
|
@@ -447,14 +476,9 @@ export function createSupervisor(opts = {}) {
|
|
|
447
476
|
op.pgid = proc.pid;
|
|
448
477
|
op.lastOutputAt = nowMs();
|
|
449
478
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
} catch {
|
|
454
|
-
op.artifacts = null;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
479
|
+
// C80-23: attach exit/error listeners BEFORE any other work — a fast-
|
|
480
|
+
// exiting child must never miss its terminal handler and leave
|
|
481
|
+
// op.exited false until the wall-timeout kill.
|
|
458
482
|
const onData = (stream) => (chunk) => {
|
|
459
483
|
op.bytesWritten += chunk?.length ?? 0;
|
|
460
484
|
op.lastOutputAt = nowMs();
|
|
@@ -469,6 +493,14 @@ export function createSupervisor(opts = {}) {
|
|
|
469
493
|
proc.on?.('error', () => onExit(op, proc.exitCode ?? 1, null));
|
|
470
494
|
proc.on?.('exit', (code, signal) => onExit(op, code, signal));
|
|
471
495
|
|
|
496
|
+
if (artifactWriter) {
|
|
497
|
+
try {
|
|
498
|
+
op.artifacts = artifactWriter.open(op.id);
|
|
499
|
+
} catch {
|
|
500
|
+
op.artifacts = null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
472
504
|
await transition(op, 'running', { pid: proc.pid ?? null });
|
|
473
505
|
if (!op.exited) scheduleTick(op);
|
|
474
506
|
|
|
@@ -116,7 +116,13 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
async function appendTransition(inst, nodeId, to, payload) {
|
|
119
|
-
|
|
119
|
+
// seq allocation follows the JOURNAL cursor (journalSeq), not the
|
|
120
|
+
// deliver-check cursor: a rehydrated instance's deliver cursor is
|
|
121
|
+
// deliberately un-replayed, but transitions must number after the last
|
|
122
|
+
// committed journal record
|
|
123
|
+
const seq = (inst.journalSeq[nodeId] ?? inst.cursors[nodeId].lastSeq) + 1;
|
|
124
|
+
inst.journalSeq[nodeId] = seq;
|
|
125
|
+
inst.cursors[nodeId].lastSeq = Math.max(inst.cursors[nodeId].lastSeq, seq);
|
|
120
126
|
const event = transitionRecord(nodeId, to, seq, { pi: inst.planInstanceId, payload });
|
|
121
127
|
await appendEvent(dir, event, { hooks: { afterAppend: hooks?.afterEventAppend } });
|
|
122
128
|
return event;
|
|
@@ -373,6 +379,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
373
379
|
entryNodes: plan.entryNodes,
|
|
374
380
|
nodes: {},
|
|
375
381
|
cursors: {},
|
|
382
|
+
journalSeq: {},
|
|
376
383
|
continuations: {},
|
|
377
384
|
recoveryReason: null,
|
|
378
385
|
forcedStatus: null,
|
|
@@ -380,6 +387,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
380
387
|
for (const [id] of nodes) {
|
|
381
388
|
inst.nodes[id] = { state: 'queued', attempt: 0, lastEventSeq: 0 };
|
|
382
389
|
inst.cursors[id] = { lastSeq: 0, seenEventIds: new Set() };
|
|
390
|
+
inst.journalSeq[id] = 0;
|
|
383
391
|
}
|
|
384
392
|
instances.set(planInstanceId, inst);
|
|
385
393
|
|
|
@@ -435,6 +443,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
435
443
|
entryNodes: raw.plan?.entryNodes ?? [],
|
|
436
444
|
nodes: {},
|
|
437
445
|
cursors: {},
|
|
446
|
+
journalSeq: {},
|
|
438
447
|
continuations: {},
|
|
439
448
|
recoveryReason: raw.recoveryReason ?? null,
|
|
440
449
|
forcedStatus: null,
|
|
@@ -447,6 +456,17 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
447
456
|
lastEventSeq: seed.lastEventSeq ?? 0,
|
|
448
457
|
};
|
|
449
458
|
inst.cursors[id] = { lastSeq: 0, seenEventIds: new Set() };
|
|
459
|
+
inst.journalSeq[id] = 0;
|
|
460
|
+
}
|
|
461
|
+
// Seed transition seq allocation from the journal tail (journal is truth):
|
|
462
|
+
// the deliver-check cursor stays memory-only — replaying it would break
|
|
463
|
+
// journaled-but-unconsumed redelivery — but appendTransition MUST continue
|
|
464
|
+
// numbering where the journal ended or every post-crash transition lands
|
|
465
|
+
// on an already-committed seq
|
|
466
|
+
for (const [id] of nodeMap) {
|
|
467
|
+
for await (const ev of readJournal(dir, opId(planInstanceId, id))) {
|
|
468
|
+
inst.journalSeq[id] = ev.seq;
|
|
469
|
+
}
|
|
450
470
|
}
|
|
451
471
|
// continuation registration survived the crash on disk — recover the ids
|
|
452
472
|
try {
|
|
@@ -460,7 +480,13 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
460
480
|
? c.operationId.slice(planInstanceId.length + 1) : null;
|
|
461
481
|
if (nid && inst.nodeMap.has(nid)) inst.continuations[nid] = c.continuationId;
|
|
462
482
|
}
|
|
463
|
-
} catch {
|
|
483
|
+
} catch (err) {
|
|
484
|
+
// ENOENT == never registered — hydrate with zero continuations. Any
|
|
485
|
+
// other fs fault (EACCES/EMFILE/corrupt read) must surface: swallowing
|
|
486
|
+
// it would silently drop every durable registration and chain into the
|
|
487
|
+
// journal/consume divergence below
|
|
488
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
489
|
+
}
|
|
464
490
|
instances.set(planInstanceId, inst);
|
|
465
491
|
return inst;
|
|
466
492
|
}
|
|
@@ -503,6 +529,10 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
503
529
|
}
|
|
504
530
|
}
|
|
505
531
|
if (!targetMatched) return escalateUnclassified(inst, event);
|
|
532
|
+
// routed-but-unregistered node: escalating BEFORE appendEvent keeps the
|
|
533
|
+
// journal honest — a journaled event whose continuation lookup then throws
|
|
534
|
+
// leaves disk claiming a delivery memory rejected (divergence on resume)
|
|
535
|
+
if (inst.continuations[nodeId] == null) return escalateUnclassified(inst, event);
|
|
506
536
|
|
|
507
537
|
// journal the delivered event BEFORE consumption record: a crash between
|
|
508
538
|
// the two leaves a journal event replay will re-apply idempotently;
|
|
@@ -515,6 +545,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
515
545
|
}
|
|
516
546
|
cursor.lastSeq = event.seq;
|
|
517
547
|
cursor.seenEventIds.add(event.eventId);
|
|
548
|
+
inst.journalSeq[nodeId] = event.seq; // committed delivered event is also the journal tail
|
|
518
549
|
|
|
519
550
|
const cont = await consumeContinuation(dir, inst.continuations[nodeId], event);
|
|
520
551
|
if (!cont.consumed && cont.duplicate) {
|
|
@@ -56,17 +56,21 @@ export function getBranchKey(rootDir) {
|
|
|
56
56
|
return 'nogit';
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Porcelain v1 -z records are NUL-separated: `XY<space>path\0`. With
|
|
60
|
+
// `-c core.quotePath=false` paths arrive as literal UTF-8 (no quoting, no
|
|
61
|
+
// octal escapes, no `->` arrows). For rename/copy (R/C in XY) the NEXT NUL
|
|
62
|
+
// field carries the ORIG path — the record's own path is already the new
|
|
63
|
+
// path — so it is consumed without being emitted.
|
|
59
64
|
function parsePorcelainPaths(output) {
|
|
60
65
|
const paths = [];
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
p = p.trim();
|
|
68
|
-
if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
|
|
66
|
+
const fields = output.split('\0');
|
|
67
|
+
for (let i = 0; i < fields.length; i++) {
|
|
68
|
+
const record = fields[i];
|
|
69
|
+
if (!record) continue;
|
|
70
|
+
const xy = record.slice(0, 2);
|
|
71
|
+
const p = record.slice(3);
|
|
69
72
|
if (p) paths.push(p);
|
|
73
|
+
if (xy.includes('R') || xy.includes('C')) i++;
|
|
70
74
|
}
|
|
71
75
|
return paths;
|
|
72
76
|
}
|
|
@@ -171,7 +175,7 @@ function hashEntries(entries) {
|
|
|
171
175
|
async function computeOverlayEntries(rootDir, headSha, { incremental = true, snapshot } = {}) {
|
|
172
176
|
const dirty = await readDirtySet(rootDir);
|
|
173
177
|
if (headSha !== 'nogit') {
|
|
174
|
-
const status = runGit(rootDir, ['status', '--porcelain']);
|
|
178
|
+
const status = runGit(rootDir, ['-c', 'core.quotePath=false', 'status', '--porcelain', '-z']);
|
|
175
179
|
if (status !== null) {
|
|
176
180
|
// Git mode: porcelain output IS the diff — dirty.json unioned defensively.
|
|
177
181
|
const extra = dirty && !dirty.saturated ? dirty.paths : [];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import fsSync from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { createHash } from 'node:crypto';
|
|
5
4
|
|
|
5
|
+
import { withFileLock } from '../fileOps.js';
|
|
6
6
|
import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from '../../index/paths.js';
|
|
7
7
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
8
8
|
import { createEdge, getSemanticProvider, IndexFileSyntaxProvider } from './providers.js';
|
|
@@ -262,15 +262,18 @@ function concatMerge(lanes) {
|
|
|
262
262
|
return merged;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
// FR-202b (TASK-228):
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
265
|
+
// FR-202b (TASK-228): lane telemetry, awaited before retrieve() resolves so
|
|
266
|
+
// the write is durable for callers/tests — failures still never propagate.
|
|
267
|
+
// One JSONL line per retrieval at .ukit/storage/cache/retriever-lanes.jsonl;
|
|
268
|
+
// query persisted as sha256 only (no raw prompts on disk).
|
|
269
|
+
// C79-14 (TASK-011): stat→read→truncate→append used to run unlocked, so an
|
|
270
|
+
// append landing between a sibling process's snapshot-read and its truncating
|
|
271
|
+
// writeFile was silently destroyed. The mutation now runs under withFileLock;
|
|
272
|
+
// an expired lock wait drops this event whole (journaled), never torn.
|
|
270
273
|
const RETRIEVER_LANES_MAX_BYTES = 256 * 1024;
|
|
271
274
|
const RETRIEVER_LANES_KEEP_LINES = 2000;
|
|
272
275
|
|
|
273
|
-
function emitRetrieverLaneTelemetry(rootDir, { query, mode, lanes, omitted, mergedPaths, merge, weights }) {
|
|
276
|
+
async function emitRetrieverLaneTelemetry(rootDir, { query, mode, lanes, omitted, mergedPaths, merge, weights }) {
|
|
274
277
|
try {
|
|
275
278
|
const laneStats = {};
|
|
276
279
|
for (const laneName of LANE_ORDER) {
|
|
@@ -293,19 +296,21 @@ function emitRetrieverLaneTelemetry(rootDir, { query, mode, lanes, omitted, merg
|
|
|
293
296
|
merge,
|
|
294
297
|
};
|
|
295
298
|
const filePath = path.join(rootDir, '.ukit', 'storage', 'cache', 'retriever-lanes.jsonl');
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.
|
|
303
|
-
|
|
299
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
300
|
+
await withFileLock(filePath, async () => {
|
|
301
|
+
// Best-effort cap: past 256 KiB keep only the newest ~2000 lines.
|
|
302
|
+
try {
|
|
303
|
+
const st = await fs.stat(filePath);
|
|
304
|
+
if (st.size > RETRIEVER_LANES_MAX_BYTES) {
|
|
305
|
+
const kept = (await fs.readFile(filePath, 'utf8')).split('\n').filter(Boolean)
|
|
306
|
+
.slice(-RETRIEVER_LANES_KEEP_LINES);
|
|
307
|
+
await fs.writeFile(filePath, kept.length ? `${kept.join('\n')}\n` : '', 'utf8');
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Missing/unreadable file is fine — append below recreates it.
|
|
304
311
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
fsSync.appendFileSync(filePath, `${JSON.stringify(event)}\n`);
|
|
312
|
+
await fs.appendFile(filePath, `${JSON.stringify(event)}\n`, 'utf8');
|
|
313
|
+
});
|
|
309
314
|
} catch {
|
|
310
315
|
// Telemetry is advisory; never propagate.
|
|
311
316
|
}
|
|
@@ -438,7 +443,7 @@ export async function retrieve(projectRoot, query, { mode = 'search', limit, sna
|
|
|
438
443
|
// FR-202b: emit lane telemetry only when a merge result exists — skipped
|
|
439
444
|
// entirely on the early no-index return and on empty merges.
|
|
440
445
|
if (merged.length > 0) {
|
|
441
|
-
emitRetrieverLaneTelemetry(rootDir, {
|
|
446
|
+
await emitRetrieverLaneTelemetry(rootDir, {
|
|
442
447
|
query: normalizedQuery,
|
|
443
448
|
mode,
|
|
444
449
|
lanes,
|
package/src/core/fileOps.js
CHANGED
|
@@ -141,11 +141,26 @@ export async function writeFileAtomic(filePath, content, { fsync = false } = {})
|
|
|
141
141
|
await withTransientFsRetry(() => fs.rename(tempPath, filePath));
|
|
142
142
|
} catch (renameError) {
|
|
143
143
|
// EXDEV: the tmp file and the destination sit on different mounts (union
|
|
144
|
-
// mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link
|
|
145
|
-
// The payload is already fully written — copy it
|
|
146
|
-
//
|
|
144
|
+
// mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link
|
|
145
|
+
// them. The payload is already fully written — copy it UP to a sibling
|
|
146
|
+
// temp on the destination's mount and publish with a second, same-layer
|
|
147
|
+
// rename. A direct copy onto the live target is the last resort only:
|
|
148
|
+
// it is non-atomic, and a crash mid-copy truncates the live file.
|
|
147
149
|
if (renameError?.code !== 'EXDEV') throw renameError;
|
|
148
|
-
|
|
150
|
+
const exdevPath = `${filePath}.exdev-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
151
|
+
try {
|
|
152
|
+
await withTransientFsRetry(() => fs.copyFile(tempPath, exdevPath));
|
|
153
|
+
try {
|
|
154
|
+
await withTransientFsRetry(() => fs.rename(exdevPath, filePath));
|
|
155
|
+
} catch (secondRenameError) {
|
|
156
|
+
// Union/bind mount where even same-dir rename is refused: the direct
|
|
157
|
+
// copy below is non-atomic, but the update must not be silently lost.
|
|
158
|
+
if (secondRenameError?.code !== 'EXDEV') throw secondRenameError;
|
|
159
|
+
await withTransientFsRetry(() => fs.copyFile(tempPath, filePath));
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
await withTransientFsRetry(() => fs.rm(exdevPath, { force: true })).catch(() => {});
|
|
163
|
+
}
|
|
149
164
|
await withTransientFsRetry(() => fs.rm(tempPath, { force: true }));
|
|
150
165
|
}
|
|
151
166
|
} catch (error) {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
|
+
import { withFileLock } from '../fileOps.js';
|
|
14
15
|
import { resolveConfigStage } from '../runtimeConfig.js';
|
|
15
16
|
|
|
16
17
|
export const MEMORY_PLANES = Object.freeze(['eligibility', 'writer', 'index', 'decision']);
|
|
@@ -71,12 +72,19 @@ export async function appendRolloutReceipt(root, receipt = {}) {
|
|
|
71
72
|
const dir = path.join(root, '.ukit', 'storage', 'memory');
|
|
72
73
|
const file = path.join(dir, 'rollout-receipts.jsonl');
|
|
73
74
|
await fs.mkdir(dir, { recursive: true });
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
// C79-13: append→stat→rotate is one read-modify-write mutation. Unlocked,
|
|
76
|
+
// a concurrent writer's truncating rewrite silently ate sibling appends
|
|
77
|
+
// and a mid-rotation death left a torn JSONL. withFileLock serializes the
|
|
78
|
+
// whole mutation across processes and async flows; an expired wait drops
|
|
79
|
+
// THIS receipt whole (journaled) — never a torn file.
|
|
80
|
+
await withFileLock(file, async () => {
|
|
81
|
+
await fs.appendFile(file, `${JSON.stringify(line)}\n`, 'utf8');
|
|
82
|
+
const stat = await fs.stat(file);
|
|
83
|
+
if (stat.size > RECEIPT_MAX_BYTES) {
|
|
84
|
+
const lines = (await fs.readFile(file, 'utf8')).split('\n').filter(Boolean);
|
|
85
|
+
await fs.writeFile(file, `${lines.slice(-RECEIPT_KEEP_LINES).join('\n')}\n`, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
});
|
|
80
88
|
} catch {
|
|
81
89
|
// EACCES / ENOSPC / vanished dir — swallowed by contract.
|
|
82
90
|
}
|