@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.
- package/README.md +3 -3
- package/SPECS.md +179 -163
- 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 +114 -0
- package/dist/run/delivery.d.ts.map +1 -0
- package/dist/run/delivery.js +278 -0
- package/dist/run/delivery.js.map +1 -0
- package/dist/run/open-conductor.d.ts +63 -51
- package/dist/run/open-conductor.d.ts.map +1 -1
- package/dist/run/open-conductor.js +378 -470
- package/dist/run/open-conductor.js.map +1 -1
- package/dist/run/paths.d.ts +1 -2
- package/dist/run/paths.d.ts.map +1 -1
- package/dist/run/paths.js +2 -5
- package/dist/run/paths.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 +58 -46
- package/dist/transformers/port.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -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 {
|
|
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
|
|
30
|
-
* made, else the one the
|
|
31
|
-
*
|
|
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
|
|
35
|
-
return aggregate?.
|
|
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
|
|
118
|
-
const
|
|
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
|
-
|
|
121
|
-
const
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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(
|
|
315
|
+
await ledger.clearWorkspace(key);
|
|
246
316
|
}
|
|
247
|
-
|
|
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
|
|
253
|
-
if (
|
|
254
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 (
|
|
317
|
-
const
|
|
318
|
-
|
|
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 (
|
|
401
|
+
if (written.outcome === "invalid-invocation") {
|
|
335
402
|
return refused("transformer-invalid");
|
|
336
403
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
return
|
|
419
|
+
if (checkpoint.outcome === "interrupted") {
|
|
420
|
+
const failure = await releaseHeldAfterInterrupt(key);
|
|
421
|
+
return failure ?? refused("interrupted");
|
|
345
422
|
}
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
if (startFail !== undefined) {
|
|
354
|
-
return startFail;
|
|
443
|
+
if (!before.ok) {
|
|
444
|
+
return refused("not-found");
|
|
355
445
|
}
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 (
|
|
386
|
-
return
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
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:
|
|
492
|
+
id: key,
|
|
398
493
|
context: key,
|
|
399
|
-
intention:
|
|
400
|
-
|
|
401
|
-
|
|
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.
|
|
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: "
|
|
434
|
-
subtaskId,
|
|
529
|
+
kind: "unit",
|
|
435
530
|
...(why === undefined ? {} : { report: why }),
|
|
436
531
|
});
|
|
437
532
|
}
|
|
438
|
-
const
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
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(
|
|
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
|
|
581
|
+
* Put the assembled ticket in front of the Authority.
|
|
652
582
|
*
|
|
653
|
-
* A
|
|
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:
|
|
669
|
-
workspace:
|
|
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
|
|
713
|
-
|
|
714
|
-
|
|
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:
|
|
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(
|
|
770
|
-
await ledger.clearWorkspace(
|
|
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
|
|
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:
|
|
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(
|
|
803
|
-
await ledger.clearWorkspace(
|
|
673
|
+
await destroy(ticketPath);
|
|
674
|
+
await ledger.clearWorkspace(key);
|
|
804
675
|
return { outcome: "done", key };
|
|
805
676
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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.
|
|
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
|
},
|