copilot-operator 0.1.38 → 0.1.40

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.
Files changed (55) hide show
  1. package/dist/src/api/operator.controller.js +128 -2
  2. package/dist/src/api/operator.service.js +473 -39
  3. package/dist/src/api/server.js +7 -0
  4. package/dist/src/log/terminal.js +145 -0
  5. package/dist/src/orchestrator/contract.js +77 -6
  6. package/dist/src/plan/brief.js +1375 -1369
  7. package/dist/src/plan/schema.js +5 -0
  8. package/dist/src/plan/systemGuide.js +218 -20
  9. package/dist/src/session/exports.js +1 -0
  10. package/dist/src/session/startFailure.js +91 -0
  11. package/dist/src/vcs/prepareFromRemote.js +7 -20
  12. package/dist/src/vcs/remoteAuth.js +26 -1
  13. package/dist/src/vcs/runPreflight.js +31 -14
  14. package/dist/src/vcs/snapshot.js +3 -1
  15. package/dist/src/vcs/startChoice.js +295 -0
  16. package/dist/src/vcs/taskVcs.js +61 -5
  17. package/dist/web/404.html +2 -2
  18. package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +1 -0
  19. package/dist/web/_next/static/chunks/{549-7e35e4fdefadb05d.js → 549-f295c8429e83b25b.js} +1 -1
  20. package/dist/web/_next/static/chunks/584-578be94a0786334b.js +1 -0
  21. package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +1 -0
  23. package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +1 -0
  24. package/dist/web/_next/static/chunks/app/page-e557d58476ae6383.js +1 -0
  25. package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +1 -0
  26. package/dist/web/_next/static/chunks/app/system/{page-5ae1f8054208e6eb.js → page-227a259e383ca2a0.js} +1 -1
  27. package/dist/web/_next/static/css/cea7913ba416dbb7.css +1 -0
  28. package/dist/web/appearance.html +2 -2
  29. package/dist/web/appearance.txt +28 -26
  30. package/dist/web/defaults.html +2 -2
  31. package/dist/web/defaults.txt +28 -26
  32. package/dist/web/history.html +2 -2
  33. package/dist/web/history.txt +28 -26
  34. package/dist/web/import.html +2 -2
  35. package/dist/web/import.txt +28 -26
  36. package/dist/web/index.html +2 -2
  37. package/dist/web/index.txt +28 -26
  38. package/dist/web/level1.html +2 -2
  39. package/dist/web/level1.txt +28 -26
  40. package/dist/web/presets.html +2 -2
  41. package/dist/web/presets.txt +28 -26
  42. package/dist/web/sessions/view.html +2 -2
  43. package/dist/web/sessions/view.txt +28 -26
  44. package/dist/web/system.html +2 -2
  45. package/dist/web/system.txt +28 -26
  46. package/package.json +158 -156
  47. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +0 -1
  48. package/dist/web/_next/static/chunks/867-d4f5bd8c5805bffb.js +0 -1
  49. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +0 -1
  50. package/dist/web/_next/static/chunks/app/layout-630b663626cebe4a.js +0 -1
  51. package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +0 -1
  52. package/dist/web/_next/static/chunks/app/sessions/view/page-ce99494b1997ee25.js +0 -1
  53. package/dist/web/_next/static/css/c37978b44194c37b.css +0 -1
  54. /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → Y9tuV5LV-O0XyCREkeGc5}/_buildManifest.js +0 -0
  55. /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → Y9tuV5LV-O0XyCREkeGc5}/_ssgManifest.js +0 -0
@@ -29,6 +29,17 @@ import { suggestRunName } from '../session/runName.js';
29
29
  import { buildStory } from '../session/story.js';
30
30
  import { checkPlan } from '../plan/schema.js';
31
31
  import { planBrief } from '../plan/brief.js';
32
+ /**
33
+ * A settings file or a policy lock that cannot be used: answered 409 with its own message on every route
34
+ * (see `server.ts`), so a start passes it on rather than answering it as a start that did not happen.
35
+ */
36
+ function unusableConfig(e) {
37
+ return e instanceof SettingsUnusableError || e instanceof PolicyLockUnusableError;
38
+ }
39
+ /** `20261013-102231` from an ISO time, in this machine's time as the file's contents are, for a file name. */
40
+ function fileStamp(iso) {
41
+ return localTime(iso).replace(/[-:]/g, '').replace(/\..*$/, '').replace(' ', '-');
42
+ }
32
43
  /** Windows paths: case and the slash direction do not make two folders. */
