knodin 0.10.0 → 0.10.1

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/dist/src/init.js CHANGED
@@ -7,7 +7,7 @@ import { compareBytes } from "./compare.js";
7
7
  import { isIndexableSourcePath } from "./engine/source-policy.js";
8
8
  import { lookupMirror } from "./engine/state-paths.js";
9
9
  import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
10
- import { acquireRepairLease } from "./repair-lease.js";
10
+ import { acquireRepairLease, LIFECYCLE_LEASE_TOKEN_ENV } from "./repair-lease.js";
11
11
  import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
12
12
  import { registerInitializedWorktree } from "./worktree-lifecycle.js";
13
13
  const MANAGED_MARKER = "KNODIN MANAGED HOOK";
@@ -357,10 +357,29 @@ export async function refreshFromGitEvent(repo, event, index) {
357
357
  }
358
358
  }
359
359
  function backgroundScript(command) {
360
- const invocation = command.map(shellQuote).join(" ");
360
+ // The interpreter is resolved at RUN time, not baked in. `process.execPath`
361
+ // is symlink-resolved by Node, so a stable `/opt/homebrew/bin/node` becomes
362
+ // a version-pinned `/opt/homebrew/Cellar/node/<version>/bin/node`. Homebrew
363
+ // deletes that directory on upgrade, and every hook generated beforehand
364
+ // then dies with exit 127 — silently, because refresh runs in the
365
+ // background, so the graph just stops keeping up.
366
+ //
367
+ // The generated path stays PREFERRED, so nothing changes while it exists;
368
+ // the fallback only engages once it does not. Falling back first would
369
+ // silently switch which runtime executes, which is its own bug.
370
+ const [interpreter, ...rest] = command;
371
+ const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
372
+ const preferred = shellQuote(interpreter ?? process.execPath);
361
373
  return String.raw `#!/bin/sh
362
374
  # knodin packaged background refresh. Generated by knodin init.
363
375
  set -u
376
+ KNODIN_NODE=${preferred}
377
+ if [ ! -x "$KNODIN_NODE" ]; then
378
+ KNODIN_NODE="$(command -v node 2>/dev/null || true)"
379
+ # No interpreter at all: fall through to the recorded path so the failure is
380
+ # the existing loud "not found", not a silent no-op.
381
+ [ -n "$KNODIN_NODE" ] || KNODIN_NODE=${preferred}
382
+ fi
364
383
  REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
365
384
  [ -n "$REPO_ROOT" ] || exit 0
366
385
  cd "$REPO_ROOT" || exit 0
@@ -460,7 +479,7 @@ function refreshProcessorIsRunning(repo) {
460
479
  return false;
461
480
  }
462
481
  }
463
- function drainQueuedLifecycleEvents(repo, backgroundPath) {
482
+ function drainQueuedLifecycleEvents(repo, backgroundPath, leaseToken) {
464
483
  const before = queuedLifecycleEvents(repo);
465
484
  if (before.length === 0)
466
485
  return { state: "fresh", queuedEvents: 0 };
@@ -469,6 +488,13 @@ function drainQueuedLifecycleEvents(repo, backgroundPath) {
469
488
  encoding: "utf-8",
470
489
  stdio: ["ignore", "pipe", "pipe"],
471
490
  timeout: 30_000,
491
+ // The caller holds the lifecycle lease while this runs. Without handing
492
+ // the token down, the child re-enters knodin, meets its own parent's
493
+ // lease, and refuses — so the drain could never succeed and init
494
+ // reported failure on every run.
495
+ env: leaseToken
496
+ ? { ...process.env, [LIFECYCLE_LEASE_TOKEN_ENV]: leaseToken }
497
+ : { ...process.env },
472
498
  });
473
499
  if (result.status !== 0) {
474
500
  const detail = result.error?.message || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
@@ -1289,7 +1315,7 @@ export async function initializeRepository(repo, options) {
1289
1315
  if (isIndexResult(indexResult) && indexResult.verification.status !== "healthy") {
1290
1316
  throw new InitializationHealthError(indexResult);
1291
1317
  }
1292
- const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath);
1318
+ const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
1293
1319
  await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), {
1294
1320
  force: true,
1295
1321
  });
@@ -200,8 +200,19 @@ export class RepositoryWorker extends EventEmitter {
200
200
  }
201
201
  const { requestId, operation } = context;
202
202
  try {
203
+ // Same race as the startup one below, one phase earlier: the queueAbort
204
+ // arm above reports CLIENT_DISCONNECTED, and this re-check reported
205
+ // CANCELLED for the identical event whenever the queue wait resolved in
206
+ // the same instant the abort landed.
207
+ //
208
+ // The supervisor observes only "the signal aborted" and cannot tell a
209
+ // disconnect from an explicit cancel; every abort-listener arm here
210
+ // already calls that a disconnect, so the re-checks now agree with them.
211
+ // The distinction belongs to the layer that knows why the signal fired —
212
+ // superviseMcpRequest, which raises REQUEST_CANCELLED for a client
213
+ // cancellation it can actually identify.
203
214
  if (signal.aborted)
204
- throw codedError("KNODIN_REQUEST_CANCELLED", "request cancelled before worker dispatch");
215
+ throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker dispatch");
205
216
  if (!this.child) {
206
217
  if (this.restartTimes.length >= MAX_RESTARTS)
207
218
  throw codedError("KNODIN_RESTART_LIMIT", "graph worker restart limit reached");
@@ -233,8 +244,20 @@ export class RepositoryWorker extends EventEmitter {
233
244
  if (startupAbort)
234
245
  signal.removeEventListener("abort", startupAbort);
235
246
  });
247
+ // Same event as the startupAbort arm above, same code. Both describe an
248
+ // abort arriving during worker startup; which one reports it is decided
249
+ // by whether the startup race settles as ready or as aborted in the same
250
+ // instant. Reporting different codes made the SAME client disconnect
251
+ // surface as CLIENT_DISCONNECTED or REQUEST_CANCELLED depending on
252
+ // scheduling — stable on an idle machine, unstable under load, which is
253
+ // worse than a consistently wrong value because it invites trust.
254
+ //
255
+ // These codes name a CAUSE, not a phase. classifyMcpFailure maps them to
256
+ // distinct kinds in the reliability journal, so the race was also
257
+ // recording one disconnect under two kinds and silently contaminating
258
+ // any disconnect-versus-cancellation rate computed from it.
236
259
  if (signal.aborted)
237
- throw codedError("KNODIN_REQUEST_CANCELLED", "request cancelled during worker startup");
260
+ throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected during worker startup");
238
261
  const predecessorTraceId = this.lastFailureTraceId;
239
262
  if (predecessorTraceId)
240
263
  recordMcpLifecycle(context, "restart", { predecessorTraceId });
@@ -92,10 +92,34 @@ function removeStaleLeaseOrThrow(target, operation) {
92
92
  throw error;
93
93
  }
94
94
  }
95
+ /**
96
+ * Environment channel by which a lease holder tells a process it spawns that
97
+ * the lease it is about to meet is its OWN.
98
+ *
99
+ * `knodin init` holds the lease and then drains the queued lifecycle refresh by
100
+ * spawning the background hook, which re-enters knodin and tries to take the
101
+ * same lease. Without this the child sees a live pid — its own parent — and
102
+ * refuses, so init can never drain its own queue and reports failure on every
103
+ * run while telling the user to run init again.
104
+ */
105
+ export const LIFECYCLE_LEASE_TOKEN_ENV = "KNODIN_LIFECYCLE_LEASE_TOKEN";
95
106
  /** Atomically serialize repair post-processing across independent CLI/MCP processes. */
96
107
  export function acquireRepairLease(repo, operation = "repair") {
97
108
  const target = leasePath(repo);
98
109
  fs.mkdirSync(path.dirname(target), { recursive: true });
110
+ // Re-entrant ONLY for a token that matches the lease actually on disk. A
111
+ // stale or forged token falls through to the normal contention check, so a
112
+ // genuinely concurrent mutation from another process is still refused —
113
+ // this makes the lease stop blocking its own holder, it does not disable it.
114
+ const inheritedToken = process.env[LIFECYCLE_LEASE_TOKEN_ENV];
115
+ if (inheritedToken) {
116
+ const held = readLease(target);
117
+ if (held?.nonce === inheritedToken) {
118
+ // The holder releases it. Releasing here would drop the lock while the
119
+ // parent still believes it holds it.
120
+ return { release() { } };
121
+ }
122
+ }
99
123
  const lifecycleCommand = [
100
124
  "repair",
101
125
  "init",
@@ -124,6 +148,7 @@ export function acquireRepairLease(repo, operation = "repair") {
124
148
  writeLease(target, record);
125
149
  writeActivity(repo, record);
126
150
  return {
151
+ token: record.nonce,
127
152
  release() {
128
153
  try {
129
154
  const current = readLease(target);
@@ -0,0 +1,83 @@
1
+ # knodin 0.10.1
2
+
3
+ Three defects, none of which a test suite would have found on its own. Two were
4
+ found by upgrading knodin on a real machine: one only appears after Homebrew
5
+ moves the Node binary, the other only when a lifecycle event is already queued.
6
+ The third is a scheduling race that reported the same cause under two different
7
+ codes, and it surfaced as an unrelated pre-push failure after six clean runs.
8
+
9
+ ## Background refresh survives a moved Node binary
10
+
11
+ `knodin init` wrote the generated hook with a fully-resolved path to whichever
12
+ Node was running at the time. `process.execPath` is symlink-resolved, so a
13
+ stable `/opt/homebrew/bin/node` became a version-pinned
14
+ `/opt/homebrew/Cellar/node/<version>/bin/node`.
15
+
16
+ Homebrew deletes that directory on upgrade, so every hook written beforehand
17
+ died with exit 127 — silently, because refresh runs in the background. The
18
+ graph simply stopped keeping up, and only a `knodin status` run revealed it.
19
+ It affected every repository initialized before the upgrade, not one.
20
+
21
+ The hook now resolves its interpreter when it runs. The recorded path stays
22
+ preferred, so nothing changes while it exists; `PATH` is consulted only once it
23
+ does not. Falling back first would silently change which runtime executes,
24
+ which is its own bug.
25
+
26
+ Hooks generated before this release still carry the old absolute path. They
27
+ keep failing until regenerated with `knodin init`; the fix prevents recurrence
28
+ after the next Node upgrade rather than repairing them retroactively.
29
+
30
+ ## `knodin init` can drain its own lifecycle queue
31
+
32
+ `init` takes the lifecycle lease, then drains queued events by spawning the
33
+ background hook. The hook re-entered knodin, met the same lease, saw a live
34
+ pid — its own parent — and refused. `init` reported "queued lifecycle refresh
35
+ could not be drained (exit 1)" on every run, and `knodin status` then advised
36
+ running `init`, which reproduced the failure exactly.
37
+
38
+ Worse, the failed drain wrote a failure marker that `status` kept reporting, so
39
+ a transient condition looked permanent while the graph was in fact intact — in
40
+ the observed case 5,883 of 5,883 files indexed and 5,059 carrying symbols.
41
+
42
+ A lease holder now passes its nonce to processes it spawns, and a child whose
43
+ token matches the lease on disk is admitted re-entrantly. Re-entry returns a
44
+ no-op release, because releasing there would drop the lock while the parent
45
+ still believes it holds it.
46
+
47
+ The lease is not weakened. A missing token, or one that does not match the
48
+ lease on disk, still hits the existing contention check, so a genuinely
49
+ concurrent mutation from another process is refused exactly as before.
50
+
51
+ ## One abort cause, one code
52
+
53
+ Worker startup and queue admission are each a race between "ready" and
54
+ "aborted". Each arm had an abort listener rejecting with
55
+ `KNODIN_CLIENT_DISCONNECTED` and a follow-up `signal.aborted` re-check
56
+ rejecting with `KNODIN_REQUEST_CANCELLED`, so the same client disconnect
57
+ surfaced as either code depending on which arm settled first.
58
+
59
+ That is worse than a consistently wrong code. It was stable on an idle machine
60
+ and unstable under load, so it looked trustworthy everywhere anyone would have
61
+ checked it — it surfaced as a pre-push gate failure on an unrelated change
62
+ after six clean runs.
63
+
64
+ It also fed the reliability journal, where the two codes map to distinct kinds.
65
+ One disconnect was recorded under two kinds, quietly skewing any rate computed
66
+ from them. **That contamination is retroactive and cannot be repaired:** journal
67
+ entries written before this release cannot be reattributed, so a
68
+ cancellation-versus-disconnect rate spanning the 0.10.1 boundary should be read
69
+ as suspect rather than corrected.
70
+
71
+ The taxonomy is unchanged. `superviseMcpRequest` can still tell an explicit
72
+ cancellation from a disconnect; the supervisor cannot, so it now names only the
73
+ cause it actually knows.
74
+
75
+ ## Homebrew formula retention
76
+
77
+ The `Retain versioned Homebrew formula` job ran on a GitHub-hosted runner whose
78
+ ephemeral IP is not on the organization's allow list, so it failed at checkout
79
+ with a 403 on every release since it was added — and it cloned
80
+ `knodin/knodin`, a stale source mirror, rather than `knodin/homebrew-tap`.
81
+
82
+ Both are corrected. Every formula through 0.10.0 was retained by hand; this is
83
+ the first release where that job can do it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "knodin": {
5
5
  "compatibility": "breaking"
6
6
  },
@@ -72,6 +72,7 @@
72
72
  "docs/releases/0.8.7.md",
73
73
  "docs/releases/0.9.0.md",
74
74
  "docs/releases/0.10.0.md",
75
+ "docs/releases/0.10.1.md",
75
76
  "docs/assets/knodin-favicon.svg",
76
77
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
77
78
  "docs/TELEMETRY.md",