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 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: report ` +
1656
- `blocked, say exactly what you needed to run and why, and let a person allow it.`,
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
- * Bounded a little beyond the platform's own expiry so the two cannot both be
1810
- * waiting on each other: if the sweep is late, this gives up and denies, which
1811
- * is the same answer the sweep would have produced.
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
- /** A little past the platform's own fifteen-minute expiry. Overridable for tests. */
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 : 960;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.6-beta",
3
+ "version": "1.0.7-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
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. Guessing a
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 fifteen minutes the request expires
1313
- and the session is told to report blocked rather than wait for ever.
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
- console.log(` ${ink.muted('If your browser did not open, go to')}`);
685
- console.log(` ${ink.accent(url)}`);
686
- console.log(` ${ink.muted('and check it is showing')} ${ink.bold(code)}\n`);
687
- console.log(` ${ink.muted('Waiting…')}`);
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
- console.log(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
700
+ say(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
691
701
  } else if (outcome.refused) {
692
- console.log(` ${ink.danger('Refused')} in the browser.\n`);
702
+ say(` ${ink.danger('Refused')} in the browser.\n`);
693
703
  } else {
694
- console.log(` ${ink.warn('The code expired.')}\n`);
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
- line.set(commonPrefix(rows.map(([name]) => name)));
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
- this.leaveSplit();
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
- // Everything queued goes out first and the footer comes down, so what
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
- const fixed = [...from, ...banner, ...overlay, input];
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
- return commands.filter(([name]) => name.slice(1).toLowerCase().startsWith(word));
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
- /** A little past the platform's own expiry, like the MCP server's. */
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 : 960;
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 = spawn(agentCmd, args, {
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 = spawn(agyCmd, args, {
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 = spawn(codexCmd, args, {
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);
@@ -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;