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