copilot-operator 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/operator.controller.js +11 -0
- package/dist/src/api/operator.service.js +32 -13
- package/dist/src/config/schema.js +6 -0
- package/dist/src/orchestrator/contract.js +58 -0
- package/dist/src/orchestrator/taskRunner.js +104 -6
- package/dist/src/plan/schema.js +21 -1
- package/dist/src/plan/systemGuide.js +48 -0
- package/dist/src/protocol/parser.js +98 -5
- package/dist/src/session/compose.js +1 -1
- package/dist/src/session/exports.js +1 -0
- package/dist/src/session/handoff.js +1 -1
- package/dist/src/session/store.js +44 -10
- package/dist/src/transport/copilotTransport.js +145 -65
- package/dist/src/vcs/scope.js +10 -7
- package/dist/src/vcs/syncCommand.js +105 -0
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +1 -0
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
- package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +1 -0
- package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.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/level1/{page-be366b35f4cc97ce.js → page-dd3ebf37d29dce4f.js} +1 -1
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/{page-cabc3876a8111edd.js → page-93ab518db67e52a2.js} +1 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +1 -0
- package/dist/web/_next/static/css/{20c6b36996df5e4c.css → 2ad2a74b7e07dcc9.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +3 -3
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +6 -2
- package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +0 -1
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
- package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +0 -1
- package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +0 -1
- package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +0 -1
- /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → oyfXbuB_cq0sRZP7ukHlQ}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → oyfXbuB_cq0sRZP7ukHlQ}/_ssgManifest.js +0 -0
|
@@ -466,6 +466,10 @@ let OperatorController = class OperatorController {
|
|
|
466
466
|
* Separate from the session's own preflight because that one only speaks when version
|
|
467
467
|
* control is already on, and the question here is whether it may be turned on.
|
|
468
468
|
*/
|
|
469
|
+
/** The command that brings a project's branch back to its remote's main — shown, never run. */
|
|
470
|
+
repoSync(dir) {
|
|
471
|
+
return this.ops.syncPlan(dir ?? '').catch(fail);
|
|
472
|
+
}
|
|
469
473
|
repo(dir) {
|
|
470
474
|
const problem = this.ops.repoProblem(dir ?? '');
|
|
471
475
|
return problem ? { ok: false, problem } : { ok: true };
|
|
@@ -994,6 +998,13 @@ __decorate([
|
|
|
994
998
|
__metadata("design:paramtypes", [Object]),
|
|
995
999
|
__metadata("design:returntype", Promise)
|
|
996
1000
|
], OperatorController.prototype, "setProject", null);
|
|
1001
|
+
__decorate([
|
|
1002
|
+
Get('repo/sync'),
|
|
1003
|
+
__param(0, Query('dir')),
|
|
1004
|
+
__metadata("design:type", Function),
|
|
1005
|
+
__metadata("design:paramtypes", [String]),
|
|
1006
|
+
__metadata("design:returntype", Promise)
|
|
1007
|
+
], OperatorController.prototype, "repoSync", null);
|
|
997
1008
|
__decorate([
|
|
998
1009
|
Get('repo'),
|
|
999
1010
|
__param(0, Query('dir')),
|
|
@@ -15,11 +15,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
15
15
|
import { Injectable } from '@nestjs/common';
|
|
16
16
|
import { readFile, readdir } from 'node:fs/promises';
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
19
|
import { installLayout } from '../config/layout.js';
|
|
20
|
-
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW } from '../session/store.js';
|
|
20
|
+
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
|
|
21
21
|
import { EventBus } from '../session/events.js';
|
|
22
|
-
import { newId
|
|
22
|
+
import { newId } from '../session/model.js';
|
|
23
23
|
import { runSession, openBrowser, queuedToRun } from '../orchestrator/taskRunner.js';
|
|
24
24
|
import { buildExport } from '../session/exportRecord.js';
|
|
25
25
|
import { buildDebugExport } from '../session/debugExport.js';
|
|
@@ -47,10 +47,17 @@ import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } fro
|
|
|
47
47
|
import { unattendedPrecondition } from '../exec/policy.js';
|
|
48
48
|
import { createTransport } from '../transport/chatTransport.js';
|
|
49
49
|
import { composeHandoff } from '../session/handoff.js';
|
|
50
|
+
import { planSync } from '../vcs/syncCommand.js';
|
|
50
51
|
import { computeMetrics } from '../session/metrics.js';
|
|
51
52
|
import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
|
|
52
53
|
import { saveAndReveal } from './saveToDesktop.js';
|
|
53
54
|
import { Settings } from './settings.js';
|
|
55
|
+
/** A folder as compared: absolute, forward slashes, no trailing slash, and case folded as Windows does. */
|
|
56
|
+
function normaliseDir(dir) {
|
|
57
|
+
if (!dir.trim())
|
|
58
|
+
return '';
|
|
59
|
+
return resolve(dir.trim()).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
60
|
+
}
|
|
54
61
|
let OperatorService = class OperatorService {
|
|
55
62
|
/** Where this install keeps its prompts and its records. See `config/layout.ts`. */
|
|
56
63
|
layout = installLayout();
|
|
@@ -303,16 +310,7 @@ let OperatorService = class OperatorService {
|
|
|
303
310
|
return await this.store.updateTask(sessionId, taskId, (t) => {
|
|
304
311
|
if (t.status !== 'queued')
|
|
305
312
|
throw new Error('Only a queued task can be edited.');
|
|
306
|
-
|
|
307
|
-
t.title = patch.title;
|
|
308
|
-
if (patch.level2 !== undefined)
|
|
309
|
-
t.level2 = patch.level2;
|
|
310
|
-
if (patch.prompt !== undefined)
|
|
311
|
-
t.prompt = patch.prompt;
|
|
312
|
-
if (patch.vcsPlan !== undefined)
|
|
313
|
-
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
314
|
-
if (patch.checks !== undefined)
|
|
315
|
-
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
313
|
+
applyTaskPatch(t, patch);
|
|
316
314
|
});
|
|
317
315
|
}
|
|
318
316
|
async deleteTask(sessionId, taskId) {
|
|
@@ -1783,6 +1781,26 @@ let OperatorService = class OperatorService {
|
|
|
1783
1781
|
repoProblem(dir) {
|
|
1784
1782
|
return repoUnusableReason(dir);
|
|
1785
1783
|
}
|
|
1784
|
+
/**
|
|
1785
|
+
* What bringing a project back to its remote's main branch would lose, and the command that does
|
|
1786
|
+
* it — for the operator to run. See `vcs/syncCommand.ts`; nothing here writes.
|
|
1787
|
+
*
|
|
1788
|
+
* Only for a folder this bot already works in: a project in Settings or the repository of a
|
|
1789
|
+
* session. The answer lists file names and commit subjects, and a local page asking about any
|
|
1790
|
+
* folder on the disk is not a question this API should answer.
|
|
1791
|
+
*/
|
|
1792
|
+
async syncPlan(dir) {
|
|
1793
|
+
await this.init();
|
|
1794
|
+
const wanted = normaliseDir(dir);
|
|
1795
|
+
const cfg = await this.settings.load();
|
|
1796
|
+
const known = new Set([cfg.project?.rootDir ?? '', ...(cfg.project?.others ?? []).map((o) => o.rootDir), ...(await this.store.listSessions()).flatMap((s) => [s.vcs?.repoDir ?? '', s.mirror?.rootDir ?? ''])]
|
|
1797
|
+
.filter((d) => d.trim())
|
|
1798
|
+
.map(normaliseDir));
|
|
1799
|
+
if (!wanted || !known.has(wanted)) {
|
|
1800
|
+
throw new Error('That folder is not one of the projects in Settings or the repository of a session, so no command is offered for it.');
|
|
1801
|
+
}
|
|
1802
|
+
return await planSync(resolve(dir.trim()));
|
|
1803
|
+
}
|
|
1786
1804
|
// --- the project being worked on ---------------------------------------------------------
|
|
1787
1805
|
/**
|
|
1788
1806
|
* The folder new sessions start pointed at, and whether it can carry version control.
|
|
@@ -2035,6 +2053,7 @@ let OperatorService = class OperatorService {
|
|
|
2035
2053
|
disputes: t.disputes?.length || undefined,
|
|
2036
2054
|
readOnly: t.readOnly || undefined,
|
|
2037
2055
|
scope: t.scope?.length ? t.scope : undefined,
|
|
2056
|
+
stopCode: t.stopCode,
|
|
2038
2057
|
autoRetries: t.autoRetries || undefined,
|
|
2039
2058
|
attempt: t.attempt,
|
|
2040
2059
|
changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
|
|
@@ -399,6 +399,12 @@ export const RunConfigSchema = z.object({
|
|
|
399
399
|
* its record. Zero turns it off.
|
|
400
400
|
*/
|
|
401
401
|
retryBlockedInFreshChat: z.number().int().nonnegative().default(2),
|
|
402
|
+
/*
|
|
403
|
+
* How many genuinely different approaches the chat must have tried before it may end a task
|
|
404
|
+
* "blocked". The reply format's own floor is 2 (replySchema.ts); this raises it. A "blocked"
|
|
405
|
+
* with fewer is sent back asking for another approach (see taskRunner.ts). Settings → Execution.
|
|
406
|
+
*/
|
|
407
|
+
minApproachesBeforeBlocked: z.number().int().min(2).max(50).default(2),
|
|
402
408
|
})
|
|
403
409
|
.prefault({}),
|
|
404
410
|
runsDir: z.string().default('./runs'),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a task can be satisfied at all, decided before anything is sent to the chat.
|
|
3
|
+
*
|
|
4
|
+
* A task is a contract: a prompt, the checks that say when it is done, and what it may change
|
|
5
|
+
* (`readOnly`, `scope`). The three are written at different times — a plan, an edit, a new prompt on
|
|
6
|
+
* a finished task — and they can end up contradicting each other: an audit made read-only that
|
|
7
|
+
* still carries the old "the file now says X" check; a task scoped to its tests that must change
|
|
8
|
+
* the config to pass. Run as it is, such a task spends its rounds and ends blocked on a check the
|
|
9
|
+
* chat could never have met. This names the contradiction instead, before the first message.
|
|
10
|
+
*
|
|
11
|
+
* Only what can be known without running anything is judged: the flags against each other, and
|
|
12
|
+
* the file checks against the tree as it is now. A file check that already passes needs no change;
|
|
13
|
+
* one that fails needs its file changed, which a read-only task, or a scope that leaves the file
|
|
14
|
+
* out, forbids. Command checks are not judged: whether a command will pass depends on work the
|
|
15
|
+
* task is allowed to do elsewhere.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
19
|
+
import { inScope } from '../vcs/scope.js';
|
|
20
|
+
async function fileCheckPassesNow(check, cwd) {
|
|
21
|
+
const path = resolve(cwd, (check.cwd ?? '').trim() || '.', (check.file ?? '').trim());
|
|
22
|
+
const info = await stat(path).catch(() => null);
|
|
23
|
+
const exists = !!info?.isFile();
|
|
24
|
+
if (check.expect === 'file-exists')
|
|
25
|
+
return { passes: exists, path };
|
|
26
|
+
if (check.expect === 'file-missing')
|
|
27
|
+
return { passes: !exists, path };
|
|
28
|
+
const text = exists ? await readFile(path, 'utf8').catch(() => '') : '';
|
|
29
|
+
return { passes: exists && text.includes(check.value ?? ''), path };
|
|
30
|
+
}
|
|
31
|
+
/** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
|
|
32
|
+
export async function contractConflicts(task, cwd, repoDir) {
|
|
33
|
+
const out = [];
|
|
34
|
+
const scope = task.scope ?? [];
|
|
35
|
+
if (task.readOnly && scope.length > 0) {
|
|
36
|
+
out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
|
|
37
|
+
}
|
|
38
|
+
if (!task.readOnly && scope.length === 0)
|
|
39
|
+
return out;
|
|
40
|
+
const checks = [
|
|
41
|
+
...(task.checks ?? []),
|
|
42
|
+
...(task.reviewChecks ?? []).filter((rc) => rc.state === 'active').map((rc) => rc.check),
|
|
43
|
+
].filter((c) => c.expect === 'file-exists' || c.expect === 'file-missing' || c.expect === 'file-contains');
|
|
44
|
+
for (const check of checks) {
|
|
45
|
+
const { passes, path } = await fileCheckPassesNow(check, cwd);
|
|
46
|
+
if (passes)
|
|
47
|
+
continue;
|
|
48
|
+
const rel = relative(resolve(repoDir), path).replace(/\\/g, '/');
|
|
49
|
+
const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
50
|
+
if (task.readOnly) {
|
|
51
|
+
out.push(`The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`);
|
|
52
|
+
}
|
|
53
|
+
else if (inside && !inScope(rel, scope)) {
|
|
54
|
+
out.push(`The check "${check.name}" fails now and can only pass if ${rel} changes, but the task's scope (${scope.join(', ')}) leaves that file out.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -38,9 +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 { MIN_TRIED_APPROACHES } from '../protocol/replySchema.js';
|
|
41
42
|
import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
|
|
42
43
|
import { composeHandoff } from '../session/handoff.js';
|
|
43
44
|
import { ProgressWatch } from './progress.js';
|
|
45
|
+
import { contractConflicts } from './contract.js';
|
|
44
46
|
import { treeFingerprint } from '../vcs/git.js';
|
|
45
47
|
import { mirrorProject, describeMirror } from '../context/projectMirror.js';
|
|
46
48
|
import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
|
|
@@ -184,11 +186,12 @@ export async function openBrowser(cfg, bus, transportDir, sessionId) {
|
|
|
184
186
|
'verification-cleared': 'Verification cleared, continuing.',
|
|
185
187
|
'error-banner': 'The chat reported a transient error; reloading the page.',
|
|
186
188
|
'reply-files-ignored': 'The reply came with a file attached. It was not downloaded and will not be run: the runner takes only the text of a reply.',
|
|
189
|
+
'model-menu-stuck': 'The model menu would not close — Escape, the menu button and a click outside all left it open. Close it in the Edge window; the run goes on.',
|
|
187
190
|
};
|
|
188
191
|
bus.publish({
|
|
189
192
|
sessionId,
|
|
190
193
|
type: `browser:${event}`,
|
|
191
|
-
level: event === 'verification-required' || event === 'reply-files-ignored' ? 'warn' : 'info',
|
|
194
|
+
level: event === 'verification-required' || event === 'reply-files-ignored' || event === 'model-menu-stuck' ? 'warn' : 'info',
|
|
192
195
|
message: spoken[event],
|
|
193
196
|
data: detail,
|
|
194
197
|
});
|
|
@@ -360,12 +363,27 @@ export async function runTask(transport, session, task, deps) {
|
|
|
360
363
|
let runnerNotes = [];
|
|
361
364
|
/** Steps the chat sent that were not run, for the handoff at the end. */
|
|
362
365
|
const notRun = [];
|
|
366
|
+
/**
|
|
367
|
+
* How many different approaches "blocked" needs, from Settings, never below the format's own two.
|
|
368
|
+
* A "blocked" with fewer is sent back; after `MAX_EARLY_BLOCKS` of them in a row with nothing tried
|
|
369
|
+
* in between the verdict is accepted, so a chat that cannot think of anything else does not spend
|
|
370
|
+
* the rest of the iterations saying so.
|
|
371
|
+
*/
|
|
372
|
+
const minApproaches = Math.max(MIN_TRIED_APPROACHES, cfg.limits.minApproachesBeforeBlocked ?? MIN_TRIED_APPROACHES);
|
|
373
|
+
const MAX_EARLY_BLOCKS = 3;
|
|
374
|
+
let earlyBlocks = 0;
|
|
363
375
|
/** What went wrong on the way, counted for the register's figures. See `TaskStats`. */
|
|
364
376
|
const stats = { formatErrors: 0, doneRejected: 0, repeatsRefused: 0, stepsRefused: 0, operatorStops: 0, reviewRejections: 0, scopeReverts: 0 };
|
|
365
377
|
/** Loops the older guards do not see; see `progress.ts`. */
|
|
366
378
|
const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
|
|
367
379
|
/** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
|
|
368
380
|
let noProgressReason = null;
|
|
381
|
+
/**
|
|
382
|
+
* Why the attempt stopped, when the status alone does not say: a format the chat could not keep,
|
|
383
|
+
* a task contract that contradicts itself, a loop, a check that could never run, a machine that
|
|
384
|
+
* lacks a shell. Written by `finish`. See `Task.stopCode`.
|
|
385
|
+
*/
|
|
386
|
+
let stopCode;
|
|
369
387
|
/**
|
|
370
388
|
* Checks earlier reviews gave with their findings — carried over from every attempt before
|
|
371
389
|
* this one, and grown by this one. See `derivedChecks.ts` for the three rules.
|
|
@@ -467,6 +485,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
467
485
|
t.reviewChecks = reviewChecks;
|
|
468
486
|
t.logFile = 'task-log.txt';
|
|
469
487
|
t.stats = stats;
|
|
488
|
+
if (stopCode)
|
|
489
|
+
t.stopCode = stopCode;
|
|
470
490
|
});
|
|
471
491
|
// The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
|
|
472
492
|
await setTask((t) => {
|
|
@@ -606,9 +626,31 @@ export async function runTask(transport, session, task, deps) {
|
|
|
606
626
|
* is the task's, and the starting commit is what a change goes back to.
|
|
607
627
|
*/
|
|
608
628
|
const scope = task.scope ?? [];
|
|
609
|
-
const
|
|
629
|
+
const onOwnBranch = !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
|
|
630
|
+
/*
|
|
631
|
+
* A read-only task is a scope of nothing, and is enforced the same way: whatever a round changed
|
|
632
|
+
* is put back before the chat reads the results. It used to be judged only at the end, so an
|
|
633
|
+
* audit that edited a file "to check something" kept the edit through every later round and its
|
|
634
|
+
* findings described a tree that was not the one it had been asked to audit.
|
|
635
|
+
*/
|
|
636
|
+
const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
|
|
610
637
|
/** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
|
|
611
638
|
const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
|
|
639
|
+
/*
|
|
640
|
+
* The task's contract, checked before a single message is sent.
|
|
641
|
+
*
|
|
642
|
+
* A task can be written so that no answer satisfies it: read-only and still given paths to
|
|
643
|
+
* change, or a check that needs a file to change while the task may not change it. Sent as it
|
|
644
|
+
* is, the chat works for rounds, the check fails at the end, and the task goes blocked on a
|
|
645
|
+
* finding the chat could never have fixed. The contradiction is named instead, now, and nothing
|
|
646
|
+
* is run. See `orchestrator/contract.ts`.
|
|
647
|
+
*/
|
|
648
|
+
const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd);
|
|
649
|
+
if (conflicts.length > 0) {
|
|
650
|
+
stopCode = 'contract-conflict';
|
|
651
|
+
sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
|
|
652
|
+
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.`);
|
|
653
|
+
}
|
|
612
654
|
// --- project mirror, attached to the first message of the task ---------------------
|
|
613
655
|
let mirrorFiles = [];
|
|
614
656
|
if (session.mirror.enabled && session.mirror.rootDir) {
|
|
@@ -664,6 +706,10 @@ export async function runTask(transport, session, task, deps) {
|
|
|
664
706
|
vcsNote: prepared.note,
|
|
665
707
|
readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
|
|
666
708
|
scopeNote: scopeNote(scope, scopeEnforced),
|
|
709
|
+
approachesNote: minApproaches > MIN_TRIED_APPROACHES
|
|
710
|
+
? `## Giving up\n\nThis task may end with status "blocked" only after at least ${minApproaches} genuinely different approaches, ` +
|
|
711
|
+
`each listed in "tried". With fewer, the runner asks you for another approach instead of accepting it.`
|
|
712
|
+
: undefined,
|
|
667
713
|
shellNote: shellNote(shells, defaultShell),
|
|
668
714
|
});
|
|
669
715
|
await setTask((t) => {
|
|
@@ -819,6 +865,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
819
865
|
if (stuck) {
|
|
820
866
|
noProgressReason = stuck;
|
|
821
867
|
stats.stoppedFor = 'no-progress';
|
|
868
|
+
stopCode = 'no-progress';
|
|
822
869
|
sink.event('no-progress', { kind: 'checks' }, stuck, 'warn');
|
|
823
870
|
return 'give-up';
|
|
824
871
|
}
|
|
@@ -1155,9 +1202,20 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1155
1202
|
formatRetries += 1;
|
|
1156
1203
|
stats.formatErrors += 1;
|
|
1157
1204
|
sink.event('format-error', { reason: parsed.reason, detail: parsed.detail }, `reply did not match the contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
|
|
1205
|
+
/*
|
|
1206
|
+
* Out of format retries: stopped at a limit, not failed.
|
|
1207
|
+
*
|
|
1208
|
+
* Nothing about the work went wrong here — the steps that ran are recorded, their output is
|
|
1209
|
+
* in the run folder, and the rejected replies ran nothing. So the task ends `limit-reached`,
|
|
1210
|
+
* marked `format-repair-exhausted`, which is what lets "Continue in the same chat" carry it
|
|
1211
|
+
* on from exactly here instead of the operator having to run it again from the start. It
|
|
1212
|
+
* used to end `failed`, which read as a verdict on the work and offered only a fresh start.
|
|
1213
|
+
*/
|
|
1158
1214
|
if (formatRetries > cfg.limits.maxFormatRetries) {
|
|
1159
1215
|
await transport.dumpFailure(log.path('failures'), 'format-error');
|
|
1160
|
-
|
|
1216
|
+
stopCode = 'format-repair-exhausted';
|
|
1217
|
+
return await finish('limit-reached', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
|
|
1218
|
+
`the last because ${parsed.detail}. Nothing from those replies was run; the work so far is kept.`, undefined, lastMarkdown);
|
|
1161
1219
|
}
|
|
1162
1220
|
await pacer.throttleSend();
|
|
1163
1221
|
const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
|
|
@@ -1167,6 +1225,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1167
1225
|
continue;
|
|
1168
1226
|
}
|
|
1169
1227
|
formatRetries = 0;
|
|
1228
|
+
if (parsed.coerced) {
|
|
1229
|
+
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`);
|
|
1230
|
+
}
|
|
1170
1231
|
iterations += 1;
|
|
1171
1232
|
await setTask((t) => {
|
|
1172
1233
|
t.iterations = iterations;
|
|
@@ -1231,18 +1292,49 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1231
1292
|
* what the reply said, at greater length and one message later. The reason carries the
|
|
1232
1293
|
* approaches that were tried, and that is what the register shows.
|
|
1233
1294
|
*/
|
|
1295
|
+
if (!blocked && reply.steps.length > 0)
|
|
1296
|
+
earlyBlocks = 0;
|
|
1297
|
+
if (blocked && reply.tried.length < minApproaches && earlyBlocks < MAX_EARLY_BLOCKS) {
|
|
1298
|
+
/*
|
|
1299
|
+
* Too early to give up, by the operator's own measure (Settings → Execution). Sent back as a
|
|
1300
|
+
* request for another approach rather than accepted: the format only knows that two is the
|
|
1301
|
+
* floor, the operator decided this work deserves more tries than that.
|
|
1302
|
+
*/
|
|
1303
|
+
earlyBlocks += 1;
|
|
1304
|
+
stats.blockedTooEarly = (stats.blockedTooEarly ?? 0) + 1;
|
|
1305
|
+
sink.event('blocked-too-early', { tried: reply.tried.length, needed: minApproaches, time: earlyBlocks }, `"blocked" after ${reply.tried.length} approach(es); Settings ask for ${minApproaches}, so the chat is asked for another`, 'warn');
|
|
1306
|
+
const message = `Not yet. This task is given up only after at least ${minApproaches} genuinely different approaches, and your reply lists ` +
|
|
1307
|
+
`${reply.tried.length}: ${reply.tried.map((x, i) => `(${i + 1}) ${x}`).join(' ')}. Try a different approach now — not one of ` +
|
|
1308
|
+
'these again, and not the same command reworded — and send its steps with status "continue". If it fails too, add it to ' +
|
|
1309
|
+
'"tried" and try another. Nothing you listed is lost: the runner keeps what has been tried.';
|
|
1310
|
+
await record('BLOCKED TOO EARLY — MESSAGE SENT', message);
|
|
1311
|
+
await pacer.throttleSend();
|
|
1312
|
+
const before = await transport.sendAndConfirm(message);
|
|
1313
|
+
const next = await transport.waitForReply(before);
|
|
1314
|
+
await saveReply(`blocked-too-early-${earlyBlocks}`, next);
|
|
1315
|
+
lastMarkdown = next.markdown;
|
|
1316
|
+
await pacer.settle();
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1234
1319
|
if (blocked) {
|
|
1235
1320
|
sink.event('task-blocked', { tried: reply.tried, needed: reply.needed }, `the task was given up as blocked after ${reply.tried.length} approach(es)`, 'warn');
|
|
1236
|
-
|
|
1321
|
+
const early = reply.tried.length < minApproaches
|
|
1322
|
+
? ` It gave up after ${reply.tried.length} of the ${minApproaches} approaches Settings ask for, having been asked ${earlyBlocks} time(s) for another.`
|
|
1323
|
+
: '';
|
|
1324
|
+
return await finish('blocked', blockedReason(reply.tried, reply.needed) + early, reply.summary, lastMarkdown);
|
|
1237
1325
|
}
|
|
1238
1326
|
if (done && reply.steps.length === 0) {
|
|
1239
1327
|
const verdict = await gateOnChecks();
|
|
1240
1328
|
if (verdict === 'environment') {
|
|
1329
|
+
stopCode = 'environment';
|
|
1241
1330
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1242
1331
|
}
|
|
1243
1332
|
if (verdict === 'give-up') {
|
|
1244
1333
|
if (noProgressReason)
|
|
1245
1334
|
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1335
|
+
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1336
|
+
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1337
|
+
stopCode = 'invalid-check';
|
|
1246
1338
|
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1247
1339
|
}
|
|
1248
1340
|
if (verdict === 'accept') {
|
|
@@ -1382,6 +1474,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1382
1474
|
// Ended before the report is written and sent: there is nobody to send it to who could
|
|
1383
1475
|
// do anything with it, and the reason on the task says what to install or set instead.
|
|
1384
1476
|
if (environmentProblem) {
|
|
1477
|
+
stopCode = 'environment';
|
|
1385
1478
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1386
1479
|
}
|
|
1387
1480
|
/*
|
|
@@ -1390,7 +1483,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1390
1483
|
* and the chat is told in the same message.
|
|
1391
1484
|
*/
|
|
1392
1485
|
if (scopeEnforced) {
|
|
1393
|
-
const check = await enforceScope(repoDirOf(session), scope).catch((e) => ({
|
|
1486
|
+
const check = await enforceScope(repoDirOf(session), scope, !!task.readOnly).catch((e) => ({
|
|
1394
1487
|
outside: [],
|
|
1395
1488
|
reverted: [],
|
|
1396
1489
|
failed: [{ path: '(the working tree)', why: e.message }],
|
|
@@ -1398,7 +1491,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1398
1491
|
if (check.outside.length > 0 || check.failed.length > 0) {
|
|
1399
1492
|
sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `outside the task's scope, put back: ${check.reverted.join(', ') || '(none)'}` +
|
|
1400
1493
|
(check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
|
|
1401
|
-
runnerNotes.push(scopeMessage(check, scope));
|
|
1494
|
+
runnerNotes.push(scopeMessage(check, scope, !!task.readOnly));
|
|
1402
1495
|
stats.scopeReverts += check.reverted.length;
|
|
1403
1496
|
await setTask((t) => {
|
|
1404
1497
|
t.scopeReverted = [...new Set([...(t.scopeReverted ?? []), ...check.reverted])];
|
|
@@ -1429,6 +1522,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1429
1522
|
const stuck = progress.afterRound(await treeNow(), results);
|
|
1430
1523
|
if (stuck) {
|
|
1431
1524
|
stats.stoppedFor = 'no-progress';
|
|
1525
|
+
stopCode = 'no-progress';
|
|
1432
1526
|
sink.event('no-progress', { kind: 'rounds', iteration: iterations }, stuck, 'warn');
|
|
1433
1527
|
return await finish('blocked', stuck, reply.summary, lastMarkdown);
|
|
1434
1528
|
}
|
|
@@ -1529,11 +1623,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1529
1623
|
if (done) {
|
|
1530
1624
|
const verdict = await gateOnChecks();
|
|
1531
1625
|
if (verdict === 'environment') {
|
|
1626
|
+
stopCode = 'environment';
|
|
1532
1627
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1533
1628
|
}
|
|
1534
1629
|
if (verdict === 'give-up') {
|
|
1535
1630
|
if (noProgressReason)
|
|
1536
1631
|
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1632
|
+
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1633
|
+
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1634
|
+
stopCode = 'invalid-check';
|
|
1537
1635
|
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1538
1636
|
}
|
|
1539
1637
|
if (verdict === 'accept') {
|
package/dist/src/plan/schema.js
CHANGED
|
@@ -432,7 +432,7 @@ export function checkPlan(text) {
|
|
|
432
432
|
if (!parsed.success) {
|
|
433
433
|
return { ok: false, warnings, issues: parsed.error.issues.map((i) => ({ path: pathOf(i.path), message: i.message })) };
|
|
434
434
|
}
|
|
435
|
-
const weak = weakCheckIssues(parsed.data);
|
|
435
|
+
const weak = [...weakCheckIssues(parsed.data), ...contradictionIssues(parsed.data)];
|
|
436
436
|
if (weak.length)
|
|
437
437
|
return { ok: false, warnings, issues: weak };
|
|
438
438
|
return {
|
|
@@ -503,6 +503,26 @@ function weakCheckIssues(plan) {
|
|
|
503
503
|
});
|
|
504
504
|
return out;
|
|
505
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
|
+
}
|
|
506
526
|
/** The issues as a block the user can hand back to the chat model without editing it. */
|
|
507
527
|
export function describeIssues(issues) {
|
|
508
528
|
return issues.map((i) => (i.path ? `- ${i.path}: ${i.message}` : `- ${i.message}`)).join('\n');
|
|
@@ -1437,6 +1437,22 @@ export const SYSTEM_GUIDE = [
|
|
|
1437
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.',
|
|
1438
1438
|
doesBg: 'При вече изпълнена задача: записва промяната и я връща в опашката; предишният опит се пази. При готова задача новият опит стъпва върху свършеното. Не я пуска.',
|
|
1439
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
|
+
},
|
|
1440
1456
|
{
|
|
1441
1457
|
key: 'task.branch',
|
|
1442
1458
|
kind: 'field',
|
|
@@ -2828,6 +2844,38 @@ export const SYSTEM_GUIDE = [
|
|
|
2828
2844
|
doesEn: 'Seconds to wait for a chat reply (default 900); a reply not finished by then fails the task with "Copilot did not finish a reply within …".',
|
|
2829
2845
|
doesBg: 'Секунди чакане на отговор от чата (по подразбиране 900); незавършен дотогава отговор проваля задачата с „Copilot did not finish a reply within …“.',
|
|
2830
2846
|
},
|
|
2847
|
+
{
|
|
2848
|
+
key: 'sync.show',
|
|
2849
|
+
kind: 'button',
|
|
2850
|
+
en: 'Command to bring this project back to the remote main branch',
|
|
2851
|
+
bg: 'Команда за връщане на проекта към основния клон на отдалеченото хранилище',
|
|
2852
|
+
doesEn: 'Under each project in Settings and on a session\u2019s version control: lists what would be lost and gives a PowerShell command to paste; the bot never runs it.',
|
|
2853
|
+
doesBg: 'Под всеки проект в Настройки и в контрола на версиите на сесия: изброява какво ще се загуби и дава команда за PowerShell; ботът никога не я пуска.',
|
|
2854
|
+
},
|
|
2855
|
+
{
|
|
2856
|
+
key: 'sync.copyPreview',
|
|
2857
|
+
kind: 'button',
|
|
2858
|
+
en: 'Copy the preview (changes nothing)',
|
|
2859
|
+
bg: 'Копирай прегледа (не променя нищо)',
|
|
2860
|
+
doesEn: 'Fetches and lists the commits, changes and new files the command would remove.',
|
|
2861
|
+
doesBg: 'Изтегля и изброява commit-ите, промените и новите файлове, които командата би махнала.',
|
|
2862
|
+
},
|
|
2863
|
+
{
|
|
2864
|
+
key: 'sync.copyCommand',
|
|
2865
|
+
kind: 'button',
|
|
2866
|
+
en: 'Copy the command',
|
|
2867
|
+
bg: 'Копирай командата',
|
|
2868
|
+
doesEn: 'fetch, reset --hard to the remote main, clean -fd — each only if the step before worked.',
|
|
2869
|
+
doesBg: 'fetch, reset --hard към основния клон, clean -fd — всяка само ако предишната е успяла.',
|
|
2870
|
+
},
|
|
2871
|
+
{
|
|
2872
|
+
key: 'exec.minApproaches',
|
|
2873
|
+
kind: 'field',
|
|
2874
|
+
en: 'Approaches the chat must try before it may give up',
|
|
2875
|
+
bg: 'Подходи, които чатът трябва да опита, преди да се откаже',
|
|
2876
|
+
doesEn: 'How many different approaches a "blocked" must list (2–20); with fewer the chat is asked for another.',
|
|
2877
|
+
doesBg: 'Колко различни подхода трябва да изброи „blocked“ (2–20); с по-малко чатът е помолен за още един.',
|
|
2878
|
+
},
|
|
2831
2879
|
{
|
|
2832
2880
|
key: 'exec.retryBlocked',
|
|
2833
2881
|
kind: 'field',
|