@bridge4dev/runner 0.52.0 → 0.54.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/claude.js +111 -33
- package/dist/adapters/codex-protocol.d.ts +11 -0
- package/dist/adapters/codex-protocol.js +41 -3
- package/dist/adapters/codex.js +14 -26
- package/dist/adapters/types.d.ts +45 -1
- package/dist/adapters/types.js +53 -0
- package/dist/checkpoints.d.ts +62 -0
- package/dist/checkpoints.js +50 -1
- package/dist/git.d.ts +64 -0
- package/dist/git.js +487 -36
- package/dist/gitops.d.ts +5 -0
- package/dist/gitops.js +7 -8
- package/dist/host-load.d.ts +156 -0
- package/dist/host-load.js +223 -0
- package/dist/index.js +190 -40
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +228 -7
- package/dist/process-priority.d.ts +55 -0
- package/dist/process-priority.js +99 -0
- package/dist/protocol.d.ts +42 -20
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/self-update.js +43 -2
- package/dist/service-unit.d.ts +232 -10
- package/dist/service-unit.js +372 -43
- package/dist/session-cage.d.ts +297 -0
- package/dist/session-cage.js +755 -0
- package/dist/supervisor.d.ts +156 -3
- package/dist/supervisor.js +351 -32
- package/dist/systemd-memory.d.ts +35 -0
- package/dist/systemd-memory.js +115 -0
- package/dist/verify.js +28 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/policy.js
CHANGED
|
@@ -517,6 +517,189 @@ function parsePush(segment) {
|
|
|
517
517
|
remoteExec,
|
|
518
518
|
};
|
|
519
519
|
}
|
|
520
|
+
const GIT_CHECKOUT = String.raw `\bgit\s+${FLAGS}(?:checkout|switch)\b`;
|
|
521
|
+
/** A word whose meaning only the shell knows — `$BRANCH`, `` `cat .b` ``, `\x`. */
|
|
522
|
+
const SHELL_UNRESOLVED = /[$`\\]/;
|
|
523
|
+
/**
|
|
524
|
+
* A REVISION expression — `main~1`, `v2^`, `abc123^{commit}`.
|
|
525
|
+
*
|
|
526
|
+
* Not a branch name (git's own ref grammar forbids `~` and `^` in one) and
|
|
527
|
+
* emphatically not a pathspec: checking one out leaves the folder on no branch
|
|
528
|
+
* at all. Grouping it with the pathspecs below, on the grounds that it «cannot
|
|
529
|
+
* be a branch», was exactly backwards — `git checkout main~1` detaches the
|
|
530
|
+
* shared folder for everybody in it, silently.
|
|
531
|
+
*/
|
|
532
|
+
const REVISION_EXPRESSION = /[~^]/;
|
|
533
|
+
/**
|
|
534
|
+
* An operand git could never read as a branch name — because it is a PATH.
|
|
535
|
+
*
|
|
536
|
+
* Not a guess about intent, a fact about the ref grammar: `*`, `?`, `[` and
|
|
537
|
+
* spaces are forbidden in a branch name, a leading `:` is git's own magic
|
|
538
|
+
* pathspec prefix, and `.`, `./x`, `../x`, `/x`, `x/` are paths by shape.
|
|
539
|
+
* A closing `]` is deliberately NOT here: `git check-ref-format --branch 'wip]1'`
|
|
540
|
+
* accepts it, so calling it impossible-in-a-branch would have waved through a
|
|
541
|
+
* real branch switch (QA #255).
|
|
542
|
+
* Everything else stays ambiguous, and git resolves ambiguity in favour of the
|
|
543
|
+
* BRANCH — which is why an ambiguous word asks rather than being waved through.
|
|
544
|
+
*
|
|
545
|
+
* Characters that are merely UNUSUAL in a branch name (a backslash, a control
|
|
546
|
+
* character, `@{`) are deliberately absent: they end up in the ambiguous pile,
|
|
547
|
+
* which asks. Every gap here has to fall on that side.
|
|
548
|
+
*/
|
|
549
|
+
function cannotBeBranch(operand) {
|
|
550
|
+
if (operand === '.' || operand === '..')
|
|
551
|
+
return true;
|
|
552
|
+
if (/^\.{1,2}\//.test(operand) || operand.startsWith('/'))
|
|
553
|
+
return true;
|
|
554
|
+
if (operand.startsWith(':'))
|
|
555
|
+
return true;
|
|
556
|
+
if (operand.endsWith('/'))
|
|
557
|
+
return true;
|
|
558
|
+
return /[*?[ ]/.test(operand);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The agent chose this word, so it is quoted back at a length WE choose.
|
|
562
|
+
*
|
|
563
|
+
* This string becomes the description of a permission card and travels in an
|
|
564
|
+
* event payload the gateway caps; a single implausibly long operand should cost
|
|
565
|
+
* a few characters of the sentence, never the card.
|
|
566
|
+
*/
|
|
567
|
+
function shortOperand(operand) {
|
|
568
|
+
return operand.length > 60 ? `${operand.slice(0, 60)}…` : operand;
|
|
569
|
+
}
|
|
570
|
+
const RESTORE_A_FILE = 'To put a file back use `git checkout -- <path>` or `git restore <path>`: those touch files only.';
|
|
571
|
+
const MOVES_THE_FOLDER = 'this session works in the project folder itself, so switching branches there changes the files under every other session and person working in it';
|
|
572
|
+
/**
|
|
573
|
+
* Does this segment move the shared folder onto another branch? (#361 п. 5)
|
|
574
|
+
*
|
|
575
|
+
* Parsed rather than pattern matched, for the same reason as `parsePush`: the
|
|
576
|
+
* difference between «restore this file» and «move everybody to another
|
|
577
|
+
* branch» is one `--` in the middle of the arguments, and no regex answers
|
|
578
|
+
* that. Synchronous and free of child processes — gotcha 196: this runs on
|
|
579
|
+
* every Bash call of every session on the machine.
|
|
580
|
+
*
|
|
581
|
+
* Returns null when the segment is not a checkout at all, or is one of the
|
|
582
|
+
* forms that provably only touches files.
|
|
583
|
+
*/
|
|
584
|
+
function parseCheckout(segment) {
|
|
585
|
+
if (!new RegExp(GIT_CHECKOUT).test(segment))
|
|
586
|
+
return null;
|
|
587
|
+
const tokens = words(segment);
|
|
588
|
+
const at = tokens.findIndex((token) => token === 'checkout' || token === 'switch');
|
|
589
|
+
if (at === -1) {
|
|
590
|
+
// The regex matched but the verb is hidden by a form this tokenizer does
|
|
591
|
+
// not model. Report the moving reading — «I could not parse it» must never
|
|
592
|
+
// become «go ahead».
|
|
593
|
+
return {
|
|
594
|
+
reason: `This git command could not be read well enough to tell whether it moves the folder onto another branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (tokens[at] === 'switch') {
|
|
598
|
+
// `git switch` has no file form at all — it exists to move a branch.
|
|
599
|
+
return {
|
|
600
|
+
reason: `\`git switch\` moves the folder onto another branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
const rest = tokens.slice(at + 1);
|
|
604
|
+
/**
|
|
605
|
+
* `--` names paths only when there ARE paths after it.
|
|
606
|
+
*
|
|
607
|
+
* The obvious reading — «a `--` anywhere means this is a file command» — is
|
|
608
|
+
* wrong, and wrong in the direction that matters. Verified on git 2.43:
|
|
609
|
+
* `git checkout feature --` prints «Switched to branch 'feature'», and
|
|
610
|
+
* `git checkout -b tmp --` creates the branch and moves onto it. Git's own
|
|
611
|
+
* parser treats a trailing `--` as «the word before me is definitely a ref»,
|
|
612
|
+
* which is the opposite of what it looks like. So the separator is only a
|
|
613
|
+
* separator when it separates something.
|
|
614
|
+
*/
|
|
615
|
+
const dashDash = rest.indexOf('--');
|
|
616
|
+
if (dashDash !== -1 && dashDash < rest.length - 1)
|
|
617
|
+
return null;
|
|
618
|
+
const head = dashDash === -1 ? rest : rest.slice(0, dashDash);
|
|
619
|
+
let fileOnly = false;
|
|
620
|
+
let movingFlag = null;
|
|
621
|
+
const operands = [];
|
|
622
|
+
for (const token of head) {
|
|
623
|
+
if (token.startsWith('--')) {
|
|
624
|
+
const name = token.split('=')[0] ?? token;
|
|
625
|
+
if (name === '--ours' ||
|
|
626
|
+
name === '--theirs' ||
|
|
627
|
+
name === '--patch' ||
|
|
628
|
+
name === '--pathspec-from-file' ||
|
|
629
|
+
name === '--pathspec-file-nul') {
|
|
630
|
+
fileOnly = true;
|
|
631
|
+
}
|
|
632
|
+
else if (name === '--orphan' ||
|
|
633
|
+
name === '--detach' ||
|
|
634
|
+
name === '--track' ||
|
|
635
|
+
name === '--merge' ||
|
|
636
|
+
name === '--force') {
|
|
637
|
+
movingFlag ??= name;
|
|
638
|
+
}
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
// A lone `-` is an operand: «the branch I was on before».
|
|
642
|
+
if (token.startsWith('-') && token.length > 1) {
|
|
643
|
+
for (const letter of token.slice(1)) {
|
|
644
|
+
if (letter === 'p')
|
|
645
|
+
fileOnly = true;
|
|
646
|
+
else if ('bBtmf'.includes(letter))
|
|
647
|
+
movingFlag ??= `-${letter}`;
|
|
648
|
+
}
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
operands.push(token);
|
|
652
|
+
}
|
|
653
|
+
if (fileOnly)
|
|
654
|
+
return null;
|
|
655
|
+
if (movingFlag) {
|
|
656
|
+
return {
|
|
657
|
+
reason: `\`git checkout ${shortOperand(movingFlag)}\` moves the folder onto another branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
// `git checkout` on its own restores nothing and moves nothing — but only
|
|
661
|
+
// when the arguments really are all of them. `echo feature | xargs git
|
|
662
|
+
// checkout` is a checkout whose operand is handed to it by the program in
|
|
663
|
+
// front of it, and this parser cannot see it: the segment ends with a bare
|
|
664
|
+
// `git checkout` and looks harmless. So «no operands» is only an answer when
|
|
665
|
+
// git is the thing being run, not an argument to something else.
|
|
666
|
+
if (operands.length === 0) {
|
|
667
|
+
const first = tokens.find((token) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token));
|
|
668
|
+
if (first === 'git')
|
|
669
|
+
return null;
|
|
670
|
+
return {
|
|
671
|
+
reason: `This \`git checkout\` gets its arguments from another program, so the runner cannot tell whether it moves the folder onto another branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
for (const operand of operands) {
|
|
675
|
+
if (SHELL_UNRESOLVED.test(operand)) {
|
|
676
|
+
return {
|
|
677
|
+
reason: `This \`git checkout\` names its target through the shell, so the runner cannot tell whether it is a file or a branch — and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
if (operand === '-') {
|
|
681
|
+
return {
|
|
682
|
+
reason: `\`git checkout -\` moves the folder back onto the previous branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
if (/^(HEAD|@)([~^].*)?$/.test(operand) ||
|
|
686
|
+
/^[0-9a-f]{7,40}$/i.test(operand) ||
|
|
687
|
+
REVISION_EXPRESSION.test(operand)) {
|
|
688
|
+
return {
|
|
689
|
+
reason: `\`git checkout ${shortOperand(operand)}\` leaves the folder on no branch at all (detached HEAD), and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
if (cannotBeBranch(operand))
|
|
693
|
+
continue;
|
|
694
|
+
// A bare word. Git reads it as a BRANCH first and only falls back to a
|
|
695
|
+
// path, so «I meant the file» is not something this can assume — and the
|
|
696
|
+
// unambiguous spelling costs the agent three characters.
|
|
697
|
+
return {
|
|
698
|
+
reason: `\`git checkout ${shortOperand(operand)}\` is read by git as a branch first, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
520
703
|
/**
|
|
521
704
|
* The project's git rules, applied to one Bash command.
|
|
522
705
|
*
|
|
@@ -525,9 +708,18 @@ function parsePush(segment) {
|
|
|
525
708
|
* DELIBERATELY switched something on — with the shipped defaults the first
|
|
526
709
|
* check refuses every push and the rest never run, which is byte for byte the
|
|
527
710
|
* behaviour of the four regexes it replaces.
|
|
711
|
+
*
|
|
712
|
+
* **A `deny` anywhere beats an `ask` anywhere.** Until #361 every decision here
|
|
713
|
+
* was a refusal, so returning the first one found in the first segment was the
|
|
714
|
+
* same thing as returning the worst one. With an `ask` in the set that stopped
|
|
715
|
+
* being true: `git checkout main && git push origin main` would have shown a
|
|
716
|
+
* card about the checkout, and confirming it would have run the push this
|
|
717
|
+
* function exists to refuse. So `deny` returns at once, `ask` is remembered,
|
|
718
|
+
* and the loop reads to the end.
|
|
528
719
|
*/
|
|
529
720
|
export function evaluateGitPolicy(command, ctx) {
|
|
530
721
|
const policy = resolveGitPolicy(ctx);
|
|
722
|
+
let asked = null;
|
|
531
723
|
for (const segment of commandSegments(command)) {
|
|
532
724
|
if (!policy.allowDestructiveGit) {
|
|
533
725
|
/**
|
|
@@ -539,10 +731,14 @@ export function evaluateGitPolicy(command, ctx) {
|
|
|
539
731
|
* that went that way on 2026-07-28 existed nowhere else — and
|
|
540
732
|
* `git reset --hard` throws away every uncommitted edit in the tree.
|
|
541
733
|
*
|
|
542
|
-
* Denied rather than turned into a permission card
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
734
|
+
* Denied rather than turned into a permission card because there is no
|
|
735
|
+
* safe version of them, not because a card was impossible: this whole
|
|
736
|
+
* function runs BEFORE the trust branches, so an `ask` returned from here
|
|
737
|
+
* does reach the person even under AUTO (that is how #361 п. 5 works two
|
|
738
|
+
* rules below). What is true is that the safe LIST never sees them — Bash
|
|
739
|
+
* is allowed before it is consulted. An agent that needs to remove build
|
|
740
|
+
* output can name the paths; one that wants to undo its own work has
|
|
741
|
+
* `git checkout -- <file>`.
|
|
546
742
|
*/
|
|
547
743
|
if (new RegExp(String.raw `\bgit\s+${FLAGS}clean\b`).test(segment)) {
|
|
548
744
|
return {
|
|
@@ -557,6 +753,16 @@ export function evaluateGitPolicy(command, ctx) {
|
|
|
557
753
|
};
|
|
558
754
|
}
|
|
559
755
|
}
|
|
756
|
+
// #361 п. 5 — only where the folder is shared. In a BRANCH session the
|
|
757
|
+
// worktree belongs to this session alone and moving it costs nobody
|
|
758
|
+
// anything. See `PolicyContext.workMode` for why silence means «ask».
|
|
759
|
+
if (ctx.workMode !== 'BRANCH') {
|
|
760
|
+
const move = parseCheckout(segment);
|
|
761
|
+
// `reason` stays a LABEL — it goes to logs and, on a denial, to the feed.
|
|
762
|
+
// The sentence a person reads on the card travels in `explain`.
|
|
763
|
+
if (move)
|
|
764
|
+
asked ??= { decision: 'ask', reason: 'moves the shared folder', explain: move.reason };
|
|
765
|
+
}
|
|
560
766
|
const push = parsePush(segment);
|
|
561
767
|
if (!push)
|
|
562
768
|
continue;
|
|
@@ -648,7 +854,7 @@ export function evaluateGitPolicy(command, ctx) {
|
|
|
648
854
|
}
|
|
649
855
|
}
|
|
650
856
|
}
|
|
651
|
-
return
|
|
857
|
+
return asked;
|
|
652
858
|
}
|
|
653
859
|
// Commands considered safe enough to run without asking under NORMAL trust.
|
|
654
860
|
// Interpreters (node/python/…) and find (-exec) are NOT here: they execute
|
|
@@ -829,7 +1035,14 @@ export function evaluateRecipeCommand(command, ctx = {}) {
|
|
|
829
1035
|
* approval screen, not a side effect of this one.
|
|
830
1036
|
*/
|
|
831
1037
|
const git = evaluateGitPolicy(variant, { trustMode: 'STRICT', worktreePath: '' });
|
|
832
|
-
|
|
1038
|
+
// Only a REFUSAL stops a recipe. Since #361 this function can also return
|
|
1039
|
+
// «ask», and the first line of this doc comment is the answer to it: an
|
|
1040
|
+
// approved recipe does not ask, because a human already read this exact
|
|
1041
|
+
// command on the approval screen. Turning that ask into `{allowed: false}`
|
|
1042
|
+
// would hard-refuse every build script that starts with `git checkout main`
|
|
1043
|
+
// — a refusal invented by a rule about AGENTS, applied to a text a person
|
|
1044
|
+
// wrote and approved.
|
|
1045
|
+
if (git?.decision === 'deny') {
|
|
833
1046
|
// The sentence is rewritten, not passed through (QA-134 MINOR-2). The
|
|
834
1047
|
// generic one says «this project has «Принудительно запретить push»
|
|
835
1048
|
// switched on» — and a project that switched it OFF would be sent to look
|
|
@@ -1001,11 +1214,19 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
1001
1214
|
// and nothing after this line is consulted. That is exactly how «agents
|
|
1002
1215
|
// never push» quietly failed for a whole release (session 13), and it is
|
|
1003
1216
|
// the reason these rules did not simply move onto the safe list.
|
|
1217
|
+
// Both spellings are read to the end before anything is returned: a `deny`
|
|
1218
|
+
// in the dequoted form must beat an `ask` in the raw one, for the same
|
|
1219
|
+
// reason a later segment beats an earlier one (see `evaluateGitPolicy`).
|
|
1220
|
+
let gitAsk = null;
|
|
1004
1221
|
for (const variant of [command, dequote(command)]) {
|
|
1005
1222
|
const git = evaluateGitPolicy(variant, ctx);
|
|
1006
|
-
if (git)
|
|
1223
|
+
if (git?.decision === 'deny')
|
|
1007
1224
|
return git;
|
|
1225
|
+
if (git)
|
|
1226
|
+
gitAsk ??= git;
|
|
1008
1227
|
}
|
|
1228
|
+
if (gitAsk)
|
|
1229
|
+
return gitAsk;
|
|
1009
1230
|
if (trust === 'STRICT')
|
|
1010
1231
|
return { decision: 'ask', reason: 'strict mode' };
|
|
1011
1232
|
if (trust === 'AUTO')
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything an agent starts runs at a lower CPU priority than the daemon that
|
|
3
|
+
* supervises it.
|
|
4
|
+
*
|
|
5
|
+
* The failure this exists for is the runner starving itself with its own
|
|
6
|
+
* children (16.08): a session's build saturated the box, the daemon lost four
|
|
7
|
+
* heartbeats in a row, the server went Offline and every session on that
|
|
8
|
+
* machine answered 504 — while the work it was doing was fine. The daemon's job
|
|
9
|
+
* during a heavy turn is a few milliseconds of socket traffic; it should not
|
|
10
|
+
* have to queue behind a `tsc` it launched itself.
|
|
11
|
+
*
|
|
12
|
+
* **What this gives.** Inside the service's own cgroup the daemon (nice 0) gets
|
|
13
|
+
* the processor before the agents (nice 10); neighbouring sessions, all at 10,
|
|
14
|
+
* still share it evenly between themselves. Children inherit the nice value at
|
|
15
|
+
* fork, so a `vitest` the agent starts through Bash — several levels down from
|
|
16
|
+
* the process we renice — is covered without us knowing about it.
|
|
17
|
+
*
|
|
18
|
+
* Inheritance is why every call site is the line straight after its `spawn`:
|
|
19
|
+
* measured here, a grandchild forked AFTER the call comes up at 10 and one
|
|
20
|
+
* forked in the microseconds before it stays at 0. An agent needs hundreds of
|
|
21
|
+
* milliseconds to boot before it forks anything, so that window is empty in
|
|
22
|
+
* practice — but it is a window, and it only grows if the call drifts down the
|
|
23
|
+
* function.
|
|
24
|
+
*
|
|
25
|
+
* **What it does NOT give.** Against processes OUTSIDE the cgroup (production
|
|
26
|
+
* in `system.slice`) it does nothing at all: across cgroups the split is
|
|
27
|
+
* decided by `cpu.weight`, and nice only orders tasks within one. It does not
|
|
28
|
+
* touch memory, which is the mechanism behind three of the four incidents on
|
|
29
|
+
* other people's machines. This is the approach to stage 2 (a scope per session
|
|
30
|
+
* with a memory ceiling) and the fallback for it on cgroup v1, where stage 2
|
|
31
|
+
* cannot work — not a replacement for it.
|
|
32
|
+
*
|
|
33
|
+
* Priority is a convenience, not correctness: nothing here throws. A session
|
|
34
|
+
* that runs at the wrong priority is a slower machine; a session that fails to
|
|
35
|
+
* start because renicing failed is a broken product.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The nice value every process the runner spawns for an agent gets.
|
|
39
|
+
*
|
|
40
|
+
* 10 rather than 19: the point is to lose to the daemon and to anything the
|
|
41
|
+
* owner is doing by hand, not to be scheduled last behind every background cron
|
|
42
|
+
* on the box. The scheduler's weight table gives nice 10 about a ninth of the
|
|
43
|
+
* share of nice 0 under contention (1024 → 110), which is all the room the
|
|
44
|
+
* heartbeat needs — 19 would buy an order of magnitude more and cost a session
|
|
45
|
+
* its throughput whenever anything else on the machine woke up.
|
|
46
|
+
*/
|
|
47
|
+
export declare const NICE = 10;
|
|
48
|
+
/**
|
|
49
|
+
* Push one spawned process down to {@link NICE}. Never throws.
|
|
50
|
+
*
|
|
51
|
+
* Takes `number | undefined` because that is exactly what `child.pid` is: a
|
|
52
|
+
* spawn that failed has none, and the caller should not have to ask.
|
|
53
|
+
*/
|
|
54
|
+
export declare function lowerPriority(pid: number | undefined): void;
|
|
55
|
+
//# sourceMappingURL=process-priority.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { log } from './log.js';
|
|
3
|
+
/**
|
|
4
|
+
* Everything an agent starts runs at a lower CPU priority than the daemon that
|
|
5
|
+
* supervises it.
|
|
6
|
+
*
|
|
7
|
+
* The failure this exists for is the runner starving itself with its own
|
|
8
|
+
* children (16.08): a session's build saturated the box, the daemon lost four
|
|
9
|
+
* heartbeats in a row, the server went Offline and every session on that
|
|
10
|
+
* machine answered 504 — while the work it was doing was fine. The daemon's job
|
|
11
|
+
* during a heavy turn is a few milliseconds of socket traffic; it should not
|
|
12
|
+
* have to queue behind a `tsc` it launched itself.
|
|
13
|
+
*
|
|
14
|
+
* **What this gives.** Inside the service's own cgroup the daemon (nice 0) gets
|
|
15
|
+
* the processor before the agents (nice 10); neighbouring sessions, all at 10,
|
|
16
|
+
* still share it evenly between themselves. Children inherit the nice value at
|
|
17
|
+
* fork, so a `vitest` the agent starts through Bash — several levels down from
|
|
18
|
+
* the process we renice — is covered without us knowing about it.
|
|
19
|
+
*
|
|
20
|
+
* Inheritance is why every call site is the line straight after its `spawn`:
|
|
21
|
+
* measured here, a grandchild forked AFTER the call comes up at 10 and one
|
|
22
|
+
* forked in the microseconds before it stays at 0. An agent needs hundreds of
|
|
23
|
+
* milliseconds to boot before it forks anything, so that window is empty in
|
|
24
|
+
* practice — but it is a window, and it only grows if the call drifts down the
|
|
25
|
+
* function.
|
|
26
|
+
*
|
|
27
|
+
* **What it does NOT give.** Against processes OUTSIDE the cgroup (production
|
|
28
|
+
* in `system.slice`) it does nothing at all: across cgroups the split is
|
|
29
|
+
* decided by `cpu.weight`, and nice only orders tasks within one. It does not
|
|
30
|
+
* touch memory, which is the mechanism behind three of the four incidents on
|
|
31
|
+
* other people's machines. This is the approach to stage 2 (a scope per session
|
|
32
|
+
* with a memory ceiling) and the fallback for it on cgroup v1, where stage 2
|
|
33
|
+
* cannot work — not a replacement for it.
|
|
34
|
+
*
|
|
35
|
+
* Priority is a convenience, not correctness: nothing here throws. A session
|
|
36
|
+
* that runs at the wrong priority is a slower machine; a session that fails to
|
|
37
|
+
* start because renicing failed is a broken product.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* The nice value every process the runner spawns for an agent gets.
|
|
41
|
+
*
|
|
42
|
+
* 10 rather than 19: the point is to lose to the daemon and to anything the
|
|
43
|
+
* owner is doing by hand, not to be scheduled last behind every background cron
|
|
44
|
+
* on the box. The scheduler's weight table gives nice 10 about a ninth of the
|
|
45
|
+
* share of nice 0 under contention (1024 → 110), which is all the room the
|
|
46
|
+
* heartbeat needs — 19 would buy an order of magnitude more and cost a session
|
|
47
|
+
* its throughput whenever anything else on the machine woke up.
|
|
48
|
+
*/
|
|
49
|
+
export const NICE = 10;
|
|
50
|
+
/**
|
|
51
|
+
* EPERM is a property of the machine, not of the process — it means this kernel
|
|
52
|
+
* or container will not let us renice at all, and it will mean that for every
|
|
53
|
+
* spawn afterwards. Said once; the alternative is one warning per agent process
|
|
54
|
+
* for the life of the daemon, which is how a real line gets buried.
|
|
55
|
+
*/
|
|
56
|
+
let permissionWarned = false;
|
|
57
|
+
function errorCode(error) {
|
|
58
|
+
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
59
|
+
return String(error.code);
|
|
60
|
+
}
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Push one spawned process down to {@link NICE}. Never throws.
|
|
65
|
+
*
|
|
66
|
+
* Takes `number | undefined` because that is exactly what `child.pid` is: a
|
|
67
|
+
* spawn that failed has none, and the caller should not have to ask.
|
|
68
|
+
*/
|
|
69
|
+
export function lowerPriority(pid) {
|
|
70
|
+
if (pid === undefined)
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
os.setPriority(pid, NICE);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const code = errorCode(error);
|
|
77
|
+
// ESRCH: the child was already gone — a binary that is not there exits
|
|
78
|
+
// before we get to it. Nothing happened and nothing is wrong, so nothing
|
|
79
|
+
// is said; the spawn failure itself is reported by whoever spawned it.
|
|
80
|
+
if (code === 'ESRCH')
|
|
81
|
+
return;
|
|
82
|
+
if (code === 'EPERM') {
|
|
83
|
+
if (permissionWarned)
|
|
84
|
+
return;
|
|
85
|
+
permissionWarned = true;
|
|
86
|
+
log.warn('priority: not allowed to renice agent processes on this machine', {
|
|
87
|
+
nice: NICE,
|
|
88
|
+
error: String(error),
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
log.warn('priority: could not lower a spawned process', {
|
|
93
|
+
pid,
|
|
94
|
+
nice: NICE,
|
|
95
|
+
error: String(error),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=process-priority.js.map
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import type { HostLoadFrame } from './host-load.js';
|
|
2
3
|
export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
3
4
|
id: z.ZodString;
|
|
4
5
|
kind: z.ZodEnum<["TICKET", "CHAT"]>;
|
|
@@ -242,6 +243,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
242
243
|
url: string;
|
|
243
244
|
token: string;
|
|
244
245
|
} | undefined;
|
|
246
|
+
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
245
247
|
pausedUntil?: string | null | undefined;
|
|
246
248
|
agentLatestVersion?: string | undefined;
|
|
247
249
|
skipAgentPrompt?: boolean | undefined;
|
|
@@ -252,7 +254,6 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
252
254
|
baseBranch?: string | undefined;
|
|
253
255
|
baseSha?: string | undefined;
|
|
254
256
|
} | undefined;
|
|
255
|
-
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
256
257
|
}, {
|
|
257
258
|
agent: "CLAUDE" | "CODEX";
|
|
258
259
|
id: string;
|
|
@@ -284,6 +285,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
284
285
|
token: string;
|
|
285
286
|
} | undefined;
|
|
286
287
|
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
288
|
+
workMode?: unknown;
|
|
287
289
|
model?: string | null | undefined;
|
|
288
290
|
effort?: string | null | undefined;
|
|
289
291
|
epoch?: number | undefined;
|
|
@@ -296,7 +298,6 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
296
298
|
skipAgentPrompt?: unknown;
|
|
297
299
|
branchHint?: unknown;
|
|
298
300
|
branchPlan?: unknown;
|
|
299
|
-
workMode?: unknown;
|
|
300
301
|
}>;
|
|
301
302
|
export type SessionDescriptor = z.infer<typeof SessionDescriptorSchema>;
|
|
302
303
|
/**
|
|
@@ -660,6 +661,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
660
661
|
url: string;
|
|
661
662
|
token: string;
|
|
662
663
|
} | undefined;
|
|
664
|
+
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
663
665
|
pausedUntil?: string | null | undefined;
|
|
664
666
|
agentLatestVersion?: string | undefined;
|
|
665
667
|
skipAgentPrompt?: boolean | undefined;
|
|
@@ -670,7 +672,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
670
672
|
baseBranch?: string | undefined;
|
|
671
673
|
baseSha?: string | undefined;
|
|
672
674
|
} | undefined;
|
|
673
|
-
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
674
675
|
}, {
|
|
675
676
|
agent: "CLAUDE" | "CODEX";
|
|
676
677
|
id: string;
|
|
@@ -702,6 +703,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
702
703
|
token: string;
|
|
703
704
|
} | undefined;
|
|
704
705
|
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
706
|
+
workMode?: unknown;
|
|
705
707
|
model?: string | null | undefined;
|
|
706
708
|
effort?: string | null | undefined;
|
|
707
709
|
epoch?: number | undefined;
|
|
@@ -714,12 +716,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
714
716
|
skipAgentPrompt?: unknown;
|
|
715
717
|
branchHint?: unknown;
|
|
716
718
|
branchPlan?: unknown;
|
|
717
|
-
workMode?: unknown;
|
|
718
719
|
}>, "many">;
|
|
719
720
|
}, "strip", z.ZodTypeAny, {
|
|
720
|
-
type: "hello_ack";
|
|
721
|
-
serverName: string;
|
|
722
|
-
serverId: string;
|
|
723
721
|
sessions: {
|
|
724
722
|
mode: "ask" | "plan" | "auto" | "full";
|
|
725
723
|
agent: "CLAUDE" | "CODEX";
|
|
@@ -758,6 +756,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
758
756
|
url: string;
|
|
759
757
|
token: string;
|
|
760
758
|
} | undefined;
|
|
759
|
+
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
761
760
|
pausedUntil?: string | null | undefined;
|
|
762
761
|
agentLatestVersion?: string | undefined;
|
|
763
762
|
skipAgentPrompt?: boolean | undefined;
|
|
@@ -768,13 +767,12 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
768
767
|
baseBranch?: string | undefined;
|
|
769
768
|
baseSha?: string | undefined;
|
|
770
769
|
} | undefined;
|
|
771
|
-
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
772
770
|
}[];
|
|
773
|
-
maxSessions?: number | undefined;
|
|
774
|
-
}, {
|
|
775
771
|
type: "hello_ack";
|
|
776
772
|
serverName: string;
|
|
777
773
|
serverId: string;
|
|
774
|
+
maxSessions?: number | undefined;
|
|
775
|
+
}, {
|
|
778
776
|
sessions: {
|
|
779
777
|
agent: "CLAUDE" | "CODEX";
|
|
780
778
|
id: string;
|
|
@@ -806,6 +804,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
806
804
|
token: string;
|
|
807
805
|
} | undefined;
|
|
808
806
|
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
807
|
+
workMode?: unknown;
|
|
809
808
|
model?: string | null | undefined;
|
|
810
809
|
effort?: string | null | undefined;
|
|
811
810
|
epoch?: number | undefined;
|
|
@@ -818,8 +817,10 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
818
817
|
skipAgentPrompt?: unknown;
|
|
819
818
|
branchHint?: unknown;
|
|
820
819
|
branchPlan?: unknown;
|
|
821
|
-
workMode?: unknown;
|
|
822
820
|
}[];
|
|
821
|
+
type: "hello_ack";
|
|
822
|
+
serverName: string;
|
|
823
|
+
serverId: string;
|
|
823
824
|
maxSessions?: unknown;
|
|
824
825
|
}>, z.ZodObject<{
|
|
825
826
|
type: z.ZodLiteral<"event_ack">;
|
|
@@ -1093,6 +1094,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1093
1094
|
url: string;
|
|
1094
1095
|
token: string;
|
|
1095
1096
|
} | undefined;
|
|
1097
|
+
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
1096
1098
|
pausedUntil?: string | null | undefined;
|
|
1097
1099
|
agentLatestVersion?: string | undefined;
|
|
1098
1100
|
skipAgentPrompt?: boolean | undefined;
|
|
@@ -1103,7 +1105,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1103
1105
|
baseBranch?: string | undefined;
|
|
1104
1106
|
baseSha?: string | undefined;
|
|
1105
1107
|
} | undefined;
|
|
1106
|
-
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
1107
1108
|
}, {
|
|
1108
1109
|
agent: "CLAUDE" | "CODEX";
|
|
1109
1110
|
id: string;
|
|
@@ -1135,6 +1136,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1135
1136
|
token: string;
|
|
1136
1137
|
} | undefined;
|
|
1137
1138
|
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
1139
|
+
workMode?: unknown;
|
|
1138
1140
|
model?: string | null | undefined;
|
|
1139
1141
|
effort?: string | null | undefined;
|
|
1140
1142
|
epoch?: number | undefined;
|
|
@@ -1147,10 +1149,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1147
1149
|
skipAgentPrompt?: unknown;
|
|
1148
1150
|
branchHint?: unknown;
|
|
1149
1151
|
branchPlan?: unknown;
|
|
1150
|
-
workMode?: unknown;
|
|
1151
1152
|
}>;
|
|
1152
1153
|
}, "strip", z.ZodTypeAny, {
|
|
1153
|
-
type: "session_start";
|
|
1154
1154
|
session: {
|
|
1155
1155
|
mode: "ask" | "plan" | "auto" | "full";
|
|
1156
1156
|
agent: "CLAUDE" | "CODEX";
|
|
@@ -1189,6 +1189,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1189
1189
|
url: string;
|
|
1190
1190
|
token: string;
|
|
1191
1191
|
} | undefined;
|
|
1192
|
+
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
1192
1193
|
pausedUntil?: string | null | undefined;
|
|
1193
1194
|
agentLatestVersion?: string | undefined;
|
|
1194
1195
|
skipAgentPrompt?: boolean | undefined;
|
|
@@ -1199,10 +1200,9 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1199
1200
|
baseBranch?: string | undefined;
|
|
1200
1201
|
baseSha?: string | undefined;
|
|
1201
1202
|
} | undefined;
|
|
1202
|
-
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
1203
1203
|
};
|
|
1204
|
-
}, {
|
|
1205
1204
|
type: "session_start";
|
|
1205
|
+
}, {
|
|
1206
1206
|
session: {
|
|
1207
1207
|
agent: "CLAUDE" | "CODEX";
|
|
1208
1208
|
id: string;
|
|
@@ -1234,6 +1234,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1234
1234
|
token: string;
|
|
1235
1235
|
} | undefined;
|
|
1236
1236
|
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
1237
|
+
workMode?: unknown;
|
|
1237
1238
|
model?: string | null | undefined;
|
|
1238
1239
|
effort?: string | null | undefined;
|
|
1239
1240
|
epoch?: number | undefined;
|
|
@@ -1246,8 +1247,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1246
1247
|
skipAgentPrompt?: unknown;
|
|
1247
1248
|
branchHint?: unknown;
|
|
1248
1249
|
branchPlan?: unknown;
|
|
1249
|
-
workMode?: unknown;
|
|
1250
1250
|
};
|
|
1251
|
+
type: "session_start";
|
|
1251
1252
|
}>, z.ZodObject<{
|
|
1252
1253
|
type: z.ZodLiteral<"session_message">;
|
|
1253
1254
|
sessionId: z.ZodString;
|
|
@@ -1406,8 +1407,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1406
1407
|
}, "strip", z.ZodTypeAny, {
|
|
1407
1408
|
type: "workspace_settings";
|
|
1408
1409
|
workspaceId: string;
|
|
1409
|
-
trustMode?: "STRICT" | "NORMAL" | "AUTO" | undefined;
|
|
1410
1410
|
agentAutoCommit?: boolean | undefined;
|
|
1411
|
+
trustMode?: "STRICT" | "NORMAL" | "AUTO" | undefined;
|
|
1411
1412
|
agentPushBan?: boolean | undefined;
|
|
1412
1413
|
agentProtectedBranches?: string[] | undefined;
|
|
1413
1414
|
agentAllowForcePush?: boolean | undefined;
|
|
@@ -1415,8 +1416,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1415
1416
|
}, {
|
|
1416
1417
|
type: "workspace_settings";
|
|
1417
1418
|
workspaceId: string;
|
|
1418
|
-
trustMode?: "STRICT" | "NORMAL" | "AUTO" | undefined;
|
|
1419
1419
|
agentAutoCommit?: boolean | undefined;
|
|
1420
|
+
trustMode?: "STRICT" | "NORMAL" | "AUTO" | undefined;
|
|
1420
1421
|
agentPushBan?: boolean | undefined;
|
|
1421
1422
|
agentProtectedBranches?: string[] | undefined;
|
|
1422
1423
|
agentAllowForcePush?: boolean | undefined;
|
|
@@ -1651,7 +1652,28 @@ export type RunnerFrame = {
|
|
|
1651
1652
|
from: string | null;
|
|
1652
1653
|
to: string;
|
|
1653
1654
|
};
|
|
1654
|
-
}
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* What this machine's own load looks like, right now (plan §5.3).
|
|
1658
|
+
*
|
|
1659
|
+
* A frame of its own rather than a field of `hello`, for the same reason
|
|
1660
|
+
* `agent_versions` is one: `hello` is composed once per process and replayed
|
|
1661
|
+
* on every reconnect, so a load put there would be frozen at daemon start —
|
|
1662
|
+
* a number that is always wrong except in the first second of the machine's
|
|
1663
|
+
* life. This one is measured on a timer and sent only when it moved.
|
|
1664
|
+
*
|
|
1665
|
+
* Nothing static travels here. `machine: {cpuCount, memTotalBytes,
|
|
1666
|
+
* memAvailableBytes}` is already in `hello`, and repeating facts is how two
|
|
1667
|
+
* sources of one truth start disagreeing. `cpuCount` is the single exception
|
|
1668
|
+
* and it earns its place: load1 without it cannot be read as a ratio, and a
|
|
1669
|
+
* consumer joining two frames to find out would eventually paint one
|
|
1670
|
+
* machine's load against another's core count.
|
|
1671
|
+
*
|
|
1672
|
+
* Fields and thresholds mirror `@devbridge/shared` — see `host-load.ts`.
|
|
1673
|
+
*/
|
|
1674
|
+
| ({
|
|
1675
|
+
type: 'host_load';
|
|
1676
|
+
} & HostLoadFrame) | {
|
|
1655
1677
|
type: 'pong';
|
|
1656
1678
|
};
|
|
1657
1679
|
//# sourceMappingURL=protocol.d.ts.map
|