@bluewombat/conductor 0.9.0 → 0.10.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,37 @@ 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 testsFirst = options.tests ?? false;
95
+ const fixDeclared = options.fixDeclared ?? false;
110
96
  const warm = options.warm;
97
+ const maxParallel = options.maxParallel ?? 1;
98
+ const line = options.line;
111
99
  let paused = false;
100
+ /**
101
+ * One at a time over the shared copy of the work line. What runs under it is
102
+ * brief and git's own — cutting a worktree from the copy, merging the copy
103
+ * in — while what takes minutes (the agents, the Gates) runs in the Ticket's
104
+ * own workspace, outside it.
105
+ */
106
+ let lastOnLine = Promise.resolve();
107
+ const onLine = (job) => {
108
+ const run = lastOnLine.then(job, job);
109
+ lastOnLine = run.catch(() => undefined);
110
+ return run;
111
+ };
112
+ /** The copy brought up to the line `target`, or why it could not be. */
113
+ const refreshLine = async (target) => {
114
+ if (line === undefined || target.length === 0) {
115
+ return { ok: true };
116
+ }
117
+ const refreshed = await line.refresh(target);
118
+ return refreshed.ok ? { ok: true } : { ok: false };
119
+ };
112
120
  const refused = (code) => ({
113
121
  outcome: "refused",
114
122
  code,
115
123
  });
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
124
  const fromLedger = (result) => {
126
125
  if (result.ok) {
127
126
  return 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
  }
@@ -267,6 +371,65 @@ export function openConductor(options) {
267
371
  }
268
372
  return undefined;
269
373
  }
