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.
- package/dist/src/api/operator.controller.js +128 -2
- package/dist/src/api/operator.service.js +473 -39
- package/dist/src/api/server.js +7 -0
- package/dist/src/log/terminal.js +145 -0
- package/dist/src/orchestrator/contract.js +77 -6
- package/dist/src/plan/brief.js +1375 -1369
- package/dist/src/plan/schema.js +5 -0
- package/dist/src/plan/systemGuide.js +218 -20
- package/dist/src/session/exports.js +1 -0
- package/dist/src/session/startFailure.js +91 -0
- package/dist/src/vcs/prepareFromRemote.js +7 -20
- package/dist/src/vcs/remoteAuth.js +26 -1
- package/dist/src/vcs/runPreflight.js +31 -14
- package/dist/src/vcs/snapshot.js +3 -1
- package/dist/src/vcs/startChoice.js +295 -0
- package/dist/src/vcs/taskVcs.js +61 -5
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +1 -0
- package/dist/web/_next/static/chunks/{549-7e35e4fdefadb05d.js → 549-f295c8429e83b25b.js} +1 -1
- package/dist/web/_next/static/chunks/584-578be94a0786334b.js +1 -0
- package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +1 -0
- package/dist/web/_next/static/chunks/app/page-e557d58476ae6383.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +1 -0
- package/dist/web/_next/static/chunks/app/system/{page-5ae1f8054208e6eb.js → page-227a259e383ca2a0.js} +1 -1
- package/dist/web/_next/static/css/cea7913ba416dbb7.css +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +28 -26
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +28 -26
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +28 -26
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +28 -26
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +28 -26
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +28 -26
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +28 -26
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +28 -26
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +28 -26
- package/package.json +158 -156
- package/dist/web/_next/static/chunks/36-94660f1cde2da529.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/layout-630b663626cebe4a.js +0 -1
- package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.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 → Y9tuV5LV-O0XyCREkeGc5}/_buildManifest.js +0 -0
- /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,
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 {
|
|
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
|
|
1489
|
+
return await refuse('refused', 'no sessions were selected');
|
|
1257
1490
|
// Version control first, before anything opens the browser: see `vcs/runPreflight.ts`.
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
2190
|
-
|
|
2191
|
-
|
|
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',
|
|
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))
|