@ngockhoale/ukit 3.0.10 → 3.0.12

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/ukit +5 -9
  3. package/package.json +1 -1
  4. package/scripts/bench/memory-baseline.mjs +1 -1
  5. package/scripts/bench/memory-bench.mjs +1 -1
  6. package/scripts/bench/memory-canary.mjs +12 -2
  7. package/scripts/bench/recorder-overhead.mjs +1 -1
  8. package/scripts/bench/sqlite-spike.mjs +1 -1
  9. package/scripts/measure-decision-gateway.mjs +1 -1
  10. package/src/cli/commands/memory.js +3 -0
  11. package/src/cli/deadline.js +19 -0
  12. package/src/core/agentRuntime/eventStore.js +38 -10
  13. package/src/core/agentRuntime/supervisor.js +41 -9
  14. package/src/core/agentRuntime/vmEngine.js +33 -2
  15. package/src/core/codeintel/freshness.js +13 -9
  16. package/src/core/codeintel/retriever.js +25 -20
  17. package/src/core/fileOps.js +19 -4
  18. package/src/core/memory/memoryFlags.js +14 -6
  19. package/src/core/metadata.js +111 -81
  20. package/src/core/token/index.js +69 -28
  21. package/src/index/buildIndex.js +7 -0
  22. package/src/index/impactContext.js +14 -5
  23. package/src/render/instructionRenderer.js +71 -19
  24. package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
  25. package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
  26. package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
  27. package/template_project/.claude/skills/docx/scripts/document.py +31 -8
  28. package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
  29. package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
  30. package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
  31. package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
  32. package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
  33. package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
  34. package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
  35. package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
  36. package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
  37. package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
  38. package/template_project/.claude/ukit/index/query-index.mjs +13 -11
  39. package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
  40. package/template_project/.claude/ukit/index/route-task.mjs +44 -25
  41. package/template_project/.claude/ukit/index/triage.mjs +12 -11
  42. package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
  43. package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
  44. package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
  46. package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.0.12 - 2026-09-26
6
+
7
+ - Re-publish of the 3.0.11 content: npm staged-publish left 3.0.11 in limbo (E409 "previously staged version"); re-published as 3.0.12 — same tree, version bump only.
8
+
9
+ ## 3.0.11 - 2026-09-26 (staged, never published)
10
+
11
+ - **C80 bug-sweep fix cycle — all 23 confirmed/high-confidence execution-path bugs fixed** (`docs/AI_HANDOFF/REPORT-C80-bug-sweep.md`):
12
+ - 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).
13
+ - `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).
14
+ - `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).
15
+ - `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).
16
+ - 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).
17
+ - `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).
18
+
5
19
  ## 3.0.10 - 2026-09-25
6
20
 
7
21
  - 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 deadlineMs = resolveCliDeadlineMs();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.0.10",
3
+ "version": "3.0.12",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- const res = spawnSync(process.execPath, args, { encoding: 'utf8' });
95
- if (res.status !== 0) throw new Error(`corpus generation failed: ${res.stderr || res.stdout}`);
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/seq is an
160
- * idempotent no-op; a future seq rejects out_of_order. The line is fsync'd
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 } }` — test fault injection
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 cursor = await loadCursor(dir, event.operationId);
173
- const order = validateEventOrder(event, cursor);
174
- if (!order.ok) throw new EventStoreError(order.code, order.code);
175
- if (order.duplicate) return { seq: event.seq, duplicate: true };
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
- await appendLine(journalPath(dir, event.operationId), event);
178
- await opts.hooks?.afterAppend?.(event);
179
- return { seq: event.seq };
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: !op.exited,
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
- if (artifactWriter) {
451
- try {
452
- op.artifacts = artifactWriter.open(op.id);
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
- const seq = ++inst.cursors[nodeId].lastSeq;
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 { /* no continuations file — nothing registered yet */ }
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
- for (const line of output.split('\n')) {
62
- if (!line.trim()) continue;
63
- let p = line.slice(3);
64
- // rename entries: "old -> new" — track the new path
65
- const arrow = p.indexOf(' -> ');
66
- if (arrow !== -1) p = p.slice(arrow + 4);
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): fire-and-forget lane telemetry. One JSONL line per
266
- // retrieval at .ukit/storage/cache/retriever-lanes.jsonl; query persisted as
267
- // sha256 only (no raw prompts on disk). Failures are swallowed — telemetry
268
- // must never break retrieve(). Sync writes keep the fire-and-forget contract
269
- // deterministic for callers/tests.
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
- fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
297
- // Best-effort cap: past 256 KiB keep only the newest ~2000 lines.
298
- try {
299
- const st = fsSync.statSync(filePath);
300
- if (st.size > RETRIEVER_LANES_MAX_BYTES) {
301
- const kept = fsSync.readFileSync(filePath, 'utf8').split('\n').filter(Boolean)
302
- .slice(-RETRIEVER_LANES_KEEP_LINES);
303
- fsSync.writeFileSync(filePath, kept.length ? `${kept.join('\n')}\n` : '');
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
- } catch {
306
- // Missing/unreadable file is fine — append below recreates it.
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,
@@ -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 them.
145
- // The payload is already fully written — copy it over and unlink the tmp.
146
- // Less atomic than rename, but the update must not be silently lost.
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
- await withTransientFsRetry(() => fs.copyFile(tempPath, filePath));
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
- await fs.appendFile(file, `${JSON.stringify(line)}\n`, 'utf8');
75
- const stat = await fs.stat(file);
76
- if (stat.size > RECEIPT_MAX_BYTES) {
77
- const lines = (await fs.readFile(file, 'utf8')).split('\n').filter(Boolean);
78
- await fs.writeFile(file, `${lines.slice(-RECEIPT_KEEP_LINES).join('\n')}\n`, 'utf8');
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
  }