copilot-operator 0.1.38 → 0.1.39

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 (39) hide show
  1. package/dist/src/api/operator.controller.js +27 -0
  2. package/dist/src/api/operator.service.js +184 -17
  3. package/dist/src/orchestrator/contract.js +77 -6
  4. package/dist/src/plan/systemGuide.js +60 -4
  5. package/dist/src/vcs/taskVcs.js +21 -0
  6. package/dist/web/404.html +2 -2
  7. package/dist/web/_next/static/chunks/549-bfde82e4a70ed1a0.js +1 -0
  8. package/dist/web/_next/static/chunks/584-eba7ffe26cf1be83.js +1 -0
  9. package/dist/web/_next/static/chunks/867-7fccf68b0840c0de.js +1 -0
  10. package/dist/web/_next/static/chunks/app/history/page-d3461384566aa332.js +1 -0
  11. package/dist/web/_next/static/chunks/app/sessions/view/page-945be57e397ae162.js +1 -0
  12. package/dist/web/_next/static/css/cea7913ba416dbb7.css +1 -0
  13. package/dist/web/appearance.html +2 -2
  14. package/dist/web/appearance.txt +11 -11
  15. package/dist/web/defaults.html +2 -2
  16. package/dist/web/defaults.txt +11 -11
  17. package/dist/web/history.html +2 -2
  18. package/dist/web/history.txt +11 -11
  19. package/dist/web/import.html +2 -2
  20. package/dist/web/import.txt +11 -11
  21. package/dist/web/index.html +2 -2
  22. package/dist/web/index.txt +11 -11
  23. package/dist/web/level1.html +2 -2
  24. package/dist/web/level1.txt +11 -11
  25. package/dist/web/presets.html +2 -2
  26. package/dist/web/presets.txt +11 -11
  27. package/dist/web/sessions/view.html +2 -2
  28. package/dist/web/sessions/view.txt +11 -11
  29. package/dist/web/system.html +2 -2
  30. package/dist/web/system.txt +11 -11
  31. package/package.json +3 -2
  32. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +0 -1
  33. package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +0 -1
  34. package/dist/web/_next/static/chunks/867-d4f5bd8c5805bffb.js +0 -1
  35. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +0 -1
  36. package/dist/web/_next/static/chunks/app/sessions/view/page-ce99494b1997ee25.js +0 -1
  37. package/dist/web/_next/static/css/c37978b44194c37b.css +0 -1
  38. /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → KYi1jz7Awse_66Fg8c7Ur}/_buildManifest.js +0 -0
  39. /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → KYi1jz7Awse_66Fg8c7Ur}/_ssgManifest.js +0 -0
@@ -171,6 +171,16 @@ let OperatorController = class OperatorController {
171
171
  const { buildOnFinished, ...patch } = body ?? {};
172
172
  return this.ops.rerunTask(id, taskId, patch, { buildOnFinished: buildOnFinished === true }).catch(fail);
173
173
  }
174
+ /** How the task contradicts itself, with the ways out of each contradiction. Changes nothing. */
175
+ contractCheck(id, taskId) {
176
+ return this.ops.contractCheck(id, taskId).catch(fail);
177
+ }
178
+ /** Applies one chosen fix per contradiction (-1 = none) and queues the task; starts nothing. */
179
+ applyContractFix(id, taskId, body) {
180
+ if (body?.expect !== undefined && (!Array.isArray(body.expect) || body.expect.some((x) => typeof x !== 'string')))
181
+ throw new BadRequestException('expect must be a list of the contradictions shown');
182
+ return this.ops.applyContractFix(id, taskId, body?.choices ?? [], body?.expect).catch(fail);
183
+ }
174
184
  /** What going back to before this task would do. Changes nothing. */
