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 +30 -4
- package/dist/src/mcp-worker-supervisor.js +25 -2
- package/dist/src/repair-lease.js +25 -0
- package/docs/releases/0.10.1.md +83 -0
- package/package.json +2 -1
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
|
-
|
|
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("
|
|
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("
|
|
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 });
|
package/dist/src/repair-lease.js
CHANGED
|
@@ -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.
|
|
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",
|