copilot-operator 0.1.10 → 0.1.12
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 +12 -1
- package/dist/src/api/operator.service.js +92 -7
- package/dist/src/config/schema.js +20 -3
- package/dist/src/context/desktopMirror.js +34 -12
- package/dist/src/context/projectMirror.js +69 -1
- package/dist/src/orchestrator/contract.js +56 -1
- package/dist/src/orchestrator/taskRunner.js +160 -13
- package/dist/src/plan/brief.js +20 -8
- package/dist/src/plan/systemGuide.js +74 -10
- package/dist/src/session/compose.js +17 -2
- package/dist/src/session/exportRecord.js +8 -1
- 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 +89 -8
- package/dist/src/vcs/git.js +15 -0
- package/dist/src/vcs/syncCommand.js +105 -0
- package/dist/src/vcs/taskVcs.js +29 -6
- 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-c3dd06c6a459c114.js +1 -0
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
- package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.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-854e3dde672e38a2.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-cc99401da5173b36.js +1 -0
- package/dist/web/_next/static/css/{20c6b36996df5e4c.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 +7 -3
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
- package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.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-fb634b39ce9ba4a3.js +0 -1
- /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → bcBE4tY_aNh6kzLzN1luP}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → bcBE4tY_aNh6kzLzN1luP}/_ssgManifest.js +0 -0
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The end of a task in one fixed shape, put together by the runner from what it recorded.
|
|
3
|
+
*
|
|
4
|
+
* The chat's summary is the model's account, in its words and in whatever order it chose. The
|
|
5
|
+
* operator reading a finished run needs the same eight answers every time — what came of it, which
|
|
6
|
+
* files, what was checked, what is still wrong, where the evidence is, where the code is, what is
|
|
7
|
+
* left for a person to do, and what was never run — and until now had to piece them together from
|
|
8
|
+
* the summary, the check list, the review panel, the branch line and the iteration reports. This
|
|
9
|
+
* reads them off the task record instead, so every entry is a fact the runner saw rather than a
|
|
10
|
+
* claim, and nothing here is written by the model.
|
|
11
|
+
*/
|
|
12
|
+
import { isContinuable } from './model.js';
|
|
1
13
|
export function composeHandoff(task, notRun = []) {
|
|
2
14
|
const failedChecks = (task.checkResults ?? []).filter((c) => !c.passed);
|
|
3
15
|
const openFindings = task.review?.verdict === 'fail' ? (task.review.findings ?? []).filter((f) => f.about !== 'task') : [];
|
|
@@ -18,10 +30,10 @@ export function composeHandoff(task, notRun = []) {
|
|
|
18
30
|
manual.push(`version control: ${vcs.problem}`);
|
|
19
31
|
if (vcs.commit && vcs.branch)
|
|
20
32
|
manual.push(`push ${vcs.branch} when you are ready — the runner never pushes`);
|
|
33
|
+
if (isContinuable(task))
|
|
34
|
+
manual.push('continue it in the same chat from the register, or run it again');
|
|
21
35
|
if (task.status === 'blocked')
|
|
22
36
|
manual.push(`unblock it: ${task.reason ?? 'see the reason'}`);
|
|
23
|
-
if (task.status === 'limit-reached' || task.status === 'aborted')
|
|
24
|
-
manual.push('continue it in the same chat from the register, or run it again');
|
|
25
37
|
if (task.status === 'failed')
|
|
26
38
|
manual.push('read why it failed, fix the prompt or the checks, and queue it again');
|
|
27
39
|
if (openFindings.length > 0)
|
|
@@ -32,7 +44,33 @@ export function composeHandoff(task, notRun = []) {
|
|
|
32
44
|
outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
|
|
33
45
|
changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
|
|
34
46
|
validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
|
|
35
|
-
...(task.review
|
|
47
|
+
...(task.review
|
|
48
|
+
? { review: { verdict: task.review.verdict, open: openFindings.length, ...(task.review.skippedBecause ? { skippedBecause: task.review.skippedBecause } : {}) } }
|
|
49
|
+
: {}),
|
|
50
|
+
...(vcs.branch || vcs.problem
|
|
51
|
+
? {
|
|
52
|
+
lifecycle: {
|
|
53
|
+
...(vcs.beforeCommit ? { preCommitState: { changed: vcs.beforeCommit.changed } } : {}),
|
|
54
|
+
checksResult: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
|
|
55
|
+
commitResult: {
|
|
56
|
+
...(vcs.branch ? { branch: vcs.branch } : {}),
|
|
57
|
+
...(vcs.commit ? { commit: vcs.commit } : {}),
|
|
58
|
+
files: vcs.files?.length ?? 0,
|
|
59
|
+
...(vcs.problem ? { problem: vcs.problem } : {}),
|
|
60
|
+
},
|
|
61
|
+
...(vcs.afterCommit
|
|
62
|
+
? {
|
|
63
|
+
postCommitState: {
|
|
64
|
+
...(vcs.afterCommit.branch ? { branch: vcs.afterCommit.branch } : {}),
|
|
65
|
+
...(vcs.afterCommit.head ? { head: vcs.afterCommit.head } : {}),
|
|
66
|
+
clean: vcs.afterCommit.clean,
|
|
67
|
+
uncommitted: vcs.afterCommit.changed,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
: {}),
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
: {}),
|
|
36
74
|
knownIssues,
|
|
37
75
|
evidence: { ...(task.runId ? { runId: task.runId } : {}), checks: task.checkResults?.length ?? 0, reviewRounds: task.review?.rounds ?? 0 },
|
|
38
76
|
vcs: { ...(vcs.branch ? { branch: vcs.branch } : {}), ...(vcs.commit ? { commit: vcs.commit } : {}), pushed: false, ...(vcs.problem ? { problem: vcs.problem } : {}) },
|
|
@@ -12,6 +12,15 @@ export function tidyVcsPlan(plan) {
|
|
|
12
12
|
return undefined;
|
|
13
13
|
return { ...(branch ? { branch } : {}), ...(commitMessage ? { commitMessage } : {}) };
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Whether "Continue" may carry a task on where it stopped, in its chat and on its branch.
|
|
17
|
+
*
|
|
18
|
+
* A task that stopped before it finished qualifies (`limit-reached`, `aborted`), and so does one
|
|
19
|
+
* whose ending came from a limit in the settings whatever its status says. See `TaskLimit`.
|
|
20
|
+
*/
|
|
21
|
+
export function isContinuable(t) {
|
|
22
|
+
return t.status === 'limit-reached' || t.status === 'aborted' || (!!t.limit && (t.status === 'failed' || t.status === 'blocked'));
|
|
23
|
+
}
|
|
15
24
|
export function newId(prefix = '') {
|
|
16
25
|
const now = new Date();
|
|
17
26
|
const p = (n) => String(n).padStart(2, '0');
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promises';
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
11
|
import { join, resolve } from 'node:path';
|
|
12
|
-
import { newId, tidyVcsPlan } from './model.js';
|
|
12
|
+
import { isContinuable, newId, tidyVcsPlan } from './model.js';
|
|
13
13
|
/** A task the runner is inside of. It cannot be edited, deleted, or left behind at startup. */
|
|
14
14
|
const ACTIVE_STATUSES = ['running', 'waiting-approval'];
|
|
15
15
|
/**
|
|
@@ -341,22 +341,24 @@ export class SessionStore {
|
|
|
341
341
|
*
|
|
342
342
|
* Two kinds of stop qualify. The runner's limit (`limit-reached`), and a stop that was not the
|
|
343
343
|
* task's doing (`aborted`): the bot itself stopping under it — power, a shutdown, Ctrl+C, a crash,
|
|
344
|
-
* recorded in `interruption` at the next start — or the operator stopping it. A task that failed
|
|
345
|
-
*
|
|
346
|
-
*
|
|
344
|
+
* recorded in `interruption` at the next start — or the operator stopping it. A task that failed
|
|
345
|
+
* or blocked has a verdict on its work, and carrying that work on would carry the verdict's cause
|
|
346
|
+
* on with it; those are run again — unless what ended them was a limit from the settings (out of
|
|
347
|
+
* rounds of fixing checks or review findings), which is a counter, not the verdict. See `TaskLimit`.
|
|
347
348
|
*/
|
|
348
349
|
async continueTask(sessionId, taskId) {
|
|
349
350
|
const current = (await this.getSession(sessionId))?.tasks.find((x) => x.id === taskId);
|
|
350
351
|
if (!current)
|
|
351
352
|
throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
|
|
352
|
-
if (current
|
|
353
|
-
throw new Error('Only a task that stopped before it finished can be continued; run this one again instead.');
|
|
353
|
+
if (!isContinuable(current)) {
|
|
354
|
+
throw new Error('Only a task that stopped before it finished, or at a limit from the settings, can be continued; run this one again instead.');
|
|
354
355
|
}
|
|
355
|
-
const how = current.status === 'limit-reached' ? 'limit' : current.interruption ? 'interrupted' : 'stopped';
|
|
356
|
+
const how = current.status === 'limit-reached' || current.limit ? 'limit' : current.interruption ? 'interrupted' : 'stopped';
|
|
356
357
|
return await this.rerunTask(sessionId, taskId, {}, {
|
|
357
358
|
fromAttempt: current.attempt ?? 1,
|
|
358
359
|
stoppedBecause: current.reason,
|
|
359
360
|
how,
|
|
361
|
+
...(current.limit ? { limit: current.limit } : {}),
|
|
360
362
|
...(current.interruption ? { interruption: current.interruption } : {}),
|
|
361
363
|
});
|
|
362
364
|
}
|
|
@@ -400,6 +402,7 @@ export class SessionStore {
|
|
|
400
402
|
buildsOn: t.buildsOn,
|
|
401
403
|
freshRetry: t.freshRetry,
|
|
402
404
|
stopCode: t.stopCode,
|
|
405
|
+
limit: t.limit,
|
|
403
406
|
scope: t.scope,
|
|
404
407
|
scopeReverted: t.scopeReverted,
|
|
405
408
|
vcs: t.vcs,
|
|
@@ -438,6 +441,7 @@ export class SessionStore {
|
|
|
438
441
|
t.scopeReverted = undefined;
|
|
439
442
|
t.freshRetry = undefined;
|
|
440
443
|
t.stopCode = undefined;
|
|
444
|
+
t.limit = undefined;
|
|
441
445
|
// The branch of the finished attempt stays in the repository and stays on the record
|
|
442
446
|
// above; the next attempt gets its own, cut from the same commit this one started at.
|
|
443
447
|
t.vcs = undefined;
|
|
@@ -12,9 +12,42 @@
|
|
|
12
12
|
* Nothing here is inferred. An entry is a file, or a section of the task log, or a field of
|
|
13
13
|
* the task record; the reader decides what it means.
|
|
14
14
|
*/
|
|
15
|
-
import { readdir,
|
|
15
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
const MAX_TEXT = 60_000;
|
|
18
|
+
/*
|
|
19
|
+
* When each thing happened.
|
|
20
|
+
*
|
|
21
|
+
* Every entry of the story is a file the runner wrote at the moment it happened: a reply is saved
|
|
22
|
+
* as it arrives, a step's log is opened as the step starts, a results file is written just before
|
|
23
|
+
* it is sent. So a file's creation time is the entry's time, for runs recorded long before anyone
|
|
24
|
+
* asked for times as much as for new ones — nothing had to be recorded differently. The modification
|
|
25
|
+
* time is the fallback on a file system that keeps no creation time. The opening message has no file
|
|
26
|
+
* of its own and takes its time from the transcript's first "message-sent".
|
|
27
|
+
*/
|
|
28
|
+
async function createdAt(path) {
|
|
29
|
+
const info = await stat(path).catch(() => null);
|
|
30
|
+
if (!info)
|
|
31
|
+
return undefined;
|
|
32
|
+
const ms = info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs;
|
|
33
|
+
return new Date(ms).toISOString();
|
|
34
|
+
}
|
|
35
|
+
async function firstSentAt(dir) {
|
|
36
|
+
const text = await readFile(join(dir, 'transcript.jsonl'), 'utf8').catch(() => '');
|
|
37
|
+
for (const line of text.split('\n')) {
|
|
38
|
+
if (!line.includes('"message-sent"'))
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
const e = JSON.parse(line);
|
|
42
|
+
if (e.type === 'message-sent' && e.at)
|
|
43
|
+
return e.at;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* a line cut off by a crash */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
18
51
|
function clip(text) {
|
|
19
52
|
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (${text.length - MAX_TEXT} more characters in the run folder)` : text;
|
|
20
53
|
}
|
|
@@ -73,6 +106,7 @@ async function stepsIn(dir) {
|
|
|
73
106
|
const iteration = Number(m[1]);
|
|
74
107
|
const parsed = parseStepLog(await readFile(join(dir, name), 'utf8').catch(() => ''));
|
|
75
108
|
const entry = {
|
|
109
|
+
at: await createdAt(join(dir, name)),
|
|
76
110
|
kind: 'step',
|
|
77
111
|
iteration,
|
|
78
112
|
id: Number(m[2]),
|
|
@@ -103,21 +137,41 @@ async function reviewRound(dir, round) {
|
|
|
103
137
|
const read = async (name) => clip(await readFile(join(dir, name), 'utf8').catch(() => ''));
|
|
104
138
|
const names = (await readdir(dir).catch(() => [])).sort();
|
|
105
139
|
if (names.includes('00-brief.md'))
|
|
106
|
-
entries.push({ kind: 'sent', iteration: 0, label: 'brief', text: await read('00-brief.md') });
|
|
140
|
+
entries.push({ kind: 'sent', iteration: 0, label: 'brief', text: await read('00-brief.md'), at: await createdAt(join(dir, '00-brief.md')) });
|
|
107
141
|
const steps = await stepsIn(join(dir, 'steps'));
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
142
|
+
/*
|
|
143
|
+
* The replies, by what they answer. `00-opening.md` is the answer to the brief — the one that
|
|
144
|
+
* proposes the first steps — and `NN-review.md` the answer to the results of iteration NN (the
|
|
145
|
+
* review saves it after sending them). So after reply k come the steps of iteration k + 1 and
|
|
146
|
+
* their results, then reply k + 1. The story used to read only the `NN-review` files and put each
|
|
147
|
+
* before the steps it came after; the times on the entries are what showed it.
|
|
148
|
+
*/
|
|
149
|
+
const replyFor = new Map();
|
|
150
|
+
if (names.includes('00-opening.md'))
|
|
151
|
+
replyFor.set(0, '00-opening.md');
|
|
152
|
+
for (const n of names) {
|
|
153
|
+
const m = n.match(/^(\d+)-review\.md$/);
|
|
154
|
+
if (m)
|
|
155
|
+
replyFor.set(Number(m[1]), n);
|
|
156
|
+
}
|
|
157
|
+
const last = Math.max(0, ...replyFor.keys(), ...steps.keys());
|
|
158
|
+
for (let k = 0; k <= last; k += 1) {
|
|
159
|
+
const name = replyFor.get(k);
|
|
160
|
+
if (name) {
|
|
161
|
+
const text = await read(name);
|
|
162
|
+
entries.push({ kind: 'reply', iteration: k, label: `review ${round}, reply ${k + 1}`, text, ...parseReply(text), at: await createdAt(join(dir, name)) });
|
|
163
|
+
}
|
|
164
|
+
for (const step of steps.get(k + 1) ?? [])
|
|
114
165
|
entries.push(step);
|
|
115
|
-
if (names.includes(`iteration-${
|
|
116
|
-
entries.push({ kind: 'sent', iteration:
|
|
166
|
+
if (names.includes(`iteration-${k + 1}.txt`)) {
|
|
167
|
+
entries.push({ kind: 'sent', iteration: k + 1, label: `results ${k + 1}`, text: await read(`iteration-${k + 1}.txt`), at: await createdAt(join(dir, `iteration-${k + 1}.txt`)) });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const replies = [...replyFor.values()];
|
|
171
|
+
if (names.includes('findings-sent.md')) {
|
|
172
|
+
entries.push({ kind: 'sent', iteration: replies.length, label: 'findings sent to the implementer', text: await read('findings-sent.md'), at: await createdAt(join(dir, 'findings-sent.md')) });
|
|
117
173
|
}
|
|
118
|
-
|
|
119
|
-
entries.push({ kind: 'sent', iteration: replies.length, label: 'findings sent to the implementer', text: await read('findings-sent.md') });
|
|
120
|
-
return { kind: 'review', round, entries };
|
|
174
|
+
return { kind: 'review', round, entries, at: entries.find((e) => e.at)?.at };
|
|
121
175
|
}
|
|
122
176
|
export async function buildStory(runsDir, runId, task, live) {
|
|
123
177
|
const dir = join(runsDir, runId);
|
|
@@ -126,7 +180,9 @@ export async function buildStory(runsDir, runId, task, live) {
|
|
|
126
180
|
const entries = [];
|
|
127
181
|
const opening = sections.find((s) => s.name === 'OPENING MESSAGE');
|
|
128
182
|
if (opening)
|
|
129
|
-
entries.push({ kind: 'sent', iteration: 0, label: 'opening message', text: clip(opening.text) });
|
|
183
|
+
entries.push({ kind: 'sent', iteration: 0, label: 'opening message', text: clip(opening.text), at: (await firstSentAt(dir)) ?? task.startedAt });
|
|
184
|
+
/** The results file of iteration k, as the report writer names it by default; its creation is when it was sent. */
|
|
185
|
+
const resultsAt = async (k) => await createdAt(join(dir, 'reports', `iteration-${k}.txt`));
|
|
130
186
|
const steps = await stepsIn(join(dir, 'steps'));
|
|
131
187
|
const reports = new Map();
|
|
132
188
|
for (const s of sections) {
|
|
@@ -161,7 +217,7 @@ export async function buildStory(runsDir, runId, task, live) {
|
|
|
161
217
|
for (const step of steps.get(k) ?? [])
|
|
162
218
|
entries.push(step);
|
|
163
219
|
if (reports.has(k))
|
|
164
|
-
entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)) });
|
|
220
|
+
entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)), at: await resultsAt(k) });
|
|
165
221
|
lastIteration = k;
|
|
166
222
|
}
|
|
167
223
|
else if (rv) {
|
|
@@ -170,14 +226,14 @@ export async function buildStory(runsDir, runId, task, live) {
|
|
|
170
226
|
for (const step of steps.get(lastIteration + 1) ?? [])
|
|
171
227
|
entries.push(step);
|
|
172
228
|
if (reports.has(lastIteration + 1))
|
|
173
|
-
entries.push({ kind: 'sent', iteration: lastIteration + 1, label: `results ${lastIteration + 1}`, text: clip(reports.get(lastIteration + 1)) });
|
|
229
|
+
entries.push({ kind: 'sent', iteration: lastIteration + 1, label: `results ${lastIteration + 1}`, text: clip(reports.get(lastIteration + 1)), at: await resultsAt(lastIteration + 1) });
|
|
174
230
|
lastIteration += reports.has(lastIteration + 1) ? 1 : 0;
|
|
175
231
|
if (!placedReviews.has(round)) {
|
|
176
232
|
entries.push(await reviewRound(join(dir, 'review', String(round)), round));
|
|
177
233
|
placedReviews.add(round);
|
|
178
234
|
}
|
|
179
235
|
}
|
|
180
|
-
entries.push({ kind: 'reply', iteration: it ? Number(it[1]) : 0, label, text, ...parsed });
|
|
236
|
+
entries.push({ kind: 'reply', iteration: it ? Number(it[1]) : 0, label, text, ...parsed, at: await createdAt(join(dir, 'replies', name)) });
|
|
181
237
|
}
|
|
182
238
|
// Steps and results after the last reply (a task that ended without another reply), and
|
|
183
239
|
// review rounds nobody replied to yet (one still going, or the last one that passed).
|
|
@@ -186,7 +242,7 @@ export async function buildStory(runsDir, runId, task, live) {
|
|
|
186
242
|
for (const step of list)
|
|
187
243
|
entries.push(step);
|
|
188
244
|
if (reports.has(k))
|
|
189
|
-
entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)) });
|
|
245
|
+
entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)), at: await resultsAt(k) });
|
|
190
246
|
}
|
|
191
247
|
}
|
|
192
248
|
for (const round of reviewDirs) {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* running in this process — a check script — can call `setTransportFactory`.
|
|
21
21
|
*/
|
|
22
22
|
import { CopilotTransport } from './copilotTransport.js';
|
|
23
|
+
export { ReplyTimeoutError, isReplyTimeout } from './copilotTransport.js';
|
|
23
24
|
const realChat = (opts) => new CopilotTransport(opts);
|
|
24
25
|
let factory = realChat;
|
|
25
26
|
/** A chat for these options: the real Copilot window, unless a check has put a scripted one in. */
|
|
@@ -18,6 +18,25 @@ import { landed } from './acceptance.js';
|
|
|
18
18
|
import { Blocker, Css, Label, Model, Rename, Sidebar, Signal, Surface, TestId, Upload, Url } from './locators.js';
|
|
19
19
|
import { acquireProfileLock } from './profileLock.js';
|
|
20
20
|
import { parseChatId } from './chatSession.js';
|
|
21
|
+
/**
|
|
22
|
+
* The chat did not finish a reply within `copilot.replyTimeoutSec`.
|
|
23
|
+
*
|
|
24
|
+
* Its own class because it is a limit from the settings, not a fault: the runner ends the task
|
|
25
|
+
* `limit-reached` on it, so "Continue" carries the task on in the same chat, where the reply may
|
|
26
|
+
* well have finished by then. Any other error from the transport still fails the task.
|
|
27
|
+
*/
|
|
28
|
+
export class ReplyTimeoutError extends Error {
|
|
29
|
+
seconds;
|
|
30
|
+
constructor(seconds, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.seconds = seconds;
|
|
33
|
+
this.name = 'ReplyTimeoutError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Recognised by name as well: a copy of this module loaded twice makes `instanceof` miss its own class. */
|
|
37
|
+
export function isReplyTimeout(e) {
|
|
38
|
+
return e instanceof ReplyTimeoutError || (e instanceof Error && e.name === 'ReplyTimeoutError' && typeof e.seconds === 'number');
|
|
39
|
+
}
|
|
21
40
|
const ORIGIN = 'https://m365.cloud.microsoft';
|
|
22
41
|
/** Escapes a model name so it can be matched literally inside a regular expression. */
|
|
23
42
|
function escapeForRegExp(text) {
|
|
@@ -680,14 +699,77 @@ Current URL: ${url}`);
|
|
|
680
699
|
}
|
|
681
700
|
await row.click({ timeout: 8_000 }).catch(() => undefined);
|
|
682
701
|
}
|
|
683
|
-
/**
|
|
702
|
+
/**
|
|
703
|
+
* Whether the model picker is open: its button says so (`aria-expanded`), or its rows are on screen.
|
|
704
|
+
* Both, because either alone has been wrong: rows can be mid-animation after the menu closed, and a
|
|
705
|
+
* button re-rendered by the page can lose the attribute while the menu is still up.
|
|
706
|
+
*/
|
|
707
|
+
async modelMenuOpen() {
|
|
708
|
+
const button = await this.resolveModelButton(1_000);
|
|
709
|
+
const expanded = button ? await button.getAttribute('aria-expanded').catch(() => null) : null;
|
|
710
|
+
if (expanded === 'true')
|
|
711
|
+
return true;
|
|
712
|
+
return (await this.readMenuRows()).length > 0;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Closes the picker, submenu included, and makes sure it is closed.
|
|
716
|
+
*
|
|
717
|
+
* Escape pressed on the page was all this did, three times, and then it gave up without a word.
|
|
718
|
+
* Live, that left the model list open over the chat after a check of the model: Escape goes to
|
|
719
|
+
* whatever has focus, and after a click dispatched on a row, or no click at all because the model
|
|
720
|
+
* was already chosen, focus was not in the menu, so nothing heard it. Each way a person would close
|
|
721
|
+
* it is tried in turn, and the menu is looked at after each: Escape; Escape sent to the menu itself;
|
|
722
|
+
* the picker button again (it toggles); a click outside the menu, in the composer, which a Fluent
|
|
723
|
+
* popup takes as "close". A menu still open after all of them is reported, not passed over.
|
|
724
|
+
*/
|
|
684
725
|
async closeMenu() {
|
|
685
|
-
|
|
686
|
-
await this.p.keyboard.press('Escape')
|
|
687
|
-
|
|
688
|
-
|
|
726
|
+
const ways = [
|
|
727
|
+
['escape', async () => await this.p.keyboard.press('Escape')],
|
|
728
|
+
[
|
|
729
|
+
'escape in the menu',
|
|
730
|
+
async () => {
|
|
731
|
+
for (const selector of Model.popupSelectors.slice(0, 2)) {
|
|
732
|
+
const menu = this.p.locator(`${selector}:visible`).last();
|
|
733
|
+
if ((await menu.count()) > 0) {
|
|
734
|
+
await menu.press('Escape', { timeout: 2_000 });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
[
|
|
741
|
+
'the picker button',
|
|
742
|
+
async () => {
|
|
743
|
+
const button = await this.resolveModelButton(1_000);
|
|
744
|
+
// Only while the button itself says the menu is open: clicking it otherwise would open it.
|
|
745
|
+
if (button && (await button.getAttribute('aria-expanded').catch(() => null)) === 'true')
|
|
746
|
+
await button.click({ timeout: 3_000 });
|
|
747
|
+
},
|
|
748
|
+
],
|
|
749
|
+
[
|
|
750
|
+
'a click outside',
|
|
751
|
+
async () => {
|
|
752
|
+
const composer = this.composer();
|
|
753
|
+
if ((await composer.count().catch(() => 0)) > 0 && (await composer.isVisible().catch(() => false)))
|
|
754
|
+
await composer.click({ timeout: 3_000 });
|
|
755
|
+
else
|
|
756
|
+
await this.p.mouse.click(4, 4);
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
];
|
|
760
|
+
for (const [how, act] of ways) {
|
|
761
|
+
if (!(await this.modelMenuOpen()))
|
|
689
762
|
return;
|
|
763
|
+
await act().catch(() => undefined);
|
|
764
|
+
await this.p.waitForTimeout(250);
|
|
765
|
+
if (!(await this.modelMenuOpen())) {
|
|
766
|
+
if (how !== 'escape')
|
|
767
|
+
this.emit('model-menu-closed', { how });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
690
770
|
}
|
|
771
|
+
if (await this.modelMenuOpen())
|
|
772
|
+
this.emit('model-menu-stuck', { tried: ways.map(([how]) => how) });
|
|
691
773
|
}
|
|
692
774
|
/** The first popup container that becomes visible after the picker is clicked. */
|
|
693
775
|
async waitForPopup(timeoutMs = 8_000) {
|
|
@@ -772,8 +854,7 @@ Current URL: ${url}`);
|
|
|
772
854
|
await this.p.waitForTimeout(1_000);
|
|
773
855
|
// Whatever the click did, the menu is not left open over the chat: a build that keeps it open
|
|
774
856
|
// after a choice, or a click that did not land, would otherwise leave it for the next message.
|
|
775
|
-
|
|
776
|
-
await this.closeMenu();
|
|
857
|
+
await this.closeMenu();
|
|
777
858
|
const after = await this.currentModel();
|
|
778
859
|
// The button shows the choice, so it is the check. It also **shortens** it: picking
|
|
779
860
|
// "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
|
|
@@ -1057,7 +1138,7 @@ Current URL: ${url}`);
|
|
|
1057
1138
|
}
|
|
1058
1139
|
if (!sawNewTurn || !last.finished) {
|
|
1059
1140
|
await this.dumpFailure(join(this.opts.transportDir, '..', 'failures'), 'reply-timeout').catch(() => undefined);
|
|
1060
|
-
throw new
|
|
1141
|
+
throw new ReplyTimeoutError(Math.round(this.opts.replyTimeoutMs / 1000), `Copilot did not finish a reply within ${Math.round(this.opts.replyTimeoutMs / 1000)}s ` +
|
|
1061
1142
|
`(new turn seen: ${sawNewTurn}, still streaming: ${last.streaming}). ` +
|
|
1062
1143
|
'A screenshot and an HTML dump are in the run folder.');
|
|
1063
1144
|
}
|
package/dist/src/vcs/git.js
CHANGED
|
@@ -155,6 +155,21 @@ export function branchNameFrom(parts, prefix = 'cop/') {
|
|
|
155
155
|
*/
|
|
156
156
|
export function plannedBranchName(raw, prefix = 'cop/', attempt = 1) {
|
|
157
157
|
const trimmed = raw.trim();
|
|
158
|
+
/*
|
|
159
|
+
* A name with its own namespace — `recovery/apz-migration`, `feature/JIRA-123-schemas` — is the
|
|
160
|
+
* team's convention, and it is used as written: the prefix is only for names that have none.
|
|
161
|
+
* Flattening it to `cop/recovery-apz-migration` made a different branch from the one asked for,
|
|
162
|
+
* so a plan continuing a recovery branch worked beside it instead of on it (2026-09-30). Each
|
|
163
|
+
* segment is still made safe, keeping its case; git itself has the last word in `switchTo`.
|
|
164
|
+
*/
|
|
165
|
+
if (trimmed.includes('/')) {
|
|
166
|
+
const segments = trimmed
|
|
167
|
+
.split('/')
|
|
168
|
+
.map((seg) => seg.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[-.]+|[-.]+$/g, ''))
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
if (segments.length > 1)
|
|
171
|
+
return `${segments.join('/')}${attempt > 1 ? `-a${attempt}` : ''}`.replace(/\.\.+/g, '.').slice(0, 200);
|
|
172
|
+
}
|
|
158
173
|
const p = prefix.trim();
|
|
159
174
|
const body = p && trimmed.toLowerCase().startsWith(p.toLowerCase()) ? trimmed.slice(p.length) : trimmed;
|
|
160
175
|
return branchNameFrom([body, attempt > 1 ? `a${attempt}` : undefined], prefix);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command that brings a project's current branch back to the remote's main branch, handed to
|
|
3
|
+
* the operator rather than run.
|
|
4
|
+
*
|
|
5
|
+
* Asked for after a day of runs: a project left on a task branch, with files a step created and
|
|
6
|
+
* nobody committed, wants to go back to where the team's code is. That is `fetch`, `reset --hard`
|
|
7
|
+
* to the remote's main, and `clean -fd` for the new files git does not ignore — three commands that
|
|
8
|
+
* destroy work if pointed at the wrong folder or run a minute too early. So the bot never runs them.
|
|
9
|
+
* It reads the repository, says exactly what would be lost — commits on this branch that the
|
|
10
|
+
* remote's main does not have, changed tracked files, new files — and gives the command, ready to
|
|
11
|
+
* paste into PowerShell, with a read-only preview beside it. Ignored files (`node_modules`, `.env`,
|
|
12
|
+
* build output) are left alone: `clean` without `-x` does not touch them.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here writes to the repository or the network. `git fetch` is in the command, not here:
|
|
15
|
+
* what "would be lost" is computed against the remote branch as last fetched, and the page says so.
|
|
16
|
+
*/
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { git, porcelainPaths } from './git.js';
|
|
20
|
+
/** A string as a single-quoted PowerShell literal: nothing inside it is expanded. */
|
|
21
|
+
export function psQuote(text) {
|
|
22
|
+
return `'${text.replace(/'/g, "''")}'`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The two commands, for Windows PowerShell 5.1 and PowerShell 7 alike: `;` and `$LASTEXITCODE`
|
|
26
|
+
* rather than `&&`, which 5.1 does not have, so a failed fetch stops everything after it instead
|
|
27
|
+
* of resetting to a remote branch that is out of date.
|
|
28
|
+
*/
|
|
29
|
+
export function syncCommands(dir, remote, target) {
|
|
30
|
+
const g = `git -C ${psQuote(dir)}`;
|
|
31
|
+
return {
|
|
32
|
+
preview: [
|
|
33
|
+
`${g} fetch ${psQuote(remote)}`,
|
|
34
|
+
`${g} log --oneline ${psQuote(`${target}..HEAD`)}`,
|
|
35
|
+
`${g} status --short`,
|
|
36
|
+
`${g} clean -nd`,
|
|
37
|
+
].join('; '),
|
|
38
|
+
command: `${g} fetch ${psQuote(remote)}; if ($LASTEXITCODE -eq 0) { ${g} reset --hard ${psQuote(target)}; ` +
|
|
39
|
+
`if ($LASTEXITCODE -eq 0) { ${g} clean -fd } }`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const empty = (repoDir, problem) => ({
|
|
43
|
+
ok: false,
|
|
44
|
+
problem,
|
|
45
|
+
repoDir,
|
|
46
|
+
willLose: { commits: [], changed: [], untracked: [] },
|
|
47
|
+
preview: '',
|
|
48
|
+
command: '',
|
|
49
|
+
});
|
|
50
|
+
export async function planSync(repoDir) {
|
|
51
|
+
const inside = await git(repoDir, ['rev-parse', '--is-inside-work-tree']);
|
|
52
|
+
if (!inside.ok || inside.stdout !== 'true')
|
|
53
|
+
return empty(repoDir, `${repoDir} is not a git repository.`);
|
|
54
|
+
const branch = await git(repoDir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
55
|
+
if (!branch.ok || !branch.stdout) {
|
|
56
|
+
return empty(repoDir, 'The repository is not on a branch (a detached HEAD), so there is no "current branch" to bring up to date. Check out a branch first.');
|
|
57
|
+
}
|
|
58
|
+
const remotes = (await git(repoDir, ['remote'])).stdout.split('\n').map((r) => r.trim()).filter(Boolean);
|
|
59
|
+
if (remotes.length === 0)
|
|
60
|
+
return empty(repoDir, 'The repository has no remote, so there is no remote main branch to go back to.');
|
|
61
|
+
const remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
62
|
+
// The remote's own idea of its main branch first, then the two usual names.
|
|
63
|
+
let target = '';
|
|
64
|
+
const head = await git(repoDir, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`]);
|
|
65
|
+
if (head.ok && head.stdout)
|
|
66
|
+
target = head.stdout;
|
|
67
|
+
for (const name of ['main', 'master']) {
|
|
68
|
+
if (target)
|
|
69
|
+
break;
|
|
70
|
+
const found = await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}`]);
|
|
71
|
+
if (found.ok)
|
|
72
|
+
target = `${remote}/${name}`;
|
|
73
|
+
}
|
|
74
|
+
if (!target) {
|
|
75
|
+
return empty(repoDir, `No main branch of ${remote} is known here yet. Run ${syncCommands(repoDir, remote, `${remote}/main`).preview.split('; ')[0]} once, then look again.`);
|
|
76
|
+
}
|
|
77
|
+
const commits = await git(repoDir, ['log', '--format=%h %s', `${target}..HEAD`]);
|
|
78
|
+
const status = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
|
|
79
|
+
const clean = await git(repoDir, ['clean', '-nd']);
|
|
80
|
+
let lastFetched;
|
|
81
|
+
try {
|
|
82
|
+
const gitDir = (await git(repoDir, ['rev-parse', '--absolute-git-dir'])).stdout;
|
|
83
|
+
lastFetched = statSync(join(gitDir, 'FETCH_HEAD')).mtime.toISOString();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* never fetched: the page says so */
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
repoDir,
|
|
91
|
+
branch: branch.stdout,
|
|
92
|
+
remote,
|
|
93
|
+
target,
|
|
94
|
+
...(lastFetched ? { lastFetched } : {}),
|
|
95
|
+
willLose: {
|
|
96
|
+
commits: commits.ok && commits.stdout ? commits.stdout.split('\n') : [],
|
|
97
|
+
changed: porcelainPaths(status.stdout),
|
|
98
|
+
untracked: clean.stdout
|
|
99
|
+
.split('\n')
|
|
100
|
+
.map((l) => l.replace(/^Would remove\s+/, '').trim())
|
|
101
|
+
.filter(Boolean),
|
|
102
|
+
},
|
|
103
|
+
...syncCommands(repoDir, remote, target),
|
|
104
|
+
};
|
|
105
|
+
}
|
package/dist/src/vcs/taskVcs.js
CHANGED
|
@@ -282,6 +282,15 @@ export function noteFor(repoDir, vcs, ctx = { mode: 'per-task', earlier: [] }) {
|
|
|
282
282
|
`The runner has already put ${repoDir} on the branch \`${vcs.branch}\`, created for this task from ${base},`,
|
|
283
283
|
'and it will commit whatever you change when the task finishes.',
|
|
284
284
|
'',
|
|
285
|
+
/*
|
|
286
|
+
* Said outright because a plan's text may still name the branch the work came from: a task that
|
|
287
|
+
* was told to continue a recovery branch went on asking for it, on a branch the runner had made
|
|
288
|
+
* (2026-09-30). The branch above is where the work goes; what it must contain is a commit.
|
|
289
|
+
*/
|
|
290
|
+
`\`${vcs.branch}\` is where this work goes, whatever branch the task or the project instructions name: do not ask for`,
|
|
291
|
+
'another branch, switch to it or check that you are on it. When earlier work must be included, check its',
|
|
292
|
+
'commit instead: `git merge-base --is-ancestor <commit> HEAD` exits 0 when it is.',
|
|
293
|
+
'',
|
|
285
294
|
...(began ? [began, ''] : []),
|
|
286
295
|
standing,
|
|
287
296
|
'',
|
|
@@ -320,19 +329,33 @@ export async function commitTaskResult(session, task, outcome, bus) {
|
|
|
320
329
|
* (they are already there; refusing would not remove them) and named on the task.
|
|
321
330
|
*/
|
|
322
331
|
const state = await repoState(dir);
|
|
332
|
+
/*
|
|
333
|
+
* The repository before the commit and after it, kept apart on the record. A task's own claims
|
|
334
|
+
* ("nothing committed, the tree is dirty") were written before this runs; without both states
|
|
335
|
+
* the export read as contradicting itself. See `TaskVcs.beforeCommit`.
|
|
336
|
+
*/
|
|
337
|
+
const beforeCommit = { changed: state.changed.slice(0, 200) };
|
|
338
|
+
const withStates = async (v) => {
|
|
339
|
+
const after = await repoState(dir).catch(() => null);
|
|
340
|
+
return {
|
|
341
|
+
...v,
|
|
342
|
+
beforeCommit,
|
|
343
|
+
...(after ? { afterCommit: { branch: after.branch ?? undefined, head: after.head ?? undefined, clean: !after.dirty, changed: after.changed.slice(0, 200) } } : {}),
|
|
344
|
+
};
|
|
345
|
+
};
|
|
323
346
|
if (state.branch !== current.branch) {
|
|
324
347
|
const problem = `the repository is on ${state.branch ?? 'a detached HEAD'}, not on the task's branch ${current.branch}: it was moved while the task ran. ` +
|
|
325
348
|
'Nothing was committed; the changes are left in the working tree for you to look at.';
|
|
326
349
|
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-provenance', level: 'error', message: problem,
|
|
327
350
|
data: { expected: current.branch, actual: state.branch } });
|
|
328
|
-
return { ...current, problem };
|
|
351
|
+
return await withStates({ ...current, problem });
|
|
329
352
|
}
|
|
330
353
|
if (current.baseCommit && state.head && !(await isAncestor(dir, current.baseCommit))) {
|
|
331
354
|
const problem = `the task's branch ${current.branch} no longer contains the commit it started from (${current.baseCommit.slice(0, 8)}): ` +
|
|
332
355
|
'it was reset or rewritten while the task ran. Nothing was committed; the changes are left in the working tree.';
|
|
333
356
|
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-provenance', level: 'error', message: problem,
|
|
334
357
|
data: { base: current.baseCommit, head: state.head } });
|
|
335
|
-
return { ...current, problem };
|
|
358
|
+
return await withStates({ ...current, problem });
|
|
336
359
|
}
|
|
337
360
|
const foreign = current.baseCommit ? await foreignCommits(dir, current.baseCommit) : [];
|
|
338
361
|
if (foreign.length > 0) {
|
|
@@ -345,12 +368,12 @@ export async function commitTaskResult(session, task, outcome, bus) {
|
|
|
345
368
|
if (result.problem) {
|
|
346
369
|
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-problem', level: 'warn',
|
|
347
370
|
message: `nothing was committed: ${result.problem}` });
|
|
348
|
-
return { ...current, problem: result.problem };
|
|
371
|
+
return await withStates({ ...current, problem: result.problem });
|
|
349
372
|
}
|
|
350
373
|
if (!result.committed) {
|
|
351
374
|
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-nothing', level: 'info',
|
|
352
375
|
message: `nothing to commit on ${current.branch}: the task changed no files` });
|
|
353
|
-
return current;
|
|
376
|
+
return await withStates(current);
|
|
354
377
|
}
|
|
355
378
|
const commits = current.baseCommit ? await commitsBetween(dir, current.baseCommit) : [];
|
|
356
379
|
const files = result.commit ? await commitFiles(dir, result.commit) : [];
|
|
@@ -367,14 +390,14 @@ export async function commitTaskResult(session, task, outcome, bus) {
|
|
|
367
390
|
suspicious.map((s) => `${s.path} (${s.reason})`).join('; '),
|
|
368
391
|
data: { commit: result.commit, suspicious } });
|
|
369
392
|
}
|
|
370
|
-
return {
|
|
393
|
+
return await withStates({
|
|
371
394
|
...current,
|
|
372
395
|
commit: result.commit,
|
|
373
396
|
commits,
|
|
374
397
|
files,
|
|
375
398
|
...(suspicious.length > 0 ? { suspicious } : {}),
|
|
376
399
|
...(foreign.length > 0 ? { foreignCommits: foreign } : {}),
|
|
377
|
-
};
|
|
400
|
+
});
|
|
378
401
|
}
|
|
379
402
|
/**
|
|
380
403
|
* Commits what a task left behind when the process running it ended before it could.
|