175
185
  restorePreview(id, taskId) {
176
186
  return this.ops.restorePreview(id, taskId).catch(fail);
@@ -738,6 +748,23 @@ __decorate([
738
748
  __metadata("design:paramtypes", [String, String, Object]),
739
749
  __metadata("design:returntype", Promise)
740
750
  ], OperatorController.prototype, "rerunTask", null);
751
+ __decorate([
752
+ Get('sessions/:id/tasks/:taskId/contract'),
753
+ __param(0, Param('id')),
754
+ __param(1, Param('taskId')),
755
+ __metadata("design:type", Function),
756
+ __metadata("design:paramtypes", [String, String]),
757
+ __metadata("design:returntype", Promise)
758
+ ], OperatorController.prototype, "contractCheck", null);
759
+ __decorate([
760
+ Post('sessions/:id/tasks/:taskId/contract-fix'),
761
+ __param(0, Param('id')),
762
+ __param(1, Param('taskId')),
763
+ __param(2, Body()),
764
+ __metadata("design:type", Function),
765
+ __metadata("design:paramtypes", [String, String, Object]),
766
+ __metadata("design:returntype", Promise)
767
+ ], OperatorController.prototype, "applyContractFix", null);
741
768
  __decorate([
742
769
  Get('sessions/:id/tasks/:taskId/restore'),
743
770
  __param(0, Param('id')),
@@ -36,8 +36,8 @@ function sameFolder(a, b) {
36
36
  }
37
37
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
38
38
  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';
39
+ import { branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
40
+ import { branchExists, changedFilesBetween, commitsBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
41
41
  import { makeAuthorizer } from '../exec/authorizer.js';
42
42
  import { pickFolder } from './folderPicker.js';
43
43
  import { findEdgeUsingProfile } from '../transport/profileLock.js';
@@ -50,7 +50,7 @@ import { planPrepare, prepareFromRemote } from '../vcs/prepareFromRemote.js';
50
50
  import { askpassProgram } from '../vcs/remoteAuth.js';
51
51
  import { appendRunLog, appendSessionLog } from '../session/runLog.js';
52
52
  import { workingDirFor, isWorkingDirProblem } from '../exec/workDir.js';
53
- import { contractConflicts } from '../orchestrator/contract.js';
53
+ import { contractConflictsWithFixes, contractFixPatch, describeContractChange } from '../orchestrator/contract.js';
54
54
  import { checkRefusalForOperator, lineRefusal } from '../orchestrator/taskRunner.js';
55
55
  import { preferredShell } from '../exec/shells.js';
56
56
  import { sessionRoots } from '../exec/confinement.js';
@@ -63,6 +63,8 @@ import { computeMetrics } from '../session/metrics.js';
63
63
  import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
64
64
  import { saveAndReveal } from './saveToDesktop.js';
65
65
  import { settingsOf } from './settings.js';
66
+ /** Why a session set aside on the Sessions page does not run: one sentence for every start that refuses it. */
67
+ const INACTIVE_REASON = 'the session is inactive; make it active on the Sessions page to run it';
66
68
  /**
67
69
  * Why the browser cannot be had now, said in terms of what was pressed. The sentences a start, a
68
70
  * batch and the model picker gave before there was one rule for all three are kept where they still
@@ -966,6 +968,11 @@ let OperatorService = class OperatorService {
966
968
  try {
967
969
  // A run nobody named is named after what it is a run of, rather than left to show up in the
968
970
  // register as "Unnamed run" beside five others of the same description.
971
+ // Set aside on the Sessions page: said first, before the preflight judges a session that will not run.
972
+ if ((await this.store.getSession(sessionId))?.active === false) {
973
+ this.releaseBrowser(claim.holder);
974
+ return { started: false, reason: INACTIVE_REASON };
975
+ }
969
976
  const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
970
977
  const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
971
978
  // A run of one is still a run, and it records the same thing a batch does, so that going
@@ -1017,7 +1024,7 @@ let OperatorService = class OperatorService {
1017
1024
  return { started: false, reason: 'no such session', done: Promise.resolve(idle) };
1018
1025
  // Set aside by the operator on the Sessions page: not started by anything until made active.
1019
1026
  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) };
1027
+ return { started: false, reason: INACTIVE_REASON, done: Promise.resolve(idle) };
1021
1028
  }
1022
1029
  const queuedIds = queuedToRun(session, onlyTasks).map((t) => t.id);
1023
1030
  if (queuedIds.length === 0)
@@ -1257,7 +1264,24 @@ let OperatorService = class OperatorService {
1257
1264
  // Version control first, before anything opens the browser: see `vcs/runPreflight.ts`.
1258
1265
  // The batch's id first, so the preflight is recorded under the run it decides about.
1259
1266
  const batchId = newId('b-');
1260
- const notReady = await this.runPreflight(batchId, wanted, onlyTasks);
1267
+ /*
1268
+ * Sessions set aside on the Sessions page are left out before the preflight: judged there, an inactive
1269
+ * session's contradiction or version-control problem was the answer to the press — and could refuse
1270
+ * the whole batch — although it would never have run (review of 2026-10-09). `prepareBatch` records
1271
+ * them as skipped, with the reason.
1272
+ */
1273
+ const active = [];
1274
+ const inactive = [];
1275
+ for (const id of wanted) {
1276
+ const s = await this.store.getSession(id);
1277
+ if (s?.active === false)
1278
+ inactive.push(s.name);
1279
+ else
1280
+ active.push(id);
1281
+ }
1282
+ if (active.length === 0)
1283
+ return { started: false, reason: `${inactive.map((n) => `"${n}"`).join(', ')}: ${INACTIVE_REASON}` };
1284
+ const notReady = await this.runPreflight(batchId, active, onlyTasks);
1261
1285
  if (notReady)
1262
1286
  return { started: false, reason: notReady };
1263
1287
  const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name, batchId);
@@ -1306,13 +1330,25 @@ let OperatorService = class OperatorService {
1306
1330
  sessions.push({ sessionId: id, name: id, state: 'skipped', ran: 0, failed: 0, reason: 'no such session' });
1307
1331
  continue;
1308
1332
  }
1333
+ /*
1334
+ * Set aside on the Sessions page: left out here, before the browser opens, with the reason `start`
1335
+ * gives. Let through, the batch said "started", opened the browser, and only then skipped it — a
1336
+ * "Run" that reported success and ran nothing.
1337
+ */
1338
+ if (s.active === false) {
1339
+ sessions.push({ sessionId: id, name: s.name, state: 'skipped', ran: 0, failed: 0, reason: INACTIVE_REASON });
1340
+ continue;
1341
+ }
1309
1342
  const queued = queuedToRun(s, onlyTasks).length;
1310
1343
  sessions.push(queued === 0
1311
1344
  ? { sessionId: id, name: s.name, state: 'skipped', ran: 0, failed: 0, reason: onlyTasks ? 'none of its chosen tasks is queued' : 'nothing queued' }
1312
1345
  : { sessionId: id, name: s.name, state: 'waiting', ran: 0, failed: 0 });
1313
1346
  }
1314
1347
  if (!sessions.some((s) => s.state === 'waiting')) {
1315
- return { reason: 'none of the selected sessions has a queued task' };
1348
+ const inactive = sessions.filter((s) => s.reason === INACTIVE_REASON);
1349
+ return {
1350
+ reason: inactive.length > 0 ? `${inactive.map((s) => `"${s.name}"`).join(', ')}: ${INACTIVE_REASON}` : 'none of the selected sessions has a queued task',
1351
+ };
1316
1352
  }
1317
1353
  /*
1318
1354
  * The same entrance rule `start` has, asked here too and for the same reason: before a browser
@@ -2152,29 +2188,38 @@ let OperatorService = class OperatorService {
2152
2188
  const next = queuedToRun(session, onlyTasks)[0];
2153
2189
  if (!next)
2154
2190
  return null;
2191
+ const conflicts = await this.contractFor(session, next, afterOthers);
2192
+ return conflicts.length === 0
2193
+ ? null
2194
+ : `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.`;
2195
+ }
2196
+ /**
2197
+ * Each way the task contradicts itself, judged as its next attempt would start, with the ways out
2198
+ * (see `ContractFix`). The one judgement behind the refusal at a run's start and behind the fixes
2199
+ * the pages offer, so a fix offered is a fix for the refusal the operator was shown.
2200
+ */
2201
+ async contractFor(session, next, afterOthers = false) {
2155
2202
  const cfg = await this.settings.load();
2156
2203
  const work = workingDirFor(session, cfg.resolved.cwd);
2157
2204
  if (isWorkingDirProblem(work))
2158
- return null;
2205
+ return [];
2159
2206
  /*
2160
2207
  * The branch the task will be on, where that is known before the run: so a check that expects another
2161
2208
  * branch is refused here too, not after the browser opened and a branch was cut (live run 2026-10-04).
2162
2209
  */
2163
2210
  const prefix = session.vcs?.branchPrefix || 'cop/';
2164
- const attempt = next.attempt ?? 1;
2211
+ // 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
2212
  const branch = !session.vcs?.enabled
2166
2213
  ? undefined
2167
2214
  : session.vcs.startFrom === 'existing-branch'
2168
2215
  ? session.vcs.existingBranch?.trim() || undefined
2169
2216
  : session.vcs.branchMode === 'per-session'
2170
2217
  ? 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);
2218
+ : nextAttemptBranch({ ...session, vcs: { ...session.vcs, branchPrefix: prefix } }, next);
2174
2219
  const files = await this.contractTree(session, afterOthers).catch(() => 'unknown');
2175
2220
  // Input files are carried onto a first start from their last capture: not judged before it (see `contract.ts`).
2176
2221
  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(() => []);
2222
+ const conflicts = await contractConflictsWithFixes(next, work.cwd, repoDirOf(session) || work.cwd, { branch, files, carried }).catch(() => []);
2178
2223
  // A plan check the runner refuses for its own command line can never run: the task could never pass.
2179
2224
  const roots = sessionRoots(work.cwd, [cfg.project.rootDir, ...cfg.project.others.map((o) => o.rootDir)]);
2180
2225
  const shell = preferredShell(cfg.execution.defaultShell);
@@ -2183,12 +2228,95 @@ let OperatorService = class OperatorService {
2183
2228
  if (!run)
2184
2229
  continue;
2185
2230
  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)}.`);
2231
+ if (why) {
2232
+ conflicts.push({
2233
+ 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)}.`,
2234
+ fixes: [{ kind: 'drop-check', check: c.name }],
2235
+ });
2236
+ }
2188
2237
  }
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.`;
2238
+ return conflicts;
2239
+ }
2240
+ /**
2241
+ * How a task contradicts itself, asked by a page: a task that ended `contract-conflict`, or a queued
2242
+ * one a run refused to start for it. Changes nothing.
2243
+ */
2244
+ async contractCheck(sessionId, taskId) {
2245
+ await this.init();
2246
+ const session = await this.store.getSession(sessionId);
2247
+ if (!session)
2248
+ throw new Error('No such session.');
2249
+ const task = session.tasks.find((t) => t.id === taskId);
2250
+ if (!task)
2251
+ throw new Error('No such task.');
2252
+ return { conflicts: await this.contractFor(session, task) };
2253
+ }
2254
+ /**
2255
+ * Applies the chosen way out of each contradiction and puts the task back in the queue.
2256
+ *
2257
+ * `choices` holds one entry per contradiction `contractCheck` names, in its order: the index of the
2258
+ * fix taken, or -1 to leave that one as it is. Judged again here rather than trusted from the page,
2259
+ * and refused when the contradictions are no longer the ones the operator chose from — an edit in
2260
+ * another tab, a check that passes now. A queued task is edited in place; one that has run is queued
2261
+ * again with the change, its attempt kept on the record as any re-run keeps it. Nothing starts.
2262
+ */
2263
+ async applyContractFix(sessionId, taskId, choices,
2264
+ /** The contradictions the choices were made from, as the page was shown them. */
2265
+ expect) {
2266
+ await this.init();
2267
+ const session = await this.store.getSession(sessionId);
2268
+ if (!session)
2269
+ throw new Error('No such session.');
2270
+ const task = session.tasks.find((t) => t.id === taskId);
2271
+ if (!task)
2272
+ throw new Error('No such task.');
2273
+ if (task.status === 'running' || task.status === 'waiting-approval')
2274
+ throw new Error('This task is running. Stop the session first.');
2275
+ const conflicts = await this.contractFor(session, task);
2276
+ // The same ones, not only as many: a fix chosen for one contradiction must not be applied to another.
2277
+ const same = Array.isArray(expect) ? expect.length === conflicts.length && expect.every((x, i) => x === conflicts[i].text) : true;
2278
+ if (!Array.isArray(choices) || choices.length !== conflicts.length || !same) {
2279
+ throw new Error(`The task's contradictions are not the ones shown (${conflicts.length} now). Look again before applying a fix.`);
2280
+ }
2281
+ const chosen = conflicts.flatMap((c, i) => {
2282
+ const at = choices[i] ?? -1;
2283
+ if (at === -1)
2284
+ return [];
2285
+ const fix = Number.isInteger(at) ? c.fixes[at] : undefined;
2286
+ if (!fix)
2287
+ throw new Error(`There is no fix number ${at} for "${c.text}".`);
2288
+ return [fix];
2289
+ });
2290
+ if (chosen.length === 0)
2291
+ throw new Error('No fix was chosen.');
2292
+ const patch = contractFixPatch(task, chosen);
2293
+ const applied = describeContractChange(task, patch);
2294
+ // 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.
2295
+ const contract = (t) => JSON.stringify([t?.status, !!t?.readOnly, t?.scope ?? [], t?.checks ?? []]);
2296
+ const meanwhile = (await this.store.getSession(sessionId))?.tasks.find((t) => t.id === taskId);
2297
+ if (contract(meanwhile) !== contract(task))
2298
+ throw new Error('The task was changed while its contradictions were being judged. Look again before applying a fix.');
2299
+ const updated = task.status === 'queued' ? await this.updateTask(sessionId, taskId, patch) : await this.rerunTask(sessionId, taskId, patch);
2300
+ const after = await this.store.getSession(sessionId);
2301
+ const now = after?.tasks.find((t) => t.id === taskId) ?? updated;
2302
+ const remaining = after ? await this.contractFor(after, now) : [];
2303
+ const cfg = await this.settings.load();
2304
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, {
2305
+ type: 'contract-fixed',
2306
+ message: `"${now.title}": ${applied.join('; ')}${remaining.length > 0 ? `; ${remaining.length} contradiction(s) left` : ''}`,
2307
+ data: { taskId, applied: chosen, remaining: remaining.map((c) => c.text) },
2308
+ }).catch(() => undefined);
2309
+ this.bus.publish({
2310
+ sessionId,
2311
+ taskId,
2312
+ type: 'contract-fixed',
2313
+ level: 'info',
2314
+ message: remaining.length === 0
2315
+ ? `"${now.title}" changed so it no longer contradicts itself: ${applied.join('; ')}`
2316
+ : `"${now.title}" changed (${applied.join('; ')}), and it still contradicts itself in ${remaining.length} way(s)`,
2317
+ data: { applied: chosen, remaining: remaining.length },
2318
+ });
2319
+ return { task: now, applied, remaining };
2192
2320
  }
