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.
- package/dist/src/api/operator.controller.js +27 -0
- package/dist/src/api/operator.service.js +184 -17
- package/dist/src/orchestrator/contract.js +77 -6
- package/dist/src/plan/systemGuide.js +60 -4
- package/dist/src/vcs/taskVcs.js +21 -0
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-bfde82e4a70ed1a0.js +1 -0
- package/dist/web/_next/static/chunks/584-eba7ffe26cf1be83.js +1 -0
- package/dist/web/_next/static/chunks/867-7fccf68b0840c0de.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-d3461384566aa332.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-945be57e397ae162.js +1 -0
- package/dist/web/_next/static/css/cea7913ba416dbb7.css +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +11 -11
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +11 -11
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +11 -11
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +11 -11
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +11 -11
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +11 -11
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +11 -11
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +11 -11
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +11 -11
- package/package.json +3 -2
- package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +0 -1
- package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +0 -1
- package/dist/web/_next/static/chunks/867-d4f5bd8c5805bffb.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-ce99494b1997ee25.js +0 -1
- package/dist/web/_next/static/css/c37978b44194c37b.css +0 -1
- /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → KYi1jz7Awse_66Fg8c7Ur}/_buildManifest.js +0 -0
- /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,
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
2190
|
-
|
|
2191
|
-
|
|
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(
|
|
99
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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',
|
package/dist/src/vcs/taskVcs.js
CHANGED
|
@@ -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) {
|