cawdev-cli 1.0.6-beta → 1.0.7-beta
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/mcp/server.mjs +12 -7
- package/package.json +1 -1
- package/runner/README.md +49 -3
- package/runner/attach.mjs +81 -21
- package/runner/input.mjs +5 -1
- package/runner/restart.mjs +191 -0
- package/runner/runner.mjs +171 -6
- package/runner/sign-in.mjs +11 -0
package/mcp/server.mjs
CHANGED
|
@@ -1651,9 +1651,13 @@ async function decide(config, args) {
|
|
|
1651
1651
|
}
|
|
1652
1652
|
// EXPIRED, or the poll gave up before the platform expired it. Same answer
|
|
1653
1653
|
// either way, and the difference is on the run for whoever reads it later.
|
|
1654
|
+
// R339: a person may still allow it late, and if they do the session is
|
|
1655
|
+
// sent a message saying so — so this says to carry on, not to stop.
|
|
1654
1656
|
return deny(
|
|
1655
|
-
`Nobody answered this permission request in time. Do not retry it in a loop
|
|
1656
|
-
`
|
|
1657
|
+
`Nobody answered this permission request in time. Do not retry it in a loop. Carry on ` +
|
|
1658
|
+
`with whatever does not need it; if a person allows it later you will be sent a ` +
|
|
1659
|
+
`message saying so, and may run it then. If nothing else can be done without it, ` +
|
|
1660
|
+
`report blocked and say exactly what you needed to run and why.`,
|
|
1657
1661
|
);
|
|
1658
1662
|
} catch (failure) {
|
|
1659
1663
|
return deny(
|
|
@@ -1806,9 +1810,10 @@ function skillServers() {
|
|
|
1806
1810
|
/**
|
|
1807
1811
|
* Waits for somebody to decide, for as long as the platform will hold it open.
|
|
1808
1812
|
*
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
1813
|
+
* R339: the platform's wait is the project's setting now, and may be never,
|
|
1814
|
+
* so this has no deadline of its own: it returns when the request stops being
|
|
1815
|
+
* PENDING — allowed, refused, or expired by the platform's sweep. A test may
|
|
1816
|
+
* still bound it.
|
|
1812
1817
|
*/
|
|
1813
1818
|
async function pollForDecision(config, project, runId, approvalId) {
|
|
1814
1819
|
const deadline = Date.now() + approvalTimeoutSeconds() * 1000;
|
|
@@ -1831,10 +1836,10 @@ async function pollForDecision(config, project, runId, approvalId) {
|
|
|
1831
1836
|
return null;
|
|
1832
1837
|
}
|
|
1833
1838
|
|
|
1834
|
-
/**
|
|
1839
|
+
/** For ever: the platform decides when nobody is coming (R339). Overridable for tests. */
|
|
1835
1840
|
function approvalTimeoutSeconds() {
|
|
1836
1841
|
const configured = Number(process.env.CAWDEV_APPROVAL_TIMEOUT_SECONDS);
|
|
1837
|
-
return Number.isFinite(configured) && configured > 0 ? configured :
|
|
1842
|
+
return Number.isFinite(configured) && configured > 0 ? configured : Infinity;
|
|
1838
1843
|
}
|
|
1839
1844
|
|
|
1840
1845
|
// There is no roadmap_delete or changelog_delete, and there will not be. The
|
package/package.json
CHANGED
package/runner/README.md
CHANGED
|
@@ -68,6 +68,13 @@ page and waits for you to approve a code; after that it is remembered, and
|
|
|
68
68
|
`/login` does it again on demand. No password is ever typed into the terminal —
|
|
69
69
|
that belongs on a page your browser has told you the origin of.
|
|
70
70
|
|
|
71
|
+
When the platform stops honouring that session — it expired, you signed out in
|
|
72
|
+
the console, or your account went — the first request answered 401 says so
|
|
73
|
+
once, the footer pins `session expired — /login to sign in again`, and the
|
|
74
|
+
terminal drops to watching only until `/login` succeeds. The polls pick up again
|
|
75
|
+
on their own after it. In advanced mode `/login` stays in advanced mode: the URL
|
|
76
|
+
and the code are printed in the prompt window.
|
|
77
|
+
|
|
71
78
|
```
|
|
72
79
|
cawdev the runner here, starting one if there is none
|
|
73
80
|
cawdev --runner <name> when this machine runs more than one
|
|
@@ -222,7 +229,10 @@ move through the command list while one is open.
|
|
|
222
229
|
| `/quit` | leave |
|
|
223
230
|
|
|
224
231
|
**Typing `/` filters that list as you go**, each row with its description; arrows
|
|
225
|
-
and `enter` pick one and `tab` completes as far as the matches agree
|
|
232
|
+
and `enter` pick one and `tab` completes as far as the matches agree — then
|
|
233
|
+
moves down the list, so `/lo` `tab` `tab` reaches `/login` past `/log`. A name
|
|
234
|
+
typed in full is the first row, so `/log` `enter` runs `/log`. Advanced mode
|
|
235
|
+
draws the same list above its prompt, six rows at a time. Guessing a
|
|
226
236
|
command name and being told `no such command` is a step, and it is the step this
|
|
227
237
|
removes.
|
|
228
238
|
|
|
@@ -1200,6 +1210,39 @@ A run **`WAITING_ON_USER` is never swept.** That is the one state where nothing
|
|
|
1200
1210
|
happening is correct: it is stalled on a person, who may reasonably take a day.
|
|
1201
1211
|
Nor is a `PAUSED` or `USAGE_LIMITED` one (below) — there is no process to lose.
|
|
1202
1212
|
|
|
1213
|
+
### When the daemon itself was killed (R339)
|
|
1214
|
+
|
|
1215
|
+
A daemon that dies and is restarted — by systemd, by `cawdev`, by hand — comes
|
|
1216
|
+
back with nothing in its hands, and the platform could only infer that five
|
|
1217
|
+
minutes later, as "its runner is alive but stopped driving it". So the daemon
|
|
1218
|
+
keeps a note of the runs it is driving (`~/.cawdev/driving/<runner id>.json`,
|
|
1219
|
+
rewritten when the set changes), and on boot the next one reads it: every run
|
|
1220
|
+
the dead daemon was driving that the platform still believes in is failed **at
|
|
1221
|
+
once** as `DAEMON_RESTARTED`, with the old and new pids and where to look for the
|
|
1222
|
+
cause. A session the old daemon left running is stopped first — only when its
|
|
1223
|
+
pid still runs the command it was spawned with. The workspace is held exactly as
|
|
1224
|
+
for a vanished runner, and a machine with **auto-resume** on carries such a run
|
|
1225
|
+
on by itself **once**; a second restart on the same run waits for a person,
|
|
1226
|
+
because a test suite that killed the daemon twice will kill it a third time.
|
|
1227
|
+
|
|
1228
|
+
The usual cause on a small Linux box is memory: the session runs a test suite,
|
|
1229
|
+
the kernel's OOM killer picks the biggest process, and that is sometimes the
|
|
1230
|
+
daemon. `journalctl -k | grep -i "killed process"` says so. Two fixes, both on
|
|
1231
|
+
the machine:
|
|
1232
|
+
|
|
1233
|
+
- **`"sessionMemoryMax": "6G"`** in `runner.config.json` spawns each agent
|
|
1234
|
+
session under `systemd-run --scope -p MemoryMax=…` (a size systemd reads —
|
|
1235
|
+
`6G`, `512M`, `75%`). A session that blows through it is killed on its own,
|
|
1236
|
+
the ordinary "agent was terminated" failure, and the daemon stays up. Linux
|
|
1237
|
+
only, and it needs a systemd manager for the user (`loginctl enable-linger
|
|
1238
|
+
<user>` on a box with nobody logged in) or a daemon running as root; the
|
|
1239
|
+
daemon probes once at boot and says in its log whether it applied. Needs a
|
|
1240
|
+
restart to change.
|
|
1241
|
+
- **Protect the daemon and give the box room**: in the daemon's systemd unit,
|
|
1242
|
+
`OOMScoreAdjust=-800` makes the kernel pick a test worker over the daemon;
|
|
1243
|
+
and add swap (`fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap
|
|
1244
|
+
/swapfile && swapon /swapfile`, plus its `/etc/fstab` line).
|
|
1245
|
+
|
|
1203
1246
|
## When the usage limit hits (R73)
|
|
1204
1247
|
|
|
1205
1248
|
Claude Code stops with a message naming the window — the five-hour one or the
|
|
@@ -1309,8 +1352,11 @@ your inbox and on the run, and waits for an answer. Allow it once, or allow it
|
|
|
1309
1352
|
always here — which writes a rule on the project that every runner serving it
|
|
1310
1353
|
inherits.
|
|
1311
1354
|
|
|
1312
|
-
Nobody answering is a decision too: after
|
|
1313
|
-
|
|
1355
|
+
Nobody answering is a decision too: after the project's wait — fifteen minutes
|
|
1356
|
+
unless its Rules page says otherwise, and possibly never (R339) — the request
|
|
1357
|
+
expires and the session is told to carry on without it. Allowing it later still
|
|
1358
|
+
works: the grant lasts the rest of the run and the session is sent a message to
|
|
1359
|
+
try again. A stage gate never expires.
|
|
1314
1360
|
|
|
1315
1361
|
You can still say in advance what a project may do, and for anything a session
|
|
1316
1362
|
needs on every run that is the better answer — with `allowedTools`, which
|
package/runner/attach.mjs
CHANGED
|
@@ -482,7 +482,7 @@ export function elapsed(ms) {
|
|
|
482
482
|
*/
|
|
483
483
|
export function footerLines(state, width, ink = painter(3)) {
|
|
484
484
|
const {
|
|
485
|
-
runner, runs = [], email, watching, connected = true,
|
|
485
|
+
runner, runs = [], email, expired = false, watching, connected = true,
|
|
486
486
|
banner = [], overlay = [], input = null, keys = '', status = '',
|
|
487
487
|
running = null, now = Date.now(), matches = [],
|
|
488
488
|
} = state;
|
|
@@ -519,6 +519,16 @@ export function footerLines(state, width, ink = painter(3)) {
|
|
|
519
519
|
), width));
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
+
// The platform refused the stored session (401). Pinned for the same reason
|
|
523
|
+
// as the row above: one line in the scrollback is gone with the next
|
|
524
|
+
// transcript line, and every action from here on would fail without it.
|
|
525
|
+
if (expired) {
|
|
526
|
+
lines.push(padVisible(clip(
|
|
527
|
+
` ${ink.warn('!')} ${ink.muted('session expired —')} ${ink.text('/login')} ${ink.muted('to sign in again')}`,
|
|
528
|
+
width,
|
|
529
|
+
), width));
|
|
530
|
+
}
|
|
531
|
+
|
|
522
532
|
// Who and where. The runner's NAME is what the console shows and what
|
|
523
533
|
// `--runner` takes, so it is the word somebody would type; the email is what
|
|
524
534
|
// every action here is done as, and "watching only" is not a lesser state,
|
|
@@ -679,19 +689,19 @@ async function openSession(argv, url) {
|
|
|
679
689
|
* too is not belt and braces: a machine over ssh has no browser to open, and
|
|
680
690
|
* this is the only thing that makes it work at all.
|
|
681
691
|
*/
|
|
682
|
-
async function runSignIn(session, ink = painter()) {
|
|
692
|
+
async function runSignIn(session, ink = painter(), say = (line) => console.log(line)) {
|
|
683
693
|
const outcome = await signInThroughBrowser(session, ({ url, code }) => {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
694
|
+
say(` ${ink.muted('If your browser did not open, go to')}`);
|
|
695
|
+
say(` ${ink.accent(url)}`);
|
|
696
|
+
say(` ${ink.muted('and check it is showing')} ${ink.bold(code)}\n`);
|
|
697
|
+
say(` ${ink.muted('Waiting…')}`);
|
|
688
698
|
});
|
|
689
699
|
if (outcome.signedIn) {
|
|
690
|
-
|
|
700
|
+
say(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
|
|
691
701
|
} else if (outcome.refused) {
|
|
692
|
-
|
|
702
|
+
say(` ${ink.danger('Refused')} in the browser.\n`);
|
|
693
703
|
} else {
|
|
694
|
-
|
|
704
|
+
say(` ${ink.warn('The code expired.')}\n`);
|
|
695
705
|
}
|
|
696
706
|
return outcome;
|
|
697
707
|
}
|
|
@@ -801,6 +811,9 @@ function refOf(word) {
|
|
|
801
811
|
return word.replace(/^r/i, 'R').replace(/^i/i, 'i');
|
|
802
812
|
}
|
|
803
813
|
|
|
814
|
+
/** How many matching commands advanced mode's prompt shows at once. */
|
|
815
|
+
const SPLIT_COMPLETIONS = 6;
|
|
816
|
+
|
|
804
817
|
/** The slash commands, and the one place they are described. */
|
|
805
818
|
const COMMANDS = [
|
|
806
819
|
['/help', 'this list'],
|
|
@@ -887,6 +900,11 @@ export class Attached {
|
|
|
887
900
|
constructor(socketPath, session, options = {}) {
|
|
888
901
|
this.socketPath = socketPath;
|
|
889
902
|
this.session = session;
|
|
903
|
+
if (session) {
|
|
904
|
+
session.onExpired = () => this.sessionExpired();
|
|
905
|
+
}
|
|
906
|
+
/** True from a 401 on a session that was signed in until `/login` works. */
|
|
907
|
+
this.expired = false;
|
|
890
908
|
this.options = options;
|
|
891
909
|
this.ink = painter();
|
|
892
910
|
this.screen = new Scrollback(options.out ?? process.stdout);
|
|
@@ -1864,8 +1882,16 @@ export class Attached {
|
|
|
1864
1882
|
}
|
|
1865
1883
|
if (rows.length) {
|
|
1866
1884
|
// As far as they agree, which is what every shell does and nobody has
|
|
1867
|
-
// to be taught. One match completes it whole.
|
|
1868
|
-
|
|
1885
|
+
// to be taught. One match completes it whole. Once the line is already
|
|
1886
|
+
// that far, Tab moves down the list instead — `/log` is a command AND
|
|
1887
|
+
// the prefix of `/login` and `/logout`, and Tab stopping there for
|
|
1888
|
+
// good left those two reachable only by the arrows.
|
|
1889
|
+
const prefix = commonPrefix(rows.map(([name]) => name));
|
|
1890
|
+
if (rows.length > 1 && line.text.toLowerCase() === prefix.toLowerCase() && this.input.showing !== false) {
|
|
1891
|
+
this.input.at = (at + 1) % rows.length;
|
|
1892
|
+
} else {
|
|
1893
|
+
line.set(prefix);
|
|
1894
|
+
}
|
|
1869
1895
|
this.input.showing = true;
|
|
1870
1896
|
this.dirty = true;
|
|
1871
1897
|
}
|
|
@@ -2829,21 +2855,31 @@ export class Attached {
|
|
|
2829
2855
|
* the moment it is decided.
|
|
2830
2856
|
*/
|
|
2831
2857
|
async signIn() {
|
|
2858
|
+
let say;
|
|
2832
2859
|
if (this.split) {
|
|
2833
|
-
|
|
2860
|
+
// Advanced mode stays: the URL and the code go to the prompt window,
|
|
2861
|
+
// where everything said there is read, and the browser opens anyway.
|
|
2862
|
+
say = (line) => this.say(line);
|
|
2863
|
+
} else {
|
|
2864
|
+
// Everything queued goes out first and the footer comes down, so what
|
|
2865
|
+
// `runSignIn` prints with `console.log` lands under the transcript rather
|
|
2866
|
+
// than through the middle of a live region nothing is going to erase.
|
|
2867
|
+
this.say('');
|
|
2868
|
+
this.screen.update(this.pending, []);
|
|
2869
|
+
this.pending = [];
|
|
2834
2870
|
}
|
|
2835
|
-
|
|
2836
|
-
// `runSignIn` prints with `console.log` lands under the transcript rather
|
|
2837
|
-
// than through the middle of a live region nothing is going to erase.
|
|
2838
|
-
this.say('');
|
|
2839
|
-
this.screen.update(this.pending, []);
|
|
2840
|
-
this.pending = [];
|
|
2841
|
-
const outcome = await runSignIn(this.session, this.ink)
|
|
2871
|
+
const outcome = await runSignIn(this.session, this.ink, say)
|
|
2842
2872
|
.catch((failure) => ({ signedIn: false, message: failure.message }));
|
|
2843
2873
|
if (outcome.message) {
|
|
2844
2874
|
this.say(this.ink.danger(` could not sign in: ${outcome.message}`));
|
|
2845
2875
|
}
|
|
2846
|
-
if (this.session.signedIn) {
|
|
2876
|
+
if (this.session.signedIn && this.expired) {
|
|
2877
|
+
// After an expiry the watchers never stopped — they have been failing
|
|
2878
|
+
// quietly and pick up on their own — so a second set would poll twice.
|
|
2879
|
+
this.expired = false;
|
|
2880
|
+
this.note('signed in again');
|
|
2881
|
+
void this.findStopped();
|
|
2882
|
+
} else if (this.session.signedIn) {
|
|
2847
2883
|
void this.watchInbox();
|
|
2848
2884
|
void this.watchQuestions();
|
|
2849
2885
|
void this.findStopped();
|
|
@@ -2852,6 +2888,20 @@ export class Attached {
|
|
|
2852
2888
|
return undefined;
|
|
2853
2889
|
}
|
|
2854
2890
|
|
|
2891
|
+
/**
|
|
2892
|
+
* The platform answered 401 to a session that was signed in: it expired, was
|
|
2893
|
+
* signed out from the console, or the account went. Said once, where it is
|
|
2894
|
+
* read — the prompt window in advanced mode, the scrollback otherwise — and
|
|
2895
|
+
* the footer keeps a row until `/login` succeeds. The watchers keep polling
|
|
2896
|
+
* and fail quietly; they pick up again on their own after the sign-in.
|
|
2897
|
+
*/
|
|
2898
|
+
sessionExpired() {
|
|
2899
|
+
if (this.expired) return;
|
|
2900
|
+
this.expired = true;
|
|
2901
|
+
this.say(this.ink.warn(' your cawdev session has expired or is no longer valid — type /login to sign in again'));
|
|
2902
|
+
this.note('session expired — /login to sign in again');
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2855
2905
|
// --- advanced mode — split.mjs ----------------------------------------------
|
|
2856
2906
|
|
|
2857
2907
|
/**
|
|
@@ -3219,7 +3269,16 @@ export class Attached {
|
|
|
3219
3269
|
+ ' · / commands · tab next window');
|
|
3220
3270
|
// A question or a request on screen says which window it came from.
|
|
3221
3271
|
const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
|
|
3222
|
-
|
|
3272
|
+
// The commands matching what is typed, above the line as in the footer —
|
|
3273
|
+
// advanced mode drew none, so enter took a highlighted command nobody saw.
|
|
3274
|
+
const matches = this.mode === 'typing' && this.input?.showing !== false ? this.matching() : { rows: [], at: 0 };
|
|
3275
|
+
// Six rows at most, scrolled to the highlighted one: a bare `/` matches
|
|
3276
|
+
// every command, and the prompt grows with what it holds.
|
|
3277
|
+
const first = Math.max(0, Math.min(matches.at - 2, matches.rows.length - SPLIT_COMPLETIONS));
|
|
3278
|
+
const completing = completionLines(
|
|
3279
|
+
matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
|
|
3280
|
+
);
|
|
3281
|
+
const fixed = [...from, ...banner, ...overlay, ...completing, input];
|
|
3223
3282
|
const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
|
|
3224
3283
|
// The roadmap across the top: as tall as its fullest column needs, never
|
|
3225
3284
|
// more than a third of the screen, and never so tall that no window fits
|
|
@@ -4561,6 +4620,7 @@ export class Attached {
|
|
|
4561
4620
|
runner: this.runner,
|
|
4562
4621
|
runs: this.runs,
|
|
4563
4622
|
email: this.session.email,
|
|
4623
|
+
expired: this.expired,
|
|
4564
4624
|
watching: run,
|
|
4565
4625
|
connected: this.connected,
|
|
4566
4626
|
// R83's status line is about what this machine is DRIVING, which is not
|
package/runner/input.mjs
CHANGED
|
@@ -192,7 +192,11 @@ export function completions(text, commands) {
|
|
|
192
192
|
return [];
|
|
193
193
|
}
|
|
194
194
|
const word = typed.slice(1).toLowerCase();
|
|
195
|
-
|
|
195
|
+
const found = commands.filter(([name]) => name.slice(1).toLowerCase().startsWith(word));
|
|
196
|
+
// A name typed in full comes first, so enter runs `/log` and not `/login`,
|
|
197
|
+
// the first longer one in the list.
|
|
198
|
+
const exact = found.filter(([name]) => name.slice(1).toLowerCase() === word);
|
|
199
|
+
return [...exact, ...found.filter((row) => !exact.includes(row))];
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
/**
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// What a daemon that restarted under its sessions does about them — R339.
|
|
2
|
+
//
|
|
3
|
+
// A daemon killed mid-run (most often for memory, by the kernel, while the
|
|
4
|
+
// session ran a test suite) comes back healthy with an empty `running` map.
|
|
5
|
+
// The platform could only infer what happened: five minutes later
|
|
6
|
+
// StaleRunSweeper failed the run as "its runner is alive but stopped driving
|
|
7
|
+
// it", a sentence that names no cause and arrives after somebody has already
|
|
8
|
+
// gone looking. The one process that KNOWS is the new daemon, so the old one
|
|
9
|
+
// leaves a note: the runs it is driving, written beside the socket every time
|
|
10
|
+
// that set changes. On boot the new one reads it and reports each run it no
|
|
11
|
+
// longer holds as DAEMON_RESTARTED, at once.
|
|
12
|
+
//
|
|
13
|
+
// And so it happens less: `sessionMemoryMax` spawns each agent in a systemd
|
|
14
|
+
// scope with a memory ceiling, so a runaway test suite takes down its own
|
|
15
|
+
// session — the ordinary "agent was terminated" path — and not the daemon.
|
|
16
|
+
//
|
|
17
|
+
// Pure where it can be. The file, the scope probe and the kill are the only
|
|
18
|
+
// side effects, and each takes what it touches as an argument.
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { basename, dirname, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where a runner's note lives — one per runner id, because one machine may
|
|
27
|
+
* run two configs. NOT in the run directory, which holds sockets and nothing
|
|
28
|
+
* else (the terminal lists it to find daemons): beside it, so a test that
|
|
29
|
+
* points CAWDEV_RUN_DIR at a temporary directory keeps its notes there too.
|
|
30
|
+
*/
|
|
31
|
+
export function drivingPath(runnerId, env = process.env) {
|
|
32
|
+
const dir = env.CAWDEV_DRIVING_DIR
|
|
33
|
+
?? (env.CAWDEV_RUN_DIR ? `${env.CAWDEV_RUN_DIR}-driving` : join(homedir(), '.cawdev', 'driving'));
|
|
34
|
+
return join(dir, `${runnerId}.json`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The note: who wrote it and what it was driving. `command` is the child's
|
|
39
|
+
* command's base name, so the next daemon can tell its own orphan from an
|
|
40
|
+
* unrelated process that was handed the same pid after a reboot.
|
|
41
|
+
*/
|
|
42
|
+
export function snapshotOf(pid, running) {
|
|
43
|
+
const runs = [];
|
|
44
|
+
for (const [id, child] of running) {
|
|
45
|
+
runs.push({
|
|
46
|
+
id,
|
|
47
|
+
projectSlug: child?.cawdevProjectSlug ?? null,
|
|
48
|
+
childPid: Number.isInteger(child?.pid) ? child.pid : null,
|
|
49
|
+
command: child?.cawdevCommand ? basename(child.cawdevCommand) : null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return { pid, runs };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Atomic: a daemon killed mid-write must not leave half a note for the next one. */
|
|
56
|
+
export async function writeDriving(path, snapshot) {
|
|
57
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
58
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
59
|
+
await writeFile(temporary, JSON.stringify(snapshot), { mode: 0o600 });
|
|
60
|
+
await rename(temporary, path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The previous note, or null — a missing or unreadable one is "nothing was being driven". */
|
|
64
|
+
export async function readDriving(path) {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
67
|
+
return parsed && Number.isInteger(parsed.pid) && Array.isArray(parsed.runs) ? parsed : null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The runs a previous daemon left behind, or none.
|
|
75
|
+
*
|
|
76
|
+
* None when the note is this process's own, and none when its writer is
|
|
77
|
+
* still alive: that is a second daemon on the same runner id, and its runs
|
|
78
|
+
* are its own business.
|
|
79
|
+
*/
|
|
80
|
+
export function leftBehind(previous, myPid, isAlive) {
|
|
81
|
+
if (!previous || previous.pid === myPid || isAlive(previous.pid)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
return previous.runs.filter((run) => typeof run.id === 'string' && run.projectSlug);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether a pid is a live process. Signal 0 tests without sending. */
|
|
88
|
+
export function isAlive(pid) {
|
|
89
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (failure) {
|
|
94
|
+
return failure.code === 'EPERM';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Live states a restart does not concern: they hold no process. */
|
|
99
|
+
const NOT_DRIVEN = new Set(['QUEUED', 'PAUSED', 'USAGE_LIMITED']);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether the platform's view of a run means a restart killed it: live,
|
|
103
|
+
* holding a process, and still this runner's. A run carried on elsewhere in
|
|
104
|
+
* the meantime is not ours to fail.
|
|
105
|
+
*/
|
|
106
|
+
export function killedByRestart(run, runnerId) {
|
|
107
|
+
return Boolean(run?.live)
|
|
108
|
+
&& !NOT_DRIVEN.has(run.state)
|
|
109
|
+
&& (!run.runnerId || run.runnerId === runnerId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What the run says — the cause, the evidence to look for, and the way out.
|
|
114
|
+
* `stopped` is the signal a daemon that stopped ON PURPOSE wrote into its
|
|
115
|
+
* note; without one it was killed, and memory is the usual reason.
|
|
116
|
+
*/
|
|
117
|
+
export function restartSummary({ name, previousPid, pid, orphanStopped, stopped = null }) {
|
|
118
|
+
const why = stopped
|
|
119
|
+
? `It was stopped (${stopped}) — by somebody, or by the service manager restarting it.`
|
|
120
|
+
: 'Most often the machine ran out of memory — `journalctl -k | grep -i "killed process"` '
|
|
121
|
+
+ 'says so on Linux; `sessionMemoryMax` in the runner config keeps a session from taking '
|
|
122
|
+
+ 'the daemon with it.';
|
|
123
|
+
return `The cawdev daemon on ${name} restarted while driving this run (it was pid `
|
|
124
|
+
+ `${previousPid}; it is pid ${pid} now), and the agent session went with it. ${why}`
|
|
125
|
+
+ (orphanStopped ? ' The session it left running unattended has been stopped.' : '')
|
|
126
|
+
+ ' The workspace is kept with whatever the session had written: carry it on there, or '
|
|
127
|
+
+ 'discard it.';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- the memory ceiling ----------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `sessionMemoryMax` as systemd reads it — `6G`, `512M`, `75%` — or null.
|
|
134
|
+
* Anything else is null, and the daemon says so rather than guessing.
|
|
135
|
+
*/
|
|
136
|
+
export function memoryMaxOf(value) {
|
|
137
|
+
if (value === undefined || value === null || value === '') return null;
|
|
138
|
+
const text = String(value).trim();
|
|
139
|
+
return /^\d+(\.\d+)?[KMGT]?$|^\d+(\.\d+)?%$/i.test(text) ? text.toUpperCase() : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The command line that runs `command args` under the ceiling — or the same
|
|
144
|
+
* command line, when there is no ceiling to apply. `systemd-run --scope` execs
|
|
145
|
+
* the command in place, so the pid, the process group and the pipes are the
|
|
146
|
+
* agent's own: cancelling (`kill(-pid)`) and the stream readers do not change.
|
|
147
|
+
*/
|
|
148
|
+
export function scoped(command, args, scope) {
|
|
149
|
+
if (!scope?.memoryMax) {
|
|
150
|
+
return { command, args };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
command: 'systemd-run',
|
|
154
|
+
args: [`--${scope.manager}`, '--scope', '--quiet', '--collect', '--no-ask-password',
|
|
155
|
+
'-p', `MemoryMax=${scope.memoryMax}`, '--', command, ...args],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether this machine can apply the ceiling: Linux, and a systemd that will
|
|
161
|
+
* make a scope for this user. Tried with `true` once at boot, because a
|
|
162
|
+
* daemon run as a system service has no user manager and every spawn would
|
|
163
|
+
* otherwise fail on a command that cannot work here.
|
|
164
|
+
*/
|
|
165
|
+
export async function probeScope(memoryMax, { platform = process.platform, run = runQuietly } = {}) {
|
|
166
|
+
if (!memoryMax) return { scope: null, why: null };
|
|
167
|
+
if (platform !== 'linux') {
|
|
168
|
+
return { scope: null, why: `sessionMemoryMax needs Linux and systemd; this is ${platform}` };
|
|
169
|
+
}
|
|
170
|
+
for (const manager of ['user', 'system']) {
|
|
171
|
+
const code = await run('systemd-run',
|
|
172
|
+
[`--${manager}`, '--scope', '--quiet', '--collect', '--no-ask-password',
|
|
173
|
+
'-p', `MemoryMax=${memoryMax}`, 'true']);
|
|
174
|
+
if (code === 0) {
|
|
175
|
+
return { scope: { manager, memoryMax }, why: null };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
scope: null,
|
|
180
|
+
why: 'systemd-run could not make a scope here (no user manager — `loginctl enable-linger` '
|
|
181
|
+
+ 'for this user, or run the daemon as root)',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function runQuietly(command, args) {
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
const child = spawn(command, args, { stdio: 'ignore' });
|
|
188
|
+
child.on('error', () => resolve(-1));
|
|
189
|
+
child.on('close', (code) => resolve(code));
|
|
190
|
+
});
|
|
191
|
+
}
|
package/runner/runner.mjs
CHANGED
|
@@ -48,6 +48,10 @@ import {
|
|
|
48
48
|
} from './auto-workspaces.mjs';
|
|
49
49
|
import { agentOfCommand, needsEverything } from '../lib/agents.mjs';
|
|
50
50
|
import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
|
|
51
|
+
import {
|
|
52
|
+
drivingPath, isAlive, killedByRestart, leftBehind, memoryMaxOf, probeScope, readDriving,
|
|
53
|
+
restartSummary, scoped, snapshotOf, writeDriving,
|
|
54
|
+
} from './restart.mjs';
|
|
51
55
|
|
|
52
56
|
// --- configuration -----------------------------------------------------------
|
|
53
57
|
|
|
@@ -420,6 +424,12 @@ async function readConfig() {
|
|
|
420
424
|
* switched on, never on by default.
|
|
421
425
|
*/
|
|
422
426
|
autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
|
|
427
|
+
/**
|
|
428
|
+
* R339: the memory ceiling each agent session is spawned under, as
|
|
429
|
+
* systemd reads it (`6G`, `75%`) — Linux only, see restart.mjs. Absent is
|
|
430
|
+
* none. Raw here; `main` probes whether this machine can apply it.
|
|
431
|
+
*/
|
|
432
|
+
sessionMemoryMax: file.sessionMemoryMax ?? null,
|
|
423
433
|
skillCache: file.skillCache ?? DEFAULTS.skillCache,
|
|
424
434
|
skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
|
|
425
435
|
idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
|
|
@@ -6755,6 +6765,15 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6755
6765
|
if (decision.allowed) {
|
|
6756
6766
|
break;
|
|
6757
6767
|
}
|
|
6768
|
+
if (decision.expired) {
|
|
6769
|
+
// R339. Unread is not refused: re-running the stage would spend a
|
|
6770
|
+
// second stage on a plan nobody has looked at yet.
|
|
6771
|
+
const why = `Stopped at the ${stage.stage} gate: ${decision.reason}. Nothing was `
|
|
6772
|
+
+ 'refused — start the run again when somebody can read it.';
|
|
6773
|
+
await skipRest(config, run, lifecycle, at + 1, why);
|
|
6774
|
+
await finish(config, run, 'FINISHED', why);
|
|
6775
|
+
return;
|
|
6776
|
+
}
|
|
6758
6777
|
if (attempt === 1) {
|
|
6759
6778
|
const why = `Stopped at the ${stage.stage} gate: ${decision.reason ?? 'refused twice.'}`;
|
|
6760
6779
|
// The same trail R122 came from: a run that ends here never reaches the
|
|
@@ -6858,6 +6877,11 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6858
6877
|
}
|
|
6859
6878
|
|
|
6860
6879
|
log(` waiting at the ${stage.stage} gate`);
|
|
6880
|
+
// R339: no deadline of its own by default. A gate is a person reading the
|
|
6881
|
+
// plan, the platform never expires one, and a run waiting on it is never
|
|
6882
|
+
// swept — a timer here turned "not read yet" into a refusal and re-ran the
|
|
6883
|
+
// stage, which is spend nobody asked for. An older platform that does
|
|
6884
|
+
// expire it still answers EXPIRED, which is handled below.
|
|
6861
6885
|
const deadline = Date.now() + gateTimeoutSeconds() * 1000;
|
|
6862
6886
|
while (Date.now() < deadline) {
|
|
6863
6887
|
const remaining = Math.ceil((deadline - Date.now()) / 1000);
|
|
@@ -6869,6 +6893,12 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6869
6893
|
// the transport — a 204 and a `{state: 'PENDING'}` both mean "not yet",
|
|
6870
6894
|
// and depending on which one the platform chose would be depending on
|
|
6871
6895
|
// something nobody promised.
|
|
6896
|
+
if (decision?.state === 'EXPIRED') {
|
|
6897
|
+
// A platform from before R339. Nobody said no, so this is not a
|
|
6898
|
+
// refusal to re-run the stage over: the run stops at the gate.
|
|
6899
|
+
log(` the ${stage.stage} gate expired unread`);
|
|
6900
|
+
return { allowed: false, expired: true, reason: 'nobody read it in time' };
|
|
6901
|
+
}
|
|
6872
6902
|
if (decision && decision.state && decision.state !== 'PENDING') {
|
|
6873
6903
|
const allowed = decision.state === 'ALLOWED';
|
|
6874
6904
|
log(` the ${stage.stage} gate was ${allowed ? 'allowed' : 'refused'}`);
|
|
@@ -6879,13 +6909,29 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6879
6909
|
return { allowed: false, reason: `waiting failed (${failure.message})` };
|
|
6880
6910
|
}
|
|
6881
6911
|
}
|
|
6882
|
-
return { allowed: false, reason: 'nobody answered in time' };
|
|
6912
|
+
return { allowed: false, expired: true, reason: 'nobody answered in time' };
|
|
6883
6913
|
}
|
|
6884
6914
|
|
|
6885
|
-
/**
|
|
6915
|
+
/**
|
|
6916
|
+
* How long a gate waits before the daemon gives up on it: for ever, unless a
|
|
6917
|
+
* test says otherwise — R339. The platform decides when nobody is coming.
|
|
6918
|
+
*/
|
|
6886
6919
|
function gateTimeoutSeconds() {
|
|
6887
6920
|
const configured = Number(process.env.CAWDEV_GATE_TIMEOUT_SECONDS);
|
|
6888
|
-
return Number.isFinite(configured) && configured > 0 ? configured :
|
|
6921
|
+
return Number.isFinite(configured) && configured > 0 ? configured : Infinity;
|
|
6922
|
+
}
|
|
6923
|
+
|
|
6924
|
+
/**
|
|
6925
|
+
* An agent session, spawned under the machine's memory ceiling when it has
|
|
6926
|
+
* one — R339. `systemd-run --scope` execs in place, so the pid, the group and
|
|
6927
|
+
* the pipes are the agent's own. `cawdevCommand` is what the next daemon reads
|
|
6928
|
+
* off the driving note to tell this session's orphan from a reused pid.
|
|
6929
|
+
*/
|
|
6930
|
+
function spawnScoped(config, command, args, options) {
|
|
6931
|
+
const line = scoped(command, args, config.sessionScope);
|
|
6932
|
+
const child = spawn(line.command, line.args, options);
|
|
6933
|
+
child.cawdevCommand = command;
|
|
6934
|
+
return child;
|
|
6889
6935
|
}
|
|
6890
6936
|
|
|
6891
6937
|
async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
@@ -7306,7 +7352,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7306
7352
|
log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
|
|
7307
7353
|
|
|
7308
7354
|
return new Promise((resolvePromise) => {
|
|
7309
|
-
const child =
|
|
7355
|
+
const child = spawnScoped(config, agentCmd, args, {
|
|
7310
7356
|
cwd,
|
|
7311
7357
|
// Its own process group, so cancelling can take down the whole tree
|
|
7312
7358
|
// rather than leaving orphaned children behind.
|
|
@@ -8418,7 +8464,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8418
8464
|
let childTotals = null;
|
|
8419
8465
|
let err = '';
|
|
8420
8466
|
let buffer = '';
|
|
8421
|
-
const child =
|
|
8467
|
+
const child = spawnScoped(config, agyCmd, args, {
|
|
8422
8468
|
cwd,
|
|
8423
8469
|
// Its own process group — see the same choice on the Claude Code path.
|
|
8424
8470
|
detached: true,
|
|
@@ -8757,7 +8803,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8757
8803
|
let lastSaid = null;
|
|
8758
8804
|
let err = '';
|
|
8759
8805
|
let buffer = '';
|
|
8760
|
-
const child =
|
|
8806
|
+
const child = spawnScoped(config, codexCmd, args, {
|
|
8761
8807
|
cwd,
|
|
8762
8808
|
// Its own process group — see the same choice on the Claude Code path.
|
|
8763
8809
|
detached: true,
|
|
@@ -8939,6 +8985,101 @@ async function finish(config, run, state, summary) {
|
|
|
8939
8985
|
|
|
8940
8986
|
// --- cancellation ------------------------------------------------------------
|
|
8941
8987
|
|
|
8988
|
+
/**
|
|
8989
|
+
* What the daemon before this one was driving when it died — R339.
|
|
8990
|
+
*
|
|
8991
|
+
* Read off the note it kept (restart.mjs), and only when its writer is dead:
|
|
8992
|
+
* a live writer is a second daemon on this runner id. Each run the platform
|
|
8993
|
+
* still believes in is failed at once as DAEMON_RESTARTED, in words that name
|
|
8994
|
+
* the cause — where the stale sweep would have said, five minutes later, that
|
|
8995
|
+
* the runner "stopped driving it". A session the old daemon left running
|
|
8996
|
+
* unattended is stopped first: two sessions writing one checkout is worse
|
|
8997
|
+
* than either.
|
|
8998
|
+
*/
|
|
8999
|
+
async function reportWhatARestartKilled(config) {
|
|
9000
|
+
const path = drivingPath(config.runnerId);
|
|
9001
|
+
const previous = await readDriving(path);
|
|
9002
|
+
const behind = leftBehind(previous, process.pid, isAlive);
|
|
9003
|
+
for (const left of behind) {
|
|
9004
|
+
const run = await api(config, `/api/projects/${left.projectSlug}/runs/${left.id}`)
|
|
9005
|
+
.catch((failure) => {
|
|
9006
|
+
log(`could not read ${left.id} after the restart: ${failure.message}`);
|
|
9007
|
+
return null;
|
|
9008
|
+
});
|
|
9009
|
+
if (!killedByRestart(run, config.runnerId)) {
|
|
9010
|
+
continue;
|
|
9011
|
+
}
|
|
9012
|
+
const orphanStopped = await stopOrphan(left);
|
|
9013
|
+
log(`run ${left.id} was being driven when this daemon last died (pid ${previous.pid}); `
|
|
9014
|
+
+ 'reporting it');
|
|
9015
|
+
await api(config, `/api/projects/${left.projectSlug}/runs/${left.id}/transition`, {
|
|
9016
|
+
method: 'POST',
|
|
9017
|
+
body: {
|
|
9018
|
+
state: 'FAILED',
|
|
9019
|
+
failureReason: 'DAEMON_RESTARTED',
|
|
9020
|
+
summary: restartSummary({
|
|
9021
|
+
name: config.name, previousPid: previous.pid, pid: process.pid, orphanStopped,
|
|
9022
|
+
stopped: previous.stopped ?? null,
|
|
9023
|
+
}),
|
|
9024
|
+
},
|
|
9025
|
+
}).catch((failure) => log(` could not report it: ${failure.message}`));
|
|
9026
|
+
}
|
|
9027
|
+
await writeDriving(path, snapshotOf(process.pid, running))
|
|
9028
|
+
.catch((failure) => log(`could not write ${path}: ${failure.message}`));
|
|
9029
|
+
}
|
|
9030
|
+
|
|
9031
|
+
/**
|
|
9032
|
+
* The old daemon's session, if it outlived it — and only if the pid still
|
|
9033
|
+
* runs the command it was spawned with, because after a reboot that number
|
|
9034
|
+
* may be somebody else's process.
|
|
9035
|
+
*/
|
|
9036
|
+
async function stopOrphan(left) {
|
|
9037
|
+
if (!left.childPid || !left.command || !isAlive(left.childPid)) {
|
|
9038
|
+
return false;
|
|
9039
|
+
}
|
|
9040
|
+
const command = await new Promise((resolveCommand) => {
|
|
9041
|
+
const ps = spawn('ps', ['-o', 'command=', '-p', String(left.childPid)],
|
|
9042
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] });
|
|
9043
|
+
let out = '';
|
|
9044
|
+
ps.stdout.on('data', (chunk) => (out += chunk));
|
|
9045
|
+
ps.on('error', () => resolveCommand(''));
|
|
9046
|
+
ps.on('close', () => resolveCommand(out));
|
|
9047
|
+
});
|
|
9048
|
+
if (!command.includes(left.command)) {
|
|
9049
|
+
return false;
|
|
9050
|
+
}
|
|
9051
|
+
try {
|
|
9052
|
+
process.kill(-left.childPid, 'SIGTERM');
|
|
9053
|
+
log(` stopped the session it left running (pid ${left.childPid})`);
|
|
9054
|
+
return true;
|
|
9055
|
+
} catch (failure) {
|
|
9056
|
+
log(` could not stop the session it left running: ${failure.message}`);
|
|
9057
|
+
return false;
|
|
9058
|
+
}
|
|
9059
|
+
}
|
|
9060
|
+
|
|
9061
|
+
/**
|
|
9062
|
+
* Keeps the note current: rewritten whenever the set of runs being driven
|
|
9063
|
+
* changes, checked every second. A second is the window in which a death
|
|
9064
|
+
* goes unreported — and the stale sweep still catches that one.
|
|
9065
|
+
*/
|
|
9066
|
+
function keepTheDrivingNote(config) {
|
|
9067
|
+
const path = drivingPath(config.runnerId);
|
|
9068
|
+
let written = null;
|
|
9069
|
+
const timer = setInterval(() => {
|
|
9070
|
+
const snapshot = snapshotOf(process.pid, running);
|
|
9071
|
+
const text = JSON.stringify(snapshot);
|
|
9072
|
+
if (text === written) return;
|
|
9073
|
+
written = text;
|
|
9074
|
+
writeDriving(path, snapshot).catch((failure) => {
|
|
9075
|
+
written = null;
|
|
9076
|
+
log(`could not write ${path}: ${failure.message}`);
|
|
9077
|
+
});
|
|
9078
|
+
}, 1000);
|
|
9079
|
+
timer.unref?.();
|
|
9080
|
+
return timer;
|
|
9081
|
+
}
|
|
9082
|
+
|
|
8942
9083
|
/**
|
|
8943
9084
|
* A cancelled run reaches the runner on its next poll, and the child's whole
|
|
8944
9085
|
* process group goes with it — an agent that spawns a build should not leave it
|
|
@@ -9191,6 +9332,24 @@ async function main() {
|
|
|
9191
9332
|
// than buried under a session that then fails for a reason it explains.
|
|
9192
9333
|
await probePermissionPrompt(config);
|
|
9193
9334
|
|
|
9335
|
+
// R339. Whether this machine can put each session under a memory ceiling,
|
|
9336
|
+
// and what an earlier daemon on this runner left behind when it died —
|
|
9337
|
+
// both before anything is claimed: a run reported now is one the queue
|
|
9338
|
+
// must not hand the checkout of to somebody else first.
|
|
9339
|
+
const memoryMax = memoryMaxOf(config.sessionMemoryMax);
|
|
9340
|
+
if (config.sessionMemoryMax && !memoryMax) {
|
|
9341
|
+
log(`sessionMemoryMax "${config.sessionMemoryMax}" is not a size systemd reads (6G, 512M, 75%); ignored`);
|
|
9342
|
+
}
|
|
9343
|
+
const probed = await probeScope(memoryMax);
|
|
9344
|
+
config.sessionScope = probed.scope;
|
|
9345
|
+
if (probed.scope) {
|
|
9346
|
+
log(`each session runs under MemoryMax=${probed.scope.memoryMax} (systemd --${probed.scope.manager} scope)`);
|
|
9347
|
+
} else if (probed.why) {
|
|
9348
|
+
log(`sessionMemoryMax not applied: ${probed.why}`);
|
|
9349
|
+
}
|
|
9350
|
+
await reportWhatARestartKilled(config);
|
|
9351
|
+
const drivingNote = keepTheDrivingNote(config);
|
|
9352
|
+
|
|
9194
9353
|
// What this daemon SAYS it is, on every hello — R52's socket, R288's rule
|
|
9195
9354
|
// that it describes the current state. One object, sent as-is by
|
|
9196
9355
|
// `serveControl` and mutated by the SIGHUP handler below, so a terminal that
|
|
@@ -9478,6 +9637,12 @@ async function main() {
|
|
|
9478
9637
|
}
|
|
9479
9638
|
log('stopping; leaving any live run to the platform’s staleness sweep');
|
|
9480
9639
|
stopping = true;
|
|
9640
|
+
// R339. Said in the note before the sessions go, so the next daemon
|
|
9641
|
+
// tells a stop somebody asked for from a kill nobody did.
|
|
9642
|
+
clearInterval(drivingNote);
|
|
9643
|
+
await writeDriving(drivingPath(config.runnerId),
|
|
9644
|
+
{ ...snapshotOf(process.pid, running), stopped: signal })
|
|
9645
|
+
.catch(() => undefined);
|
|
9481
9646
|
clearInterval(heartbeat);
|
|
9482
9647
|
clearInterval(gitSurvey);
|
|
9483
9648
|
clearInterval(workspaceRequests);
|
package/runner/sign-in.mjs
CHANGED
|
@@ -26,6 +26,9 @@ export class Session {
|
|
|
26
26
|
this.url = String(url).replace(/\/+$/, '');
|
|
27
27
|
this.cookies = new Map();
|
|
28
28
|
this.email = null;
|
|
29
|
+
/** Called once when a signed-in session is answered 401 — expired, or
|
|
30
|
+
* revoked on the platform. The terminal offers `/login` from there. */
|
|
31
|
+
this.onExpired = null;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
get signedIn() {
|
|
@@ -63,6 +66,14 @@ export class Session {
|
|
|
63
66
|
});
|
|
64
67
|
this.#remember(response);
|
|
65
68
|
const text = await response.text();
|
|
69
|
+
if (response.status === 401 && this.email !== null) {
|
|
70
|
+
// The cookie stopped meaning anything: expired, signed out elsewhere, or
|
|
71
|
+
// the account revoked. Until this every action failed one at a time with
|
|
72
|
+
// a bare "HTTP 401" while the footer still named the person — so the
|
|
73
|
+
// state is put right HERE, where every request passes, and said once.
|
|
74
|
+
this.email = null;
|
|
75
|
+
this.onExpired?.();
|
|
76
|
+
}
|
|
66
77
|
if (!response.ok) {
|
|
67
78
|
const failure = new Error(messageIn(text) ?? `${method} ${path} failed: HTTP ${response.status}`);
|
|
68
79
|
failure.status = response.status;
|