@zq-silk/yui 0.5.2 → 0.6.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.
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Generic bounded RPC seam for Worker Threads (task-21, §3.1).
3
+ *
4
+ * The main thread talks to a worker over a `MessageChannel` port. This module
5
+ * provides the shared primitives used by every worker RPC in the control plane
6
+ * (the persistence worker, `storage/storeRpc`, and the resource inventory
7
+ * worker, `controller/resourceInventoryRpc`):
8
+ *
9
+ * - {@link BoundedSlotPool} ..... an async semaphore with a bounded waiter
10
+ * queue: bounds in-flight requests (default 64) and queue depth, applies
11
+ * backpressure (callers await; the socket keeps draining), so the main
12
+ * event loop is never blocked.
13
+ * - {@link BoundedRpcClient} .... the main-thread client: spawns the worker,
14
+ * completes the `ready` handshake, bounds in-flight requests, tracks
15
+ * pending requests, honours `AbortSignal` cancellation (posts a cancel
16
+ * notice, rejects promptly), and restarts the worker + replays
17
+ * unacknowledged requests on crash (the §3.1 fault boundary).
18
+ * - {@link runRpcWorker} ......... the worker-side host: owns the port
19
+ * handshake, dispatches requests (optionally through a FIFO queue),
20
+ * observes cancels, and serializes errors back across the thread boundary.
21
+ *
22
+ * Each worker supplies a small protocol adapter ({@link BoundedRpcProtocol} /
23
+ * {@link RpcWorkerHost}) describing its own request/response dialect; the
24
+ * backpressure, cancellation, and fault-boundary logic lives here once.
25
+ */
26
+ import { parentPort } from "node:worker_threads";
27
+ import { Worker, MessageChannel } from "node:worker_threads";
28
+ export function serializeError(error) {
29
+ if (error instanceof Error) {
30
+ return {
31
+ name: error.name,
32
+ message: error.message,
33
+ ...(error.stack === undefined ? {} : { stack: error.stack }),
34
+ ...("code" in error && typeof error.code === "string"
35
+ ? { code: error.code }
36
+ : {})
37
+ };
38
+ }
39
+ return { name: "Error", message: String(error) };
40
+ }
41
+ /**
42
+ * Default error deserializer. Worker RPCs with domain-specific error classes
43
+ * override this in their protocol's `settle` (the persistence client maps
44
+ * `Storage*Error` names back to their classes).
45
+ */
46
+ export function deserializeError(serialized) {
47
+ const error = new Error(serialized.message);
48
+ error.name = serialized.name;
49
+ return error;
50
+ }
51
+ // -- Bounded slot pool (backpressure) ----------------------------------------
52
+ /**
53
+ * An async semaphore with a bounded waiter queue (§3.1). `acquire` waits when
54
+ * all permits are in flight; when the waiter queue is full, callers wait on the
55
+ * backpressure condition instead. Callers always await (they already return
56
+ * promises), so the socket keeps accepting and draining — the main event loop
57
+ * is never blocked.
58
+ */
59
+ export class BoundedSlotPool {
60
+ #maxInFlight;
61
+ #permits;
62
+ #maxQueue;
63
+ #waiters = [];
64
+ #backpressure = [];
65
+ constructor(maxInFlight, maxQueue) {
66
+ if (!Number.isSafeInteger(maxInFlight) || maxInFlight < 1) {
67
+ throw new Error(`maxInFlight must be a positive integer: ${maxInFlight}`);
68
+ }
69
+ if (!Number.isSafeInteger(maxQueue) || maxQueue < 0) {
70
+ throw new Error(`maxQueue must be a non-negative integer: ${maxQueue}`);
71
+ }
72
+ this.#maxInFlight = maxInFlight;
73
+ this.#permits = maxInFlight;
74
+ this.#maxQueue = maxQueue;
75
+ }
76
+ async acquire() {
77
+ // Backpressure: the waiter queue is full. Wait for it to drain before
78
+ // queueing (the socket keeps draining; callers await without blocking).
79
+ while (this.#waiters.length >= this.#maxQueue) {
80
+ await new Promise((resolve) => this.#backpressure.push(resolve));
81
+ }
82
+ if (this.#permits > 0) {
83
+ this.#permits -= 1;
84
+ return;
85
+ }
86
+ await new Promise((resolve) => this.#waiters.push(resolve));
87
+ // A released permit was handed directly to this waiter.
88
+ }
89
+ release() {
90
+ const waiter = this.#waiters.shift();
91
+ if (waiter !== undefined) {
92
+ waiter();
93
+ return;
94
+ }
95
+ this.#permits += 1;
96
+ const drained = this.#backpressure.shift();
97
+ if (drained !== null && drained !== undefined)
98
+ drained();
99
+ }
100
+ /** Current queue depth (waiters), for tests/metrics. */
101
+ get queueDepth() {
102
+ return this.#waiters.length;
103
+ }
104
+ /** Currently in-flight permits, for tests/metrics. */
105
+ get inFlight() {
106
+ return this.#maxInFlight - this.#permits;
107
+ }
108
+ }
109
+ // -- Request ids --------------------------------------------------------------
110
+ let requestCounter = 0;
111
+ export function nextRequestId() {
112
+ requestCounter += 1;
113
+ return `rpc-${process.pid}-${Date.now().toString(36)}-${requestCounter.toString(36)}`;
114
+ }
115
+ /**
116
+ * The main-thread client for a worker RPC. Spawns the worker, completes the
117
+ * ready handshake, bounds in-flight requests with a {@link BoundedSlotPool},
118
+ * tracks pending requests, honours `AbortSignal` cancellation, and restarts
119
+ * the worker + replays unacknowledged requests on crash (§3.1 fault boundary).
120
+ */
121
+ export class BoundedRpcClient {
122
+ #protocol;
123
+ #workerScript;
124
+ #restartBackoffMs;
125
+ #slots;
126
+ #pending = new Map();
127
+ #worker;
128
+ #port;
129
+ #ready;
130
+ #readyResolve;
131
+ #readyReject;
132
+ #readyFired = false;
133
+ #closed = false;
134
+ #restarting = false;
135
+ #generation = 0;
136
+ constructor(protocol, options) {
137
+ this.#protocol = protocol;
138
+ this.#workerScript = options.workerScript;
139
+ this.#restartBackoffMs = options.restartBackoffMs ?? 10;
140
+ this.#slots = new BoundedSlotPool(options.maxInFlight ?? 64, options.maxQueue ?? 256);
141
+ this.#ready = this.#newReadyPromise();
142
+ this.#spawnWorker();
143
+ }
144
+ #newReadyPromise() {
145
+ this.#readyFired = false;
146
+ return new Promise((resolve, reject) => {
147
+ this.#readyResolve = resolve;
148
+ this.#readyReject = reject;
149
+ });
150
+ }
151
+ #workerUrl() {
152
+ return this.#workerScript instanceof URL
153
+ ? this.#workerScript
154
+ : new URL(this.#workerScript);
155
+ }
156
+ #spawnWorker() {
157
+ const generation = this.#generation;
158
+ const worker = new Worker(this.#workerUrl());
159
+ const channel = new MessageChannel();
160
+ worker.postMessage({ port: channel.port2 }, [channel.port2]);
161
+ const port = channel.port1;
162
+ this.#worker = worker;
163
+ this.#port = port;
164
+ port.on("message", (response) => {
165
+ if (this.#protocol.isReady(response)) {
166
+ if (!this.#readyFired) {
167
+ this.#readyFired = true;
168
+ this.#readyResolve?.();
169
+ }
170
+ return;
171
+ }
172
+ this.#handleResponse(response);
173
+ });
174
+ worker.on("error", (error) => {
175
+ // A worker-level error (e.g. uncaught exception). Before ready, fail the
176
+ // ready handshake; after ready, the exit handler owns restart.
177
+ if (!this.#readyFired) {
178
+ this.#readyFired = true;
179
+ this.#readyReject?.(error);
180
+ }
181
+ });
182
+ worker.on("exit", (code) => {
183
+ if (this.#closed || code === 0)
184
+ return;
185
+ if (generation !== this.#generation)
186
+ return; // stale worker
187
+ void this.#restart();
188
+ });
189
+ // Send init once the port is connected.
190
+ port.postMessage(this.#protocol.initRequest());
191
+ }
192
+ #handleResponse(response) {
193
+ const requestId = this.#protocol.responseRequestId(response);
194
+ const pending = this.#pending.get(requestId);
195
+ if (pending === undefined)
196
+ return; // stale/unknown (e.g. aborted)
197
+ this.#pending.delete(requestId);
198
+ if (!pending.slotReleased) {
199
+ pending.slotReleased = true;
200
+ this.#slots.release();
201
+ }
202
+ this.#protocol.settle(response, {
203
+ resolve: pending.resolve,
204
+ reject: pending.reject
205
+ });
206
+ }
207
+ async #restart() {
208
+ if (this.#restarting || this.#closed)
209
+ return;
210
+ this.#restarting = true;
211
+ try {
212
+ // If the worker died before becoming ready, fail the old ready handshake
213
+ // so awaiting send() calls reject instead of hanging on an orphaned promise.
214
+ if (!this.#readyFired) {
215
+ this.#readyFired = true;
216
+ this.#readyReject?.(new Error("Worker exited before becoming ready."));
217
+ }
218
+ // Brief backoff to avoid a hot crash loop.
219
+ await new Promise((resolve) => setTimeout(resolve, this.#restartBackoffMs));
220
+ this.#generation += 1;
221
+ this.#port?.close();
222
+ this.#ready = this.#newReadyPromise();
223
+ this.#spawnWorker();
224
+ await this.#ready;
225
+ // Replay unacknowledged requests (§3.1 fault boundary). Idempotent
226
+ // effects are deduped by the worker; read-only effects re-execute. The
227
+ // original promises are still pending and resolve when the new responses
228
+ // arrive.
229
+ for (const pending of this.#pending.values()) {
230
+ this.#port?.postMessage(pending.request);
231
+ }
232
+ }
233
+ catch (error) {
234
+ // Give up: fail all pending requests and release their slots.
235
+ const failure = error instanceof Error ? error : new Error(String(error));
236
+ for (const [id, pending] of this.#pending) {
237
+ this.#pending.delete(id);
238
+ if (!pending.slotReleased) {
239
+ pending.slotReleased = true;
240
+ this.#slots.release();
241
+ }
242
+ pending.reject(failure);
243
+ }
244
+ }
245
+ finally {
246
+ this.#restarting = false;
247
+ }
248
+ }
249
+ /**
250
+ * Send one request and await its response. The request must carry
251
+ * `requestId` (used for the pending map, cancel notices, and restart replay).
252
+ */
253
+ async send(requestId, request, options = {}) {
254
+ if (this.#closed)
255
+ return Promise.reject(new Error("BoundedRpcClient is closed."));
256
+ await this.#ready;
257
+ await this.#slots.acquire();
258
+ if (this.#closed) {
259
+ this.#slots.release();
260
+ return Promise.reject(new Error("BoundedRpcClient is closed."));
261
+ }
262
+ const abortError = (beforeSend) => this.#protocol.abortError?.(beforeSend)
263
+ ?? new Error(beforeSend ? "Request aborted before it was sent." : "Request aborted.");
264
+ return new Promise((resolve, reject) => {
265
+ let abortListener;
266
+ const signal = options.signal;
267
+ if (signal !== undefined) {
268
+ if (signal.aborted) {
269
+ this.#slots.release();
270
+ reject(abortError(true));
271
+ return;
272
+ }
273
+ abortListener = () => {
274
+ // Best-effort cancel; the worker observes the notice and suppresses
275
+ // a late result. Already-completed effects are not undone.
276
+ try {
277
+ this.#port?.postMessage(this.#protocol.cancelRequest(requestId));
278
+ }
279
+ catch {
280
+ // Port may be gone.
281
+ }
282
+ const pending = this.#pending.get(requestId);
283
+ if (pending !== undefined && !pending.slotReleased) {
284
+ pending.slotReleased = true;
285
+ this.#slots.release();
286
+ }
287
+ this.#pending.delete(requestId);
288
+ reject(abortError(false));
289
+ };
290
+ signal.addEventListener("abort", abortListener, { once: true });
291
+ }
292
+ this.#pending.set(requestId, {
293
+ resolve: (value) => {
294
+ if (abortListener !== undefined && signal !== undefined) {
295
+ signal.removeEventListener("abort", abortListener);
296
+ }
297
+ resolve(value);
298
+ },
299
+ reject: (error) => {
300
+ if (abortListener !== undefined && signal !== undefined) {
301
+ signal.removeEventListener("abort", abortListener);
302
+ }
303
+ reject(error);
304
+ },
305
+ request,
306
+ slotReleased: false
307
+ });
308
+ this.#port?.postMessage(request);
309
+ });
310
+ }
311
+ /** Close the worker and release its resources. */
312
+ async close() {
313
+ if (this.#closed)
314
+ return;
315
+ this.#closed = true;
316
+ // Fail any requests still waiting on the ready handshake.
317
+ if (!this.#readyFired) {
318
+ this.#readyFired = true;
319
+ this.#readyReject?.(new Error("BoundedRpcClient closed before ready."));
320
+ }
321
+ // Reject all in-flight requests; their slots are released.
322
+ for (const [id, pending] of this.#pending) {
323
+ this.#pending.delete(id);
324
+ if (!pending.slotReleased) {
325
+ pending.slotReleased = true;
326
+ this.#slots.release();
327
+ }
328
+ pending.reject(new Error("BoundedRpcClient closed."));
329
+ }
330
+ try {
331
+ this.#port?.postMessage(this.#protocol.shutdownRequest());
332
+ }
333
+ catch {
334
+ // Worker may already be gone.
335
+ }
336
+ // Give the worker a moment to exit cleanly.
337
+ await new Promise((resolve) => setTimeout(resolve, 20));
338
+ try {
339
+ await this.#worker?.terminate();
340
+ }
341
+ catch {
342
+ // Already terminated.
343
+ }
344
+ this.#port?.close();
345
+ }
346
+ /** Currently in-flight requests (metrics/tests). */
347
+ get inFlight() {
348
+ return this.#slots.inFlight;
349
+ }
350
+ /** Currently queued requests waiting for a slot (metrics/tests). */
351
+ get queueDepth() {
352
+ return this.#slots.queueDepth;
353
+ }
354
+ /**
355
+ * Test-only fault injection: abruptly terminate the worker (simulating a
356
+ * crash) so the exit handler restarts it and replays unacknowledged requests
357
+ * (§3.1 fault boundary). The pending requests stay pending; they resolve
358
+ * after the restart + replay.
359
+ */
360
+ async crashForTest() {
361
+ await this.#worker?.terminate();
362
+ }
363
+ }
364
+ /**
365
+ * Run a worker RPC host. Called once at the top level of a worker script.
366
+ * Completes the port handshake, then dispatches requests per the host's
367
+ * {@link RpcWorkerHost.kindOf}. `cancel` is processed immediately (so it can
368
+ * interrupt queued work); `shutdown` cleans up and exits; `init` and `request`
369
+ * are queued (FIFO) when `serial` is set, otherwise run immediately.
370
+ *
371
+ * A request whose cancel notice arrived before its result is posted is
372
+ * suppressed (the main thread already rejected the call).
373
+ */
374
+ export function runRpcWorker(host) {
375
+ const port = parentPort;
376
+ if (port === null) {
377
+ throw new Error("RPC worker must be run as a worker thread.");
378
+ }
379
+ const cancelled = new Set();
380
+ let queue = Promise.resolve();
381
+ // Responses go back on the handshake MessageChannel port, not parentPort:
382
+ // the main thread listens on the channel, not on the worker's parent port.
383
+ let messagePort;
384
+ const post = (message) => {
385
+ messagePort?.postMessage(message);
386
+ };
387
+ const runRequest = async (request) => {
388
+ const requestId = host.requestIdOf(request);
389
+ try {
390
+ const value = await host.handle(request);
391
+ if (requestId !== undefined && cancelled.has(requestId)) {
392
+ cancelled.delete(requestId);
393
+ return; // the main thread already gave up; don't post a late result
394
+ }
395
+ if (requestId === undefined) {
396
+ throw new Error("RPC worker request has no requestId.");
397
+ }
398
+ post(host.result(requestId, value));
399
+ }
400
+ catch (error) {
401
+ if (requestId !== undefined)
402
+ cancelled.delete(requestId);
403
+ if (requestId === undefined) {
404
+ console.error("[rpcWorker] request failed without a requestId:", error);
405
+ return;
406
+ }
407
+ post(host.error(requestId, serializeError(error)));
408
+ }
409
+ };
410
+ const enqueue = (task) => {
411
+ queue = queue.then(task).catch((error) => {
412
+ // Backstop: a handler threw without posting a response. Fail the worker
413
+ // rather than hang the caller.
414
+ console.error("[rpcWorker] unhandled handler error:", error);
415
+ });
416
+ };
417
+ port.once("message", (value) => {
418
+ messagePort = value.port;
419
+ messagePort.on("message", (message) => {
420
+ try {
421
+ const kind = host.kindOf(message);
422
+ if (kind === "cancel") {
423
+ // Process immediately so it can interrupt queued work.
424
+ const requestId = host.requestIdOf(message);
425
+ if (requestId !== undefined) {
426
+ cancelled.add(requestId);
427
+ host.cancel(requestId);
428
+ }
429
+ return;
430
+ }
431
+ if (kind === "shutdown") {
432
+ void host.shutdown()
433
+ .catch((error) => {
434
+ console.error("[rpcWorker] shutdown error:", error);
435
+ })
436
+ .finally(() => {
437
+ process.exit(0);
438
+ });
439
+ return;
440
+ }
441
+ if (kind === "init") {
442
+ const initAndReady = async () => {
443
+ await host.init(message);
444
+ post(host.ready());
445
+ };
446
+ if (host.serial === true) {
447
+ enqueue(initAndReady);
448
+ }
449
+ else {
450
+ void initAndReady().catch((error) => {
451
+ console.error("[rpcWorker] init failed:", error);
452
+ process.exit(1);
453
+ });
454
+ }
455
+ return;
456
+ }
457
+ // A regular work request.
458
+ if (host.serial === true) {
459
+ enqueue(() => runRequest(message));
460
+ }
461
+ else {
462
+ void runRequest(message);
463
+ }
464
+ }
465
+ catch (error) {
466
+ // A synchronous dispatch failure: surface it. Init failures crash the
467
+ // worker (the client restarts); request failures are posted per-handler.
468
+ console.error("[rpcWorker] dispatch error:", error);
469
+ }
470
+ });
471
+ messagePort.on("messageerror", (error) => {
472
+ console.error("[rpcWorker] message deserialization error:", error);
473
+ });
474
+ });
475
+ }
@@ -1485,7 +1485,7 @@ export class FileTaskWorkspacePreparer {
1485
1485
  * A Task that already carries an identity takes the resume path: only the
1486
1486
  * pending legacy archive and old-worktree removal run.
1487
1487
  */
1488
- async rebuildTaskWorkspace(taskId) {
1488
+ async rebuildTaskWorkspace(taskId, options = {}) {
1489
1489
  const task = requireTask(this.store, taskId);
1490
1490
  if (!["draft", "active"].includes(task.status)) {
1491
1491
  throw new Error(`Only a draft or active Task can be rebuilt in place: ${task.id}/${task.status}.`);
@@ -1518,9 +1518,12 @@ export class FileTaskWorkspacePreparer {
1518
1518
  const pins = new Map();
1519
1519
  for (const binding of task.projectBindings) {
1520
1520
  const project = requireProject(this.store, binding.projectId);
1521
- const useRemoteDefault = defaultProjects.has(project.id)
1522
- && project.remoteUrl !== undefined
1523
- && !looksLikeCommit(binding.baseRef);
1521
+ // `--latest` explicitly re-resolves every remote-backed Project. Without
1522
+ // it, only a still-symbolic creation default is refreshed; an explicit
1523
+ // or previously pinned commit retains the established rebuild behavior.
1524
+ const useRemoteDefault = (options.latestRemote === true
1525
+ || (defaultProjects.has(project.id) && !looksLikeCommit(binding.baseRef)))
1526
+ && project.remoteUrl !== undefined;
1524
1527
  if (useRemoteDefault) {
1525
1528
  const resolver = this.git.resolveRemoteBaseline;
1526
1529
  if (typeof resolver !== "function") {
@@ -1529,9 +1532,9 @@ export class FileTaskWorkspacePreparer {
1529
1532
  const remote = await resolver.call(this.git, {
1530
1533
  repositoryPath: project.path,
1531
1534
  remoteUrl: project.remoteUrl,
1532
- // The binding captured the configured development ref at Task
1533
- // creation; use that snapshot even if the Project catalog changed.
1534
- developmentRef: binding.baseRef
1535
+ developmentRef: options.latestRemote === true
1536
+ ? project.developmentBranch
1537
+ : binding.baseRef
1535
1538
  });
1536
1539
  pins.set(project.id, remote.commit);
1537
1540
  }
@@ -158,12 +158,13 @@ async function configureYui(store, home, env, question, selectionIo, catalogs, i
158
158
  const operatorConfig = await promptRoleAgentConfig("Operator", operatorAgent, store.getGlobalRole(SYSTEM_OPERATOR_ROLE), home, selectionIo, catalogs);
159
159
  const existingWorker = store.getGlobalRole(SYSTEM_WORKER_ROLE);
160
160
  const workerModeFallback = workerConfigurationModeFallback(existingWorker, defaultAgentId, leaderConfig);
161
- io.output?.write("\nWorker is the default Agent configuration copied into Task Roles such as "
161
+ io.output?.write("\n\nWorker is the default Agent configuration copied into Task Roles such as "
162
162
  + "investigator and implementer. Each Task Role gets its own Session.\n");
163
- const workerReusesLeader = parseWorkerConfigurationMode(await question(`Choose Worker configuration (reuse Leader/configure separately) [${workerModeFallback === "reuse-leader" ? "reuse Leader" : "configure separately"}]: `), workerModeFallback) === "reuse-leader";
163
+ const workerReusesLeader = await selectWorkerConfigurationMode(question, io, workerModeFallback) === "reuse-leader";
164
164
  let workerAgentId = defaultAgentId;
165
165
  let workerConfig = structuredClone(leaderConfig);
166
166
  if (!workerReusesLeader) {
167
+ io.output?.write("\n\nConfigure Worker separately:\n");
167
168
  const existingWorkerAgent = existingWorker?.activeAgentId;
168
169
  const workerFallback = configuredIds.has(existingWorkerAgent ?? "")
169
170
  ? existingWorkerAgent
@@ -173,10 +174,11 @@ async function configureYui(store, home, env, question, selectionIo, catalogs, i
173
174
  if (workerAgent === undefined) {
174
175
  throw usageError("Selected Worker Agent is no longer available.");
175
176
  }
176
- workerConfig = await promptRoleAgentConfig("Worker", workerAgent, existingWorker, home, selectionIo, catalogs);
177
+ workerConfig = await promptRoleAgentConfig("Worker", workerAgent, existingWorker, home, selectionIo, catalogs, true);
177
178
  }
178
179
  const suggestedWorkspace = config.defaultWorkspace?.trim()
179
180
  || join(dirname(resolve(home)), "workspace");
181
+ io.output?.write("\n\n");
180
182
  const workspaceAnswer = (await question(`Project workspace for stable checkouts and managed worktrees [${suggestedWorkspace}]: `)).trim();
181
183
  const workspace = resolveWorkspace(workspaceAnswer || suggestedWorkspace, home);
182
184
  if (config.defaultWorkspace !== undefined
@@ -245,6 +247,27 @@ function reviewerRoleProfile() {
245
247
  ...(reviewer.skills === undefined ? {} : { skills: [...reviewer.skills] })
246
248
  };
247
249
  }
250
+ async function selectWorkerConfigurationMode(question, io, fallback) {
251
+ const choices = [
252
+ [
253
+ "1",
254
+ `Reuse Leader${fallback === "reuse-leader" ? " (default)" : ""}`,
255
+ "Copy the complete Leader Agent launch configuration; skip Worker-specific prompts"
256
+ ],
257
+ [
258
+ "2",
259
+ `Configure separately${fallback === "configure-separately" ? " (default)" : ""}`,
260
+ "Choose the Worker Agent, model, effort, and permission independently"
261
+ ]
262
+ ];
263
+ const fallbackNumber = fallback === "reuse-leader" ? "1" : "2";
264
+ io.output?.write(`\n${renderTable("Choose Worker configuration", [
265
+ { header: "#", minWidth: 1, maxWidth: 4 },
266
+ { header: "Choice", minWidth: 16, maxWidth: 28 },
267
+ { header: "Consequence", minWidth: 24, maxWidth: 64 }
268
+ ], choices, tableWidth(io))}\n\n`);
269
+ return parseWorkerConfigurationMode(await question(`Choose Worker configuration [1-2; default ${fallbackNumber}]: `), fallback);
270
+ }
248
271
  function workerConfigurationModeFallback(existing, leaderAgentId, leaderConfig) {
249
272
  if (existing === null)
250
273
  return "reuse-leader";
@@ -290,7 +313,7 @@ async function promptRoleAgentConfig(label, agent, existingRole, cwd, io, catalo
290
313
  const existing = existingRole?.activeAgentId === agent.id
291
314
  ? existingRole.agentBindings[agent.id]?.config
292
315
  : undefined;
293
- io.write(`\n${label} Agent configuration: ${agent.id}\n`);
316
+ io.write(`\n\n${label} Agent configuration: ${agent.id}\n`);
294
317
  const resolved = await catalogs.resolve({
295
318
  agent,
296
319
  cwd,
@@ -8,6 +8,8 @@ const FINAL_REVIEW_AGGREGATE_FROM_VERSION = 16;
8
8
  const FINAL_REVIEW_AGGREGATE_TO_VERSION = 17;
9
9
  const HOME_IDENTITY_AGGREGATE_FROM_VERSION = 17;
10
10
  const HOME_IDENTITY_AGGREGATE_TO_VERSION = 18;
11
+ const SQLITE_LAYOUT_FROM_VERSION = 6;
12
+ const SQLITE_LAYOUT_TO_VERSION = 7;
11
13
  const PROJECT_FROM_VERSION = 2;
12
14
  const PROJECT_TO_VERSION = 3;
13
15
  const TASK_FROM_VERSION = 3;
@@ -38,6 +40,14 @@ const MANAGED_WORKSPACE_TO_VERSION = 2;
38
40
  export function createProductionStorageRegistry() {
39
41
  assertBaselineConsistency();
40
42
  const registry = new MigrationRegistry().registerOfflineMigration({
43
+ axis: "layout",
44
+ fromVersion: SQLITE_LAYOUT_FROM_VERSION,
45
+ toVersion: SQLITE_LAYOUT_TO_VERSION,
46
+ preconditions: requireLayoutV6Snapshot,
47
+ transform: migrateLayoutV6ToV7,
48
+ declaredEffects: []
49
+ })
50
+ .registerOfflineMigration({
41
51
  axis: "aggregate",
42
52
  fromVersion: FINAL_REVIEW_AGGREGATE_FROM_VERSION,
43
53
  toVersion: FINAL_REVIEW_AGGREGATE_TO_VERSION,
@@ -462,6 +472,29 @@ function asObject(value, label) {
462
472
  }
463
473
  /** Historical public spelling retained as an alias to the single graph. */
464
474
  export const createProductionRegistry = createProductionStorageRegistry;
475
+ /**
476
+ * Layout 6 -> 7 is the state.json -> SQLite WAL transition (task-21 §8). The
477
+ * transform only advances the manifest's layout version; the staged SQLite
478
+ * database is populated by the migration target's `writeFreshOutput`, and the
479
+ * `state.json` document is retained read-only for rollback. The precondition
480
+ * requires a layout-6 manifest so a future layout cannot be silently regressed.
481
+ */
482
+ function requireLayoutV6Snapshot(snapshot) {
483
+ if (snapshot.schemaManifest.storageVersion !== SQLITE_LAYOUT_FROM_VERSION) {
484
+ throw new Error(`Layout ${SQLITE_LAYOUT_FROM_VERSION}->${SQLITE_LAYOUT_TO_VERSION} migration requires ` +
485
+ `schema.json storageVersion ${SQLITE_LAYOUT_FROM_VERSION}.`);
486
+ }
487
+ }
488
+ function migrateLayoutV6ToV7(snapshot) {
489
+ requireLayoutV6Snapshot(snapshot);
490
+ return {
491
+ schemaManifest: {
492
+ ...snapshot.schemaManifest,
493
+ storageVersion: SQLITE_LAYOUT_TO_VERSION
494
+ },
495
+ state: snapshot.state
496
+ };
497
+ }
465
498
  /** Advance the aggregate identity after proving manifest/root agreement at v16. */
466
499
  function migrateAggregateV16ToV17(snapshot) {
467
500
  return {