copilot-operator 0.1.12 → 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 (80) 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 +692 -364
  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 +482 -150
  26. package/dist/src/plan/brief.js +56 -44
  27. package/dist/src/plan/importPlan.js +18 -7
  28. package/dist/src/plan/schema.js +90 -31
  29. package/dist/src/plan/script.js +79 -90
  30. package/dist/src/plan/systemGuide.js +39 -182
  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 +52 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/vcs/git.js +77 -2
  37. package/dist/src/vcs/scope.js +29 -6
  38. package/dist/src/vcs/taskVcs.js +149 -23
  39. package/dist/web/404.html +2 -2
  40. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
  41. package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
  42. package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
  43. package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
  44. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
  45. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  46. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  47. package/dist/web/appearance.html +2 -2
  48. package/dist/web/appearance.txt +12 -12
  49. package/dist/web/defaults.html +2 -2
  50. package/dist/web/defaults.txt +12 -12
  51. package/dist/web/history.html +2 -2
  52. package/dist/web/history.txt +12 -12
  53. package/dist/web/import.html +2 -2
  54. package/dist/web/import.txt +12 -12
  55. package/dist/web/index.html +2 -2
  56. package/dist/web/index.txt +12 -12
  57. package/dist/web/level1.html +2 -2
  58. package/dist/web/level1.txt +12 -12
  59. package/dist/web/presets.html +2 -2
  60. package/dist/web/presets.txt +12 -12
  61. package/dist/web/sessions/view.html +2 -2
  62. package/dist/web/sessions/view.txt +12 -12
  63. package/dist/web/system.html +2 -2
  64. package/dist/web/system.txt +12 -12
  65. package/package.json +18 -10
  66. package/prompts/level1.md +2 -1
  67. package/prompts/organisation.bg.md +0 -1
  68. package/prompts/organisation.en.md +0 -1
  69. package/dist/src/context/contextFiles.js +0 -295
  70. package/dist/src/context/desktopMirror.js +0 -135
  71. package/dist/src/context/projectMirror.js +0 -432
  72. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  73. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  74. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  75. package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +0 -1
  76. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  77. package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +0 -1
  78. package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +0 -1
  79. /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
  80. /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → 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 { 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
  /*
@@ -670,6 +828,12 @@ export async function runTask(transport, session, task, deps) {
670
828
  const prepared = await prepareForTask(session, task, bus, async (mutate) => {
671
829
  await store.updateSession(session.id, mutate);
672
830
  }, () => store.listSessions());
831
+ if (prepared.refuse) {
832
+ await setTask((t) => {
833
+ t.vcs = prepared.vcs;
834
+ });
835
+ return await finish('failed', prepared.refuse);
836
+ }
673
837
  if (prepared.vcs.branch || prepared.vcs.problem) {
674
838
  await setTask((t) => {
675
839
  t.vcs = prepared.vcs;
@@ -711,54 +875,59 @@ export async function runTask(transport, session, task, deps) {
711
875
  sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
712
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.`);
713
877
  }
714
- // --- project mirror, attached to the first message of the task ---------------------
715
- let mirrorFiles = [];
716
- if (session.mirror.enabled && session.mirror.rootDir) {
717
- // One folder per project, and the project's name in front of every file name, so three
718
- // repositories mirrored in turn do not delete each other's copies or collide in the chat.
719
- const projectName = projectNameFor(session.mirror.rootDir, cfg);
720
- const targetDir = projectTargetDir(cfg, projectName);
721
- await removeLegacyFlatMirror(cfg).catch(() => undefined);
722
- /*
723
- * A selection that cannot be copied stops the task before anything is sent: the chat would
724
- * otherwise work without the files it was promised. The copies already there are kept.
725
- */
726
- const result = await mirrorProject({
727
- rootDir: session.mirror.rootDir,
728
- // `rules-engine` in a plan usually means the repository's folder, wherever the root is.
729
- alsoUnder: [repoDirOf(session)],
730
- includeDirs: session.mirror.includeDirs.length ? session.mirror.includeDirs : ['.'],
731
- excludeDirs: session.mirror.excludeDirs,
732
- targetDir,
733
- namePrefix: projectName,
734
- separator: cfg.projectMirror.separator,
735
- txtMode: cfg.projectMirror.txtMode,
736
- // The session's own switches win: they are what the operator ticked for this project.
737
- // Settings only supply the fallback for a session saved before they existed.
738
- respectGitignore: session.mirror.respectGitignore ?? cfg.projectMirror.respectGitignore,
739
- ignoreDirs: cfg.projectMirror.ignoreDirs,
740
- includeEnvFiles: session.mirror.includeEnvFiles ?? cfg.projectMirror.includeEnvFiles,
741
- maxFileBytes: cfg.projectMirror.maxFileBytes,
742
- }).catch((e) => (e instanceof MirrorSelectionError ? e : Promise.reject(e)));
743
- if (result instanceof MirrorSelectionError) {
744
- sink.event('mirror-refused', { error: result.message }, result.message, 'error');
745
- 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');
746
900
  }
747
- sink.event('mirror', { targetDir, ...result }, `project mirror: ${describeMirror(result)}`);
748
- if (session.mirror.includeEnvFiles) {
749
- const envCount = Object.keys(result.mapping).filter((p) => /(^|\/)\.env(\.|$)/i.test(p)).length;
750
- if (envCount > 0) {
751
- 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
+ });
752
929
  }
