cawdev-cli 1.0.6-beta → 1.0.8-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/runner/runner.mjs CHANGED
@@ -48,6 +48,11 @@ 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';
55
+ import { COMMITS_ITS_WORK, settleBranch } from '../lib/finish-check.mjs';
51
56
 
52
57
  // --- configuration -----------------------------------------------------------
53
58
 
@@ -420,6 +425,12 @@ async function readConfig() {
420
425
  * switched on, never on by default.
421
426
  */
422
427
  autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
428
+ /**
429
+ * R339: the memory ceiling each agent session is spawned under, as
430
+ * systemd reads it (`6G`, `75%`) — Linux only, see restart.mjs. Absent is
431
+ * none. Raw here; `main` probes whether this machine can apply it.
432
+ */
433
+ sessionMemoryMax: file.sessionMemoryMax ?? null,
423
434
  skillCache: file.skillCache ?? DEFAULTS.skillCache,
424
435
  skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
425
436
  idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
@@ -6755,13 +6766,22 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
6755
6766
  if (decision.allowed) {
6756
6767
  break;
6757
6768
  }
6769
+ if (decision.expired) {
6770
+ // R339. Unread is not refused: re-running the stage would spend a
6771
+ // second stage on a plan nobody has looked at yet.
6772
+ const why = `Stopped at the ${stage.stage} gate: ${decision.reason}. Nothing was `
6773
+ + 'refused — start the run again when somebody can read it.';
6774
+ await skipRest(config, run, lifecycle, at + 1, why);
6775
+ await finish(config, run, 'FINISHED', why, cwd);
6776
+ return;
6777
+ }
6758
6778
  if (attempt === 1) {
6759
6779
  const why = `Stopped at the ${stage.stage} gate: ${decision.reason ?? 'refused twice.'}`;
6760
6780
  // The same trail R122 came from: a run that ends here never reaches the
6761
6781
  // stages after it, and stages left PENDING on a run that is over read
6762
6782
  // as a lifecycle still going.
6763
6783
  await skipRest(config, run, lifecycle, at + 1, why);
6764
- await finish(config, run, 'FINISHED', why);
6784
+ await finish(config, run, 'FINISHED', why, cwd);
6765
6785
  return;
6766
6786
  }
6767
6787
  // Refused once: the reason is the point of refusing, so it goes into the
@@ -6798,7 +6818,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
6798
6818
  const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
6799
6819
  .catch(() => null);
6800
6820
  if (current?.live) {
6801
- await finish(config, run, 'FINISHED', last?.text ?? 'The lifecycle finished.');
6821
+ await finish(config, run, 'FINISHED', last?.text ?? 'The lifecycle finished.', cwd);
6802
6822
  }
6803
6823
  await settleActions(config, run, cwd);
6804
6824
  }
@@ -6858,6 +6878,11 @@ async function awaitGate(config, run, runToken, stage, text) {
6858
6878
  }
6859
6879
 
6860
6880
  log(` waiting at the ${stage.stage} gate`);
6881
+ // R339: no deadline of its own by default. A gate is a person reading the
6882
+ // plan, the platform never expires one, and a run waiting on it is never
6883
+ // swept — a timer here turned "not read yet" into a refusal and re-ran the
6884
+ // stage, which is spend nobody asked for. An older platform that does
6885
+ // expire it still answers EXPIRED, which is handled below.
6861
6886
  const deadline = Date.now() + gateTimeoutSeconds() * 1000;
6862
6887
  while (Date.now() < deadline) {
6863
6888
  const remaining = Math.ceil((deadline - Date.now()) / 1000);
@@ -6869,6 +6894,12 @@ async function awaitGate(config, run, runToken, stage, text) {
6869
6894
  // the transport — a 204 and a `{state: 'PENDING'}` both mean "not yet",
6870
6895
  // and depending on which one the platform chose would be depending on
6871
6896
  // something nobody promised.
6897
+ if (decision?.state === 'EXPIRED') {
6898
+ // A platform from before R339. Nobody said no, so this is not a
6899
+ // refusal to re-run the stage over: the run stops at the gate.
6900
+ log(` the ${stage.stage} gate expired unread`);
6901
+ return { allowed: false, expired: true, reason: 'nobody read it in time' };
6902
+ }
6872
6903
  if (decision && decision.state && decision.state !== 'PENDING') {
6873
6904
  const allowed = decision.state === 'ALLOWED';
6874
6905
  log(` the ${stage.stage} gate was ${allowed ? 'allowed' : 'refused'}`);
@@ -6879,13 +6910,29 @@ async function awaitGate(config, run, runToken, stage, text) {
6879
6910
  return { allowed: false, reason: `waiting failed (${failure.message})` };
6880
6911
  }
6881
6912
  }
6882
- return { allowed: false, reason: 'nobody answered in time' };
6913
+ return { allowed: false, expired: true, reason: 'nobody answered in time' };
6883
6914
  }
6884
6915
 
6885
- /** A little past the platform's own expiry, like the MCP server's. */
6916
+ /**
6917
+ * How long a gate waits before the daemon gives up on it: for ever, unless a
6918
+ * test says otherwise — R339. The platform decides when nobody is coming.
6919
+ */
6886
6920
  function gateTimeoutSeconds() {
6887
6921
  const configured = Number(process.env.CAWDEV_GATE_TIMEOUT_SECONDS);
6888
- return Number.isFinite(configured) && configured > 0 ? configured : 960;
6922
+ return Number.isFinite(configured) && configured > 0 ? configured : Infinity;
6923
+ }
6924
+
6925
+ /**
6926
+ * An agent session, spawned under the machine's memory ceiling when it has
6927
+ * one — R339. `systemd-run --scope` execs in place, so the pid, the group and
6928
+ * the pipes are the agent's own. `cawdevCommand` is what the next daemon reads
6929
+ * off the driving note to tell this session's orphan from a reused pid.
6930
+ */
6931
+ function spawnScoped(config, command, args, options) {
6932
+ const line = scoped(command, args, config.sessionScope);
6933
+ const child = spawn(line.command, line.args, options);
6934
+ child.cawdevCommand = command;
6935
+ return child;
6889
6936
  }
6890
6937
 
6891
6938
  async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
@@ -7306,7 +7353,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7306
7353
  log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
7307
7354
 
7308
7355
  return new Promise((resolvePromise) => {
7309
- const child = spawn(agentCmd, args, {
7356
+ const child = spawnScoped(config, agentCmd, args, {
7310
7357
  cwd,
7311
7358
  // Its own process group, so cancelling can take down the whole tree
7312
7359
  // rather than leaving orphaned children behind.
@@ -7922,7 +7969,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7922
7969
  const summary = signal
7923
7970
  ? `The agent was terminated (${signal}).`
7924
7971
  : `The agent exited with code ${code} without reporting. ${lastText}`.trim();
7925
- await finish(config, run, code === 0 ? 'FINISHED' : 'FAILED', summary);
7972
+ await finish(config, run, code === 0 ? 'FINISHED' : 'FAILED', summary, cwd);
7926
7973
  }
7927
7974
  // R112. A STAGE ending is not the RUN ending. The loop decides that —
7928
7975
  // it is the only thing that knows whether there is another stage — and
@@ -8418,7 +8465,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8418
8465
  let childTotals = null;
8419
8466
  let err = '';
8420
8467
  let buffer = '';
8421
- const child = spawn(agyCmd, args, {
8468
+ const child = spawnScoped(config, agyCmd, args, {
8422
8469
  cwd,
8423
8470
  // Its own process group — see the same choice on the Claude Code path.
8424
8471
  detached: true,
@@ -8567,7 +8614,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8567
8614
  .catch(() => null);
8568
8615
  if (current?.live && !stage) {
8569
8616
  await finish(config, run, code === 0 && result?.status === 'SUCCESS' ? 'FINISHED' : 'FAILED',
8570
- text);
8617
+ text, cwd);
8571
8618
  }
8572
8619
  // R112: not between stages — see the same line on the Claude Code path.
8573
8620
  if (!stage) {
@@ -8757,7 +8804,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8757
8804
  let lastSaid = null;
8758
8805
  let err = '';
8759
8806
  let buffer = '';
8760
- const child = spawn(codexCmd, args, {
8807
+ const child = spawnScoped(config, codexCmd, args, {
8761
8808
  cwd,
8762
8809
  // Its own process group — see the same choice on the Claude Code path.
8763
8810
  detached: true,
@@ -8871,7 +8918,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8871
8918
  const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
8872
8919
  .catch(() => null);
8873
8920
  if (current?.live && !stage) {
8874
- await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text);
8921
+ await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text, cwd);
8875
8922
  }
8876
8923
  // R112: not between stages — see the same line on the Claude Code path.
8877
8924
  if (!stage) {
@@ -8930,7 +8977,32 @@ function reportStageUsage(config, run, stageName, totals) {
8930
8977
  log(` could not record what the ${stageName} stage used: ${failure.message}`));
8931
8978
  }
8932
8979
 
8933
- async function finish(config, run, state, summary) {
8980
+ /**
8981
+ * Ends a run on the platform.
8982
+ *
8983
+ * R345: a coding run reaches FINISHED only committed and pushed. Given the
8984
+ * checkout, a FINISHED for a profile that commits its work settles the branch
8985
+ * first — commits what the session left (unless it looks like a credential),
8986
+ * pushes without force — and becomes FAILED, with the reason in the summary
8987
+ * and the checkout held for Carry on (R80), when that cannot be done. Before
8988
+ * this, R325's run finished with a file modified and nothing pushed, and the
8989
+ * next run in that checkout refused to start on top of it.
8990
+ */
8991
+ async function finish(config, run, state, summary, cwd = null) {
8992
+ if (state === 'FINISHED' && cwd && run.branch && COMMITS_ITS_WORK.has(run.profile)) {
8993
+ const settled = await settleBranch(cwd, run.branch, {
8994
+ push: pushBranch,
8995
+ label: run.entryRef ?? 'cawdev',
8996
+ }).catch((failure) => ({ ok: false, reason: `Checking the branch failed: ${failure.message}` }));
8997
+ if (!settled.ok) {
8998
+ log(` not finished: ${settled.reason}`);
8999
+ state = 'FAILED';
9000
+ summary = `${settled.reason}\n\n${summary ?? ''}`.trim();
9001
+ } else if (settled.note) {
9002
+ log(` ${settled.note}`);
9003
+ summary = `${summary ?? ''}\n\n${settled.note}`.trim();
9004
+ }
9005
+ }
8934
9006
  await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
8935
9007
  method: 'POST',
8936
9008
  body: { state, summary },
@@ -8939,6 +9011,101 @@ async function finish(config, run, state, summary) {
8939
9011
 
8940
9012
  // --- cancellation ------------------------------------------------------------
8941
9013
 
9014
+ /**
9015
+ * What the daemon before this one was driving when it died — R339.
9016
+ *
9017
+ * Read off the note it kept (restart.mjs), and only when its writer is dead:
9018
+ * a live writer is a second daemon on this runner id. Each run the platform
9019
+ * still believes in is failed at once as DAEMON_RESTARTED, in words that name
9020
+ * the cause — where the stale sweep would have said, five minutes later, that
9021
+ * the runner "stopped driving it". A session the old daemon left running
9022
+ * unattended is stopped first: two sessions writing one checkout is worse
9023
+ * than either.
9024
+ */
9025
+ async function reportWhatARestartKilled(config) {
9026
+ const path = drivingPath(config.runnerId);
9027
+ const previous = await readDriving(path);
9028
+ const behind = leftBehind(previous, process.pid, isAlive);
9029
+ for (const left of behind) {
9030
+ const run = await api(config, `/api/projects/${left.projectSlug}/runs/${left.id}`)
9031
+ .catch((failure) => {
9032
+ log(`could not read ${left.id} after the restart: ${failure.message}`);
9033
+ return null;
9034
+ });
9035
+ if (!killedByRestart(run, config.runnerId)) {
9036
+ continue;
9037
+ }
9038
+ const orphanStopped = await stopOrphan(left);
9039
+ log(`run ${left.id} was being driven when this daemon last died (pid ${previous.pid}); `
9040
+ + 'reporting it');
9041
+ await api(config, `/api/projects/${left.projectSlug}/runs/${left.id}/transition`, {
9042
+ method: 'POST',
9043
+ body: {
9044
+ state: 'FAILED',
9045
+ failureReason: 'DAEMON_RESTARTED',
9046
+ summary: restartSummary({
9047
+ name: config.name, previousPid: previous.pid, pid: process.pid, orphanStopped,
9048
+ stopped: previous.stopped ?? null,
9049
+ }),
9050
+ },
9051
+ }).catch((failure) => log(` could not report it: ${failure.message}`));
9052
+ }
9053
+ await writeDriving(path, snapshotOf(process.pid, running))
9054
+ .catch((failure) => log(`could not write ${path}: ${failure.message}`));
9055
+ }
9056
+
9057
+ /**
9058
+ * The old daemon's session, if it outlived it — and only if the pid still
9059
+ * runs the command it was spawned with, because after a reboot that number
9060
+ * may be somebody else's process.
9061
+ */
9062
+ async function stopOrphan(left) {
9063
+ if (!left.childPid || !left.command || !isAlive(left.childPid)) {
9064
+ return false;
9065
+ }
9066
+ const command = await new Promise((resolveCommand) => {
9067
+ const ps = spawn('ps', ['-o', 'command=', '-p', String(left.childPid)],
9068
+ { stdio: ['ignore', 'pipe', 'ignore'] });
9069
+ let out = '';
9070
+ ps.stdout.on('data', (chunk) => (out += chunk));
9071
+ ps.on('error', () => resolveCommand(''));
9072
+ ps.on('close', () => resolveCommand(out));
9073
+ });
9074
+ if (!command.includes(left.command)) {
9075
+ return false;
9076
+ }
9077
+ try {
9078
+ process.kill(-left.childPid, 'SIGTERM');
9079
+ log(` stopped the session it left running (pid ${left.childPid})`);
9080
+ return true;
9081
+ } catch (failure) {
9082
+ log(` could not stop the session it left running: ${failure.message}`);
9083
+ return false;
9084
+ }
9085
+ }
9086
+
9087
+ /**
9088
+ * Keeps the note current: rewritten whenever the set of runs being driven
9089
+ * changes, checked every second. A second is the window in which a death
9090
+ * goes unreported — and the stale sweep still catches that one.
9091
+ */
9092
+ function keepTheDrivingNote(config) {
9093
+ const path = drivingPath(config.runnerId);
9094
+ let written = null;
9095
+ const timer = setInterval(() => {
9096
+ const snapshot = snapshotOf(process.pid, running);
9097
+ const text = JSON.stringify(snapshot);
9098
+ if (text === written) return;
9099
+ written = text;
9100
+ writeDriving(path, snapshot).catch((failure) => {
9101
+ written = null;
9102
+ log(`could not write ${path}: ${failure.message}`);
9103
+ });
9104
+ }, 1000);
9105
+ timer.unref?.();
9106
+ return timer;
9107
+ }
9108
+
8942
9109
  /**
8943
9110
  * A cancelled run reaches the runner on its next poll, and the child's whole
8944
9111
  * process group goes with it — an agent that spawns a build should not leave it
@@ -9191,6 +9358,24 @@ async function main() {
9191
9358
  // than buried under a session that then fails for a reason it explains.
9192
9359
  await probePermissionPrompt(config);
9193
9360
 
9361
+ // R339. Whether this machine can put each session under a memory ceiling,
9362
+ // and what an earlier daemon on this runner left behind when it died —
9363
+ // both before anything is claimed: a run reported now is one the queue
9364
+ // must not hand the checkout of to somebody else first.
9365
+ const memoryMax = memoryMaxOf(config.sessionMemoryMax);
9366
+ if (config.sessionMemoryMax && !memoryMax) {
9367
+ log(`sessionMemoryMax "${config.sessionMemoryMax}" is not a size systemd reads (6G, 512M, 75%); ignored`);
9368
+ }
9369
+ const probed = await probeScope(memoryMax);
9370
+ config.sessionScope = probed.scope;
9371
+ if (probed.scope) {
9372
+ log(`each session runs under MemoryMax=${probed.scope.memoryMax} (systemd --${probed.scope.manager} scope)`);
9373
+ } else if (probed.why) {
9374
+ log(`sessionMemoryMax not applied: ${probed.why}`);
9375
+ }
9376
+ await reportWhatARestartKilled(config);
9377
+ const drivingNote = keepTheDrivingNote(config);
9378
+
9194
9379
  // What this daemon SAYS it is, on every hello — R52's socket, R288's rule
9195
9380
  // that it describes the current state. One object, sent as-is by
9196
9381
  // `serveControl` and mutated by the SIGHUP handler below, so a terminal that
@@ -9478,6 +9663,12 @@ async function main() {
9478
9663
  }
9479
9664
  log('stopping; leaving any live run to the platform’s staleness sweep');
9480
9665
  stopping = true;
9666
+ // R339. Said in the note before the sessions go, so the next daemon
9667
+ // tells a stop somebody asked for from a kill nobody did.
9668
+ clearInterval(drivingNote);
9669
+ await writeDriving(drivingPath(config.runnerId),
9670
+ { ...snapshotOf(process.pid, running), stopped: signal })
9671
+ .catch(() => undefined);
9481
9672
  clearInterval(heartbeat);
9482
9673
  clearInterval(gitSurvey);
9483
9674
  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;