kronk-cli 0.6.1 → 0.8.0

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/src/rescue.js ADDED
@@ -0,0 +1,306 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdirSync, openSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { config, DEFAULT_MODEL, applyLimits } from './config.js';
6
+ import { listModels, modelLimits } from './client.js';
7
+ import { chatModels, pickDefault } from './boot.js';
8
+ import { resolveModel } from './resolve.js';
9
+ import { AGENTS, subagentTools, systemFor } from './subagent.js';
10
+ import { runTurn } from './agent.js';
11
+ import { projectContext } from './context.js';
12
+ import {
13
+ ROOT, TERMINAL, createJob, readJob, recordStep, markRunning, markDone, markStopped,
14
+ markFailed, markInterrupted, toJson, collect, sweep, acquireLock, releaseLock,
15
+ } from './rescueStore.js';
16
+
17
+ /**
18
+ * `kronk-cli rescue` — the door the issue describes: a job an external
19
+ * agentic tool can submit to, follow up on, and collect from, without
20
+ * speaking a protocol or keeping a server up. `oneShot` in src/index.js is
21
+ * the same idea with the caller inside the process, blocking on the terminal;
22
+ * this is that same run, detached, reporting through a file instead of a
23
+ * screen.
24
+ *
25
+ * The one thing this file has that src/rescueStore.js deliberately does not:
26
+ * the ability to spawn a process. Submitting a job means starting a detached
27
+ * one and walking away from it — that is the whole point, the submitter must
28
+ * not wait — so it belongs in its own module, added once, deliberately, to
29
+ * the egress allowlist test/egress.test.js enforces.
30
+ */
31
+
32
+ /** `done` 0 · `stopped` 1 · `interrupted` 2 · `failed` 3 — `wait`'s exit code. */
33
+ export const EXIT_CODES = { done: 0, stopped: 1, interrupted: 2, failed: 3 };
34
+
35
+ /** How often a waiting caller, or a queued worker, checks the record again. */
36
+ const POLL_MS = 300;
37
+
38
+ const CLI = new URL('./index.js', import.meta.url).pathname;
39
+
40
+ const verbs = ['status', 'wait', 'result'];
41
+
42
+ /**
43
+ * `rescue`'s own argv, one level below src/argv.js: which of the four shapes
44
+ * — submit, status, wait, result — the words after `rescue` describe.
45
+ *
46
+ * Bare words, exactly like `setup` reserves its own name in src/index.js: a
47
+ * caller who quotes the prompt (the form the README recommends, and the only
48
+ * form the `--role` example in the issue itself uses) always lands here as
49
+ * one word, so the common case never collides with a verb. What does collide
50
+ * is a two-word invocation whose first word is not one of the three verbs —
51
+ * read as an attempted `<verb> <id>` rather than a short prompt, because
52
+ * silently running the wrong job's status check is worse than refusing. A
53
+ * genuine two-word prompt still works; it just has to be quoted, or written
54
+ * as three words.
55
+ */
56
+ export function parseRescueCommand(words) {
57
+ const rest = words.slice(1);
58
+ if (!rest.length) {
59
+ return { error: 'rescue needs a prompt, or one of: status <id>, wait <id>, result <id>' };
60
+ }
61
+ if (rest.length === 2 && !verbs.includes(rest[0])) {
62
+ return {
63
+ error: `unknown rescue verb: "${rest[0]}" — use status <id>, wait <id>, result <id>, `
64
+ + 'or quote the prompt',
65
+ };
66
+ }
67
+ if (verbs.includes(rest[0])) {
68
+ if (rest.length !== 2) return { error: `rescue ${rest[0]} takes exactly one job id` };
69
+ return { verb: rest[0], id: rest[1] };
70
+ }
71
+ return { verb: 'submit', prompt: rest.join(' ') };
72
+ }
73
+
74
+ /** `queued` · `running (step 7)` · `done` · `stopped` · `interrupted` · `failed`. */
75
+ function humanState(record) {
76
+ if (record.state === 'running') return `running (step ${record.steps})`;
77
+ return record.state;
78
+ }
79
+
80
+ /**
81
+ * The only place this program starts a detached job. `stdio` points at a log
82
+ * file under the rescue root rather than the parent's own streams — nobody is
83
+ * attached to read them live, and `ignore` would throw away the one clue left
84
+ * if the worker dies before it can write its own record (a bad argv, a
85
+ * missing entry point) — and `unref()` is what actually lets the submitting
86
+ * process exit while the child keeps running; without it Node holds the
87
+ * parent open waiting on a child it was just asked to walk away from.
88
+ */
89
+ function spawnWorker(id) {
90
+ let stdio = 'ignore';
91
+ try {
92
+ mkdirSync(ROOT(), { recursive: true, mode: 0o700 });
93
+ const fd = openSync(join(ROOT(), `${id}.log`), 'a', 0o600);
94
+ stdio = ['ignore', fd, fd];
95
+ } catch { /* the job still runs; only the crash-before-first-write case loses its clue */ }
96
+ const child = spawn(process.execPath, [CLI, 'rescue', '--rescue-worker', id], {
97
+ detached: true, stdio, cwd: process.cwd(), env: process.env,
98
+ });
99
+ child.unref();
100
+ }
101
+
102
+ async function submitCmd(prompt, args) {
103
+ const role = args.role ?? 'explore';
104
+ if (!AGENTS[role]) {
105
+ console.error(`\n unknown role "${role}" — use one of: ${Object.keys(AGENTS).join(', ')}\n`);
106
+ return 2;
107
+ }
108
+ if (!prompt.trim()) {
109
+ console.error('\n rescue needs a prompt saying what to do and what to report back\n');
110
+ return 2;
111
+ }
112
+
113
+ // Resolved here, the same way as #84 (src/resolve.js against
114
+ // chatModels(await listModels())), not deferred to the worker: a caller
115
+ // that named a model that does not exist should hear about it now, on the
116
+ // process it is still attached to, rather than have the job quietly run on
117
+ // something else or sit `failed` for a `wait` to discover later.
118
+ let model = null;
119
+ if (args.model) {
120
+ let ids;
121
+ try { ids = await listModels(); }
122
+ catch (e) {
123
+ console.error(`\n cannot reach Kronk at ${config.baseUrl}\n ${e.message}\n`);
124
+ return 1;
125
+ }
126
+ model = resolveModel(args.model, chatModels(ids));
127
+ if (!model) {
128
+ console.error(`\n no model matching "${args.model}"\n`);
129
+ return 1;
130
+ }
131
+ }
132
+
133
+ const job = createJob({
134
+ role, prompt, model,
135
+ yes: args.yes, auto: args.auto,
136
+ // `JSON.stringify(Infinity)` is `null`, so the "unlimited" a bare
137
+ // `--steps off` produces (src/argv.js) cannot ride through the record
138
+ // file as the number it is on the way in — it needs a shape that
139
+ // survives being written and read back as JSON.
140
+ maxSteps: args.steps === null ? null : (Number.isFinite(args.steps) ? args.steps : 'unlimited'),
141
+ });
142
+ spawnWorker(job.id);
143
+
144
+ if (args.json) console.log(JSON.stringify(toJson(job)));
145
+ else console.log(job.id);
146
+ return 0;
147
+ }
148
+
149
+ function statusCmd(id, args) {
150
+ const record = readJob(id);
151
+ if (!record) { console.error(`\n no rescue job ${id}\n`); return 1; }
152
+ if (args.json) console.log(JSON.stringify(toJson(record)));
153
+ else console.log(humanState(record));
154
+ return 0;
155
+ }
156
+
157
+ async function waitCmd(id, args) {
158
+ let record = readJob(id);
159
+ if (!record) { console.error(`\n no rescue job ${id}\n`); return 1; }
160
+ while (!TERMINAL.has(record.state)) {
161
+ await sleep(POLL_MS);
162
+ record = readJob(id);
163
+ if (!record) { console.error(`\n rescue job ${id} vanished while waiting\n`); return 1; }
164
+ }
165
+ collect(id);
166
+ if (args.json) console.log(JSON.stringify(toJson(record)));
167
+ else console.log(record.report ?? '');
168
+ return EXIT_CODES[record.state] ?? 1;
169
+ }
170
+
171
+ /** Not one of `wait`'s four codes on purpose — this is "ask again", not a finished job's state. */
172
+ const NOT_READY = 4;
173
+
174
+ function resultCmd(id, args) {
175
+ const record = readJob(id);
176
+ if (!record) { console.error(`\n no rescue job ${id}\n`); return 1; }
177
+ if (!TERMINAL.has(record.state)) {
178
+ console.error(`\n rescue job ${id} is ${humanState(record)} — not finished yet; `
179
+ + `use \`kronk-cli rescue wait ${id}\`\n`);
180
+ return NOT_READY;
181
+ }
182
+ collect(id);
183
+ if (args.json) console.log(JSON.stringify(toJson(record)));
184
+ else console.log(record.report ?? '');
185
+ return EXIT_CODES[record.state] ?? 1;
186
+ }
187
+
188
+ /**
189
+ * Run one job to completion inside the detached worker process. Everything
190
+ * below is `oneShot` in src/index.js with the terminal taken away: no
191
+ * banner, no streamed output, no readline — the record file is the only
192
+ * audience.
193
+ *
194
+ * `approve` and `grant` are the whole enforcement of the ground rule that
195
+ * matters most here: nobody is at the prompt, so without `--yes` / `--auto`
196
+ * on the original submit, both refuse outright. A rescue job is not a way to
197
+ * get a command approved that nobody was ever asked about.
198
+ */
199
+ export async function runWorker(id) {
200
+ const job = readJob(id);
201
+ if (!job) return;
202
+
203
+ for (;;) {
204
+ if (acquireLock(id)) break;
205
+ await sleep(POLL_MS);
206
+ if (!readJob(id)) return; // collected-and-swept, or otherwise gone, while we waited
207
+ }
208
+
209
+ // A stale lock this job's own runner is recovering from, or a duplicate
210
+ // worker started by mistake, both look the same from here: the record is
211
+ // not `queued` any more. Either way there is nothing left for this process
212
+ // to do.
213
+ const fresh = readJob(id);
214
+ if (!fresh || fresh.state !== 'queued') { releaseLock(id); return; }
215
+
216
+ const ac = new AbortController();
217
+ let interrupted = false;
218
+ const onSignal = () => { interrupted = true; ac.abort(); };
219
+ process.once('SIGTERM', onSignal);
220
+ process.once('SIGINT', onSignal);
221
+
222
+ let steps = 0;
223
+ try {
224
+ const ids = await listModels();
225
+ const model = job.model ?? (ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : pickDefault(ids));
226
+ config.model = model;
227
+ applyLimits(await modelLimits(model));
228
+ markRunning(id, { pid: process.pid, model });
229
+
230
+ const ctx = await projectContext(process.cwd());
231
+ config.projectPrimer = ctx.text;
232
+
233
+ const messages = [
234
+ { role: 'system', content: systemFor(job.role) },
235
+ { role: 'user', content: job.prompt },
236
+ ];
237
+
238
+ const approve = async () => job.yes || job.auto;
239
+ const grant = async () => ((job.yes || job.auto) ? 'yes' : 'no');
240
+
241
+ const done = await runTurn({
242
+ messages,
243
+ model,
244
+ signal: ac.signal,
245
+ approve,
246
+ grant,
247
+ mcp: null,
248
+ auto: job.auto,
249
+ tools: subagentTools(job.role),
250
+ maxSteps: job.maxSteps === 'unlimited' ? Infinity : (job.maxSteps ?? config.subagentSteps),
251
+ plan: false,
252
+ stream: false,
253
+ out: () => {},
254
+ quiet: true,
255
+ // One, not zero. `runTurn` adds `taskTools(depth)` on top of whatever
256
+ // tool list it is given, and at depth 0 that is the `task` tool — so a
257
+ // rescue job would be handed a tool the role it was submitted under does
258
+ // not list, and the issue's "the same tool lists" would quietly not be
259
+ // true. It would also let a detached job nobody is watching start
260
+ // further runs of its own, each able to pull a different model into a
261
+ // pool that holds one. Depth 1 is also simply what this is: a delegated
262
+ // run, with the caller one level up and outside the process.
263
+ depth: 1,
264
+ onStep: (msgs, n) => { steps = n; recordStep(id, msgs, n); },
265
+ });
266
+
267
+ const last = [...done].reverse().find((m) => m.role === 'assistant');
268
+ const text = String(last?.content ?? '').trim();
269
+ // `runTurn` returns the same way whether it finished or ran out of steps,
270
+ // so the note it pushes as the last assistant message is the only signal
271
+ // there is. Matched on its shape rather than on one exact sentence: it
272
+ // has two spellings already, one per depth, and a caller reading `stopped`
273
+ // needs that state to be right far more than this needs to be clever.
274
+ if (text.startsWith('(stopped:')) {
275
+ // Said in terms of the job the caller submitted. `runTurn`'s own wording
276
+ // is written for a sub-agent with someone at a terminal above it, which
277
+ // is not who is reading this.
278
+ const cap = job.maxSteps === 'unlimited' ? 'step cap' : `step cap of ${job.maxSteps ?? config.subagentSteps}`;
279
+ markStopped(id, { report: `error: the job reached its ${cap} before finishing`, steps });
280
+ } else {
281
+ markDone(id, { report: text || 'error: the job finished without reporting anything', steps });
282
+ }
283
+ } catch (e) {
284
+ if (interrupted) markInterrupted(id, { steps });
285
+ else markFailed(id, { error: e.message, steps });
286
+ } finally {
287
+ process.removeListener('SIGTERM', onSignal);
288
+ process.removeListener('SIGINT', onSignal);
289
+ releaseLock(id);
290
+ }
291
+ }
292
+
293
+ /** Dispatched from src/index.js's main() exactly where `setup` is. */
294
+ export async function runRescue(args) {
295
+ sweep();
296
+
297
+ if (args.rescueWorker) { await runWorker(args.rescueWorker); return 0; }
298
+
299
+ const parsed = parseRescueCommand(args.words);
300
+ if (parsed.error) { console.error(`\n ${parsed.error}\n`); return 2; }
301
+
302
+ if (parsed.verb === 'submit') return submitCmd(parsed.prompt, args);
303
+ if (parsed.verb === 'status') return statusCmd(parsed.id, args);
304
+ if (parsed.verb === 'wait') return waitCmd(parsed.id, args);
305
+ return resultCmd(parsed.id, args);
306
+ }
@@ -0,0 +1,277 @@
1
+ import {
2
+ mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, unlinkSync,
3
+ } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { config } from './config.js';
8
+
9
+ /**
10
+ * The record store behind `kronk-cli rescue` — one JSON file per job, plus a
11
+ * single lock file that stands in for the one resident copy of the model
12
+ * Kronk's pool holds. Nothing here spawns anything; that is `src/rescue.js`'s
13
+ * job, and the split is deliberate — the state machine and the file layout
14
+ * are exactly the part of this feature a test can drive without a process to
15
+ * spawn, and keeping child_process out of this file keeps that true.
16
+ *
17
+ * A rescue record has to outlive the process that wrote it in a way a
18
+ * sub-agent transcript (src/tasklog.js) never had to: the submitter prints an
19
+ * id and exits on purpose, so `cleanup()` removing *its* session directory,
20
+ * or `sweep()` aging a session out after 24h, would delete the very answer a
21
+ * caller was told to come back for. The fix is not a special case in those
22
+ * two functions — it's a directory neither of them has ever heard of.
23
+ * `ROOT()` here is never `src/tasklog.js`'s `ROOT()`, so this store's own
24
+ * lifetime rule (below) is the only one that ever applies to it.
25
+ */
26
+
27
+ export const ROOT = () => config.rescueDir ?? join(tmpdir(), 'kronk-cli-rescue');
28
+ const LOCK = () => join(ROOT(), 'lock.json');
29
+
30
+ /**
31
+ * Exactly the shape `newId()` produces, and the only shape a job id may have.
32
+ *
33
+ * A job id arrives from outside — `rescue status <id>` takes whatever was
34
+ * typed — and it is used to build a path. Without this it is used to build
35
+ * *any* path: `rescue result ../../something --json` would read that file and
36
+ * print it, and `collect()` would then write the record back over it, both
37
+ * outside the store entirely. That is a local CLI reading files its own
38
+ * operator can already read, so the ceiling on it is low — but a path built
39
+ * from unvalidated input is worth closing on its own terms, and the honest
40
+ * answer to an id that cannot exist is the one the callers already handle:
41
+ * there is no such job.
42
+ */
43
+ const ID = /^[0-9a-z]+-[0-9a-z]+-[0-9a-f]{6}$/;
44
+
45
+ export const isJobId = (id) => typeof id === 'string' && ID.test(id);
46
+
47
+ const file = (id) => {
48
+ if (!isJobId(id)) throw new Error(`not a job id: ${id}`);
49
+ return join(ROOT(), `${id}.json`);
50
+ };
51
+
52
+ /**
53
+ * Kept until collected, then aged out — the lifetime the issue asks for.
54
+ * Anything still uncollected stays no matter how old, because "uncollected"
55
+ * means the one caller who could read the answer has not yet asked for it;
56
+ * removing it on a clock the caller never agreed to would be losing a job
57
+ * nobody failed to explain to them, and quietly. Once `collect()` has stamped
58
+ * `collectedAt`, the same 24h window `src/tasklog.js` uses applies — chosen
59
+ * for the same reason: the record worth keeping is one from the last session
60
+ * or two, not an accumulating archive.
61
+ */
62
+ const KEEP_MS = 24 * 60 * 60 * 1000;
63
+
64
+ /** How the state machine may end. `queued` and `running` are the only other states. */
65
+ export const TERMINAL = new Set(['done', 'stopped', 'interrupted', 'failed']);
66
+
67
+ /** 0700 / 0600 throughout — a rescue prompt and its transcript are the same material src/tasklog.js guards. */
68
+ function ensureDir() {
69
+ mkdirSync(ROOT(), { recursive: true, mode: 0o700 });
70
+ }
71
+
72
+ /** Never throw into the caller: a full disk must not take the job down with it. */
73
+ function flush(record) {
74
+ try {
75
+ ensureDir();
76
+ writeFileSync(file(record.id), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
77
+ } catch { /* the record is a convenience for whoever collects it, not the work itself */ }
78
+ return record;
79
+ }
80
+
81
+ /**
82
+ * `<timestamp>-<pid>-<random>`, all base36/hex so it reads cleanly on a
83
+ * command line with no quoting. Timestamp and pid alone repeat within the
84
+ * same submitting process inside the same millisecond — unlikely, but a job
85
+ * id colliding with an existing file would silently overwrite someone else's
86
+ * record — so six hex digits of randomness closes that off for the cost of a
87
+ * few extra characters.
88
+ */
89
+ export function newId() {
90
+ return `${Date.now().toString(36)}-${process.pid.toString(36)}-${randomBytes(3).toString('hex')}`;
91
+ }
92
+
93
+ /** A job as first written: queued, nothing run yet. */
94
+ export function createJob({ role, prompt, model = null, yes = false, auto = false, maxSteps = null }) {
95
+ const id = newId();
96
+ return flush({
97
+ id, role, prompt, model,
98
+ yes: Boolean(yes), auto: Boolean(auto), maxSteps,
99
+ state: 'queued',
100
+ pid: null,
101
+ steps: 0,
102
+ startedAt: null,
103
+ endedAt: null,
104
+ report: null,
105
+ messages: [],
106
+ collectedAt: null,
107
+ });
108
+ }
109
+
110
+ /**
111
+ * One job in full, or null. An id that is not a job id at all, one no file was
112
+ * ever written for, and one already swept away all look the same from here —
113
+ * deliberately: every caller's answer to all three is "there is no such job".
114
+ */
115
+ export function readJob(id) {
116
+ if (!isJobId(id)) return null;
117
+ try { return JSON.parse(readFileSync(file(id), 'utf8')); }
118
+ catch { return null; }
119
+ }
120
+
121
+ function updateJob(id, patch) {
122
+ const record = readJob(id);
123
+ if (!record) return null;
124
+ Object.assign(record, patch);
125
+ return flush(record);
126
+ }
127
+
128
+ /** Written as the job goes, same reasoning as `Run.step` in src/tasklog.js. */
129
+ export const recordStep = (id, messages, steps) => updateJob(id, { messages, steps });
130
+
131
+ export const markRunning = (id, { pid, model }) =>
132
+ updateJob(id, { state: 'running', pid, model, startedAt: new Date().toISOString() });
133
+
134
+ function finish(id, state, extra) {
135
+ return updateJob(id, { state, endedAt: new Date().toISOString(), ...extra });
136
+ }
137
+
138
+ export const markDone = (id, { report, steps }) => finish(id, 'done', { report, steps });
139
+ export const markStopped = (id, { report, steps }) => finish(id, 'stopped', { report, steps });
140
+ export const markFailed = (id, { error, steps }) =>
141
+ finish(id, 'failed', { report: `error: ${error}`, steps });
142
+ export const markInterrupted = (id, { steps } = {}) =>
143
+ finish(id, 'interrupted', {
144
+ report: 'error: the job was interrupted before it finished',
145
+ ...(steps === undefined ? {} : { steps }),
146
+ });
147
+
148
+ /**
149
+ * The `--json` shape, and exactly it: `{ id, state, role, model, steps, ms,
150
+ * report, transcript }`. `ms` is measured live for a running job (there is no
151
+ * `endedAt` yet) so `status --json` on a job mid-run reports how long it has
152
+ * been going, not zero.
153
+ */
154
+ export function toJson(record) {
155
+ const ms = record.startedAt
156
+ ? Date.parse(record.endedAt ?? new Date().toISOString()) - Date.parse(record.startedAt)
157
+ : 0;
158
+ return {
159
+ id: record.id,
160
+ state: record.state,
161
+ role: record.role,
162
+ model: record.model,
163
+ steps: record.steps,
164
+ ms,
165
+ report: record.report,
166
+ transcript: record.messages,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Stamp the moment a terminal job was actually read back by `wait` or
172
+ * `result` — never by `status`, which is a peek, not a collection. Idempotent:
173
+ * the first caller to ask starts the clock, and asking again does not reset
174
+ * it, so two scripts polling the same id do not keep a finished job alive
175
+ * forever between them.
176
+ */
177
+ export function collect(id) {
178
+ const record = readJob(id);
179
+ if (!record) return null;
180
+ if (TERMINAL.has(record.state) && !record.collectedAt) {
181
+ return updateJob(id, { collectedAt: new Date().toISOString() });
182
+ }
183
+ return record;
184
+ }
185
+
186
+ /** Collected records older than `KEEP_MS`; everything else is left alone. */
187
+ export function sweep() {
188
+ let names;
189
+ try { names = readdirSync(ROOT()); } catch { return 0; }
190
+ let removed = 0;
191
+ for (const f of names) {
192
+ if (!f.endsWith('.json') || f === 'lock.json') continue;
193
+ const full = join(ROOT(), f);
194
+ try {
195
+ const record = JSON.parse(readFileSync(full, 'utf8'));
196
+ if (!record.collectedAt) continue;
197
+ if (Date.now() - Date.parse(record.collectedAt) < KEEP_MS) continue;
198
+ rmSync(full, { force: true });
199
+ removed += 1;
200
+ } catch { /* a record this cannot parse is not one it should guess about */ }
201
+ }
202
+ return removed;
203
+ }
204
+
205
+ /** Whether `pid` is a live process this machine still knows about. */
206
+ function isAlive(pid) {
207
+ try { process.kill(pid, 0); return true; }
208
+ catch (e) { return e.code === 'EPERM'; } // exists, owned by someone else — do not steal it
209
+ }
210
+
211
+ function readLock() {
212
+ try { return JSON.parse(readFileSync(LOCK(), 'utf8')); }
213
+ catch { return null; }
214
+ }
215
+
216
+ /**
217
+ * Claim the single slot the pool allows — see src/subagent.js's module
218
+ * comment for why there is only one. A plain exclusive create (`wx`) is the
219
+ * whole mechanism for the race: two runners asking for the same path can only
220
+ * ever have one winner, with no window in which both believe they hold it.
221
+ *
222
+ * The failure this still has to survive is not the race, it's the crash. A
223
+ * runner that dies holding the lock — killed, OOM, the machine rebooting —
224
+ * leaves the file behind with nobody left to remove it, and every caller
225
+ * after that would otherwise queue forever behind a process that no longer
226
+ * exists. So a lock file whose pid is not running is not treated as held: the
227
+ * job it belonged to never reached a terminal state on its own and never
228
+ * will, so that is recorded — `interrupted`, not left at `running` forever —
229
+ * before the file is cleared and the next claimant gets a turn.
230
+ *
231
+ * A lock that cannot be *parsed* is the same case, and it has to be handled
232
+ * as one rather than retried. A process killed between creating the file and
233
+ * finishing the write leaves a zero-byte `lock.json`: there is no pid in it to
234
+ * ask about, so treating "unreadable" as "try again" is an unyielding busy
235
+ * loop — the exclusive create keeps failing because the file is still there,
236
+ * and nothing in the loop ever removes it. In a detached worker nobody is
237
+ * watching, that is one core at 100% for as long as the machine is up, with
238
+ * every job after it queued behind a holder that was never alive. Unreadable
239
+ * means stale, for the same reason a dead pid does: there is no holder to
240
+ * respect.
241
+ *
242
+ * And the loop is bounded regardless. Every branch below either returns or
243
+ * removes the file it just failed to create, so the bound should be
244
+ * unreachable — which is exactly why it is cheap to have, and why a bug here
245
+ * costs a delayed job rather than a pinned CPU.
246
+ */
247
+ const LOCK_TRIES = 50;
248
+
249
+ export function acquireLock(id) {
250
+ ensureDir();
251
+ for (let attempt = 0; attempt < LOCK_TRIES; attempt += 1) {
252
+ try {
253
+ writeFileSync(LOCK(), JSON.stringify({ pid: process.pid, id }), { flag: 'wx', mode: 0o600 });
254
+ return true;
255
+ } catch (e) {
256
+ if (e.code !== 'EEXIST') throw e;
257
+ }
258
+ const holder = readLock();
259
+ if (holder && isAlive(holder.pid)) return false; // genuinely running someone else's job
260
+ if (holder && holder.id !== id) {
261
+ const stuck = readJob(holder.id);
262
+ if (stuck && !TERMINAL.has(stuck.state)) markInterrupted(holder.id, { steps: stuck.steps });
263
+ }
264
+ try { unlinkSync(LOCK()); } catch { /* someone else already cleared it */ }
265
+ }
266
+ // Contended past all patience: not held by us, so the caller waits and asks
267
+ // again rather than proceeding as though it had the slot.
268
+ return false;
269
+ }
270
+
271
+ /** Release the lock, but only the copy this process actually holds. */
272
+ export function releaseLock(id) {
273
+ const holder = readLock();
274
+ if (holder && holder.pid === process.pid && holder.id === id) {
275
+ try { unlinkSync(LOCK()); } catch { /* already gone */ }
276
+ }
277
+ }
package/src/resolve.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Resolve a wanted model id against the ids Kronk is actually serving.
3
+ *
4
+ * This exact three-step fallback — exact id wins, else the closest substring,
5
+ * preferring an /AGENT profile — used to be written out three times: the main
6
+ * model pick and the sub-agent model pick in src/index.js's boot(), and
7
+ * `/model` in the REPL. None of the three needed a copy of their own; they
8
+ * needed this. Now a fourth site has the same problem — a delegated task
9
+ * naming a model by a short id — and it calls this instead of becoming a
10
+ * fourth copy.
11
+ *
12
+ * Exact match wins outright: a caller who already typed the whole id gets
13
+ * exactly that id, never a substring guess. Among the substring matches, an
14
+ * /AGENT profile wins over the bare id, because Kronk holds each as a
15
+ * separate resident copy in the pool — picking the wrong one silently loads
16
+ * a second one.
17
+ *
18
+ * Returns null rather than throwing. Every caller already has its own way of
19
+ * saying "nothing matched" — a console warning that falls back to a default,
20
+ * or a tool result a small local model can act on — and none of them is this
21
+ * function's business.
22
+ */
23
+ export function resolveModel(want, ids) {
24
+ if (!want || !Array.isArray(ids) || !ids.length) return null;
25
+ const subs = ids.filter((id) => id.includes(want));
26
+ return ids.find((id) => id === want) ?? subs.find((id) => id.endsWith('/AGENT')) ?? subs[0] ?? null;
27
+ }
package/src/setup.js CHANGED
@@ -207,8 +207,13 @@ export function findOnPath(name, env = process.env) {
207
207
  *
208
208
  * `stream: true` hands the child our terminal, which is what a 21 GB pull wants;
209
209
  * otherwise its output is captured (up to CAPTURE_CAP) for parsing.
210
+ *
211
+ * `timeoutMs`, when given, kills the child after that long and still resolves
212
+ * (never rejects) with whatever was captured — the version check this exists
213
+ * for runs at startup and must come back on its own regardless of what the
214
+ * binary does, not hang the thing that asked.
210
215
  */
211
- export function runKronk(argv, { stream = false } = {}) {
216
+ export function runKronk(argv, { stream = false, timeoutMs } = {}) {
212
217
  return new Promise((resolve, reject) => {
213
218
  const child = spawn('kronk', argv, {
214
219
  stdio: ['ignore', stream ? 'inherit' : 'pipe', stream ? 'inherit' : 'pipe'],
@@ -217,8 +222,16 @@ export function runKronk(argv, { stream = false } = {}) {
217
222
  const take = (d) => { if (output.length < CAPTURE_CAP) output += d; };
218
223
  child.stdout?.setEncoding('utf8').on('data', take);
219
224
  child.stderr?.setEncoding('utf8').on('data', take);
220
- child.on('error', reject);
221
- child.on('close', (code) => resolve({ code: code ?? 1, output }));
225
+
226
+ let timer;
227
+ if (timeoutMs) {
228
+ timer = setTimeout(() => child.kill(), timeoutMs);
229
+ timer.unref?.();
230
+ }
231
+ const settle = (fn) => (arg) => { clearTimeout(timer); fn(arg); };
232
+
233
+ child.on('error', settle(reject));
234
+ child.on('close', settle((code) => resolve({ code: code ?? 1, output })));
222
235
  });
223
236
  }
224
237