pi-roundtable-coding 0.27.1 → 0.29.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.
@@ -1,10 +1,13 @@
1
- import type {
2
- ChannelKey,
3
- HeldCall,
4
- InterimTextMode,
5
- Logger,
6
- OwnerPrompts,
7
- ThinkingLevel,
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 { RepoShelf, RepoState } from "./repo-shelf.ts";
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<RepoShelf, "dirOf" | "state" | "commitsSince">;
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
- /** One worker per repository, at most three per channel; results and jobs are in memory. */
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
- number,
129
- { job: CodingJob; controller: AbortController; done?: Promise<void> }
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
- if (
135
- !Number.isFinite(options.timeoutMs ?? 3_600_000) ||
136
- (options.timeoutMs ?? 3_600_000) <= 0 ||
137
- (options.timeoutMs ?? 3_600_000) > 2_147_483_647
138
- )
139
- throw new AgentError(
140
- "timeoutMs must be positive, finite and at most 2147483647.",
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 [...this.#running.values()].map(
149
- ({ job }) => `Coding #${job.id} in ${job.repo}`,
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 ([...this.#running.values()].some(({ job }) => job.repo === repo))
154
- throw new AgentError(`A coding worker is still using ${repo}.`);
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
- await Promise.all(this.#pending);
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<CodingJob> {
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
- const dir = this.#options.shelf.dirOf(request.repo);
175
- const busy = [...this.#running.values()].find(
176
- ({ job }) => job.repo === request.repo,
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
- `This channel already has ${inChannel} coding workers running; wait for one to report back.`,
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
- startedAt: new Date(),
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
- run.done = this.#run(job, dir, run.controller)
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.#running.delete(job.id));
211
- const done = run.done;
212
- this.#pending.add(done);
213
- void done.finally(() => this.#pending.delete(done));
214
- return job;
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
- async #run(
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
- dir: string,
219
- controller: AbortController,
220
- ): Promise<void> {
221
- const {
222
- shelf,
223
- worker,
224
- prompts,
225
- threads,
226
- threadText,
227
- deliver,
228
- timeoutMs = 3_600_000,
229
- } = this.#options;
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 = false;
514
+ let timedOut: number | undefined;
515
+ let tree: WorkerTree | undefined;
240
516
  let outcome: CodingResult["outcome"];
241
517
  try {
242
- job.startHead = (await shelf.state(job.repo)).head;
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 = workTimeout(timeoutMs, slot, () => {
280
- timedOut = true;
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(timeoutMs / 60_000)} minutes)`;
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
- state = await shelf.state(job.repo);
354
- if (job.startHead)
355
- commits = await shelf.commitsSince(job.repo, job.startHead);
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.#running.delete(job.id);
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 } = result;
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
- state
398
- ? `Branch ${state.branch}, HEAD ${state.head} (was ${job.startHead}).\nNew commits:\n${commits.join("\n") || "None."}\nUncommitted:\n${state.uncommitted.join("\n") || "None."}`
399
- : "Unreadable.",
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
  }