2193
2321
  /**
2194
2322
  * Where the next task's file checks can be judged before the run: see `ContractTree`. The folder holds
@@ -2508,11 +2636,35 @@ let OperatorService = class OperatorService {
2508
2636
  // Asked of the machine as it is now, as `restartFrom` will ask it; see `unattendedRefused`.
2509
2637
  if (plan.mode === 'unattended')
2510
2638
  plan.unattendedRefused = (await this.unattendedRefusal('unattended')) ?? undefined;
2639
+ // The sessions in the order the run had them: the order `rerunFromRestore` moves them in.
2640
+ const inOrder = [...new Map(tasks.map(({ session: s }) => [s.id, s])).values()];
2511
2641
  for (const target of restoreTargets(tasks)) {
2512
2642
  const preview = await restorePreview(target.session, target.task);
2513
2643
  // What would stop the restore itself, asked here as well, so the dialog says it before the
2514
2644
  // operator agrees rather than after.
2515
2645
  const refused = target.problem ?? (await this.restoreRefusal(target.session));
2646
+ /*
2647
+ * What the restart moves besides the first session's line: each later per-session session in this
2648
+ * repository that ran goes onto a new branch, and its own commits stay on the branch it leaves. By
2649
+ * the conditions `rerunFromRestore` applies when it does it, so the list is what will happen.
2650
+ */
2651
+ const listed = new Set(preview.leftBehind);
2652
+ const here = normaliseDir(target.dir);
2653
+ const laterSessions = [];
2654
+ for (const s of inOrder.slice(inOrder.findIndex((x) => x.id === target.session.id) + 1)) {
2655
+ if (!s.vcs?.enabled || s.vcs.branchMode !== 'per-session' || s.vcs.startFrom === 'existing-branch')
2656
+ continue;
2657
+ if (!s.vcsBaseCommit || normaliseDir(repoDirOf(s)) !== here)
2658
+ continue;
2659
+ const first = tasks.find((x) => x.session.id === s.id && x.task.startedAt)?.task;
2660
+ if (!first)
2661
+ continue;
2662
+ const branch = sessionBranchName(s);
2663
+ const from = (s.vcsStart?.kind === 'branch' ? restorePoint(s, first) : undefined) ?? s.vcsBaseCommit;
2664
+ const commits = (await commitsBetween(target.dir, from, branch).catch(() => [])).filter((c) => !listed.has(c));
2665
+ commits.forEach((c) => listed.add(c));
2666
+ laterSessions.push({ sessionName: s.name, branch, commits });
2667
+ }
2516
2668
  plan.restores.push({
2517
2669
  repoDir: preview.repoDir || target.dir,
2518
2670
  ok: preview.ok && !refused,
@@ -2522,6 +2674,7 @@ let OperatorService = class OperatorService {
2522
2674
  branchName: preview.branchName,
2523
2675
  leftBehind: preview.leftBehind,
2524
2676
  keptOn: preview.keptOn,
2677
+ ...(laterSessions.length > 0 ? { laterSessions } : {}),
2525
2678
  });
2526
2679
  }
