kronk-cli 0.7.0 → 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/README.md +187 -5
- package/package.json +1 -1
- package/src/agent.js +7 -2
- package/src/argv.js +11 -1
- package/src/boot.js +35 -2
- package/src/config.js +21 -3
- package/src/index.js +87 -28
- package/src/mcp.js +4 -1
- package/src/pane.js +10 -2
- package/src/rescue.js +306 -0
- package/src/rescueStore.js +277 -0
- package/src/resolve.js +27 -0
- package/src/setup.js +16 -3
- package/src/subagent.js +148 -21
- package/src/tasklog.js +7 -1
- package/src/ui.js +20 -6
- package/src/update.js +291 -0
- package/src/version.js +35 -0
package/src/pane.js
CHANGED
|
@@ -150,12 +150,20 @@ const OFF = {
|
|
|
150
150
|
* to itself rather than competing with a redraw every 250 ms.
|
|
151
151
|
*/
|
|
152
152
|
export function livePane({
|
|
153
|
-
agent = 'agent', maxSteps, rows: keep = BODY_ROWS, signal,
|
|
153
|
+
agent = 'agent', model, maxSteps, rows: keep = BODY_ROWS, signal,
|
|
154
154
|
stream = process.stdout, input = process.stdin, env = process.env,
|
|
155
155
|
now = Date.now, tickMs = 250,
|
|
156
156
|
} = {}) {
|
|
157
157
|
if (!paneUsable(stream, env)) return OFF;
|
|
158
158
|
|
|
159
|
+
// The label carries the model too, the same way the opening line above a
|
|
160
|
+
// pipe does (src/subagent.js's `openLine`) — a task on a model that had to
|
|
161
|
+
// be loaded cold is exactly the run a person watches the box wondering why
|
|
162
|
+
// it is slow, and the header is where that question gets answered. The last
|
|
163
|
+
// two path segments, not one: a served id's last segment alone (`AGENT`,
|
|
164
|
+
// `base`) is shared by every profile of every model and says nothing.
|
|
165
|
+
const label = model ? `${agent} · ${String(model).split('/').slice(-2).join('/')}` : agent;
|
|
166
|
+
|
|
159
167
|
const started = now();
|
|
160
168
|
const fed = [];
|
|
161
169
|
let steps = 0;
|
|
@@ -174,7 +182,7 @@ export function livePane({
|
|
|
174
182
|
if (collapsed) return;
|
|
175
183
|
const width = paneWidth(stream.columns);
|
|
176
184
|
const hint = c.grey(unwatch ? 'esc to collapse · ctrl-c to stop' : 'ctrl-c to stop');
|
|
177
|
-
const frame = renderPane({ agent, rows: fed, status: status(), hint, width });
|
|
185
|
+
const frame = renderPane({ agent: label, rows: fed, status: status(), hint, width });
|
|
178
186
|
// Up to the top of the old box, then repaint row by row. `\x1b[K` on each
|
|
179
187
|
// row so a shorter line does not leave the tail of a longer one behind.
|
|
180
188
|
let buf = height ? `\r\x1b[${height}A` : '';
|
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
|
-
|
|
221
|
-
|
|
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
|
|