753
930
  }
754
- for (const s of result.skipped.slice(0, 20)) {
755
- sink.event('mirror-skipped', { ...s }, `not copied: ${s.relPath} (${s.reason})`);
756
- }
757
- const all = Object.values(result.mapping).sort();
758
- mirrorFiles = all.slice(0, cfg.projectMirror.maxAttachedFiles).map((n) => join(targetDir, n));
759
- if (all.length > mirrorFiles.length) {
760
- sink.event('mirror-truncated', { total: all.length, attached: mirrorFiles.length }, `only the first ${mirrorFiles.length} of ${all.length} mirrored files will be attached`, 'warn');
761
- }
762
931
  }
763
932
  // --- opening messages -------------------------------------------------------------
764
933
  const { content: level1 } = await store.getLevel1();
@@ -769,9 +938,11 @@ export async function runTask(transport, session, task, deps) {
769
938
  prompt: task.prompt,
770
939
  taskTitle: task.title,
771
940
  taskNumber,
772
- contractAlreadySent: session.contractSent,
773
- continuing: task.continuing,
774
- 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,
775
946
  workDirNote: workingDirNote(work),
776
947
  vcsNote: prepared.note,
777
948
  readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
@@ -787,14 +958,27 @@ export async function runTask(transport, session, task, deps) {
787
958
  });
788
959
  await record('OPENING MESSAGE', opening.firstMessage);
789
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;
790
965
  for (const [index, message] of opening.messages.entries()) {
791
966
  if (signal?.aborted)
792
967
  return await finish('aborted', 'stopped before the task was sent');
793
- const isFirstOfTask = index === 0;
794
- const attach = isFirstOfTask && mirrorFiles.length ? mirrorFiles : [];
795
968
  await pacer.throttleSend();
796
- const before = await transport.sendAndConfirm(message, attach);
797
- 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);
798
982
  const reply = await transport.waitForReply(before);
799
983
  lastMarkdown = reply.markdown;
800
984
  await saveReply(`opening-${index + 1}`, reply);
@@ -816,16 +1000,31 @@ export async function runTask(transport, session, task, deps) {
816
1000
  await store.updateSession(session.id, (s) => {
817
1001
  s.chat = chat;
818
1002
  });
819
- await savePointer(log.path('chat.json'), chat);
1003
+ if (carriesTask)
1004
+ await savePointer(log.path('chat.json'), chat);
820
1005
  sink.event('chat-registered', { ...chat }, `chat: ${name}`);
821
1006
  }
822
1007
  }
