copilot-operator 0.1.11 → 0.1.13
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 +1 -1
- package/dist/src/api/operator.service.js +98 -8
- package/dist/src/config/schema.js +14 -3
- package/dist/src/context/desktopMirror.js +73 -13
- package/dist/src/context/projectMirror.js +69 -1
- package/dist/src/orchestrator/contract.js +56 -1
- package/dist/src/orchestrator/taskRunner.js +140 -16
- package/dist/src/plan/brief.js +39 -8
- package/dist/src/plan/importPlan.js +3 -0
- package/dist/src/plan/schema.js +26 -2
- package/dist/src/plan/script.js +79 -75
- package/dist/src/plan/systemGuide.js +79 -15
- package/dist/src/session/compose.js +16 -1
- package/dist/src/session/exportRecord.js +8 -1
- package/dist/src/session/exports.js +3 -0
- package/dist/src/session/handoff.js +41 -3
- package/dist/src/session/model.js +9 -0
- package/dist/src/session/store.js +11 -7
- package/dist/src/session/story.js +74 -18
- package/dist/src/transport/chatTransport.js +1 -0
- package/dist/src/transport/copilotTransport.js +80 -61
- package/dist/src/vcs/git.js +92 -2
- package/dist/src/vcs/taskVcs.js +100 -19
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
- package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
- package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +1 -0
- package/dist/web/_next/static/css/{2ad2a74b7e07dcc9.css → a0642fdb8adf9edb.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +6 -3
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
- package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +0 -1
- package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +0 -1
- /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_ssgManifest.js +0 -0
|
@@ -13,7 +13,7 @@
|
|
|
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 { createTransport } from '../transport/chatTransport.js';
|
|
16
|
+
import { createTransport, isReplyTimeout } 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';
|
|
@@ -42,10 +42,10 @@ import { MIN_TRIED_APPROACHES } from '../protocol/replySchema.js';
|
|
|
42
42
|
import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
|
|
43
43
|
import { composeHandoff } from '../session/handoff.js';
|
|
44
44
|
import { ProgressWatch } from './progress.js';
|
|
45
|
-
import { contractConflicts } from './contract.js';
|
|
45
|
+
import { contractConflicts, readsTreeClean } from './contract.js';
|
|
46
46
|
import { treeFingerprint } from '../vcs/git.js';
|
|
47
|
-
import { mirrorProject, describeMirror } from '../context/projectMirror.js';
|
|
48
|
-
import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
|
|
47
|
+
import { mirrorProject, describeMirror, MirrorSelectionError } from '../context/projectMirror.js';
|
|
48
|
+
import { keptOnDesktop, mirrorSourceRoot, projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
|
|
49
49
|
import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
|
|
50
50
|
import { exportMachine, writeAttemptRecord } from '../session/exports.js';
|
|
51
51
|
/** Why a task that was reported as done is being closed as failed. */
|
|
@@ -384,6 +384,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
384
384
|
* lacks a shell. Written by `finish`. See `Task.stopCode`.
|
|
385
385
|
*/
|
|
386
386
|
let stopCode;
|
|
387
|
+
/** The setting whose limit ended the attempt, when one did. Written by `finish`. See `TaskLimit`. */
|
|
388
|
+
let limitHit;
|
|
389
|
+
/**
|
|
390
|
+
* Checks that the working tree is clean, decided after the runner's commit rather than at the gate.
|
|
391
|
+
* The chat may not commit, so before that commit such a check could only fail (2026-09-30: failed,
|
|
392
|
+
* and then the commit left the tree clean). Set by the gate, run by `finish`. See `readsTreeClean`.
|
|
393
|
+
*/
|
|
394
|
+
let afterCommitChecks = [];
|
|
395
|
+
let runAfterCommit = null;
|
|
387
396
|
/**
|
|
388
397
|
* Checks earlier reviews gave with their findings — carried over from every attempt before
|
|
389
398
|
* this one, and grown by this one. See `derivedChecks.ts` for the three rules.
|
|
@@ -456,6 +465,40 @@ export async function runTask(transport, session, task, deps) {
|
|
|
456
465
|
sink.event('vcs-error', { error: String(e) }, `version control failed after the task: ${e.message}`, 'warn');
|
|
457
466
|
return undefined;
|
|
458
467
|
});
|
|
468
|
+
/*
|
|
469
|
+
* The checks about a clean tree, now that the runner has committed. A done task whose tree is
|
|
470
|
+
* still not clean after the commit has failed; any other ending keeps its own reason, and the
|
|
471
|
+
* results are recorded all the same.
|
|
472
|
+
*/
|
|
473
|
+
let afterCommitResults = [];
|
|
474
|
+
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');
|
|
482
|
+
}
|
|
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('; ')}`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/*
|
|
492
|
+
* What the task said about the repository was written before the commit. When it did not end
|
|
493
|
+
* done and the runner then committed its changes, the reason says so, so "not committed, the
|
|
494
|
+
* tree is dirty" is not left standing beside a commit on the record.
|
|
495
|
+
*/
|
|
496
|
+
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();
|
|
501
|
+
}
|
|
459
502
|
if (vcsAfter?.branch) {
|
|
460
503
|
await record('VERSION CONTROL', [
|
|
461
504
|
`branch : ${vcsAfter.branch}`,
|
|
@@ -487,11 +530,26 @@ export async function runTask(transport, session, task, deps) {
|
|
|
487
530
|
t.stats = stats;
|
|
488
531
|
if (stopCode)
|
|
489
532
|
t.stopCode = stopCode;
|
|
533
|
+
if (limitHit)
|
|
534
|
+
t.limit = limitHit;
|
|
535
|
+
if (afterCommitResults.length > 0)
|
|
536
|
+
t.checkResults = [...(t.checkResults ?? []), ...afterCommitResults];
|
|
537
|
+
// A review that was asked for and never ran says why, instead of reading as zero rounds.
|
|
538
|
+
if (!t.review && (task.reviewEnabled ?? session.review?.enabled ?? true) && status !== 'done') {
|
|
539
|
+
t.review = {
|
|
540
|
+
verdict: 'skipped',
|
|
541
|
+
rounds: 0,
|
|
542
|
+
stepsRun: 0,
|
|
543
|
+
skippedBecause: `the task ended ${status} before its work reached the review; the review runs only on work reported done whose checks pass`,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
490
546
|
});
|
|
491
547
|
// The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
|
|
492
548
|
await setTask((t) => {
|
|
493
549
|
t.handoff = composeHandoff(t, notRun);
|
|
494
550
|
});
|
|
551
|
+
// Its last changes, checks and commit included, go to the Desktop copies too.
|
|
552
|
+
deps.onWorkChanged?.();
|
|
495
553
|
sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
|
|
496
554
|
await log.close();
|
|
497
555
|
/*
|
|
@@ -612,6 +670,12 @@ export async function runTask(transport, session, task, deps) {
|
|
|
612
670
|
const prepared = await prepareForTask(session, task, bus, async (mutate) => {
|
|
613
671
|
await store.updateSession(session.id, mutate);
|
|
614
672
|
}, () => store.listSessions());
|
|
673
|
+
if (prepared.refuse) {
|
|
674
|
+
await setTask((t) => {
|
|
675
|
+
t.vcs = prepared.vcs;
|
|
676
|
+
});
|
|
677
|
+
return await finish('failed', prepared.refuse);
|
|
678
|
+
}
|
|
615
679
|
if (prepared.vcs.branch || prepared.vcs.problem) {
|
|
616
680
|
await setTask((t) => {
|
|
617
681
|
t.vcs = prepared.vcs;
|
|
@@ -645,7 +709,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
645
709
|
* finding the chat could never have fixed. The contradiction is named instead, now, and nothing
|
|
646
710
|
* is run. See `orchestrator/contract.ts`.
|
|
647
711
|
*/
|
|
648
|
-
const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd
|
|
712
|
+
const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd, {
|
|
713
|
+
branch: prepared.vcs.problem ? undefined : prepared.vcs.branch,
|
|
714
|
+
});
|
|
649
715
|
if (conflicts.length > 0) {
|
|
650
716
|
stopCode = 'contract-conflict';
|
|
651
717
|
sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
|
|
@@ -656,11 +722,30 @@ export async function runTask(transport, session, task, deps) {
|
|
|
656
722
|
if (session.mirror.enabled && session.mirror.rootDir) {
|
|
657
723
|
// One folder per project, and the project's name in front of every file name, so three
|
|
658
724
|
// repositories mirrored in turn do not delete each other's copies or collide in the chat.
|
|
659
|
-
|
|
660
|
-
const
|
|
725
|
+
// The bot's own Desktop copy named as the root means the project it is a copy of. See `mirrorSourceRoot`.
|
|
726
|
+
const source = mirrorSourceRoot(cfg, session.mirror.rootDir);
|
|
727
|
+
if ('problem' in source) {
|
|
728
|
+
sink.event('mirror-refused', { error: source.problem }, source.problem, 'error');
|
|
729
|
+
return await finish('failed', `${source.problem} Nothing was sent to the chat.`);
|
|
730
|
+
}
|
|
731
|
+
if (source.fromCopy) {
|
|
732
|
+
sink.event('mirror-source', { asked: source.fromCopy, used: source.rootDir }, `the root of the files to attach is the bot's Desktop copy ${source.fromCopy}; they are copied from the project itself, ${source.rootDir}`, 'warn');
|
|
733
|
+
}
|
|
734
|
+
const sourceRoot = source.rootDir;
|
|
735
|
+
const projectName = projectNameFor(sourceRoot, cfg);
|
|
736
|
+
// A project kept on the Desktop has its folder there looked after by the Desktop mirror; the
|
|
737
|
+
// attachments are copied into this attempt's own folder instead. See `keptOnDesktop`.
|
|
738
|
+
const onDesktop = keptOnDesktop(cfg, sourceRoot);
|
|
739
|
+
const targetDir = onDesktop ? log.path('attachments') : projectTargetDir(cfg, projectName);
|
|
661
740
|
await removeLegacyFlatMirror(cfg).catch(() => undefined);
|
|
741
|
+
/*
|
|
742
|
+
* A selection that cannot be copied stops the task before anything is sent: the chat would
|
|
743
|
+
* otherwise work without the files it was promised. The copies already there are kept.
|
|
744
|
+
*/
|
|
662
745
|
const result = await mirrorProject({
|
|
663
|
-
rootDir:
|
|
746
|
+
rootDir: sourceRoot,
|
|
747
|
+
// `rules-engine` in a plan usually means the repository's folder, wherever the root is.
|
|
748
|
+
alsoUnder: [repoDirOf(session)],
|
|
664
749
|
includeDirs: session.mirror.includeDirs.length ? session.mirror.includeDirs : ['.'],
|
|
665
750
|
excludeDirs: session.mirror.excludeDirs,
|
|
666
751
|
targetDir,
|
|
@@ -673,8 +758,12 @@ export async function runTask(transport, session, task, deps) {
|
|
|
673
758
|
ignoreDirs: cfg.projectMirror.ignoreDirs,
|
|
674
759
|
includeEnvFiles: session.mirror.includeEnvFiles ?? cfg.projectMirror.includeEnvFiles,
|
|
675
760
|
maxFileBytes: cfg.projectMirror.maxFileBytes,
|
|
676
|
-
});
|
|
677
|
-
|
|
761
|
+
}).catch((e) => (e instanceof MirrorSelectionError ? e : Promise.reject(e)));
|
|
762
|
+
if (result instanceof MirrorSelectionError) {
|
|
763
|
+
sink.event('mirror-refused', { error: result.message }, result.message, 'error');
|
|
764
|
+
return await finish('failed', `${result.message} Nothing was sent to the chat. Fix the files to attach and queue the task again.`);
|
|
765
|
+
}
|
|
766
|
+
sink.event('mirror', { targetDir, onDesktop, ...result }, `project mirror${onDesktop ? ' (attachments, in the run folder: the Desktop copy is kept by the Desktop mirror)' : ''}: ${describeMirror(result)}`);
|
|
678
767
|
if (session.mirror.includeEnvFiles) {
|
|
679
768
|
const envCount = Object.keys(result.mapping).filter((p) => /(^|\/)\.env(\.|$)/i.test(p)).length;
|
|
680
769
|
if (envCount > 0) {
|
|
@@ -786,14 +875,21 @@ export async function runTask(transport, session, task, deps) {
|
|
|
786
875
|
* happened, and a task cannot be trusted to answer that about itself.
|
|
787
876
|
*/
|
|
788
877
|
const gateOnChecks = async () => {
|
|
789
|
-
const
|
|
878
|
+
const every = [...(task.checks ?? []), ...activeChecks(reviewChecks)];
|
|
879
|
+
// Whether the tree is clean is decided after the runner's commit; see `afterCommitChecks`.
|
|
880
|
+
const deferred = willCommit ? every.filter(readsTreeClean) : [];
|
|
881
|
+
if (deferred.length > 0 && afterCommitChecks.length === 0) {
|
|
882
|
+
sink.event('checks-after-commit', { checks: deferred.map((c) => c.name) }, `${deferred.length} check(s) about a clean working tree are decided after the runner commits the work: ${deferred.map((c) => c.name).join(', ')}`);
|
|
883
|
+
}
|
|
884
|
+
afterCommitChecks = deferred;
|
|
885
|
+
const checks = [...every.filter((c) => !deferred.includes(c)), ...(willCommit ? [COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK] : [])];
|
|
790
886
|
if (checks.length === 0)
|
|
791
887
|
return 'accept';
|
|
792
888
|
// Counted only once the round turns out to have been a real attempt. A round that died of
|
|
793
889
|
// a missing interpreter asked nothing of the work and must not cost the task one.
|
|
794
890
|
const round = checkRounds + 1;
|
|
795
891
|
sink.event('checks-started', { round, count: checks.length }, `checking the task against ${checks.length} condition(s)`);
|
|
796
|
-
const
|
|
892
|
+
const checkOptions = {
|
|
797
893
|
cwd: work.cwd,
|
|
798
894
|
tracker,
|
|
799
895
|
passEnv: cfg.execution.passEnv,
|
|
@@ -811,7 +907,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
811
907
|
// The same shell a step that named none is given, so the gate and the work it judges
|
|
812
908
|
// cannot have been read by different interpreters.
|
|
813
909
|
defaultShell,
|
|
814
|
-
}
|
|
910
|
+
};
|
|
911
|
+
runAfterCommit = (cs) => runChecks(cs, checkOptions);
|
|
912
|
+
const outcomes = (lastOutcomes = await runChecks(checks, checkOptions));
|
|
815
913
|
/*
|
|
816
914
|
* The runner's own checks are advice given once, not a gate: a second "done" with the finding
|
|
817
915
|
* still there is committed and the finding stays on the task. Refusing instead would leave the
|
|
@@ -885,6 +983,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
885
983
|
sink.event('checks-derived-deferred', { rounds: checkRounds - 1, checks: failed.map((o) => o.check.name) }, `${failed.length} check(s) from earlier reviews still fail after ${maxCheckRounds} attempt(s); the work goes to the reviewer with them named`, 'warn');
|
|
886
984
|
return 'accept';
|
|
887
985
|
}
|
|
986
|
+
limitHit = { setting: 'maxCheckRounds', value: maxCheckRounds };
|
|
888
987
|
sink.event('checks-exhausted', { rounds: checkRounds - 1 }, `${failed.length} check(s) still failing after ${maxCheckRounds} attempt(s); the task is closed as failed`, 'warn');
|
|
889
988
|
return 'give-up';
|
|
890
989
|
}
|
|
@@ -944,7 +1043,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
944
1043
|
*/
|
|
945
1044
|
const gateOnReview = async (closing) => {
|
|
946
1045
|
if (!reviewWanted) {
|
|
947
|
-
await saveReview({ verdict: 'skipped', rounds: 0, stepsRun: 0 });
|
|
1046
|
+
await saveReview({ verdict: 'skipped', rounds: 0, stepsRun: 0, skippedBecause: `the review is switched off for this ${task.reviewEnabled === false ? 'task' : 'session'}` });
|
|
948
1047
|
return 'accept';
|
|
949
1048
|
}
|
|
950
1049
|
reviewRounds += 1;
|
|
@@ -1129,6 +1228,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1129
1228
|
* at the fix. It had been fixed.
|
|
1130
1229
|
*/
|
|
1131
1230
|
if (reviewRounds > maxReviewRounds) {
|
|
1231
|
+
limitHit = { setting: 'maxReviewRounds', value: maxReviewRounds };
|
|
1132
1232
|
sink.event('review-exhausted', { rounds: maxReviewRounds, findings: outcome.findings.length }, `the review still has findings after ${maxReviewRounds} round(s) of fixing; the task is closed as blocked`, 'warn');
|
|
1133
1233
|
return 'give-up';
|
|
1134
1234
|
}
|
|
@@ -1190,10 +1290,14 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1190
1290
|
for (;;) {
|
|
1191
1291
|
if (signal?.aborted)
|
|
1192
1292
|
return await finish('aborted', 'stopped by the operator');
|
|
1193
|
-
if (Date.now() > deadline)
|
|
1293
|
+
if (Date.now() > deadline) {
|
|
1294
|
+
limitHit = { setting: 'maxRunMinutes', value: cfg.limits.maxRunMinutes };
|
|
1194
1295
|
return await finish('limit-reached', `maxRunMinutes (${cfg.limits.maxRunMinutes}) reached`);
|
|
1195
|
-
|
|
1296
|
+
}
|
|
1297
|
+
if (iterations >= cfg.limits.maxIterations) {
|
|
1298
|
+
limitHit = { setting: 'maxIterations', value: cfg.limits.maxIterations };
|
|
1196
1299
|
return await finish('limit-reached', `maxIterations (${cfg.limits.maxIterations}) reached`);
|
|
1300
|
+
}
|
|
1197
1301
|
const parsed = parseReply(lastMarkdown, {
|
|
1198
1302
|
stopMarker: cfg.copilot.stopMarker,
|
|
1199
1303
|
defaultShell,
|
|
@@ -1214,6 +1318,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1214
1318
|
if (formatRetries > cfg.limits.maxFormatRetries) {
|
|
1215
1319
|
await transport.dumpFailure(log.path('failures'), 'format-error');
|
|
1216
1320
|
stopCode = 'format-repair-exhausted';
|
|
1321
|
+
limitHit = { setting: 'maxFormatRetries', value: cfg.limits.maxFormatRetries };
|
|
1217
1322
|
return await finish('limit-reached', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
|
|
1218
1323
|
`the last because ${parsed.detail}. Nothing from those replies was run; the work so far is kept.`, undefined, lastMarkdown);
|
|
1219
1324
|
}
|
|
@@ -1517,6 +1622,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1517
1622
|
}
|
|
1518
1623
|
if (aborted)
|
|
1519
1624
|
return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
|
|
1625
|
+
// The files may have changed: the Desktop copies catch up, in the background (see RunDeps).
|
|
1626
|
+
if (results.some((r) => r.outcome !== 'refused' && r.outcome !== 'aborted'))
|
|
1627
|
+
deps.onWorkChanged?.();
|
|
1520
1628
|
// A loop in which every round looks new: see `progress.ts`. After the report is written, so
|
|
1521
1629
|
// the evidence the diagnosis points at is on disk.
|
|
1522
1630
|
const stuck = progress.afterRound(await treeNow(), results);
|
|
@@ -1604,6 +1712,13 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1604
1712
|
sent = true;
|
|
1605
1713
|
}
|
|
1606
1714
|
catch (e) {
|
|
1715
|
+
/*
|
|
1716
|
+
* The report went out and the chat is still answering it. Sending it again would stack a
|
|
1717
|
+
* second copy under a reply in progress; this is the reply wait from the settings running
|
|
1718
|
+
* out, which ends the task at that limit (see the catch at the end), not an upload to retry.
|
|
1719
|
+
*/
|
|
1720
|
+
if (isReplyTimeout(e))
|
|
1721
|
+
throw e;
|
|
1607
1722
|
sink.event('report-send-failed', { attempt, error: String(e) }, `sending the report failed (attempt ${attempt + 1}): ${e.message}`, 'warn');
|
|
1608
1723
|
if (attempt === cfg.report.uploadRetries) {
|
|
1609
1724
|
const body = await readFile(report.paths[0], 'utf8');
|
|
@@ -1648,6 +1763,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1648
1763
|
}
|
|
1649
1764
|
catch (e) {
|
|
1650
1765
|
const message = e.message;
|
|
1766
|
+
/*
|
|
1767
|
+
* The chat took longer than Settings allow for a reply. A limit, not a fault: the conversation
|
|
1768
|
+
* and the work are where they were, and "Continue" carries the task on in that chat.
|
|
1769
|
+
*/
|
|
1770
|
+
if (isReplyTimeout(e)) {
|
|
1771
|
+
sink.event('reply-timeout', { seconds: e.seconds }, message, 'warn');
|
|
1772
|
+
limitHit = { setting: 'replyTimeoutSec', value: e.seconds };
|
|
1773
|
+
return await finish('limit-reached', `replyTimeoutSec (${e.seconds}) reached: ${message}`);
|
|
1774
|
+
}
|
|
1651
1775
|
sink.event('task-error', { error: message, stack: e.stack }, message, 'error');
|
|
1652
1776
|
await transport.dumpFailure(log.path('failures'), 'crash').catch(() => undefined);
|
|
1653
1777
|
/*
|
package/dist/src/plan/brief.js
CHANGED
|
@@ -402,12 +402,12 @@ const FIELD_ROWS_BG = [
|
|
|
402
402
|
];
|
|
403
403
|
const VCS_ROWS_EN = [
|
|
404
404
|
['vcs', 'session', 'yes, always', 'Whether the runner does version control for this session, and where. See below. A session without it is refused.'],
|
|
405
|
-
['vcs.branch', 'task', 'yes in per-task mode', 'The branch this task works on, without the prefix.'],
|
|
405
|
+
['vcs.branch', 'task', 'yes in per-task mode', 'The branch this task works on. A name with a "/" (feature/x, recovery/y) is used as written; one without gets the prefix.'],
|
|
406
406
|
['vcs.commitMessage', 'task', 'yes', 'The commit this task ends with. Imperative, first line under 72 characters.'],
|
|
407
407
|
];
|
|
408
408
|
const VCS_ROWS_BG = [
|
|
409
409
|
['vcs', 'сесия', 'да, винаги', 'Дали runner-ът прави контрол на версиите за тази сесия и къде. Виж по-долу. Сесия без него се отказва.'],
|
|
410
|
-
['vcs.branch', 'задача', 'да, в режим per-task', 'Клонът, по който работи
|
|
410
|
+
['vcs.branch', 'задача', 'да, в режим per-task', 'Клонът, по който работи задачата. Име с „/“ (feature/x, recovery/y) се ползва както е; без „/“ получава представката.'],
|
|
411
411
|
['vcs.commitMessage', 'задача', 'да', 'Комитът, с който задачата завършва. В повелително наклонение, първият ред под 72 знака.'],
|
|
412
412
|
];
|
|
413
413
|
function table(header, rows) {
|
|
@@ -417,7 +417,12 @@ const VCS_DETAIL_EN = `
|
|
|
417
417
|
**vcs on a session**: \`enabled\` (true here), \`repoDir\` (absolute path to the git repository),
|
|
418
418
|
\`branchMode\`, \`commitOnFinish\` (true unless the user says otherwise), \`branchPrefix\` (leave it
|
|
419
419
|
"cop/"), \`branchName\` — used **only** in per-session mode, for the one branch the whole
|
|
420
|
-
session works on
|
|
420
|
+
session works on — and \`startFrom\` with \`baseBranch\`. A branch name with a "/" follows the
|
|
421
|
+
team's own convention and is used exactly as written (in per-session mode an existing branch of that
|
|
422
|
+
name is carried on, not copied); a name without one gets the prefix. With version control on, a check never tests which
|
|
423
|
+
branch is checked out — the runner chooses it; to check that earlier work is included, check the
|
|
424
|
+
commit: \`git merge-base --is-ancestor <commit> HEAD\`. Nor does a check test that the working tree
|
|
425
|
+
is clean: the runner commits after the checks, and its own commit is what leaves it clean.
|
|
421
426
|
|
|
422
427
|
**startFrom** decides where each session's work begins, and it is the user's choice — ask:
|
|
423
428
|
|
|
@@ -427,6 +432,16 @@ session works on, without the prefix — and \`startFrom\` with \`baseBranch\`.
|
|
|
427
432
|
- \`"previous-session"\` — the session carries on from the end of the branch of the session that
|
|
428
433
|
ran before it **in the same repository**, so the sessions form a chain. The first session of
|
|
429
434
|
such a chain starts from \`baseBranch\`. Sessions in other repositories do not count.
|
|
435
|
+
- \`"existing-branch"\` with \`existingBranch\` — no new branch at all: every task of the session
|
|
436
|
+
works on that existing local branch, exactly as named (\`"existingBranch": "recovery/apz-migration"\`),
|
|
437
|
+
one after another, building on what is already on it. For new work that continues a branch the
|
|
438
|
+
user already has. The branch must exist when the plan is imported; \`branchMode\`, \`branchName\`
|
|
439
|
+
and the prefix do not apply.
|
|
440
|
+
|
|
441
|
+
Before a session's first branch the runner fetches and fast-forwards the branch it starts from
|
|
442
|
+
(\`baseBranch\`, or \`existingBranch\`), so the work begins from the server's code; it never resets or
|
|
443
|
+
merges. \`"updateFromRemote": false\` turns that off — only when the user asks to work offline or from
|
|
444
|
+
local commits on purpose.
|
|
430
445
|
|
|
431
446
|
Put the same \`startFrom\` on every session that works in one repository unless the user wants it
|
|
432
447
|
mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
|
|
@@ -460,7 +475,12 @@ const VCS_DETAIL_BG = `
|
|
|
460
475
|
**vcs на сесия**: \`enabled\` (тук true), \`repoDir\` (абсолютен път до git хранилището),
|
|
461
476
|
\`branchMode\`, \`commitOnFinish\` (true, освен ако потребителят не каже друго), \`branchPrefix\`
|
|
462
477
|
(остави "cop/"), \`branchName\` — използва се **само** в режим per-session, за единствения клон,
|
|
463
|
-
по който работи цялата
|
|
478
|
+
по който работи цялата сесия — и \`startFrom\` с \`baseBranch\`. Име на клон с „/“ следва
|
|
479
|
+
конвенцията на екипа и се ползва точно както е написано (в режим per-session съществуващ клон с това
|
|
480
|
+
име се продължава, не се копира); име без „/“ получава представката. При включен контрол на версиите проверка никога не
|
|
481
|
+
проверява кой клон е активен — него го избира runner-ът; за да провериш, че по-ранна работа е
|
|
482
|
+
включена, провери commit-а: \`git merge-base --is-ancestor <commit> HEAD\`. Проверка не проверява и
|
|
483
|
+
че работното дърво е чисто: runner-ът комитва след проверките и неговият commit го оставя чисто.
|
|
464
484
|
|
|
465
485
|
**startFrom** решава откъде започва работата на всяка сесия, и изборът е на потребителя — питай:
|
|
466
486
|
|
|
@@ -469,6 +489,15 @@ const VCS_DETAIL_BG = `
|
|
|
469
489
|
- \`"previous-session"\` — сесията продължава от края на клона на сесията, пусната преди нея **в
|
|
470
490
|
същото хранилище**, така че сесиите образуват верига. Първата сесия от веригата тръгва от
|
|
471
491
|
\`baseBranch\`. Сесии в други хранилища не се броят.
|
|
492
|
+
- \`"existing-branch"\` с \`existingBranch\` — без нов клон: всяка задача от сесията работи на този
|
|
493
|
+
съществуващ локален клон, точно с това име (\`"existingBranch": "recovery/apz-migration"\`), една след
|
|
494
|
+
друга, върху това, което вече е там. За нова работа, която продължава клон, който потребителят вече
|
|
495
|
+
има. Клонът трябва да съществува при импорта; \`branchMode\`, \`branchName\` и представката не се прилагат.
|
|
496
|
+
|
|
497
|
+
Преди първия клон на сесията runner-ът прави fetch и fast-forward на клона, от който тя тръгва
|
|
498
|
+
(\`baseBranch\` или \`existingBranch\`), така че работата започва от кода на сървъра; никога не прави
|
|
499
|
+
reset или merge. \`"updateFromRemote": false\` го изключва — само ако потребителят поиска да работи
|
|
500
|
+
офлайн или нарочно от локални commit-и.
|
|
472
501
|
|
|
473
502
|
Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
|
|
474
503
|
различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
|
|
@@ -738,8 +767,9 @@ in those words, every time:
|
|
|
738
767
|
A check that is wrong rather than a prompt that is wrong is edited instead: **"Edit"** on the
|
|
739
768
|
task card, then **"What it checks"**, **"Must be"** and **"Command"** under **"Checks"**, then
|
|
740
769
|
**"Save and queue it again"** — which, like every "queue" button, starts nothing by itself.
|
|
741
|
-
A task
|
|
742
|
-
|
|
770
|
+
A task stopped by a limit from the settings, or **aborted** because the bot stopped under it, is carried on in its own chat instead:
|
|
771
|
+
**"Continue where it stopped"** on its row in the register, which starts it, or **"Continue in the same chat"** on its card on the session page,
|
|
772
|
+
then **"Run {n} task(s)"** at the top of that page.
|
|
743
773
|
When a failure shows up while the run is still going, **"Pause after this task"** holds the rest
|
|
744
774
|
while you work it out.
|
|
745
775
|
Putting the repository back to before the task is **"Restore"**; putting it back and re-running
|
|
@@ -899,8 +929,9 @@ const PHASE3_BG = `
|
|
|
899
929
|
Когато е сгрешена проверката, а не prompt-ът, се редактира друго: **„Редактирай“** на картата
|
|
900
930
|
на задачата, после **„Какво проверява“**, **„Трябва“** и **„Команда“** под **„Проверки“**, после
|
|
901
931
|
**„Запази и върни в опашката“** — който, като всеки бутон за връщане в опашката, сам не пуска нищо.
|
|
902
|
-
Задача,
|
|
903
|
-
на
|
|
932
|
+
Задача, спряна от лимит в настройките, или **aborted**, защото ботът е спрял под нея, продължава в собствения си чат:
|
|
933
|
+
**„Продължи откъдето спря“** на реда ѝ в регистъра, който я пуска, или **„Продължи в същия чат“** на картата ѝ на страницата на сесията,
|
|
934
|
+
после **„Пусни {n} задача(и)“** горе на същата страница.
|
|
904
935
|
Когато провал се покаже, докато пускането още върви, **„Пауза след тази задача“** задържа
|
|
905
936
|
останалото, докато го разбереш.
|
|
906
937
|
Връщането на хранилището отпреди задачата е **„Върни“**; връщането му плюс ново изпълнение на
|
|
@@ -169,6 +169,9 @@ persona = '') {
|
|
|
169
169
|
const vcs = { ...DEFAULT_VCS, ...(planned.vcs ?? {}) };
|
|
170
170
|
vcs.branchPrefix = vcs.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
|
|
171
171
|
vcs.repoDir = vcs.repoDir.trim();
|
|
172
|
+
// A branch to carry on, given alone, is that choice: see `VersionControl.startFrom`.
|
|
173
|
+
if (vcs.existingBranch?.trim())
|
|
174
|
+
vcs.startFrom = 'existing-branch';
|
|
172
175
|
const group = planned.conversationGroup.trim() || sharedGroup;
|
|
173
176
|
await store.updateSession(created.id, (s) => {
|
|
174
177
|
s.onFailure = planned.onFailure;
|
package/dist/src/plan/schema.js
CHANGED
|
@@ -44,16 +44,40 @@ const VcsInput = z
|
|
|
44
44
|
* Absent keeps the old behaviour, wherever the repository is. See `VersionControl.startFrom`.
|
|
45
45
|
*/
|
|
46
46
|
startFrom: z
|
|
47
|
-
.enum(['branch', 'previous-session', 'head'], {
|
|
48
|
-
error: 'startFrom must be "branch" (every session starts from the local branch in baseBranch, "main" unless said otherwise)
|
|
47
|
+
.enum(['branch', 'previous-session', 'head', 'existing-branch'], {
|
|
48
|
+
error: 'startFrom must be "branch" (every session starts from the local branch in baseBranch, "main" unless said otherwise), "previous-session" (each session carries on from the branch of the session before it in the same repository) or "existing-branch" (the session works on the existing branch named in existingBranch).',
|
|
49
49
|
})
|
|
50
50
|
.optional(),
|
|
51
51
|
/** The local branch `startFrom: "branch"` starts from, and `previous-session`'s fallback. */
|
|
52
52
|
baseBranch: z.string().trim().optional(),
|
|
53
|
+
/**
|
|
54
|
+
* The existing local branch the whole session works on, exactly as named: no new branch, no
|
|
55
|
+
* prefix. Given alone it means `startFrom: "existing-branch"`. See `VersionControl.startFrom`.
|
|
56
|
+
*/
|
|
57
|
+
existingBranch: z.string().trim().optional(),
|
|
58
|
+
/**
|
|
59
|
+
* Fetch and fast-forward the branch the session starts from before its first branch is cut.
|
|
60
|
+
* Absent means true. See `VersionControl.updateFromRemote`.
|
|
61
|
+
*/
|
|
62
|
+
updateFromRemote: z.boolean().optional(),
|
|
53
63
|
}, {
|
|
54
64
|
error: 'Every session needs a "vcs" object saying whether the runner does version control for it and, when it does, which git repository to work in. Ask the user both questions and write the answers here.',
|
|
55
65
|
})
|
|
56
66
|
.superRefine((vcs, ctx) => {
|
|
67
|
+
if (vcs.startFrom === 'existing-branch' && !vcs.existingBranch) {
|
|
68
|
+
ctx.addIssue({
|
|
69
|
+
code: 'custom',
|
|
70
|
+
path: ['existingBranch'],
|
|
71
|
+
message: 'startFrom is "existing-branch", so "existingBranch" must name the local branch to carry on, exactly as it is in git (for example "recovery/apz-migration").',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (vcs.existingBranch && vcs.startFrom && vcs.startFrom !== 'existing-branch') {
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: 'custom',
|
|
77
|
+
path: ['existingBranch'],
|
|
78
|
+
message: `"existingBranch" carries on a branch that exists, and startFrom "${vcs.startFrom}" cuts a new one. Keep one: startFrom "existing-branch" with existingBranch, or leave existingBranch out.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
57
81
|
if (vcs.enabled && !vcs.repoDir.trim()) {
|
|
58
82
|
ctx.addIssue({
|
|
59
83
|
code: 'custom',
|