pi-roundtable-coding 0.27.1 → 0.28.0
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/CHANGELOG.md +12 -0
- package/README.md +38 -10
- package/package.json +3 -3
- package/src/coding-desk.ts +429 -84
- package/src/coding-plugin.ts +105 -28
- package/src/index.ts +14 -2
- package/src/pi-coding-worker.ts +40 -4
- package/src/repo-shelf.ts +212 -1
- package/src/worker-entry.ts +1 -0
package/src/coding-desk.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
ChannelKey,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import {
|
|
2
|
+
type ChannelKey,
|
|
3
|
+
type ConversationModes,
|
|
4
|
+
type HeldCall,
|
|
5
|
+
type InterimTextMode,
|
|
6
|
+
type Logger,
|
|
7
|
+
type OwnerPrompts,
|
|
8
|
+
SYSTEM_PRINCIPAL,
|
|
9
|
+
type ThinkingLevel,
|
|
10
|
+
type Tier,
|
|
8
11
|
} from "pi-roundtable";
|
|
9
12
|
import {
|
|
10
13
|
AgentError,
|
|
@@ -13,13 +16,32 @@ import {
|
|
|
13
16
|
type DispatchThread,
|
|
14
17
|
type DispatchThreads,
|
|
15
18
|
InterimPoster,
|
|
19
|
+
type PromptSlot,
|
|
16
20
|
promptSlot,
|
|
17
21
|
workTimeout,
|
|
22
|
+
YOLO_CARDED_SHELL_CLASSES,
|
|
18
23
|
} from "pi-roundtable/kit";
|
|
19
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
Integration,
|
|
26
|
+
RepoShelf,
|
|
27
|
+
RepoState,
|
|
28
|
+
WorkerTree,
|
|
29
|
+
} from "./repo-shelf.ts";
|
|
20
30
|
import { CodingWorkerFailure } from "./worker-failure.ts";
|
|
21
31
|
|
|
22
32
|
export type HeldCallAnswer = "approved" | "declined" | "held";
|
|
33
|
+
/** Who spoke the turn that started a job, as yolo and long-running modes need to know. */
|
|
34
|
+
export interface CodingSpeaker {
|
|
35
|
+
id: string;
|
|
36
|
+
principalId: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
tier?: Tier;
|
|
39
|
+
}
|
|
40
|
+
/** The conversation whose turn started a job, whose modes the worker follows live. */
|
|
41
|
+
export interface CodingStarter {
|
|
42
|
+
channel: ChannelKey;
|
|
43
|
+
speaker: CodingSpeaker;
|
|
44
|
+
}
|
|
23
45
|
export interface CodingJob {
|
|
24
46
|
id: number;
|
|
25
47
|
repo: string;
|
|
@@ -27,6 +49,8 @@ export interface CodingJob {
|
|
|
27
49
|
channel: ChannelKey;
|
|
28
50
|
/** The calling turn's channel, where a progress thread opens. */
|
|
29
51
|
origin?: ChannelKey;
|
|
52
|
+
/** The conversation that started it; its yolo and long-running modes apply while it works. */
|
|
53
|
+
starter?: CodingStarter;
|
|
30
54
|
/** Host-resolved names of the skills carried to this run. */
|
|
31
55
|
skillNames?: string[];
|
|
32
56
|
/** Progress thread, when the surface supports one. */
|
|
@@ -34,9 +58,19 @@ export interface CodingJob {
|
|
|
34
58
|
model: string;
|
|
35
59
|
thinking: ThinkingLevel;
|
|
36
60
|
skillFiles: string[];
|
|
61
|
+
/** When it was asked for; a queued job starts later. */
|
|
62
|
+
queuedAt?: Date;
|
|
63
|
+
/** When its worker started. */
|
|
37
64
|
startedAt: Date;
|
|
38
65
|
startHead: string;
|
|
66
|
+
/** The worker's own branch, in its own worktree, once it has started. */
|
|
67
|
+
branch?: string;
|
|
39
68
|
}
|
|
69
|
+
/** A started or queued job, as `CodingDesk.start` answers. */
|
|
70
|
+
export type CodingStart = CodingJob & {
|
|
71
|
+
/** Its place in the queue, from 1, when every worker slot is taken; absent when it started. */
|
|
72
|
+
queuePosition?: number;
|
|
73
|
+
};
|
|
40
74
|
/** What the worker writes and runs as it goes, for its thread's progress posts. */
|
|
41
75
|
export interface CodingProgress {
|
|
42
76
|
/** A finished message of the worker's session: its role, stop reason, and text. */
|
|
@@ -61,8 +95,24 @@ export interface CodingResult {
|
|
|
61
95
|
job: CodingJob;
|
|
62
96
|
outcome: { ok: true; report: string } | { ok: false; error: string };
|
|
63
97
|
held: string[];
|
|
98
|
+
/** Held calls the starter's yolo mode approved without a card. */
|
|
99
|
+
autoApproved?: string[];
|
|
64
100
|
state?: RepoState;
|
|
65
101
|
commits: string[];
|
|
102
|
+
/** How the worker's branch came back into the clone; absent when no worktree was made. */
|
|
103
|
+
integration?: Integration;
|
|
104
|
+
}
|
|
105
|
+
/** The conversations' modes, read live for each held call and at each work-time limit. */
|
|
106
|
+
export type CodingModes = Pick<ConversationModes, "peek"> &
|
|
107
|
+
Partial<Pick<ConversationModes, "autoApproved">>;
|
|
108
|
+
/**
|
|
109
|
+
* The host's autonomy limits, as its `autonomy` config resolves them: what yolo may approve, and
|
|
110
|
+
* how long a long-running run works in all.
|
|
111
|
+
*/
|
|
112
|
+
export interface CodingAutonomy {
|
|
113
|
+
yolo?: { allow?: readonly string[]; card?: readonly string[] };
|
|
114
|
+
/** Default four hours; at most 2147483647. */
|
|
115
|
+
longRunningMaxMs?: number;
|
|
66
116
|
}
|
|
67
117
|
export interface CodingThreadText {
|
|
68
118
|
initial?(job: CodingJob): string;
|
|
@@ -71,7 +121,15 @@ export interface CodingThreadText {
|
|
|
71
121
|
report?(result: CodingResult): string;
|
|
72
122
|
}
|
|
73
123
|
export interface CodingDeskOptions {
|
|
74
|
-
shelf: Pick<
|
|
124
|
+
shelf: Pick<
|
|
125
|
+
RepoShelf,
|
|
126
|
+
| "dirOf"
|
|
127
|
+
| "state"
|
|
128
|
+
| "addWorktree"
|
|
129
|
+
| "integrate"
|
|
130
|
+
| "treeState"
|
|
131
|
+
| "treeCommits"
|
|
132
|
+
>;
|
|
75
133
|
worker: CodingWorker;
|
|
76
134
|
prompts?(channel: ChannelKey): OwnerPrompts | undefined;
|
|
77
135
|
/** When configured, approval cards stay in the job's thread; no thread means held. */
|
|
@@ -88,6 +146,17 @@ export interface CodingDeskOptions {
|
|
|
88
146
|
interimText?: InterimTextMode;
|
|
89
147
|
/** Length from which an intermediate text is posted as its own message; default 400. */
|
|
90
148
|
interimPrimaryChars?: number;
|
|
149
|
+
/** Workers running at once on the host, across repositories and channels; default 4. */
|
|
150
|
+
maxWorkers?: number;
|
|
151
|
+
/** Tasks waiting for a worker slot, first in first out; one more is refused. Default 16. */
|
|
152
|
+
maxQueued?: number;
|
|
153
|
+
/**
|
|
154
|
+
* The conversations' modes: with them a worker follows the yolo and long-running modes of the
|
|
155
|
+
* conversation that started it, read live, when an owner other than the host spoke that turn.
|
|
156
|
+
*/
|
|
157
|
+
modes?: CodingModes;
|
|
158
|
+
/** The host's autonomy limits for those modes; pass the same values its `autonomy` config sets. */
|
|
159
|
+
autonomy?: CodingAutonomy;
|
|
91
160
|
}
|
|
92
161
|
/** What a coding report keeps of a long run; each is a count of characters or entries, or `Infinity` for no bound. */
|
|
93
162
|
export interface CodingLimits {
|
|
@@ -119,51 +188,149 @@ function bounded(text: string, max: number): string {
|
|
|
119
188
|
: text;
|
|
120
189
|
}
|
|
121
190
|
|
|
122
|
-
|
|
191
|
+
const DEFAULT_TIMEOUT_MS = 3_600_000;
|
|
192
|
+
/** The core's default long-running ceiling, four hours. */
|
|
193
|
+
const DEFAULT_LONG_RUNNING_MS = 4 * 60 * 60_000;
|
|
194
|
+
/** How often a run past its own limit looks again at the starter's long-running mode. */
|
|
195
|
+
const LONG_RUNNING_STEP_MS = 60_000;
|
|
196
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
197
|
+
const DEFAULT_MAX_WORKERS = 4;
|
|
198
|
+
const DEFAULT_MAX_QUEUED = 16;
|
|
199
|
+
|
|
200
|
+
function checkTime(name: string, value: number): void {
|
|
201
|
+
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_MS)
|
|
202
|
+
throw new AgentError(
|
|
203
|
+
`${name} must be positive, finite and at most ${MAX_TIMER_MS}.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
function checkCount(name: string, value: number, least: number): void {
|
|
207
|
+
if (!Number.isSafeInteger(value) || value < least)
|
|
208
|
+
throw new AgentError(
|
|
209
|
+
`${name} must be a whole number of at least ${least}; got ${value}.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The rule a class belongs to: the part before its first dot, or a rule's bare name. */
|
|
214
|
+
function ruleOf(id: string): string {
|
|
215
|
+
const dot = id.indexOf(".");
|
|
216
|
+
return dot === -1 ? id : id.slice(0, dot);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface Running {
|
|
220
|
+
job: CodingJob;
|
|
221
|
+
controller: AbortController;
|
|
222
|
+
done?: Promise<void>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Up to `maxWorkers` workers at once, each in its own worktree; more tasks wait in a FIFO queue.
|
|
227
|
+
* A finished worker's branch is rebased onto its clone and fast-forwarded, one clone at a time.
|
|
228
|
+
* Results and jobs are in memory.
|
|
229
|
+
*/
|
|
123
230
|
export class CodingDesk {
|
|
124
231
|
readonly #options: CodingDeskOptions;
|
|
232
|
+
readonly #maxWorkers: number;
|
|
233
|
+
readonly #maxQueued: number;
|
|
234
|
+
readonly #longRunningMs: number;
|
|
235
|
+
readonly #yoloApproves: (id: string) => boolean;
|
|
125
236
|
/** Every run until its report is delivered, for idle(). */
|
|
126
237
|
readonly #pending = new Set<Promise<void>>();
|
|
127
|
-
readonly #running = new Map<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
238
|
+
readonly #running = new Map<number, Running>();
|
|
239
|
+
readonly #queue: CodingJob[] = [];
|
|
240
|
+
/** The last integration asked for in each repository, which the next waits for. */
|
|
241
|
+
readonly #integrations = new Map<string, Promise<unknown>>();
|
|
242
|
+
/** Repositories whose clone an integration is changing now. */
|
|
243
|
+
readonly #integrating = new Set<string>();
|
|
244
|
+
/** Reports and pushes in progress, by repository. */
|
|
245
|
+
readonly #shipping = new Map<string, Promise<unknown>>();
|
|
131
246
|
#nextId = 1;
|
|
132
247
|
#stopped = false;
|
|
133
248
|
constructor(options: CodingDeskOptions) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
249
|
+
checkTime("timeoutMs", options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
250
|
+
const longRunning =
|
|
251
|
+
options.autonomy?.longRunningMaxMs ?? DEFAULT_LONG_RUNNING_MS;
|
|
252
|
+
checkTime("autonomy.longRunningMaxMs", longRunning);
|
|
253
|
+
this.#maxWorkers = options.maxWorkers ?? DEFAULT_MAX_WORKERS;
|
|
254
|
+
this.#maxQueued = options.maxQueued ?? DEFAULT_MAX_QUEUED;
|
|
255
|
+
checkCount("maxWorkers", this.#maxWorkers, 1);
|
|
256
|
+
checkCount("maxQueued", this.#maxQueued, 0);
|
|
257
|
+
this.#longRunningMs = longRunning;
|
|
258
|
+
const allow = new Set(options.autonomy?.yolo?.allow ?? []);
|
|
259
|
+
const card = new Set(options.autonomy?.yolo?.card ?? []);
|
|
260
|
+
const carded = new Set<string>(YOLO_CARDED_SHELL_CLASSES);
|
|
261
|
+
// The core's yolo rules: a carded class or rule never, an unclassed call only by its rule's name.
|
|
262
|
+
this.#yoloApproves = (id) => {
|
|
263
|
+
if (card.has(id) || card.has(ruleOf(id))) return false;
|
|
264
|
+
if (!id.includes(".")) return allow.has(id);
|
|
265
|
+
return !carded.has(id) || allow.has(id);
|
|
266
|
+
};
|
|
142
267
|
this.#options = options;
|
|
143
268
|
}
|
|
269
|
+
/** The channels of running workers, which a shutdown waits for; queued tasks are not among them. */
|
|
144
270
|
runningChannels(): ChannelKey[] {
|
|
145
271
|
return [...this.#running.values()].map(({ job }) => job.channel);
|
|
146
272
|
}
|
|
273
|
+
/** The channels of queued tasks, in queue order. */
|
|
274
|
+
queuedChannels(): ChannelKey[] {
|
|
275
|
+
return this.#queue.map((job) => job.channel);
|
|
276
|
+
}
|
|
147
277
|
busy(): string[] {
|
|
148
|
-
return [
|
|
149
|
-
(
|
|
150
|
-
|
|
278
|
+
return [
|
|
279
|
+
...[...this.#running.values()].map(
|
|
280
|
+
({ job }) => `Coding #${job.id} in ${job.repo}`,
|
|
281
|
+
),
|
|
282
|
+
...this.#queue.map(
|
|
283
|
+
(job, index) =>
|
|
284
|
+
`Coding #${job.id} in ${job.repo}, queued (position ${index + 1})`,
|
|
285
|
+
),
|
|
286
|
+
];
|
|
151
287
|
}
|
|
288
|
+
/** Refuses while a worker's branch is being merged into the clone; running workers do not count. */
|
|
152
289
|
checkIdle(repo: string): void {
|
|
153
|
-
if (
|
|
154
|
-
throw new AgentError(
|
|
290
|
+
if (this.#integrating.has(repo))
|
|
291
|
+
throw new AgentError(
|
|
292
|
+
`A coding worker's commits are being merged into ${repo}; try again in a moment.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
/** Whether a report or push is in progress for the repository. */
|
|
296
|
+
shipping(repo: string): boolean {
|
|
297
|
+
return this.#shipping.has(repo);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Runs a report or push of the repository, one at a time; refuses during an integration, and
|
|
301
|
+
* an integration that comes due waits for it.
|
|
302
|
+
*/
|
|
303
|
+
async ship<T>(repo: string, operation: () => Promise<T>): Promise<T> {
|
|
304
|
+
this.checkIdle(repo);
|
|
305
|
+
if (this.#shipping.has(repo))
|
|
306
|
+
throw new AgentError(
|
|
307
|
+
"A report or push is already in progress for this repository.",
|
|
308
|
+
);
|
|
309
|
+
const done = operation();
|
|
310
|
+
this.#shipping.set(
|
|
311
|
+
repo,
|
|
312
|
+
done.catch(() => {}),
|
|
313
|
+
);
|
|
314
|
+
try {
|
|
315
|
+
return await done;
|
|
316
|
+
} finally {
|
|
317
|
+
this.#shipping.delete(repo);
|
|
318
|
+
}
|
|
155
319
|
}
|
|
156
320
|
async idle(): Promise<void> {
|
|
157
|
-
|
|
321
|
+
// A run that ends starts the next queued one before it settles, so look again.
|
|
322
|
+
while (this.#pending.size) await Promise.all(this.#pending);
|
|
158
323
|
}
|
|
324
|
+
/** Stops the running workers and drops the queued tasks, telling each that it never ran. */
|
|
159
325
|
async stop(): Promise<void> {
|
|
160
326
|
this.#stopped = true;
|
|
327
|
+
for (const job of this.#queue.splice(0)) this.#track(this.#drop(job));
|
|
161
328
|
for (const run of this.#running.values()) run.controller.abort();
|
|
162
329
|
await this.idle();
|
|
163
330
|
}
|
|
164
331
|
async start(
|
|
165
332
|
request: Omit<CodingJob, "id" | "startedAt" | "startHead" | "thread">,
|
|
166
|
-
): Promise<
|
|
333
|
+
): Promise<CodingStart> {
|
|
167
334
|
if (this.#stopped) throw new AgentError("The coding desk is stopped.");
|
|
168
335
|
const task = request.task.trim();
|
|
169
336
|
if (!task) throw new AgentError("The task is required.");
|
|
@@ -171,75 +338,186 @@ export class CodingDesk {
|
|
|
171
338
|
throw new AgentError(
|
|
172
339
|
`The task is ${task.length} characters; keep it within ${MAX_CODING_TASK_CHARS}.`,
|
|
173
340
|
);
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
);
|
|
178
|
-
if (busy)
|
|
179
|
-
throw new AgentError(
|
|
180
|
-
`Coding task #${busy.job.id} is still working in ${request.repo}; one worker runs per repository.`,
|
|
181
|
-
);
|
|
182
|
-
const inChannel = [...this.#running.values()].filter(
|
|
183
|
-
({ job }) => job.channel === request.channel,
|
|
184
|
-
).length;
|
|
185
|
-
if (inChannel >= 3)
|
|
341
|
+
this.#options.shelf.dirOf(request.repo);
|
|
342
|
+
const full = this.#running.size >= this.#maxWorkers;
|
|
343
|
+
if (full && this.#queue.length >= this.#maxQueued)
|
|
186
344
|
throw new AgentError(
|
|
187
|
-
`
|
|
345
|
+
`All ${this.#maxWorkers} coding workers are busy and ${this.#queue.length} tasks are already waiting; try again after one reports back.`,
|
|
188
346
|
);
|
|
347
|
+
const now = new Date();
|
|
189
348
|
const job: CodingJob = {
|
|
190
349
|
...request,
|
|
191
350
|
task,
|
|
192
351
|
id: this.#nextId++,
|
|
193
|
-
|
|
352
|
+
queuedAt: now,
|
|
353
|
+
startedAt: now,
|
|
194
354
|
startHead: "",
|
|
195
355
|
};
|
|
196
|
-
const run = {
|
|
197
|
-
job,
|
|
198
|
-
controller: new AbortController(),
|
|
199
|
-
done: undefined as Promise<void> | undefined,
|
|
200
|
-
};
|
|
201
|
-
this.#running.set(job.id, run);
|
|
202
356
|
// Reserve before the first await, including startup state reads.
|
|
203
|
-
|
|
357
|
+
if (!full) {
|
|
358
|
+
this.#launch(job);
|
|
359
|
+
return job;
|
|
360
|
+
}
|
|
361
|
+
this.#queue.push(job);
|
|
362
|
+
return { ...job, queuePosition: this.#queue.length };
|
|
363
|
+
}
|
|
364
|
+
#track(done: Promise<void>): void {
|
|
365
|
+
this.#pending.add(done);
|
|
366
|
+
void done.finally(() => this.#pending.delete(done));
|
|
367
|
+
}
|
|
368
|
+
#launch(job: CodingJob): void {
|
|
369
|
+
job.startedAt = new Date();
|
|
370
|
+
const run: Running = { job, controller: new AbortController() };
|
|
371
|
+
this.#running.set(job.id, run);
|
|
372
|
+
run.done = this.#run(job, run.controller)
|
|
204
373
|
.catch(() => {
|
|
205
374
|
this.#options.logger.error(
|
|
206
375
|
{ job: job.id },
|
|
207
376
|
"Coding report delivery failed.",
|
|
208
377
|
);
|
|
209
378
|
})
|
|
210
|
-
.finally(() => this.#
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
379
|
+
.finally(() => this.#free(job));
|
|
380
|
+
this.#track(run.done);
|
|
381
|
+
}
|
|
382
|
+
/** Frees the job's slot and starts the next queued tasks. */
|
|
383
|
+
#free(job: CodingJob): void {
|
|
384
|
+
this.#running.delete(job.id);
|
|
385
|
+
while (
|
|
386
|
+
!this.#stopped &&
|
|
387
|
+
this.#running.size < this.#maxWorkers &&
|
|
388
|
+
this.#queue.length
|
|
389
|
+
) {
|
|
390
|
+
const next = this.#queue.shift();
|
|
391
|
+
if (next) this.#launch(next);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async #drop(job: CodingJob): Promise<void> {
|
|
395
|
+
try {
|
|
396
|
+
await this.#options.deliver({
|
|
397
|
+
job,
|
|
398
|
+
outcome: {
|
|
399
|
+
ok: false,
|
|
400
|
+
error:
|
|
401
|
+
"the host stopped before this queued task started; nothing ran, so start it again",
|
|
402
|
+
},
|
|
403
|
+
held: [],
|
|
404
|
+
commits: [],
|
|
405
|
+
});
|
|
406
|
+
} catch {
|
|
407
|
+
this.#options.logger.error(
|
|
408
|
+
{ job: job.id },
|
|
409
|
+
"Coding report delivery failed.",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** Whether the starter's modes may act: only for an owner other than the host's own principal. */
|
|
414
|
+
#modeOf(job: CodingJob): { yolo: boolean; longRunning: boolean } {
|
|
415
|
+
const { modes } = this.#options;
|
|
416
|
+
const speaker = job.starter?.speaker;
|
|
417
|
+
if (
|
|
418
|
+
!modes ||
|
|
419
|
+
!job.starter ||
|
|
420
|
+
speaker?.tier !== "owner" ||
|
|
421
|
+
speaker.principalId === SYSTEM_PRINCIPAL
|
|
422
|
+
)
|
|
423
|
+
return { yolo: false, longRunning: false };
|
|
424
|
+
const mode = modes.peek(job.starter.channel);
|
|
425
|
+
return { yolo: mode.yolo === true, longRunning: mode.longRunning === true };
|
|
426
|
+
}
|
|
427
|
+
/** Whether the starter's yolo mode approves the call now, as it would in that conversation. */
|
|
428
|
+
#yolo(job: CodingJob, call: HeldCall): boolean {
|
|
429
|
+
if (!this.#modeOf(job).yolo || call.approverIds !== undefined) return false;
|
|
430
|
+
const classes = call.holdClasses;
|
|
431
|
+
if (!classes?.length || !classes.every(this.#yoloApproves)) return false;
|
|
432
|
+
const { starter } = job;
|
|
433
|
+
if (!starter) return false;
|
|
434
|
+
try {
|
|
435
|
+
this.#options.modes?.autoApproved?.({
|
|
436
|
+
channel: starter.channel,
|
|
437
|
+
tool: call.tool,
|
|
438
|
+
action: `coding #${job.id} in ${job.repo}: ${call.action}`,
|
|
439
|
+
classes: [...classes],
|
|
440
|
+
principalId: starter.speaker.principalId,
|
|
441
|
+
at: new Date(),
|
|
442
|
+
});
|
|
443
|
+
} catch {
|
|
444
|
+
this.#options.logger.warn(
|
|
445
|
+
{ job: job.id },
|
|
446
|
+
"Recording a yolo approval failed.",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
215
450
|
}
|
|
216
|
-
|
|
451
|
+
/**
|
|
452
|
+
* The worker's work-time budget: `timeoutMs`, then, while the starter's long-running mode is
|
|
453
|
+
* on, further steps up to `autonomy.longRunningMaxMs` in all. Card waits never count.
|
|
454
|
+
*/
|
|
455
|
+
#budget(
|
|
217
456
|
job: CodingJob,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
):
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
457
|
+
slot: PromptSlot,
|
|
458
|
+
expire: (workedMs: number) => void,
|
|
459
|
+
): () => void {
|
|
460
|
+
const base = this.#options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
461
|
+
let worked = 0;
|
|
462
|
+
let cancel = () => {};
|
|
463
|
+
const phase = (ms: number) => {
|
|
464
|
+
cancel = workTimeout(ms, slot, () => {
|
|
465
|
+
worked += ms;
|
|
466
|
+
if (this.#modeOf(job).longRunning && worked < this.#longRunningMs)
|
|
467
|
+
phase(Math.min(LONG_RUNNING_STEP_MS, this.#longRunningMs - worked));
|
|
468
|
+
else expire(worked);
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
phase(base);
|
|
472
|
+
return () => cancel();
|
|
473
|
+
}
|
|
474
|
+
/** Integrations of one clone run one after another, and never during its report or push. */
|
|
475
|
+
async #integrate(tree: WorkerTree): Promise<Integration> {
|
|
476
|
+
const { repo } = tree;
|
|
477
|
+
const previous = this.#integrations.get(repo) ?? Promise.resolve();
|
|
478
|
+
const mine = previous
|
|
479
|
+
.catch(() => {})
|
|
480
|
+
.then(async () => {
|
|
481
|
+
for (let ship = this.#shipping.get(repo); ship; ) {
|
|
482
|
+
await ship;
|
|
483
|
+
ship = this.#shipping.get(repo);
|
|
484
|
+
}
|
|
485
|
+
this.#integrating.add(repo);
|
|
486
|
+
try {
|
|
487
|
+
return await this.#options.shelf.integrate(tree);
|
|
488
|
+
} finally {
|
|
489
|
+
this.#integrating.delete(repo);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
this.#integrations.set(repo, mine);
|
|
493
|
+
try {
|
|
494
|
+
return await mine;
|
|
495
|
+
} finally {
|
|
496
|
+
if (this.#integrations.get(repo) === mine)
|
|
497
|
+
this.#integrations.delete(repo);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async #run(job: CodingJob, controller: AbortController): Promise<void> {
|
|
501
|
+
const { shelf, worker, prompts, threads, threadText, deliver } =
|
|
502
|
+
this.#options;
|
|
230
503
|
const slot = promptSlot();
|
|
231
504
|
const held: string[] = [];
|
|
505
|
+
const autoApproved: string[] = [];
|
|
232
506
|
let omittedHeld = 0;
|
|
507
|
+
let omittedApproved = 0;
|
|
233
508
|
const maxReport = this.#options.limits?.reportChars ?? MAX_REPORT_CHARS;
|
|
234
509
|
const maxHeldEntries =
|
|
235
510
|
this.#options.limits?.heldEntries ?? MAX_HELD_ENTRIES;
|
|
236
511
|
const maxHeldChars = this.#options.limits?.heldChars ?? MAX_HELD_CHARS;
|
|
237
512
|
let cancel = () => {};
|
|
238
513
|
let interim: InterimPoster | undefined;
|
|
239
|
-
let timedOut
|
|
514
|
+
let timedOut: number | undefined;
|
|
515
|
+
let tree: WorkerTree | undefined;
|
|
240
516
|
let outcome: CodingResult["outcome"];
|
|
241
517
|
try {
|
|
242
|
-
|
|
518
|
+
tree = await shelf.addWorktree(job.repo, job.id);
|
|
519
|
+
job.startHead = tree.base.slice(0, 12);
|
|
520
|
+
job.branch = tree.branch;
|
|
243
521
|
if (controller.signal.aborted)
|
|
244
522
|
throw new AgentError("the worker was stopped");
|
|
245
523
|
if (threads) {
|
|
@@ -276,11 +554,23 @@ export class CodingDesk {
|
|
|
276
554
|
`Coding worker #${job.id}`,
|
|
277
555
|
poster ? () => poster.flush() : undefined,
|
|
278
556
|
);
|
|
279
|
-
cancel =
|
|
280
|
-
timedOut =
|
|
557
|
+
cancel = this.#budget(job, slot, (worked) => {
|
|
558
|
+
timedOut = worked;
|
|
281
559
|
controller.abort();
|
|
282
560
|
});
|
|
283
561
|
const review = async (call: HeldCall): Promise<HeldCallAnswer> => {
|
|
562
|
+
// The starter's yolo mode answers for a call it may approve, read at the time of the call.
|
|
563
|
+
if (!controller.signal.aborted && this.#yolo(job, call)) {
|
|
564
|
+
if (autoApproved.length < maxHeldEntries)
|
|
565
|
+
autoApproved.push(
|
|
566
|
+
bounded(
|
|
567
|
+
`${call.action}: ${call.tool} ${call.input}`,
|
|
568
|
+
maxHeldChars,
|
|
569
|
+
),
|
|
570
|
+
);
|
|
571
|
+
else omittedApproved++;
|
|
572
|
+
return "approved";
|
|
573
|
+
}
|
|
284
574
|
let answer: HeldCallAnswer = "held";
|
|
285
575
|
try {
|
|
286
576
|
const decision = await slot.prompts?.confirm(
|
|
@@ -318,7 +608,7 @@ export class CodingDesk {
|
|
|
318
608
|
return answer;
|
|
319
609
|
};
|
|
320
610
|
const report = await worker.run(
|
|
321
|
-
{ ...job, dir },
|
|
611
|
+
{ ...job, dir: tree.dir },
|
|
322
612
|
controller.signal,
|
|
323
613
|
review,
|
|
324
614
|
interim,
|
|
@@ -338,8 +628,8 @@ export class CodingDesk {
|
|
|
338
628
|
);
|
|
339
629
|
}
|
|
340
630
|
if (controller.signal.aborted) message = "the worker was stopped";
|
|
341
|
-
if (timedOut)
|
|
342
|
-
message = `the worker ran out of time (${Math.round(
|
|
631
|
+
if (timedOut !== undefined)
|
|
632
|
+
message = `the worker ran out of time (${Math.round(timedOut / 60_000)} minutes)`;
|
|
343
633
|
outcome = { ok: false, error: message };
|
|
344
634
|
} finally {
|
|
345
635
|
cancel();
|
|
@@ -347,12 +637,36 @@ export class CodingDesk {
|
|
|
347
637
|
// What the worker wrote lands before its report.
|
|
348
638
|
await interim?.flush();
|
|
349
639
|
}
|
|
640
|
+
let integration: Integration | undefined;
|
|
641
|
+
if (tree)
|
|
642
|
+
try {
|
|
643
|
+
integration = await this.#integrate(tree);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
integration = {
|
|
646
|
+
status: "refused",
|
|
647
|
+
branch: tree.branch,
|
|
648
|
+
dir: tree.dir,
|
|
649
|
+
reason:
|
|
650
|
+
error instanceof AgentError
|
|
651
|
+
? error.message
|
|
652
|
+
: "the merge failed; inspect the clone and the worktree on the host",
|
|
653
|
+
};
|
|
654
|
+
}
|
|
350
655
|
let state: RepoState | undefined;
|
|
351
656
|
let commits: string[] = [];
|
|
352
657
|
try {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
658
|
+
if (
|
|
659
|
+
tree &&
|
|
660
|
+
(integration?.status === "conflict" ||
|
|
661
|
+
integration?.status === "refused")
|
|
662
|
+
) {
|
|
663
|
+
// The work stays on its branch: describe the worktree.
|
|
664
|
+
state = await shelf.treeState(tree);
|
|
665
|
+
commits = await shelf.treeCommits(tree);
|
|
666
|
+
} else {
|
|
667
|
+
state = await shelf.state(job.repo);
|
|
668
|
+
if (integration?.status === "merged") commits = integration.commits;
|
|
669
|
+
}
|
|
356
670
|
} catch {
|
|
357
671
|
this.#options.logger.warn(
|
|
358
672
|
{ job: job.id },
|
|
@@ -361,12 +675,16 @@ export class CodingDesk {
|
|
|
361
675
|
}
|
|
362
676
|
if (omittedHeld)
|
|
363
677
|
held.push(`[${omittedHeld} more unapproved actions omitted]`);
|
|
678
|
+
if (omittedApproved)
|
|
679
|
+
autoApproved.push(`[${omittedApproved} more approved actions omitted]`);
|
|
364
680
|
const result: CodingResult = {
|
|
365
681
|
job,
|
|
366
682
|
outcome,
|
|
367
683
|
held,
|
|
684
|
+
...(autoApproved.length ? { autoApproved } : {}),
|
|
368
685
|
...(state ? { state } : {}),
|
|
369
686
|
commits,
|
|
687
|
+
...(integration ? { integration } : {}),
|
|
370
688
|
};
|
|
371
689
|
try {
|
|
372
690
|
await job.thread?.close(
|
|
@@ -379,24 +697,51 @@ export class CodingDesk {
|
|
|
379
697
|
} catch {
|
|
380
698
|
this.#options.logger.warn({ job: job.id }, "Coding thread close failed.");
|
|
381
699
|
}
|
|
382
|
-
// The work is over: free the repository before the report turn, so that turn can ship it.
|
|
383
|
-
this.#
|
|
700
|
+
// The work is over: free the slot and the repository before the report turn, so that turn can ship it.
|
|
701
|
+
this.#free(job);
|
|
384
702
|
await deliver(result);
|
|
385
703
|
}
|
|
386
704
|
}
|
|
387
705
|
|
|
706
|
+
/** What became of the worker's branch, for the report. */
|
|
707
|
+
export function integrationText(
|
|
708
|
+
integration: Integration | undefined,
|
|
709
|
+
branch: string | undefined,
|
|
710
|
+
): string | undefined {
|
|
711
|
+
switch (integration?.status) {
|
|
712
|
+
case "merged":
|
|
713
|
+
return `Merged ${integration.branch}: rebased onto the clone's branch and fast-forwarded it.`;
|
|
714
|
+
case "nothing":
|
|
715
|
+
return `${branch ?? "The worker's branch"} added no commits; its worktree is removed.`;
|
|
716
|
+
case "conflict":
|
|
717
|
+
return `Rebasing ${integration.branch} onto the clone's branch conflicted in:\n${integration.files.join("\n") || "(files not listed)"}\nThe rebase was aborted and the clone is unchanged; the branch ${integration.branch} and its worktree ${integration.dir} are kept to resolve.`;
|
|
718
|
+
case "refused":
|
|
719
|
+
return `Not merged: ${integration.reason}\nThe branch ${integration.branch} and its worktree ${integration.dir} are kept.`;
|
|
720
|
+
default:
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
388
725
|
export function codingReport(result: CodingResult, started: string): string {
|
|
389
|
-
const { job, outcome, state, commits, held } =
|
|
726
|
+
const { job, outcome, state, commits, held, autoApproved, integration } =
|
|
727
|
+
result;
|
|
728
|
+
const merge = integrationText(integration, job.branch);
|
|
390
729
|
return [
|
|
391
730
|
`## Coding task #${job.id}: ${job.repo}`,
|
|
392
731
|
`Started: ${started}`,
|
|
393
732
|
outcome.ok ? outcome.report : outcome.error,
|
|
394
733
|
"### Held",
|
|
395
734
|
held.length ? held.join("\n") : "None.",
|
|
735
|
+
...(autoApproved?.length
|
|
736
|
+
? ["### Approved by yolo mode", autoApproved.join("\n")]
|
|
737
|
+
: []),
|
|
396
738
|
"### Repository",
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
739
|
+
[
|
|
740
|
+
...(merge ? [merge] : []),
|
|
741
|
+
state
|
|
742
|
+
? `Branch ${state.branch}, HEAD ${state.head} (was ${job.startHead}).\nNew commits:\n${commits.join("\n") || "None."}\nUncommitted:\n${state.uncommitted.join("\n") || "None."}`
|
|
743
|
+
: "Unreadable.",
|
|
744
|
+
].join("\n"),
|
|
400
745
|
"Review the changes and checks, then use repo_change_report before repo_push.",
|
|
401
746
|
].join("\n\n");
|
|
402
747
|
}
|