823
- }
824
- if (!session.contractSent) {
825
- session.contractSent = true;
826
- await store.updateSession(session.id, (s) => {
827
- s.contractSent = true;
828
- });
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
+ }
829
1028
  }
830
1029
  // --- the loop ---------------------------------------------------------------------
831
1030
  // How many times the checks have been run for this task, and how many times they may be.
@@ -843,7 +1042,7 @@ export async function runTask(transport, session, task, deps) {
843
1042
  * files are committed and marked, because refusing would leave the tree dirty and the next
844
1043
  * task refusing to start over it.
845
1044
  */
846
- const willCommit = !!(session.vcs?.enabled && session.vcs.commitOnFinish && repoDirOf(session));
1045
+ const willCommit = !!(session.vcs?.commitOnFinish && trackedRepoOf(session));
847
1046
  /** The runner's own checks that have been pointed out to the chat once already. */
848
1047
  const pointedOut = new Set();
849
1048
  /**
@@ -851,9 +1050,11 @@ export async function runTask(transport, session, task, deps) {
851
1050
  *
852
1051
  * Returns `accept` when there is nothing to check or everything passed, `retry` when the
853
1052
  * failures have been sent back for Copilot to fix, `give-up` when it has had its rounds,
854
- * and `environment` when the machine could not run the checks at all. The checks run here,
855
- * at the end, and not as steps: they are not work, they are the question of whether the work
856
- * 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.
857
1058
  */
858
1059
  const gateOnChecks = async () => {
859
1060
  const every = [...(task.checks ?? []), ...activeChecks(reviewChecks)];
@@ -870,6 +1071,8 @@ export async function runTask(transport, session, task, deps) {
870
1071
  // a missing interpreter asked nothing of the work and must not cost the task one.
871
1072
  const round = checkRounds + 1;
872
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();
873
1076
  const checkOptions = {
874
1077
  cwd: work.cwd,
875
1078
  tracker,
@@ -881,16 +1084,61 @@ export async function runTask(transport, session, task, deps) {
881
1084
  * reviewer's — is refused rather than held: there is no approval screen here. Requests to
882
1085
  * this machine are not downloads and still run. See `network.ts`.
883
1086
  */
884
- 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
+ },
885
1093
  roots: confinement.roots,
886
1094
  repoDir: willCommit ? repoDirOf(session) : undefined,
887
1095
  baseCommit: prepared.vcs.baseCommit,
888
1096
  // The same shell a step that named none is given, so the gate and the work it judges
889
1097
  // cannot have been read by different interpreters.
890
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,
891
1102
  };
892
1103
  runAfterCommit = (cs) => runChecks(cs, checkOptions);
893
- 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;
894
1142
  /*
895
1143
  * The runner's own checks are advice given once, not a gate: a second "done" with the finding
896
1144
  * still there is committed and the finding stays on the task. Refusing instead would leave the
@@ -927,18 +1175,37 @@ export async function runTask(transport, session, task, deps) {
927
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');
928
1176
  return 'environment';
929
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
+ }
930
1200
  checkRounds = round;
931
1201
  for (const o of outcomes) {
932
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');
933
1203
  }
934
- const failed = outcomes.filter((o) => !o.passed);
935
1204
  if (failed.length === 0) {
936
1205
  sink.event('checks-passed', { count: outcomes.length }, `all ${outcomes.length} check(s) passed`);
937
1206
  return 'accept';
938
1207
  }
939
1208
  stats.doneRejected += 1;
940
- if (deps.signal?.aborted)
941
- return 'give-up';
942
1209
  // The same failures as last time, and not a file changed since they were reported.
943
1210
  const stuck = progress.afterFailedChecks(await treeNow(), failed.map((o) => ({ name: o.check.name, detail: o.detail })));
944
1211
  if (stuck) {
@@ -968,15 +1235,28 @@ export async function runTask(transport, session, task, deps) {
968
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');
969
1236
  return 'give-up';
970
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';
971
1241
  // The failures go back exactly the way step output does: a message with a file attached,
972
1242
  // because a compiler's opinion belongs in a file and not in a chat bubble.
973
1243
  const path = join(reportsDir, `checks-${checkRounds}.txt`);
974
- // Check output is uploaded too, so it is redacted the same way a step report is.
975
- 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);
976
1251
  await writeFile(path, checkReport, 'utf8');
977
1252
  await record(`CHECKS ${checkRounds}`, checkReport);
978
- const message = failureMessage(outcomes, checkRounds, maxCheckRounds);
1253
+ const message = failureMessage(outcomes, checkRounds, maxCheckRounds, cfg.report.redactPatterns);
979
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
+ }
980
1260
  await pacer.throttleSend();
981
1261
  const before = await transport.sendAndConfirm(message, [path]);
982
1262
  const next = await transport.waitForReply(before);
@@ -1021,6 +1301,7 @@ export async function runTask(transport, session, task, deps) {
1021
1301
  * `accept` — reviewed and passed, or not reviewed at all.
1022
1302
  * `retry` — the reviewer found problems and they have been sent back to the implementer.
1023
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.
1024
1305
  */
1025
1306
  const gateOnReview = async (closing) => {
1026
1307
  if (!reviewWanted) {
@@ -1029,7 +1310,7 @@ export async function runTask(transport, session, task, deps) {
1029
1310
  }
1030
1311
  reviewRounds += 1;
1031
1312
  const model = effectiveModels(session, cfg).reviewModel;
1032
- const repoDir = session.vcs?.enabled ? (session.vcs.repoDir ?? '').trim() : '';
1313
+ const repoDir = trackedRepoOf(session);
1033
1314
  /*
1034
1315
  * What changed, read from git rather than from anybody's account of it.
1035
1316
  *
@@ -1053,7 +1334,7 @@ export async function runTask(transport, session, task, deps) {
1053
1334
  const picked = await transport.selectModel(model).catch((e) => ({ ok: false, current: null, reason: e.message }));
1054
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');
1055
1336
  }
1056
- outcome = await runReview(transport, session, task, {
1337
+ outcome = await runReview(sendsUntilStopped(transport, signal), session, task, {
1057
1338
  cfg,
1058
1339
  authorizer,
1059
1340
  signal,
@@ -1092,6 +1373,25 @@ export async function runTask(transport, session, task, deps) {
1092
1373
  }
1093
1374
  // A reviewer that left its own server listening once failed the work for it.
1094
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
+ }
1095
1395
  /*
1096
1396
  * Which findings an earlier round already raised.
1097
1397
  *
@@ -1214,6 +1514,9 @@ export async function runTask(transport, session, task, deps) {
1214
1514
  return 'give-up';
1215
1515
  }
1216
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';
1217
1520
  await pacer.throttleSend();
1218
1521
  // The reviewer quotes output in its evidence, so the message gets the same treatment.
1219
1522
  // Recorded as sent, because what reached the chat is what the next question is about.
@@ -1241,6 +1544,54 @@ export async function runTask(transport, session, task, deps) {
1241
1544
  : `an independent review found ${findings.length} problem(s) that were still there after ` +
1242
1545
  `${maxReviewRounds} round(s) of fixing: ${listed}`;
1243
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
+ };
1244
1595
  let formatRetries = 0;
1245
1596
  /*
1246
1597
  * The anti-spin guards.
@@ -1300,8 +1651,10 @@ export async function runTask(transport, session, task, deps) {
1300
1651
  await transport.dumpFailure(log.path('failures'), 'format-error');
1301
1652
  stopCode = 'format-repair-exhausted';
1302
1653
  limitHit = { setting: 'maxFormatRetries', value: cfg.limits.maxFormatRetries };
1303
- return await finish('limit-reached', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
1304
- `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);
1305
1658
  }
1306
1659
  await pacer.throttleSend();
1307
1660
  const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
@@ -1405,31 +1758,14 @@ export async function runTask(transport, session, task, deps) {
1405
1758
  if (blocked) {
1406
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');
1407
1760
  const early = reply.tried.length < minApproaches
1408
- ? ` 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.`
1409
1762
  : '';
1410
- 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);
1411
1764
  }
1412
1765
  if (done && reply.steps.length === 0) {
1413
- const verdict = await gateOnChecks();
1414
- if (verdict === 'environment') {
1415
- stopCode = 'environment';
1416
- return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1417
- }
1418
- if (verdict === 'give-up') {
1419
- if (noProgressReason)
1420
- return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
1421
- // Every failing check was refused before it ran: a verdict on the checks, not on the work.
1422
- if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
1423
- stopCode = 'invalid-check';
1424
- return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1425
- }
1426
- if (verdict === 'accept') {
1427
- const reviewed = await gateOnReview(reply.summary);
1428
- if (reviewed === 'accept')
1429
- return await finish('done', undefined, reply.summary, lastMarkdown);
1430
- if (reviewed === 'give-up')
1431
- return await finish('blocked', reviewBlockedReason(), reply.summary, lastMarkdown);
1432
- }
1766
+ const ended = await settleDone(reply.summary);
1767
+ if (ended)
1768
+ return ended;
1433
1769
  continue;
1434
1770
  }
1435
1771
  // --- execute -----------------------------------------------------------------
@@ -1489,10 +1825,15 @@ export async function runTask(transport, session, task, deps) {
1489
1825
  t.status = 'waiting-approval';
1490
1826
  });
1491
1827
  sink.event('step-proposed', { id: step.id, description: describeStep(step) }, `step ${step.id}: ${describeStep(step)}`);
1492
- 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 });
1493
1829
  await setTask((t) => {
1494
1830
  t.status = 'running';
1495
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;
1496
1837
  if (decision.action === 'abort') {
1497
1838
  results.push(refusedResult(step, decision.reason, decision.by === 'operator' ? 'operator' : 'runner'));
1498
1839
  aborted = true;
@@ -1531,6 +1872,17 @@ export async function runTask(transport, session, task, deps) {
1531
1872
  sameInARow: previous && previous.signature === signature ? previous.sameInARow + 1 : 1,
1532
1873
  });
1533
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
+ }
1534
1886
  /*
1535
1887
  * The shell itself would not start. The same rule as in the check gate applies, for the
1536
1888
  * same reason: nothing was learned about the work, and the next message would be asking
@@ -1603,9 +1955,6 @@ export async function runTask(transport, session, task, deps) {
1603
1955
  }
1604
1956
  if (aborted)
1605
1957
  return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
1606
- // The files may have changed: the Desktop copies catch up, in the background (see RunDeps).
1607
- if (results.some((r) => r.outcome !== 'refused' && r.outcome !== 'aborted'))
1608
- deps.onWorkChanged?.();
1609
1958
  // A loop in which every round looks new: see `progress.ts`. After the report is written, so
1610
1959
  // the evidence the diagnosis points at is on disk.
1611
1960
  const stuck = progress.afterRound(await treeNow(), results);
@@ -1717,26 +2066,9 @@ export async function runTask(transport, session, task, deps) {
1717
2066
  }
1718
2067
  }
1719
2068
  if (done) {
1720
- const verdict = await gateOnChecks();
1721
- if (verdict === 'environment') {
1722
- stopCode = 'environment';
1723
- return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1724
- }
1725
- if (verdict === 'give-up') {
1726
- if (noProgressReason)
1727
- return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
1728
- // Every failing check was refused before it ran: a verdict on the checks, not on the work.
1729
- if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
1730
- stopCode = 'invalid-check';
1731
- return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1732
- }
1733
- if (verdict === 'accept') {
1734
- const reviewed = await gateOnReview(reply.summary);
1735
- if (reviewed === 'accept')
1736
- return await finish('done', undefined, reply.summary, lastMarkdown);
1737
- if (reviewed === 'give-up')
1738
- return await finish('blocked', reviewBlockedReason(), reply.summary, lastMarkdown);
1739
- }
2069
+ const ended = await settleDone(reply.summary);
2070
+ if (ended)
2071
+ return ended;
1740
2072
  continue;
1741
2073
  }
1742
2074
  await pacer.settle();