copilot-operator 0.1.13 → 0.1.15
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/README.md +9 -14
- package/dist/src/api/operator.controller.js +31 -42
- package/dist/src/api/operator.service.js +663 -367
- package/dist/src/api/saveToDesktop.js +1 -1
- package/dist/src/api/security.js +40 -10
- package/dist/src/api/server.js +75 -12
- package/dist/src/api/settings.js +156 -15
- package/dist/src/cli.js +108 -97
- package/dist/src/config/handWritten.js +36 -0
- package/dist/src/config/layout.js +13 -4
- package/dist/src/config/lockedPolicy.js +56 -22
- package/dist/src/config/schema.js +13 -84
- package/dist/src/context/desktopDir.js +44 -0
- package/dist/src/exec/authorizer.js +9 -1
- package/dist/src/exec/checks.js +240 -23
- package/dist/src/exec/dangerous.js +29 -5
- package/dist/src/exec/network.js +105 -28
- package/dist/src/exec/redaction.js +52 -9
- package/dist/src/exec/reportFile.js +4 -5
- package/dist/src/exec/runner.js +146 -13
- package/dist/src/exec/shells.js +41 -6
- package/dist/src/exec/workDir.js +4 -4
- package/dist/src/orchestrator/derivedChecks.js +1 -0
- package/dist/src/orchestrator/review.js +25 -3
- package/dist/src/orchestrator/taskRunner.js +476 -163
- package/dist/src/plan/brief.js +37 -44
- package/dist/src/plan/importPlan.js +15 -7
- package/dist/src/plan/schema.js +64 -29
- package/dist/src/plan/script.js +0 -15
- package/dist/src/plan/systemGuide.js +2 -177
- package/dist/src/protocol/parser.js +5 -1
- package/dist/src/session/debugExport.js +1 -1
- package/dist/src/session/exportRecord.js +1 -1
- package/dist/src/session/exports.js +49 -9
- package/dist/src/session/store.js +215 -80
- package/dist/src/vcs/scope.js +29 -6
- package/dist/src/vcs/taskVcs.js +78 -10
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
- package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
- package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
- package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +18 -10
- package/prompts/level1.md +2 -1
- package/prompts/organisation.bg.md +0 -1
- package/prompts/organisation.en.md +0 -1
- package/dist/src/context/contextFiles.js +0 -295
- package/dist/src/context/desktopMirror.js +0 -173
- package/dist/src/context/projectMirror.js +0 -432
- package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
- package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
|
@@ -14,14 +14,14 @@ import { createHash } from 'node:crypto';
|
|
|
14
14
|
import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { createTransport, isReplyTimeout } from '../transport/chatTransport.js';
|
|
17
|
-
import { buildChatName, chatCode, savePointer } from '../transport/chatSession.js';
|
|
17
|
+
import { buildChatName, chatCode, loadPointer, savePointer } from '../transport/chatSession.js';
|
|
18
18
|
import { parseReply, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
|
|
19
19
|
import { resolveDeviations, describeDeviations, mergeDisputes, describeDisputes } from '../protocol/replySchema.js';
|
|
20
20
|
import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
|
|
21
21
|
import { runStep } from '../exec/runner.js';
|
|
22
22
|
import { availableShells, detectShells, effectiveShell, preferredShell, refusalForChat, resolveShell, shellNote } from '../exec/shells.js';
|
|
23
|
-
import { runChecks, failureMessage, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK, RUNNER_CHECK_KINDS } from '../exec/checks.js';
|
|
24
|
-
import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing } from './derivedChecks.js';
|
|
23
|
+
import { runChecks, failureMessage, failureRedactions, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK, RUNNER_CHECK_KINDS } from '../exec/checks.js';
|
|
24
|
+
import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing, isDerivedCheck } from './derivedChecks.js';
|
|
25
25
|
import { workingDirFor, isWorkingDirProblem, workingDirNote } from '../exec/workDir.js';
|
|
26
26
|
import { redactSecrets } from '../exec/redaction.js';
|
|
27
27
|
import { snapshotProcesses, reapLeftovers, describeLeftovers, ProcessTracker } from '../exec/processes.js';
|
|
@@ -30,10 +30,11 @@ import { describeCrash } from '../transport/edgeCrash.js';
|
|
|
30
30
|
import { runReview, findingsMessage, deliverableFor } from './review.js';
|
|
31
31
|
import { allAboutTheTask, isRepeat, findingId } from '../protocol/reviewSchema.js';
|
|
32
32
|
import { repoState, workingTreePaths } from '../vcs/git.js';
|
|
33
|
-
import { describeStep, checkCommandRefusal } from '../exec/policy.js';
|
|
33
|
+
import { describeStep, checkCommandRefusal, commandRefusal } from '../exec/policy.js';
|
|
34
|
+
import { networkFetchReason } from '../exec/network.js';
|
|
34
35
|
import { collectPolicyManifest, describePolicyManifest } from '../exec/policyManifest.js';
|
|
35
36
|
import { assessIsolation, readIsolationSignals } from '../exec/isolation.js';
|
|
36
|
-
import { projectRoots } from '../exec/confinement.js';
|
|
37
|
+
import { confinementRefusal, projectRoots } from '../exec/confinement.js';
|
|
37
38
|
import { writeReport } from '../exec/reportFile.js';
|
|
38
39
|
import { Pacer } from '../util/pacing.js';
|
|
39
40
|
import { RunLog } from '../log/runLog.js';
|
|
@@ -44,9 +45,7 @@ import { composeHandoff } from '../session/handoff.js';
|
|
|
44
45
|
import { ProgressWatch } from './progress.js';
|
|
45
46
|
import { contractConflicts, readsTreeClean } from './contract.js';
|
|
46
47
|
import { treeFingerprint } from '../vcs/git.js';
|
|
47
|
-
import {
|
|
48
|
-
import { keptOnDesktop, mirrorSourceRoot, projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
|
|
49
|
-
import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
|
|
48
|
+
import { prepareForTask, commitTaskResult, repoDirOf, trackedRepoOf } from '../vcs/taskVcs.js';
|
|
50
49
|
import { exportMachine, writeAttemptRecord } from '../session/exports.js';
|
|
51
50
|
/** Why a task that was reported as done is being closed as failed. */
|
|
52
51
|
function checksFailedReason(outcomes) {
|
|
@@ -57,6 +56,25 @@ function checksFailedReason(outcomes) {
|
|
|
57
56
|
.map((o) => `${o.check.name} (${o.detail})`)
|
|
58
57
|
.join('; ')}`;
|
|
59
58
|
}
|
|
59
|
+
/** A package runner (`npx` and its kin): it fetches the tool it names unless the project has it installed. */
|
|
60
|
+
const PACKAGE_RUNNER = /\b(?:npx|pnpx|bunx)(?:\.cmd|\.exe)?\s|\b(?:pnpm|yarn)(?:\.cmd)?\s+dlx\s|\bnpm(?:\.cmd)?\s+exec\s/i;
|
|
61
|
+
/**
|
|
62
|
+
* Why a check's command line is refused for itself, or null when the gate refuses it, if at all,
|
|
63
|
+
* only for what the project's files hold now.
|
|
64
|
+
*
|
|
65
|
+
* `checkCommandRefusal` is the gate; this is the part of it that nothing in the tree decides — the
|
|
66
|
+
* command's own words, the settings, the folders it reaches. The other two parts read the files,
|
|
67
|
+
* and the work can change both: the scripts the command runs, refused while missing or while they
|
|
68
|
+
* hold a line the gate refuses (see `scriptFiles.ts`), and a package runner, held only while its
|
|
69
|
+
* tool is not installed in the project (see `network.ts`). A command with a package runner in it is
|
|
70
|
+
* left to them whatever else it fetches: ending a task on a check the work could still have made
|
|
71
|
+
* run is the worse mistake of the two.
|
|
72
|
+
*/
|
|
73
|
+
function lineRefusal(command, shell, cwd, cfg, roots) {
|
|
74
|
+
return (commandRefusal(command, shell, cfg.denyPatterns, cfg.allowedPrograms) ??
|
|
75
|
+
confinementRefusal(command, { roots, cwd }) ??
|
|
76
|
+
(PACKAGE_RUNNER.test(command) ? null : networkFetchReason(command, cwd)));
|
|
77
|
+
}
|
|
60
78
|
/**
|
|
61
79
|
* Why a task ended because of the machine rather than because of the work.
|
|
62
80
|
*
|
|
@@ -100,6 +118,20 @@ export function earlierTasksForReview(session, task, limit = 3, maxChars = 2500)
|
|
|
100
118
|
.slice(-limit)
|
|
101
119
|
.map((t) => ({ title: t.title, prompt: t.prompt.length > maxChars ? `${t.prompt.slice(0, maxChars)}\n[… ${t.prompt.length - maxChars} more characters]` : t.prompt }));
|
|
102
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Parts of a reason joined as the sentences they are: every part that another follows ends with a
|
|
123
|
+
* full stop unless it already ends as a sentence does, and an empty part is left out.
|
|
124
|
+
*
|
|
125
|
+
* A reason is mostly written as a clause — "stopped by the operator before the review" — so that it
|
|
126
|
+
* reads inside other sentences too, and one followed by the next with only a space between them ran
|
|
127
|
+
* into it: "… before the review After that, the runner committed …". A part that already ends with
|
|
128
|
+
* its own full stop is not given a second one, and the last part is left as it was written, so a
|
|
129
|
+
* reason of one clause reads as it always has.
|
|
130
|
+
*/
|
|
131
|
+
function sentences(...parts) {
|
|
132
|
+
const kept = parts.map((p) => (p ?? '').trim()).filter(Boolean);
|
|
133
|
+
return kept.map((p, i) => (i === kept.length - 1 || /[.!?…]["'”’)\]]*$/.test(p) ? p : `${p}.`)).join(' ');
|
|
134
|
+
}
|
|
103
135
|
/**
|
|
104
136
|
* The reason line for a task the model gave up on, built from what it says it tried.
|
|
105
137
|
*
|
|
@@ -137,6 +169,29 @@ function refusedResult(step, reason, by = 'runner') {
|
|
|
137
169
|
lastOutputAgoMs: 0,
|
|
138
170
|
};
|
|
139
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The same chat, refusing to send once the run has been stopped.
|
|
174
|
+
*
|
|
175
|
+
* Handed to the review, whose own loop looks at the signal at the top of each round and after a
|
|
176
|
+
* step it ran. Every other message it sends — a format repair, a refused "pass", a turned-down
|
|
177
|
+
* check — goes out before it looks again, and a Stop pressed while the reviewer was answering would
|
|
178
|
+
* have one more sent and its answer waited for, a Stop that took as long as a reply. Refused here,
|
|
179
|
+
* the review ends at the send, and the runner, seeing the Stop, ends the task `aborted` (see
|
|
180
|
+
* `gateOnReview`). Everything else is the chat itself, unchanged.
|
|
181
|
+
*/
|
|
182
|
+
function sendsUntilStopped(transport, signal) {
|
|
183
|
+
if (!signal)
|
|
184
|
+
return transport;
|
|
185
|
+
return new Proxy(transport, {
|
|
186
|
+
get(target, key) {
|
|
187
|
+
if (key === 'sendAndConfirm') {
|
|
188
|
+
return (...args) => signal.aborted ? Promise.reject(new Error('the run was stopped; nothing more is sent')) : target.sendAndConfirm(...args);
|
|
189
|
+
}
|
|
190
|
+
const value = Reflect.get(target, key, target);
|
|
191
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
140
195
|
/** One place that writes to the transcript, the console and the live stream at once. */
|
|
141
196
|
class Sink {
|
|
142
197
|
log;
|
|
@@ -197,8 +252,22 @@ export async function openBrowser(cfg, bus, transportDir, sessionId) {
|
|
|
197
252
|
});
|
|
198
253
|
},
|
|
199
254
|
});
|
|
200
|
-
|
|
201
|
-
|
|
255
|
+
/*
|
|
256
|
+
* A window that opened and then failed to reach the chat — sign-in not done in time, a
|
|
257
|
+
* verification challenge not cleared, the consumer Copilot — is closed here, since nobody else
|
|
258
|
+
* holds it: the caller has no transport yet, so its own `finally` closes nothing. Left open, the
|
|
259
|
+
* run ended and gave up its claim on the browser with Edge still on the profile, and the next start
|
|
260
|
+
* or read of the models, the one the error asks for, was refused as "Edge is already running with
|
|
261
|
+
* this profile" until the window was closed by hand.
|
|
262
|
+
*/
|
|
263
|
+
try {
|
|
264
|
+
await transport.open();
|
|
265
|
+
await transport.ensureSignedIn();
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
await transport.close().catch(() => undefined);
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
202
271
|
return transport;
|
|
203
272
|
}
|
|
204
273
|
/**
|
|
@@ -225,6 +294,80 @@ export async function enterSessionConversation(transport, session, opts) {
|
|
|
225
294
|
await transport.newChat();
|
|
226
295
|
}
|
|
227
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Whether an attempt's task reached a conversation, and which, as its run folder records it.
|
|
299
|
+
*
|
|
300
|
+
* `chat.json` is written the moment the message that carries the task has gone into a conversation
|
|
301
|
+
* (see `runTask`), and never before: not when the attempt starts, and not when a new conversation
|
|
302
|
+
* answers the contract, which is a message earlier. The transcript answers for an attempt that has
|
|
303
|
+
* no `chat.json`. It records every opening message as it is sent (`message-sent`), and since
|
|
304
|
+
* 2026-10-01 how many the opening had and whether this one carried the task. So an attempt that sent
|
|
305
|
+
* nothing at all never delivered its task, whenever it ran; one that sent the second message of an
|
|
306
|
+
* opening delivered it, since the task is always the last and an opening has two at most. Only an
|
|
307
|
+
* older attempt that sent one message without saying more cannot be told: one message is a whole
|
|
308
|
+
* opening into a conversation that had the contract, or the contract alone, stopped before the task.
|
|
309
|
+
*/
|
|
310
|
+
async function deliveryOf(runDir) {
|
|
311
|
+
const pointer = await loadPointer(join(runDir, 'chat.json'));
|
|
312
|
+
if (pointer)
|
|
313
|
+
return { sent: true, pointer };
|
|
314
|
+
const raw = await readFile(join(runDir, 'transcript.jsonl'), 'utf8').catch(() => null);
|
|
315
|
+
if (raw === null)
|
|
316
|
+
return { sent: 'unknown' };
|
|
317
|
+
const sent = [];
|
|
318
|
+
for (const line of raw.split('\n')) {
|
|
319
|
+
if (!line.includes('"message-sent"'))
|
|
320
|
+
continue;
|
|
321
|
+
try {
|
|
322
|
+
const e = JSON.parse(line);
|
|
323
|
+
if (e.type === 'message-sent')
|
|
324
|
+
sent.push(e);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// A line cut in half by a crash: the lines around it still answer.
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (sent.length === 0)
|
|
331
|
+
return { sent: false };
|
|
332
|
+
if (sent.some((e) => e.task === true || (typeof e.of !== 'number' && Number(e.index) >= 1)))
|
|
333
|
+
return { sent: true, pointer: null };
|
|
334
|
+
return sent.every((e) => typeof e.of === 'number') ? { sent: false } : { sent: 'unknown' };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Where the assignment a "Continue" carries on is, against the conversation the session is in now.
|
|
338
|
+
*
|
|
339
|
+
* `here` — in the session's conversation, or nothing on record says otherwise; `elsewhere` — in
|
|
340
|
+
* another one, with its pointer when the attempt kept one; `nowhere` — no conversation was ever given
|
|
341
|
+
* it, because the attempt was stopped before its task was sent (see `deliveryOf`), and so was every
|
|
342
|
+
* attempt it continued. Then the assignment started with the first of those, and `buildsOn` is what
|
|
343
|
+
* that one was told about the work before it.
|
|
344
|
+
*
|
|
345
|
+
* The newest attempt that delivered its task is the one whose conversation has the assignment and
|
|
346
|
+
* the latest work on it; an undelivered continuation added nothing to any chat, so the one it
|
|
347
|
+
* continued is asked instead. An attempt whose record cannot say is answered from the session's own
|
|
348
|
+
* pointer, as far as that goes: a conversation registered by another attempt after this one started
|
|
349
|
+
* cannot hold it. That is what a later task's retry in a fresh conversation leaves behind (see
|
|
350
|
+
* `runSession`), the one thing that moves a session.
|
|
351
|
+
*/
|
|
352
|
+
async function conversationOfAttempt(runsDir, attempts, current) {
|
|
353
|
+
const fromThePointer = (a) => current && current.runId !== a.runId && !!a.startedAt && Date.parse(current.createdAt) > Date.parse(a.startedAt)
|
|
354
|
+
? { where: 'elsewhere', pointer: null }
|
|
355
|
+
: { where: 'here' };
|
|
356
|
+
for (let i = attempts.length - 1; i >= 0; i -= 1) {
|
|
357
|
+
const a = attempts[i];
|
|
358
|
+
const delivered = a.runId ? await deliveryOf(join(runsDir, a.runId)) : { sent: 'unknown' };
|
|
359
|
+
if (delivered.sent === true) {
|
|
360
|
+
if (!delivered.pointer)
|
|
361
|
+
return fromThePointer(a);
|
|
362
|
+
return current?.chatId === delivered.pointer.chatId ? { where: 'here' } : { where: 'elsewhere', pointer: delivered.pointer };
|
|
363
|
+
}
|
|
364
|
+
if (delivered.sent === 'unknown')
|
|
365
|
+
return fromThePointer(a);
|
|
366
|
+
if (!a.continuing)
|
|
367
|
+
return { where: 'nowhere', buildsOn: a.buildsOn };
|
|
368
|
+
}
|
|
369
|
+
return { where: 'here' };
|
|
370
|
+
}
|
|
228
371
|
/**
|
|
229
372
|
* Joins the conversation of another session in the same group, when there is one.
|
|
230
373
|
*
|
|
@@ -469,23 +612,42 @@ export async function runTask(transport, session, task, deps) {
|
|
|
469
612
|
* The checks about a clean tree, now that the runner has committed. A done task whose tree is
|
|
470
613
|
* still not clean after the commit has failed; any other ending keeps its own reason, and the
|
|
471
614
|
* results are recorded all the same.
|
|
615
|
+
*
|
|
616
|
+
* Not once the operator has stopped the run, for the reason a stopped round at the gate records
|
|
617
|
+
* nothing: these checks cannot run then, and one cut short or never started reads as failed ("the
|
|
618
|
+
* operator stopped the run before this check ran"). Such rows were recorded as the task's results
|
|
619
|
+
* and named as failures in its handoff, and a done task was failed on them. A Stop is no verdict
|
|
620
|
+
* on the work, whether it came before these checks or while they ran: they decide nothing, and a
|
|
621
|
+
* task that was done but whose tree was never judged ends `aborted`, as every Stop ends a task,
|
|
622
|
+
* which "Continue" carries on to be judged.
|
|
472
623
|
*/
|
|
473
624
|
let afterCommitResults = [];
|
|
474
625
|
if (afterCommitChecks.length > 0 && runAfterCommit) {
|
|
475
|
-
const outcomes =
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
626
|
+
const outcomes = signal?.aborted
|
|
627
|
+
? []
|
|
628
|
+
: await runAfterCommit(afterCommitChecks).catch((e) => {
|
|
629
|
+
sink.event('checks-after-commit-error', { error: String(e) }, `the checks after the commit could not run: ${e.message}`, 'warn');
|
|
630
|
+
return [];
|
|
631
|
+
});
|
|
632
|
+
if (signal?.aborted) {
|
|
633
|
+
sink.event('checks-after-commit-stopped', { checks: afterCommitChecks.map((c) => c.name) }, `the operator stopped the run, so the check(s) after the commit decide nothing: ${afterCommitChecks.map((c) => c.name).join(', ')}`, 'warn');
|
|
634
|
+
if (status === 'done') {
|
|
635
|
+
status = 'aborted';
|
|
636
|
+
reason = 'stopped by the operator before the checks after the commit had decided';
|
|
637
|
+
}
|
|
482
638
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
639
|
+
else {
|
|
640
|
+
afterCommitResults = outcomes.map((o) => ({ name: `${o.check.name} (after the commit)`, passed: o.passed, detail: o.detail }));
|
|
641
|
+
for (const o of outcomes) {
|
|
642
|
+
sink.event(o.passed ? 'check-passed' : 'check-failed', { name: o.check.name, detail: o.detail, afterCommit: true }, `${o.passed ? 'passed' : 'FAILED'} after the commit: ${o.check.name} — ${o.detail}`, o.passed ? 'info' : 'warn');
|
|
643
|
+
}
|
|
644
|
+
const failedAfter = outcomes.filter((o) => !o.passed);
|
|
645
|
+
if (status === 'done' && failedAfter.length > 0) {
|
|
646
|
+
status = 'failed';
|
|
647
|
+
reason =
|
|
648
|
+
`after the runner's commit${vcsAfter?.commit ? ` (${vcsAfter.commit.slice(0, 8)})` : ''}${vcsAfter?.problem ? `, which did not happen: ${vcsAfter.problem}` : ''}, ` +
|
|
649
|
+
`${failedAfter.length} check(s) about the working tree still failed: ${failedAfter.map((o) => `${o.check.name} (${o.detail})`).join('; ')}`;
|
|
650
|
+
}
|
|
489
651
|
}
|
|
490
652
|
}
|
|
491
653
|
/*
|
|
@@ -494,10 +656,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
494
656
|
* tree is dirty" is not left standing beside a commit on the record.
|
|
495
657
|
*/
|
|
496
658
|
if (status !== 'done' && vcsAfter?.commit && vcsAfter.commit !== task.vcs?.commit) {
|
|
497
|
-
reason =
|
|
498
|
-
`${
|
|
499
|
-
`${vcsAfter.afterCommit ? (vcsAfter.afterCommit.clean ? '; the working tree is clean' : `; still uncommitted: ${vcsAfter.afterCommit.changed.slice(0, 8).join(', ')}`) : ''}.`;
|
|
500
|
-
reason = reason.trim();
|
|
659
|
+
reason = sentences(reason, `After that, the runner committed the task's changes as ${vcsAfter.commit.slice(0, 8)} on ${vcsAfter.branch}` +
|
|
660
|
+
`${vcsAfter.afterCommit ? (vcsAfter.afterCommit.clean ? '; the working tree is clean' : `; still uncommitted: ${vcsAfter.afterCommit.changed.slice(0, 8).join(', ')}`) : ''}.`);
|
|
501
661
|
}
|
|
502
662
|
if (vcsAfter?.branch) {
|
|
503
663
|
await record('VERSION CONTROL', [
|
|
@@ -548,8 +708,6 @@ export async function runTask(transport, session, task, deps) {
|
|
|
548
708
|
await setTask((t) => {
|
|
549
709
|
t.handoff = composeHandoff(t, notRun);
|
|
550
710
|
});
|
|
551
|
-
// Its last changes, checks and commit included, go to the Desktop copies too.
|
|
552
|
-
deps.onWorkChanged?.();
|
|
553
711
|
sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
|
|
554
712
|
await log.close();
|
|
555
713
|
/*
|
|
@@ -717,67 +875,59 @@ export async function runTask(transport, session, task, deps) {
|
|
|
717
875
|
sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
|
|
718
876
|
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.`);
|
|
719
877
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
* A selection that cannot be copied stops the task before anything is sent: the chat would
|
|
743
|
-
* otherwise work without the files it was promised. The copies already there are kept.
|
|
744
|
-
*/
|
|
745
|
-
const result = await mirrorProject({
|
|
746
|
-
rootDir: sourceRoot,
|
|
747
|
-
// `rules-engine` in a plan usually means the repository's folder, wherever the root is.
|
|
748
|
-
alsoUnder: [repoDirOf(session)],
|
|
749
|
-
includeDirs: session.mirror.includeDirs.length ? session.mirror.includeDirs : ['.'],
|
|
750
|
-
excludeDirs: session.mirror.excludeDirs,
|
|
751
|
-
targetDir,
|
|
752
|
-
namePrefix: projectName,
|
|
753
|
-
separator: cfg.projectMirror.separator,
|
|
754
|
-
txtMode: cfg.projectMirror.txtMode,
|
|
755
|
-
// The session's own switches win: they are what the operator ticked for this project.
|
|
756
|
-
// Settings only supply the fallback for a session saved before they existed.
|
|
757
|
-
respectGitignore: session.mirror.respectGitignore ?? cfg.projectMirror.respectGitignore,
|
|
758
|
-
ignoreDirs: cfg.projectMirror.ignoreDirs,
|
|
759
|
-
includeEnvFiles: session.mirror.includeEnvFiles ?? cfg.projectMirror.includeEnvFiles,
|
|
760
|
-
maxFileBytes: cfg.projectMirror.maxFileBytes,
|
|
761
|
-
}).catch((e) => (e instanceof MirrorSelectionError ? e : Promise.reject(e)));
|
|
762
|
-
if (result instanceof MirrorSelectionError) {
|
|
763
|
-
sink.event('mirror-refused', { error: result.message }, result.message, 'error');
|
|
764
|
-
return await finish('failed', `${result.message} Nothing was sent to the chat. Fix the files to attach and queue the task again.`);
|
|
878
|
+
/*
|
|
879
|
+
* "Continue" carries a task on in the conversation that has it, which is the session's own
|
|
880
|
+
* unless the session has moved since — a later task's retry in a fresh conversation moves it.
|
|
881
|
+
* Then "carry on; the assignment is the one you were given above", sent where the session is
|
|
882
|
+
* now, landed in a conversation that had never seen the task. So the attempt's own conversation
|
|
883
|
+
* is opened again and the session goes back to it, the later queued tasks with it; where that
|
|
884
|
+
* cannot be done, a fresh one is opened and the task goes out in full, with the contract,
|
|
885
|
+
* saying that it continues an earlier attempt (see `composeOpening`).
|
|
886
|
+
*
|
|
887
|
+
* An attempt stopped before its task was sent gave it to no conversation at all, so "carry on"
|
|
888
|
+
* has nothing to point at anywhere: the task goes out in full where the session is, as it would
|
|
889
|
+
* have the first time, since no chat has done any of it. See `conversationOfAttempt`.
|
|
890
|
+
*/
|
|
891
|
+
/** What the opening says about the attempts before this one; see `composeOpening`. */
|
|
892
|
+
let continuing = task.continuing;
|
|
893
|
+
let buildsOn = task.buildsOn;
|
|
894
|
+
if (task.continuing) {
|
|
895
|
+
const found = await conversationOfAttempt(cfg.resolved.runsDir, task.attempts ?? [], session.chat);
|
|
896
|
+
if (found.where === 'nowhere') {
|
|
897
|
+
continuing = undefined;
|
|
898
|
+
buildsOn = found.buildsOn;
|
|
899
|
+
sink.event('continue-never-sent', { fromAttempt: task.continuing.fromAttempt }, 'the attempt this continues was stopped before its task reached any conversation, so the task goes out in full');
|
|
765
900
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
901
|
+
if (found.where === 'elsewhere') {
|
|
902
|
+
const own = found.pointer;
|
|
903
|
+
// By its id only. Every conversation of a session carries the session's name, the fresh
|
|
904
|
+
// ones a retry opens included, so the name would find one of those just as readily.
|
|
905
|
+
const back = !!own && (await transport.openConversation(own.chatId).catch(() => false));
|
|
906
|
+
if (!back)
|
|
907
|
+
await transport.newChat();
|
|
908
|
+
const chat = back && own ? own : undefined;
|
|
909
|
+
session.chat = chat;
|
|
910
|
+
session.contractSent = !!chat;
|
|
911
|
+
await store.updateSession(session.id, (s) => {
|
|
912
|
+
s.chat = chat;
|
|
913
|
+
s.contractSent = !!chat;
|
|
914
|
+
});
|
|
915
|
+
sink.event(back ? 'chat-returned' : 'chat-fresh-for-continue', { chatId: chat?.chatId ?? null, from: own?.chatId ?? null }, back
|
|
916
|
+
? `continuing in the task's own conversation "${own?.name}", where it was stopped; the session carries on there`
|
|
917
|
+
: `the conversation this task was stopped in is not the session's any more and ${own ? 'could not be opened again' : 'is not on record'}, so it continues in a fresh one, sent in full`, back ? 'info' : 'warn');
|
|
918
|
+
/*
|
|
919
|
+
* The picker belongs to the conversation, and the run applied the session's model to the one
|
|
920
|
+
* it started in, not to this one: a fresh conversation opens on the chat's default, and the
|
|
921
|
+
* record would go on naming the model the session asked for. Applied again here, as the
|
|
922
|
+
* fresh-chat retry in `runSession` does for the same reason.
|
|
923
|
+
*/
|
|
924
|
+
const modelNow = await applySessionModel(transport, session, bus, cfg);
|
|
925
|
+
if (effectiveModels(session, cfg).model) {
|
|
926
|
+
await store.updateSession(session.id, (s) => {
|
|
927
|
+
s.modelInUse = modelNow;
|
|
928
|
+
});
|
|
771
929
|
}
|
|
772
930
|
}
|
|
773
|
-
for (const s of result.skipped.slice(0, 20)) {
|
|
774
|
-
sink.event('mirror-skipped', { ...s }, `not copied: ${s.relPath} (${s.reason})`);
|
|
775
|
-
}
|
|
776
|
-
const all = Object.values(result.mapping).sort();
|
|
777
|
-
mirrorFiles = all.slice(0, cfg.projectMirror.maxAttachedFiles).map((n) => join(targetDir, n));
|
|
778
|
-
if (all.length > mirrorFiles.length) {
|
|
779
|
-
sink.event('mirror-truncated', { total: all.length, attached: mirrorFiles.length }, `only the first ${mirrorFiles.length} of ${all.length} mirrored files will be attached`, 'warn');
|
|
780
|
-
}
|
|
781
931
|
}
|
|
782
932
|
// --- opening messages -------------------------------------------------------------
|
|
783
933
|
const { content: level1 } = await store.getLevel1();
|
|
@@ -788,9 +938,11 @@ export async function runTask(transport, session, task, deps) {
|
|
|
788
938
|
prompt: task.prompt,
|
|
789
939
|
taskTitle: task.title,
|
|
790
940
|
taskNumber,
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
941
|
+
// Said of the session's conversation, so with none on record nothing has had it: this run
|
|
942
|
+
// entered a fresh one (see `enterSessionConversation`), whatever an older record says.
|
|
943
|
+
contractAlreadySent: session.contractSent && !!session.chat,
|
|
944
|
+
continuing,
|
|
945
|
+
buildsOn,
|
|
794
946
|
workDirNote: workingDirNote(work),
|
|
795
947
|
vcsNote: prepared.note,
|
|
796
948
|
readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
|
|
@@ -806,14 +958,27 @@ export async function runTask(transport, session, task, deps) {
|
|
|
806
958
|
});
|
|
807
959
|
await record('OPENING MESSAGE', opening.firstMessage);
|
|
808
960
|
let lastMarkdown = '';
|
|
961
|
+
// The task is always in the last message of the opening; a first one before it is the contract.
|
|
962
|
+
const taskIndex = opening.messages.length - 1;
|
|
963
|
+
/** Whether this opening's contract has been answered, to be recorded once the conversation is. */
|
|
964
|
+
let contractAnswered = false;
|
|
809
965
|
for (const [index, message] of opening.messages.entries()) {
|
|
810
966
|
if (signal?.aborted)
|
|
811
967
|
return await finish('aborted', 'stopped before the task was sent');
|
|
812
|
-
const isFirstOfTask = index === 0;
|
|
813
|
-
const attach = isFirstOfTask && mirrorFiles.length ? mirrorFiles : [];
|
|
814
968
|
await pacer.throttleSend();
|
|
815
|
-
const before = await transport.sendAndConfirm(message
|
|
816
|
-
|
|
969
|
+
const before = await transport.sendAndConfirm(message);
|
|
970
|
+
const carriesTask = index === taskIndex;
|
|
971
|
+
sink.event('message-sent', { index, of: opening.messages.length, task: carriesTask, chatId: session.chat?.chatId ?? null, chars: message.length }, `message ${index + 1}/${opening.messages.length} sent`);
|
|
972
|
+
/*
|
|
973
|
+
* Where this attempt's task is, kept in its run folder once the message carrying it is in a
|
|
974
|
+
* conversation, and not before: what "Continue" goes back to (see `conversationOfAttempt`). It
|
|
975
|
+
* was kept as soon as the attempt entered a conversation, so an attempt stopped before its task
|
|
976
|
+
* went out — or after a new conversation had answered only the contract — left a record saying
|
|
977
|
+
* the task was there, and "Continue" told that chat to carry on with an assignment it never had.
|
|
978
|
+
* A new conversation is known only once it answers, and is kept where it is registered, below.
|
|
979
|
+
*/
|
|
980
|
+
if (carriesTask && session.chat)
|
|
981
|
+
await savePointer(log.path('chat.json'), session.chat);
|
|
817
982
|
const reply = await transport.waitForReply(before);
|
|
818
983
|
lastMarkdown = reply.markdown;
|
|
819
984
|
await saveReply(`opening-${index + 1}`, reply);
|
|
@@ -835,16 +1000,31 @@ export async function runTask(transport, session, task, deps) {
|
|
|
835
1000
|
await store.updateSession(session.id, (s) => {
|
|
836
1001
|
s.chat = chat;
|
|
837
1002
|
});
|
|
838
|
-
|
|
1003
|
+
if (carriesTask)
|
|
1004
|
+
await savePointer(log.path('chat.json'), chat);
|
|
839
1005
|
sink.event('chat-registered', { ...chat }, `chat: ${name}`);
|
|
840
1006
|
}
|
|
841
1007
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1008
|
+
/*
|
|
1009
|
+
* The contract is in the conversation once its message has been answered, whether or not the
|
|
1010
|
+
* task follows. Recorded only after the whole opening, a Stop between the two left a
|
|
1011
|
+
* conversation that had the contract on record as one that had not, and the next task sent it
|
|
1012
|
+
* a second time.
|
|
1013
|
+
*
|
|
1014
|
+
* And recorded once that conversation is on record itself, which may be a message later: its
|
|
1015
|
+
* id is read from the page's address, which may not have changed yet when the contract is
|
|
1016
|
+
* answered (the registration above tries again after the task). Recorded before, a Stop in
|
|
1017
|
+
* between left a session with no conversation that said it had the contract, and the next
|
|
1018
|
+
* task opened a fresh one and sent it only the reminder that the contract "still applies".
|
|
1019
|
+
*/
|
|
1020
|
+
if (index < taskIndex)
|
|
1021
|
+
contractAnswered = true;
|
|
1022
|
+
if (contractAnswered && session.chat && !session.contractSent) {
|
|
1023
|
+
session.contractSent = true;
|
|
1024
|
+
await store.updateSession(session.id, (s) => {
|
|
1025
|
+
s.contractSent = true;
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
848
1028
|
}
|
|
849
1029
|
// --- the loop ---------------------------------------------------------------------
|
|
850
1030
|
// How many times the checks have been run for this task, and how many times they may be.
|
|
@@ -862,7 +1042,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
862
1042
|
* files are committed and marked, because refusing would leave the tree dirty and the next
|
|
863
1043
|
* task refusing to start over it.
|
|
864
1044
|
*/
|
|
865
|
-
const willCommit = !!(session.vcs?.
|
|
1045
|
+
const willCommit = !!(session.vcs?.commitOnFinish && trackedRepoOf(session));
|
|
866
1046
|
/** The runner's own checks that have been pointed out to the chat once already. */
|
|
867
1047
|
const pointedOut = new Set();
|
|
868
1048
|
/**
|
|
@@ -870,9 +1050,11 @@ export async function runTask(transport, session, task, deps) {
|
|
|
870
1050
|
*
|
|
871
1051
|
* Returns `accept` when there is nothing to check or everything passed, `retry` when the
|
|
872
1052
|
* failures have been sent back for Copilot to fix, `give-up` when it has had its rounds,
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
1053
|
+
* `environment` when the machine could not run the checks at all, `invalid` when every
|
|
1054
|
+
* failing check was refused for good (see `refusedForGood`), and `stopped` when the operator
|
|
1055
|
+
* stopped the run while they ran. The checks run here, at the end, and not as steps: they are
|
|
1056
|
+
* not work, they are the question of whether the work happened, and a task cannot be trusted
|
|
1057
|
+
* to answer that about itself.
|
|
876
1058
|
*/
|
|
877
1059
|
const gateOnChecks = async () => {
|
|
878
1060
|
const every = [...(task.checks ?? []), ...activeChecks(reviewChecks)];
|
|
@@ -889,6 +1071,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
889
1071
|
// a missing interpreter asked nothing of the work and must not cost the task one.
|
|
890
1072
|
const round = checkRounds + 1;
|
|
891
1073
|
sink.event('checks-started', { round, count: checks.length }, `checking the task against ${checks.length} condition(s)`);
|
|
1074
|
+
/** The commands this round refused only for what the project's files hold now; see `lineRefusal`. */
|
|
1075
|
+
const refusedForTheFiles = new Set();
|
|
892
1076
|
const checkOptions = {
|
|
893
1077
|
cwd: work.cwd,
|
|
894
1078
|
tracker,
|
|
@@ -900,16 +1084,61 @@ export async function runTask(transport, session, task, deps) {
|
|
|
900
1084
|
* reviewer's — is refused rather than held: there is no approval screen here. Requests to
|
|
901
1085
|
* this machine are not downloads and still run. See `network.ts`.
|
|
902
1086
|
*/
|
|
903
|
-
deny: (command, shell, cwd) =>
|
|
1087
|
+
deny: (command, shell, cwd) => {
|
|
1088
|
+
const refused = checkCommandRefusal(command, shell, cfg.execution, { roots: confinement.roots, cwd });
|
|
1089
|
+
if (refused && !lineRefusal(command, shell, cwd, cfg.execution, confinement.roots))
|
|
1090
|
+
refusedForTheFiles.add(command);
|
|
1091
|
+
return refused;
|
|
1092
|
+
},
|
|
904
1093
|
roots: confinement.roots,
|
|
905
1094
|
repoDir: willCommit ? repoDirOf(session) : undefined,
|
|
906
1095
|
baseCommit: prepared.vcs.baseCommit,
|
|
907
1096
|
// The same shell a step that named none is given, so the gate and the work it judges
|
|
908
1097
|
// cannot have been read by different interpreters.
|
|
909
1098
|
defaultShell,
|
|
1099
|
+
// What a check says is quoted to the chat — here, and in a reviewer's brief — so the
|
|
1100
|
+
// operator's own patterns are applied where it is said, with the built-in shapes.
|
|
1101
|
+
redactPatterns: cfg.report.redactPatterns,
|
|
910
1102
|
};
|
|
911
1103
|
runAfterCommit = (cs) => runChecks(cs, checkOptions);
|
|
912
|
-
const
|
|
1104
|
+
const ran = await runChecks(checks, checkOptions);
|
|
1105
|
+
/*
|
|
1106
|
+
* The operator stopped the run, which is not a verdict on the work. The check in flight was
|
|
1107
|
+
* killed and the ones after it never ran, so what this round "found" is only the Stop: it is
|
|
1108
|
+
* not recorded as the task's results, not counted as a rejected "done", and the task ends
|
|
1109
|
+
* `aborted` — continuable, like every other Stop. It used to be read as a failed round and
|
|
1110
|
+
* end the task `failed`, which "Continue" does not offer.
|
|
1111
|
+
*/
|
|
1112
|
+
if (deps.signal?.aborted) {
|
|
1113
|
+
sink.event('checks-stopped', { round }, 'the operator stopped the run while the checks ran; this round decides nothing', 'warn');
|
|
1114
|
+
return 'stopped';
|
|
1115
|
+
}
|
|
1116
|
+
/** Refused before it ran for its own command line, which nothing in the tree decides; see `lineRefusal`. */
|
|
1117
|
+
const refusedForGood = (o) => !!o.refusedBeforeRunning && !refusedForTheFiles.has((o.check.run ?? '').trim());
|
|
1118
|
+
/*
|
|
1119
|
+
* A reviewer's check the runner now refuses for its own line: dropped, and the round judged on
|
|
1120
|
+
* the others. The review keeps no check the runner refuses (see `validateDerivedChecks`), but
|
|
1121
|
+
* one it kept can be refused later — a deny pattern added in Settings, the allowlist narrowed,
|
|
1122
|
+
* the project folders changed — and it then outlives every attempt, Continue and a rerun
|
|
1123
|
+
* included. Read as a check of the task's that can never run, it ended each of them at the
|
|
1124
|
+
* first "done" as `invalid-check`, with no way out; before that it was sent back as work to
|
|
1125
|
+
* fix. The finding stands without it, as at the review, and the next reviewer judges the work.
|
|
1126
|
+
*/
|
|
1127
|
+
const unrunnable = ran.filter((o) => isDerivedCheck(o.check) && refusedForGood(o));
|
|
1128
|
+
if (unrunnable.length > 0) {
|
|
1129
|
+
const why = new Map(unrunnable.map((o) => [o.check.name, o.detail]));
|
|
1130
|
+
reviewChecks = reviewChecks.map((rc) => rc.state === 'active' && why.has(rc.check.name)
|
|
1131
|
+
? { ...rc, state: 'dropped', droppedBecause: `the runner refuses it now, before it runs: ${why.get(rc.check.name)}` }
|
|
1132
|
+
: rc);
|
|
1133
|
+
await setTask((t) => {
|
|
1134
|
+
t.reviewChecks = reviewChecks;
|
|
1135
|
+
});
|
|
1136
|
+
for (const o of unrunnable) {
|
|
1137
|
+
sink.event('review-check-blocked', { round, name: o.check.name, detail: o.detail }, `the check "${o.check.name}" is refused by the runner now and never ran; dropped, the finding stands without it — ${o.detail}`, 'warn');
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const outcomes = ran.filter((o) => !unrunnable.includes(o));
|
|
1141
|
+
lastOutcomes = outcomes;
|
|
913
1142
|
/*
|
|
914
1143
|
* The runner's own checks are advice given once, not a gate: a second "done" with the finding
|
|
915
1144
|
* still there is committed and the finding stays on the task. Refusing instead would leave the
|
|
@@ -946,18 +1175,37 @@ export async function runTask(transport, session, task, deps) {
|
|
|
946
1175
|
sink.event('checks-environment', { round, requested: problem.requested, available: problem.available, checks: outcomes.filter((o) => o.environmentProblem).map((o) => o.check.name) }, problem.message, 'error');
|
|
947
1176
|
return 'environment';
|
|
948
1177
|
}
|
|
1178
|
+
/*
|
|
1179
|
+
* The checks, not the work — the same reasoning as the machine above.
|
|
1180
|
+
*
|
|
1181
|
+
* Every check still failing was refused before it ran, for its own command line, the
|
|
1182
|
+
* settings, or a folder or file outside the project: nothing the chat does can make it run.
|
|
1183
|
+
* These are the plan's checks and the runner's own; a reviewer's is dropped instead (above).
|
|
1184
|
+
* Sending it back as "the task is not finished yet" spent the rounds on it, and with version
|
|
1185
|
+
* control on the second "done" with an unchanged tree ended the task `blocked` as no progress,
|
|
1186
|
+
* to be retried in fresh conversations that could not fix it either. So nothing is sent, the
|
|
1187
|
+
* round is not counted, and the task ends `failed` with `invalid-check`. A check refused only
|
|
1188
|
+
* for what a script it runs holds, or for a tool not installed yet, still goes back: those the
|
|
1189
|
+
* work can change (see `lineRefusal`).
|
|
1190
|
+
*/
|
|
1191
|
+
const failed = outcomes.filter((o) => !o.passed);
|
|
1192
|
+
if (failed.length > 0 && failed.every(refusedForGood)) {
|
|
1193
|
+
stopCode = 'invalid-check';
|
|
1194
|
+
for (const o of failed) {
|
|
1195
|
+
sink.event('check-failed', { name: o.check.name, detail: o.detail }, `FAILED: ${o.check.name} — ${o.detail}`, 'warn');
|
|
1196
|
+
}
|
|
1197
|
+
sink.event('checks-invalid', { round, checks: failed.map((o) => o.check.name) }, `${failed.length} check(s) were refused before they ran, and nothing the chat does can change that; the task ends on the checks, not on the work`, 'error');
|
|
1198
|
+
return 'invalid';
|
|
1199
|
+
}
|
|
949
1200
|
checkRounds = round;
|
|
950
1201
|
for (const o of outcomes) {
|
|
951
1202
|
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');
|
|
952
1203
|
}
|
|
953
|
-
const failed = outcomes.filter((o) => !o.passed);
|
|
954
1204
|
if (failed.length === 0) {
|
|
955
1205
|
sink.event('checks-passed', { count: outcomes.length }, `all ${outcomes.length} check(s) passed`);
|
|
956
1206
|
return 'accept';
|
|
957
1207
|
}
|
|
958
1208
|
stats.doneRejected += 1;
|
|
959
|
-
if (deps.signal?.aborted)
|
|
960
|
-
return 'give-up';
|
|
961
1209
|
// The same failures as last time, and not a file changed since they were reported.
|
|
962
1210
|
const stuck = progress.afterFailedChecks(await treeNow(), failed.map((o) => ({ name: o.check.name, detail: o.detail })));
|
|
963
1211
|
if (stuck) {
|
|
@@ -987,15 +1235,28 @@ export async function runTask(transport, session, task, deps) {
|
|
|
987
1235
|
sink.event('checks-exhausted', { rounds: checkRounds - 1 }, `${failed.length} check(s) still failing after ${maxCheckRounds} attempt(s); the task is closed as failed`, 'warn');
|
|
988
1236
|
return 'give-up';
|
|
989
1237
|
}
|
|
1238
|
+
// A Stop that came after every check had run: the round stands, and nothing more is sent.
|
|
1239
|
+
if (deps.signal?.aborted)
|
|
1240
|
+
return 'stopped';
|
|
990
1241
|
// The failures go back exactly the way step output does: a message with a file attached,
|
|
991
1242
|
// because a compiler's opinion belongs in a file and not in a chat bubble.
|
|
992
1243
|
const path = join(reportsDir, `checks-${checkRounds}.txt`);
|
|
993
|
-
|
|
994
|
-
|
|
1244
|
+
/*
|
|
1245
|
+
* Check output is uploaded too, so it is redacted the same way a step report is — the file and
|
|
1246
|
+
* the message beside it alike, with the operator's patterns on top of the built-in shapes. The
|
|
1247
|
+
* message quotes each failing check's command and value, and it used to get the shapes only,
|
|
1248
|
+
* so a key in a company's own format went to the chat in it while the file beside it hid it.
|
|
1249
|
+
*/
|
|
1250
|
+
const checkReport = failureReport(outcomes, cfg.report.redactPatterns);
|
|
995
1251
|
await writeFile(path, checkReport, 'utf8');
|
|
996
1252
|
await record(`CHECKS ${checkRounds}`, checkReport);
|
|
997
|
-
const message = failureMessage(outcomes, checkRounds, maxCheckRounds);
|
|
1253
|
+
const message = failureMessage(outcomes, checkRounds, maxCheckRounds, cfg.report.redactPatterns);
|
|
998
1254
|
await record(`CHECKS ${checkRounds} MESSAGE SENT`, message);
|
|
1255
|
+
// Said as a step report's redaction is, so a checks round that hid something is not silent.
|
|
1256
|
+
const redactions = failureRedactions(outcomes, cfg.report.redactPatterns);
|
|
1257
|
+
if (redactions.length > 0) {
|
|
1258
|
+
sink.event('report-redacted', { round: checkRounds, iteration: iterations, checks: true, redactions }, `redacted from the checks of round ${checkRounds} before upload: ${redactions.map((r) => `${r.count}× ${r.name}`).join(', ')}`, 'warn');
|
|
1259
|
+
}
|
|
999
1260
|
await pacer.throttleSend();
|
|
1000
1261
|
const before = await transport.sendAndConfirm(message, [path]);
|
|
1001
1262
|
const next = await transport.waitForReply(before);
|
|
@@ -1040,6 +1301,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1040
1301
|
* `accept` — reviewed and passed, or not reviewed at all.
|
|
1041
1302
|
* `retry` — the reviewer found problems and they have been sent back to the implementer.
|
|
1042
1303
|
* `give-up` — the rounds are spent and the findings are still standing.
|
|
1304
|
+
* `stopped` — the operator stopped the run while the review was going.
|
|
1043
1305
|
*/
|
|
1044
1306
|
const gateOnReview = async (closing) => {
|
|
1045
1307
|
if (!reviewWanted) {
|
|
@@ -1048,7 +1310,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1048
1310
|
}
|
|
1049
1311
|
reviewRounds += 1;
|
|
1050
1312
|
const model = effectiveModels(session, cfg).reviewModel;
|
|
1051
|
-
const repoDir =
|
|
1313
|
+
const repoDir = trackedRepoOf(session);
|
|
1052
1314
|
/*
|
|
1053
1315
|
* What changed, read from git rather than from anybody's account of it.
|
|
1054
1316
|
*
|
|
@@ -1072,7 +1334,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1072
1334
|
const picked = await transport.selectModel(model).catch((e) => ({ ok: false, current: null, reason: e.message }));
|
|
1073
1335
|
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');
|
|
1074
1336
|
}
|
|
1075
|
-
outcome = await runReview(transport, session, task, {
|
|
1337
|
+
outcome = await runReview(sendsUntilStopped(transport, signal), session, task, {
|
|
1076
1338
|
cfg,
|
|
1077
1339
|
authorizer,
|
|
1078
1340
|
signal,
|
|
@@ -1111,6 +1373,25 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1111
1373
|
}
|
|
1112
1374
|
// A reviewer that left its own server listening once failed the work for it.
|
|
1113
1375
|
roundLeftovers = [...roundLeftovers, ...(await reap(beforeReview, `review round ${reviewRounds}`))];
|
|
1376
|
+
/*
|
|
1377
|
+
* Stopped by the operator: neither a verdict on the work nor the machinery failing.
|
|
1378
|
+
*
|
|
1379
|
+
* The review reports a Stop the way it reports a broken browser — an error, "the run was
|
|
1380
|
+
* stopped" — and an error accepts the work unreviewed (below), so a task stopped in its
|
|
1381
|
+
* review was closed `done` and committed, and "Continue" could not carry it on. The round is
|
|
1382
|
+
* recorded as cut short, and the task ends `aborted`, as every Stop ends it.
|
|
1383
|
+
*/
|
|
1384
|
+
if (signal?.aborted) {
|
|
1385
|
+
await saveReview({
|
|
1386
|
+
verdict: 'error',
|
|
1387
|
+
rounds: reviewRounds,
|
|
1388
|
+
stepsRun: outcome.stepsRun,
|
|
1389
|
+
problem: `the run was stopped by the operator during review round ${reviewRounds}, before a verdict was acted on`,
|
|
1390
|
+
model: model || undefined,
|
|
1391
|
+
});
|
|
1392
|
+
sink.event('review-stopped', { round: reviewRounds, stepsRun: outcome.stepsRun }, `the operator stopped the run during review round ${reviewRounds}; the work is neither accepted nor sent back`, 'warn');
|
|
1393
|
+
return 'stopped';
|
|
1394
|
+
}
|
|
1114
1395
|
/*
|
|
1115
1396
|
* Which findings an earlier round already raised.
|
|
1116
1397
|
*
|
|
@@ -1233,6 +1514,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1233
1514
|
return 'give-up';
|
|
1234
1515
|
}
|
|
1235
1516
|
sink.event('review-failed', { round: reviewRounds, findings: outcome.findings.length }, `the review found ${outcome.findings.length} problem(s); sending them back to be fixed`, 'warn');
|
|
1517
|
+
// A Stop that came after the verdict: it stands on the record, and nothing more is sent.
|
|
1518
|
+
if (signal?.aborted)
|
|
1519
|
+
return 'stopped';
|
|
1236
1520
|
await pacer.throttleSend();
|
|
1237
1521
|
// The reviewer quotes output in its evidence, so the message gets the same treatment.
|
|
1238
1522
|
// Recorded as sent, because what reached the chat is what the next question is about.
|
|
@@ -1260,6 +1544,54 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1260
1544
|
: `an independent review found ${findings.length} problem(s) that were still there after ` +
|
|
1261
1545
|
`${maxReviewRounds} round(s) of fixing: ${listed}`;
|
|
1262
1546
|
};
|
|
1547
|
+
/**
|
|
1548
|
+
* What a reported "done" comes to: the checks, then the review, either of which may end the
|
|
1549
|
+
* task here. Null means the work went back to the chat and the loop goes on.
|
|
1550
|
+
*
|
|
1551
|
+
* One place for both ways a "done" arrives — on its own, and after the report of its last
|
|
1552
|
+
* steps — which used to be two copies of the same lines, and the Stop has to be answered the
|
|
1553
|
+
* same way in both. A Stop while the checks run, or during the review, ends the task `aborted`;
|
|
1554
|
+
* one that comes after the checks have accepted the work ends it before a review conversation
|
|
1555
|
+
* is opened for it. A verdict the checks reached on their own — out of rounds, no progress,
|
|
1556
|
+
* checks that cannot run — stands.
|
|
1557
|
+
*
|
|
1558
|
+
* With the review switched off there is no conversation to keep from opening, and nothing left
|
|
1559
|
+
* to judge once the checks have accepted the work: a Stop then leaves the task done, its review
|
|
1560
|
+
* recorded as skipped. Ended `aborted` "before the review", it named a review that did not
|
|
1561
|
+
* exist, and a batch that stops on a failure counted it as one and skipped the sessions after.
|
|
1562
|
+
*/
|
|
1563
|
+
const settleDone = async (summary) => {
|
|
1564
|
+
const verdict = await gateOnChecks();
|
|
1565
|
+
if (verdict === 'stopped')
|
|
1566
|
+
return await finish('aborted', 'stopped by the operator while the checks ran', summary, lastMarkdown);
|
|
1567
|
+
if (verdict === 'environment') {
|
|
1568
|
+
stopCode = 'environment';
|
|
1569
|
+
return await finish('failed', shellProblemReason(environmentProblem), summary, lastMarkdown);
|
|
1570
|
+
}
|
|
1571
|
+
// Its stop code is set by the gate: every failing check was refused for good.
|
|
1572
|
+
if (verdict === 'invalid')
|
|
1573
|
+
return await finish('failed', checksFailedReason(lastOutcomes), summary, lastMarkdown);
|
|
1574
|
+
if (verdict === 'give-up') {
|
|
1575
|
+
if (noProgressReason)
|
|
1576
|
+
return await finish('blocked', noProgressReason, summary, lastMarkdown);
|
|
1577
|
+
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1578
|
+
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1579
|
+
stopCode = 'invalid-check';
|
|
1580
|
+
return await finish('failed', checksFailedReason(lastOutcomes), summary, lastMarkdown);
|
|
1581
|
+
}
|
|
1582
|
+
if (verdict === 'retry')
|
|
1583
|
+
return null;
|
|
1584
|
+
if (reviewWanted && signal?.aborted)
|
|
1585
|
+
return await finish('aborted', 'stopped by the operator before the review', summary, lastMarkdown);
|
|
1586
|
+
const reviewed = await gateOnReview(summary);
|
|
1587
|
+
if (reviewed === 'stopped')
|
|
1588
|
+
return await finish('aborted', 'stopped by the operator during the review', summary, lastMarkdown);
|
|
1589
|
+
if (reviewed === 'accept')
|
|
1590
|
+
return await finish('done', undefined, summary, lastMarkdown);
|
|
1591
|
+
if (reviewed === 'give-up')
|
|
1592
|
+
return await finish('blocked', reviewBlockedReason(), summary, lastMarkdown);
|
|
1593
|
+
return null;
|
|
1594
|
+
};
|
|
1263
1595
|
let formatRetries = 0;
|
|
1264
1596
|
/*
|
|
1265
1597
|
* The anti-spin guards.
|
|
@@ -1319,8 +1651,10 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1319
1651
|
await transport.dumpFailure(log.path('failures'), 'format-error');
|
|
1320
1652
|
stopCode = 'format-repair-exhausted';
|
|
1321
1653
|
limitHit = { setting: 'maxFormatRetries', value: cfg.limits.maxFormatRetries };
|
|
1322
|
-
return await finish('limit-reached',
|
|
1323
|
-
|
|
1654
|
+
return await finish('limit-reached',
|
|
1655
|
+
// The parser's detail is a sentence of its own, often with its own full stop.
|
|
1656
|
+
sentences(`format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
|
|
1657
|
+
`the last one refused for this: ${parsed.detail}`, 'Nothing from those replies was run; the work so far is kept.'), undefined, lastMarkdown);
|
|
1324
1658
|
}
|
|
1325
1659
|
await pacer.throttleSend();
|
|
1326
1660
|
const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
|
|
@@ -1424,31 +1758,14 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1424
1758
|
if (blocked) {
|
|
1425
1759
|
sink.event('task-blocked', { tried: reply.tried, needed: reply.needed }, `the task was given up as blocked after ${reply.tried.length} approach(es)`, 'warn');
|
|
1426
1760
|
const early = reply.tried.length < minApproaches
|
|
1427
|
-
? `
|
|
1761
|
+
? `It gave up after ${reply.tried.length} of the ${minApproaches} approaches Settings ask for, having been asked ${earlyBlocks} time(s) for another.`
|
|
1428
1762
|
: '';
|
|
1429
|
-
return await finish('blocked', blockedReason(reply.tried, reply.needed)
|
|
1763
|
+
return await finish('blocked', sentences(blockedReason(reply.tried, reply.needed), early), reply.summary, lastMarkdown);
|
|
1430
1764
|
}
|
|
1431
1765
|
if (done && reply.steps.length === 0) {
|
|
1432
|
-
const
|
|
1433
|
-
if (
|
|
1434
|
-
|
|
1435
|
-
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1436
|
-
}
|
|
1437
|
-
if (verdict === 'give-up') {
|
|
1438
|
-
if (noProgressReason)
|
|
1439
|
-
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1440
|
-
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1441
|
-
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1442
|
-
stopCode = 'invalid-check';
|
|
1443
|
-
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1444
|
-
}
|
|
1445
|
-
if (verdict === 'accept') {
|
|
1446
|
-
const reviewed = await gateOnReview(reply.summary);
|
|
1447
|
-
if (reviewed === 'accept')
|
|
1448
|
-
return await finish('done', undefined, reply.summary, lastMarkdown);
|
|
1449
|
-
if (reviewed === 'give-up')
|
|
1450
|
-
return await finish('blocked', reviewBlockedReason(), reply.summary, lastMarkdown);
|
|
1451
|
-
}
|
|
1766
|
+
const ended = await settleDone(reply.summary);
|
|
1767
|
+
if (ended)
|
|
1768
|
+
return ended;
|
|
1452
1769
|
continue;
|
|
1453
1770
|
}
|
|
1454
1771
|
// --- execute -----------------------------------------------------------------
|
|
@@ -1508,10 +1825,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1508
1825
|
t.status = 'waiting-approval';
|
|
1509
1826
|
});
|
|
1510
1827
|
sink.event('step-proposed', { id: step.id, description: describeStep(step) }, `step ${step.id}: ${describeStep(step)}`);
|
|
1511
|
-
const
|
|
1828
|
+
const decided = await authorizer.authorize(step, { sessionId: session.id, taskId: task.id, iteration: iterations, confinement });
|
|
1512
1829
|
await setTask((t) => {
|
|
1513
1830
|
t.status = 'running';
|
|
1514
1831
|
});
|
|
1832
|
+
// A Stop can land while the answer is awaited or written down, after the look at the signal
|
|
1833
|
+
// above; whatever was decided, a stopped run does not start the step.
|
|
1834
|
+
const decision = decided.action === 'run' && signal?.aborted
|
|
1835
|
+
? { action: 'abort', reason: 'stopped by the operator', by: 'operator' }
|
|
1836
|
+
: decided;
|
|
1515
1837
|
if (decision.action === 'abort') {
|
|
1516
1838
|
results.push(refusedResult(step, decision.reason, decision.by === 'operator' ? 'operator' : 'runner'));
|
|
1517
1839
|
aborted = true;
|
|
@@ -1550,6 +1872,17 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1550
1872
|
sameInARow: previous && previous.signature === signature ? previous.sameInARow + 1 : 1,
|
|
1551
1873
|
});
|
|
1552
1874
|
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`);
|
|
1875
|
+
/*
|
|
1876
|
+
* Stopped while it ran. The signal is looked at before each step, and after the last one
|
|
1877
|
+
* there is no next one to look before: the cut-short step's report was uploaded and the
|
|
1878
|
+
* chat's answer waited for before the top of the loop ended the task, a Stop that took as
|
|
1879
|
+
* long as a reply. It ends here instead, the way a Stop between steps does: the report is
|
|
1880
|
+
* written for the record, and nothing more is sent.
|
|
1881
|
+
*/
|
|
1882
|
+
if (signal?.aborted) {
|
|
1883
|
+
aborted = true;
|
|
1884
|
+
break;
|
|
1885
|
+
}
|
|
1553
1886
|
/*
|
|
1554
1887
|
* The shell itself would not start. The same rule as in the check gate applies, for the
|
|
1555
1888
|
* same reason: nothing was learned about the work, and the next message would be asking
|
|
@@ -1622,9 +1955,6 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1622
1955
|
}
|
|
1623
1956
|
if (aborted)
|
|
1624
1957
|
return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
|
|
1625
|
-
// The files may have changed: the Desktop copies catch up, in the background (see RunDeps).
|
|
1626
|
-
if (results.some((r) => r.outcome !== 'refused' && r.outcome !== 'aborted'))
|
|
1627
|
-
deps.onWorkChanged?.();
|
|
1628
1958
|
// A loop in which every round looks new: see `progress.ts`. After the report is written, so
|
|
1629
1959
|
// the evidence the diagnosis points at is on disk.
|
|
1630
1960
|
const stuck = progress.afterRound(await treeNow(), results);
|
|
@@ -1736,26 +2066,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1736
2066
|
}
|
|
1737
2067
|
}
|
|
1738
2068
|
if (done) {
|
|
1739
|
-
const
|
|
1740
|
-
if (
|
|
1741
|
-
|
|
1742
|
-
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1743
|
-
}
|
|
1744
|
-
if (verdict === 'give-up') {
|
|
1745
|
-
if (noProgressReason)
|
|
1746
|
-
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1747
|
-
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1748
|
-
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1749
|
-
stopCode = 'invalid-check';
|
|
1750
|
-
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1751
|
-
}
|
|
1752
|
-
if (verdict === 'accept') {
|
|
1753
|
-
const reviewed = await gateOnReview(reply.summary);
|
|
1754
|
-
if (reviewed === 'accept')
|
|
1755
|
-
return await finish('done', undefined, reply.summary, lastMarkdown);
|
|
1756
|
-
if (reviewed === 'give-up')
|
|
1757
|
-
return await finish('blocked', reviewBlockedReason(), reply.summary, lastMarkdown);
|
|
1758
|
-
}
|
|
2069
|
+
const ended = await settleDone(reply.summary);
|
|
2070
|
+
if (ended)
|
|
2071
|
+
return ended;
|
|
1759
2072
|
continue;
|
|
1760
2073
|
}
|
|
1761
2074
|
await pacer.settle();
|