copilot-operator 0.1.23 → 0.1.25
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 +1 -1
- package/dist/src/api/operator.service.js +326 -33
- package/dist/src/exec/confinement.js +85 -7
- package/dist/src/exec/programs.js +12 -3
- package/dist/src/exec/reportFile.js +5 -3
- package/dist/src/exec/shells.js +13 -2
- package/dist/src/log/runLog.js +6 -1
- package/dist/src/orchestrator/contract.js +29 -7
- package/dist/src/orchestrator/review.js +16 -3
- package/dist/src/orchestrator/taskRunner.js +391 -54
- package/dist/src/plan/schema.js +11 -1
- package/dist/src/plan/systemGuide.js +12 -4
- package/dist/src/protocol/parser.js +33 -5
- package/dist/src/protocol/reporter.js +2 -0
- package/dist/src/session/compose.js +18 -9
- package/dist/src/session/exportRecord.js +12 -3
- package/dist/src/session/exports.js +46 -9
- package/dist/src/session/handoff.js +5 -2
- package/dist/src/session/interruption.js +3 -1
- package/dist/src/session/runLog.js +42 -1
- package/dist/src/session/runName.js +3 -1
- package/dist/src/session/store.js +7 -2
- package/dist/src/transport/copilotTransport.js +89 -62
- package/dist/src/transport/modelMatch.js +37 -0
- package/dist/src/vcs/artifacts.js +20 -7
- package/dist/src/vcs/commitHygiene.js +7 -2
- package/dist/src/vcs/contentIntegrity.js +65 -6
- package/dist/src/vcs/git.js +42 -7
- package/dist/src/vcs/prepareFromRemote.js +21 -14
- package/dist/src/vcs/remoteAuth.js +31 -0
- package/dist/src/vcs/runPreflight.js +100 -31
- package/dist/src/vcs/scope.js +18 -2
- package/dist/src/vcs/snapshot.js +61 -16
- package/dist/src/vcs/syncCommand.js +3 -11
- package/dist/src/vcs/taskVcs.js +173 -24
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +1 -0
- package/dist/web/_next/static/chunks/867-52e371cb1ec49089.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/{page-0e778dafc69afbd3.js → page-62c8fbe84baa436a.js} +1 -1
- package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/{page-9b66b729748595bb.js → page-2d3bbc68b27fed2c.js} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +10 -10
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +10 -10
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +10 -10
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +10 -10
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +10 -10
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +10 -10
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +10 -10
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +10 -10
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +10 -10
- package/package.json +3 -2
- package/prompts/level1.md +10 -3
- package/dist/web/_next/static/chunks/549-df7a4876917615da.js +0 -1
- package/dist/web/_next/static/chunks/867-a67ea8ba3f9b24aa.js +0 -1
- package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +0 -1
- /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → beP1HgHJZvBVRTsoU1sZv}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → beP1HgHJZvBVRTsoU1sZv}/_ssgManifest.js +0 -0
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
14
|
import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
|
15
|
-
import { join } from 'node:path';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
16
|
import { createTransport, isReplyTimeout } from '../transport/chatTransport.js';
|
|
17
17
|
import { buildChatName, chatCode, loadPointer, savePointer } from '../transport/chatSession.js';
|
|
18
18
|
import { parseReply, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
|
|
@@ -34,21 +34,53 @@ import { describeStep, checkCommandRefusal, commandRefusal } from '../exec/polic
|
|
|
34
34
|
import { networkFetchReason } from '../exec/network.js';
|
|
35
35
|
import { collectPolicyManifest, describePolicyManifest } from '../exec/policyManifest.js';
|
|
36
36
|
import { assessIsolation, readIsolationSignals } from '../exec/isolation.js';
|
|
37
|
-
import { confinementRefusal,
|
|
37
|
+
import { confinementRefusal, sessionRoots } from '../exec/confinement.js';
|
|
38
38
|
import { writeReport } from '../exec/reportFile.js';
|
|
39
39
|
import { Pacer } from '../util/pacing.js';
|
|
40
40
|
import { RunLog } from '../log/runLog.js';
|
|
41
|
-
import { composeOpening,
|
|
41
|
+
import { composeOpening, readOnlyNote } from '../session/compose.js';
|
|
42
42
|
import { MIN_TRIED_APPROACHES } from '../protocol/replySchema.js';
|
|
43
43
|
import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
|
|
44
44
|
import { composeHandoff } from '../session/handoff.js';
|
|
45
45
|
import { ProgressWatch } from './progress.js';
|
|
46
46
|
import { contractConflicts, readsTreeClean } from './contract.js';
|
|
47
47
|
import { treeFingerprint } from '../vcs/git.js';
|
|
48
|
-
import { prepareForTask, commitTaskResult, commitShortfall, repoDirOf, trackedRepoOf } from '../vcs/taskVcs.js';
|
|
49
|
-
import { protectInputs, inputsMessage, inputsAtCommit, untrackedInputs } from '../vcs/inputs.js';
|
|
48
|
+
import { prepareForTask, commitTaskResult, commitShortfall, repoDirOf, trackedRepoOf, whereLeft } from '../vcs/taskVcs.js';
|
|
49
|
+
import { protectInputs, inputsMessage, inputsAtCommit, untrackedInputs, blobOfFile } from '../vcs/inputs.js';
|
|
50
50
|
import { artifactPatterns, artifactState, keepArtifacts } from '../vcs/artifacts.js';
|
|
51
51
|
import { exportMachine, writeAttemptRecord } from '../session/exports.js';
|
|
52
|
+
/**
|
|
53
|
+
* Causes no conversation can change: the task contradicts itself, the machine lacks what a step needs,
|
|
54
|
+
* a check can never run. A fresh chat is no cure for these, and retrying them in one only repeats the
|
|
55
|
+
* verdict with empty branches and conversations behind it (seen live on 2026-10-03: a contract
|
|
56
|
+
* conflict decided before any message was retried twice in fresh chats).
|
|
57
|
+
*/
|
|
58
|
+
export const NOT_THE_CHATS = ['contract-conflict', 'environment', 'invalid-check'];
|
|
59
|
+
/**
|
|
60
|
+
* Records a model the page offers under a new name where it was chosen — the session, or Settings — and
|
|
61
|
+
* the line-up read on the way. Returns the sentence that says so, or ''.
|
|
62
|
+
*/
|
|
63
|
+
async function followPageModels(which, wanted, result, session, store, hooks) {
|
|
64
|
+
if (result.options?.length)
|
|
65
|
+
await hooks?.seen?.(result.options, result.current).catch(() => undefined);
|
|
66
|
+
if (!result.ok || !result.matched)
|
|
67
|
+
return '';
|
|
68
|
+
const own = which === 'model' ? session.model?.trim() : session.review?.model?.trim();
|
|
69
|
+
if (own) {
|
|
70
|
+
await store
|
|
71
|
+
?.updateSession(session.id, (s) => {
|
|
72
|
+
if (which === 'model')
|
|
73
|
+
s.model = result.matched;
|
|
74
|
+
else
|
|
75
|
+
s.review = { ...(s.review ?? {}), model: result.matched };
|
|
76
|
+
})
|
|
77
|
+
.catch(() => undefined);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
await hooks?.renamedDefault?.(which, wanted, result.matched).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
return `"${wanted}" is no longer offered under that name; the page offers it as "${result.matched}", which was chosen, and ${own ? "the session's" : 'Settings\''} ${which === 'model' ? 'model' : 'review model'} now says so`;
|
|
83
|
+
}
|
|
52
84
|
/** Why a task that was reported as done is being closed as failed. */
|
|
53
85
|
function checksFailedReason(outcomes) {
|
|
54
86
|
const failed = outcomes.filter((o) => !o.passed);
|
|
@@ -72,7 +104,20 @@ const PACKAGE_RUNNER = /\b(?:npx|pnpx|bunx)(?:\.cmd|\.exe)?\s|\b(?:pnpm|yarn)(?:
|
|
|
72
104
|
* left to them whatever else it fetches: ending a task on a check the work could still have made
|
|
73
105
|
* run is the worse mistake of the two.
|
|
74
106
|
*/
|
|
75
|
-
|
|
107
|
+
/**
|
|
108
|
+
* A check's refusal in the operator's words. The gate's reasons are written for the chat about a step —
|
|
109
|
+
* "this step waits for the operator to allow it" — which is untrue of a check: checks run with nobody
|
|
110
|
+
* asked, so a refused one can never run (live run 2026-10-03, copied into records and a commit body).
|
|
111
|
+
*/
|
|
112
|
+
export function checkRefusalForOperator(why) {
|
|
113
|
+
const text = why.replace(/^refused: /, '');
|
|
114
|
+
if (/fetches from the network/i.test(text)) {
|
|
115
|
+
const what = /\(([^)]+)\)/.exec(text)?.[1];
|
|
116
|
+
return `it fetches from the network${what ? ` (${what})` : ''}; checks run without anyone being asked, so the runner never runs it — change or remove the check`;
|
|
117
|
+
}
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
export function lineRefusal(command, shell, cwd, cfg, roots) {
|
|
76
121
|
return (commandRefusal(command, shell, cfg.denyPatterns, cfg.allowedPrograms) ??
|
|
77
122
|
confinementRefusal(command, { roots, cwd }) ??
|
|
78
123
|
(PACKAGE_RUNNER.test(command) ? null : networkFetchReason(command, cwd)));
|
|
@@ -96,13 +141,32 @@ function shellProblemReason(problem) {
|
|
|
96
141
|
* as an instruction rather than a complaint: the useful half of a refusal is what to do next.
|
|
97
142
|
*/
|
|
98
143
|
function repeatRefusal(count, limit) {
|
|
99
|
-
return (`this exact command has already run ${count} time(s) in
|
|
100
|
-
`
|
|
101
|
-
'tell you anything new, so it was not run. Do something different in kind: a different ' +
|
|
144
|
+
return (`this exact command has already run ${count} time(s) in this task, each time with the same result, and ` +
|
|
145
|
+
`nothing in the working tree has changed since it last ran, which is the limit (maxCommandRepeats ${limit}). ` +
|
|
146
|
+
'Running it again cannot tell you anything new, so it was not run. Do something different in kind: a different ' +
|
|
102
147
|
'command, a different tool, a different way round the problem, or read something you have ' +
|
|
103
148
|
'not read yet. If you have genuinely run out of approaches, end the task with status ' +
|
|
104
149
|
'"blocked" and list in "tried" the different things you attempted.');
|
|
105
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* The working tree's uncommitted paths and what each holds, as git would hash it ('-' for a deleted file).
|
|
153
|
+
*
|
|
154
|
+
* The read-only verdict and the scope used to judge the tree as it is at the end, which is wrong both
|
|
155
|
+
* ways (live run 2026-10-03): it blamed a read-only task for files another session had left before it
|
|
156
|
+
* started, and for a report a plan's own check wrote at the gate. What a task changed is the difference
|
|
157
|
+
* between two of these: at its start and just before its checks.
|
|
158
|
+
*/
|
|
159
|
+
async function treeState(dir) {
|
|
160
|
+
const out = new Map();
|
|
161
|
+
for (const path of await workingTreePaths(dir).catch(() => []))
|
|
162
|
+
out.set(path, (await blobOfFile(dir, path)) ?? '-');
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/** The paths whose content differs between two tree states. */
|
|
166
|
+
function changedBetween(before, after) {
|
|
167
|
+
const paths = new Set([...before.keys(), ...after.keys()]);
|
|
168
|
+
return [...paths].filter((p) => (before.get(p) ?? 'committed') !== (after.get(p) ?? 'committed')).sort();
|
|
169
|
+
}
|
|
106
170
|
/**
|
|
107
171
|
* The earlier tasks a reviewer is shown, when tasks build on each other.
|
|
108
172
|
*
|
|
@@ -156,8 +220,9 @@ function blockedReason(tried, needed) {
|
|
|
156
220
|
* hear who declined it: the runner (`refused`: a rule, so rewrite the step) or the operator
|
|
157
221
|
* (`aborted`: a person stopped or skipped it).
|
|
158
222
|
*/
|
|
159
|
-
function refusedResult(step, reason, by = 'runner') {
|
|
223
|
+
function refusedResult(step, reason, by = 'runner', skippedAfter) {
|
|
160
224
|
return {
|
|
225
|
+
...(skippedAfter !== undefined ? { skippedAfter } : {}),
|
|
161
226
|
id: step.id,
|
|
162
227
|
shell: effectiveShell(step.shell),
|
|
163
228
|
command: describeStep(step),
|
|
@@ -206,9 +271,17 @@ class Sink {
|
|
|
206
271
|
this.sessionId = sessionId;
|
|
207
272
|
this.taskId = taskId;
|
|
208
273
|
}
|
|
274
|
+
/** True while this sink is publishing, so the mirror below does not write its own events twice. */
|
|
275
|
+
publishing = false;
|
|
209
276
|
event(type, data = {}, human, level = 'info') {
|
|
210
277
|
this.log.event(type, data, human, level);
|
|
211
|
-
this.
|
|
278
|
+
this.publishing = true;
|
|
279
|
+
try {
|
|
280
|
+
this.bus.publish({ sessionId: this.sessionId, taskId: this.taskId, type, level, message: human, data });
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
this.publishing = false;
|
|
284
|
+
}
|
|
212
285
|
}
|
|
213
286
|
say(text) {
|
|
214
287
|
this.log.say(text);
|
|
@@ -224,7 +297,19 @@ class Sink {
|
|
|
224
297
|
* those launches another chance to hit the failure where a leftover Edge process is still
|
|
225
298
|
* holding the profile.
|
|
226
299
|
*/
|
|
300
|
+
/**
|
|
301
|
+
* Who a shared window speaks for: the session using it now. A window opened for a batch said everything
|
|
302
|
+
* under the batch's id, so the browser's events of a session in a batch were in no transcript of its own
|
|
303
|
+
* (live run 2026-10-04). `speakFor` moves it; the id it was opened under still hears every event.
|
|
304
|
+
*/
|
|
305
|
+
const speakers = new WeakMap();
|
|
306
|
+
export function speakFor(transport, sessionId) {
|
|
307
|
+
const speaker = speakers.get(transport);
|
|
308
|
+
if (speaker)
|
|
309
|
+
speaker.id = sessionId;
|
|
310
|
+
}
|
|
227
311
|
export async function openBrowser(cfg, bus, transportDir, sessionId) {
|
|
312
|
+
const speaker = { id: sessionId };
|
|
228
313
|
const transport = createTransport({
|
|
229
314
|
profileDir: cfg.resolved.profileDir,
|
|
230
315
|
transportDir,
|
|
@@ -245,15 +330,19 @@ export async function openBrowser(cfg, bus, transportDir, sessionId) {
|
|
|
245
330
|
'reply-files-ignored': 'The reply came with a file attached. It was not downloaded and will not be run: the runner takes only the text of a reply.',
|
|
246
331
|
'model-menu-stuck': 'The model menu would not close — Escape, the menu button and a click outside all left it open. Close it in the Edge window; the run goes on.',
|
|
247
332
|
};
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
333
|
+
for (const id of new Set([speaker.id, sessionId])) {
|
|
334
|
+
bus.publish({
|
|
335
|
+
sessionId: id,
|
|
336
|
+
type: `browser:${event}`,
|
|
337
|
+
level: event === 'verification-required' || event === 'reply-files-ignored' || event === 'model-menu-stuck' ? 'warn' : 'info',
|
|
338
|
+
// An event with no sentence of its own is named, not said as "undefined".
|
|
339
|
+
message: spoken[event] ?? event.replace(/-/g, ' '),
|
|
340
|
+
data: detail,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
255
343
|
},
|
|
256
344
|
});
|
|
345
|
+
speakers.set(transport, speaker);
|
|
257
346
|
/*
|
|
258
347
|
* A window that opened and then failed to reach the chat — sign-in not done in time, a
|
|
259
348
|
* verification challenge not cleared, the consumer Copilot — is closed here, since nobody else
|
|
@@ -431,7 +520,7 @@ export function effectiveModels(session, cfg) {
|
|
|
431
520
|
reviewModel: (session.review?.model ?? '').trim() || (cfg.copilot.defaultReviewModel ?? '').trim(),
|
|
432
521
|
};
|
|
433
522
|
}
|
|
434
|
-
async function applySessionModel(transport, session, bus, cfg) {
|
|
523
|
+
async function applySessionModel(transport, session, bus, cfg, store, hooks) {
|
|
435
524
|
const wanted = effectiveModels(session, cfg).model;
|
|
436
525
|
if (!wanted)
|
|
437
526
|
return undefined;
|
|
@@ -440,15 +529,32 @@ async function applySessionModel(transport, session, bus, cfg) {
|
|
|
440
529
|
current: null,
|
|
441
530
|
reason: e.message,
|
|
442
531
|
}));
|
|
532
|
+
const renamed = await followPageModels('model', wanted, result, session, store, hooks);
|
|
533
|
+
if (renamed)
|
|
534
|
+
bus.publish({ sessionId: session.id, type: 'model-renamed', level: 'warn', message: renamed, data: { asked: wanted, chosen: result.matched } });
|
|
443
535
|
bus.publish({
|
|
444
536
|
sessionId: session.id,
|
|
445
537
|
type: result.ok ? 'model-selected' : 'model-not-selected',
|
|
446
538
|
level: result.ok ? 'info' : 'warn',
|
|
447
539
|
message: result.ok
|
|
448
540
|
? `model: ${result.current ?? wanted}${session.model?.trim() ? '' : ' (from Settings)'}`
|
|
449
|
-
: `could not switch to "${wanted}": ${result.reason ?? 'unknown reason'}. Continuing on ${result.current ?? 'the chat default'}.`,
|
|
541
|
+
: `could not switch to "${wanted}"${session.model?.trim() ? '' : ' (the default model in Settings — change it there)'}: ${(result.reason ?? 'unknown reason').replace(/[.\s]+$/, '')}. Continuing on ${result.current ?? 'the chat default'}.`,
|
|
450
542
|
data: { asked: wanted, fromSettings: !session.model?.trim(), current: result.current, ok: result.ok },
|
|
451
543
|
});
|
|
544
|
+
// Kept on the session, so each task's record says which model it ran on and why (see `runTask`).
|
|
545
|
+
await store
|
|
546
|
+
?.updateSession(session.id, (s) => {
|
|
547
|
+
s.modelSelection = {
|
|
548
|
+
asked: wanted,
|
|
549
|
+
current: result.current ?? null,
|
|
550
|
+
ok: result.ok,
|
|
551
|
+
fromSettings: !session.model?.trim(),
|
|
552
|
+
...(result.matched ? { renamedTo: result.matched } : {}),
|
|
553
|
+
...(result.ok ? {} : { reason: (result.reason ?? 'unknown reason').replace(/[.\s]+$/, '') }),
|
|
554
|
+
at: new Date().toISOString(),
|
|
555
|
+
};
|
|
556
|
+
})
|
|
557
|
+
.catch(() => undefined);
|
|
452
558
|
return result.current ?? undefined;
|
|
453
559
|
}
|
|
454
560
|
/** Runs one task to completion inside an already-open transport. */
|
|
@@ -460,6 +566,43 @@ export async function runTask(transport, session, task, deps) {
|
|
|
460
566
|
const runId = task.runId ?? `${session.id}-${task.id}${attempt > 1 ? `-a${attempt}` : ''}`;
|
|
461
567
|
const log = new RunLog(runId, cfg.resolved.runsDir);
|
|
462
568
|
const sink = new Sink(log, bus, session.id, task.id);
|
|
569
|
+
/*
|
|
570
|
+
* What other parts publish about this task while it runs — version control, the browser and the
|
|
571
|
+
* model picker, approvals, Stop, retries — written into its transcript too. They went to the live
|
|
572
|
+
* event stream only, which keeps the last 500 events in memory, so the exports and the run folder
|
|
573
|
+
* never had them (live run 2026-10-03: no vcs-*, model-* or approval events in any transcript).
|
|
574
|
+
*/
|
|
575
|
+
/*
|
|
576
|
+
* Every message this task sends, kept: its text in `messages/NN.md` and a `message-sent` event. Only the
|
|
577
|
+
* opening was recorded, so the results of each round with the runner's notes, the checks' and the
|
|
578
|
+
* review's feedback were in no file, and the export counted 2 messages where 15 went out (live run
|
|
579
|
+
* 2026-10-03). The opening keeps its own event, with its index; the rest are said here.
|
|
580
|
+
*/
|
|
581
|
+
let sentCount = 0;
|
|
582
|
+
let inOpening = false;
|
|
583
|
+
const chatOf = transport;
|
|
584
|
+
transport = new Proxy(chatOf, {
|
|
585
|
+
get(target, key) {
|
|
586
|
+
if (key !== 'sendAndConfirm')
|
|
587
|
+
return Reflect.get(target, key);
|
|
588
|
+
return async (...args) => {
|
|
589
|
+
const before = await target.sendAndConfirm(...args);
|
|
590
|
+
sentCount += 1;
|
|
591
|
+
const [text, attachments] = args;
|
|
592
|
+
await mkdir(log.path('messages'), { recursive: true }).catch(() => undefined);
|
|
593
|
+
await writeFile(log.path('messages', `${String(sentCount).padStart(2, '0')}.md`), text, 'utf8').catch(() => undefined);
|
|
594
|
+
if (!inOpening) {
|
|
595
|
+
sink.event('message-sent', { n: sentCount, chars: text.length, attachments: (attachments ?? []).map((a) => a.replace(/^.*[\\/]/, '')), chatId: session.chat?.chatId ?? null }, `message ${sentCount} sent (${text.length} chars${attachments?.length ? `, ${attachments.length} file(s) attached` : ''})`);
|
|
596
|
+
}
|
|
597
|
+
return before;
|
|
598
|
+
};
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
const unmirror = bus.subscribe(session.id, (e) => {
|
|
602
|
+
if (sink.publishing || e.type === 'console' || (e.taskId && e.taskId !== task.id))
|
|
603
|
+
return;
|
|
604
|
+
log.event(e.type, { ...(e.data ?? {}), ...(e.message ? { message: e.message } : {}), mirrored: true }, undefined, e.level);
|
|
605
|
+
});
|
|
463
606
|
const pacer = new Pacer({
|
|
464
607
|
enabled: cfg.pacing.enabled,
|
|
465
608
|
settleMs: cfg.pacing.settleMs,
|
|
@@ -523,6 +666,17 @@ export async function runTask(transport, session, task, deps) {
|
|
|
523
666
|
const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
|
|
524
667
|
/** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
|
|
525
668
|
let noProgressReason = null;
|
|
669
|
+
/** The scope as enforced on the task's own branch, once known; `finish` holds the commit to it too. */
|
|
670
|
+
let enforcement = null;
|
|
671
|
+
/**
|
|
672
|
+
* The tree when the task started, and just before its checks last ran: see `treeState`. Taken for a
|
|
673
|
+
* read-only task, and for one whose work no commit will record, so its changed files are on record.
|
|
674
|
+
*/
|
|
675
|
+
let startState = null;
|
|
676
|
+
let preGateState = null;
|
|
677
|
+
/** Whether the runner commits this task's work; when it does not, `treeChanged` records what changed. */
|
|
678
|
+
const commitsWork = !!(session.vcs?.enabled && session.vcs.commitOnFinish !== false && trackedRepoOf(session));
|
|
679
|
+
const watchesTree = !!repoDirOf(session) && (!!task.readOnly || !commitsWork);
|
|
526
680
|
/**
|
|
527
681
|
* Why the attempt stopped, when the status alone does not say: a format the chat could not keep,
|
|
528
682
|
* a task contract that contradicts itself, a loop, a check that could never run, a machine that
|
|
@@ -605,7 +759,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
605
759
|
return check;
|
|
606
760
|
};
|
|
607
761
|
const finish = async (status, reason, summary, finalReply) => {
|
|
608
|
-
|
|
762
|
+
// How it ended as reported; the runner's own checks below can still change it, and the final word is written at the end.
|
|
763
|
+
await record(`ENDING: ${status.toUpperCase()}`, [summary ?? '', reason ? `Reason: ${reason}` : ''].filter(Boolean).join('\n\n') || '(no details)');
|
|
609
764
|
// The net under the plan's own checks: whatever the task left running is stopped and named.
|
|
610
765
|
await reap(processesBefore, 'the task');
|
|
611
766
|
/*
|
|
@@ -655,16 +810,42 @@ export async function runTask(transport, session, task, deps) {
|
|
|
655
810
|
* from a clean tree — but the task ends `failed` with the files named. Only a task that
|
|
656
811
|
* claims `done` is overturned; one that already ended badly keeps its own reason.
|
|
657
812
|
*/
|
|
658
|
-
|
|
659
|
-
|
|
813
|
+
// What the task did, judged before what the checks and the review wrote after its last round.
|
|
814
|
+
const taskEndState = watchesTree ? (preGateState ?? (await treeState(repoDirOf(session)))) : null;
|
|
815
|
+
/*
|
|
816
|
+
* The scope holds for the commit too. Put back each round, it still let through what the checks and
|
|
817
|
+
* the independent review wrote after the last one (live run 2026-10-03: a docs build run by a check
|
|
818
|
+
* and by the reviewer was committed with a scoped task's work).
|
|
819
|
+
*/
|
|
820
|
+
if (enforcement && repoDirOf(session)) {
|
|
821
|
+
const last = await enforceScope(repoDirOf(session), enforcement.scope, enforcement.readOnly).catch(() => null);
|
|
822
|
+
if (last && last.reverted.length + last.failed.length > 0) {
|
|
823
|
+
sink.event('scope-reverted', { phase: 'finish', reverted: last.reverted, failed: last.failed }, `outside the task's ${enforcement.readOnly ? 'read-only rule' : 'scope'}, put back before the commit (written after the last round, by the checks or the review): ${last.reverted.join(', ') || '(none)'}` +
|
|
824
|
+
(last.failed.length > 0 ? `; could not be put back: ${last.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (task.readOnly && status === 'done' && repoDirOf(session) && startState && taskEndState) {
|
|
828
|
+
const changed = changedBetween(startState, taskEndState);
|
|
660
829
|
if (changed.length > 0) {
|
|
830
|
+
const commits = !!(session.vcs?.enabled && session.vcs.commitOnFinish !== false && trackedRepoOf(session));
|
|
661
831
|
status = 'failed';
|
|
662
832
|
reason =
|
|
663
833
|
`this task is read-only and it changed ${changed.length} file(s): ${changed.slice(0, 8).join(', ')}` +
|
|
664
|
-
`${changed.length > 8 ? `, and ${changed.length - 8} more` : ''}
|
|
834
|
+
`${changed.length > 8 ? `, and ${changed.length - 8} more` : ''}.` +
|
|
835
|
+
(commits ? " The change is committed on the task's branch; nothing is lost." : ' Nothing is committed: the change is in the working tree.');
|
|
665
836
|
sink.event('readonly-violated', { files: changed.slice(0, 20) }, reason, 'error');
|
|
666
837
|
}
|
|
667
838
|
}
|
|
839
|
+
/*
|
|
840
|
+
* With no commit to read them from, the files the task changed are recorded from the tree, so the
|
|
841
|
+
* record does not say "no files changed" beside a verdict that names them (live run 2026-10-04).
|
|
842
|
+
*/
|
|
843
|
+
if (!commitsWork && startState && taskEndState) {
|
|
844
|
+
const changed = changedBetween(startState, taskEndState);
|
|
845
|
+
await setTask((t) => {
|
|
846
|
+
t.treeChanged = changed.length > 0 ? changed : undefined;
|
|
847
|
+
});
|
|
848
|
+
}
|
|
668
849
|
// Whatever the outcome, what the task changed goes onto its branch. A failed task that
|
|
669
850
|
// left files behind is exactly when having them committed somewhere is worth the most.
|
|
670
851
|
// The task is re-read first, because the branch was recorded on it after this closure
|
|
@@ -790,7 +971,10 @@ export async function runTask(transport, session, task, deps) {
|
|
|
790
971
|
await setTask((t) => {
|
|
791
972
|
t.handoff = composeHandoff(t, notRun);
|
|
792
973
|
});
|
|
974
|
+
// The final status, after the read-only, commit and clean-tree checks (an unverified finding of 2026-10-03: "TASK DONE" for a failed task).
|
|
975
|
+
await record(`TASK ${status.toUpperCase()}`, reason ? `Reason: ${reason}` : '(no reason: it ended as reported)');
|
|
793
976
|
sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
|
|
977
|
+
unmirror();
|
|
794
978
|
await log.close();
|
|
795
979
|
/*
|
|
796
980
|
* An attempt that did not end done leaves its plan, work and runner views in its own folder,
|
|
@@ -812,7 +996,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
812
996
|
message: `could not save the plan, work and runner of this attempt: ${e.message}` }));
|
|
813
997
|
}
|
|
814
998
|
}
|
|
815
|
-
return { status, iterations, summary, reason };
|
|
999
|
+
return { status, iterations, summary, reason, ...(stopCode ? { stopCode } : {}) };
|
|
816
1000
|
};
|
|
817
1001
|
await setTask((t) => {
|
|
818
1002
|
t.status = 'running';
|
|
@@ -824,6 +1008,14 @@ export async function runTask(transport, session, task, deps) {
|
|
|
824
1008
|
t.reviewChecks = reviewChecks;
|
|
825
1009
|
});
|
|
826
1010
|
sink.event('task-started', { runId, title: task.title }, `task "${task.title}" starting (run ${runId})`);
|
|
1011
|
+
{
|
|
1012
|
+
const sel = (await store.getSession(session.id))?.modelSelection;
|
|
1013
|
+
if (sel) {
|
|
1014
|
+
sink.event('model-in-use', sel, sel.ok
|
|
1015
|
+
? `the chat is on ${sel.current ?? sel.asked}${sel.fromSettings ? ' (from Settings)' : ''}`
|
|
1016
|
+
: `the chat is on ${sel.current ?? 'its default'}, not "${sel.asked}"${sel.fromSettings ? ' (the default model in Settings)' : ''}: ${sel.reason ?? 'the picker refused it'}`, sel.ok ? 'info' : 'warn');
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
827
1019
|
await writeFile(taskLogPath, `TASK: ${task.title}\nSESSION: ${session.name} (${session.id})\nRUN: ${runId}\nSTARTED: ${new Date().toISOString()}\n`, 'utf8');
|
|
828
1020
|
/*
|
|
829
1021
|
* Which shell a step or a check gets when it names none, decided here and once.
|
|
@@ -883,7 +1075,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
883
1075
|
* different idea of where the project ends. See `confinement.ts`.
|
|
884
1076
|
*/
|
|
885
1077
|
const confinement = {
|
|
886
|
-
roots:
|
|
1078
|
+
roots: sessionRoots(work.cwd, [cfg.project.rootDir, ...cfg.project.others.map((o) => o.rootDir)]),
|
|
887
1079
|
cwd: work.cwd,
|
|
888
1080
|
};
|
|
889
1081
|
sink.event('confinement', { roots: confinement.roots }, `commands are confined to ${confinement.roots.join(', ')}`);
|
|
@@ -941,6 +1133,10 @@ export async function runTask(transport, session, task, deps) {
|
|
|
941
1133
|
* findings described a tree that was not the one it had been asked to audit.
|
|
942
1134
|
*/
|
|
943
1135
|
const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
|
|
1136
|
+
if (scopeEnforced)
|
|
1137
|
+
enforcement = { scope, readOnly: !!task.readOnly };
|
|
1138
|
+
if (watchesTree)
|
|
1139
|
+
startState = await treeState(repoDirOf(session));
|
|
944
1140
|
// The operator's input files, read-only on a branch of the task's own; see `vcs/inputs.ts`.
|
|
945
1141
|
const startNow = (await store.getSession(session.id))?.vcsStart;
|
|
946
1142
|
if (onOwnBranch && startNow?.inputs?.readOnly && startNow.inputs.files.length > 0) {
|
|
@@ -980,6 +1176,21 @@ export async function runTask(transport, session, task, deps) {
|
|
|
980
1176
|
sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
|
|
981
1177
|
return await finish('blocked', `the task contradicts itself, so it was not started: ${conflicts.join(' ')} Change the prompt, the checks, the scope or read-only, and queue it again.`);
|
|
982
1178
|
}
|
|
1179
|
+
/*
|
|
1180
|
+
* A plan check the runner refuses for its own command line can never run, so the task can never
|
|
1181
|
+
* pass: said now, before a message is sent, and not at the first "done" (live run 2026-10-03: eight
|
|
1182
|
+
* iterations and two and a half minutes for a curl to the internet the checks may not make).
|
|
1183
|
+
*/
|
|
1184
|
+
const neverRun = (task.checks ?? [])
|
|
1185
|
+
.filter((c) => !!c.run?.trim())
|
|
1186
|
+
.map((c) => ({ c, why: lineRefusal((c.run ?? '').trim(), c.shell ?? defaultShell, c.cwd ? resolve(work.cwd, c.cwd) : work.cwd, cfg.execution, confinement.roots) }))
|
|
1187
|
+
.filter((x) => !!x.why);
|
|
1188
|
+
if (neverRun.length > 0) {
|
|
1189
|
+
stopCode = 'invalid-check';
|
|
1190
|
+
const said = neverRun.map((x) => `"${x.c.name}": ${checkRefusalForOperator(x.why)}`).join(' ');
|
|
1191
|
+
sink.event('checks-invalid', { round: 0, checks: neverRun.map((x) => x.c.name) }, `the runner refuses ${neverRun.length} of the task's checks for their command line, so it was not started: ${said}`, 'error');
|
|
1192
|
+
return await finish('failed', `the runner refuses ${neverRun.length} of this task's checks for their own command line, so the task could never pass and was not started: ${said} Change those checks and queue it again.`);
|
|
1193
|
+
}
|
|
983
1194
|
/*
|
|
984
1195
|
* "Continue" carries a task on in the conversation that has it, which is the session's own
|
|
985
1196
|
* unless the session has moved since — a later task's retry in a fresh conversation moves it.
|
|
@@ -1026,7 +1237,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1026
1237
|
* record would go on naming the model the session asked for. Applied again here, as the
|
|
1027
1238
|
* fresh-chat retry in `runSession` does for the same reason.
|
|
1028
1239
|
*/
|
|
1029
|
-
const modelNow = await applySessionModel(transport, session, bus, cfg);
|
|
1240
|
+
const modelNow = await applySessionModel(transport, session, bus, cfg, store, deps.models);
|
|
1030
1241
|
if (effectiveModels(session, cfg).model) {
|
|
1031
1242
|
await store.updateSession(session.id, (s) => {
|
|
1032
1243
|
s.modelInUse = modelNow;
|
|
@@ -1050,9 +1261,20 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1050
1261
|
buildsOn,
|
|
1051
1262
|
workDirNote: workingDirNote(work),
|
|
1052
1263
|
vcsNote: prepared.note,
|
|
1053
|
-
|
|
1264
|
+
// What is said about a commit is true only where one happens (live run 2026-10-03: promised with commits off).
|
|
1265
|
+
readOnlyNote: task.readOnly ? readOnlyNote(!!(session.vcs?.enabled && session.vcs.commitOnFinish !== false && trackedRepoOf(session)), artifactPatterns(session.vcs)) : undefined,
|
|
1054
1266
|
scopeNote: [
|
|
1055
|
-
scopeNote(scope, scopeEnforced
|
|
1267
|
+
scopeNote(scope, scopeEnforced, {
|
|
1268
|
+
artifacts: artifactPatterns(session.vcs),
|
|
1269
|
+
outputs: task.outputs ?? [],
|
|
1270
|
+
// The real reason it is not enforced, not always "version control is off" (live run 2026-10-03).
|
|
1271
|
+
why: !session.vcs?.enabled
|
|
1272
|
+
? 'Version control is off for this session'
|
|
1273
|
+
: prepared.vcs.problem
|
|
1274
|
+
// Said here in full: the chat is never shown the results' "Version control" section (live run 2026-10-04).
|
|
1275
|
+
? `This task has no branch of its own: version control could not prepare one (${prepared.vcs.problem.replace(/\s+/g, ' ').trim().replace(/[.\s]+$/, '')})`
|
|
1276
|
+
: 'This task has no branch of its own',
|
|
1277
|
+
}),
|
|
1056
1278
|
(task.outputs?.length ?? 0) > 0
|
|
1057
1279
|
? `## Outputs\n\nThis task is to produce: ${(task.outputs ?? []).map((o) => `\`${o}\``).join(', ')}. What it creates or changes there is kept with the run's record as its evidence.`
|
|
1058
1280
|
: '',
|
|
@@ -1072,6 +1294,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1072
1294
|
const taskIndex = opening.messages.length - 1;
|
|
1073
1295
|
/** Whether this opening's contract has been answered, to be recorded once the conversation is. */
|
|
1074
1296
|
let contractAnswered = false;
|
|
1297
|
+
inOpening = true;
|
|
1075
1298
|
for (const [index, message] of opening.messages.entries()) {
|
|
1076
1299
|
if (signal?.aborted)
|
|
1077
1300
|
return await finish('aborted', 'stopped before the task was sent');
|
|
@@ -1097,12 +1320,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1097
1320
|
if (!session.chat) {
|
|
1098
1321
|
const chatId = await transport.currentChatId();
|
|
1099
1322
|
if (chatId) {
|
|
1100
|
-
|
|
1101
|
-
|
|
1323
|
+
// A fresh-chat retry, or any later attempt, says so in the name: the abandoned chat had the same one (live run 2026-10-03).
|
|
1324
|
+
// Only a retry in a fresh chat is named apart; a first conversation is not, whatever the attempt number.
|
|
1325
|
+
const name = buildChatName(`${chatCode(session.id)}${task.freshRetry ? `/a${attempt}` : ''}`, session.name);
|
|
1326
|
+
const named = await transport.nameChat(chatId, name).catch(() => false);
|
|
1102
1327
|
const chat = {
|
|
1103
1328
|
chatId,
|
|
1104
1329
|
url: `${cfg.copilot.url.replace(/\/chat.*$/, '')}/chat/conversation/${chatId}?es=SSR`,
|
|
1105
1330
|
name,
|
|
1331
|
+
...(named ? {} : { named: false }),
|
|
1106
1332
|
runId,
|
|
1107
1333
|
createdAt: new Date().toISOString(),
|
|
1108
1334
|
};
|
|
@@ -1112,7 +1338,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1112
1338
|
});
|
|
1113
1339
|
if (carriesTask)
|
|
1114
1340
|
await savePointer(log.path('chat.json'), chat);
|
|
1115
|
-
sink.event('chat-registered', { ...chat }, `chat: ${name}
|
|
1341
|
+
sink.event('chat-registered', { ...chat }, `chat: ${name}${named ? '' : " (not renamed in Copilot's sidebar: it keeps Copilot's own title)"}`, named ? 'info' : 'warn');
|
|
1116
1342
|
}
|
|
1117
1343
|
}
|
|
1118
1344
|
/*
|
|
@@ -1136,6 +1362,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1136
1362
|
});
|
|
1137
1363
|
}
|
|
1138
1364
|
}
|
|
1365
|
+
inOpening = false;
|
|
1139
1366
|
// --- the loop ---------------------------------------------------------------------
|
|
1140
1367
|
// How many times the checks have been run for this task, and how many times they may be.
|
|
1141
1368
|
let checkRounds = 0;
|
|
@@ -1154,7 +1381,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1154
1381
|
*/
|
|
1155
1382
|
const willCommit = !!(session.vcs?.commitOnFinish && trackedRepoOf(session));
|
|
1156
1383
|
/** The runner's own checks that have been pointed out to the chat once already. */
|
|
1157
|
-
|
|
1384
|
+
/** Per runner check, the findings the chat has been shown: "path<TAB>kind", the first two columns of its output. */
|
|
1385
|
+
const pointedOut = new Map();
|
|
1158
1386
|
/**
|
|
1159
1387
|
* Whether "done" is accepted, decided by the operator's checks rather than by the reply.
|
|
1160
1388
|
*
|
|
@@ -1180,6 +1408,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1180
1408
|
// Counted only once the round turns out to have been a real attempt. A round that died of
|
|
1181
1409
|
// a missing interpreter asked nothing of the work and must not cost the task one.
|
|
1182
1410
|
const round = checkRounds + 1;
|
|
1411
|
+
if (watchesTree)
|
|
1412
|
+
preGateState = await treeState(repoDirOf(session));
|
|
1183
1413
|
sink.event('checks-started', { round, count: checks.length }, `checking the task against ${checks.length} condition(s)`);
|
|
1184
1414
|
/** The commands this round refused only for what the project's files hold now; see `lineRefusal`. */
|
|
1185
1415
|
const refusedForTheFiles = new Set();
|
|
@@ -1255,15 +1485,26 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1255
1485
|
* tree dirty and the next task unable to start. Each check is pointed out once on its own, so a
|
|
1256
1486
|
* content problem found after the paths were settled still gets its one mention.
|
|
1257
1487
|
*/
|
|
1488
|
+
/*
|
|
1489
|
+
* Once per finding, not once per kind of check: keyed by kind, a problem in a file the chat wrote
|
|
1490
|
+
* after the only mention passed as "still there after being pointed out once" without ever being
|
|
1491
|
+
* shown (live run 2026-10-03, helper scripts with mixed line endings committed unseen).
|
|
1492
|
+
*/
|
|
1258
1493
|
for (const o of outcomes) {
|
|
1259
1494
|
if (!RUNNER_CHECK_KINDS.has(o.check.expect) || o.passed)
|
|
1260
1495
|
continue;
|
|
1261
|
-
|
|
1496
|
+
const keys = (o.output ?? o.detail).split('\n').map((l) => l.split('\t').slice(0, 2).join('\t').trim()).filter(Boolean);
|
|
1497
|
+
const shown = pointedOut.get(o.check.expect) ?? new Set();
|
|
1498
|
+
const fresh = keys.filter((k) => !shown.has(k));
|
|
1499
|
+
if (fresh.length === 0) {
|
|
1500
|
+
const files = [...new Set(keys.map((k) => k.split('\t')[0]))].join(', ');
|
|
1262
1501
|
o.passed = true;
|
|
1263
|
-
o.detail = `still there after being pointed out once; committed and kept on the task — ${o.detail}`;
|
|
1502
|
+
o.detail = `still there after being pointed out once (${files}); committed and kept on the task — ${o.detail}`;
|
|
1264
1503
|
}
|
|
1265
1504
|
else {
|
|
1266
|
-
|
|
1505
|
+
for (const k of keys)
|
|
1506
|
+
shown.add(k);
|
|
1507
|
+
pointedOut.set(o.check.expect, shown);
|
|
1267
1508
|
}
|
|
1268
1509
|
}
|
|
1269
1510
|
await setTask((t) => {
|
|
@@ -1299,18 +1540,35 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1299
1540
|
* work can change (see `lineRefusal`).
|
|
1300
1541
|
*/
|
|
1301
1542
|
const failed = outcomes.filter((o) => !o.passed);
|
|
1302
|
-
|
|
1543
|
+
/*
|
|
1544
|
+
* One check that can never run is enough: the task can never pass it, whatever else the chat fixes.
|
|
1545
|
+
* Waiting until it was the only failure sent it to the chat as work, with advice to end blocked, and
|
|
1546
|
+
* spent a round on it (live run 2026-10-03).
|
|
1547
|
+
*/
|
|
1548
|
+
const never = failed.filter(refusedForGood);
|
|
1549
|
+
if (never.length > 0) {
|
|
1303
1550
|
stopCode = 'invalid-check';
|
|
1304
1551
|
for (const o of failed) {
|
|
1305
1552
|
sink.event('check-failed', { name: o.check.name, detail: o.detail }, `FAILED: ${o.check.name} — ${o.detail}`, 'warn');
|
|
1306
1553
|
}
|
|
1307
|
-
sink.event('checks-invalid', { round, checks:
|
|
1554
|
+
sink.event('checks-invalid', { round, checks: never.map((o) => o.check.name) }, `${never.length} check(s) were refused before they ran (${never.map((o) => `"${o.check.name}"`).join(', ')}), and nothing the chat does can change that; the task ends on the checks, not on the work`, 'error');
|
|
1308
1555
|
return 'invalid';
|
|
1309
1556
|
}
|
|
1310
1557
|
checkRounds = round;
|
|
1311
1558
|
for (const o of outcomes) {
|
|
1312
1559
|
sink.event(o.passed ? 'check-passed' : 'check-failed', { name: o.check.name, detail: o.detail }, `${o.passed ? 'passed' : 'FAILED'}: ${o.check.name} — ${o.detail}`, o.passed ? 'info' : 'warn');
|
|
1313
1560
|
}
|
|
1561
|
+
/*
|
|
1562
|
+
* What the checks themselves wrote outside the scope is put back now, before anything is sent: the
|
|
1563
|
+
* next round then starts clean, and the chat is never told it broke the scope for a file a check
|
|
1564
|
+
* wrote (live run 2026-10-03).
|
|
1565
|
+
*/
|
|
1566
|
+
if (scopeEnforced) {
|
|
1567
|
+
const after = await enforceScope(repoDirOf(session), scope, !!task.readOnly).catch(() => null);
|
|
1568
|
+
if (after && after.reverted.length + after.failed.length > 0) {
|
|
1569
|
+
sink.event('scope-reverted', { phase: 'checks', round, reverted: after.reverted, failed: after.failed }, `written by the checks outside the task's ${task.readOnly ? 'read-only rule' : 'scope'}, put back: ${after.reverted.join(', ') || '(none)'}`, 'info');
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1314
1572
|
if (failed.length === 0) {
|
|
1315
1573
|
sink.event('checks-passed', { count: outcomes.length }, `all ${outcomes.length} check(s) passed`);
|
|
1316
1574
|
return 'accept';
|
|
@@ -1442,6 +1700,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1442
1700
|
await transport.newChat();
|
|
1443
1701
|
if (model) {
|
|
1444
1702
|
const picked = await transport.selectModel(model).catch((e) => ({ ok: false, current: null, reason: e.message }));
|
|
1703
|
+
const renamed = await followPageModels('review', model, picked, session, store, deps.models);
|
|
1704
|
+
if (renamed)
|
|
1705
|
+
sink.event('model-renamed', { asked: model, chosen: picked.matched, review: true }, renamed, 'warn');
|
|
1445
1706
|
sink.event(picked.ok ? 'review-model-selected' : 'review-model-not-selected', { asked: model, current: picked.current }, picked.ok ? `the review runs on ${picked.current ?? model}` : `the review could not switch to "${model}": ${picked.reason ?? 'unknown reason'}`, picked.ok ? 'info' : 'warn');
|
|
1446
1707
|
}
|
|
1447
1708
|
outcome = await runReview(sendsUntilStopped(transport, signal), session, task, {
|
|
@@ -1724,7 +1985,14 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1724
1985
|
* into inventing ways around its own type-checker. Counting identical results refuses only
|
|
1725
1986
|
* what it was meant to: the same command, returning the same answer, again.
|
|
1726
1987
|
*/
|
|
1988
|
+
/*
|
|
1989
|
+
* With each command, the working tree as it was after it ran and how many steps had run by then. A
|
|
1990
|
+
* repeat is refused only when nothing has changed since: found live on 2026-10-03, a read-back right
|
|
1991
|
+
* after a step had rewritten the file was refused as "the same result", judged from history alone.
|
|
1992
|
+
*/
|
|
1727
1993
|
const seen = new Map();
|
|
1994
|
+
/** Steps that ran in this attempt, of any kind; a "blocked" before the first one is not an approach. */
|
|
1995
|
+
let stepsRunThisAttempt = 0;
|
|
1728
1996
|
const fingerprint = (step) => step.cmd.replace(/\s+/g, ' ').trim();
|
|
1729
1997
|
/** What "the same answer" means: the exit code and the output, hashed. */
|
|
1730
1998
|
const resultSignature = (r) => createHash('sha256').update(`${r.exitCode}\u0000${r.outcome}\u0000${r.stdout}\u0000${r.stderr}`).digest('hex');
|
|
@@ -1843,6 +2111,38 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1843
2111
|
*/
|
|
1844
2112
|
if (!blocked && reply.steps.length > 0)
|
|
1845
2113
|
earlyBlocks = 0;
|
|
2114
|
+
/*
|
|
2115
|
+
* "Blocked" before a single step ran in this attempt: what it lists as tried was thinking, not
|
|
2116
|
+
* trying. Seen live on 2026-10-03, twice: a chat that said it cannot "operate under an external
|
|
2117
|
+
* runner" blocked on its first reply with two free-text entries in `tried`, which passed the
|
|
2118
|
+
* two-approaches floor, and the task went to a fresh chat. It is told what the runner is first.
|
|
2119
|
+
*/
|
|
2120
|
+
if (blocked && stepsRunThisAttempt === 0 && earlyBlocks < MAX_EARLY_BLOCKS) {
|
|
2121
|
+
earlyBlocks += 1;
|
|
2122
|
+
stats.blockedTooEarly = (stats.blockedTooEarly ?? 0) + 1;
|
|
2123
|
+
sink.event('blocked-before-any-step', { tried: reply.tried.length, time: earlyBlocks }, '"blocked" before any step ran in this task, so the chat is told how the runner works and asked for a first step', 'warn');
|
|
2124
|
+
/*
|
|
2125
|
+
* A continued task has run steps before, in its earlier attempt, and a chat that blocks there has
|
|
2126
|
+
* usually lost the assignment, said "above": it is given again rather than told nothing ran (live run 2026-10-04).
|
|
2127
|
+
*/
|
|
2128
|
+
const message = task.continuing
|
|
2129
|
+
? 'Not yet: this task is being continued, and nothing has run since. The assignment is this one, unchanged:\n\n' +
|
|
2130
|
+
`${task.prompt.length > 6000 ? `${task.prompt.slice(0, 6000)}…` : task.prompt}\n\n` +
|
|
2131
|
+
'Carry on from where it stopped: send your next step now with status "continue", for example a command that shows ' +
|
|
2132
|
+
'the state of the files the task works on. A task may end "blocked" only after steps that ran.'
|
|
2133
|
+
: 'Not yet: nothing has been run in this task. You do not need to operate anything yourself — this runner executes ' +
|
|
2134
|
+
'the commands you send in "steps", on the operator\'s machine in the project folder, and sends you back what they printed. ' +
|
|
2135
|
+
'Send your first step now with status "continue", for example a command that lists the project\'s files ' +
|
|
2136
|
+
'(Get-ChildItem -Recurse -File | Select-Object -First 50 FullName). A task may end "blocked" only after steps that ran.';
|
|
2137
|
+
await record('BLOCKED BEFORE ANY STEP — MESSAGE SENT', message);
|
|
2138
|
+
await pacer.throttleSend();
|
|
2139
|
+
const before = await transport.sendAndConfirm(message);
|
|
2140
|
+
const next = await transport.waitForReply(before);
|
|
2141
|
+
await saveReply(`blocked-before-any-step-${earlyBlocks}`, next);
|
|
2142
|
+
lastMarkdown = next.markdown;
|
|
2143
|
+
await pacer.settle();
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
1846
2146
|
if (blocked && reply.tried.length < minApproaches && earlyBlocks < MAX_EARLY_BLOCKS) {
|
|
1847
2147
|
/*
|
|
1848
2148
|
* Too early to give up, by the operator's own measure (Settings → Execution). Sent back as a
|
|
@@ -1885,17 +2185,29 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1885
2185
|
let repeatsRefused = 0;
|
|
1886
2186
|
/** Steps refused because they named a shell this machine has not got. */
|
|
1887
2187
|
let shellRefused = 0;
|
|
2188
|
+
/*
|
|
2189
|
+
* The step of this reply that was refused, once one was: the steps after it are not run. They were,
|
|
2190
|
+
* and the chat got test results from a tree its own plan had not produced — a write refused, the
|
|
2191
|
+
* tests run on the old code (live run 2026-10-03).
|
|
2192
|
+
*/
|
|
2193
|
+
let refusedAt = null;
|
|
1888
2194
|
for (const step of reply.steps) {
|
|
1889
2195
|
if (signal?.aborted) {
|
|
1890
2196
|
results.push(refusedResult(step, 'stopped by the operator', 'operator'));
|
|
1891
2197
|
aborted = true;
|
|
1892
2198
|
break;
|
|
1893
2199
|
}
|
|
2200
|
+
if (refusedAt !== null) {
|
|
2201
|
+
sink.event('step-skipped', { id: step.id, reason: `step ${refusedAt} was refused`, by: 'runner', after: refusedAt }, `step ${step.id} not run: step ${refusedAt} was refused`, 'info');
|
|
2202
|
+
results.push(refusedResult(step, `not run, because step ${refusedAt} of this reply was refused: it would have run on a tree your plan did not produce. Send it again after step ${refusedAt} is replaced.`, 'runner', refusedAt));
|
|
2203
|
+
continue;
|
|
2204
|
+
}
|
|
1894
2205
|
{
|
|
1895
2206
|
const damage = findLikelyDamage(step.cmd);
|
|
1896
2207
|
if (damage) {
|
|
1897
2208
|
sink.event('step-damaged', { id: step.id, cmd: step.cmd, damage }, `step ${step.id} arrived damaged: ${damage}`, 'warn');
|
|
1898
2209
|
results.push(refusedResult(step, `${damage}. ${damageGuidance()}`));
|
|
2210
|
+
refusedAt = step.id;
|
|
1899
2211
|
continue;
|
|
1900
2212
|
}
|
|
1901
2213
|
}
|
|
@@ -1923,11 +2235,21 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1923
2235
|
// Refused before the operator is asked to approve it, because a step that cannot teach
|
|
1924
2236
|
// anyone anything is not worth a person's attention either.
|
|
1925
2237
|
const key = fingerprint(step);
|
|
1926
|
-
const
|
|
2238
|
+
const last = seen.get(key);
|
|
2239
|
+
let ran = last?.sameInARow ?? 0;
|
|
2240
|
+
if (ran >= maxCommandRepeats && last) {
|
|
2241
|
+
// Something changed since it last ran — the tree, or (without a tree to compare) another step ran: not a repeat.
|
|
2242
|
+
const nowTree = await treeNow();
|
|
2243
|
+
const changed = last.tree !== null && nowTree !== null ? last.tree !== nowTree : stepsRunThisAttempt > last.stepNo;
|
|
2244
|
+
if (changed) {
|
|
2245
|
+
seen.set(key, { ...last, sameInARow: 0 });
|
|
2246
|
+
ran = 0;
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
1927
2249
|
if (ran >= maxCommandRepeats) {
|
|
1928
2250
|
repeatsRefused += 1;
|
|
1929
2251
|
stats.repeatsRefused += 1;
|
|
1930
|
-
sink.event('step-repeated', { id: step.id, count: ran, limit: maxCommandRepeats }, `step ${step.id} refused: already run ${ran} time(s) in this task with the same result`, 'warn');
|
|
2252
|
+
sink.event('step-repeated', { id: step.id, count: ran, limit: maxCommandRepeats, command: describeStep(step) }, `step ${step.id} refused: already run ${ran} time(s) in this task with the same result`, 'warn');
|
|
1931
2253
|
results.push(refusedResult(step, repeatRefusal(ran, maxCommandRepeats)));
|
|
1932
2254
|
continue;
|
|
1933
2255
|
}
|
|
@@ -1952,6 +2274,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1952
2274
|
if (decision.action === 'skip') {
|
|
1953
2275
|
sink.event('step-skipped', { id: step.id, reason: decision.reason, by: decision.by ?? 'runner' }, `step ${step.id} skipped: ${decision.reason}`, 'warn');
|
|
1954
2276
|
results.push(refusedResult(step, decision.reason, decision.by === 'operator' ? 'operator' : 'runner'));
|
|
2277
|
+
refusedAt = step.id;
|
|
1955
2278
|
continue;
|
|
1956
2279
|
}
|
|
1957
2280
|
const long = step.expect === 'long';
|
|
@@ -1974,13 +2297,13 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1974
2297
|
onHeartbeat: ({ elapsedMs, idleMs, lastLine }) => sink.event('step-heartbeat', { id: step.id, elapsedMs, idleMs, lastLine }, `step ${step.id} still running: ${Math.round(elapsedMs / 1000)}s elapsed, ${Math.round(idleMs / 1000)}s since output${lastLine ? ` — ${lastLine.slice(0, 60)}` : ''}`),
|
|
1975
2298
|
});
|
|
1976
2299
|
results.push(result);
|
|
2300
|
+
stepsRunThisAttempt += 1;
|
|
1977
2301
|
// The run counts toward the limit only if it changed nothing about the answer.
|
|
1978
2302
|
const signature = resultSignature(result);
|
|
1979
2303
|
const previous = seen.get(key);
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
});
|
|
2304
|
+
const sameInARow = previous && previous.signature === signature ? previous.sameInARow + 1 : 1;
|
|
2305
|
+
// The tree is read only for a command at the limit, the one case it is asked about.
|
|
2306
|
+
seen.set(key, { signature, sameInARow, tree: sameInARow >= maxCommandRepeats ? await treeNow() : null, stepNo: stepsRunThisAttempt });
|
|
1984
2307
|
sink.event('step-finished', { id: step.id, outcome: result.outcome, exitCode: result.exitCode, durationMs: result.durationMs, shell: result.shell, requestedShell: result.requestedShell ?? null, shellPath: result.shellPath ?? '' }, `step ${step.id}: ${result.outcome}, exit ${result.exitCode}, ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
1985
2308
|
/*
|
|
1986
2309
|
* Stopped while it ran. The signal is looked at before each step, and after the last one
|
|
@@ -2037,7 +2360,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
2037
2360
|
failed: [{ path: '(the working tree)', why: e.message }],
|
|
2038
2361
|
}));
|
|
2039
2362
|
if (check.outside.length > 0 || check.failed.length > 0) {
|
|
2040
|
-
sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed },
|
|
2363
|
+
sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `${task.readOnly ? 'changed by a read-only task' : "outside the task's scope"}, put back: ${check.reverted.join(', ') || '(none)'}` +
|
|
2041
2364
|
(check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
|
|
2042
2365
|
runnerNotes.push(scopeMessage(check, scope, !!task.readOnly));
|
|
2043
2366
|
stats.scopeReverts += check.reverted.length;
|
|
@@ -2064,8 +2387,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
2064
2387
|
if (report.redactions.length > 0) {
|
|
2065
2388
|
sink.event('report-redacted', { iteration: iterations, redactions: report.redactions }, `redacted before upload: ${report.redactions.map((r) => `${r.count}× ${r.name}`).join(', ')}`, 'warn');
|
|
2066
2389
|
}
|
|
2390
|
+
// A Stop said as one, apart from "abort" given to a step: the record could not tell them apart (live run 2026-10-04).
|
|
2067
2391
|
if (aborted)
|
|
2068
|
-
return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
|
|
2392
|
+
return await finish('aborted', signal?.aborted ? 'stopped by the operator' : 'the operator aborted the task', undefined, lastMarkdown);
|
|
2069
2393
|
// A loop in which every round looks new: see `progress.ts`. After the report is written, so
|
|
2070
2394
|
// the evidence the diagnosis points at is on disk.
|
|
2071
2395
|
const stuck = progress.afterRound(await treeNow(), results);
|
|
@@ -2252,6 +2576,7 @@ export async function runSession(sessionId, deps) {
|
|
|
2252
2576
|
try {
|
|
2253
2577
|
session = await joinGroupConversation(store, session, bus);
|
|
2254
2578
|
if (borrowed) {
|
|
2579
|
+
speakFor(borrowed, session.id);
|
|
2255
2580
|
bus.publish({ sessionId, type: 'browser-reused', level: 'info', message: 'using the browser window that is already open' });
|
|
2256
2581
|
await enterSessionConversation(borrowed, session, { closeOnFailure: false });
|
|
2257
2582
|
}
|
|
@@ -2262,7 +2587,7 @@ export async function runSession(sessionId, deps) {
|
|
|
2262
2587
|
const chat = transport;
|
|
2263
2588
|
// The picker belongs to the conversation, so the session's choice is applied once, here,
|
|
2264
2589
|
// before the first task goes out. What the chat ended up on is recorded either way.
|
|
2265
|
-
const modelInUse = await applySessionModel(chat, session, bus, cfg);
|
|
2590
|
+
const modelInUse = await applySessionModel(chat, session, bus, cfg, store, deps.models);
|
|
2266
2591
|
if (effectiveModels(session, cfg).model) {
|
|
2267
2592
|
await store.updateSession(sessionId, (s) => {
|
|
2268
2593
|
s.modelInUse = modelInUse;
|
|
@@ -2310,7 +2635,7 @@ export async function runSession(sessionId, deps) {
|
|
|
2310
2635
|
*/
|
|
2311
2636
|
const retriesAllowed = cfg.limits.retryBlockedInFreshChat ?? 0;
|
|
2312
2637
|
let retried = 0;
|
|
2313
|
-
while (outcome.status === 'blocked' && retried < retriesAllowed && !deps.signal?.aborted) {
|
|
2638
|
+
while (outcome.status === 'blocked' && retried < retriesAllowed && !deps.signal?.aborted && !(outcome.stopCode && NOT_THE_CHATS.includes(outcome.stopCode))) {
|
|
2314
2639
|
retried += 1;
|
|
2315
2640
|
bus.publish({
|
|
2316
2641
|
sessionId,
|
|
@@ -2343,7 +2668,7 @@ export async function runSession(sessionId, deps) {
|
|
|
2343
2668
|
* it was the model the session asked for. The review has always done this for its own
|
|
2344
2669
|
* fresh conversations a few hundred lines up; this path simply never did.
|
|
2345
2670
|
*/
|
|
2346
|
-
const freshModel = await applySessionModel(chat, session, bus, cfg);
|
|
2671
|
+
const freshModel = await applySessionModel(chat, session, bus, cfg, store, deps.models);
|
|
2347
2672
|
if (effectiveModels(session, cfg).model) {
|
|
2348
2673
|
session = await store.updateSession(sessionId, (s) => {
|
|
2349
2674
|
s.modelInUse = freshModel;
|
|
@@ -2359,11 +2684,14 @@ export async function runSession(sessionId, deps) {
|
|
|
2359
2684
|
bus.publish({
|
|
2360
2685
|
sessionId,
|
|
2361
2686
|
taskId: task.id,
|
|
2362
|
-
|
|
2363
|
-
|
|
2687
|
+
// "recovered" only when it ended done: a failure after a retry is not a recovery.
|
|
2688
|
+
type: outcome.status === 'blocked' ? 'task-retry-exhausted' : outcome.status === 'done' ? 'task-retry-recovered' : 'task-retry-ended',
|
|
2689
|
+
level: outcome.status === 'blocked' ? 'error' : outcome.status === 'done' ? 'info' : 'warn',
|
|
2364
2690
|
message: outcome.status === 'blocked'
|
|
2365
2691
|
? `"${task.title}" blocked again after ${retried} fresh conversation(s): the cause is not the chat — read the task text, the checks and the machine`
|
|
2366
|
-
:
|
|
2692
|
+
: outcome.status === 'done'
|
|
2693
|
+
? `"${task.title}" ended done in a fresh conversation after blocking ${retried} time(s): the earlier block was the chat's`
|
|
2694
|
+
: `"${task.title}" ended ${outcome.status} in a fresh conversation after blocking ${retried} time(s)`,
|
|
2367
2695
|
data: { retried, status: outcome.status },
|
|
2368
2696
|
});
|
|
2369
2697
|
}
|
|
@@ -2371,7 +2699,12 @@ export async function runSession(sessionId, deps) {
|
|
|
2371
2699
|
if (outcome.status !== 'done') {
|
|
2372
2700
|
if (!deps.continueOnFailure) {
|
|
2373
2701
|
bus.publish({ sessionId, type: 'session-stopped-early', level: 'warn',
|
|
2374
|
-
message:
|
|
2702
|
+
message: (() => {
|
|
2703
|
+
const left = queued.length - queued.findIndex((q) => q.id === queuedTask.id) - 1;
|
|
2704
|
+
return left > 0
|
|
2705
|
+
? `task "${task.title}" ended ${outcome.status}; the ${left} task(s) after it stay queued`
|
|
2706
|
+
: `task "${task.title}" ended ${outcome.status}; it was the session's last queued task`;
|
|
2707
|
+
})() });
|
|
2375
2708
|
break;
|
|
2376
2709
|
}
|
|
2377
2710
|
// Saying this out loud matters: carrying on past a failure is a choice the operator
|
|
@@ -2392,7 +2725,11 @@ export async function runSession(sessionId, deps) {
|
|
|
2392
2725
|
await store.updateSession(sessionId, (s) => {
|
|
2393
2726
|
s.status = 'idle';
|
|
2394
2727
|
});
|
|
2395
|
-
|
|
2728
|
+
const distinct = new Set(queued.map((q) => q.id)).size;
|
|
2729
|
+
bus.publish({ sessionId, type: 'session-finished', level: 'info', message: `${Math.min(ran, distinct)} task(s) ran${ran > distinct ? ` (${ran} attempts, with retries)` : ''}`, data: { attempts: ran } });
|
|
2730
|
+
const left = await whereLeft((await store.getSession(sessionId)) ?? session).catch(() => null);
|
|
2731
|
+
if (left)
|
|
2732
|
+
bus.publish({ sessionId, type: 'vcs-left-on', level: 'warn', message: left });
|
|
2396
2733
|
}
|
|
2397
2734
|
return { ran, lastStatus, paused };
|
|
2398
2735
|
}
|