@bluewombat/conductor 0.7.0 → 0.9.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,9 @@
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 { ticketWorkspacePath } from "./paths.js";
5
5
  const DEFAULT_MAX_REFUSALS = 3;
6
6
  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
7
  async function isDirectory(path) {
18
8
  try {
19
9
  return (await stat(path)).isDirectory();
@@ -26,13 +16,12 @@ async function destroy(path) {
26
16
  await rm(path, { recursive: true, force: true });
27
17
  }
28
18
  /**
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.
19
+ * Where a Ticket's workspace is: the path the ledger declared when it was
20
+ * made, else the one the layout gives. The declared path wins because a
21
+ * Ticket in flight keeps its work and its git branch where they were.
33
22
  */
34
- function declaredFeaturePath(workspaceRoot, key, aggregate) {
35
- return aggregate?.workspaces?.feature ?? featureWorkspacePath(workspaceRoot, key);
23
+ function declaredTicketPath(workspaceRoot, key, aggregate) {
24
+ return aggregate?.workspace ?? ticketWorkspacePath(workspaceRoot, key);
36
25
  }
37
26
  async function destroyOrphans(workspaceRoot, allowed) {
38
27
  if (!(await isDirectory(workspaceRoot))) {
@@ -113,12 +102,12 @@ export function openConductor(options) {
113
102
  const durationMs = options.transformerDurationMs ?? DEFAULT_TRANSFORMER_DURATION_MS;
114
103
  const authority = options.authority;
115
104
  const workLineTarget = options.workLineTarget ?? "";
105
+ /** The line a Ticket lands on: its own when it names one, else the Project's. */
106
+ const targetOf = (aggregate) => aggregate.intention.line ?? workLineTarget;
116
107
  const maxRefusals = options.maxRefusals ?? DEFAULT_MAX_REFUSALS;
117
108
  const assemblyValidate = options.assemblyValidate ?? false;
118
109
  const assemblyFixDeclared = options.assemblyFixDeclared ?? false;
119
110
  const warm = options.warm;
120
- /** Features whose warm already succeeded in this Conductor instance. */
121
- const warmed = new Set();
122
111
  let paused = false;
123
112
  const refused = (code) => ({
124
113
  outcome: "refused",
@@ -149,13 +138,13 @@ export function openConductor(options) {
149
138
  return refused("illegal-transition");
150
139
  };
151
140
  /**
152
- * Freeze the Feature for a human. If a newer fingerprint arrived in flight,
141
+ * Freeze the Ticket for a human. If a newer fingerprint arrived in flight,
153
142
  * it is named on the escalation and consumed by the ledger.
154
143
  */
155
144
  async function freeze(key, input) {
156
145
  const got = await ledger.get(key);
157
146
  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.`
147
+ ? `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
148
  : undefined;
160
149
  const report = [input.report, pending]
161
150
  .filter((part) => part !== undefined && part.length > 0)
@@ -163,7 +152,6 @@ export function openConductor(options) {
163
152
  const escalated = await ledger.escalate({
164
153
  key,
165
154
  kind: input.kind,
166
- ...(input.subtaskId === undefined ? {} : { subtaskId: input.subtaskId }),
167
155
  ...(report.length === 0 ? {} : { report }),
168
156
  });
169
157
  const failure = fromLedger(escalated);
@@ -177,24 +165,23 @@ export function openConductor(options) {
177
165
  if (!got.ok || got.aggregate.pending_fingerprint === undefined) {
178
166
  return undefined;
179
167
  }
180
- return await freeze(key, { kind: "plan" });
168
+ return await freeze(key, { kind: "unit" });
181
169
  }
182
170
  async function expireIfHeld(key) {
171
+ const before = await ledger.get(key);
172
+ const held = before.ok && before.aggregate.state === "running" ? before.aggregate.workspace : undefined;
183
173
  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
- }
174
+ if (expired.ok && held !== undefined) {
175
+ await destroy(held);
189
176
  }
190
177
  }
191
178
  /**
192
- * A Subtask was started and a transformer stopped on interrupt. Release the
179
+ * A unit was started and a transformer stopped on interrupt. Release the
193
180
  * bail now so the next process does not wait an hour for the clock.
194
181
  */
195
182
  async function releaseHeldAfterInterrupt(key) {
196
183
  const before = await ledger.get(key);
197
- const child = before.ok ? before.aggregate.workspaces?.subtask : undefined;
184
+ const child = before.ok ? before.aggregate.workspace : undefined;
198
185
  const released = await ledger.releaseBail(key);
199
186
  if (!released.ok) {
200
187
  if (released.code === "persist-failed") {
@@ -213,12 +200,7 @@ export function openConductor(options) {
213
200
  const declared = await ledger.listDeclaredWorkspaces();
214
201
  const allowed = new Set();
215
202
  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
- }
203
+ allowed.add(resolve(row.path));
222
204
  }
223
205
  await destroyOrphans(workspaceRoot, allowed);
224
206
  for (const row of declared) {
@@ -234,61 +216,45 @@ export function openConductor(options) {
234
216
  if (!got.ok) {
235
217
  return;
236
218
  }
237
- const feature = got.aggregate.workspaces?.feature;
238
- const subtask = got.aggregate.workspaces?.subtask;
239
- if (subtask !== undefined) {
240
- await destroy(subtask);
219
+ if (got.aggregate.workspace !== undefined) {
220
+ await destroy(got.aggregate.workspace);
241
221
  }
242
- if (feature !== undefined) {
243
- await destroy(feature);
244
- }
245
- await ledger.clearWorkspace({ key, feature: true, subtask: true });
222
+ await ledger.clearWorkspace(key);
246
223
  }
247
- async function ensureFeatureIsolated(key) {
224
+ /**
225
+ * Make the Ticket's workspace from nothing: a unit that starts, starts from
226
+ * zero, so what an earlier pass left is destroyed first. No lease is held
227
+ * yet: a failure here costs no Attempt, and the next pass makes it again.
228
+ */
229
+ async function isolateFresh(key) {
248
230
  const got = await ledger.get(key);
249
231
  if (!got.ok) {
250
232
  return refused("not-found");
251
233
  }
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
- }
234
+ const workspace = declaredTicketPath(workspaceRoot, key, got.aggregate);
235
+ if (existsSync(workspace)) {
236
+ await destroy(workspace);
259
237
  }
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;
270
- }
271
- const isolated = await transformers.isolate({
272
- id: key,
273
- context: key,
274
- parent: workLineStable,
275
- child,
276
- durationMs,
277
- });
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);
238
+ const declared = await ledger.declareWorkspace({ key, path: workspace });
239
+ const declareFail = fromLedger(declared);
240
+ if (declareFail !== undefined) {
241
+ return declareFail;
286
242
  }
287
- if (warm !== undefined && (fresh || !warmed.has(key))) {
288
- const prepared = await warm.prepare({
289
- workspace: child,
290
- durationMs: warm.durationMs,
291
- });
243
+ const isolated = await transformers.isolate({
244
+ id: key,
245
+ context: key,
246
+ parent: workLineStable,
247
+ child: workspace,
248
+ durationMs,
249
+ });
250
+ if (isolated.outcome === "interrupted") {
251
+ return refused("interrupted");
252
+ }
253
+ if (isolated.outcome !== "isolated") {
254
+ return refused(isolated.outcome === "invalid-invocation" ? "transformer-invalid" : "isolate-failed");
255
+ }
256
+ if (warm !== undefined) {
257
+ const prepared = await warm.prepare({ workspace, durationMs: warm.durationMs });
292
258
  if (prepared.outcome === "interrupted") {
293
259
  return refused("interrupted");
294
260
  }
@@ -298,107 +264,50 @@ export function openConductor(options) {
298
264
  if (prepared.outcome !== "warmed") {
299
265
  return refused("warm-failed");
300
266
  }
301
- warmed.add(key);
302
267
  }
303
268
  return undefined;
304
269
  }
305
- async function doPlan(key) {
306
- const got = await ledger.get(key);
307
- if (!got.ok) {
270
+ /**
271
+ * Run the unit: Builder, then the Gates, in the workspace Isolation made.
272
+ * The result stays loose in the workspace; the Integration in `doIntegrating`
273
+ * is what takes it up.
274
+ */
275
+ async function doRunning(key) {
276
+ if (paused) {
277
+ return { outcome: "paused", key };
278
+ }
279
+ const before = await ledger.get(key);
280
+ if (!before.ok) {
308
281
  return refused("not-found");
309
282
  }
310
- if (got.aggregate.state !== "planning") {
283
+ if (before.aggregate.state !== "running") {
311
284
  return undefined;
312
285
  }
313
- const broken = await transformers.breakDown({
314
- featureJson: featureJsonOf(got.aggregate.intention),
315
- });
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");
286
+ if (before.aggregate.bail !== undefined) {
287
+ // A unit is held: its lease runs out on its own if nobody is on it.
288
+ return { outcome: "paused", key };
333
289
  }
334
- if (broken.outcome !== "planned") {
335
- return refused("transformer-invalid");
290
+ const isolated = await isolateFresh(key);
291
+ if (isolated !== undefined) {
292
+ return isolated;
336
293
  }
337
- const recorded = await ledger.recordPlan({
338
- key,
339
- plannedAt: broken.plannedAt,
340
- subtasks: broken.subtasks,
341
- });
342
- const recordFail = fromLedger(recorded);
343
- if (recordFail !== undefined) {
344
- return recordFail;
294
+ if (paused) {
295
+ return { outcome: "paused", key };
345
296
  }
346
- await observe({ kind: "planned", key });
347
- return undefined;
348
- }
349
- async function runOneSubtask(key, subtaskId) {
350
- const before = await ledger.get(key);
351
- const started = await ledger.startSubtask({ key, subtaskId });
297
+ const started = await ledger.startUnit(key);
352
298
  const startFail = fromLedger(started);
353
299
  if (startFail !== undefined) {
354
300
  return startFail;
355
301
  }
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
- }
364
- }
365
- const declared = await ledger.declareWorkspace({ key, subtask: child });
366
- const declareFail = fromLedger(declared);
367
- if (declareFail !== undefined) {
368
- return declareFail;
369
- }
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");
384
- }
385
- if (isolated.outcome !== "isolated") {
386
- return refused(isolated.outcome === "invalid-invocation" ? "transformer-invalid" : "isolate-failed");
387
- }
388
302
  const got = await ledger.get(key);
389
303
  if (!got.ok) {
390
304
  return refused("not-found");
391
305
  }
392
- const subtask = got.aggregate.plan?.subtasks.find((st) => st.id === subtaskId);
393
- if (subtask === undefined) {
394
- return refused("not-found");
395
- }
396
306
  const implemented = await transformers.implement({
397
- id: subtaskId,
307
+ id: key,
398
308
  context: key,
399
- intention: subtask.intention,
400
- definitionOfDone: subtask.definition_of_done,
401
- workspace: child,
309
+ intention: got.aggregate.intention.intention,
310
+ workspace: declaredTicketPath(workspaceRoot, key, got.aggregate),
402
311
  });
403
312
  if (implemented.outcome === "interrupted") {
404
313
  const failure = await releaseHeldAfterInterrupt(key);
@@ -407,11 +316,10 @@ export function openConductor(options) {
407
316
  if (implemented.outcome === "invalid-invocation") {
408
317
  return refused("transformer-invalid");
409
318
  }
410
- let number = got.aggregate.attempts.filter((a) => a.subtask_id === subtaskId).length + 1;
319
+ let number = got.aggregate.attempts.length + 1;
411
320
  for (const trace of implemented.traces) {
412
321
  const recorded = await ledger.recordAttempt({
413
322
  key,
414
- subtaskId,
415
323
  number,
416
324
  trace: {
417
325
  ended: trace.ended,
@@ -430,77 +338,17 @@ export function openConductor(options) {
430
338
  if (implemented.outcome === "escalated") {
431
339
  const why = reasonOfTraces(implemented.traces);
432
340
  return await freeze(key, {
433
- kind: "subtask",
434
- subtaskId,
341
+ kind: "unit",
435
342
  ...(why === undefined ? {} : { report: why }),
436
343
  });
437
344
  }
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 });
345
+ const marked = await ledger.markIntegrating(key);
465
346
  const markFail = fromLedger(marked);
466
347
  if (markFail !== undefined) {
467
348
  return markFail;
468
349
  }
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
350
  return await freezeIfPending(key);
483
351
  }
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
352
  async function doIntegrating(key) {
505
353
  if (paused) {
506
354
  return { outcome: "paused", key };
@@ -513,14 +361,14 @@ export function openConductor(options) {
513
361
  if (!got.ok) {
514
362
  return refused("not-found");
515
363
  }
516
- const featurePath = declaredFeaturePath(workspaceRoot, key, got.aggregate);
364
+ const ticketPath = declaredTicketPath(workspaceRoot, key, got.aggregate);
517
365
  // A parked refusal is this round's; a Submission's is carried forward on
518
366
  // purpose and may already be repaired. The fresher one wins.
519
367
  const parked = got.aggregate.parked_refusal;
520
368
  const sentBack = parked?.report ?? got.aggregate.submission?.last_report;
521
369
  if (sentBack !== undefined) {
522
370
  // 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.
371
+ // reason this stage has to produce: the unit is already validated.
524
372
  //
525
373
  // Before the Integration below, and that order is the whole point. What a
526
374
  // producer leaves is loose in the workspace; the Integration is the only
@@ -535,7 +383,7 @@ export function openConductor(options) {
535
383
  stage: "assembly",
536
384
  produce: true,
537
385
  intention: got.aggregate.intention.intention,
538
- workspace: featurePath,
386
+ workspace: ticketPath,
539
387
  report: sentBack,
540
388
  ...(refusedBy === undefined ? {} : { reportFrom: refusedBy }),
541
389
  });
@@ -546,7 +394,7 @@ export function openConductor(options) {
546
394
  return refused("transformer-invalid");
547
395
  }
548
396
  if (remade.outcome === "escalated") {
549
- // The whole, not one of its parts: naming a Subtask here sends a reader
397
+ // The assembled work, not the unit: naming the unit here sends a reader
550
398
  // to look at work that did not fail.
551
399
  const why = reasonOfTraces(remade.traces);
552
400
  return await freeze(key, {
@@ -558,14 +406,15 @@ export function openConductor(options) {
558
406
  const aligned = await transformers.integrate({
559
407
  id: `${key}:align`,
560
408
  context: key,
561
- parent: featurePath,
409
+ parent: ticketPath,
562
410
  child: workLineStable,
563
411
  durationMs,
564
- // What is loose in the feature workspace here is a repair, if anything.
412
+ // What is loose in the workspace here is the work the Builder made, or a
413
+ // repair of it.
565
414
  subject: sentBack === undefined
566
- ? `Align with ${workLineTarget || "the work line"}`
415
+ ? foldMessageOf(got.aggregate.intention.title ?? got.aggregate.intention.intention)
567
416
  : "Repair after it was refused",
568
- mergeSubject: `Merge ${workLineTarget || "the work line"} into the feature`,
417
+ mergeSubject: `Merge ${targetOf(got.aggregate) || "the work line"} into the ticket`,
569
418
  });
570
419
  if (aligned.outcome === "interrupted") {
571
420
  return refused("interrupted");
@@ -586,7 +435,7 @@ export function openConductor(options) {
586
435
  stage: "assembly",
587
436
  validate: true,
588
437
  intention: got.aggregate.intention.intention,
589
- workspace: featurePath,
438
+ workspace: ticketPath,
590
439
  });
591
440
  if (validated.outcome === "interrupted") {
592
441
  return refused("interrupted");
@@ -596,7 +445,7 @@ export function openConductor(options) {
596
445
  }
597
446
  if (validated.outcome === "escalated") {
598
447
  const last = validated.traces.at(-1);
599
- const report = last?.report ?? "The assembled feature was refused, with no reason given.";
448
+ const report = last?.report ?? "The assembled ticket was refused, with no reason given.";
600
449
  const spent = (got.aggregate.submission?.refusals ?? 0) + (got.aggregate.parked_refusals ?? 0);
601
450
  const canRepair = assemblyFixDeclared && last?.ended !== "fail-blocking" && spent + 1 < maxRefusals;
602
451
  if (!canRepair) {
@@ -626,7 +475,7 @@ export function openConductor(options) {
626
475
  }
627
476
  if (authority === undefined) {
628
477
  // 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
478
+ // only judgement the assembled ticket gets. Skipping it here would let
630
479
  // work into the work line that nothing ever judged as a whole.
631
480
  const judged = await judge(key, got.aggregate);
632
481
  if (judged.outcome === "interrupted") {
@@ -648,9 +497,9 @@ export function openConductor(options) {
648
497
  return await offer(key, got.aggregate);
649
498
  }
650
499
  /**
651
- * Put the assembled feature in front of the Authority.
500
+ * Put the assembled ticket in front of the Authority.
652
501
  *
653
- * A feature already submitted is offered again rather than a second time:
502
+ * A ticket already submitted is offered again rather than a second time:
654
503
  * the work was published to the same place, so the Authority is judging the
655
504
  * same Submission with new content.
656
505
  */
@@ -660,16 +509,14 @@ export function openConductor(options) {
660
509
  }
661
510
  const title = aggregate.intention.title;
662
511
  const intention = aggregate.intention.intention;
663
- const steps = aggregate.plan?.subtasks.map((subtask) => subtask.intention) ?? [];
664
512
  const submitted = await authority.submit({
665
513
  key,
666
514
  project: aggregate.intention.project,
667
515
  ref: key,
668
- target: workLineTarget,
669
- workspace: declaredFeaturePath(workspaceRoot, key, aggregate),
516
+ target: targetOf(aggregate),
517
+ workspace: declaredTicketPath(workspaceRoot, key, aggregate),
670
518
  ...(title === undefined ? {} : { title }),
671
519
  ...(intention === undefined ? {} : { intention }),
672
- ...(steps.length === 0 ? {} : { steps }),
673
520
  });
674
521
  if (submitted.outcome === "unavailable") {
675
522
  // Nothing is lost: the state still says integrating, and the next pass
@@ -688,7 +535,7 @@ export function openConductor(options) {
688
535
  return { outcome: "paused", key };
689
536
  }
690
537
  /**
691
- * Judge the assembled feature with the Project's Gate sequence for it.
538
+ * Judge the assembled ticket with the Project's Gate sequence for it.
692
539
  *
693
540
  * Nothing is produced: the work is done, and this pass only decides whether it
694
541
  * may enter the work line. A Gate that reads an outside answer belongs in that
@@ -701,7 +548,7 @@ export function openConductor(options) {
701
548
  stage: "assembly",
702
549
  produce: false,
703
550
  intention: aggregate.intention.intention,
704
- workspace: declaredFeaturePath(workspaceRoot, key, aggregate),
551
+ workspace: declaredTicketPath(workspaceRoot, key, aggregate),
705
552
  });
706
553
  }
707
554
  async function doSubmitted(key) {
@@ -713,7 +560,7 @@ export function openConductor(options) {
713
560
  if (authority === undefined || submission === undefined) {
714
561
  return refused("transformer-invalid");
715
562
  }
716
- const featurePath = declaredFeaturePath(workspaceRoot, key, got.aggregate);
563
+ const ticketPath = declaredTicketPath(workspaceRoot, key, got.aggregate);
717
564
  const judged = await judge(key, got.aggregate);
718
565
  if (judged.outcome === "interrupted") {
719
566
  return refused("interrupted");
@@ -766,13 +613,13 @@ export function openConductor(options) {
766
613
  if (doneFailure !== undefined) {
767
614
  return doneFailure;
768
615
  }
769
- await destroy(featurePath);
770
- await ledger.clearWorkspace({ key, feature: true, subtask: true });
616
+ await destroy(ticketPath);
617
+ await ledger.clearWorkspace(key);
771
618
  return { outcome: "done", key };
772
619
  }
773
620
  async function doMerging(key) {
774
621
  const got = await ledger.get(key);
775
- const featurePath = declaredFeaturePath(workspaceRoot, key, got.ok ? got.aggregate : undefined);
622
+ const ticketPath = declaredTicketPath(workspaceRoot, key, got.ok ? got.aggregate : undefined);
776
623
  const title = got.ok
777
624
  ? (got.aggregate.intention.title ?? got.aggregate.intention.intention)
778
625
  : key;
@@ -780,7 +627,7 @@ export function openConductor(options) {
780
627
  id: key,
781
628
  context: key,
782
629
  parent: workLineStable,
783
- child: featurePath,
630
+ child: ticketPath,
784
631
  durationMs,
785
632
  subject: foldMessageOf(title),
786
633
  mergeSubject: `Merge ${subjectOf(title)}`,
@@ -799,15 +646,30 @@ export function openConductor(options) {
799
646
  if (failure !== undefined) {
800
647
  return failure;
801
648
  }
802
- await destroy(featurePath);
803
- await ledger.clearWorkspace({ key, feature: true, subtask: true });
649
+ await destroy(ticketPath);
650
+ await ledger.clearWorkspace(key);
804
651
  return { outcome: "done", key };
805
652
  }
806
- async function drive(key) {
807
- const planResult = await doPlan(key);
808
- if (planResult !== undefined) {
809
- return planResult;
653
+ /**
654
+ * The assembled work is in the workspace and nowhere else. When it is gone,
655
+ * making it again from the work line would drop what was made, and when the
656
+ * Authority holds a Submission built from it, publish over what it was
657
+ * shown. Either is a human's call, not this loop's.
658
+ */
659
+ async function workspaceGone(key) {
660
+ const got = await ledger.get(key);
661
+ if (!got.ok) {
662
+ return refused("not-found");
810
663
  }
664
+ if (existsSync(declaredTicketPath(workspaceRoot, key, got.aggregate))) {
665
+ return undefined;
666
+ }
667
+ return await freeze(key, {
668
+ kind: got.aggregate.submission === undefined ? "assembly" : "submitted",
669
+ report: "The workspace the work was assembled in is gone.",
670
+ });
671
+ }
672
+ async function drive(key) {
811
673
  const runningResult = await doRunning(key);
812
674
  if (runningResult !== undefined) {
813
675
  return runningResult;
@@ -822,6 +684,13 @@ export function openConductor(options) {
822
684
  if (got.aggregate.state === "running") {
823
685
  return { outcome: "paused", key };
824
686
  }
687
+ if (got.aggregate.state === "done") {
688
+ return { outcome: "done", key };
689
+ }
690
+ const gone = await workspaceGone(key);
691
+ if (gone !== undefined) {
692
+ return gone;
693
+ }
825
694
  if (got.aggregate.state === "integrating") {
826
695
  const integratingResult = await doIntegrating(key);
827
696
  if (integratingResult !== undefined) {
@@ -887,7 +756,7 @@ export function openConductor(options) {
887
756
  },
888
757
  async release(key) {
889
758
  const before = await ledger.get(key);
890
- const child = before.ok ? before.aggregate.workspaces?.subtask : undefined;
759
+ const child = before.ok ? before.aggregate.workspace : undefined;
891
760
  const released = await ledger.releaseBail(key);
892
761
  if (released.ok && child !== undefined) {
893
762
  await destroy(child);