@bluewombat/conductor 0.8.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,19 +1,12 @@
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 { featureWorkspacePath, subtaskWorkspacePath } from "./paths.js";
4
+ import { openDelivery, reasonOfTraces, } from "./delivery.js";
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
- function featureJsonOf(admission) {
8
- const document = {
9
- key: admission.key,
10
- intention: admission.intention,
11
- };
12
- if (admission.title !== undefined) {
13
- document.title = admission.title;
14
- }
15
- return JSON.stringify(document);
16
- }
17
10
  async function isDirectory(path) {
18
11
  try {
19
12
  return (await stat(path)).isDirectory();
@@ -25,14 +18,27 @@ async function isDirectory(path) {
25
18
  async function destroy(path) {
26
19
  await rm(path, { recursive: true, force: true });
27
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.";
28
23
  /**
29
- * Where a feature's workspace is: the path the ledger declared when it was
30
- * made, else the one the current layout gives. The declared path wins because a
31
- * feature in flight — escalated, resumed — keeps its work and its git branch
32
- * where they were; a layout that changed since must not strand them.
24
+ * Where a Ticket's workspace is: the path the ledger declared when it was
25
+ * made, else the one the layout gives. The declared path wins because a
26
+ * Ticket in flight keeps its work and its git branch where they were.
33
27
  */
34
- function declaredFeaturePath(workspaceRoot, key, aggregate) {
35
- return aggregate?.workspaces?.feature ?? featureWorkspacePath(workspaceRoot, key);
28
+ function declaredTicketPath(workspaceRoot, key, aggregate) {
29
+ return aggregate?.workspace ?? ticketWorkspacePath(workspaceRoot, key);
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
+ }
36
42
  }
37
43
  async function destroyOrphans(workspaceRoot, allowed) {
38
44
  if (!(await isDirectory(workspaceRoot))) {
@@ -59,38 +65,6 @@ async function destroyOrphans(workspaceRoot, allowed) {
59
65
  }
60
66
  }
61
67
  }
62
- const SUBJECT_MAX = 72;
63
- /**
64
- * A fold's subject out of prose. Text written the way a commit is — a headline,
65
- * a blank line, the rest — gives its headline. Otherwise the first sentence,
66
- * cut at a word when even that runs past what one line of history shows. A
67
- * subject is not markdown, so the backticks prose uses for names come off; and
68
- * it ends on a word, not on an ellipsis — the whole text follows in the body
69
- * for whoever wants it.
70
- */
71
- export function subjectOf(text) {
72
- const trimmed = text.trim();
73
- const headline = /^([^\n]+)\n\s*\n/.exec(trimmed)?.[1];
74
- const oneLine = (headline ?? trimmed).replace(/\s+/g, " ");
75
- // A sentence ends where the next one starts with a capital, or where the
76
- // text ends: "e.g. foo" and "src/index.js" are not sentence ends.
77
- const sentence = /^(.*?[.!?])(?:\s+(?=[A-Z\p{Lu}])|$)/u.exec(oneLine)?.[1] ?? oneLine;
78
- const candidate = sentence.replace(/`/g, "").replace(/[.:;,\s]+$/, "");
79
- if (candidate.length <= SUBJECT_MAX) {
80
- return candidate;
81
- }
82
- const cut = candidate.slice(0, SUBJECT_MAX);
83
- const atWord = cut.lastIndexOf(" ");
84
- return (atWord > SUBJECT_MAX / 2 ? cut.slice(0, atWord) : cut).replace(/[.:;,\s]+$/, "");
85
- }
86
- /** Subject line, then the whole text when the subject did not carry it all. */
87
- export function foldMessageOf(text) {
88
- const subject = subjectOf(text);
89
- const whole = text.trim();
90
- return subject === whole || subject === whole.replace(/[.\s]+$/, "")
91
- ? subject
92
- : `${subject}\n\n${whole}`;
93
- }
94
68
  export function openConductor(options) {
95
69
  if (!isAbsolute(options.workLineStable) || !isAbsolute(options.workspaceRoot)) {
96
70
  throw new Error("Conductor workLineStable and workspaceRoot must be absolute directories.");
@@ -113,26 +87,40 @@ export function openConductor(options) {
113
87
  const durationMs = options.transformerDurationMs ?? DEFAULT_TRANSFORMER_DURATION_MS;
114
88
  const authority = options.authority;
115
89
  const workLineTarget = options.workLineTarget ?? "";
90
+ /** The line a Ticket lands on: its own when it names one, else the Project's. */
91
+ const targetOf = (aggregate) => aggregate.intention.line ?? workLineTarget;
116
92
  const maxRefusals = options.maxRefusals ?? DEFAULT_MAX_REFUSALS;
117
- const assemblyValidate = options.assemblyValidate ?? false;
118
- const assemblyFixDeclared = options.assemblyFixDeclared ?? false;
93
+ const validate = options.validate ?? false;
94
+ const testsFirst = options.tests ?? false;
95
+ const fixDeclared = options.fixDeclared ?? false;
119
96
  const warm = options.warm;
120
- /** Features whose warm already succeeded in this Conductor instance. */
121
- const warmed = new Set();
97
+ const maxParallel = options.maxParallel ?? 1;
98
+ const line = options.line;
122
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
+ };
123
120
  const refused = (code) => ({
124
121
  outcome: "refused",
125
122
  code,
126
123
  });
127
- /** What the last Attempt ended on, for whoever reads the escalation. */
128
- const reasonOfTraces = (traces) => {
129
- const last = traces.at(-1);
130
- const report = last?.report?.trim();
131
- if (report === undefined || report.length === 0) {
132
- return undefined;
133
- }
134
- return last?.refusedBy === undefined ? report : `${last.refusedBy}: ${report}`;
135
- };
136
124
  const fromLedger = (result) => {
137
125
  if (result.ok) {
138
126
  return undefined;
@@ -149,13 +137,13 @@ export function openConductor(options) {
149
137
  return refused("illegal-transition");
150
138
  };
151
139
  /**
152
- * Freeze the Feature for a human. If a newer fingerprint arrived in flight,
140
+ * Freeze the Ticket for a human. If a newer fingerprint arrived in flight,
153
141
  * it is named on the escalation and consumed by the ledger.
154
142
  */
155
143
  async function freeze(key, input) {
156
144
  const got = await ledger.get(key);
157
145
  const pending = got.ok && got.aggregate.pending_fingerprint !== undefined
158
- ? `The intention was edited while the work was in flight (now fingerprint ${got.aggregate.pending_fingerprint}). The work stops here for a human to decide: on resume it is planned again from the new words, and what already landed is kept.`
146
+ ? `The intention was edited while the work was in flight (now fingerprint ${got.aggregate.pending_fingerprint}). The work stops here for a human to decide: on resume the work starts again from zero.`
159
147
  : undefined;
160
148
  const report = [input.report, pending]
161
149
  .filter((part) => part !== undefined && part.length > 0)
@@ -163,7 +151,6 @@ export function openConductor(options) {
163
151
  const escalated = await ledger.escalate({
164
152
  key,
165
153
  kind: input.kind,
166
- ...(input.subtaskId === undefined ? {} : { subtaskId: input.subtaskId }),
167
154
  ...(report.length === 0 ? {} : { report }),
168
155
  });
169
156
  const failure = fromLedger(escalated);
@@ -177,48 +164,136 @@ export function openConductor(options) {
177
164
  if (!got.ok || got.aggregate.pending_fingerprint === undefined) {
178
165
  return undefined;
179
166
  }
180
- return await freeze(key, { kind: "plan" });
167
+ return await freeze(key, { kind: "unit" });
181
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. */
182
244
  async function expireIfHeld(key) {
183
- const expired = await ledger.expireBail(key);
184
- if (expired.ok) {
185
- const got = await ledger.get(key);
186
- if (got.ok && got.aggregate.workspaces?.subtask !== undefined) {
187
- await destroy(got.aggregate.workspaces.subtask);
188
- }
189
- }
245
+ await ledger.expireBail(key);
190
246
  }
191
247
  /**
192
- * A Subtask was started and a transformer stopped on interrupt. Release the
248
+ * A unit was started and a transformer stopped on interrupt. Release the
193
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`).
194
254
  */
195
- async function releaseHeldAfterInterrupt(key) {
255
+ async function releaseHeldAfterInterrupt(key, traces = []) {
196
256
  const before = await ledger.get(key);
197
- const child = before.ok ? before.aggregate.workspaces?.subtask : undefined;
198
- const released = await ledger.releaseBail(key);
199
- if (!released.ok) {
200
- if (released.code === "persist-failed") {
201
- 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;
202
268
  }
203
- // Already free, or a state releaseBail refuses — leave the ledger and
204
- // still answer interrupted.
205
- return undefined;
206
269
  }
207
- if (child !== undefined) {
208
- await destroy(child);
270
+ const released = await ledger.releaseBail(key);
271
+ if (!released.ok && released.code === "persist-failed") {
272
+ return refused("persist-failed");
209
273
  }
274
+ // Already free, or a state releaseBail refuses — leave the ledger and
275
+ // still answer interrupted.
210
276
  return undefined;
211
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
+ }
212
292
  async function reconcile(project) {
213
293
  const declared = await ledger.listDeclaredWorkspaces();
214
294
  const allowed = new Set();
215
295
  for (const row of declared) {
216
- if (row.feature !== undefined) {
217
- allowed.add(resolve(row.feature));
218
- }
219
- if (row.subtask !== undefined) {
220
- allowed.add(resolve(row.subtask));
221
- }
296
+ allowed.add(resolve(row.path));
222
297
  }
223
298
  await destroyOrphans(workspaceRoot, allowed);
224
299
  for (const row of declared) {
@@ -234,61 +309,56 @@ export function openConductor(options) {
234
309
  if (!got.ok) {
235
310
  return;
236
311
  }
237
- const feature = got.aggregate.workspaces?.feature;
238
- const subtask = got.aggregate.workspaces?.subtask;
239
- if (subtask !== undefined) {
240
- await destroy(subtask);
241
- }
242
- if (feature !== undefined) {
243
- await destroy(feature);
312
+ if (got.aggregate.workspace !== undefined) {
313
+ await destroy(got.aggregate.workspace);
244
314
  }
245
- await ledger.clearWorkspace({ key, feature: true, subtask: true });
315
+ await ledger.clearWorkspace(key);
246
316
  }
247
- async function ensureFeatureIsolated(key) {
317
+ /**
318
+ * Make the Ticket's workspace from nothing: a unit that starts, starts from
319
+ * zero, so what an earlier pass left is destroyed first. No lease is held
320
+ * yet: a failure here costs no Attempt, and the next pass makes it again.
321
+ */
322
+ async function isolateFresh(key) {
248
323
  const got = await ledger.get(key);
249
324
  if (!got.ok) {
250
325
  return refused("not-found");
251
326
  }
252
- const child = declaredFeaturePath(workspaceRoot, key, got.aggregate);
253
- if (got.aggregate.workspaces?.feature === undefined) {
254
- const declared = await ledger.declareWorkspace({ key, feature: child });
255
- const failure = fromLedger(declared);
256
- if (failure !== undefined) {
257
- return failure;
258
- }
327
+ const workspace = declaredTicketPath(workspaceRoot, key, got.aggregate);
328
+ if (existsSync(workspace)) {
329
+ await destroy(workspace);
259
330
  }
260
- let fresh = false;
261
- if (!existsSync(child)) {
262
- if (got.aggregate.submission !== undefined) {
263
- // The Authority is holding a Submission built from a workspace that is no
264
- // longer here. Isolating again would rebuild the feature from the work
265
- // line and publish it over that Submission, which would drop whatever the
266
- // Authority has already been shown. That is a human's call, not this
267
- // loop's.
268
- const escalated = await freeze(key, { kind: "submitted" });
269
- return escalated;
331
+ const declared = await ledger.declareWorkspace({ key, path: workspace });
332
+ const declareFail = fromLedger(declared);
333
+ if (declareFail !== undefined) {
334
+ return declareFail;
335
+ }
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;
270
341
  }
271
- const isolated = await transformers.isolate({
342
+ return await transformers.isolate({
272
343
  id: key,
273
344
  context: key,
274
345
  parent: workLineStable,
275
- child,
346
+ child: workspace,
276
347
  durationMs,
277
348
  });
278
- if (isolated.outcome === "interrupted") {
279
- return refused("interrupted");
280
- }
281
- if (isolated.outcome !== "isolated") {
282
- return refused(isolated.outcome === "invalid-invocation" ? "transformer-invalid" : "isolate-failed");
283
- }
284
- fresh = true;
285
- warmed.delete(key);
349
+ });
350
+ if (cut === undefined) {
351
+ return refused("line-refresh-failed");
286
352
  }
287
- if (warm !== undefined && (fresh || !warmed.has(key))) {
288
- const prepared = await warm.prepare({
289
- workspace: child,
290
- durationMs: warm.durationMs,
291
- });
353
+ const isolated = cut;
354
+ if (isolated.outcome === "interrupted") {
355
+ return refused("interrupted");
356
+ }
357
+ if (isolated.outcome !== "isolated") {
358
+ return refused(isolated.outcome === "invalid-invocation" ? "transformer-invalid" : "isolate-failed");
359
+ }
360
+ if (warm !== undefined) {
361
+ const prepared = await warm.prepare({ workspace, durationMs: warm.durationMs });
292
362
  if (prepared.outcome === "interrupted") {
293
363
  return refused("interrupted");
294
364
  }
@@ -298,120 +368,146 @@ export function openConductor(options) {
298
368
  if (prepared.outcome !== "warmed") {
299
369
  return refused("warm-failed");
300
370
  }
301
- warmed.add(key);
302
371
  }
303
372
  return undefined;
304
373
  }
305
- async function doPlan(key) {
306
- const got = await ledger.get(key);
307
- if (!got.ok) {
308
- return refused("not-found");
309
- }
310
- if (got.aggregate.state !== "planning") {
311
- return undefined;
312
- }
313
- const broken = await transformers.breakDown({
314
- featureJson: featureJsonOf(got.aggregate.intention),
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,
315
396
  });
316
- if (broken.outcome === "refused") {
317
- const recorded = await ledger.refusePlan({
318
- key,
319
- code: broken.code,
320
- reason: broken.reason,
321
- });
322
- const failure = fromLedger(recorded);
323
- if (failure !== undefined) {
324
- return failure;
325
- }
326
- return { outcome: "escalated", key };
327
- }
328
- if (broken.outcome === "interrupted") {
329
- return refused("interrupted");
330
- }
331
- if (broken.outcome === "unavailable") {
332
- return refused("unavailable");
397
+ if (written.outcome === "interrupted") {
398
+ const failure = await releaseHeldAfterInterrupt(key);
399
+ return failure ?? refused("interrupted");
333
400
  }
334
- if (broken.outcome !== "planned") {
401
+ if (written.outcome === "invalid-invocation") {
335
402
  return refused("transformer-invalid");
336
403
  }
337
- const recorded = await ledger.recordPlan({
338
- key,
339
- plannedAt: broken.plannedAt,
340
- subtasks: broken.subtasks,
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)}`,
341
418
  });
342
- const recordFail = fromLedger(recorded);
343
- if (recordFail !== undefined) {
344
- return recordFail;
419
+ if (checkpoint.outcome === "interrupted") {
420
+ const failure = await releaseHeldAfterInterrupt(key);
421
+ return failure ?? refused("interrupted");
345
422
  }
346
- await observe({ kind: "planned", key });
347
- return undefined;
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);
348
432
  }
349
- async function runOneSubtask(key, subtaskId) {
433
+ /**
434
+ * Run the unit: Builder, then the Gates, in the workspace Isolation made.
435
+ * The result stays loose in the workspace; the Integration in `doIntegrating`
436
+ * is what takes it up.
437
+ */
438
+ async function doRunning(key) {
439
+ if (paused) {
440
+ return { outcome: "paused", key };
441
+ }
350
442
  const before = await ledger.get(key);
351
- const started = await ledger.startSubtask({ key, subtaskId });
352
- const startFail = fromLedger(started);
353
- if (startFail !== undefined) {
354
- return startFail;
443
+ if (!before.ok) {
444
+ return refused("not-found");
355
445
  }
356
- const child = subtaskWorkspacePath(workspaceRoot, key, subtaskId);
357
- // Resume from zero: Isolator refuses an existing Child. A kept one may sit
358
- // where an earlier layout put it; it goes too, or its branch stays taken.
359
- const kept = before.ok ? before.aggregate.workspaces?.subtask : undefined;
360
- for (const path of new Set([child, ...(kept === undefined ? [] : [kept])])) {
361
- if (existsSync(path)) {
362
- await destroy(path);
363
- }
446
+ if (before.aggregate.state !== "running") {
447
+ return undefined;
364
448
  }
365
- const declared = await ledger.declareWorkspace({ key, subtask: child });
366
- const declareFail = fromLedger(declared);
367
- if (declareFail !== undefined) {
368
- return declareFail;
449
+ if (before.aggregate.bail !== undefined) {
450
+ // A unit is held: its lease runs out on its own if nobody is on it.
451
+ return { outcome: "paused", key };
369
452
  }
370
- const featureNow = await ledger.get(key);
371
- const parent = declaredFeaturePath(workspaceRoot, key, featureNow.ok ? featureNow.aggregate : undefined);
372
- const isolated = await transformers.isolate({
373
- // Scoped to the feature: a Subtask id is unique inside its plan and
374
- // nowhere else, so "A" alone would name every feature's first Subtask.
375
- id: `${key}:${subtaskId}`,
376
- context: key,
377
- parent,
378
- child,
379
- durationMs,
380
- });
381
- if (isolated.outcome === "interrupted") {
382
- const failure = await releaseHeldAfterInterrupt(key);
383
- return failure ?? refused("interrupted");
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
+ }
384
461
  }
385
- if (isolated.outcome !== "isolated") {
386
- return refused(isolated.outcome === "invalid-invocation" ? "transformer-invalid" : "isolate-failed");
462
+ if (paused) {
463
+ return { outcome: "paused", key };
464
+ }
465
+ const started = await ledger.startUnit(key);
466
+ const startFail = fromLedger(started);
467
+ if (startFail !== undefined) {
468
+ return startFail;
387
469
  }
388
470
  const got = await ledger.get(key);
389
471
  if (!got.ok) {
390
472
  return refused("not-found");
391
473
  }
392
- const subtask = got.aggregate.plan?.subtasks.find((st) => st.id === subtaskId);
393
- if (subtask === undefined) {
394
- return refused("not-found");
395
- }
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);
396
491
  const implemented = await transformers.implement({
397
- id: subtaskId,
492
+ id: key,
398
493
  context: key,
399
- intention: subtask.intention,
400
- definitionOfDone: subtask.definition_of_done,
401
- workspace: child,
494
+ intention: got.aggregate.intention.intention,
495
+ workspace: unitPath,
496
+ ...(report === undefined ? {} : { report }),
497
+ ...(carried?.refusedBy === undefined ? {} : { reportFrom: carried.refusedBy }),
498
+ ...(earlier.length === 0 ? {} : { firstAttempt: earlier.length + 1 }),
402
499
  });
403
500
  if (implemented.outcome === "interrupted") {
404
- const failure = await releaseHeldAfterInterrupt(key);
501
+ const failure = await releaseHeldAfterInterrupt(key, implemented.traces);
405
502
  return failure ?? refused("interrupted");
406
503
  }
407
504
  if (implemented.outcome === "invalid-invocation") {
408
505
  return refused("transformer-invalid");
409
506
  }
410
- let number = got.aggregate.attempts.filter((a) => a.subtask_id === subtaskId).length + 1;
507
+ let number = got.aggregate.attempts.length + 1;
411
508
  for (const trace of implemented.traces) {
412
509
  const recorded = await ledger.recordAttempt({
413
510
  key,
414
- subtaskId,
415
511
  number,
416
512
  trace: {
417
513
  ended: trace.ended,
@@ -430,77 +526,17 @@ export function openConductor(options) {
430
526
  if (implemented.outcome === "escalated") {
431
527
  const why = reasonOfTraces(implemented.traces);
432
528
  return await freeze(key, {
433
- kind: "subtask",
434
- subtaskId,
529
+ kind: "unit",
435
530
  ...(why === undefined ? {} : { report: why }),
436
531
  });
437
532
  }
438
- const folded = await transformers.integrate({
439
- id: subtaskId,
440
- context: key,
441
- parent,
442
- child,
443
- durationMs,
444
- // What carries the work into the feature says what the work was.
445
- subject: foldMessageOf(subtask.intention),
446
- mergeSubject: `Merge ${subtaskId} into the feature`,
447
- });
448
- if (folded.outcome === "interrupted") {
449
- const failure = await releaseHeldAfterInterrupt(key);
450
- return failure ?? refused("interrupted");
451
- }
452
- if (folded.outcome === "conflict") {
453
- // The unit passed its Gates; what stopped it is the fold, which leaves
454
- // no trace of its own. Said here, or the escalation says nothing.
455
- return await freeze(key, {
456
- kind: "subtask",
457
- subtaskId,
458
- report: `Folding ${subtaskId} into the feature hit a conflict.`,
459
- });
460
- }
461
- if (folded.outcome !== "integrated") {
462
- return refused("transformer-invalid");
463
- }
464
- const marked = await ledger.markSubtaskIntegrated({ key, subtaskId });
533
+ const marked = await ledger.markIntegrating(key);
465
534
  const markFail = fromLedger(marked);
466
535
  if (markFail !== undefined) {
467
536
  return markFail;
468
537
  }
469
- await destroy(child);
470
- await ledger.clearWorkspace({ key, subtask: true });
471
- const after = await ledger.get(key);
472
- if (after.ok && after.aggregate.plan !== undefined) {
473
- const subtasks = after.aggregate.plan.subtasks;
474
- await observe({
475
- kind: "subtask-integrated",
476
- key,
477
- subtaskId,
478
- integrated: subtasks.filter((subtask) => subtask.state === "integrated").length,
479
- total: subtasks.length,
480
- });
481
- }
482
538
  return await freezeIfPending(key);
483
539
  }
484
- async function doRunning(key) {
485
- const isolated = await ensureFeatureIsolated(key);
486
- if (isolated !== undefined) {
487
- return isolated;
488
- }
489
- while (!paused) {
490
- const next = await ledger.nextRunnable(key);
491
- if (next === undefined) {
492
- break;
493
- }
494
- const step = await runOneSubtask(key, next);
495
- if (step !== undefined) {
496
- return step;
497
- }
498
- }
499
- if (paused) {
500
- return { outcome: "paused", key };
501
- }
502
- return undefined;
503
- }
504
540
  async function doIntegrating(key) {
505
541
  if (paused) {
506
542
  return { outcome: "paused", key };
@@ -513,122 +549,16 @@ export function openConductor(options) {
513
549
  if (!got.ok) {
514
550
  return refused("not-found");
515
551
  }
516
- const featurePath = declaredFeaturePath(workspaceRoot, key, got.aggregate);
517
- // A parked refusal is this round's; a Submission's is carried forward on
518
- // purpose and may already be repaired. The fresher one wins.
519
- const parked = got.aggregate.parked_refusal;
520
- const sentBack = parked?.report ?? got.aggregate.submission?.last_report;
521
- if (sentBack !== undefined) {
522
- // Something refused the last round and said why. That report is the only
523
- // reason this stage has to produce: the Subtasks are already validated.
524
- //
525
- // Before the Integration below, and that order is the whole point. What a
526
- // producer leaves is loose in the workspace; the Integration is the only
527
- // thing that takes it up. Producing after it means the repair waits for
528
- // the next round to be taken up at all, and what goes out meanwhile is the
529
- // state that was already refused — the same refusal comes back, and the
530
- // budget empties on the delay.
531
- const refusedBy = parked?.refused_by ?? got.aggregate.submission?.last_refused_by;
532
- const remade = await transformers.implement({
533
- id: `${key}:assembly`,
534
- context: key,
535
- stage: "assembly",
536
- produce: true,
537
- intention: got.aggregate.intention.intention,
538
- workspace: featurePath,
539
- report: sentBack,
540
- ...(refusedBy === undefined ? {} : { reportFrom: refusedBy }),
541
- });
542
- if (remade.outcome === "interrupted") {
543
- return refused("interrupted");
544
- }
545
- if (remade.outcome === "invalid-invocation") {
546
- return refused("transformer-invalid");
547
- }
548
- if (remade.outcome === "escalated") {
549
- // The whole, not one of its parts: naming a Subtask here sends a reader
550
- // to look at work that did not fail.
551
- const why = reasonOfTraces(remade.traces);
552
- return await freeze(key, {
553
- kind: "assembly",
554
- ...(why === undefined ? {} : { report: why }),
555
- });
556
- }
557
- }
558
- const aligned = await transformers.integrate({
559
- id: `${key}:align`,
560
- context: key,
561
- parent: featurePath,
562
- child: workLineStable,
563
- durationMs,
564
- // What is loose in the feature workspace here is a repair, if anything.
565
- subject: sentBack === undefined
566
- ? `Align with ${workLineTarget || "the work line"}`
567
- : "Repair after it was refused",
568
- mergeSubject: `Merge ${workLineTarget || "the work line"} into the feature`,
569
- });
570
- if (aligned.outcome === "interrupted") {
571
- return refused("interrupted");
572
- }
573
- if (aligned.outcome === "conflict") {
574
- return refused("align-conflict");
575
- }
576
- if (aligned.outcome !== "integrated") {
577
- return refused("transformer-invalid");
578
- }
579
- if (paused) {
580
- return { outcome: "paused", key };
581
- }
582
- if (assemblyValidate) {
583
- const validated = await transformers.implement({
584
- id: `${key}:validate`,
585
- context: key,
586
- stage: "assembly",
587
- validate: true,
588
- intention: got.aggregate.intention.intention,
589
- workspace: featurePath,
590
- });
591
- if (validated.outcome === "interrupted") {
592
- return refused("interrupted");
593
- }
594
- if (validated.outcome === "invalid-invocation") {
595
- return refused("transformer-invalid");
596
- }
597
- if (validated.outcome === "escalated") {
598
- const last = validated.traces.at(-1);
599
- const report = last?.report ?? "The assembled feature was refused, with no reason given.";
600
- const spent = (got.aggregate.submission?.refusals ?? 0) + (got.aggregate.parked_refusals ?? 0);
601
- const canRepair = assemblyFixDeclared && last?.ended !== "fail-blocking" && spent + 1 < maxRefusals;
602
- if (!canRepair) {
603
- return await freeze(key, {
604
- kind: "assembly",
605
- report: last?.refusedBy === undefined ? report : `${last.refusedBy}: ${report}`,
606
- });
607
- }
608
- const recorded = await ledger.recordParkedRefusal({
609
- key,
610
- report,
611
- ...(last?.refusedBy === undefined ? {} : { refusedBy: last.refusedBy }),
612
- });
613
- const failure = fromLedger(recorded);
614
- if (failure !== undefined) {
615
- return failure;
616
- }
617
- // Repair is on its way: do not offer, or judge, work that is already
618
- // known to need it.
619
- return { outcome: "paused", key };
620
- }
621
- const cleared = await ledger.clearParkedRefusal(key);
622
- const clearFail = fromLedger(cleared);
623
- if (clearFail !== undefined) {
624
- return clearFail;
625
- }
552
+ const unit = unitOf(key, got.aggregate);
553
+ const prepared = await delivery.prepare(unit, ticketStore(key));
554
+ if (prepared !== "ready") {
555
+ return prepared;
626
556
  }
627
557
  if (authority === undefined) {
628
558
  // Nobody outside will look at it, so the Project's own sequence is the
629
- // only judgement the assembled feature gets. Skipping it here would let
559
+ // only judgement the assembled ticket gets. Skipping it here would let
630
560
  // work into the work line that nothing ever judged as a whole.
631
- const judged = await judge(key, got.aggregate);
561
+ const judged = await delivery.judge(unit);
632
562
  if (judged.outcome === "interrupted") {
633
563
  return refused("interrupted");
634
564
  }
@@ -648,9 +578,9 @@ export function openConductor(options) {
648
578
  return await offer(key, got.aggregate);
649
579
  }
650
580
  /**
651
- * Put the assembled feature in front of the Authority.
581
+ * Put the assembled ticket in front of the Authority.
652
582
  *
653
- * A feature already submitted is offered again rather than a second time:
583
+ * A ticket already submitted is offered again rather than a second time:
654
584
  * the work was published to the same place, so the Authority is judging the
655
585
  * same Submission with new content.
656
586
  */
@@ -660,16 +590,14 @@ export function openConductor(options) {
660
590
  }
661
591
  const title = aggregate.intention.title;
662
592
  const intention = aggregate.intention.intention;
663
- const steps = aggregate.plan?.subtasks.map((subtask) => subtask.intention) ?? [];
664
593
  const submitted = await authority.submit({
665
594
  key,
666
595
  project: aggregate.intention.project,
667
596
  ref: key,
668
- target: workLineTarget,
669
- workspace: declaredFeaturePath(workspaceRoot, key, aggregate),
597
+ target: targetOf(aggregate),
598
+ workspace: declaredTicketPath(workspaceRoot, key, aggregate),
670
599
  ...(title === undefined ? {} : { title }),
671
600
  ...(intention === undefined ? {} : { intention }),
672
- ...(steps.length === 0 ? {} : { steps }),
673
601
  });
674
602
  if (submitted.outcome === "unavailable") {
675
603
  // Nothing is lost: the state still says integrating, and the next pass
@@ -687,72 +615,15 @@ export function openConductor(options) {
687
615
  await observe({ kind: "submitted", key, reference: submitted.reference });
688
616
  return { outcome: "paused", key };
689
617
  }
690
- /**
691
- * Judge the assembled feature with the Project's Gate sequence for it.
692
- *
693
- * Nothing is produced: the work is done, and this pass only decides whether it
694
- * may enter the work line. A Gate that reads an outside answer belongs in that
695
- * sequence like any other — it says ok or not, and it does not act.
696
- */
697
- async function judge(key, aggregate) {
698
- return await transformers.implement({
699
- id: `${key}:judgement`,
700
- context: key,
701
- stage: "assembly",
702
- produce: false,
703
- intention: aggregate.intention.intention,
704
- workspace: declaredFeaturePath(workspaceRoot, key, aggregate),
705
- });
706
- }
707
618
  async function doSubmitted(key) {
708
619
  const got = await ledger.get(key);
709
620
  if (!got.ok) {
710
621
  return refused("not-found");
711
622
  }
712
- const submission = got.aggregate.submission;
713
- if (authority === undefined || submission === undefined) {
714
- return refused("transformer-invalid");
715
- }
716
- const featurePath = declaredFeaturePath(workspaceRoot, key, got.aggregate);
717
- const judged = await judge(key, got.aggregate);
718
- if (judged.outcome === "interrupted") {
719
- return refused("interrupted");
720
- }
721
- if (judged.outcome === "invalid-invocation") {
722
- return refused("transformer-invalid");
723
- }
724
- if (judged.outcome === "escalated") {
725
- const last = judged.traces.at(-1);
726
- const report = last?.report ?? "The Submission was refused, with no reason given.";
727
- // A blocking refusal is not something another round repairs, and neither
728
- // is one more refusal than the budget allows.
729
- if (last?.ended === "fail-blocking" || submission.refusals + 1 >= maxRefusals) {
730
- // The refusal that stopped it is never recorded as one — this is the
731
- // only place it can be kept for the person who reads the escalation.
732
- return await freeze(key, {
733
- kind: "submitted",
734
- report: last?.refusedBy === undefined ? report : `${last.refusedBy}: ${report}`,
735
- });
736
- }
737
- const recorded = await ledger.recordRefusal({
738
- key,
739
- report,
740
- ...(last?.refusedBy === undefined ? {} : { refusedBy: last.refusedBy }),
741
- });
742
- const failure = fromLedger(recorded);
743
- if (failure !== undefined) {
744
- return failure;
745
- }
746
- // Back to where the work is made. The next pass produces with that report
747
- // in hand and publishes to the same place.
748
- return { outcome: "paused", key };
749
- }
750
- const folded = await authority.fold({ reference: submission.reference });
751
- if (folded.outcome === "unavailable") {
752
- return { outcome: "paused", key };
753
- }
754
- if (folded.outcome === "conflict") {
755
- return await freeze(key, { kind: "submitted" });
623
+ const ticketPath = declaredTicketPath(workspaceRoot, key, got.aggregate);
624
+ const settled = await delivery.settle(unitOf(key, got.aggregate), ticketStore(key));
625
+ if (!("folded" in settled)) {
626
+ return settled;
756
627
  }
757
628
  // The Authority folded it. The work line moved; this system's copy has not,
758
629
  // and whoever wired the Authority is the one that brings it up to date.
@@ -761,30 +632,30 @@ export function openConductor(options) {
761
632
  if (mergingFailure !== undefined) {
762
633
  return mergingFailure;
763
634
  }
764
- const done = await ledger.markDone(key, { reference: folded.reference });
635
+ const done = await ledger.markDone(key, { reference: settled.reference });
765
636
  const doneFailure = fromLedger(done);
766
637
  if (doneFailure !== undefined) {
767
638
  return doneFailure;
768
639
  }
769
- await destroy(featurePath);
770
- await ledger.clearWorkspace({ key, feature: true, subtask: true });
640
+ await destroy(ticketPath);
641
+ await ledger.clearWorkspace(key);
771
642
  return { outcome: "done", key };
772
643
  }
773
644
  async function doMerging(key) {
774
645
  const got = await ledger.get(key);
775
- const featurePath = declaredFeaturePath(workspaceRoot, key, got.ok ? got.aggregate : undefined);
646
+ const ticketPath = declaredTicketPath(workspaceRoot, key, got.ok ? got.aggregate : undefined);
776
647
  const title = got.ok
777
648
  ? (got.aggregate.intention.title ?? got.aggregate.intention.intention)
778
649
  : key;
779
- const folded = await transformers.integrate({
650
+ const folded = await onLine(async () => await transformers.integrate({
780
651
  id: key,
781
652
  context: key,
782
653
  parent: workLineStable,
783
- child: featurePath,
654
+ child: ticketPath,
784
655
  durationMs,
785
656
  subject: foldMessageOf(title),
786
657
  mergeSubject: `Merge ${subjectOf(title)}`,
787
- });
658
+ }));
788
659
  if (folded.outcome === "interrupted") {
789
660
  return refused("interrupted");
790
661
  }
@@ -799,15 +670,30 @@ export function openConductor(options) {
799
670
  if (failure !== undefined) {
800
671
  return failure;
801
672
  }
802
- await destroy(featurePath);
803
- await ledger.clearWorkspace({ key, feature: true, subtask: true });
673
+ await destroy(ticketPath);
674
+ await ledger.clearWorkspace(key);
804
675
  return { outcome: "done", key };
805
676
  }
806
- async function drive(key) {
807
- const planResult = await doPlan(key);
808
- if (planResult !== undefined) {
809
- return planResult;
677
+ /**
678
+ * The assembled work is in the workspace and nowhere else. When it is gone,
679
+ * making it again from the work line would drop what was made, and when the
680
+ * Authority holds a Submission built from it, publish over what it was
681
+ * shown. Either is a human's call, not this loop's.
682
+ */
683
+ async function workspaceGone(key) {
684
+ const got = await ledger.get(key);
685
+ if (!got.ok) {
686
+ return refused("not-found");
687
+ }
688
+ if (existsSync(declaredTicketPath(workspaceRoot, key, got.aggregate))) {
689
+ return undefined;
810
690
  }
691
+ return await freeze(key, {
692
+ kind: got.aggregate.submission === undefined ? "assembly" : "submitted",
693
+ report: "The workspace the work was assembled in is gone.",
694
+ });
695
+ }
696
+ async function drive(key) {
811
697
  const runningResult = await doRunning(key);
812
698
  if (runningResult !== undefined) {
813
699
  return runningResult;
@@ -822,6 +708,13 @@ export function openConductor(options) {
822
708
  if (got.aggregate.state === "running") {
823
709
  return { outcome: "paused", key };
824
710
  }
711
+ if (got.aggregate.state === "done") {
712
+ return { outcome: "done", key };
713
+ }
714
+ const gone = await workspaceGone(key);
715
+ if (gone !== undefined) {
716
+ return gone;
717
+ }
825
718
  if (got.aggregate.state === "integrating") {
826
719
  const integratingResult = await doIntegrating(key);
827
720
  if (integratingResult !== undefined) {
@@ -868,7 +761,7 @@ export function openConductor(options) {
868
761
  if (paused) {
869
762
  return { outcome: "paused" };
870
763
  }
871
- const claimed = await ledger.claim(project);
764
+ const claimed = await ledger.claim(project, maxParallel);
872
765
  if (!claimed.ok) {
873
766
  const failure = fromLedger(claimed);
874
767
  return failure ?? refused("illegal-transition");
@@ -878,6 +771,19 @@ export function openConductor(options) {
878
771
  }
879
772
  return await drive(claimed.key);
880
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
+ },
881
787
  async cancel(key) {
882
788
  const cancelled = await ledger.cancel(key);
883
789
  if (cancelled.ok) {
@@ -887,10 +793,12 @@ export function openConductor(options) {
887
793
  },
888
794
  async release(key) {
889
795
  const before = await ledger.get(key);
890
- const child = before.ok ? before.aggregate.workspaces?.subtask : undefined;
796
+ const child = before.ok ? before.aggregate.workspace : undefined;
891
797
  const released = await ledger.releaseBail(key);
892
798
  if (released.ok && child !== undefined) {
799
+ // The way to start a unit over: what a stopped pass left is dropped, not picked up.
893
800
  await destroy(child);
801
+ await ledger.clearWorkspace(key);
894
802
  }
895
803
  return released;
896
804
  },