@bridge4dev/runner 0.63.0 → 0.65.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/dist/adapters/agent-tasks.d.ts +113 -0
- package/dist/adapters/agent-tasks.js +260 -0
- package/dist/adapters/claude.js +45 -236
- package/dist/adapters/codex-subagents.d.ts +169 -0
- package/dist/adapters/codex-subagents.js +569 -0
- package/dist/adapters/codex.d.ts +4 -0
- package/dist/adapters/codex.js +194 -18
- package/dist/checkpoints.js +5 -13
- package/dist/environment.d.ts +15 -0
- package/dist/environment.js +23 -0
- package/dist/git.d.ts +36 -0
- package/dist/git.js +326 -4
- package/dist/index.js +5 -0
- package/dist/supervisor.d.ts +20 -0
- package/dist/supervisor.js +83 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/git.js
CHANGED
|
@@ -2,8 +2,10 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import { previewsDir, worktreesDir } from './paths.js';
|
|
6
|
-
import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
|
|
5
|
+
import { previewsDir, stateDir, worktreesDir } from './paths.js';
|
|
6
|
+
import { cleanGitEnv, firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
|
|
7
|
+
import { isGitInternalPath, isInsideWorktree, isSecretPath } from './policy.js';
|
|
8
|
+
import { log } from './log.js';
|
|
7
9
|
const execFileAsync = promisify(execFile);
|
|
8
10
|
const GIT_TIMEOUT_MS = 30_000;
|
|
9
11
|
/**
|
|
@@ -14,12 +16,27 @@ const GIT_TIMEOUT_MS = 30_000;
|
|
|
14
16
|
* Ninety seconds is the owner's number (plan §9, 06.09.2026).
|
|
15
17
|
*/
|
|
16
18
|
const WORKTREE_ADD_TIMEOUT_MS = 90_000;
|
|
19
|
+
/**
|
|
20
|
+
* Every git call in this file names its repository with `cwd` and nothing else,
|
|
21
|
+
* so an inherited `GIT_DIR` (or index, or object store) can only ever point
|
|
22
|
+
* somewhere we did not mean — see `cleanGitEnv`. Free everywhere; load-bearing
|
|
23
|
+
* in `initProjectDir`, where `git init` under an inherited `GIT_DIR` would
|
|
24
|
+
* initialise a different directory and report success (#417).
|
|
25
|
+
*/
|
|
17
26
|
async function git(cwd, ...args) {
|
|
18
|
-
const { stdout } = await execFileAsync('git', args, {
|
|
27
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
28
|
+
cwd,
|
|
29
|
+
timeout: GIT_TIMEOUT_MS,
|
|
30
|
+
env: cleanGitEnv(),
|
|
31
|
+
});
|
|
19
32
|
return stdout.trim();
|
|
20
33
|
}
|
|
21
34
|
async function gitSlow(cwd, timeoutMs, ...args) {
|
|
22
|
-
const { stdout } = await execFileAsync('git', args, {
|
|
35
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
36
|
+
cwd,
|
|
37
|
+
timeout: timeoutMs,
|
|
38
|
+
env: cleanGitEnv(),
|
|
39
|
+
});
|
|
23
40
|
return stdout.trim();
|
|
24
41
|
}
|
|
25
42
|
/**
|
|
@@ -252,6 +269,311 @@ export async function validateWorkspacePath(workspacePath) {
|
|
|
252
269
|
...(defaultBranch ? { defaultBranch } : {}),
|
|
253
270
|
};
|
|
254
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* The branch a repository created from the binding window starts on (#417).
|
|
274
|
+
*
|
|
275
|
+
* One name, here, because two calls ask for it — `git init --initial-branch`
|
|
276
|
+
* and the `symbolic-ref` that stands in for it on a git older than 2.28 — and a
|
|
277
|
+
* folder that came out on `master` because the two disagreed would be a bug
|
|
278
|
+
* nobody sees until a push.
|
|
279
|
+
*/
|
|
280
|
+
const INITIAL_BRANCH = 'main';
|
|
281
|
+
/**
|
|
282
|
+
* Directories a project folder is never created inside (#417).
|
|
283
|
+
*
|
|
284
|
+
* The root itself and the trees a Linux system is assembled from. Deliberately
|
|
285
|
+
* a short, literal list rather than a clever rule: everything here is a place
|
|
286
|
+
* where `mkdir` succeeding is worse than it failing, and a person who typed one
|
|
287
|
+
* of them meant something else. `/opt`, `/srv`, `/home`, `/var/www` are NOT on
|
|
288
|
+
* it — those are exactly where projects live.
|
|
289
|
+
*
|
|
290
|
+
* `/usr/local` and friends are covered by their parents: the test is «is this
|
|
291
|
+
* path at or below one of these».
|
|
292
|
+
*/
|
|
293
|
+
const SYSTEM_DIRECTORIES = [
|
|
294
|
+
'/etc',
|
|
295
|
+
'/usr',
|
|
296
|
+
'/bin',
|
|
297
|
+
'/sbin',
|
|
298
|
+
'/lib',
|
|
299
|
+
'/lib32',
|
|
300
|
+
'/lib64',
|
|
301
|
+
'/libx32',
|
|
302
|
+
'/boot',
|
|
303
|
+
'/proc',
|
|
304
|
+
'/sys',
|
|
305
|
+
'/dev',
|
|
306
|
+
'/run',
|
|
307
|
+
];
|
|
308
|
+
function refuseInit(error) {
|
|
309
|
+
return { ok: false, created: false, exists: false, error };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Why this path may not be created, or `null` — asked of a STRING (#417).
|
|
313
|
+
*
|
|
314
|
+
* Pulled out of `initProjectDir` because it has to be asked twice: once about
|
|
315
|
+
* what the person typed, and again about where that path really lands once the
|
|
316
|
+
* filesystem has had its say. See `realLandingPlace`.
|
|
317
|
+
*/
|
|
318
|
+
function pathClassRefusal(candidate) {
|
|
319
|
+
if (isGitInternalPath(candidate)) {
|
|
320
|
+
return `${candidate} is inside a .git directory, which belongs to git itself — pick a folder for the project instead`;
|
|
321
|
+
}
|
|
322
|
+
if (candidate === path.sep || SYSTEM_DIRECTORIES.some((dir) => isAtOrBelow(candidate, dir))) {
|
|
323
|
+
return `${candidate} is a system directory on this server — pick a folder for the project instead`;
|
|
324
|
+
}
|
|
325
|
+
// Said separately from the line above, because it would not be true there:
|
|
326
|
+
// `~/.ssh` is not a system directory, and a refusal that misnames what it is
|
|
327
|
+
// refusing teaches the person the wrong thing about their own machine.
|
|
328
|
+
if (isSecretPath(candidate)) {
|
|
329
|
+
return `${candidate} is on this server's protected list — pick a folder for the project instead`;
|
|
330
|
+
}
|
|
331
|
+
// `isSecretPath` cannot answer this one: the runner tree is on its list, but
|
|
332
|
+
// `worktrees/` and `previews/` are excused from it so an agent can read its
|
|
333
|
+
// own workspace. Everything under the state directory is the runner's own
|
|
334
|
+
// bookkeeping either way, and a project living inside it would be rewritten by
|
|
335
|
+
// the next session cleanup. Both sides go through the real filesystem, because
|
|
336
|
+
// `stateDir()` is built from `os.homedir()` and a symlinked home would
|
|
337
|
+
// otherwise make the two paths look unrelated.
|
|
338
|
+
const state = realPathOf(stateDir());
|
|
339
|
+
if (isAtOrBelow(candidate, state)) {
|
|
340
|
+
return `${candidate} is inside the runner's own state directory — pick a folder for the project instead`;
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
/** `realpathSync` where it works, the path itself where it does not. */
|
|
345
|
+
function realPathOf(target) {
|
|
346
|
+
try {
|
|
347
|
+
return fs.realpathSync(target);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return path.resolve(target);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Where `mkdir` would actually land, symlinks and all (#417).
|
|
355
|
+
*
|
|
356
|
+
* Every refusal above is string work — `path.resolve` does not follow symlinks,
|
|
357
|
+
* `isSecretPath` normalises nothing, and containment is `path.relative`. The
|
|
358
|
+
* kernel disagrees: on a stock Debian `/var/run` IS `/run`, so `/var/run/shop`
|
|
359
|
+
* passes a list that exists to refuse exactly that, and the repository ends up
|
|
360
|
+
* on tmpfs and is gone at the next reboot.
|
|
361
|
+
*
|
|
362
|
+
* `policy.ts` solves the same problem in `normalize()` by resolving the parent
|
|
363
|
+
* and putting the last segment back on. That is not enough here, because this
|
|
364
|
+
* is asked BEFORE anything about the parent is known: with `/tmp/link -> /etc`
|
|
365
|
+
* and the path `/tmp/link/new/project`, the parent does not exist, its
|
|
366
|
+
* `realpath` fails, and the fallback is the unresolved string — which would then
|
|
367
|
+
* earn the advice «create /tmp/link/new first», i.e. make a folder in `/etc`. So
|
|
368
|
+
* the walk goes up to the deepest ancestor that DOES exist, resolves that, and
|
|
369
|
+
* puts every missing segment back on.
|
|
370
|
+
*/
|
|
371
|
+
function realLandingPlace(resolved) {
|
|
372
|
+
const missing = [];
|
|
373
|
+
let existing = resolved;
|
|
374
|
+
for (;;) {
|
|
375
|
+
try {
|
|
376
|
+
const real = fs.realpathSync(existing);
|
|
377
|
+
return path.join(real, ...missing.reverse());
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
const up = path.dirname(existing);
|
|
381
|
+
if (up === existing)
|
|
382
|
+
return resolved;
|
|
383
|
+
missing.push(path.basename(existing));
|
|
384
|
+
existing = up;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Is `target` at or below `root`?
|
|
390
|
+
*
|
|
391
|
+
* `isInsideWorktree` is the containment helper this package already has, and it
|
|
392
|
+
* answers exactly this question — the name says worktree because that was its
|
|
393
|
+
* first caller, not because it knows anything about worktrees.
|
|
394
|
+
*/
|
|
395
|
+
function isAtOrBelow(target, root) {
|
|
396
|
+
return isInsideWorktree(target, root);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Create the project folder and initialise git in it, from the binding window
|
|
400
|
+
* (#417).
|
|
401
|
+
*
|
|
402
|
+
* Two things and no more: ONE directory — the last segment of the path, never a
|
|
403
|
+
* chain of parents — and `git init` with `main` as the branch. No first commit:
|
|
404
|
+
* an empty repository is a legitimate state (#137, and the runner has known how
|
|
405
|
+
* to work in one since 0.63.0), while an «Initial commit» nobody asked for is
|
|
406
|
+
* the thing that makes `git pull` from an existing remote refuse with
|
|
407
|
+
* «unrelated histories» later on.
|
|
408
|
+
*
|
|
409
|
+
* Its own refusal list, because `validateWorkspacePath` has none to share: that
|
|
410
|
+
* function asks «can the runner work here», which is a question about
|
|
411
|
+
* permissions, and every answer it gives is about reaching, reading and writing.
|
|
412
|
+
* «Should anything be created here at all» is a different question and this is
|
|
413
|
+
* the only place that asks it. The API cannot ask it either — it sees the
|
|
414
|
+
* runner's verdict and nothing of the machine — so the list lives here.
|
|
415
|
+
*/
|
|
416
|
+
export async function initProjectDir(target) {
|
|
417
|
+
const me = runnerIdentity();
|
|
418
|
+
// The same rule the API's `WorkspacePathSchema` applies, re-derived here: the
|
|
419
|
+
// runner is the process that actually calls `mkdir`, and it must not trust the
|
|
420
|
+
// wire (QA-99 MAJOR-4 is the same argument for `validate_path`).
|
|
421
|
+
if (!target.startsWith('/') || target.split('/').includes('..')) {
|
|
422
|
+
return refuseInit('The project folder must be an absolute path with no ".." segments');
|
|
423
|
+
}
|
|
424
|
+
const resolved = path.resolve(target);
|
|
425
|
+
// The cheap pass, on what the person typed: nothing touches the disk for a
|
|
426
|
+
// path that is obviously not a project folder.
|
|
427
|
+
const typedRefusal = pathClassRefusal(resolved);
|
|
428
|
+
if (typedRefusal)
|
|
429
|
+
return refuseInit(typedRefusal);
|
|
430
|
+
// The second pass, and the one that decides: where would `mkdir` LAND? A
|
|
431
|
+
// symlinked ancestor otherwise walks straight through every refusal above,
|
|
432
|
+
// and `/var/run/shop` on a stock Debian is not a hypothetical: that directory
|
|
433
|
+
// IS `/run`, and the repository would sit on tmpfs until the next reboot.
|
|
434
|
+
//
|
|
435
|
+
// Asked BEFORE any permission check, and that order is the point (the 0.64.0
|
|
436
|
+
// build had it the other way round and was never published): for a runner that is
|
|
437
|
+
// not root, `/tmp/link -> /etc` failed «can the runner write to the parent»
|
|
438
|
+
// first — and the refusal then advised `chown <runner> /tmp/link`, which is
|
|
439
|
+
// `chown` on `/etc`. «This is a system directory» must win over «grant
|
|
440
|
+
// yourself access to it».
|
|
441
|
+
const landing = realLandingPlace(resolved);
|
|
442
|
+
if (landing !== resolved) {
|
|
443
|
+
const realRefusal = pathClassRefusal(landing);
|
|
444
|
+
// Both paths named, in that order: the person recognises what they typed,
|
|
445
|
+
// and then learns where it actually goes — which is the fact they have to
|
|
446
|
+
// act on.
|
|
447
|
+
if (realRefusal)
|
|
448
|
+
return refuseInit(`${resolved} leads to ${realRefusal}`);
|
|
449
|
+
}
|
|
450
|
+
const here = inspectPath(resolved);
|
|
451
|
+
if (here.unreachable) {
|
|
452
|
+
const blocked = firstUnreachableAncestor(resolved) ?? resolved;
|
|
453
|
+
return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
|
|
454
|
+
`Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
|
|
455
|
+
`or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
|
|
456
|
+
}
|
|
457
|
+
if (here.exists) {
|
|
458
|
+
// Not a refusal, and deliberately not a `mkdir` either: a directory that is
|
|
459
|
+
// already there is the ordinary case of pressing the button twice, and the
|
|
460
|
+
// window's next move — bind it — is the right one.
|
|
461
|
+
if (here.isDirectory)
|
|
462
|
+
return { ok: true, created: false, exists: true };
|
|
463
|
+
return refuseInit(`${resolved} already exists and is not a directory`);
|
|
464
|
+
}
|
|
465
|
+
const parent = path.dirname(resolved);
|
|
466
|
+
const above = inspectPath(parent);
|
|
467
|
+
if (above.unreachable) {
|
|
468
|
+
const blocked = firstUnreachableAncestor(parent) ?? parent;
|
|
469
|
+
return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
|
|
470
|
+
`Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
|
|
471
|
+
`or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
|
|
472
|
+
}
|
|
473
|
+
if (!above.exists || !above.isDirectory) {
|
|
474
|
+
return refuseInit(`${parent} does not exist on this server, and the runner creates only the last folder of the path. ` +
|
|
475
|
+
`Create ${parent} there first (\`mkdir -p ${parent}\` as a user who may write to it), then try again.`);
|
|
476
|
+
}
|
|
477
|
+
if (!above.writable) {
|
|
478
|
+
return refuseInit(`The runner runs as ${me.user} and cannot write to ${parent}, so it cannot create ${resolved} there. ` +
|
|
479
|
+
`Give that user write access (for example \`setfacl -m u:${me.user}:rwx ${parent}\`, ` +
|
|
480
|
+
`or \`chown ${me.user} ${parent}\` if the directory is meant to be theirs), then try again.`);
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
// Deliberately NOT `recursive: true`: the chain of parents is somebody
|
|
484
|
+
// else's decision about the layout of their machine, and a button that
|
|
485
|
+
// quietly makes `/opt/a/b/c` out of a typo is worse than one that says the
|
|
486
|
+
// folder above is missing.
|
|
487
|
+
fs.mkdirSync(resolved);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
const code = error.code;
|
|
491
|
+
if (code === 'EEXIST') {
|
|
492
|
+
// Somebody else got there between `inspectPath` and here — or the name was
|
|
493
|
+
// never free at all: `mkdir(2)` answers EEXIST for ANY existing name, and
|
|
494
|
+
// a dangling symlink is invisible to the `statSync` above, which follows
|
|
495
|
+
// it and reports «not there». Look again rather than assume: «a directory
|
|
496
|
+
// is there» is what the window binds on next.
|
|
497
|
+
const now = inspectPath(resolved);
|
|
498
|
+
if (now.exists && now.isDirectory)
|
|
499
|
+
return { ok: true, created: false, exists: true };
|
|
500
|
+
return refuseInit(`${resolved} already exists and is not a directory`);
|
|
501
|
+
}
|
|
502
|
+
return refuseInit(`The runner runs as ${me.user} and could not create ${resolved} (${code ?? 'unknown error'}). ` +
|
|
503
|
+
`Check that ${parent} is writable by that user, then try again.`);
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
await initRepositoryOnMain(resolved);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
// All-or-nothing, like laying out a worktree (#360): a folder that exists
|
|
510
|
+
// but is not a repository binds and then fails on «Not a git work tree»,
|
|
511
|
+
// which is a worse place to learn about it. `rmdirSync` refuses a directory
|
|
512
|
+
// with anything in it, which is exactly the safety wanted here — we are only
|
|
513
|
+
// ever removing the empty one made three lines up.
|
|
514
|
+
let takenBack = true;
|
|
515
|
+
try {
|
|
516
|
+
fs.rmdirSync(resolved);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// It is not empty, so it is not only ours to remove. Leave it — and say
|
|
520
|
+
// so, because the two outcomes want opposite things from the person.
|
|
521
|
+
takenBack = false;
|
|
522
|
+
}
|
|
523
|
+
// Git's own words go to the runner's log, where the person who can act on
|
|
524
|
+
// them is; the sentence that travels names the folder and what to do next.
|
|
525
|
+
// `describeGitFailure` would have carried `fatal: …` into a browser, and
|
|
526
|
+
// the API's net for that (`actionableRunnerRefusal`) rewrites the WHOLE
|
|
527
|
+
// text — so forwarding it would have cost this sentence as well.
|
|
528
|
+
log.warn('init_project_dir: git init failed', {
|
|
529
|
+
path: resolved,
|
|
530
|
+
takenBack,
|
|
531
|
+
error: describeGitFailure(error),
|
|
532
|
+
});
|
|
533
|
+
const said = takenBack
|
|
534
|
+
? `Git would not start in ${resolved}, so the folder was removed again and nothing was left behind. ` +
|
|
535
|
+
`The runner's log on this server has git's own words — check that git is installed and that the disk is writable, then try again.`
|
|
536
|
+
: `${resolved} was created, but git would not start in it, and the folder is still there. ` +
|
|
537
|
+
`The runner's log on this server has git's own words. Run \`git init -b main\` in that folder yourself, or remove it and try again.`;
|
|
538
|
+
return { ok: false, created: !takenBack, exists: !takenBack, error: said };
|
|
539
|
+
}
|
|
540
|
+
// Read back rather than reported from intent: on a git old enough to need the
|
|
541
|
+
// fallback below, «main» is something we asked for, not something we saw.
|
|
542
|
+
const head = await headState(resolved);
|
|
543
|
+
return {
|
|
544
|
+
ok: true,
|
|
545
|
+
created: true,
|
|
546
|
+
exists: true,
|
|
547
|
+
...(head.branch ? { branch: head.branch } : {}),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* `git init` with `main` as the initial branch, on every git the fleet has.
|
|
552
|
+
*
|
|
553
|
+
* `--initial-branch` arrived in git 2.28 (2020); Debian 10 and CentOS 7 ship
|
|
554
|
+
* older ones and are still out there. The fallback is not a lesser outcome —
|
|
555
|
+
* `symbolic-ref` on a repository with no commits in it is exactly what `-b`
|
|
556
|
+
* does — it just takes two calls.
|
|
557
|
+
*
|
|
558
|
+
* Tried rather than version-parsed on purpose: the question is whether THIS git
|
|
559
|
+
* accepts the flag, and the flag itself answers it in one cheap call.
|
|
560
|
+
*/
|
|
561
|
+
async function initRepositoryOnMain(repoPath) {
|
|
562
|
+
try {
|
|
563
|
+
await git(repoPath, 'init', '--quiet', '--initial-branch', INITIAL_BRANCH);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// Falls through: either the flag is unknown, or `git init` itself failed —
|
|
568
|
+
// and the plain form below tells the two apart by either working or
|
|
569
|
+
// throwing the real reason to the caller.
|
|
570
|
+
}
|
|
571
|
+
await git(repoPath, 'init', '--quiet');
|
|
572
|
+
// Best effort, and deliberately not fatal: `git init` has already made a
|
|
573
|
+
// working repository, and the caller reads the branch back off it rather than
|
|
574
|
+
// trusting this call. Throwing here would throw away a folder that is fine.
|
|
575
|
+
await git(repoPath, 'symbolic-ref', 'HEAD', `refs/heads/${INITIAL_BRANCH}`).catch(() => undefined);
|
|
576
|
+
}
|
|
255
577
|
/**
|
|
256
578
|
* The repository's main branch, read locally (ADR 0004).
|
|
257
579
|
*
|
package/dist/index.js
CHANGED
|
@@ -566,6 +566,11 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
566
566
|
// это чтение, а не установка, и машину, чей владелец запретил ставить из
|
|
567
567
|
// дашборда, спросить о том, что на ней стоит, по-прежнему можно.
|
|
568
568
|
'agent_versions_refresh',
|
|
569
|
+
// #417: create the project folder and `git init` in it, from the
|
|
570
|
+
// binding window. Announced here and nowhere else — the API refuses the
|
|
571
|
+
// button on a machine that does not name this command, because an older
|
|
572
|
+
// runner drops a command it cannot parse and never answers.
|
|
573
|
+
'init_project_dir',
|
|
569
574
|
// #396. Listed unconditionally, unlike the `restart` flag above: the
|
|
570
575
|
// flag is what the dashboard draws a button from, and this list is what
|
|
571
576
|
// `runCommand` dispatches on. A machine that cannot restart still
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -1076,6 +1076,26 @@ export declare class Supervisor {
|
|
|
1076
1076
|
*/
|
|
1077
1077
|
private armCompactionWatchdog;
|
|
1078
1078
|
private clearCompactionWatchdog;
|
|
1079
|
+
/**
|
|
1080
|
+
* A card that interrupted a RESTING session has been answered: go back to
|
|
1081
|
+
* rest instead of reporting a turn (#382).
|
|
1082
|
+
*
|
|
1083
|
+
* Which card it was does not matter — a Codex helper's approval, a Claude
|
|
1084
|
+
* background subagent's, an ask either of them parked while the session was
|
|
1085
|
+
* already the person's. What matters is that no turn of this session was
|
|
1086
|
+
* running when the card went out, so there is no turn to go back to and
|
|
1087
|
+
* nothing that would end one: on Codex the helper's own ending is explicitly
|
|
1088
|
+
* not the session's, so «Working» stood until somebody typed.
|
|
1089
|
+
*
|
|
1090
|
+
* Only when the burst is over — both card sets empty — because answering one
|
|
1091
|
+
* of three still leaves the session parked on the other two. The frame
|
|
1092
|
+
* carries the background count like every other status report, so the badge
|
|
1093
|
+
* and the Inbox see «resting, with helpers» rather than «your turn».
|
|
1094
|
+
*
|
|
1095
|
+
* Returns true when it handled the resolution, so the callers' «the human
|
|
1096
|
+
* answered, bill again» branches stay out of it.
|
|
1097
|
+
*/
|
|
1098
|
+
private restAfterCard;
|
|
1079
1099
|
/**
|
|
1080
1100
|
* Record how many subagents are alive, and say so when it matters (#236).
|
|
1081
1101
|
*
|
package/dist/supervisor.js
CHANGED
|
@@ -9,7 +9,7 @@ import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryD
|
|
|
9
9
|
import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
|
|
10
10
|
import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
|
|
11
11
|
import { JournalStore } from './journal.js';
|
|
12
|
-
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
|
|
12
|
+
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, initProjectDir, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
|
|
13
13
|
import { readRecipeProposal } from './recipe.js';
|
|
14
14
|
import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
|
|
15
15
|
import { proposeCommitMessage } from './commit-message.js';
|
|
@@ -3404,6 +3404,39 @@ export class Supervisor {
|
|
|
3404
3404
|
clearTimeout(running.compactionWatchdog);
|
|
3405
3405
|
delete running.compactionWatchdog;
|
|
3406
3406
|
}
|
|
3407
|
+
/**
|
|
3408
|
+
* A card that interrupted a RESTING session has been answered: go back to
|
|
3409
|
+
* rest instead of reporting a turn (#382).
|
|
3410
|
+
*
|
|
3411
|
+
* Which card it was does not matter — a Codex helper's approval, a Claude
|
|
3412
|
+
* background subagent's, an ask either of them parked while the session was
|
|
3413
|
+
* already the person's. What matters is that no turn of this session was
|
|
3414
|
+
* running when the card went out, so there is no turn to go back to and
|
|
3415
|
+
* nothing that would end one: on Codex the helper's own ending is explicitly
|
|
3416
|
+
* not the session's, so «Working» stood until somebody typed.
|
|
3417
|
+
*
|
|
3418
|
+
* Only when the burst is over — both card sets empty — because answering one
|
|
3419
|
+
* of three still leaves the session parked on the other two. The frame
|
|
3420
|
+
* carries the background count like every other status report, so the badge
|
|
3421
|
+
* and the Inbox see «resting, with helpers» rather than «your turn».
|
|
3422
|
+
*
|
|
3423
|
+
* Returns true when it handled the resolution, so the callers' «the human
|
|
3424
|
+
* answered, bill again» branches stay out of it.
|
|
3425
|
+
*/
|
|
3426
|
+
restAfterCard(running) {
|
|
3427
|
+
const back = running.restBeforeCard;
|
|
3428
|
+
if (!back)
|
|
3429
|
+
return false;
|
|
3430
|
+
if (running.openPermissions.size > 0 || running.openQuestions.size > 0)
|
|
3431
|
+
return true;
|
|
3432
|
+
// `reportStatus` clears the memory — the status it sends is the newest
|
|
3433
|
+
// truth about this session, whatever it is.
|
|
3434
|
+
this.reportStatus(running.descriptor.id, back, {
|
|
3435
|
+
costUsd: running.costUsd,
|
|
3436
|
+
activeMs: Supervisor.spentMs(running),
|
|
3437
|
+
});
|
|
3438
|
+
return true;
|
|
3439
|
+
}
|
|
3407
3440
|
/**
|
|
3408
3441
|
* Record how many subagents are alive, and say so when it matters (#236).
|
|
3409
3442
|
*
|
|
@@ -3817,6 +3850,8 @@ export class Supervisor {
|
|
|
3817
3850
|
source: event.source,
|
|
3818
3851
|
reason: event.reason,
|
|
3819
3852
|
});
|
|
3853
|
+
if (this.restAfterCard(running))
|
|
3854
|
+
return;
|
|
3820
3855
|
if (event.source === 'user' &&
|
|
3821
3856
|
event.allow &&
|
|
3822
3857
|
running.lastReported === 'WAITING_PERMISSION') {
|
|
@@ -3844,6 +3879,11 @@ export class Supervisor {
|
|
|
3844
3879
|
// is the bug session 7 removed (five of the first twelve prod sessions
|
|
3845
3880
|
// died having spent their budget waiting for a human).
|
|
3846
3881
|
running.openQuestions.add(event.askId);
|
|
3882
|
+
// #382, the same as a permission card: an ask from work that outlives
|
|
3883
|
+
// the turn finds the session at rest, and the answer must put it back.
|
|
3884
|
+
if (running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW') {
|
|
3885
|
+
running.restBeforeCard = running.lastReported;
|
|
3886
|
+
}
|
|
3847
3887
|
this.sendEvent(running, 'question', {
|
|
3848
3888
|
askId: event.askId,
|
|
3849
3889
|
questions: event.questions,
|
|
@@ -3872,6 +3912,8 @@ export class Supervisor {
|
|
|
3872
3912
|
// `openQuestions.size` matters: both agents can park several asks at
|
|
3873
3913
|
// once, and reporting RUNNING while another card is still waiting would
|
|
3874
3914
|
// bill a human's thinking time all over again (QA-106 M4).
|
|
3915
|
+
if (this.restAfterCard(running))
|
|
3916
|
+
return;
|
|
3875
3917
|
if (event.source === 'user' &&
|
|
3876
3918
|
running.openQuestions.size === 0 &&
|
|
3877
3919
|
running.lastReported === 'WAITING_INPUT') {
|
|
@@ -6140,6 +6182,37 @@ export class Supervisor {
|
|
|
6140
6182
|
...(validation.ok ? {} : { error: validation.error }),
|
|
6141
6183
|
});
|
|
6142
6184
|
}
|
|
6185
|
+
/**
|
|
6186
|
+
* «Create this folder and put git in it», from the binding window
|
|
6187
|
+
* (#417).
|
|
6188
|
+
*
|
|
6189
|
+
* Next to `validate_path` because it is the same conversation: that
|
|
6190
|
+
* command answers «the folder is not there», and this one is what the
|
|
6191
|
+
* person presses next. Everything it is allowed and not allowed to do
|
|
6192
|
+
* is decided in `initProjectDir` — the API sees a verdict and never the
|
|
6193
|
+
* machine.
|
|
6194
|
+
*
|
|
6195
|
+
* Under the repository lock like every other operation on a folder,
|
|
6196
|
+
* even though there is no repository yet: `repoKeyFor` falls back to the
|
|
6197
|
+
* path itself when git cannot answer, so two presses of the button
|
|
6198
|
+
* serialise against each other rather than racing into one `mkdir`.
|
|
6199
|
+
*/
|
|
6200
|
+
case 'init_project_dir': {
|
|
6201
|
+
const path = typeof frame.args?.['path'] === 'string' ? frame.args['path'] : null;
|
|
6202
|
+
if (!path)
|
|
6203
|
+
return void reply({ ok: false, error: 'path argument is required' });
|
|
6204
|
+
const init = await this.withRepoLockFor(path, () => initProjectDir(path));
|
|
6205
|
+
// Remembered for the same reason `validate_path` remembers it: this
|
|
6206
|
+
// is a real project directory now, and `devbridge-runner doctor`
|
|
6207
|
+
// checks the permissions of the ones it knows about.
|
|
6208
|
+
if (init.ok)
|
|
6209
|
+
rememberWorkspacePath(path);
|
|
6210
|
+
return void reply({
|
|
6211
|
+
ok: init.ok,
|
|
6212
|
+
result: init,
|
|
6213
|
+
...(init.ok ? {} : { error: init.error }),
|
|
6214
|
+
});
|
|
6215
|
+
}
|
|
6143
6216
|
case 'clean': {
|
|
6144
6217
|
const sessionId = frame.sessionId;
|
|
6145
6218
|
if (!sessionId)
|
|
@@ -7532,6 +7605,13 @@ export class Supervisor {
|
|
|
7532
7605
|
// The API infers WAITING_PERMISSION from the event itself, so this never
|
|
7533
7606
|
// goes through reportStatus — but the budget clock still has to stop, or
|
|
7534
7607
|
// an ask-mode session bills every second the human spends reading the card.
|
|
7608
|
+
//
|
|
7609
|
+
// #382: remember what the card interrupted. A card from work that
|
|
7610
|
+
// outlives the turn finds the session at rest, and the answer must put it
|
|
7611
|
+
// back there rather than into a turn nobody is running.
|
|
7612
|
+
if (running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW') {
|
|
7613
|
+
running.restBeforeCard = running.lastReported;
|
|
7614
|
+
}
|
|
7535
7615
|
running.lastReported = 'WAITING_PERMISSION';
|
|
7536
7616
|
this.syncBudgetClock(running);
|
|
7537
7617
|
}
|
|
@@ -7541,6 +7621,8 @@ export class Supervisor {
|
|
|
7541
7621
|
const running = this.sessions.get(sessionId);
|
|
7542
7622
|
if (running) {
|
|
7543
7623
|
running.lastReported = status;
|
|
7624
|
+
// #382: any status report is newer than the rest a card interrupted.
|
|
7625
|
+
delete running.restBeforeCard;
|
|
7544
7626
|
// Coming to rest ends the busy period the once-per-turn notices were
|
|
7545
7627
|
// limited to: the next one is about a new answer and deserves saying.
|
|
7546
7628
|
if (!Supervisor.MID_TURN_STATUSES.includes(status)) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.65.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED