cawdev-cli 1.0.5-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/runner/runner.mjs CHANGED
@@ -40,13 +40,18 @@ import {
40
40
  } from '../lib/stage-tools.mjs';
41
41
  import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
42
42
  import { findSecret } from '../lib/secrets.mjs';
43
+ import { BackgroundShells } from '../lib/background-shells.mjs';
43
44
  import { cliVersion, isBelow } from '../lib/version.mjs';
44
45
  import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
45
46
  import {
46
47
  createAutoWorkspace, discoverAutoWorkspaces, isAutoWorkspace,
47
48
  } from './auto-workspaces.mjs';
48
- import { agentOfCommand } from '../lib/agents.mjs';
49
+ import { agentOfCommand, needsEverything } from '../lib/agents.mjs';
49
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';
50
55
 
51
56
  // --- configuration -----------------------------------------------------------
52
57
 
@@ -419,6 +424,12 @@ async function readConfig() {
419
424
  * switched on, never on by default.
420
425
  */
421
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,
422
433
  skillCache: file.skillCache ?? DEFAULTS.skillCache,
423
434
  skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
424
435
  idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
@@ -5182,21 +5193,60 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
5182
5193
  * re-derived the plan from a précis would be planning again, which is the one
5183
5194
  * thing the gate exists to stop it doing after somebody approved the first one.
5184
5195
  */
5185
- function stagePrompt(stage, carried) {
5196
+ /** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
5197
+ const CAWDEV_TOOL_TIMEOUT_SEC = 660;
5198
+
5199
+ /**
5200
+ * A decision that is the person's is ASKED, and the stage goes on with the
5201
+ * answer. R19 and R24 (Codex) each met one — a plan the code had moved away
5202
+ * from, a plan whose review left the scope open — and ended the stage saying
5203
+ * so, the run FINISHED with nothing built and the question in a summary
5204
+ * nobody was asked. Claude asked; the wording let Codex stop instead.
5205
+ */
5206
+ const ASK_DONT_STOP = 'If a decision is genuinely the person\'s — a scope the card leaves open, '
5207
+ + 'a plan that no longer matches the code, a trade-off with no right answer — ASK it with '
5208
+ + '`ask_user` (a round of questions if there are several), wait for the answer, and carry on '
5209
+ + 'with it. Never end this stage on an open question: a question left in your report is one '
5210
+ + 'nobody was asked.';
5211
+
5212
+ /**
5213
+ * A stage is ONE turn, and it ends the moment the turn does — so a command left
5214
+ * running in the background is a result lost (background-shells.mjs).
5215
+ */
5216
+ const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every command in the '
5217
+ + 'foreground and wait for it to finish — a build or a test suite left running in the '
5218
+ + 'background is lost, and the stage is run again.';
5219
+
5220
+ /**
5221
+ * @param hasPlan whether the card has a plan of record — the same test
5222
+ * harnessPrompt uses to draw "The plan for this card" (`plan?.body`), so the
5223
+ * instruction and the block it points at cannot disagree
5224
+ */
5225
+ function stagePrompt(stage, carried, hasPlan = true) {
5186
5226
  const asked = {
5187
5227
  PLAN: 'Work out what to do and WRITE THE PLAN. Do not change anything — you have no '
5188
5228
  + 'tool that can, so do not spend turns discovering that. Read what you need, then say, '
5189
5229
  + 'concretely: which files, what change in each, and how you will know it worked. '
5190
5230
  + 'What you report becomes the card\'s plan, and a DIFFERENT session — another '
5191
5231
  + 'process, with none of your context — will be handed it and told to carry it out. '
5192
- + 'Write it for them.',
5232
+ + 'Write it for them — with every decision in it already made. ' + ASK_DONT_STOP,
5193
5233
  VERIFY: 'Check the plan against what is actually there. Does every file it names exist? '
5194
5234
  + 'Does every interface it assumes still look like that? You cannot change anything — '
5195
5235
  + 'say what is wrong with the plan, or say plainly that it holds.',
5196
- IMPLEMENT: 'Do the work in the plan — it is under "The plan for this card" above, '
5197
- + 'written by the plan phase and agreed. If it turns out to be wrong, say so and stop '
5198
- + 'rather than improvising a different change: somebody approved that plan, and a '
5199
- + 'different one has not been approved.',
5236
+ // With a plan, the plan is the task. Without one — `/new dev R21` on a
5237
+ // card nobody planned — the CARD is: the old wording pointed at a plan
5238
+ // section that was not there, and a careful session read "stop rather than
5239
+ // improvise" as "stop", ending R21 with nothing built and nothing to push.
5240
+ IMPLEMENT: (hasPlan
5241
+ ? 'Do the work in the plan — it is under "The plan for this card" above, '
5242
+ + 'written by the plan phase and agreed. Do not quietly improvise a different change: '
5243
+ + 'somebody approved that plan. If it turns out to be wrong or incomplete, ask what to '
5244
+ + 'do instead of stopping — and stop only if the answer is to stop. '
5245
+ : 'This card has no plan, and it does not need one: it was started straight into '
5246
+ + 'development, so the CARD\'s text is the task. Work out what it asks — read the code '
5247
+ + 'it touches first — build it, and commit it on this branch. Never stop because a plan '
5248
+ + 'is missing. ')
5249
+ + ASK_DONT_STOP + ' ' + WAIT_FOR_COMMANDS,
5200
5250
  // R129. Two honest ways to do this stage, and the project chose one. The
5201
5251
  // TESTBOOK wording says "you have no tool that could" for the PLAN
5202
5252
  // prompt's reason: a session that has to discover a refusal spends turns
@@ -5215,7 +5265,8 @@ function stagePrompt(stage, carried) {
5215
5265
  + 'failing test is a result, not a failure of this stage. The branch\'s SonarQube gate '
5216
5266
  + 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
5217
5267
  + 'gate with new vulnerabilities is a finding to report with the output, not a failure '
5218
- + 'to hide.',
5268
+ + 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
5269
+ + 'after this one writes code, and work left uncommitted is work nothing can push.',
5219
5270
  MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
5220
5271
  + 'tried and rejected and why, and anything that turned out to matter and would not be '
5221
5272
  + 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
@@ -6556,8 +6607,23 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
6556
6607
  return { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 };
6557
6608
  };
6558
6609
 
6610
+ const storedRow = (name) => (Array.isArray(stored)
6611
+ ? stored.find((each) => each?.stage === name) : null);
6612
+
6559
6613
  for (let at = 0; at < lifecycle.length; at++) {
6560
6614
  const stage = lifecycle[at];
6615
+ // Where a carried-on run resumes, and what its DONE stages said, is the
6616
+ // platform's (R263, `WorkflowService.resumeFrom`): the claim's lifecycle
6617
+ // starts at the first stage not DONE. What only the daemon knows is WHY
6618
+ // the stage about to run stopped last time — so that is handed to it.
6619
+ const before = storedRow(stage.stage);
6620
+ if (before?.state === 'FAILED' && before.outcome) {
6621
+ // The attempt that stopped, handed to the one about to run.
6622
+ carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
6623
+ + 'This stage is being run again to finish that. Run the commands it left running '
6624
+ + 'again IN THE FOREGROUND — not with run_in_background — wait for each one to end '
6625
+ + 'and read its output, then finish the stage properly.';
6626
+ }
6561
6627
  // R305. What THIS stage's processes have used, across its attempts. Outside
6562
6628
  // the attempt loop on purpose: `run_stage` is one row per stage and keeps
6563
6629
  // the larger reading, so a second attempt after a refused gate must report
@@ -6668,6 +6734,21 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
6668
6734
  return;
6669
6735
  }
6670
6736
 
6737
+ // The turn ended, but with commands it started in the background still
6738
+ // running, or finished and never read. Not done: the result is what the
6739
+ // stage was for. FAILED keeps the checkout and the session (R80), so
6740
+ // /continue picks the run up HERE — the stages behind it stay pending.
6741
+ if (last?.unfinishedShells?.length) {
6742
+ const listed = last.unfinishedShells.map((command) => `\`${command.slice(0, 120)}\``).join(', ');
6743
+ const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) `
6744
+ + `still running in the background, or finished and never read: ${listed}. `
6745
+ + 'Nothing they proved was recorded — /continue runs this stage again and waits for them.';
6746
+ log(` ${why}`);
6747
+ await reportStage(config, run, stage.stage, 'report', { state: 'FAILED', outcome: why });
6748
+ await finish(config, run, 'FAILED', `${why}\n\n${last.text ?? ''}`.trim());
6749
+ return;
6750
+ }
6751
+
6671
6752
  await reportStage(config, run, stage.stage, 'report', {
6672
6753
  state: 'DONE',
6673
6754
  // The PLAN's text IS the plan — the artefact R109 exists to store, and
@@ -6684,6 +6765,15 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
6684
6765
  if (decision.allowed) {
6685
6766
  break;
6686
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
+ }
6687
6777
  if (attempt === 1) {
6688
6778
  const why = `Stopped at the ${stage.stage} gate: ${decision.reason ?? 'refused twice.'}`;
6689
6779
  // The same trail R122 came from: a run that ends here never reaches the
@@ -6787,6 +6877,11 @@ async function awaitGate(config, run, runToken, stage, text) {
6787
6877
  }
6788
6878
 
6789
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.
6790
6885
  const deadline = Date.now() + gateTimeoutSeconds() * 1000;
6791
6886
  while (Date.now() < deadline) {
6792
6887
  const remaining = Math.ceil((deadline - Date.now()) / 1000);
@@ -6798,6 +6893,12 @@ async function awaitGate(config, run, runToken, stage, text) {
6798
6893
  // the transport — a 204 and a `{state: 'PENDING'}` both mean "not yet",
6799
6894
  // and depending on which one the platform chose would be depending on
6800
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
+ }
6801
6902
  if (decision && decision.state && decision.state !== 'PENDING') {
6802
6903
  const allowed = decision.state === 'ALLOWED';
6803
6904
  log(` the ${stage.stage} gate was ${allowed ? 'allowed' : 'refused'}`);
@@ -6808,13 +6909,29 @@ async function awaitGate(config, run, runToken, stage, text) {
6808
6909
  return { allowed: false, reason: `waiting failed (${failure.message})` };
6809
6910
  }
6810
6911
  }
6811
- return { allowed: false, reason: 'nobody answered in time' };
6912
+ return { allowed: false, expired: true, reason: 'nobody answered in time' };
6812
6913
  }
6813
6914
 
6814
- /** 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
+ */
6815
6919
  function gateTimeoutSeconds() {
6816
6920
  const configured = Number(process.env.CAWDEV_GATE_TIMEOUT_SECONDS);
6817
- 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;
6818
6935
  }
6819
6936
 
6820
6937
  async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
@@ -7235,7 +7352,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7235
7352
  log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
7236
7353
 
7237
7354
  return new Promise((resolvePromise) => {
7238
- const child = spawn(agentCmd, args, {
7355
+ const child = spawnScoped(config, agentCmd, args, {
7239
7356
  cwd,
7240
7357
  // Its own process group, so cancelling can take down the whole tree
7241
7358
  // rather than leaving orphaned children behind.
@@ -7300,7 +7417,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7300
7417
  // sending it again is a repeat wearing a resume's clothes — R69's rule about
7301
7418
  // the opening prompt, applied to the thing that now travels with it.
7302
7419
  writeUserMessage(child, resume?.prompt
7303
- ?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
7420
+ ?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : '')));
7304
7421
 
7305
7422
  let lastText = '';
7306
7423
  // R305. This child's own last reading, handed back to the walk so the next
@@ -7326,6 +7443,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7326
7443
  // wait for it, which is exactly the shape that loses it when `endSession`
7327
7444
  // closes stdin below.
7328
7445
  const backgrounded = new Set();
7446
+ // Shell commands this child started in the background and never saw end
7447
+ // — the walk will not call a stage done with any of them left (see
7448
+ // background-shells.mjs).
7449
+ const shells = new BackgroundShells();
7329
7450
  // The last of what the CLI said on stderr, for the exit decision below.
7330
7451
  // The usage-limit notice is written there, and a decision made on stdout
7331
7452
  // alone would call every window a crash.
@@ -7487,6 +7608,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7487
7608
  buffer = buffer.slice(newline + 1);
7488
7609
  if (!line) continue;
7489
7610
  const event = safeJson(line);
7611
+ if (event) shells.observe(event);
7490
7612
 
7491
7613
  // i257. Read off the RAW event, before `linesOf` flattens it to a
7492
7614
  // string: `describeCall` (`../lib/tool-line.mjs`) never carries
@@ -7893,7 +8015,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7893
8015
  // gate on it, and carry it into the next stage. A run with no lifecycle
7894
8016
  // resolves with nothing, exactly as it always did.
7895
8017
  resolvePromise(stage
7896
- ? { code, signal, text: lastText, turnEnded, usage: childTotals }
8018
+ ? { code, signal, text: lastText, turnEnded, usage: childTotals,
8019
+ unfinishedShells: shells.unfinished() }
7897
8020
  : undefined);
7898
8021
  });
7899
8022
  });
@@ -8188,14 +8311,36 @@ function inlinedSkillsFor(skills) {
8188
8311
  * tool a session delegates through — confirmed end-to-end by hand, a probe
8189
8312
  * agent installed, invoked by name, and its answer returned correctly.
8190
8313
  */
8314
+ /** This machine, as a refusal names it: what it is called, where its config and console are. */
8315
+ function thisMachine(config) {
8316
+ return {
8317
+ name: config.name,
8318
+ acceptsConsoleRules: config.acceptsRulesFromConsole === true,
8319
+ configPath: config.configPath ?? null,
8320
+ consoleUrl: config.url,
8321
+ };
8322
+ }
8323
+
8324
+ /**
8325
+ * A refusal said on the run's own transcript, where the terminal and the run
8326
+ * page show it — the run's summary alone left a window reading "failed" and
8327
+ * nothing else, with the reason one page away.
8328
+ */
8329
+ async function sayOnTheRun(config, run, text) {
8330
+ await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/output`, {
8331
+ method: 'POST',
8332
+ body: { lines: text.split('\n').filter((body) => body.trim()).map((body) => ({ kind: 'ERROR', body })) },
8333
+ }).catch((failure) => log(` could not say it on the run: ${failure.message}`));
8334
+ }
8335
+
8191
8336
  async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
8192
8337
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
8193
8338
  agyCmd, granted, usage = null) {
8194
8339
  if (!granted.everything) {
8195
- const summary = 'This machine has not allowed everything, and Antigravity has no narrower '
8196
- + 'permission mode — no per-tool allow-list exists on it, and even reading needs it. Turn '
8197
- + 'on "allow everything" for this machine, or use Claude Code for this run.';
8198
- log(` refusing to spawn Antigravity: ${summary}`);
8340
+ // Antigravity needs it even to read: no per-tool allow-list exists on it.
8341
+ const summary = needsEverything('agy', thisMachine(config), run, { readsWithout: false });
8342
+ log(` refusing to spawn Antigravity: ${summary.split('\n')[0]}`);
8343
+ await sayOnTheRun(config, run, summary);
8199
8344
  await finish(config, run, 'FAILED', summary);
8200
8345
  return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
8201
8346
  }
@@ -8285,7 +8430,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8285
8430
  const promptText = resuming
8286
8431
  ? resume.prompt
8287
8432
  : (promptFor(run) + harness + skillsBlock + expertsBlock
8288
- + (stage ? stagePrompt(stage, carried) : ''));
8433
+ + (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : ''));
8289
8434
 
8290
8435
  const args = ['-p', promptText, '--output-format', 'stream-json', '--dangerously-skip-permissions'];
8291
8436
  if (run.model) {
@@ -8319,7 +8464,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8319
8464
  let childTotals = null;
8320
8465
  let err = '';
8321
8466
  let buffer = '';
8322
- const child = spawn(agyCmd, args, {
8467
+ const child = spawnScoped(config, agyCmd, args, {
8323
8468
  cwd,
8324
8469
  // Its own process group — see the same choice on the Claude Code path.
8325
8470
  detached: true,
@@ -8334,6 +8479,12 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8334
8479
  // Code one does, with no code of its own.
8335
8480
  running.set(run.id, child);
8336
8481
 
8482
+ // What the console can see of the checkout, and the commits as they land —
8483
+ // the same watcher as the Claude Code path. Without it the platform never
8484
+ // hears of this run's commits, so the finish rules (R40) see a run that
8485
+ // wrote nothing and queue no push and no pull request.
8486
+ const workingCopy = watchWorkingCopy(config, run, cwd, baseCommit);
8487
+
8337
8488
  // stream-json arrives in chunks that split mid-line, same as Claude
8338
8489
  // Code's — buffer until a full line rather than assuming one chunk is
8339
8490
  // one event.
@@ -8430,6 +8581,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8430
8581
  });
8431
8582
 
8432
8583
  child.on('error', async (failure) => {
8584
+ workingCopy.stop();
8433
8585
  running.delete(run.id);
8434
8586
  const summary = `Could not spawn Antigravity: ${failure.message}`;
8435
8587
  await transcript.flush();
@@ -8439,6 +8591,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8439
8591
  });
8440
8592
 
8441
8593
  child.on('close', async (code) => {
8594
+ workingCopy.stop();
8442
8595
  running.delete(run.id);
8443
8596
  await transcript.flush();
8444
8597
  const text = result?.response
@@ -8466,6 +8619,10 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8466
8619
  if (!stage) {
8467
8620
  await settleActions(config, run, cwd);
8468
8621
  }
8622
+ // The last reading, as on the Claude Code path: after the agent has
8623
+ // stopped and after anything the rules did.
8624
+ await reportCommits(config, run, cwd, baseCommit);
8625
+
8469
8626
  resolvePromise(stage
8470
8627
  ? { code, signal: null, text, turnEnded, usage: childTotals }
8471
8628
  : undefined);
@@ -8535,17 +8692,18 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8535
8692
  skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
8536
8693
  codexCmd, granted, shield, usage = null) {
8537
8694
  const failedBeforeSpawn = async (summary) => {
8538
- log(` ${summary}`);
8695
+ log(` ${summary.split('\n')[0]}`);
8696
+ await sayOnTheRun(config, run, summary);
8539
8697
  await finish(config, run, 'FAILED', summary);
8540
8698
  return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
8541
8699
  };
8542
8700
 
8543
8701
  const mayWrite = writesCodeProfile(run) && !readOnlyStage;
8544
8702
  if (mayWrite && !granted.everything) {
8545
- return failedBeforeSpawn('This machine has not allowed everything, and a Codex session that '
8546
- + 'writes has no narrower mode — it must commit and push, which its sandbox refuses, and '
8547
- + 'there is no allow-list for its shell. Turn on "allow everything" for this machine, or use '
8548
- + 'Claude Code for this run. (Read-only runs and stages need nothing.)');
8703
+ // It must commit and push, which its sandbox refuses, and there is no
8704
+ // allow-list for its shell — so everything, or nothing. Read-only runs
8705
+ // and stages need nothing.
8706
+ return failedBeforeSpawn(needsEverything('codex', thisMachine(config), run));
8549
8707
  }
8550
8708
  if (mayWrite) {
8551
8709
  log(' !! this machine allows EVERYTHING: this Codex session runs without a sandbox');
@@ -8591,6 +8749,12 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8591
8749
  args: [serverPath],
8592
8750
  envVars: Object.keys(cawdevEnv),
8593
8751
  enabledTools: codexCawdevTools(run, stage, mayWrite),
8752
+ // Codex cuts an MCP call off after 60 s unless told otherwise, and
8753
+ // `ask_user` waits up to ten minutes for a person: a question asked from
8754
+ // Codex died before anybody could answer it, so it did not ask — it
8755
+ // stopped. Longer than ask_user's own wait (mcp/server.mjs), so the
8756
+ // question is answered or handed back as pending, never cut off.
8757
+ toolTimeoutSec: CAWDEV_TOOL_TIMEOUT_SEC,
8594
8758
  });
8595
8759
 
8596
8760
  const cannotDelegate = stageExperts.length
@@ -8612,7 +8776,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8612
8776
  })
8613
8777
  + inlinedSkillsFor(skills)
8614
8778
  + expertsBlockFor([], cannotDelegate)
8615
- + (stage ? stagePrompt(stage, carried) : ''));
8779
+ + (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : ''));
8616
8780
 
8617
8781
  const model = stage?.model ?? run.model;
8618
8782
  const args = codexArgs({
@@ -8639,7 +8803,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8639
8803
  let lastSaid = null;
8640
8804
  let err = '';
8641
8805
  let buffer = '';
8642
- const child = spawn(codexCmd, args, {
8806
+ const child = spawnScoped(config, codexCmd, args, {
8643
8807
  cwd,
8644
8808
  // Its own process group — see the same choice on the Claude Code path.
8645
8809
  detached: true,
@@ -8651,6 +8815,13 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8651
8815
  child.cawdevModel = model ?? null;
8652
8816
  // `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
8653
8817
  running.set(run.id, child);
8818
+
8819
+ // What the console can see of the checkout, and the commits as they land —
8820
+ // the same watcher as the Claude Code path. Without it the platform never
8821
+ // hears of this run's commits, so the finish rules (R40) see a run that
8822
+ // wrote nothing and queue no push and no pull request.
8823
+ const workingCopy = watchWorkingCopy(config, run, cwd, baseCommit);
8824
+
8654
8825
  child.stdin.on('error', () => {
8655
8826
  // A CLI that died before reading its prompt says why on stderr.
8656
8827
  });
@@ -8728,6 +8899,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8728
8899
  });
8729
8900
 
8730
8901
  child.on('error', async (failure) => {
8902
+ workingCopy.stop();
8731
8903
  running.delete(run.id);
8732
8904
  const summary = `Could not spawn Codex: ${failure.message}`;
8733
8905
  await transcript.flush();
@@ -8736,6 +8908,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8736
8908
  });
8737
8909
 
8738
8910
  child.on('close', async (code) => {
8911
+ workingCopy.stop();
8739
8912
  running.delete(run.id);
8740
8913
  await transcript.flush();
8741
8914
  const text = failed ?? lastSaid ?? lastError ?? (err.trim() || 'Codex produced no output.');
@@ -8750,6 +8923,10 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8750
8923
  if (!stage) {
8751
8924
  await settleActions(config, run, cwd);
8752
8925
  }
8926
+ // The last reading, as on the Claude Code path: after the agent has
8927
+ // stopped and after anything the rules did.
8928
+ await reportCommits(config, run, cwd, baseCommit);
8929
+
8753
8930
  resolvePromise(stage
8754
8931
  ? { code, signal: null, text, turnEnded: ended && !failed, usage: childTotals }
8755
8932
  : undefined);
@@ -8808,6 +8985,101 @@ async function finish(config, run, state, summary) {
8808
8985
 
8809
8986
  // --- cancellation ------------------------------------------------------------
8810
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
+
8811
9083
  /**
8812
9084
  * A cancelled run reaches the runner on its next poll, and the child's whole
8813
9085
  * process group goes with it — an agent that spawns a build should not leave it
@@ -9060,6 +9332,24 @@ async function main() {
9060
9332
  // than buried under a session that then fails for a reason it explains.
9061
9333
  await probePermissionPrompt(config);
9062
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
+
9063
9353
  // What this daemon SAYS it is, on every hello — R52's socket, R288's rule
9064
9354
  // that it describes the current state. One object, sent as-is by
9065
9355
  // `serveControl` and mutated by the SIGHUP handler below, so a terminal that
@@ -9347,6 +9637,12 @@ async function main() {
9347
9637
  }
9348
9638
  log('stopping; leaving any live run to the platform’s staleness sweep');
9349
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);
9350
9646
  clearInterval(heartbeat);
9351
9647
  clearInterval(gitSurvey);
9352
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;