374
+ /**
375
+ * The tests of the Task, before its code.
376
+ *
377
+ * Their own agent writes them in the Ticket's workspace and their own Gates
378
+ * judge them (they must fail, for the right reason). What they wrote is then
379
+ * committed — a checkpoint, by folding the workspace into itself, which the
380
+ * Integration does by committing what is loose — so that the producer's work is
381
+ * what differs from it afterwards, and its Gates can see whether a test was
382
+ * touched.
383
+ *
384
+ * `undefined` when the code may be written; otherwise the result that ends the
385
+ * pass. Nothing of this is an Attempt of the unit: a pass that fails here is a
386
+ * Task whose tests could not be written, and a person looks.
387
+ */
388
+ async function writeTestsFirst(key, aggregate, workspace) {
389
+ const written = await transformers.implement({
390
+ id: `${key}:tests`,
391
+ context: key,
392
+ stage: "tests",
393
+ produce: true,
394
+ intention: aggregate.intention.intention,
395
+ workspace,
396
+ });
397
+ if (written.outcome === "interrupted") {
398
+ const failure = await releaseHeldAfterInterrupt(key);
399
+ return failure ?? refused("interrupted");
400
+ }
401
+ if (written.outcome === "invalid-invocation") {
402
+ return refused("transformer-invalid");
403
+ }
404
+ if (written.outcome === "escalated") {
405
+ const why = reasonOfTraces(written.traces);
406
+ return await freeze(key, {
407
+ kind: "unit",
408
+ report: `The tests could not be written first.${why === undefined ? "" : ` ${why}`}`,
409
+ });
410
+ }
411
+ const checkpoint = await transformers.integrate({
412
+ id: `${key}:tests`,
413
+ context: key,
414
+ parent: workspace,
415
+ child: workspace,
416
+ durationMs,
417
+ subject: `Tests: ${subjectOf(aggregate.intention.title ?? aggregate.intention.intention)}`,
418
+ });
419
+ if (checkpoint.outcome === "interrupted") {
420
+ const failure = await releaseHeldAfterInterrupt(key);
421
+ return failure ?? refused("interrupted");
422
+ }
423
+ if (checkpoint.outcome !== "integrated") {
424
+ return await freeze(key, {
425
+ kind: "unit",
426
+ report: "The tests that were written first could not be committed.",
427
+ });
428
+ }
429
+ // A pass that is stopped from here on does not write them again.
430
+ const marked = await ledger.markFirstStageDone(key);
431
+ return fromLedger(marked);
432
+ }
270
433
  /**
271
434
  * Run the unit: Builder, then the Gates, in the workspace Isolation made.
272
435
  * The result stays loose in the workspace; the Integration in `doIntegrating`
@@ -287,9 +450,14 @@ export function openConductor(options) {
287
450
  // A unit is held: its lease runs out on its own if nobody is on it.
288
451
  return { outcome: "paused", key };
289
452
  }
290
- const isolated = await isolateFresh(key);
291
- if (isolated !== undefined) {
292
- return isolated;
453
+ // A pass that was stopped (Mason stopped, or died) leaves its workspace declared: this one
454
+ // picks it up. A unit that starts with nothing left starts from zero.
455
+ const resumed = await stillThere(before.aggregate.workspace);
456
+ if (!resumed) {
457
+ const isolated = await isolateFresh(key);
458
+ if (isolated !== undefined) {
459
+ return isolated;
460
+ }
293
461
  }
294
462
  if (paused) {
295
463
  return { outcome: "paused", key };
@@ -303,14 +471,34 @@ export function openConductor(options) {
303
471
  if (!got.ok) {
304
472
  return refused("not-found");
305
473
  }
474
+ const unitPath = declaredTicketPath(workspaceRoot, key, got.aggregate);
475
+ // The tracker can say a ticket has no tests written first: a label, not a line of its text.
476
+ if (testsFirst &&
477
+ got.aggregate.intention.no_tests !== true &&
478
+ got.aggregate.first_stage_done !== true) {
479
+ const stopped = await writeTestsFirst(key, got.aggregate, unitPath);
480
+ if (stopped !== undefined) {
481
+ return stopped;
482
+ }
483
+ }
484
+ // The Attempts the stopped pass finished count, and the next one goes on from them.
485
+ const earlier = resumed
486
+ ? got.aggregate.attempts.filter((attempt) => attempt.round === got.aggregate.resumes)
487
+ : [];
488
+ const last = earlier[earlier.length - 1]?.trace;
489
+ const carried = last?.ended === "fail-retryable" ? last : undefined;
490
+ const report = carried?.report ?? (resumed ? RESUMED_NOTE : undefined);
306
491
  const implemented = await transformers.implement({
307
492
  id: key,
308
493
  context: key,
309
494
  intention: got.aggregate.intention.intention,
310
- workspace: declaredTicketPath(workspaceRoot, key, got.aggregate),
495
+ workspace: unitPath,
496
+ ...(report === undefined ? {} : { report }),
497
+ ...(carried?.refusedBy === undefined ? {} : { reportFrom: carried.refusedBy }),
498
+ ...(earlier.length === 0 ? {} : { firstAttempt: earlier.length + 1 }),
311
499
  });
312
500
  if (implemented.outcome === "interrupted") {
313
- const failure = await releaseHeldAfterInterrupt(key);
501
+ const failure = await releaseHeldAfterInterrupt(key, implemented.traces);
314
502
  return failure ?? refused("interrupted");
315
503
  }
316
504
  if (implemented.outcome === "invalid-invocation") {
@@ -361,123 +549,16 @@ export function openConductor(options) {
361
549
  if (!got.ok) {
362
550
  return refused("not-found");
363
551
  }
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
- }
552
+ const unit = unitOf(key, got.aggregate);
553
+ const prepared = await delivery.prepare(unit, ticketStore(key));
554
+ if (prepared !== "ready") {
555
+ return prepared;
475
556
  }
476
557
  if (authority === undefined) {
477
558
  // Nobody outside will look at it, so the Project's own sequence is the
478
559
  // only judgement the assembled ticket gets. Skipping it here would let
479
560
  // work into the work line that nothing ever judged as a whole.
480
- const judged = await judge(key, got.aggregate);
561
+ const judged = await delivery.judge(unit);
481
562
  if (judged.outcome === "interrupted") {
482
563
  return refused("interrupted");
483
564
  }
@@ -534,72 +615,15 @@ export function openConductor(options) {
534
615
  await observe({ kind: "submitted", key, reference: submitted.reference });
535
616
  return { outcome: "paused", key };
536
617
  }
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
618
  async function doSubmitted(key) {
555
619
  const got = await ledger.get(key);
556
620
  if (!got.ok) {
557
621
  return refused("not-found");
558
622
  }
559
- const submission = got.aggregate.submission;
560
- if (authority === undefined || submission === undefined) {
561
- return refused("transformer-invalid");
562
- }
563
623
  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" });
624
+ const settled = await delivery.settle(unitOf(key, got.aggregate), ticketStore(key));
625
+ if (!("folded" in settled)) {
626
+ return settled;
603
627
  }
604
628
  // The Authority folded it. The work line moved; this system's copy has not,
605
629
  // and whoever wired the Authority is the one that brings it up to date.
@@ -608,7 +632,7 @@ export function openConductor(options) {
608
632
  if (mergingFailure !== undefined) {
609
633
  return mergingFailure;
610
634
  }
611
- const done = await ledger.markDone(key, { reference: folded.reference });
635
+ const done = await ledger.markDone(key, { reference: settled.reference });
612
636
  const doneFailure = fromLedger(done);
613
637
  if (doneFailure !== undefined) {
614
638
  return doneFailure;
@@ -623,7 +647,7 @@ export function openConductor(options) {
623
647
  const title = got.ok
624
648
  ? (got.aggregate.intention.title ?? got.aggregate.intention.intention)
625
649
  : key;
626
- const folded = await transformers.integrate({
650
+ const folded = await onLine(async () => await transformers.integrate({
627
651
  id: key,
628
652
  context: key,
629
653
  parent: workLineStable,
@@ -631,7 +655,7 @@ export function openConductor(options) {
631
655
  durationMs,
632
656
  subject: foldMessageOf(title),
633
657
  mergeSubject: `Merge ${subjectOf(title)}`,
634
- });
658
+ }));
635
659
  if (folded.outcome === "interrupted") {
636
660
  return refused("interrupted");
637
661
  }
@@ -737,7 +761,7 @@ export function openConductor(options) {
737
761
  if (paused) {
738
762
  return { outcome: "paused" };
739
763
  }
740
- const claimed = await ledger.claim(project);
764
+ const claimed = await ledger.claim(project, maxParallel);
741
765
  if (!claimed.ok) {
742
766
  const failure = fromLedger(claimed);
743
767
  return failure ?? refused("illegal-transition");
@@ -747,6 +771,19 @@ export function openConductor(options) {
747
771
  }
748
772
  return await drive(claimed.key);
749
773
  },
774
+ async claimNext(project) {
775
+ if (paused || !(await isDirectory(workLineStable))) {
776
+ return undefined;
777
+ }
778
+ const claimed = await ledger.claim(project, maxParallel);
779
+ return claimed.ok && "key" in claimed ? { key: claimed.key } : undefined;
780
+ },
781
+ async runTicket(key) {
782
+ if (!(await isDirectory(workLineStable))) {
783
+ return refused("stable-missing");
784
+ }
785
+ return await drive(key);
786
+ },
750
787
  async cancel(key) {
751
788
  const cancelled = await ledger.cancel(key);
752
789
  if (cancelled.ok) {
@@ -759,7 +796,9 @@ export function openConductor(options) {
759
796
  const child = before.ok ? before.aggregate.workspace : undefined;
760
797
  const released = await ledger.releaseBail(key);
761
798
  if (released.ok && child !== undefined) {
799
+ // The way to start a unit over: what a stopped pass left is dropped, not picked up.
762
800
  await destroy(child);
801
+ await ledger.clearWorkspace(key);
763
802
  }
764
803
  return released;
765
804
  },