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.
Files changed (77) hide show
  1. package/README.md +9 -14
  2. package/dist/src/api/operator.controller.js +31 -42
  3. package/dist/src/api/operator.service.js +663 -367
  4. package/dist/src/api/saveToDesktop.js +1 -1
  5. package/dist/src/api/security.js +40 -10
  6. package/dist/src/api/server.js +75 -12
  7. package/dist/src/api/settings.js +156 -15
  8. package/dist/src/cli.js +108 -97
  9. package/dist/src/config/handWritten.js +36 -0
  10. package/dist/src/config/layout.js +13 -4
  11. package/dist/src/config/lockedPolicy.js +56 -22
  12. package/dist/src/config/schema.js +13 -84
  13. package/dist/src/context/desktopDir.js +44 -0
  14. package/dist/src/exec/authorizer.js +9 -1
  15. package/dist/src/exec/checks.js +240 -23
  16. package/dist/src/exec/dangerous.js +29 -5
  17. package/dist/src/exec/network.js +105 -28
  18. package/dist/src/exec/redaction.js +52 -9
  19. package/dist/src/exec/reportFile.js +4 -5
  20. package/dist/src/exec/runner.js +146 -13
  21. package/dist/src/exec/shells.js +41 -6
  22. package/dist/src/exec/workDir.js +4 -4
  23. package/dist/src/orchestrator/derivedChecks.js +1 -0
  24. package/dist/src/orchestrator/review.js +25 -3
  25. package/dist/src/orchestrator/taskRunner.js +476 -163
  26. package/dist/src/plan/brief.js +37 -44
  27. package/dist/src/plan/importPlan.js +15 -7
  28. package/dist/src/plan/schema.js +64 -29
  29. package/dist/src/plan/script.js +0 -15
  30. package/dist/src/plan/systemGuide.js +2 -177
  31. package/dist/src/protocol/parser.js +5 -1
  32. package/dist/src/session/debugExport.js +1 -1
  33. package/dist/src/session/exportRecord.js +1 -1
  34. package/dist/src/session/exports.js +49 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/vcs/scope.js +29 -6
  37. package/dist/src/vcs/taskVcs.js +78 -10
  38. package/dist/web/404.html +2 -2
  39. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
  40. package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
  41. package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
  42. package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
  43. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  44. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  45. package/dist/web/appearance.html +2 -2
  46. package/dist/web/appearance.txt +12 -12
  47. package/dist/web/defaults.html +2 -2
  48. package/dist/web/defaults.txt +12 -12
  49. package/dist/web/history.html +2 -2
  50. package/dist/web/history.txt +12 -12
  51. package/dist/web/import.html +2 -2
  52. package/dist/web/import.txt +12 -12
  53. package/dist/web/index.html +2 -2
  54. package/dist/web/index.txt +12 -12
  55. package/dist/web/level1.html +2 -2
  56. package/dist/web/level1.txt +12 -12
  57. package/dist/web/presets.html +2 -2
  58. package/dist/web/presets.txt +12 -12
  59. package/dist/web/sessions/view.html +2 -2
  60. package/dist/web/sessions/view.txt +12 -12
  61. package/dist/web/system.html +2 -2
  62. package/dist/web/system.txt +12 -12
  63. package/package.json +18 -10
  64. package/prompts/level1.md +2 -1
  65. package/prompts/organisation.bg.md +0 -1
  66. package/prompts/organisation.en.md +0 -1
  67. package/dist/src/context/contextFiles.js +0 -295
  68. package/dist/src/context/desktopMirror.js +0 -173
  69. package/dist/src/context/projectMirror.js +0 -432
  70. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  71. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  72. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  73. package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +0 -1
  74. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  75. package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
  76. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
  77. /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 { mirrorProject, describeMirror, MirrorSelectionError } from '../context/projectMirror.js';
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
- await transport.open();
201
- await transport.ensureSignedIn();
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 = await runAfterCommit(afterCommitChecks).catch((e) => {
476
- sink.event('checks-after-commit-error', { error: String(e) }, `the checks after the commit could not run: ${e.message}`, 'warn');
477
- return [];
478
- });
479
- afterCommitResults = outcomes.map((o) => ({ name: `${o.check.name} (after the commit)`, passed: o.passed, detail: o.detail }));
480
- for (const o of outcomes) {
481
- 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');
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
- const failedAfter = outcomes.filter((o) => !o.passed);
484
- if (status === 'done' && failedAfter.length > 0) {
485
- status = 'failed';
486
- reason =
487
- `after the runner's commit${vcsAfter?.commit ? ` (${vcsAfter.commit.slice(0, 8)})` : ''}${vcsAfter?.problem ? `, which did not happen: ${vcsAfter.problem}` : ''}, ` +
488
- `${failedAfter.length} check(s) about the working tree still failed: ${failedAfter.map((o) => `${o.check.name} (${o.detail})`).join('; ')}`;
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
- `${reason ?? ''} After that, the runner committed the task's changes as ${vcsAfter.commit.slice(0, 8)} on ${vcsAfter.branch}` +
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
- // --- project mirror, attached to the first message of the task ---------------------
721
- let mirrorFiles = [];
722
- if (session.mirror.enabled && session.mirror.rootDir) {
723
- // One folder per project, and the project's name in front of every file name, so three
724
- // repositories mirrored in turn do not delete each other's copies or collide in the chat.
725
- // The bot's own Desktop copy named as the root means the project it is a copy of. See `mirrorSourceRoot`.
726
- const source = mirrorSourceRoot(cfg, session.mirror.rootDir);
727
- if ('problem' in source) {
728
- sink.event('mirror-refused', { error: source.problem }, source.problem, 'error');
729
- return await finish('failed', `${source.problem} Nothing was sent to the chat.`);
730
- }
731
- if (source.fromCopy) {
732
- sink.event('mirror-source', { asked: source.fromCopy, used: source.rootDir }, `the root of the files to attach is the bot's Desktop copy ${source.fromCopy}; they are copied from the project itself, ${source.rootDir}`, 'warn');
733
- }
734
- const sourceRoot = source.rootDir;
735
- const projectName = projectNameFor(sourceRoot, cfg);
736
- // A project kept on the Desktop has its folder there looked after by the Desktop mirror; the
737
- // attachments are copied into this attempt's own folder instead. See `keptOnDesktop`.
738
- const onDesktop = keptOnDesktop(cfg, sourceRoot);
739
- const targetDir = onDesktop ? log.path('attachments') : projectTargetDir(cfg, projectName);
740
- await removeLegacyFlatMirror(cfg).catch(() => undefined);
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
- sink.event('mirror', { targetDir, onDesktop, ...result }, `project mirror${onDesktop ? ' (attachments, in the run folder: the Desktop copy is kept by the Desktop mirror)' : ''}: ${describeMirror(result)}`);
767
- if (session.mirror.includeEnvFiles) {
768
- const envCount = Object.keys(result.mapping).filter((p) => /(^|\/)\.env(\.|$)/i.test(p)).length;
769
- if (envCount > 0) {
770
- sink.event('mirror-env', { envCount }, `${envCount} .env file(s) are being attached, as configured`, 'warn');
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
- contractAlreadySent: session.contractSent,
792
- continuing: task.continuing,
793
- buildsOn: task.buildsOn,
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, attach);
816
- sink.event('message-sent', { index, chars: message.length, attached: attach.length }, `message ${index + 1}/${opening.messages.length} sent`);
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
- await savePointer(log.path('chat.json'), chat);
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
- if (!session.contractSent) {
844
- session.contractSent = true;
845
- await store.updateSession(session.id, (s) => {
846
- s.contractSent = true;
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?.enabled && session.vcs.commitOnFinish && repoDirOf(session));
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
- * and `environment` when the machine could not run the checks at all. The checks run here,
874
- * at the end, and not as steps: they are not work, they are the question of whether the work
875
- * happened, and a task cannot be trusted to answer that about itself.
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) => checkCommandRefusal(command, shell, cfg.execution, { roots: confinement.roots, 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 outcomes = (lastOutcomes = await runChecks(checks, checkOptions));
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
- // Check output is uploaded too, so it is redacted the same way a step report is.
994
- const checkReport = redactSecrets(failureReport(outcomes), cfg.report.redactPatterns);
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 = session.vcs?.enabled ? (session.vcs.repoDir ?? '').trim() : '';
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', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
1323
- `the last because ${parsed.detail}. Nothing from those replies was run; the work so far is kept.`, undefined, lastMarkdown);
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
- ? ` It gave up after ${reply.tried.length} of the ${minApproaches} approaches Settings ask for, having been asked ${earlyBlocks} time(s) for another.`
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) + early, reply.summary, lastMarkdown);
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 verdict = await gateOnChecks();
1433
- if (verdict === 'environment') {
1434
- stopCode = 'environment';
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 decision = await authorizer.authorize(step, { sessionId: session.id, taskId: task.id, iteration: iterations, confinement });
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 verdict = await gateOnChecks();
1740
- if (verdict === 'environment') {
1741
- stopCode = 'environment';
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();