2527
2680
  /*
@@ -2534,6 +2687,17 @@ let OperatorService = class OperatorService {
2534
2687
  plan.ok = false;
2535
2688
  plan.problem = refusedRestores.map((r) => `${r.repoDir}: ${r.problem ?? 'the restore cannot be made'}`).join(' ');
2536
2689
  }
2690
+ /*
2691
+ * A session set aside on the Sessions page would not run, and the run is refused at its start for it:
2692
+ * said here, before anything moves, as the other entrance rules are (review of 2026-10-09: the
2693
+ * repositories were taken back and the tasks queued, and only then was the run refused).
2694
+ */
2695
+ const inactive = sessions.filter((s) => s.active === false);
2696
+ if (inactive.length > 0) {
2697
+ plan.ok = false;
2698
+ plan.inactive = true;
2699
+ plan.problem = `${inactive.map((s) => `"${s.name}"`).join(', ')}: ${INACTIVE_REASON}`;
2700
+ }
2537
2701
  return plan;
2538
2702
  }
2539
2703
  /**
@@ -2554,6 +2718,9 @@ let OperatorService = class OperatorService {
2554
2718
  if (this.batch?.running)
2555
2719
  return { ...idle, reason: 'A run is in progress. Stop it first.' };
2556
2720
  const plan = await this.restartPlan(sessionId, taskId);
2721
+ // A session set aside stops it whatever is asked: its run would be refused after everything moved.
2722
+ if (plan.inactive)
2723
+ return { ...idle, reason: plan.problem };
2557
2724
  // A refused restore stops the restart only when the restore is asked for: `restore: false` queues without one.
2558
2725
  const onlyRestoreRefused = plan.tasks.length > 0 && plan.restores.some((r) => !r.ok);
2559
2726
  if (!plan.ok && !(opts.restore === false && onlyRestoreRefused))
@@ -79,11 +79,68 @@ function branchCheckPasses(check, branch) {
79
79
  return null;
80
80
  }
81
81
  /** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
82
- export async function contractConflicts(task, cwd, repoDir,
82
+ export async function contractConflicts(task, cwd, repoDir, vcs = {}) {
83
+ return (await contractConflictsWithFixes(task, cwd, repoDir, vcs)).map((c) => c.text);
84
+ }
85
+ /**
86
+ * The contract fixes applied to the task as it is now, as the patch an edit or a re-run takes. In the
87
+ * order given; a fix naming a check the task no longer has changes nothing. Nothing the operator did not
88
+ * choose is changed: a read-only task keeps its paths unless "drop-scope" was chosen, even though the two
89
+ * contradict each other, because leaving that contradiction was the operator's choice.
90
+ */
91
+ export function contractFixPatch(task, fixes) {
92
+ let readOnly = !!task.readOnly;
93
+ let scope = [...(task.scope ?? [])];
94
+ let checks = [...(task.checks ?? [])];
95
+ for (const fix of fixes) {
96
+ if (fix.kind === 'add-to-scope') {
97
+ if (!scope.includes(fix.path))
98
+ scope.push(fix.path);
99
+ }
100
+ else if (fix.kind === 'not-read-only') {
101
+ readOnly = false;
102
+ for (const p of fix.scope)
103
+ if (!scope.includes(p))
104
+ scope.push(p);
105
+ }
106
+ else if (fix.kind === 'drop-scope') {
107
+ scope = [];
108
+ }
109
+ else if (fix.kind === 'drop-check') {
110
+ checks = checks.filter((c) => c.name !== fix.check);
111
+ }
112
+ }
113
+ return { readOnly, scope, checks };
114
+ }
115
+ /**
116
+ * What changed in the contract, read from before and after rather than from the fixes chosen: two fixes
117
+ * can pull different ways ("drop the paths" for one contradiction, "allow only this file" for another),
118
+ * and their descriptions side by side read as a contradiction; the result does not. For a log line or
119
+ * an event.
120
+ */
121
+ export function describeContractChange(before, after) {
122
+ const out = [];
123
+ if (!!before.readOnly !== !!after.readOnly)
124
+ out.push(after.readOnly ? 'now read-only' : 'no longer read-only');
125
+ const was = (before.scope ?? []).join(', ');
126
+ const now = (after.scope ?? []).join(', ');
127
+ if (was !== now)
128
+ out.push(now ? `may change only ${now}${was ? ` (was ${was})` : ''}` : `its list of paths removed (was ${was})`);
129
+ const kept = new Set((after.checks ?? []).map((c) => c.name));
130
+ for (const c of before.checks ?? [])
131
+ if (!kept.has(c.name))
132
+ out.push(`the check "${c.name}" dropped`);
133
+ return out;
134
+ }
135
+ /** Each contradiction in the task, with the ways out of it; empty when it can be satisfied. */
136
+ export async function contractConflictsWithFixes(task, cwd, repoDir,
83
137
  /** The branch version control put the task on, when it is on; where its file checks are judged (see `ContractTree`). */
84
138
  vcs = {}) {
85
139
  const out = [];
86
140
  const scope = task.scope ?? [];
141
+ // Only a check the task itself carries can be dropped from it; a review's check is that review's finding.
142
+ const own = new Set((task.checks ?? []).map((c) => c.name));
143
+ const dropIfOwn = (check) => (own.has(check.name) ? [{ kind: 'drop-check', check: check.name }] : []);
87
144
  /*
88
145
  * A check that the task is on a branch the runner did not choose can never pass: the chat may not
89
146
  * switch branches. Seen on 2026-09-30, a check still expecting the recovery branch the session had
@@ -95,12 +152,19 @@ vcs = {}) {
95
152
  for (const check of all.filter(readsCurrentBranch)) {
96
153
  if (branchCheckPasses(check, vcs.branch) !== false)
97
154
  continue;
98
- out.push(`The check "${check.name}" expects another branch, but version control put this task on ${vcs.branch}, and the chat may not switch branches. ` +
99
- 'To check that earlier work is included, check its commit instead: git merge-base --is-ancestor <commit> HEAD.');
155
+ out.push({
156
+ text: `The check "${check.name}" expects another branch, but version control put this task on ${vcs.branch}, and the chat may not switch branches. ` +
157
+ 'To check that earlier work is included, check its commit instead: git merge-base --is-ancestor <commit> HEAD.',
158
+ fixes: dropIfOwn(check),
159
+ });
100
160
  }
101
161
  }
102
162
  if (task.readOnly && scope.length > 0) {
103
- out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
163
+ out.push({
164
+ text: `It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`,
165
+ // Read-only already means "nowhere", so dropping the paths changes nothing the task could do.
166
+ fixes: [{ kind: 'drop-scope' }, { kind: 'not-read-only', scope: [...scope] }],
167
+ });
104
168
  }
105
169
  if (!task.readOnly && scope.length === 0)
106
170
  return out;
@@ -125,10 +189,17 @@ vcs = {}) {
125
189
  if (inside && (vcs.carried?.length ?? 0) > 0 && inScope(rel, vcs.carried))
126
190
  continue;
127
191
  if (task.readOnly) {
128
- out.push(`The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`);
192
+ out.push({
193
+ text: `The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`,
194
+ // Allowed to change that one file and nothing else: the least a read-only task can be loosened.
195
+ fixes: [...(inside ? [{ kind: 'not-read-only', scope: [rel] }] : []), ...dropIfOwn(check)],
196
+ });
129
197
  }
130
198
  else if (inside && !inScope(rel, scope)) {
131
- out.push(`The check "${check.name}" fails now and can only pass if ${rel} changes, but the task's scope (${scope.join(', ')}) leaves that file out.`);
199
+ out.push({
200
+ text: `The check "${check.name}" fails now and can only pass if ${rel} changes, but the task's scope (${scope.join(', ')}) leaves that file out.`,
201
+ fixes: [{ kind: 'add-to-scope', path: rel }, ...dropIfOwn(check)],
202
+ });
132
203
  }
133
204
  }
134
205
  return out;
@@ -1587,8 +1587,8 @@ export const SYSTEM_GUIDE = [
1587
1587
  kind: 'button',
1588
1588
  en: 'Run again',
1589
1589
  bg: 'Пусни отново',
1590
- doesEn: 'Puts a finished task back in the queue unchanged, to start afresh; the earlier attempt is kept with its own log. It only queues.',
1591
- doesBg: 'Връща приключила задача в опашката без промяна, да тръгне отначало; предишният опит се пази със собствен дневник. Само я нарежда.',
1590
+ doesEn: 'Puts a finished task back in the queue unchanged, to start afresh; the earlier attempt is kept with its own log. It only queues, then offers "Run this task" on the card.',
1591
+ doesBg: 'Връща приключила задача в опашката без промяна, да тръгне отначало; предишният опит се пази със собствен дневник. Само я нарежда, после предлага „Пусни тази задача“ на картата.',
1592
1592
  },
1593
1593
  {
1594
1594
  key: 'restore.button',
@@ -2300,8 +2300,64 @@ export const SYSTEM_GUIDE = [
2300
2300
  kind: 'button',
2301
2301
  en: 'Fix the prompt and queue it again',
2302
2302
  bg: 'Поправи prompt-а и върни в опашката',
2303
- doesEn: 'On a row that did not end done, its session idle: rewrite the task\'s text and queue it again. It does not start it.',
2304
- doesBg: 'На ред, който не е завършил готов, при спряна сесия: пренапишете текста на задачата и я върнете в опашката. Не я пуска.',
2303
+ doesEn: 'On a row that did not end done, its session idle: rewrite the task\'s text and queue it again. Saving does not start it; the same dialog then asks "Run it now?" with the "Run this task" choices and buttons.',
2304
+ doesBg: 'На ред, който не е завършил готов, при спряна сесия: пренапишете текста на задачата и я върнете в опашката. Запазването не я пуска; после същият прозорец пита „Да я пусна ли сега?“ с изборите и бутоните на „Пусни тази задача“.',
2305
+ },
2306
+ {
2307
+ key: 'runq.later',
2308
+ kind: 'button',
2309
+ en: 'Later',
2310
+ bg: 'По-късно',
2311
+ doesEn: 'On a "Run this task" panel that was offered rather than asked for — after a prompt was saved, a contradiction fixed, or a task queued again on its card: leaves the task waiting in the queue.',
2312
+ doesBg: 'В панела „Пусни тази задача“, когато е предложен, а не поискан — след запазен prompt, поправено противоречие или задача, върната в опашката от картата ѝ: оставя задачата да чака в опашката.',
2313
+ },
2314
+ {
2315
+ key: 'runq.button',
2316
+ kind: 'button',
2317
+ en: 'Run this task',
2318
+ bg: 'Пусни тази задача',
2319
+ doesEn: 'On a waiting (queued) row, its session idle and active: a panel to start that task now — "Only this task", or "This one and the N queued after it" in its session. It lists what runs, says the tasks that already ran are not touched, and names an earlier task of a chain that did not succeed (a warning, not a refusal). Also on a queued task card on the session page.',
2320
+ doesBg: 'На чакащ ред, при спряна и активна сесия: панел за пускане на задачата сега — „Само тази задача“ или „Тази и N чакащи след нея“ в сесията ѝ. Изброява какво ще тръгне, казва, че вече изпълнените задачи не се пипат, и назовава по-ранна задача от верига, която не е минала (предупреждение, не отказ). Има го и на картата на чакаща задача в страницата на сесията.',
2321
+ },
2322
+ {
2323
+ key: 'runq.go',
2324
+ kind: 'button',
2325
+ en: 'Run, asking before each command',
2326
+ bg: 'Пусни, с питане преди всяка команда',
2327
+ doesEn: 'In "Run this task": starts what the panel lists, step by step. A refusal is said in the panel; if the task contradicts itself, its fixes open right there.',
2328
+ doesBg: 'В „Пусни тази задача“: пуска изброеното в панела, стъпка по стъпка. Отказът се казва в панела; ако задачата си противоречи, поправките ѝ се отварят там.',
2329
+ },
2330
+ {
2331
+ key: 'runq.unattended',
2332
+ kind: 'button',
2333
+ en: 'Run without asking',
2334
+ bg: 'Пусни без да пита',
2335
+ doesEn: 'In "Run this task": the same, on its own, without stopping to ask; asks "are you sure" first unless Settings say not to.',
2336
+ doesBg: 'В „Пусни тази задача“: същото, самостоятелно, без да спира да пита; първо пита „сигурни ли сте“, освен ако настройките не казват друго.',
2337
+ },
2338
+ {
2339
+ key: 'cfix.button',
2340
+ kind: 'button',
2341
+ en: 'Fix the contradiction',
2342
+ bg: 'Поправи противоречието',
2343
+ doesEn: 'On a row (a dialog) or task card blocked because it contradicts itself (the chip "task contradicts itself"): lists each contradiction with the ways out, the least change chosen — add the file to the scope, let a read-only task change only that file, drop the scope, or drop one of its own checks. Never changes the prompt. A task a run refused to start for this stays queued: its fixes open inside "Run this task".',
2344
+ doesBg: 'На ред (или карта), блокиран с „задачата си противоречи“: изброява всяко противоречие с изходите от него, като е избрана най-малката промяна — файлът в обхвата, задача само за четене да променя само този файл, без обхват, или без една от собствените ѝ проверки. Prompt-ът никога не се променя.',
2345
+ },
2346
+ {
2347
+ key: 'cfix.apply',
2348
+ kind: 'button',
2349
+ en: 'Apply and queue it again',
2350
+ bg: 'Приложи и върни в опашката',
2351
+ doesEn: 'Applies the chosen fixes, queues the task as a new attempt and offers "Run this task". Nothing starts by itself.',
2352
+ doesBg: 'Прилага избраните поправки, връща задачата в опашката като нов опит и предлага „Пусни тази задача“. Нищо не тръгва само.',
2353
+ },
2354
+ {
2355
+ key: 'cfix.applyQueued',
2356
+ kind: 'button',
2357
+ en: 'Apply',
2358
+ bg: 'Приложи',
2359
+ doesEn: 'The same for a task still waiting (a run refused to start it): edits it where it waits; then start it again from the same panel.',
2360
+ doesBg: 'Същото за задача, която още чака (пускането е отказано): променя я на място; после я пуснете отново от същия панел.',
2305
2361
  },
2306
2362
  {
2307
2363
  key: 'reg.newPrompt',
@@ -598,6 +598,27 @@ export function sessionBranches(session) {
598
598
  function firstAttemptBase(task) {
599
599
  return earliestBase(task);
600
600
  }
601
+ /**
602
+ * The branch a per-task session's task will be put on when its next attempt starts, worked out before the
603
+ * run by the rules `prepareForTask` applies at the start: a continuation, or a new prompt on finished work,
604
+ * goes on the previous attempt's branch; an attempt after one that committed nothing goes on that empty
605
+ * branch again; otherwise the planned or derived name, "-aN" from the second attempt on. A task that has
606
+ * run is judged as the attempt it would be queued as, by a plain re-run (no continuation). The one thing
607
+ * not asked here is whether that empty branch still sits where it was cut, which only the start can see.
608
+ */
609
+ export function nextAttemptBranch(session, task) {
610
+ const prefix = session.vcs?.branchPrefix || 'cop/';
611
+ const ran = task.status !== 'queued';
612
+ const attempt = (task.attempt ?? 1) + (ran ? 1 : 0);
613
+ const prev = ran ? { vcs: task.vcs } : task.attempts?.at(-1);
614
+ const carried = !ran && (task.continuing || task.buildsOn) ? task.attempts?.at(-1)?.vcs?.branch : undefined;
615
+ if (carried)
616
+ return carried;
617
+ if (prev?.vcs?.branch && prev.vcs.baseCommit && !prev.vcs.commit && prev.vcs.baseCommit === firstAttemptBase(task))
618
+ return prev.vcs.branch;
619
+ const planned = task.vcsPlan?.branch?.trim();
620
+ return planned ? plannedBranchName(planned, prefix, attempt) : branchNameFrom([session.name, task.title, attempt > 1 ? `a${attempt}` : undefined], prefix);
621
+ }
601
622
  async function switchTo(session, task, dir, wantedName, from, bus,
602
623
  /** `mustExist`: carry on this branch or nothing; never create it. */
603
624
  opts) {