33
44
  function sameFolder(a, b) {
34
45
  const norm = (p) => p.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
@@ -36,8 +47,8 @@ function sameFolder(a, b) {
36
47
  }
37
48
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
38
49
  import { readInterruption } from '../session/interruption.js';
39
- import { branchAt, commitInterrupted, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
40
- import { branchExists, branchNameFrom, changedFilesBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
50
+ import { baseRefOf, branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
51
+ import { branchExists, changedFilesBetween, commitsBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
41
52
  import { makeAuthorizer } from '../exec/authorizer.js';
42
53
  import { pickFolder } from './folderPicker.js';
43
54
  import { findEdgeUsingProfile } from '../transport/profileLock.js';
@@ -48,9 +59,13 @@ import { composeHandoff } from '../session/handoff.js';
48
59
  import { planSync } from '../vcs/syncCommand.js';
49
60
  import { planPrepare, prepareFromRemote } from '../vcs/prepareFromRemote.js';
50
61
  import { askpassProgram } from '../vcs/remoteAuth.js';
51
- import { appendRunLog, appendSessionLog } from '../session/runLog.js';
62
+ import { appendRunLog, appendSessionLog, readRunLog } from '../session/runLog.js';
63
+ import { applyStartChoice, fetchForStart, startOptions, withChoice } from '../vcs/startChoice.js';
64
+ import { StartFailures, failureReport, refOf } from '../session/startFailure.js';
65
+ import { formatTerminal, localTime, terminalLines, terminalLogFile } from '../log/terminal.js';
66
+ import { redactSecrets } from '../exec/redaction.js';
52
67
  import { workingDirFor, isWorkingDirProblem } from '../exec/workDir.js';
53
- import { contractConflicts } from '../orchestrator/contract.js';
68
+ import { contractConflictsWithFixes, contractFixPatch, describeContractChange } from '../orchestrator/contract.js';
54
69
  import { checkRefusalForOperator, lineRefusal } from '../orchestrator/taskRunner.js';
55
70
  import { preferredShell } from '../exec/shells.js';
56
71
  import { sessionRoots } from '../exec/confinement.js';
@@ -62,7 +77,10 @@ import { botVersion } from '../config/version.js';
62
77
  import { computeMetrics } from '../session/metrics.js';
63
78
  import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
64
79
  import { saveAndReveal } from './saveToDesktop.js';
65
- import { settingsOf } from './settings.js';
80
+ import { settingsOf, SettingsUnusableError } from './settings.js';
81
+ import { PolicyLockUnusableError } from '../config/lockedPolicy.js';
82
+ /** Why a session set aside on the Sessions page does not run: one sentence for every start that refuses it. */
83
+ const INACTIVE_REASON = 'the session is inactive; make it active on the Sessions page to run it';
66
84
  /**
67
85
  * Why the browser cannot be had now, said in terms of what was pressed. The sentences a start, a
68
86
  * batch and the model picker gave before there was one rule for all three are kept where they still
@@ -193,6 +211,8 @@ let OperatorService = class OperatorService {
193
211
  settings = settingsOf(this.layout);
194
212
  store = new SessionStore(this.dataDir, join(this.layout.promptsDir, 'level1.md'));
195
213
  bus = new EventBus();
214
+ /** Starts that did not get as far as a first task, for the page to show and offer as a file. */
215
+ startFailures = new StartFailures();
196
216
  running = new Map();
197
217
  waiting = new Map();
198
218
  /** The batch in progress, or the last one that finished. At most one ever exists. */
@@ -431,6 +451,17 @@ let OperatorService = class OperatorService {
431
451
  merged.baseBranch = merged.baseBranch.trim();
432
452
  if (merged.existingBranch !== undefined)
433
453
  merged.existingBranch = merged.existingBranch.trim();
454
+ /*
455
+ * The session page names local branches: a start or a branch changed there is a local one, and
456
+ * the remote the start window chose it from last time no longer applies (see `baseRemote`).
457
+ */
458
+ const pointMoved = (patch.vcs.startFrom !== undefined && patch.vcs.startFrom !== s.vcs?.startFrom) ||
459
+ (patch.vcs.baseBranch !== undefined && patch.vcs.baseBranch.trim() !== (s.vcs?.baseBranch ?? '').trim()) ||
460
+ (patch.vcs.existingBranch !== undefined && patch.vcs.existingBranch.trim() !== (s.vcs?.existingBranch ?? '').trim());
461
+ if (pointMoved && patch.vcs.baseRemote === undefined)
462
+ delete merged.baseRemote;
463
+ if (merged.baseRemote !== undefined && !merged.baseRemote.trim())
464
+ delete merged.baseRemote;
434
465
  // Only the two known fields, and absent when it says what absent says: reject, asking first.
435
466
  const dirty = dirtyPolicy(merged);
436
467
  if (dirty.policy === 'reject')
@@ -477,6 +508,7 @@ let OperatorService = class OperatorService {
477
508
  const startChanged = (merged.startFrom ?? 'head') !== (s.vcs?.startFrom ?? 'head') ||
478
509
  (merged.baseBranch || 'main') !== (s.vcs?.baseBranch || 'main') ||
479
510
  (merged.existingBranch ?? '') !== (s.vcs?.existingBranch ?? '') ||
511
+ (merged.baseRemote ?? '') !== (s.vcs?.baseRemote ?? '') ||
480
512
  (merged.updateFromRemote !== false) !== (s.vcs?.updateFromRemote !== false) ||
481
513
  merged.repoDir.toLowerCase() !== (s.vcs?.repoDir ?? '').trim().toLowerCase();
482
514
  if (startChanged) {
@@ -955,19 +987,47 @@ let OperatorService = class OperatorService {
955
987
  * Starts the session's queued tasks in the background. Returns immediately; progress
956
988
  * arrives on the event stream. One run at a time, of this session or any other.
957
989
  */
958
- async start(sessionId, mode = 'confirm', name) {
990
+ async start(sessionId, mode = 'confirm', name,
991
+ /** Where the session starts, as chosen in the start window: see `vcs/startChoice.ts`. Absent keeps its settings. */
992
+ starts) {
993
+ const pressedAt = new Date().toISOString();
959
994
  // One conversation at a time is not a policy, it is the browser profile: a second run
960
995
  // does not get a second browser, it gets an error about a closed one. Whatever has the
961
996
  // browser now — a batch, another session, a read of the models — the start is refused where
962
997
  // the reason can still be read, rather than three minutes later in a stack trace.
963
998
  const claim = this.claimBrowser({ kind: 'run', sessionId });
964
999
  if ('refused' in claim)
965
- return { started: false, reason: claim.refused };
1000
+ return await this.notStarted({ stage: 'refused', message: claim.refused, sessionIds: [sessionId], pressedAt });
1001
+ let runId;
966
1002
  try {
967
1003
  // A run nobody named is named after what it is a run of, rather than left to show up in the
968
1004
  // register as "Unnamed run" beside five others of the same description.
1005
+ // Set aside on the Sessions page: said first, before the preflight judges a session that will not run.
1006
+ if ((await this.store.getSession(sessionId))?.active === false) {
1007
+ this.releaseBrowser(claim.holder);
1008
+ return await this.notStarted({ stage: 'refused', message: INACTIVE_REASON, sessionIds: [sessionId], pressedAt });
1009
+ }
969
1010
  const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
970
1011
  const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
1012
+ runId = runGroup.id;
1013
+ /*
1014
+ * What refuses this start whatever branch is chosen, asked before the choice moves anything: a local
1015
+ * branch made or brought up to its remote, and the session's settings, stayed changed after a start
1016
+ * refused for an unattended run this machine does not allow (review of 2026-10-13).
1017
+ */
1018
+ const before = await this.store.getSession(sessionId);
1019
+ const earlyRefusal = !before ? 'no such session' : queuedToRun(before).length === 0 ? 'no queued tasks' : await this.unattendedRefusal(mode);
1020
+ if (earlyRefusal) {
1021
+ this.releaseBrowser(claim.holder);
1022
+ // No run log for a session that does not exist: the id is the caller's, and may be anything.
1023
+ return await this.notStarted({ stage: 'refused', message: earlyRefusal, sessionIds: [sessionId], pressedAt, ...(before ? { runId } : {}) });
1024
+ }
1025
+ // The branch the session starts from, as the start window chose it, before the preflight judges it.
1026
+ const unusable = await this.applyStartChoices(runGroup.id, [sessionId], starts);
1027
+ if (unusable) {
1028
+ this.releaseBrowser(claim.holder);
1029
+ return await this.notStarted({ stage: 'branch', message: unusable, sessionIds: [sessionId], pressedAt, runId });
1030
+ }
971
1031
  // A run of one is still a run, and it records the same thing a batch does, so that going
972
1032
  // back and starting again from a task works the same whether one session was started or six.
973
1033
  // Recorded by `beginRun` once the start is accepted, not here: see `recordOnSession`.
@@ -975,12 +1035,12 @@ let OperatorService = class OperatorService {
975
1035
  const notReady = await this.runPreflight(runGroup.id, [sessionId]);
976
1036
  if (notReady) {
977
1037
  this.releaseBrowser(claim.holder);
978
- return { started: false, reason: notReady };
1038
+ return await this.notStarted({ stage: 'preflight', message: notReady, sessionIds: [sessionId], pressedAt, runId });
979
1039
  }
980
- const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true });
1040
+ const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true, pressedAt });
981
1041
  if (!begun.started) {
982
1042
  this.releaseBrowser(claim.holder);
983
- return { started: false, reason: begun.reason };
1043
+ return await this.notStarted({ stage: 'refused', message: begun.reason ?? 'not started', sessionIds: [sessionId], pressedAt, runId });
984
1044
  }
985
1045
  // The window closes before the run's promise settles (see `runSession`), so the browser is
986
1046
  // free again exactly when it is.
@@ -989,9 +1049,157 @@ let OperatorService = class OperatorService {
989
1049
  }
990
1050
  catch (e) {
991
1051
  this.releaseBrowser(claim.holder);
992
- throw e;
1052
+ if (unusableConfig(e))
1053
+ throw e;
1054
+ // Said with the error itself, and on record with the console of those moments, rather than as a bare 500.
1055
+ console.error('[start] the session could not be started:', e.stack ?? e.message);
1056
+ return await this.notStarted({ stage: 'error', message: e.message, sessionIds: [sessionId], pressedAt, runId });
993
1057
  }
994
1058
  }
1059
+ /**
1060
+ * A start that did not happen, put on record (`session/startFailure.ts`) and in the run's log when it
1061
+ * had a run, and said on the console, so the terminal shows it as well. `answered`: the reply to the
1062
+ * press carries it; false for one found after the start was accepted, which every page then shows.
1063
+ */
1064
+ async notStarted(f) {
1065
+ const sessions = [];
1066
+ for (const id of f.sessionIds) {
1067
+ const s = await this.store.getSession(id).catch(() => null);
1068
+ sessions.push({ id, name: s?.name ?? id });
1069
+ }
1070
+ const recorded = this.startFailures.record({
1071
+ pressedAt: f.pressedAt,
1072
+ stage: f.stage,
1073
+ message: f.message,
1074
+ ...(f.runId ? { runId: f.runId } : {}),
1075
+ sessions,
1076
+ answered: f.answered !== false,
1077
+ });
1078
+ console.warn(`[start] the work did not start (${f.stage}${f.runId ? `, run ${f.runId}` : ''}): ${f.message}`);
1079
+ if (f.runId) {
1080
+ const cfg = await this.settings.load().catch(() => null);
1081
+ if (cfg) {
1082
+ await appendRunLog(cfg.resolved.runsDir, f.runId, [
1083
+ { at: recorded.at, type: 'start-failed', message: f.message, data: { stage: f.stage, failure: recorded.id, sessions: f.sessionIds } },
1084
+ ]).catch(() => undefined);
1085
+ }
1086
+ }
1087
+ return { started: false, reason: f.message, failure: refOf(recorded) };
1088
+ }
1089
+ /**
1090
+ * Writes the start window's choices onto the sessions of a run, or says why one cannot be used.
1091
+ *
1092
+ * Every choice is checked first, with nothing moved (`dry`), so a choice that cannot be used for one
1093
+ * session leaves every other session and every branch as it was; then each is written, and a local
1094
+ * branch made from, or brought up to, its remote one where the choice was to work on it. A session
1095
+ * whose start is fixed keeps it. Recorded in the run's log and the session's.
1096
+ */
1097
+ async applyStartChoices(runId, sessionIds, starts) {
1098
+ if (!starts || typeof starts !== 'object')
1099
+ return null;
1100
+ const wanted = sessionIds.filter((id) => starts[id]);
1101
+ if (wanted.length === 0)
1102
+ return null;
1103
+ for (const id of wanted) {
1104
+ const s = await this.store.getSession(id);
1105
+ if (!s)
1106
+ continue;
1107
+ if (this.running.has(id))
1108
+ return `"${s.name}" is running; its start cannot be changed now.`;
1109
+ const checked = await applyStartChoice(s, starts[id], { dry: true });
1110
+ if (!checked.ok)
1111
+ return `"${s.name}": ${checked.problem}`;
1112
+ }
1113
+ const cfg = await this.settings.load();
1114
+ const entries = [];
1115
+ for (const id of wanted) {
1116
+ const s = await this.store.getSession(id);
1117
+ if (!s)
1118
+ continue;
1119
+ const done = await applyStartChoice(s, starts[id]);
1120
+ if (!done.ok)
1121
+ return `"${s.name}": ${done.problem}`;
1122
+ if (!done.applied)
1123
+ continue;
1124
+ const applied = done.applied;
1125
+ await this.store.updateSession(id, (x) => {
1126
+ if (!x.vcs)
1127
+ return;
1128
+ x.vcs = withChoice(x.vcs, applied.vcs);
1129
+ if (applied.clearStart) {
1130
+ x.vcsBaseCommit = undefined;
1131
+ x.vcsStart = undefined;
1132
+ }
1133
+ });
1134
+ const message = `${applied.said}${applied.note ? `; ${applied.note}` : ''}${applied.clearStart ? '; the start prepared before (a starting snapshot) no longer applies' : ''}`;
1135
+ entries.push({ at: new Date().toISOString(), type: 'start-chosen', message, data: { session: id, choice: starts[id], ...(applied.note ? { note: applied.note } : {}) } });
1136
+ this.bus.publish({ sessionId: id, type: 'start-chosen', level: 'info', message, data: { runId, choice: starts[id] } });
1137
+ await appendSessionLog(cfg.resolved.runsDir, id, { type: 'start-chosen', message, data: { runId, choice: starts[id] } }).catch(() => undefined);
1138
+ }
1139
+ await appendRunLog(cfg.resolved.runsDir, runId, entries).catch(() => undefined);
1140
+ return null;
1141
+ }
1142
+ /** The start window's question for these sessions: their starts and the branches of their repositories. */
1143
+ async startChoices(sessionIds) {
1144
+ await this.init();
1145
+ const sessions = [];
1146
+ for (const id of [...new Set(sessionIds.map((x) => x.trim()).filter(Boolean))]) {
1147
+ const s = await this.store.getSession(id);
1148
+ if (s)
1149
+ sessions.push(s);
1150
+ }
1151
+ return await startOptions(sessions);
1152
+ }
1153
+ /**
1154
+ * Fetches branches of a remote for the start window, where the operator is looking — so through Git's
1155
+ * own password window when the remote asks (see `vcs/remoteAuth.ts`). Only for a folder this bot works
1156
+ * in; only remote-tracking branches move.
1157
+ */
1158
+ async startFetch(dir, remote, branches) {
1159
+ await this.init();
1160
+ await this.knownProjectFolder(dir, 'it is not fetched from here');
1161
+ return await fetchForStart(resolve(dir.trim()), remote.trim(), branches, await askpassProgram());
1162
+ }
1163
+ /** The starts that did not get as far as a first task, newest first: see `session/startFailure.ts`. */
1164
+ recentStartFailures(unansweredOnly) {
1165
+ return this.startFailures.recent({ unansweredOnly }).slice(0, 10).map(refOf);
1166
+ }
1167
+ /**
1168
+ * One of them as a file: what failed and where, the run's record, the sessions' events from the press,
1169
+ * and the console lines from the press to a few seconds after — secret-shaped text taken out.
1170
+ */
1171
+ async startFailureReport(id) {
1172
+ await this.init();
1173
+ const f = this.startFailures.get(id);
1174
+ if (!f)
1175
+ throw new Error('That failed start is not known to this process (was the bot restarted since?). Its console is still in the terminal log: System → "Download what the terminal showed".');
1176
+ const cfg = await this.settings.load();
1177
+ const from = new Date(new Date(f.pressedAt).getTime() - 2_000).toISOString();
1178
+ const to = new Date(new Date(f.at).getTime() + 5_000).toISOString();
1179
+ const events = f.sessions.flatMap((s) => this.bus.recent(s.id)).filter((e) => e.at >= from).sort((a, b) => a.at.localeCompare(b.at));
1180
+ const text = failureReport(f, {
1181
+ runLog: await readRunLog(cfg.resolved.runsDir, f.runId),
1182
+ events,
1183
+ terminal: terminalLines({ from, to, max: 2_000 }),
1184
+ terminalFile: terminalLogFile(),
1185
+ version: botVersion(),
1186
+ });
1187
+ return { fileName: `copilot-operator-not-started-${fileStamp(f.at)}.txt`, content: redactSecrets(text, cfg.report.redactPatterns) };
1188
+ }
1189
+ /** What the bot's console showed: the latest lines kept in memory, as a file. See `log/terminal.ts`. */
1190
+ async terminalReport() {
1191
+ const cfg = await this.settings.load();
1192
+ const lines = terminalLines({ max: 5_000 });
1193
+ const file = terminalLogFile();
1194
+ const head = [
1195
+ "copilot-operator — what the terminal showed (the bot's console)",
1196
+ `Version: ${botVersion()}`,
1197
+ `${lines.length} line(s), the latest kept in memory${file ? `; everything since the bot started is in ${file}` : ''}.`,
1198
+ '',
1199
+ '',
1200
+ ].join('\n');
1201
+ return { fileName: `copilot-operator-terminal-${fileStamp(new Date().toISOString())}.txt`, content: redactSecrets(`${head}${formatTerminal(lines)}\n`, cfg.report.redactPatterns) };
1202
+ }
995
1203
  /**
996
1204
  * The same thing `start` does, but it also hands back a promise that settles when the run
997
1205
  * is over and says how it went.
@@ -1017,7 +1225,7 @@ let OperatorService = class OperatorService {
1017
1225
  return { started: false, reason: 'no such session', done: Promise.resolve(idle) };
1018
1226
  // Set aside by the operator on the Sessions page: not started by anything until made active.
1019
1227
  if (session.active === false) {
1020
- return { started: false, reason: 'the session is inactive; make it active on the Sessions page to run it', done: Promise.resolve(idle) };
1228
+ return { started: false, reason: INACTIVE_REASON, done: Promise.resolve(idle) };
1021
1229
  }
1022
1230
  const queuedIds = queuedToRun(session, onlyTasks).map((t) => t.id);
1023
1231
  if (queuedIds.length === 0)
@@ -1056,7 +1264,8 @@ let OperatorService = class OperatorService {
1056
1264
  * nothing else.
1057
1265
  */
1058
1266
  const authorizer = this.webAuthorizer(policy, controller.signal);
1059
- this.running.set(sessionId, { controller, startedAt: new Date().toISOString(), mode, policy });
1267
+ const startedAt = new Date().toISOString();
1268
+ this.running.set(sessionId, { controller, startedAt, mode, policy });
1060
1269
  this.bus.publish({ sessionId, type: 'run-requested', level: 'info', message: `starting in ${mode} mode` });
1061
1270
  // Said at the start of every run, in both modes, because it is the one fact about this tool a
1062
1271
  // data-protection team needs and the one an operator forgets: the reports leave the machine.
@@ -1115,10 +1324,27 @@ let OperatorService = class OperatorService {
1115
1324
  },
1116
1325
  },
1117
1326
  })
1118
- .then(async (outcome) => ({ ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { refused: outcome.refused } : {}) }))
1327
+ .then(async (outcome) => {
1328
+ const tally = { ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { refused: outcome.refused } : {}) };
1329
+ // Refused at its turn in a run that was going: nothing of it began, and nobody was answered with it.
1330
+ if (outcome.refused && tally.ran === 0) {
1331
+ await this.notStarted({ stage: 'at-turn', message: outcome.refused, sessionIds: [sessionId], pressedAt: how.pressedAt ?? startedAt, ...(runGroup ? { runId: runGroup.id } : {}), answered: false });
1332
+ }
1333
+ return tally;
1334
+ })
1119
1335
  .catch(async (e) => {
1120
1336
  this.bus.publish({ sessionId, type: 'run-failed', level: 'error', message: e.message });
1121
- return { ...(await this.tally(sessionId, queuedIds)), error: e.message };
1337
+ const tally = { ...(await this.tally(sessionId, queuedIds)), error: e.message };
1338
+ /*
1339
+ * Failed before any task of it began — the browser, the profile, the sign-in, the model: the start
1340
+ * was answered "started" already, so the failure is shown on every page, with the console (2026-10-13).
1341
+ */
1342
+ // Not when the operator stopped it: that is an answer, not a failure to show.
1343
+ if (tally.ran === 0 && !controller.signal.aborted) {
1344
+ console.error('[start] the run ended before its first task:', e.stack ?? e.message);
1345
+ await this.notStarted({ stage: how.laterInRun ? 'at-turn' : 'run', message: e.message, sessionIds: [sessionId], pressedAt: how.pressedAt ?? startedAt, ...(runGroup ? { runId: runGroup.id } : {}), answered: false });
1346
+ }
1347
+ return tally;
1122
1348
  })
1123
1349
  .finally(() => {
1124
1350
  this.running.delete(sessionId);
@@ -1232,14 +1458,17 @@ let OperatorService = class OperatorService {
1232
1458
  * ticked in the register. A session none of whose chosen tasks is queued is skipped; the
1233
1459
  * tasks nobody chose stay queued exactly as they were. Absent means every queued task.
1234
1460
  */
1235
- taskIds) {
1461
+ taskIds,
1462
+ /** Where each session starts, as chosen in the start window: see `vcs/startChoice.ts`. Absent keeps their settings. */
1463
+ starts) {
1464
+ const pressedAt = new Date().toISOString();
1236
1465
  const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
1237
1466
  // Before anything is waited for (see `browser`): a second batch, a session running on its own
1238
1467
  // — one of these or any other — and a read of the models all have the browser already.
1239
1468
  const claim = this.claimBrowser({ kind: 'batch', sessionIds: wanted });
1240
1469
  if ('refused' in claim)
1241
- return { started: false, reason: claim.refused };
1242
- return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds);
1470
+ return await this.notStarted({ stage: 'refused', message: claim.refused, sessionIds: wanted, pressedAt });
1471
+ return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds, { starts, pressedAt });
1243
1472
  }
1244
1473
  /**
1245
1474
  * `startBatch` for a caller that has claimed the browser already, and the claim with it: handed on
@@ -1247,27 +1476,73 @@ let OperatorService = class OperatorService {
1247
1476
  * refusal. "Run again from here" claims it before it moves anything and starts through this, so
1248
1477
  * one claim covers the restore, the requeue and the run.
1249
1478
  */
1250
- async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds) {
1479
+ async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds, how = {}) {
1251
1480
  const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
1481
+ const pressedAt = how.pressedAt ?? new Date().toISOString();
1252
1482
  let handedOn = false;
1483
+ // The batch's id first, so the preflight is recorded under the run it decides about.
1484
+ const batchId = newId('b-');
1485
+ const refuse = (stage, message, runId) => this.notStarted({ stage, message, sessionIds: wanted, pressedAt, ...(runId ? { runId } : {}) });
1253
1486
  try {
1254
1487
  await this.init();
1255
1488
  if (wanted.length === 0)
1256
- return { started: false, reason: 'no sessions were selected' };
1489
+ return await refuse('refused', 'no sessions were selected');
1257
1490
  // Version control first, before anything opens the browser: see `vcs/runPreflight.ts`.
1258
- // The batch's id first, so the preflight is recorded under the run it decides about.
1259
- const batchId = newId('b-');
1260
- const notReady = await this.runPreflight(batchId, wanted, onlyTasks);
1491
+ /*
1492
+ * Sessions set aside on the Sessions page are left out before the preflight: judged there, an inactive
1493
+ * session's contradiction or version-control problem was the answer to the press — and could refuse
1494
+ * the whole batch — although it would never have run (review of 2026-10-09). `prepareBatch` records
1495
+ * them as skipped, with the reason.
1496
+ */
1497
+ const active = [];
1498
+ const inactive = [];
1499
+ for (const id of wanted) {
1500
+ const s = await this.store.getSession(id);
1501
+ if (s?.active === false)
1502
+ inactive.push(s.name);
1503
+ else
1504
+ active.push(id);
1505
+ }
1506
+ if (active.length === 0)
1507
+ return await refuse('refused', `${inactive.map((n) => `"${n}"`).join(', ')}: ${INACTIVE_REASON}`);
1508
+ /*
1509
+ * What refuses the batch whatever branch is chosen, asked before the choices move anything (review of
1510
+ * 2026-10-13): nothing queued among the chosen tasks, an unattended run this machine does not allow.
1511
+ */
1512
+ let anyQueued = false;
1513
+ for (const id of active) {
1514
+ const s = await this.store.getSession(id);
1515
+ if (s && queuedToRun(s, onlyTasks).length > 0)
1516
+ anyQueued = true;
1517
+ }
1518
+ if (!anyQueued)
1519
+ return await refuse('refused', 'none of the selected sessions has a queued task', batchId);
1520
+ const blockedEarly = await this.unattendedRefusal(mode);
1521
+ if (blockedEarly)
1522
+ return await refuse('refused', blockedEarly, batchId);
1523
+ // The branch each session starts from, as the start window chose it, before the preflight judges it.
1524
+ const unusable = await this.applyStartChoices(batchId, active, how.starts);
1525
+ if (unusable)
1526
+ return await refuse('branch', unusable, batchId);
1527
+ const notReady = await this.runPreflight(batchId, active, onlyTasks);
1261
1528
  if (notReady)
1262
- return { started: false, reason: notReady };
1529
+ return await refuse('preflight', notReady, batchId);
1263
1530
  const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name, batchId);
1264
1531
  if (!begun.batch)
1265
- return { started: false, reason: begun.reason };
1532
+ return await refuse('refused', begun.reason, batchId);
1533
+ begun.batch.pressedAt = pressedAt;
1266
1534
  this.batch = begun.batch;
1267
1535
  handedOn = true;
1268
1536
  void this.runBatch(begun.batch, holder);
1269
1537
  return { started: true, batch: this.batchState() };
1270
1538
  }
1539
+ catch (e) {
1540
+ if (unusableConfig(e))
1541
+ throw e;
1542
+ // Said with the error itself, and on record with the console of those moments, rather than as a bare 400.
1543
+ console.error('[start] the run could not be started:', e.stack ?? e.message);
1544
+ return await refuse('error', e.message, batchId);
1545
+ }
1271
1546
  finally {
1272
1547
  if (!handedOn)
1273
1548
  this.releaseBrowser(holder);
@@ -1306,13 +1581,25 @@ let OperatorService = class OperatorService {
1306
1581
  sessions.push({ sessionId: id, name: id, state: 'skipped', ran: 0, failed: 0, reason: 'no such session' });
1307
1582
  continue;
1308
1583
  }
1584
+ /*
1585
+ * Set aside on the Sessions page: left out here, before the browser opens, with the reason `start`
1586
+ * gives. Let through, the batch said "started", opened the browser, and only then skipped it — a
1587
+ * "Run" that reported success and ran nothing.
1588
+ */
1589
+ if (s.active === false) {
1590
+ sessions.push({ sessionId: id, name: s.name, state: 'skipped', ran: 0, failed: 0, reason: INACTIVE_REASON });
1591
+ continue;
1592
+ }
1309
1593
  const queued = queuedToRun(s, onlyTasks).length;
1310
1594
  sessions.push(queued === 0
1311
1595
  ? { sessionId: id, name: s.name, state: 'skipped', ran: 0, failed: 0, reason: onlyTasks ? 'none of its chosen tasks is queued' : 'nothing queued' }
1312
1596
  : { sessionId: id, name: s.name, state: 'waiting', ran: 0, failed: 0 });
1313
1597
  }
1314
1598
  if (!sessions.some((s) => s.state === 'waiting')) {
1315
- return { reason: 'none of the selected sessions has a queued task' };
1599
+ const inactive = sessions.filter((s) => s.reason === INACTIVE_REASON);
1600
+ return {
1601
+ reason: inactive.length > 0 ? `${inactive.map((s) => `"${s.name}"`).join(', ')}: ${INACTIVE_REASON}` : 'none of the selected sessions has a queued task',
1602
+ };
1316
1603
  }
1317
1604
  /*
1318
1605
  * The same entrance rule `start` has, asked here too and for the same reason: before a browser
@@ -1491,6 +1778,19 @@ let OperatorService = class OperatorService {
1491
1778
  level: 'error',
1492
1779
  message: `the browser could not be opened: ${e.message}`,
1493
1780
  });
1781
+ /*
1782
+ * The run had been accepted, so the press was answered "started": this is shown on every page
1783
+ * until it is closed, with what the console printed (2026-10-13). The stack is printed for it.
1784
+ */
1785
+ console.error('[start] the browser could not be opened:', e.stack ?? e.message);
1786
+ await this.notStarted({
1787
+ stage: 'browser',
1788
+ message: `the browser could not be opened: ${e.message}`,
1789
+ sessionIds: batch.sessions.map((x) => x.sessionId),
1790
+ pressedAt: batch.pressedAt ?? batch.startedAt,
1791
+ runId: batch.id,
1792
+ answered: false,
1793
+ });
1494
1794
  return;
1495
1795
  }
1496
1796
  try {
@@ -1561,6 +1861,7 @@ let OperatorService = class OperatorService {
1561
1861
  // switched the batch with it (see `setRunMode`). The hold is this batch's own.
1562
1862
  const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined, {
1563
1863
  shouldPause: () => batch.pausing,
1864
+ laterInRun: batch.sessions.some((x) => x.ran > 0),
1564
1865
  });
1565
1866
  if (!begun.started) {
1566
1867
  entry.state = 'skipped';
@@ -2152,29 +2453,38 @@ let OperatorService = class OperatorService {
2152
2453
  const next = queuedToRun(session, onlyTasks)[0];
2153
2454
  if (!next)
2154
2455
  return null;
2456
+ const conflicts = await this.contractFor(session, next, afterOthers);
2457
+ return conflicts.length === 0
2458
+ ? null
2459
+ : `The task "${next.title}" of "${session.name}" contradicts itself, so it was not started and stays queued: ${conflicts.map((c) => c.text).join(' ')} Press "Run this task" on it, in the register or on the session's page, and the smallest change that removes it is offered there; or change the prompt, the checks, the scope or read-only on the session's page, and start again.`;
2460
+ }
2461
+ /**
2462
+ * Each way the task contradicts itself, judged as its next attempt would start, with the ways out
2463
+ * (see `ContractFix`). The one judgement behind the refusal at a run's start and behind the fixes
2464
+ * the pages offer, so a fix offered is a fix for the refusal the operator was shown.
2465
+ */
2466
+ async contractFor(session, next, afterOthers = false) {
2155
2467
  const cfg = await this.settings.load();
2156
2468
  const work = workingDirFor(session, cfg.resolved.cwd);
2157
2469
  if (isWorkingDirProblem(work))
2158
- return null;
2470
+ return [];
2159
2471
  /*
2160
2472
  * The branch the task will be on, where that is known before the run: so a check that expects another
2161
2473
  * branch is refused here too, not after the browser opened and a branch was cut (live run 2026-10-04).
2162
2474
  */
2163
2475
  const prefix = session.vcs?.branchPrefix || 'cop/';
2164
- const attempt = next.attempt ?? 1;
2476
+ // By the runner's own rules for the attempt's branch; a task that has run is judged as the attempt it would be queued as.
2165
2477
  const branch = !session.vcs?.enabled
2166
2478
  ? undefined
2167
2479
  : session.vcs.startFrom === 'existing-branch'
2168
2480
  ? session.vcs.existingBranch?.trim() || undefined
2169
2481
  : session.vcs.branchMode === 'per-session'
2170
2482
  ? sessionBranchName(session)
2171
- : next.vcsPlan?.branch?.trim()
2172
- ? plannedBranchName(next.vcsPlan.branch, prefix, attempt)
2173
- : branchNameFrom([session.name, next.title, attempt > 1 ? `a${attempt}` : undefined], prefix);
2483
+ : nextAttemptBranch({ ...session, vcs: { ...session.vcs, branchPrefix: prefix } }, next);
2174
2484
  const files = await this.contractTree(session, afterOthers).catch(() => 'unknown');
2175
2485
  // Input files are carried onto a first start from their last capture: not judged before it (see `contract.ts`).
2176
2486
  const carried = !session.vcsBaseCommit ? inputSettings(session.vcs)?.patterns : undefined;
2177
- const conflicts = await contractConflicts(next, work.cwd, repoDirOf(session) || work.cwd, { branch, files, carried }).catch(() => []);
2487
+ const conflicts = await contractConflictsWithFixes(next, work.cwd, repoDirOf(session) || work.cwd, { branch, files, carried }).catch(() => []);
2178
2488
  // A plan check the runner refuses for its own command line can never run: the task could never pass.
2179
2489
  const roots = sessionRoots(work.cwd, [cfg.project.rootDir, ...cfg.project.others.map((o) => o.rootDir)]);
2180
2490
  const shell = preferredShell(cfg.execution.defaultShell);
@@ -2183,12 +2493,95 @@ let OperatorService = class OperatorService {
2183
2493
  if (!run)
2184
2494
  continue;
2185
2495
  const why = lineRefusal(run, c.shell ?? shell, c.cwd ? resolve(work.cwd, c.cwd) : work.cwd, cfg.execution, roots);
2186
- if (why)
2187
- conflicts.push(`The check "${c.name}" is refused by the runner for its own command line, so it can never run and the task could never pass: ${checkRefusalForOperator(why)}.`);
2496
+ if (why) {
2497
+ conflicts.push({
2498
+ text: `The check "${c.name}" is refused by the runner for its own command line, so it can never run and the task could never pass: ${checkRefusalForOperator(why)}.`,
2499
+ fixes: [{ kind: 'drop-check', check: c.name }],
2500
+ });
2501
+ }
2188
2502
  }
2189
- return conflicts.length === 0
2190
- ? null
2191
- : `The task "${next.title}" of "${session.name}" contradicts itself, so it was not started and stays queued: ${conflicts.join(' ')} Change the prompt, the checks, the scope or read-only on the session's page, and start again.`;
2503
+ return conflicts;
2504
+ }
2505
+ /**
2506
+ * How a task contradicts itself, asked by a page: a task that ended `contract-conflict`, or a queued
2507
+ * one a run refused to start for it. Changes nothing.
2508
+ */
2509
+ async contractCheck(sessionId, taskId) {
2510
+ await this.init();
2511
+ const session = await this.store.getSession(sessionId);
2512
+ if (!session)
2513
+ throw new Error('No such session.');
2514
+ const task = session.tasks.find((t) => t.id === taskId);
2515
+ if (!task)
2516
+ throw new Error('No such task.');
2517
+ return { conflicts: await this.contractFor(session, task) };
2518
+ }
2519
+ /**
2520
+ * Applies the chosen way out of each contradiction and puts the task back in the queue.
2521
+ *
2522
+ * `choices` holds one entry per contradiction `contractCheck` names, in its order: the index of the
2523
+ * fix taken, or -1 to leave that one as it is. Judged again here rather than trusted from the page,
2524
+ * and refused when the contradictions are no longer the ones the operator chose from — an edit in
2525
+ * another tab, a check that passes now. A queued task is edited in place; one that has run is queued
2526
+ * again with the change, its attempt kept on the record as any re-run keeps it. Nothing starts.
2527
+ */
2528
+ async applyContractFix(sessionId, taskId, choices,
2529
+ /** The contradictions the choices were made from, as the page was shown them. */
2530
+ expect) {
2531
+ await this.init();
2532
+ const session = await this.store.getSession(sessionId);
2533
+ if (!session)
2534
+ throw new Error('No such session.');
2535
+ const task = session.tasks.find((t) => t.id === taskId);
2536
+ if (!task)
2537
+ throw new Error('No such task.');
2538
+ if (task.status === 'running' || task.status === 'waiting-approval')
2539
+ throw new Error('This task is running. Stop the session first.');
2540
+ const conflicts = await this.contractFor(session, task);
2541
+ // The same ones, not only as many: a fix chosen for one contradiction must not be applied to another.
2542
+ const same = Array.isArray(expect) ? expect.length === conflicts.length && expect.every((x, i) => x === conflicts[i].text) : true;
2543
+ if (!Array.isArray(choices) || choices.length !== conflicts.length || !same) {
2544
+ throw new Error(`The task's contradictions are not the ones shown (${conflicts.length} now). Look again before applying a fix.`);
2545
+ }
2546
+ const chosen = conflicts.flatMap((c, i) => {
2547
+ const at = choices[i] ?? -1;
2548
+ if (at === -1)
2549
+ return [];
2550
+ const fix = Number.isInteger(at) ? c.fixes[at] : undefined;
2551
+ if (!fix)
2552
+ throw new Error(`There is no fix number ${at} for "${c.text}".`);
2553
+ return [fix];
2554
+ });
2555
+ if (chosen.length === 0)
2556
+ throw new Error('No fix was chosen.');
2557
+ const patch = contractFixPatch(task, chosen);
2558
+ const applied = describeContractChange(task, patch);
2559
+ // Judged on the task as it was read: one edited meanwhile, in another tab, is not overwritten with a patch made from the old one.
2560
+ const contract = (t) => JSON.stringify([t?.status, !!t?.readOnly, t?.scope ?? [], t?.checks ?? []]);
2561
+ const meanwhile = (await this.store.getSession(sessionId))?.tasks.find((t) => t.id === taskId);
2562
+ if (contract(meanwhile) !== contract(task))
2563
+ throw new Error('The task was changed while its contradictions were being judged. Look again before applying a fix.');
2564
+ const updated = task.status === 'queued' ? await this.updateTask(sessionId, taskId, patch) : await this.rerunTask(sessionId, taskId, patch);
2565
+ const after = await this.store.getSession(sessionId);
2566
+ const now = after?.tasks.find((t) => t.id === taskId) ?? updated;
2567
+ const remaining = after ? await this.contractFor(after, now) : [];
2568
+ const cfg = await this.settings.load();
2569
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, {
2570
+ type: 'contract-fixed',
2571
+ message: `"${now.title}": ${applied.join('; ')}${remaining.length > 0 ? `; ${remaining.length} contradiction(s) left` : ''}`,
2572
+ data: { taskId, applied: chosen, remaining: remaining.map((c) => c.text) },
2573
+ }).catch(() => undefined);
2574
+ this.bus.publish({
2575
+ sessionId,
2576
+ taskId,
2577
+ type: 'contract-fixed',
2578
+ level: 'info',
2579
+ message: remaining.length === 0
2580
+ ? `"${now.title}" changed so it no longer contradicts itself: ${applied.join('; ')}`
2581
+ : `"${now.title}" changed (${applied.join('; ')}), and it still contradicts itself in ${remaining.length} way(s)`,
2582
+ data: { applied: chosen, remaining: remaining.length },
2583
+ });
2584
+ return { task: now, applied, remaining };
2192
2585
  }
2193
2586
  /**
2194
2587
  * Where the next task's file checks can be judged before the run: see `ContractTree`. The folder holds
@@ -2218,13 +2611,15 @@ let OperatorService = class OperatorService {
2218
2611
  const branch = how === 'existing-branch' ? session.vcs?.existingBranch?.trim() : session.vcs?.baseBranch?.trim() || 'main';
2219
2612
  if (!branch)
2220
2613
  return 'unknown';
2614
+ // A remote branch chosen in the start window: its remote-tracking branch, as that window fetched it.
2615
+ const ref = how === 'branch' ? baseRefOf(session.vcs).ref : `refs/heads/${branch}`;
2221
2616
  // Brought up to its remote first: what that brings is known only then.
2222
- if (session.vcs?.updateFromRemote !== false) {
2617
+ if (session.vcs?.updateFromRemote !== false && !(how === 'branch' && session.vcs?.baseRemote)) {
2223
2618
  const remotes = await git(repo, ['remote']);
2224
2619
  if (!remotes.ok || remotes.stdout.trim() !== '')
2225
2620
  return 'unknown';
2226
2621
  }
2227
- const tip = await git(repo, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
2622
+ const tip = await git(repo, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
2228
2623
  return tip.ok && tip.stdout ? { ref: tip.stdout } : 'unknown';
2229
2624
  }
2230
2625
  /**
@@ -2508,11 +2903,35 @@ let OperatorService = class OperatorService {
2508
2903
  // Asked of the machine as it is now, as `restartFrom` will ask it; see `unattendedRefused`.
2509
2904
  if (plan.mode === 'unattended')
2510
2905
  plan.unattendedRefused = (await this.unattendedRefusal('unattended')) ?? undefined;
2906
+ // The sessions in the order the run had them: the order `rerunFromRestore` moves them in.
2907
+ const inOrder = [...new Map(tasks.map(({ session: s }) => [s.id, s])).values()];
2511
2908
  for (const target of restoreTargets(tasks)) {
2512
2909
  const preview = await restorePreview(target.session, target.task);
2513
2910
  // What would stop the restore itself, asked here as well, so the dialog says it before the
2514
2911
  // operator agrees rather than after.
2515
2912
  const refused = target.problem ?? (await this.restoreRefusal(target.session));
2913
+ /*
2914
+ * What the restart moves besides the first session's line: each later per-session session in this
2915
+ * repository that ran goes onto a new branch, and its own commits stay on the branch it leaves. By
2916
+ * the conditions `rerunFromRestore` applies when it does it, so the list is what will happen.
2917
+ */
2918
+ const listed = new Set(preview.leftBehind);
2919
+ const here = normaliseDir(target.dir);
2920
+ const laterSessions = [];
2921
+ for (const s of inOrder.slice(inOrder.findIndex((x) => x.id === target.session.id) + 1)) {
2922
+ if (!s.vcs?.enabled || s.vcs.branchMode !== 'per-session' || s.vcs.startFrom === 'existing-branch')
2923
+ continue;
2924
+ if (!s.vcsBaseCommit || normaliseDir(repoDirOf(s)) !== here)
2925
+ continue;
2926
+ const first = tasks.find((x) => x.session.id === s.id && x.task.startedAt)?.task;
2927
+ if (!first)
2928
+ continue;
2929
+ const branch = sessionBranchName(s);
2930
+ const from = (s.vcsStart?.kind === 'branch' ? restorePoint(s, first) : undefined) ?? s.vcsBaseCommit;
2931
+ const commits = (await commitsBetween(target.dir, from, branch).catch(() => [])).filter((c) => !listed.has(c));
2932
+ commits.forEach((c) => listed.add(c));
2933
+ laterSessions.push({ sessionName: s.name, branch, commits });
2934
+ }
2516
2935
  plan.restores.push({
2517
2936
  repoDir: preview.repoDir || target.dir,
2518
2937
  ok: preview.ok && !refused,
@@ -2522,6 +2941,7 @@ let OperatorService = class OperatorService {
2522
2941
  branchName: preview.branchName,
2523
2942
  leftBehind: preview.leftBehind,
2524
2943
  keptOn: preview.keptOn,
2944
+ ...(laterSessions.length > 0 ? { laterSessions } : {}),
2525
2945
  });
2526
2946
  }
2527
2947
  /*
@@ -2534,6 +2954,17 @@ let OperatorService = class OperatorService {
2534
2954
  plan.ok = false;
2535
2955
  plan.problem = refusedRestores.map((r) => `${r.repoDir}: ${r.problem ?? 'the restore cannot be made'}`).join(' ');
2536
2956
  }
2957
+ /*
2958
+ * A session set aside on the Sessions page would not run, and the run is refused at its start for it:
2959
+ * said here, before anything moves, as the other entrance rules are (review of 2026-10-09: the
2960
+ * repositories were taken back and the tasks queued, and only then was the run refused).
2961
+ */
2962
+ const inactive = sessions.filter((s) => s.active === false);
2963
+ if (inactive.length > 0) {
2964
+ plan.ok = false;
2965
+ plan.inactive = true;
2966
+ plan.problem = `${inactive.map((s) => `"${s.name}"`).join(', ')}: ${INACTIVE_REASON}`;
2967
+ }
2537
2968
  return plan;
2538
2969
  }
2539
2970
  /**
@@ -2554,6 +2985,9 @@ let OperatorService = class OperatorService {
2554
2985
  if (this.batch?.running)
2555
2986
  return { ...idle, reason: 'A run is in progress. Stop it first.' };
2556
2987
  const plan = await this.restartPlan(sessionId, taskId);
2988
+ // A session set aside stops it whatever is asked: its run would be refused after everything moved.
2989
+ if (plan.inactive)
2990
+ return { ...idle, reason: plan.problem };
2557
2991
  // A refused restore stops the restart only when the restore is asked for: `restore: false` queues without one.
2558
2992
  const onlyRestoreRefused = plan.tasks.length > 0 && plan.restores.some((r) => !r.ok);
2559
2993
  if (!plan.ok && !(opts.restore === false && onlyRestoreRefused))