copilot-operator 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/main.js +2 -104
- package/dist/src/api/operator.controller.js +18 -1
- package/dist/src/api/operator.service.js +19 -14
- package/dist/src/api/server.js +114 -0
- package/dist/src/config/schema.js +5 -0
- package/dist/src/exec/checks.js +17 -0
- package/dist/src/orchestrator/contract.js +58 -0
- package/dist/src/orchestrator/progress.js +106 -0
- package/dist/src/orchestrator/taskRunner.js +167 -11
- package/dist/src/plan/brief.js +2 -0
- package/dist/src/plan/importPlan.js +1 -0
- package/dist/src/plan/schema.js +28 -1
- package/dist/src/plan/systemGuide.js +61 -6
- package/dist/src/protocol/parser.js +98 -5
- package/dist/src/session/compose.js +1 -1
- package/dist/src/session/exports.js +6 -0
- package/dist/src/session/handoff.js +42 -0
- package/dist/src/session/metrics.js +95 -0
- package/dist/src/session/store.js +58 -10
- package/dist/src/transport/chatTransport.js +32 -0
- package/dist/src/transport/copilotTransport.js +18 -0
- package/dist/src/vcs/contentIntegrity.js +174 -0
- package/dist/src/vcs/git.js +42 -0
- package/dist/src/vcs/scope.js +132 -0
- package/dist/src/vcs/taskVcs.js +43 -2
- package/dist/web/404.html +4 -3
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
- package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-dd3ebf37d29dce4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-93ab518db67e52a2.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +1 -0
- package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
- package/dist/web/appearance.html +4 -3
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +4 -3
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +4 -3
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +4 -3
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +4 -3
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +4 -3
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +5 -4
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +4 -3
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +4 -3
- package/dist/web/system.txt +12 -12
- package/package.json +16 -4
- package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
- package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
- package/dist/web/_next/static/chunks/867-3d4ee08c5f9f0e71.js +0 -1
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-dfcbd748f454d2d7.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-49413c3a8266a0b7.js +0 -1
- package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
- package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
- package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
- package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-a82e5e801dabc423.js +0 -1
- package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
- /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_ssgManifest.js +0 -0
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
14
|
import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
-
import {
|
|
16
|
+
import { createTransport } from '../transport/chatTransport.js';
|
|
17
17
|
import { buildChatName, chatCode, 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 } from '../exec/checks.js';
|
|
23
|
+
import { runChecks, failureMessage, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK, RUNNER_CHECK_KINDS } from '../exec/checks.js';
|
|
24
24
|
import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing } from './derivedChecks.js';
|
|
25
25
|
import { workingDirFor, isWorkingDirProblem, workingDirNote } from '../exec/workDir.js';
|
|
26
26
|
import { redactSecrets } from '../exec/redaction.js';
|
|
@@ -38,6 +38,11 @@ import { writeReport } from '../exec/reportFile.js';
|
|
|
38
38
|
import { Pacer } from '../util/pacing.js';
|
|
39
39
|
import { RunLog } from '../log/runLog.js';
|
|
40
40
|
import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
|
|
41
|
+
import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
|
|
42
|
+
import { composeHandoff } from '../session/handoff.js';
|
|
43
|
+
import { ProgressWatch } from './progress.js';
|
|
44
|
+
import { contractConflicts } from './contract.js';
|
|
45
|
+
import { treeFingerprint } from '../vcs/git.js';
|
|
41
46
|
import { mirrorProject, describeMirror } from '../context/projectMirror.js';
|
|
42
47
|
import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
|
|
43
48
|
import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
|
|
@@ -162,7 +167,7 @@ class Sink {
|
|
|
162
167
|
* holding the profile.
|
|
163
168
|
*/
|
|
164
169
|
export async function openBrowser(cfg, bus, transportDir, sessionId) {
|
|
165
|
-
const transport =
|
|
170
|
+
const transport = createTransport({
|
|
166
171
|
profileDir: cfg.resolved.profileDir,
|
|
167
172
|
transportDir,
|
|
168
173
|
chatUrl: cfg.copilot.url,
|
|
@@ -319,6 +324,13 @@ export async function runTask(transport, session, task, deps) {
|
|
|
319
324
|
const taskLogPath = log.path('task-log.txt');
|
|
320
325
|
await mkdir(artifactsDir, { recursive: true });
|
|
321
326
|
await mkdir(repliesDir, { recursive: true });
|
|
327
|
+
/*
|
|
328
|
+
* Made here, not left to the first step report. The report writer creates it as a side effect, so
|
|
329
|
+
* a chat that answered "done" before running a single step reached the failed-checks report with
|
|
330
|
+
* no folder to write it into, and the task ended "failed" on an ENOENT about checks-1.txt instead
|
|
331
|
+
* of telling the chat which check failed. Found by the end-to-end checks (test/e2e-run.check.ts).
|
|
332
|
+
*/
|
|
333
|
+
await mkdir(reportsDir, { recursive: true });
|
|
322
334
|
/** The consolidated, human-readable record of the whole task, appended as it happens. */
|
|
323
335
|
const record = async (heading, body) => {
|
|
324
336
|
await appendFile(taskLogPath, `\n${'='.repeat(78)}\n${heading}\n${'='.repeat(78)}\n${body.trimEnd()}\n`, 'utf8');
|
|
@@ -347,6 +359,20 @@ export async function runTask(transport, session, task, deps) {
|
|
|
347
359
|
let disputes = [];
|
|
348
360
|
/** What the runner tells the model in its next message about the reply just processed. */
|
|
349
361
|
let runnerNotes = [];
|
|
362
|
+
/** Steps the chat sent that were not run, for the handoff at the end. */
|
|
363
|
+
const notRun = [];
|
|
364
|
+
/** What went wrong on the way, counted for the register's figures. See `TaskStats`. */
|
|
365
|
+
const stats = { formatErrors: 0, doneRejected: 0, repeatsRefused: 0, stepsRefused: 0, operatorStops: 0, reviewRejections: 0, scopeReverts: 0 };
|
|
366
|
+
/** Loops the older guards do not see; see `progress.ts`. */
|
|
367
|
+
const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
|
|
368
|
+
/** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
|
|
369
|
+
let noProgressReason = null;
|
|
370
|
+
/**
|
|
371
|
+
* Why the attempt stopped, when the status alone does not say: a format the chat could not keep,
|
|
372
|
+
* a task contract that contradicts itself, a loop, a check that could never run, a machine that
|
|
373
|
+
* lacks a shell. Written by `finish`. See `Task.stopCode`.
|
|
374
|
+
*/
|
|
375
|
+
let stopCode;
|
|
350
376
|
/**
|
|
351
377
|
* Checks earlier reviews gave with their findings — carried over from every attempt before
|
|
352
378
|
* this one, and grown by this one. See `derivedChecks.ts` for the three rules.
|
|
@@ -447,6 +473,13 @@ export async function runTask(transport, session, task, deps) {
|
|
|
447
473
|
if (reviewChecks.length > 0)
|
|
448
474
|
t.reviewChecks = reviewChecks;
|
|
449
475
|
t.logFile = 'task-log.txt';
|
|
476
|
+
t.stats = stats;
|
|
477
|
+
if (stopCode)
|
|
478
|
+
t.stopCode = stopCode;
|
|
479
|
+
});
|
|
480
|
+
// The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
|
|
481
|
+
await setTask((t) => {
|
|
482
|
+
t.handoff = composeHandoff(t, notRun);
|
|
450
483
|
});
|
|
451
484
|
sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
|
|
452
485
|
await log.close();
|
|
@@ -576,6 +609,37 @@ export async function runTask(transport, session, task, deps) {
|
|
|
576
609
|
? `not active: ${prepared.vcs.problem}`
|
|
577
610
|
: `branch ${prepared.vcs.branch}, from ${prepared.vcs.baseCommit ?? '(no commits yet)'}`);
|
|
578
611
|
}
|
|
612
|
+
/*
|
|
613
|
+
* The paths this task may change. Enforced only on a branch of the task's own, which is what
|
|
614
|
+
* makes "put back" safe: the tree was clean when the branch was made, so every change in it
|
|
615
|
+
* is the task's, and the starting commit is what a change goes back to.
|
|
616
|
+
*/
|
|
617
|
+
const scope = task.scope ?? [];
|
|
618
|
+
const onOwnBranch = !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
|
|
619
|
+
/*
|
|
620
|
+
* A read-only task is a scope of nothing, and is enforced the same way: whatever a round changed
|
|
621
|
+
* is put back before the chat reads the results. It used to be judged only at the end, so an
|
|
622
|
+
* audit that edited a file "to check something" kept the edit through every later round and its
|
|
623
|
+
* findings described a tree that was not the one it had been asked to audit.
|
|
624
|
+
*/
|
|
625
|
+
const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
|
|
626
|
+
/** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
|
|
627
|
+
const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
|
|
628
|
+
/*
|
|
629
|
+
* The task's contract, checked before a single message is sent.
|
|
630
|
+
*
|
|
631
|
+
* A task can be written so that no answer satisfies it: read-only and still given paths to
|
|
632
|
+
* change, or a check that needs a file to change while the task may not change it. Sent as it
|
|
633
|
+
* is, the chat works for rounds, the check fails at the end, and the task goes blocked on a
|
|
634
|
+
* finding the chat could never have fixed. The contradiction is named instead, now, and nothing
|
|
635
|
+
* is run. See `orchestrator/contract.ts`.
|
|
636
|
+
*/
|
|
637
|
+
const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd);
|
|
638
|
+
if (conflicts.length > 0) {
|
|
639
|
+
stopCode = 'contract-conflict';
|
|
640
|
+
sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
|
|
641
|
+
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.`);
|
|
642
|
+
}
|
|
579
643
|
// --- project mirror, attached to the first message of the task ---------------------
|
|
580
644
|
let mirrorFiles = [];
|
|
581
645
|
if (session.mirror.enabled && session.mirror.rootDir) {
|
|
@@ -630,6 +694,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
630
694
|
workDirNote: workingDirNote(work),
|
|
631
695
|
vcsNote: prepared.note,
|
|
632
696
|
readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
|
|
697
|
+
scopeNote: scopeNote(scope, scopeEnforced),
|
|
633
698
|
shellNote: shellNote(shells, defaultShell),
|
|
634
699
|
});
|
|
635
700
|
await setTask((t) => {
|
|
@@ -694,7 +759,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
694
759
|
* task refusing to start over it.
|
|
695
760
|
*/
|
|
696
761
|
const willCommit = !!(session.vcs?.enabled && session.vcs.commitOnFinish && repoDirOf(session));
|
|
697
|
-
|
|
762
|
+
/** The runner's own checks that have been pointed out to the chat once already. */
|
|
763
|
+
const pointedOut = new Set();
|
|
698
764
|
/**
|
|
699
765
|
* Whether "done" is accepted, decided by the operator's checks rather than by the reply.
|
|
700
766
|
*
|
|
@@ -705,7 +771,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
705
771
|
* happened, and a task cannot be trusted to answer that about itself.
|
|
706
772
|
*/
|
|
707
773
|
const gateOnChecks = async () => {
|
|
708
|
-
const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK] : [])];
|
|
774
|
+
const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK] : [])];
|
|
709
775
|
if (checks.length === 0)
|
|
710
776
|
return 'accept';
|
|
711
777
|
// Counted only once the round turns out to have been a real attempt. A round that died of
|
|
@@ -726,19 +792,26 @@ export async function runTask(transport, session, task, deps) {
|
|
|
726
792
|
deny: (command, shell, cwd) => checkCommandRefusal(command, shell, cfg.execution, { roots: confinement.roots, cwd }),
|
|
727
793
|
roots: confinement.roots,
|
|
728
794
|
repoDir: willCommit ? repoDirOf(session) : undefined,
|
|
795
|
+
baseCommit: prepared.vcs.baseCommit,
|
|
729
796
|
// The same shell a step that named none is given, so the gate and the work it judges
|
|
730
797
|
// cannot have been read by different interpreters.
|
|
731
798
|
defaultShell,
|
|
732
799
|
}));
|
|
800
|
+
/*
|
|
801
|
+
* The runner's own checks are advice given once, not a gate: a second "done" with the finding
|
|
802
|
+
* still there is committed and the finding stays on the task. Refusing instead would leave the
|
|
803
|
+
* tree dirty and the next task unable to start. Each check is pointed out once on its own, so a
|
|
804
|
+
* content problem found after the paths were settled still gets its one mention.
|
|
805
|
+
*/
|
|
733
806
|
for (const o of outcomes) {
|
|
734
|
-
if (o.check.expect
|
|
807
|
+
if (!RUNNER_CHECK_KINDS.has(o.check.expect) || o.passed)
|
|
735
808
|
continue;
|
|
736
|
-
if (
|
|
809
|
+
if (pointedOut.has(o.check.expect)) {
|
|
737
810
|
o.passed = true;
|
|
738
|
-
o.detail = `still there after being pointed out once; committed and
|
|
811
|
+
o.detail = `still there after being pointed out once; committed and kept on the task — ${o.detail}`;
|
|
739
812
|
}
|
|
740
813
|
else {
|
|
741
|
-
|
|
814
|
+
pointedOut.add(o.check.expect);
|
|
742
815
|
}
|
|
743
816
|
}
|
|
744
817
|
await setTask((t) => {
|
|
@@ -769,8 +842,18 @@ export async function runTask(transport, session, task, deps) {
|
|
|
769
842
|
sink.event('checks-passed', { count: outcomes.length }, `all ${outcomes.length} check(s) passed`);
|
|
770
843
|
return 'accept';
|
|
771
844
|
}
|
|
845
|
+
stats.doneRejected += 1;
|
|
772
846
|
if (deps.signal?.aborted)
|
|
773
847
|
return 'give-up';
|
|
848
|
+
// The same failures as last time, and not a file changed since they were reported.
|
|
849
|
+
const stuck = progress.afterFailedChecks(await treeNow(), failed.map((o) => ({ name: o.check.name, detail: o.detail })));
|
|
850
|
+
if (stuck) {
|
|
851
|
+
noProgressReason = stuck;
|
|
852
|
+
stats.stoppedFor = 'no-progress';
|
|
853
|
+
stopCode = 'no-progress';
|
|
854
|
+
sink.event('no-progress', { kind: 'checks' }, stuck, 'warn');
|
|
855
|
+
return 'give-up';
|
|
856
|
+
}
|
|
774
857
|
if (checkRounds > maxCheckRounds) {
|
|
775
858
|
/*
|
|
776
859
|
* Only checks from earlier reviews are failing. A reviewer's check is outranked by the
|
|
@@ -941,6 +1024,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
941
1024
|
* task; the ones suspended by a dispute or by spent rounds come back if the finding was
|
|
942
1025
|
* raised again, and go if it was not.
|
|
943
1026
|
*/
|
|
1027
|
+
if (outcome.verdict === 'fail')
|
|
1028
|
+
stats.reviewRejections += 1;
|
|
944
1029
|
if (outcome.verdict === 'pass' || outcome.verdict === 'fail') {
|
|
945
1030
|
for (const d of outcome.derivedChecks ?? []) {
|
|
946
1031
|
reviewChecks = [...reviewChecks, { check: d.check, findingId: d.findingId, what: d.what, where: d.where, round: reviewRounds, attempt, state: 'active' }];
|
|
@@ -1100,10 +1185,22 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1100
1185
|
});
|
|
1101
1186
|
if (!parsed.ok) {
|
|
1102
1187
|
formatRetries += 1;
|
|
1188
|
+
stats.formatErrors += 1;
|
|
1103
1189
|
sink.event('format-error', { reason: parsed.reason, detail: parsed.detail }, `reply did not match the contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
|
|
1190
|
+
/*
|
|
1191
|
+
* Out of format retries: stopped at a limit, not failed.
|
|
1192
|
+
*
|
|
1193
|
+
* Nothing about the work went wrong here — the steps that ran are recorded, their output is
|
|
1194
|
+
* in the run folder, and the rejected replies ran nothing. So the task ends `limit-reached`,
|
|
1195
|
+
* marked `format-repair-exhausted`, which is what lets "Continue in the same chat" carry it
|
|
1196
|
+
* on from exactly here instead of the operator having to run it again from the start. It
|
|
1197
|
+
* used to end `failed`, which read as a verdict on the work and offered only a fresh start.
|
|
1198
|
+
*/
|
|
1104
1199
|
if (formatRetries > cfg.limits.maxFormatRetries) {
|
|
1105
1200
|
await transport.dumpFailure(log.path('failures'), 'format-error');
|
|
1106
|
-
|
|
1201
|
+
stopCode = 'format-repair-exhausted';
|
|
1202
|
+
return await finish('limit-reached', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
|
|
1203
|
+
`the last because ${parsed.detail}. Nothing from those replies was run; the work so far is kept.`, undefined, lastMarkdown);
|
|
1107
1204
|
}
|
|
1108
1205
|
await pacer.throttleSend();
|
|
1109
1206
|
const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
|
|
@@ -1113,6 +1210,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1113
1210
|
continue;
|
|
1114
1211
|
}
|
|
1115
1212
|
formatRetries = 0;
|
|
1213
|
+
if (parsed.coerced) {
|
|
1214
|
+
sink.event('format-coerced', { fields: parsed.coerced }, `read as text: ${parsed.coerced.join(', ')} arrived in another shape; nothing in them is run, so the reply was taken as it was`);
|
|
1215
|
+
}
|
|
1116
1216
|
iterations += 1;
|
|
1117
1217
|
await setTask((t) => {
|
|
1118
1218
|
t.iterations = iterations;
|
|
@@ -1184,9 +1284,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1184
1284
|
if (done && reply.steps.length === 0) {
|
|
1185
1285
|
const verdict = await gateOnChecks();
|
|
1186
1286
|
if (verdict === 'environment') {
|
|
1287
|
+
stopCode = 'environment';
|
|
1187
1288
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1188
1289
|
}
|
|
1189
1290
|
if (verdict === 'give-up') {
|
|
1291
|
+
if (noProgressReason)
|
|
1292
|
+
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1293
|
+
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1294
|
+
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1295
|
+
stopCode = 'invalid-check';
|
|
1190
1296
|
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1191
1297
|
}
|
|
1192
1298
|
if (verdict === 'accept') {
|
|
@@ -1246,6 +1352,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1246
1352
|
const ran = seen.get(key)?.sameInARow ?? 0;
|
|
1247
1353
|
if (ran >= maxCommandRepeats) {
|
|
1248
1354
|
repeatsRefused += 1;
|
|
1355
|
+
stats.repeatsRefused += 1;
|
|
1249
1356
|
sink.event('step-repeated', { id: step.id, count: ran, limit: maxCommandRepeats }, `step ${step.id} refused: already run ${ran} time(s) in this task with the same result`, 'warn');
|
|
1250
1357
|
results.push(refusedResult(step, repeatRefusal(ran, maxCommandRepeats)));
|
|
1251
1358
|
continue;
|
|
@@ -1312,11 +1419,43 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1312
1419
|
}
|
|
1313
1420
|
await pacer.settle();
|
|
1314
1421
|
}
|
|
1422
|
+
for (const r of results) {
|
|
1423
|
+
if (r.outcome !== 'refused' && r.outcome !== 'aborted')
|
|
1424
|
+
continue;
|
|
1425
|
+
if (r.outcome === 'refused')
|
|
1426
|
+
stats.stepsRefused += 1;
|
|
1427
|
+
else
|
|
1428
|
+
stats.operatorStops += 1;
|
|
1429
|
+
const why = (r.stderr.split('\n')[0] ?? '').replace(/^\[policy\] step not executed: /, '').trim();
|
|
1430
|
+
notRun.push({ command: r.command.slice(0, 200), why: why.slice(0, 300) || r.outcome });
|
|
1431
|
+
}
|
|
1315
1432
|
// Ended before the report is written and sent: there is nobody to send it to who could
|
|
1316
1433
|
// do anything with it, and the reason on the task says what to install or set instead.
|
|
1317
1434
|
if (environmentProblem) {
|
|
1435
|
+
stopCode = 'environment';
|
|
1318
1436
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1319
1437
|
}
|
|
1438
|
+
/*
|
|
1439
|
+
* The task's scope, as a rule rather than a sentence (see `vcs/scope.ts`): whatever this round
|
|
1440
|
+
* changed outside it is put back now, before the chat reads the results and builds on them,
|
|
1441
|
+
* and the chat is told in the same message.
|
|
1442
|
+
*/
|
|
1443
|
+
if (scopeEnforced) {
|
|
1444
|
+
const check = await enforceScope(repoDirOf(session), scope, !!task.readOnly).catch((e) => ({
|
|
1445
|
+
outside: [],
|
|
1446
|
+
reverted: [],
|
|
1447
|
+
failed: [{ path: '(the working tree)', why: e.message }],
|
|
1448
|
+
}));
|
|
1449
|
+
if (check.outside.length > 0 || check.failed.length > 0) {
|
|
1450
|
+
sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `outside the task's scope, put back: ${check.reverted.join(', ') || '(none)'}` +
|
|
1451
|
+
(check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
|
|
1452
|
+
runnerNotes.push(scopeMessage(check, scope, !!task.readOnly));
|
|
1453
|
+
stats.scopeReverts += check.reverted.length;
|
|
1454
|
+
await setTask((t) => {
|
|
1455
|
+
t.scopeReverted = [...new Set([...(t.scopeReverted ?? []), ...check.reverted])];
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1320
1459
|
// --- report back ---------------------------------------------------------------
|
|
1321
1460
|
const report = await writeReport(results, {
|
|
1322
1461
|
runId,
|
|
@@ -1336,6 +1475,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1336
1475
|
}
|
|
1337
1476
|
if (aborted)
|
|
1338
1477
|
return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
|
|
1478
|
+
// A loop in which every round looks new: see `progress.ts`. After the report is written, so
|
|
1479
|
+
// the evidence the diagnosis points at is on disk.
|
|
1480
|
+
const stuck = progress.afterRound(await treeNow(), results);
|
|
1481
|
+
if (stuck) {
|
|
1482
|
+
stats.stoppedFor = 'no-progress';
|
|
1483
|
+
stopCode = 'no-progress';
|
|
1484
|
+
sink.event('no-progress', { kind: 'rounds', iteration: iterations }, stuck, 'warn');
|
|
1485
|
+
return await finish('blocked', stuck, reply.summary, lastMarkdown);
|
|
1486
|
+
}
|
|
1339
1487
|
/*
|
|
1340
1488
|
* Nothing this iteration did anything.
|
|
1341
1489
|
*
|
|
@@ -1433,9 +1581,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1433
1581
|
if (done) {
|
|
1434
1582
|
const verdict = await gateOnChecks();
|
|
1435
1583
|
if (verdict === 'environment') {
|
|
1584
|
+
stopCode = 'environment';
|
|
1436
1585
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1437
1586
|
}
|
|
1438
1587
|
if (verdict === 'give-up') {
|
|
1588
|
+
if (noProgressReason)
|
|
1589
|
+
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1590
|
+
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1591
|
+
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1592
|
+
stopCode = 'invalid-check';
|
|
1439
1593
|
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1440
1594
|
}
|
|
1441
1595
|
if (verdict === 'accept') {
|
|
@@ -1562,8 +1716,10 @@ export async function runSession(sessionId, deps) {
|
|
|
1562
1716
|
s.chat = undefined;
|
|
1563
1717
|
s.contractSent = false;
|
|
1564
1718
|
const again = s.tasks.find((x) => x.id === task.id);
|
|
1565
|
-
if (again)
|
|
1719
|
+
if (again) {
|
|
1566
1720
|
again.autoRetries = (again.autoRetries ?? 0) + 1;
|
|
1721
|
+
again.freshRetry = true;
|
|
1722
|
+
}
|
|
1567
1723
|
});
|
|
1568
1724
|
await chat.newChat();
|
|
1569
1725
|
/*
|
package/dist/src/plan/brief.js
CHANGED
|
@@ -316,6 +316,7 @@ const FIELD_ROWS_EN = [
|
|
|
316
316
|
],
|
|
317
317
|
['review', 'task', 'no', 'Set to `false` to skip the review for one task. Absent means the session decides.'],
|
|
318
318
|
['readOnly', 'task', 'no', '`true` for a task that must not change files — an audit, a smoke test, a report. The runner fails it if the tree changed, and still commits the change on its branch so nothing is lost.'],
|
|
319
|
+
['scope', 'task', 'no', 'The paths the task may change: files, folders (`tests/e2e/`) or `*`/`**` patterns. With version control on, the runner puts back any change outside them. Give it when the task is about one suite or module.'],
|
|
319
320
|
['mirror', 'session', 'no, but ask', 'Whether project files are copied and attached to the first message as context, and which: root, directories in and out, gitignore, env files. Off when left out.'],
|
|
320
321
|
['tasks', 'session', 'yes', 'At least one, in the order they must run.'],
|
|
321
322
|
['title', 'task', 'yes', '3 to 120 characters. Short, latin, hyphenated.'],
|
|
@@ -380,6 +381,7 @@ const FIELD_ROWS_BG = [
|
|
|
380
381
|
],
|
|
381
382
|
['review', 'задача', 'не', 'Сложи `false`, за да се пропусне рецензията само за тази задача. Липсата значи каквото казва сесията.'],
|
|
382
383
|
['readOnly', 'задача', 'не', '`true` за задача, която не бива да променя файлове — одит, smoke тест, доклад. Runner-ът я проваля, ако дървото е променено, и пак комитва промяната на клона ѝ, за да не се губи нищо.'],
|
|
384
|
+
['scope', 'задача', 'не', 'Пътищата, които задачата може да променя: файлове, папки (`tests/e2e/`) или шаблони с `*`/`**`. При включен контрол на версиите runner-ът връща всяка промяна извън тях. Давай го, когато задачата е за един suite или модул.'],
|
|
383
385
|
['mirror', 'сесия', 'не, но питай', 'Дали файлове от проекта се копират и прикачат към първото съобщение като контекст, и кои: корен, директории вътре и вън, gitignore, env файлове. Изключено, когато липсва.'],
|
|
384
386
|
['tasks', 'сесия', 'да', 'Поне една, в реда, в който трябва да се изпълнят.'],
|
|
385
387
|
['title', 'задача', 'да', 'От 3 до 120 знака. Кратко, латиница, с тирета.'],
|
package/dist/src/plan/schema.js
CHANGED
|
@@ -207,6 +207,13 @@ const TaskInput = z.object({
|
|
|
207
207
|
* branch so nothing is lost and the next task starts clean.
|
|
208
208
|
*/
|
|
209
209
|
readOnly: z.boolean().default(false),
|
|
210
|
+
/**
|
|
211
|
+
* The paths this task may change, repository-relative: a file, a folder (`tests/e2e/`), or a
|
|
212
|
+
* pattern with `*` and `**`. Absent or empty means anywhere in the project. Enforced by the runner
|
|
213
|
+
* after every round of steps when version control is on — a change outside is put back and the
|
|
214
|
+
* chat told — which a sentence in the prompt cannot be. See `vcs/scope.ts`.
|
|
215
|
+
*/
|
|
216
|
+
scope: z.array(z.string().trim().min(1, 'A scope entry needs a path or a pattern.')).default([]),
|
|
210
217
|
/**
|
|
211
218
|
* What earlier attempts of this task were asked, written by the plan export for a task that has
|
|
212
219
|
* been run more than once (`earlierPlans` in `session/exports.ts`). History, not instruction:
|
|
@@ -425,7 +432,7 @@ export function checkPlan(text) {
|
|
|
425
432
|
if (!parsed.success) {
|
|
426
433
|
return { ok: false, warnings, issues: parsed.error.issues.map((i) => ({ path: pathOf(i.path), message: i.message })) };
|
|
427
434
|
}
|
|
428
|
-
const weak = weakCheckIssues(parsed.data);
|
|
435
|
+
const weak = [...weakCheckIssues(parsed.data), ...contradictionIssues(parsed.data)];
|
|
429
436
|
if (weak.length)
|
|
430
437
|
return { ok: false, warnings, issues: weak };
|
|
431
438
|
return {
|
|
@@ -496,6 +503,26 @@ function weakCheckIssues(plan) {
|
|
|
496
503
|
});
|
|
497
504
|
return out;
|
|
498
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* A task whose own flags contradict each other: read-only (change nothing) and scoped (change
|
|
508
|
+
* these). Refused at import, where the chat that wrote it can still be asked which it meant; the
|
|
509
|
+
* runner refuses it again before starting (orchestrator/contract.ts) for a task edited since.
|
|
510
|
+
*/
|
|
511
|
+
function contradictionIssues(plan) {
|
|
512
|
+
const out = [];
|
|
513
|
+
plan.sessions.forEach((s, i) => {
|
|
514
|
+
s.tasks.forEach((t, j) => {
|
|
515
|
+
if (!t.readOnly || t.scope.length === 0)
|
|
516
|
+
return;
|
|
517
|
+
out.push({
|
|
518
|
+
path: `sessions[${i}].tasks[${j}]`,
|
|
519
|
+
message: `"${t.title}" is "readOnly": true, which allows no change, and has a "scope" (${t.scope.join(', ')}), which allows changes there. ` +
|
|
520
|
+
'An audit that writes a report is not read-only: drop "readOnly" and scope it to the report. One that writes nothing: drop "scope".',
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
499
526
|
/** The issues as a block the user can hand back to the chat model without editing it. */
|
|
500
527
|
export function describeIssues(issues) {
|
|
501
528
|
return issues.map((i) => (i.path ? `- ${i.path}: ${i.message}` : `- ${i.message}`)).join('\n');
|
|
@@ -278,6 +278,22 @@ export const SYSTEM_GUIDE = [
|
|
|
278
278
|
doesEn: 'Puts the whole brief on the clipboard; paste it into a fresh chat conversation.',
|
|
279
279
|
doesBg: 'Слага цялото задание в клипборда; поставете го в нов разговор с чат модел.',
|
|
280
280
|
},
|
|
281
|
+
{
|
|
282
|
+
key: 'plan.saveBrief',
|
|
283
|
+
kind: 'button',
|
|
284
|
+
en: 'Save the brief as a file',
|
|
285
|
+
bg: 'Запази заданието като файл',
|
|
286
|
+
doesEn: 'Saves the brief as a .txt to attach to a fresh chat; the way when it is over about 116 000 characters.',
|
|
287
|
+
doesBg: 'Запазва заданието като .txt за прикачване в нов разговор; начинът, когато е над около 116 000 знака.',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
key: 'plan.copyBriefNote',
|
|
291
|
+
kind: 'button',
|
|
292
|
+
en: 'Copy the message for the file',
|
|
293
|
+
bg: 'Копирай съобщението към файла',
|
|
294
|
+
doesEn: 'Only when the brief is too long to paste: the one line to send with the attached file.',
|
|
295
|
+
doesBg: 'Само когато заданието е твърде дълго за поставяне: едното изречение, което се праща с прикачения файл.',
|
|
296
|
+
},
|
|
281
297
|
{
|
|
282
298
|
key: 'plan.softwarePart',
|
|
283
299
|
kind: 'disclosure',
|
|
@@ -1421,6 +1437,22 @@ export const SYSTEM_GUIDE = [
|
|
|
1421
1437
|
doesEn: 'On a task that has already run: stores the edit and queues it again; the earlier attempt is kept. On a done task the new attempt builds on its work. It does not start it.',
|
|
1422
1438
|
doesBg: 'При вече изпълнена задача: записва промяната и я връща в опашката; предишният опит се пази. При готова задача новият опит стъпва върху свършеното. Не я пуска.',
|
|
1423
1439
|
},
|
|
1440
|
+
{
|
|
1441
|
+
key: 'task.readOnlyEdit',
|
|
1442
|
+
kind: 'checkbox',
|
|
1443
|
+
en: 'Read-only: this task must not change any file',
|
|
1444
|
+
bg: 'Само за четене: задачата не бива да променя нито един файл',
|
|
1445
|
+
doesEn: 'Also in the new-prompt dialog of the register. For an audit: every change a round makes is put back.',
|
|
1446
|
+
doesBg: 'Има го и в диалога за нов prompt в регистъра. За одит: всяка промяна от кръг се връща.',
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
key: 'task.scopeEdit',
|
|
1450
|
+
kind: 'field',
|
|
1451
|
+
en: 'Files this task may change',
|
|
1452
|
+
bg: 'Файлове, които задачата може да променя',
|
|
1453
|
+
doesEn: 'One path or pattern per line; changes elsewhere are put back. Also in the new-prompt dialog, which lists the old checks to keep or drop.',
|
|
1454
|
+
doesBg: 'По един път или шаблон на ред; промени другаде се връщат. Има го и в диалога за нов prompt, който изброява старите проверки за запазване или махане.',
|
|
1455
|
+
},
|
|
1424
1456
|
{
|
|
1425
1457
|
key: 'task.branch',
|
|
1426
1458
|
kind: 'field',
|
|
@@ -2571,6 +2603,13 @@ export const SYSTEM_GUIDE = [
|
|
|
2571
2603
|
},
|
|
2572
2604
|
],
|
|
2573
2605
|
},
|
|
2606
|
+
{
|
|
2607
|
+
headingKey: 'metrics.title',
|
|
2608
|
+
en: 'How the bot is doing',
|
|
2609
|
+
bg: 'Как се справя ботът',
|
|
2610
|
+
// A table of figures at the foot of the register, read-only; the heading says what it is.
|
|
2611
|
+
controls: [],
|
|
2612
|
+
},
|
|
2574
2613
|
],
|
|
2575
2614
|
},
|
|
2576
2615
|
{
|
|
@@ -3075,29 +3114,45 @@ export const SYSTEM_GUIDE = [
|
|
|
3075
3114
|
en: 'Text size',
|
|
3076
3115
|
bg: 'Размер на текста',
|
|
3077
3116
|
controls: [
|
|
3117
|
+
{
|
|
3118
|
+
key: 'ap.sizeSmall',
|
|
3119
|
+
kind: 'button',
|
|
3120
|
+
en: 'Small',
|
|
3121
|
+
bg: 'Малък',
|
|
3122
|
+
doesEn: 'One step below normal.',
|
|
3123
|
+
doesBg: 'Една степен под нормалния.',
|
|
3124
|
+
},
|
|
3078
3125
|
{
|
|
3079
3126
|
key: 'ap.sizeNormal',
|
|
3080
3127
|
kind: 'button',
|
|
3081
3128
|
en: 'Normal',
|
|
3082
3129
|
bg: 'Нормален',
|
|
3083
|
-
doesEn: 'Normal
|
|
3084
|
-
doesBg: 'Нормален
|
|
3130
|
+
doesEn: 'Normal size.',
|
|
3131
|
+
doesBg: 'Нормален размер.',
|
|
3085
3132
|
},
|
|
3086
3133
|
{
|
|
3087
3134
|
key: 'ap.sizeLarge',
|
|
3088
3135
|
kind: 'button',
|
|
3089
3136
|
en: 'Large',
|
|
3090
3137
|
bg: 'Голям',
|
|
3091
|
-
doesEn: '
|
|
3092
|
-
doesBg: '
|
|
3138
|
+
doesEn: 'One step above normal.',
|
|
3139
|
+
doesBg: 'Една степен над нормалния.',
|
|
3093
3140
|
},
|
|
3094
3141
|
{
|
|
3095
3142
|
key: 'ap.sizeHuge',
|
|
3096
3143
|
kind: 'button',
|
|
3144
|
+
en: 'Larger',
|
|
3145
|
+
bg: 'По-голям',
|
|
3146
|
+
doesEn: 'Two steps above normal.',
|
|
3147
|
+
doesBg: 'Две степени над нормалния.',
|
|
3148
|
+
},
|
|
3149
|
+
{
|
|
3150
|
+
key: 'ap.sizeGiant',
|
|
3151
|
+
kind: 'button',
|
|
3097
3152
|
en: 'Largest',
|
|
3098
3153
|
bg: 'Най-голям',
|
|
3099
|
-
doesEn: '
|
|
3100
|
-
doesBg: '
|
|
3154
|
+
doesEn: 'Three steps above normal.',
|
|
3155
|
+
doesBg: 'Три степени над нормалния.',
|
|
3101
3156
|
},
|
|
3102
3157
|
],
|
|
3103
3158
|
},
|