@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.
- package/dist/cli/agentConfigurationPicker.js +1 -1
- package/dist/cli/commandCatalog.js +2 -2
- package/dist/commands/taskWorkspaceCommands.js +11 -4
- package/dist/controller/controller.js +11 -5
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- package/dist/controller/resourceInventoryRpc.js +85 -0
- package/dist/controller/resourceInventoryWorker.js +50 -0
- package/dist/controller/runtime.js +82 -16
- package/dist/controller/runtimeEventProcessor.js +268 -0
- package/dist/core/boundedRpc.js +475 -0
- package/dist/repository/taskWorkspacePreparer.js +10 -7
- package/dist/setup/setupCommand.js +27 -4
- package/dist/storage/migration/productionRegistry.js +33 -0
- package/dist/storage/persistenceWorker.js +194 -0
- package/dist/storage/sqliteSchema.js +444 -0
- package/dist/storage/sqliteStore.js +1222 -0
- package/dist/storage/storageVersions.js +9 -2
- package/dist/storage/storeRpc.js +277 -0
- package/dist/storage/taskStore.js +9 -0
- package/dist/storage/upgrade/homeClassification.js +38 -11
- package/dist/storage/upgrade/sqliteMigrationTarget.js +299 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +457 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +41 -15
- package/package.json +2 -1
|
@@ -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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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 =
|
|
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 {
|