@bluewombat/conductor 0.9.0 → 0.11.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,7 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readdir, rm, stat } from "node:fs/promises";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
+ import { openDelivery, reasonOfTraces, } from "./delivery.js";
4
5
  import { ticketWorkspacePath } from "./paths.js";
6
+ import { foldMessageOf, subjectOf } from "./subject.js";
7
+ export { foldMessageOf, subjectOf } from "./subject.js";
5
8
  const DEFAULT_MAX_REFUSALS = 3;
6
9
  const DEFAULT_TRANSFORMER_DURATION_MS = 30_000;
7
10
  async function isDirectory(path) {
@@ -15,6 +18,8 @@ async function isDirectory(path) {
15
18
  async function destroy(path) {
16
19
  await rm(path, { recursive: true, force: true });
17
20
  }
21
+ /** What the next pass is told when it picks up a workspace the earlier one did not finish. */
22
+ const RESUMED_NOTE = "The pass before this one was stopped before it finished. What it made is in this directory: look at it before you write, and do not start over.";
18
23
  /**
19
24
  * Where a Ticket's workspace is: the path the ledger declared when it was
20
25
  * made, else the one the layout gives. The declared path wins because a
@@ -23,6 +28,18 @@ async function destroy(path) {
23
28
  function declaredTicketPath(workspaceRoot, key, aggregate) {
24
29
  return aggregate?.workspace ?? ticketWorkspacePath(workspaceRoot, key);
25
30
  }
31
+ /** Whether a workspace the ledger declares is still there with something in it. */
32
+ async function stillThere(path) {
33
+ if (path === undefined) {
34
+ return false;
35
+ }
36
+ try {
37
+ return (await readdir(path)).length > 0;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
26
43
  async function destroyOrphans(workspaceRoot, allowed) {
27
44
  if (!(await isDirectory(workspaceRoot))) {
28
45
  return;
@@ -48,38 +65,6 @@ async function destroyOrphans(workspaceRoot, allowed) {
48
65
  }
49
66
  }
50
67
  }
51
- const SUBJECT_MAX = 72;
52
- /**
53
- * A fold's subject out of prose. Text written the way a commit is — a headline,
54
- * a blank line, the rest — gives its headline. Otherwise the first sentence,
55
- * cut at a word when even that runs past what one line of history shows. A
56
- * subject is not markdown, so the backticks prose uses for names come off; and
57
- * it ends on a word, not on an ellipsis — the whole text follows in the body
58
- * for whoever wants it.
59
- */
60
- export function subjectOf(text) {
61
- const trimmed = text.trim();
62
- const headline = /^([^\n]+)\n\s*\n/.exec(trimmed)?.[1];
63
- const oneLine = (headline ?? trimmed).replace(/\s+/g, " ");
64
- // A sentence ends where the next one starts with a capital, or where the
65
- // text ends: "e.g. foo" and "src/index.js" are not sentence ends.
66
- const sentence = /^(.*?[.!?])(?:\s+(?=[A-Z\p{Lu}])|$)/u.exec(oneLine)?.[1] ?? oneLine;
67
- const candidate = sentence.replace(/`/g, "").replace(/[.:;,\s]+$/, "");
68
- if (candidate.length <= SUBJECT_MAX) {
69
- return candidate;
70
- }
71
- const cut = candidate.slice(0, SUBJECT_MAX);
72
- const atWord = cut.lastIndexOf(" ");
73
- return (atWord > SUBJECT_MAX / 2 ? cut.slice(0, atWord) : cut).replace(/[.:;,\s]+$/, "");
74
- }
75
- /** Subject line, then the whole text when the subject did not carry it all. */
76
- export function foldMessageOf(text) {
77
- const subject = subjectOf(text);
78
- const whole = text.trim();
79
- return subject === whole || subject === whole.replace(/[.\s]+$/, "")
80
- ? subject
81
- : `${subject}\n\n${whole}`;
82
- }
83
68
  export function openConductor(options) {
84
69
  if (!isAbsolute(options.workLineStable) || !isAbsolute(options.workspaceRoot)) {
85
70
  throw new Error("Conductor workLineStable and workspaceRoot must be absolute directories.");
@@ -105,23 +90,36 @@ export function openConductor(options) {
105
90
  /** The line a Ticket lands on: its own when it names one, else the Project's. */
106
91
  const targetOf = (aggregate) => aggregate.intention.line ?? workLineTarget;
107
92
  const maxRefusals = options.maxRefusals ?? DEFAULT_MAX_REFUSALS;
108
- const assemblyValidate = options.assemblyValidate ?? false;
109
- const assemblyFixDeclared = options.assemblyFixDeclared ?? false;
93
+ const validate = options.validate ?? false;
94
+ const fixDeclared = options.fixDeclared ?? false;
110
95
  const warm = options.warm;
96
+ const maxParallel = options.maxParallel ?? 1;
97
+ const line = options.line;
111
98
  let paused = false;
99
+ /**
100
+ * One at a time over the shared copy of the work line. What runs under it is
101
+ * brief and git's own — cutting a worktree from the copy, merging the copy
102
+ * in — while what takes minutes (the agents, the Gates) runs in the Ticket's
103
+ * own workspace, outside it.
104
+ */
105
+ let lastOnLine = Promise.resolve();
106
+ const onLine = (job) => {
107
+ const run = lastOnLine.then(job, job);
108
+ lastOnLine = run.catch(() => undefined);
109
+ return run;
110
+ };
111
+ /** The copy brought up to the line `target`, or why it could not be. */
112
+ const refreshLine = async (target) => {
113
+ if (line === undefined || target.length === 0) {
114
+ return { ok: true };
115
+ }
116
+ const refreshed = await line.refresh(target);
117
+ return refreshed.ok ? { ok: true } : { ok: false };
118
+ };
112
119
  const refused = (code) => ({
113
120
  outcome: "refused",
114
121
  code,
115
122
  });
116
- /** What the last Attempt ended on, for whoever reads the escalation. */
117
- const reasonOfTraces = (traces) => {
118
- const last = traces.at(-1);
119
- const report = last?.report?.trim();
120
- if (report === undefined || report.length === 0) {
121
- return undefined;
122
- }
123
- return last?.refusedBy === undefined ? report : `${last.refusedBy}: ${report}`;
124
- };
125
123
  const fromLedger = (result) => {
126
124
  if (result.ok) {
127
125
  return undefined;
@@ -153,6 +151,7 @@ export function openConductor(options) {
153
151
  key,
154
152
  kind: input.kind,
155
153
  ...(report.length === 0 ? {} : { report }),
154
+ ...(input.refusedBy === undefined ? {} : { refusedBy: input.refusedBy }),
156
155
  });
157
156
  const failure = fromLedger(escalated);
158
157
  if (failure !== undefined) {
@@ -167,35 +166,129 @@ export function openConductor(options) {
167
166
  }
168
167
  return await freeze(key, { kind: "unit" });
169
168
  }
169
+ /**
170
+ * What the corrective rounds of one ticket have used. A refusal, a conflict, a
171
+ * recheck that failed and a line that moved on all draw on the one
172
+ * `maxRefusals` budget: whichever loop it is, the ticket stops being corrected
173
+ * when it is empty, not once per loop.
174
+ */
175
+ function spentOf(aggregate) {
176
+ return ((aggregate.submission?.refusals ?? 0) +
177
+ (aggregate.parked_refusals ?? 0) +
178
+ (aggregate.submission?.realigns ?? 0));
179
+ }
180
+ /** What a Ticket's delivery reads of it, and how it keeps what the delivery decides. */
181
+ function ticketStore(key) {
182
+ return {
183
+ async read() {
184
+ const got = await ledger.get(key);
185
+ if (!got.ok) {
186
+ return undefined;
187
+ }
188
+ const aggregate = got.aggregate;
189
+ // A parked refusal is this round's; a Submission's is carried forward on
190
+ // purpose and may already be repaired. The fresher one wins.
191
+ const parked = aggregate.parked_refusal;
192
+ const report = parked?.report ?? aggregate.submission?.last_report;
193
+ const refusedBy = parked?.refused_by ?? aggregate.submission?.last_refused_by;
194
+ return {
195
+ sentBack: report === undefined ? undefined : { report, refusedBy },
196
+ spent: spentOf(aggregate),
197
+ submission: aggregate.submission === undefined
198
+ ? undefined
199
+ : { reference: aggregate.submission.reference },
200
+ };
201
+ },
202
+ freeze: async (input) => await freeze(key, input),
203
+ park: async (why) => fromLedger(await ledger.recordParkedRefusal({
204
+ key,
205
+ report: why.report,
206
+ ...(why.refusedBy === undefined ? {} : { refusedBy: why.refusedBy }),
207
+ })),
208
+ unpark: async () => fromLedger(await ledger.clearParkedRefusal(key)),
209
+ refuse: async (why) => fromLedger(await ledger.recordRefusal({
210
+ key,
211
+ report: why.report,
212
+ ...(why.refusedBy === undefined ? {} : { refusedBy: why.refusedBy }),
213
+ })),
214
+ realign: async () => fromLedger(await ledger.recordRealign({ key })),
215
+ };
216
+ }
217
+ /** Where a Ticket's work is and where it goes. */
218
+ function unitOf(key, aggregate) {
219
+ const workspace = declaredTicketPath(workspaceRoot, key, aggregate);
220
+ return {
221
+ key,
222
+ intention: aggregate.intention.intention,
223
+ title: aggregate.intention.title,
224
+ noun: "ticket",
225
+ workspace,
226
+ judgeWorkspace: workspace,
227
+ target: targetOf(aggregate),
228
+ line: workLineStable,
229
+ };
230
+ }
231
+ const delivery = openDelivery({
232
+ transformers,
233
+ authority,
234
+ durationMs,
235
+ maxRefusals,
236
+ recheck: true,
237
+ validate,
238
+ fixDeclared,
239
+ onLine,
240
+ refreshLine: async (target) => (await refreshLine(target)).ok,
241
+ isPaused: () => paused,
242
+ });
243
+ /** A unit nobody is on any more: its lease runs out. Its workspace stays, for the next pass. */
170
244
  async function expireIfHeld(key) {
171
- const before = await ledger.get(key);
172
- const held = before.ok && before.aggregate.state === "running" ? before.aggregate.workspace : undefined;
173
- const expired = await ledger.expireBail(key);
174
- if (expired.ok && held !== undefined) {
175
- await destroy(held);
176
- }
245
+ await ledger.expireBail(key);
177
246
  }
178
247
  /**
179
248
  * A unit was started and a transformer stopped on interrupt. Release the
180
249
  * bail now so the next process does not wait an hour for the clock.
250
+ *
251
+ * The workspace stays, and so do the Attempts the pass finished: stopping Mason is not a
252
+ * failure of the work, so the next pass picks it up where it was instead of starting over.
253
+ * Whoever wants it started over says so (`release`).
181
254
  */
182
- async function releaseHeldAfterInterrupt(key) {
255
+ async function releaseHeldAfterInterrupt(key, traces = []) {
183
256
  const before = await ledger.get(key);
184
- const child = before.ok ? before.aggregate.workspace : undefined;
185
- const released = await ledger.releaseBail(key);
186
- if (!released.ok) {
187
- if (released.code === "persist-failed") {
188
- return refused("persist-failed");
257
+ if (before.ok) {
258
+ let number = before.aggregate.attempts.length + 1;
259
+ for (const trace of traces) {
260
+ if (trace.ended === "interrupted") {
261
+ continue;
262
+ }
263
+ const recorded = await recordFinished(key, number, trace);
264
+ if (recorded !== undefined) {
265
+ return recorded;
266
+ }
267
+ number += 1;
189
268
  }
190
- // Already free, or a state releaseBail refuses — leave the ledger and
191
- // still answer interrupted.
192
- return undefined;
193
269
  }
194
- if (child !== undefined) {
195
- await destroy(child);
270
+ const released = await ledger.releaseBail(key);
271
+ if (!released.ok && released.code === "persist-failed") {
272
+ return refused("persist-failed");
196
273
  }
274
+ // Already free, or a state releaseBail refuses — leave the ledger and
275
+ // still answer interrupted.
197
276
  return undefined;
198
277
  }
278
+ async function recordFinished(key, number, trace) {
279
+ const recorded = await ledger.recordAttempt({
280
+ key,
281
+ number,
282
+ trace: {
283
+ ended: trace.ended,
284
+ ...(trace.report === undefined || trace.report.length === 0
285
+ ? {}
286
+ : { report: trace.report }),
287
+ ...(trace.refusedBy === undefined ? {} : { refusedBy: trace.refusedBy }),
288
+ },
289
+ });
290
+ return fromLedger(recorded);
291
+ }
199
292
  async function reconcile(project) {
200
293
  const declared = await ledger.listDeclaredWorkspaces();
201
294
  const allowed = new Set();
@@ -240,13 +333,24 @@ export function openConductor(options) {
240
333
  if (declareFail !== undefined) {
241
334
  return declareFail;
242
335
  }
243
- const isolated = await transformers.isolate({
244
- id: key,
245
- context: key,
246
- parent: workLineStable,
247
- child: workspace,
248
- durationMs,
336
+ // Cut from the line as it is now, with nobody moving it meanwhile.
337
+ const target = targetOf(got.aggregate);
338
+ const cut = await onLine(async () => {
339
+ if (!(await refreshLine(target)).ok) {
340
+ return undefined;
341
+ }
342
+ return await transformers.isolate({
343
+ id: key,
344
+ context: key,
345
+ parent: workLineStable,
346
+ child: workspace,
347
+ durationMs,
348
+ });
249
349
  });
350
+ if (cut === undefined) {
351
+ return refused("line-refresh-failed");
352
+ }
353
+ const isolated = cut;
250
354
  if (isolated.outcome === "interrupted") {
251
355
  return refused("interrupted");
252
356
  }
@@ -287,9 +391,14 @@ export function openConductor(options) {
287
391
  // A unit is held: its lease runs out on its own if nobody is on it.
288
392
  return { outcome: "paused", key };
289
393
  }
290
- const isolated = await isolateFresh(key);
291
- if (isolated !== undefined) {
292
- return isolated;
394
+ // A pass that was stopped (Mason stopped, or died) leaves its workspace declared: this one
395
+ // picks it up. A unit that starts with nothing left starts from zero.
396
+ const resumed = await stillThere(before.aggregate.workspace);
397
+ if (!resumed) {
398
+ const isolated = await isolateFresh(key);
399
+ if (isolated !== undefined) {
400
+ return isolated;
401
+ }
293
402
  }
294
403
  if (paused) {
295
404
  return { outcome: "paused", key };
@@ -303,14 +412,25 @@ export function openConductor(options) {
303
412
  if (!got.ok) {
304
413
  return refused("not-found");
305
414
  }
415
+ const unitPath = declaredTicketPath(workspaceRoot, key, got.aggregate);
416
+ // The Attempts the stopped pass finished count, and the next one goes on from them.
417
+ const earlier = resumed
418
+ ? got.aggregate.attempts.filter((attempt) => attempt.round === got.aggregate.resumes)
419
+ : [];
420
+ const last = earlier[earlier.length - 1]?.trace;
421
+ const carried = last?.ended === "fail-retryable" ? last : undefined;
422
+ const report = carried?.report ?? (resumed ? RESUMED_NOTE : undefined);
306
423
  const implemented = await transformers.implement({
307
424
  id: key,
308
425
  context: key,
309
426
  intention: got.aggregate.intention.intention,
310
- workspace: declaredTicketPath(workspaceRoot, key, got.aggregate),
427
+ workspace: unitPath,
428
+ ...(report === undefined ? {} : { report }),
429
+ ...(carried?.refusedBy === undefined ? {} : { reportFrom: carried.refusedBy }),
430
+ ...(earlier.length === 0 ? {} : { firstAttempt: earlier.length + 1 }),
311
431
  });
312
432
  if (implemented.outcome === "interrupted") {
313
- const failure = await releaseHeldAfterInterrupt(key);
433
+ const failure = await releaseHeldAfterInterrupt(key, implemented.traces);
314
434
  return failure ?? refused("interrupted");
315
435
  }
316
436
  if (implemented.outcome === "invalid-invocation") {
@@ -361,123 +481,16 @@ export function openConductor(options) {
361
481
  if (!got.ok) {
362
482
  return refused("not-found");
363
483
  }
364
- const ticketPath = declaredTicketPath(workspaceRoot, key, got.aggregate);
365
- // A parked refusal is this round's; a Submission's is carried forward on
366
- // purpose and may already be repaired. The fresher one wins.
367
- const parked = got.aggregate.parked_refusal;
368
- const sentBack = parked?.report ?? got.aggregate.submission?.last_report;
369
- if (sentBack !== undefined) {
370
- // Something refused the last round and said why. That report is the only
371
- // reason this stage has to produce: the unit is already validated.
372
- //
373
- // Before the Integration below, and that order is the whole point. What a
374
- // producer leaves is loose in the workspace; the Integration is the only
375
- // thing that takes it up. Producing after it means the repair waits for
376
- // the next round to be taken up at all, and what goes out meanwhile is the
377
- // state that was already refused — the same refusal comes back, and the
378
- // budget empties on the delay.
379
- const refusedBy = parked?.refused_by ?? got.aggregate.submission?.last_refused_by;
380
- const remade = await transformers.implement({
381
- id: `${key}:assembly`,
382
- context: key,
383
- stage: "assembly",
384
- produce: true,
385
- intention: got.aggregate.intention.intention,
386
- workspace: ticketPath,
387
- report: sentBack,
388
- ...(refusedBy === undefined ? {} : { reportFrom: refusedBy }),
389
- });
390
- if (remade.outcome === "interrupted") {
391
- return refused("interrupted");
392
- }
393
- if (remade.outcome === "invalid-invocation") {
394
- return refused("transformer-invalid");
395
- }
396
- if (remade.outcome === "escalated") {
397
- // The assembled work, not the unit: naming the unit here sends a reader
398
- // to look at work that did not fail.
399
- const why = reasonOfTraces(remade.traces);
400
- return await freeze(key, {
401
- kind: "assembly",
402
- ...(why === undefined ? {} : { report: why }),
403
- });
404
- }
405
- }
406
- const aligned = await transformers.integrate({
407
- id: `${key}:align`,
408
- context: key,
409
- parent: ticketPath,
410
- child: workLineStable,
411
- durationMs,
412
- // What is loose in the workspace here is the work the Builder made, or a
413
- // repair of it.
414
- subject: sentBack === undefined
415
- ? foldMessageOf(got.aggregate.intention.title ?? got.aggregate.intention.intention)
416
- : "Repair after it was refused",
417
- mergeSubject: `Merge ${targetOf(got.aggregate) || "the work line"} into the ticket`,
418
- });
419
- if (aligned.outcome === "interrupted") {
420
- return refused("interrupted");
421
- }
422
- if (aligned.outcome === "conflict") {
423
- return refused("align-conflict");
424
- }
425
- if (aligned.outcome !== "integrated") {
426
- return refused("transformer-invalid");
427
- }
428
- if (paused) {
429
- return { outcome: "paused", key };
430
- }
431
- if (assemblyValidate) {
432
- const validated = await transformers.implement({
433
- id: `${key}:validate`,
434
- context: key,
435
- stage: "assembly",
436
- validate: true,
437
- intention: got.aggregate.intention.intention,
438
- workspace: ticketPath,
439
- });
440
- if (validated.outcome === "interrupted") {
441
- return refused("interrupted");
442
- }
443
- if (validated.outcome === "invalid-invocation") {
444
- return refused("transformer-invalid");
445
- }
446
- if (validated.outcome === "escalated") {
447
- const last = validated.traces.at(-1);
448
- const report = last?.report ?? "The assembled ticket was refused, with no reason given.";
449
- const spent = (got.aggregate.submission?.refusals ?? 0) + (got.aggregate.parked_refusals ?? 0);
450
- const canRepair = assemblyFixDeclared && last?.ended !== "fail-blocking" && spent + 1 < maxRefusals;
451
- if (!canRepair) {
452
- return await freeze(key, {
453
- kind: "assembly",
454
- report: last?.refusedBy === undefined ? report : `${last.refusedBy}: ${report}`,
455
- });
456
- }
457
- const recorded = await ledger.recordParkedRefusal({
458
- key,
459
- report,
460
- ...(last?.refusedBy === undefined ? {} : { refusedBy: last.refusedBy }),
461
- });
462
- const failure = fromLedger(recorded);
463
- if (failure !== undefined) {
464
- return failure;
465
- }
466
- // Repair is on its way: do not offer, or judge, work that is already
467
- // known to need it.
468
- return { outcome: "paused", key };
469
- }
470
- const cleared = await ledger.clearParkedRefusal(key);
471
- const clearFail = fromLedger(cleared);
472
- if (clearFail !== undefined) {
473
- return clearFail;
474
- }
484
+ const unit = unitOf(key, got.aggregate);
485
+ const prepared = await delivery.prepare(unit, ticketStore(key));
486
+ if (prepared !== "ready") {
487
+ return prepared;
475
488
  }
476
489
  if (authority === undefined) {
477
490
  // Nobody outside will look at it, so the Project's own sequence is the
478
491
  // only judgement the assembled ticket gets. Skipping it here would let
479
492
  // work into the work line that nothing ever judged as a whole.
480
- const judged = await judge(key, got.aggregate);
493
+ const judged = await delivery.judge(unit);
481
494
  if (judged.outcome === "interrupted") {
482
495
  return refused("interrupted");
483
496
  }
@@ -534,72 +547,15 @@ export function openConductor(options) {
534
547
  await observe({ kind: "submitted", key, reference: submitted.reference });
535
548
  return { outcome: "paused", key };
536
549
  }
537
- /**
538
- * Judge the assembled ticket with the Project's Gate sequence for it.
539
- *
540
- * Nothing is produced: the work is done, and this pass only decides whether it
541
- * may enter the work line. A Gate that reads an outside answer belongs in that
542
- * sequence like any other — it says ok or not, and it does not act.
543
- */
544
- async function judge(key, aggregate) {
545
- return await transformers.implement({
546
- id: `${key}:judgement`,
547
- context: key,
548
- stage: "assembly",
549
- produce: false,
550
- intention: aggregate.intention.intention,
551
- workspace: declaredTicketPath(workspaceRoot, key, aggregate),
552
- });
553
- }
554
550
  async function doSubmitted(key) {
555
551
  const got = await ledger.get(key);
556
552
  if (!got.ok) {
557
553
  return refused("not-found");
558
554
  }
559
- const submission = got.aggregate.submission;
560
- if (authority === undefined || submission === undefined) {
561
- return refused("transformer-invalid");
562
- }
563
555
  const ticketPath = declaredTicketPath(workspaceRoot, key, got.aggregate);
564
- const judged = await judge(key, got.aggregate);
565
- if (judged.outcome === "interrupted") {
566
- return refused("interrupted");
567
- }
568
- if (judged.outcome === "invalid-invocation") {
569
- return refused("transformer-invalid");
570
- }
571
- if (judged.outcome === "escalated") {
572
- const last = judged.traces.at(-1);
573
- const report = last?.report ?? "The Submission was refused, with no reason given.";
574
- // A blocking refusal is not something another round repairs, and neither
575
- // is one more refusal than the budget allows.
576
- if (last?.ended === "fail-blocking" || submission.refusals + 1 >= maxRefusals) {
577
- // The refusal that stopped it is never recorded as one — this is the
578
- // only place it can be kept for the person who reads the escalation.
579
- return await freeze(key, {
580
- kind: "submitted",
581
- report: last?.refusedBy === undefined ? report : `${last.refusedBy}: ${report}`,
582
- });
583
- }
584
- const recorded = await ledger.recordRefusal({
585
- key,
586
- report,
587
- ...(last?.refusedBy === undefined ? {} : { refusedBy: last.refusedBy }),
588
- });
589
- const failure = fromLedger(recorded);
590
- if (failure !== undefined) {
591
- return failure;
592
- }
593
- // Back to where the work is made. The next pass produces with that report
594
- // in hand and publishes to the same place.
595
- return { outcome: "paused", key };
596
- }
597
- const folded = await authority.fold({ reference: submission.reference });
598
- if (folded.outcome === "unavailable") {
599
- return { outcome: "paused", key };
600
- }
601
- if (folded.outcome === "conflict") {
602
- return await freeze(key, { kind: "submitted" });
556
+ const settled = await delivery.settle(unitOf(key, got.aggregate), ticketStore(key));
557
+ if (!("folded" in settled)) {
558
+ return settled;
603
559
  }
604
560
  // The Authority folded it. The work line moved; this system's copy has not,
605
561
  // and whoever wired the Authority is the one that brings it up to date.
@@ -608,7 +564,7 @@ export function openConductor(options) {
608
564
  if (mergingFailure !== undefined) {
609
565
  return mergingFailure;
610
566
  }
611
- const done = await ledger.markDone(key, { reference: folded.reference });
567
+ const done = await ledger.markDone(key, { reference: settled.reference });
612
568
  const doneFailure = fromLedger(done);
613
569
  if (doneFailure !== undefined) {
614
570
  return doneFailure;
@@ -623,7 +579,7 @@ export function openConductor(options) {
623
579
  const title = got.ok
624
580
  ? (got.aggregate.intention.title ?? got.aggregate.intention.intention)
625
581
  : key;
626
- const folded = await transformers.integrate({
582
+ const folded = await onLine(async () => await transformers.integrate({
627
583
  id: key,
628
584
  context: key,
629
585
  parent: workLineStable,
@@ -631,7 +587,7 @@ export function openConductor(options) {
631
587
  durationMs,
632
588
  subject: foldMessageOf(title),
633
589
  mergeSubject: `Merge ${subjectOf(title)}`,
634
- });
590
+ }));
635
591
  if (folded.outcome === "interrupted") {
636
592
  return refused("interrupted");
637
593
  }
@@ -737,7 +693,7 @@ export function openConductor(options) {
737
693
  if (paused) {
738
694
  return { outcome: "paused" };
739
695
  }
740
- const claimed = await ledger.claim(project);
696
+ const claimed = await ledger.claim(project, maxParallel);
741
697
  if (!claimed.ok) {
742
698
  const failure = fromLedger(claimed);
743
699
  return failure ?? refused("illegal-transition");
@@ -747,6 +703,19 @@ export function openConductor(options) {
747
703
  }
748
704
  return await drive(claimed.key);
749
705
  },
706
+ async claimNext(project) {
707
+ if (paused || !(await isDirectory(workLineStable))) {
708
+ return undefined;
709
+ }
710
+ const claimed = await ledger.claim(project, maxParallel);
711
+ return claimed.ok && "key" in claimed ? { key: claimed.key } : undefined;
712
+ },
713
+ async runTicket(key) {
714
+ if (!(await isDirectory(workLineStable))) {
715
+ return refused("stable-missing");
716
+ }
717
+ return await drive(key);
718
+ },
750
719
  async cancel(key) {
751
720
  const cancelled = await ledger.cancel(key);
752
721
  if (cancelled.ok) {
@@ -759,7 +728,9 @@ export function openConductor(options) {
759
728
  const child = before.ok ? before.aggregate.workspace : undefined;
760
729
  const released = await ledger.releaseBail(key);
761
730
  if (released.ok && child !== undefined) {
731
+ // The way to start a unit over: what a stopped pass left is dropped, not picked up.
762
732
  await destroy(child);
733
+ await ledger.clearWorkspace(key);
763
734
  }
764
735
  return released;
765
736
  },