@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
|
@@ -261,7 +261,7 @@ function renderResolutionNotice(resolved, io) {
|
|
|
261
261
|
io.write(notice);
|
|
262
262
|
}
|
|
263
263
|
async function choose(title, choices, io, defaultValue, label) {
|
|
264
|
-
io.write(
|
|
264
|
+
io.write(`\n\n${renderTable(title, [{ header: "#", minWidth: 1, maxWidth: 4 }, ...COLUMNS], choices.map((choice, index) => [
|
|
265
265
|
String(index + 1),
|
|
266
266
|
choice.label,
|
|
267
267
|
choice.detail
|
|
@@ -229,8 +229,8 @@ const taskChildren = [
|
|
|
229
229
|
{
|
|
230
230
|
name: "rebuild",
|
|
231
231
|
summary: "Rebuild a legacy Task workspace under its canonical identity.",
|
|
232
|
-
usage: "yui task rebuild <task>",
|
|
233
|
-
options: []
|
|
232
|
+
usage: "yui task rebuild <task> [--latest]",
|
|
233
|
+
options: ["--latest"]
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
236
|
name: "history",
|
|
@@ -20,11 +20,17 @@ export async function runTaskWorkspaceCommand(args, store, preparer, options = {
|
|
|
20
20
|
+ "yui task history list|archive [task], yui task replace <task>.");
|
|
21
21
|
}
|
|
22
22
|
async function rebuildTaskCommand(args, preparer) {
|
|
23
|
-
const usage = "Task rebuild usage: yui task rebuild <task
|
|
23
|
+
const usage = "Task rebuild usage: yui task rebuild <task> [--latest].";
|
|
24
24
|
const taskId = args[0];
|
|
25
|
-
|
|
25
|
+
const options = new Set(args.slice(1));
|
|
26
|
+
if (taskId === undefined
|
|
27
|
+
|| options.size !== args.length - 1
|
|
28
|
+
|| [...options].some((option) => option !== "--latest")) {
|
|
26
29
|
throw usageError(usage);
|
|
27
|
-
|
|
30
|
+
}
|
|
31
|
+
const result = await preparer.rebuildTaskWorkspace(taskId, {
|
|
32
|
+
latestRemote: options.has("--latest")
|
|
33
|
+
});
|
|
28
34
|
const archived = result.archived.length === 0
|
|
29
35
|
? "no legacy refs"
|
|
30
36
|
: `${result.archived.length} legacy ref(s) archived`;
|
|
@@ -35,7 +41,8 @@ async function rebuildTaskCommand(args, preparer) {
|
|
|
35
41
|
data: {
|
|
36
42
|
taskId: result.task.id,
|
|
37
43
|
archived: result.archived,
|
|
38
|
-
resumed: result.resumed
|
|
44
|
+
resumed: result.resumed,
|
|
45
|
+
latestRemote: options.has("--latest")
|
|
39
46
|
}
|
|
40
47
|
};
|
|
41
48
|
}
|
|
@@ -768,9 +768,9 @@ export class FileTaskController {
|
|
|
768
768
|
}
|
|
769
769
|
}
|
|
770
770
|
}
|
|
771
|
-
const firstRuntimeDrain = this.#drainRuntimeEvents();
|
|
771
|
+
const firstRuntimeDrain = await this.#drainRuntimeEvents();
|
|
772
772
|
result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#resourceSuppressionKeys);
|
|
773
|
-
const secondRuntimeDrain = this.#drainRuntimeEvents();
|
|
773
|
+
const secondRuntimeDrain = await this.#drainRuntimeEvents();
|
|
774
774
|
this.#clearPassRetry();
|
|
775
775
|
this.#scheduleRuntimeCleanupRetries(runtimeCleanupOutcomes);
|
|
776
776
|
this.#scheduleDeliveryRetries(result);
|
|
@@ -830,9 +830,15 @@ export class FileTaskController {
|
|
|
830
830
|
this.#lastOperatorSignalIdentity = identity;
|
|
831
831
|
}
|
|
832
832
|
}
|
|
833
|
-
#drainRuntimeEvents() {
|
|
834
|
-
const
|
|
835
|
-
if (
|
|
833
|
+
async #drainRuntimeEvents() {
|
|
834
|
+
const processor = this.#runtimeEventProcessor;
|
|
835
|
+
if (processor === undefined)
|
|
836
|
+
return undefined;
|
|
837
|
+
// The worker backend exposes drainAsync; the file backend stays sync.
|
|
838
|
+
const result = "drainAsync" in processor
|
|
839
|
+
? await processor.drainAsync(this.#now())
|
|
840
|
+
: processor.drain(this.#now());
|
|
841
|
+
if (result.failed.length > 0) {
|
|
836
842
|
throw new RuntimeEventApplyError(result.failed.map((failure) => failure.error), "One or more native Turn events could not be applied.");
|
|
837
843
|
}
|
|
838
844
|
return result;
|
|
@@ -80,8 +80,9 @@ export async function reapExpiredEphemeralResources(options = {}) {
|
|
|
80
80
|
return result;
|
|
81
81
|
}
|
|
82
82
|
export function createEphemeralResourceReaper(options) {
|
|
83
|
+
const scan = options.scan ?? (() => scanControllerResourceInventory(options));
|
|
83
84
|
return () => reapExpiredEphemeralResources({
|
|
84
|
-
scan
|
|
85
|
+
scan,
|
|
85
86
|
clean: (resource) => cleanControllerResource(resource, {
|
|
86
87
|
environment: options.environment
|
|
87
88
|
}),
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded RPC client for the resource inventory Worker Thread
|
|
3
|
+
* (task-21, work-item-6).
|
|
4
|
+
*
|
|
5
|
+
* The `/proc` scanning in `resourceInventoryLinux` is blocking IO (hundreds of
|
|
6
|
+
* `readFileSync` calls per pass, `spawnSync`, tmux inspection). When the worker
|
|
7
|
+
* backend is active (`YUI_STORE_BACKEND=sqlite` + `YUI_STORE_WORKER=1`, the same
|
|
8
|
+
* flag as the persistence worker, §6), the Controller runtime and the ephemeral
|
|
9
|
+
* reaper scan through this client instead: the scan runs in the inventory
|
|
10
|
+
* worker (its own thread), the main event loop stays free, and the result is
|
|
11
|
+
* posted back at the same cadence (design §3.3).
|
|
12
|
+
*
|
|
13
|
+
* The backpressure, cancellation, and fault-boundary machinery is the shared
|
|
14
|
+
* `core/boundedRpc` client; this module adds the inventory dialect:
|
|
15
|
+
*
|
|
16
|
+
* main -> worker: init | scan | cancel | shutdown
|
|
17
|
+
* worker -> main: ready | result | error
|
|
18
|
+
*
|
|
19
|
+
* Only structured-cloneable scan options cross the port (the file-backed
|
|
20
|
+
* `inspectStorage` / `openCompatibleStore` / `now` seams stay on the direct
|
|
21
|
+
* path, which the file backend still uses). The inventory worker never touches
|
|
22
|
+
* the persistence worker's database connection (§3.3): it is a separate worker
|
|
23
|
+
* with a separate concern.
|
|
24
|
+
*/
|
|
25
|
+
import { BoundedRpcClient, deserializeError, nextRequestId } from "../core/boundedRpc.js";
|
|
26
|
+
// -- Protocol adapter ---------------------------------------------------------
|
|
27
|
+
const inventoryProtocol = {
|
|
28
|
+
initRequest: () => ({ kind: "init" }),
|
|
29
|
+
cancelRequest: (requestId) => ({ kind: "cancel", requestId }),
|
|
30
|
+
shutdownRequest: () => ({ kind: "shutdown" }),
|
|
31
|
+
isReady: (response) => response.kind === "ready",
|
|
32
|
+
responseRequestId: (response) => {
|
|
33
|
+
if (response.kind === "ready") {
|
|
34
|
+
throw new Error("ready response has no requestId.");
|
|
35
|
+
}
|
|
36
|
+
return response.requestId;
|
|
37
|
+
},
|
|
38
|
+
settle: (response, settlement) => {
|
|
39
|
+
if (response.kind === "result") {
|
|
40
|
+
settlement.resolve(response.inventory);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (response.kind === "error") {
|
|
44
|
+
settlement.reject(deserializeError(response.error));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
// -- Client ------------------------------------------------------------------
|
|
49
|
+
/**
|
|
50
|
+
* The main-thread client for the inventory worker. `scan` runs the full
|
|
51
|
+
* `scanControllerResourceInventory` in the worker and resolves with the same
|
|
52
|
+
* inventory shape the direct call returns; the scheduler and the ephemeral
|
|
53
|
+
* reaper consume it through the same ports as today (§3.3, behavior unchanged).
|
|
54
|
+
*/
|
|
55
|
+
export class ResourceInventoryClient {
|
|
56
|
+
#rpc;
|
|
57
|
+
constructor(options = {}) {
|
|
58
|
+
this.#rpc = new BoundedRpcClient(inventoryProtocol, {
|
|
59
|
+
maxInFlight: options.maxInFlight ?? 4,
|
|
60
|
+
maxQueue: options.maxQueue ?? 16,
|
|
61
|
+
workerScript: options.workerScript ?? new URL("./resourceInventoryWorker.js", import.meta.url)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Run one resource inventory scan in the worker. */
|
|
65
|
+
scan(options, rpcOptions) {
|
|
66
|
+
const requestId = nextRequestId();
|
|
67
|
+
return this.#rpc.send(requestId, { kind: "scan", requestId, options }, rpcOptions);
|
|
68
|
+
}
|
|
69
|
+
/** Close the worker. */
|
|
70
|
+
close() {
|
|
71
|
+
return this.#rpc.close();
|
|
72
|
+
}
|
|
73
|
+
/** Currently in-flight scans (metrics/tests). */
|
|
74
|
+
get inFlight() {
|
|
75
|
+
return this.#rpc.inFlight;
|
|
76
|
+
}
|
|
77
|
+
/** Currently queued scans waiting for a slot (metrics/tests). */
|
|
78
|
+
get queueDepth() {
|
|
79
|
+
return this.#rpc.queueDepth;
|
|
80
|
+
}
|
|
81
|
+
/** Test-only fault injection: terminate the worker (the client restarts it). */
|
|
82
|
+
crashForTest() {
|
|
83
|
+
return this.#rpc.crashForTest();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource inventory Worker Thread (task-21, work-item-6).
|
|
3
|
+
*
|
|
4
|
+
* Runs the blocking `/proc` scanning of `resourceInventoryLinux` off the main
|
|
5
|
+
* thread. The main thread asks for a scan over a `MessageChannel` port; the
|
|
6
|
+
* worker runs the exact same `scanControllerResourceInventory` the file backend
|
|
7
|
+
* calls directly and posts the inventory back. Because worker threads share the
|
|
8
|
+
* process, the scanner's self-exclusion (`process.pid`, its start identity)
|
|
9
|
+
* keeps excluding the Controller process exactly as on the main thread.
|
|
10
|
+
*
|
|
11
|
+
* The port handshake, dispatch, cancellation observation, and error
|
|
12
|
+
* serialization are the shared `core/boundedRpc` worker host; this module only
|
|
13
|
+
* supplies the inventory dialect. The worker is read-only: it never touches the
|
|
14
|
+
* persistence worker's database connection (§3.3) — it opens the same file
|
|
15
|
+
* store reads the direct scan uses, in its own thread.
|
|
16
|
+
*
|
|
17
|
+
* Protocol (see resourceInventoryRpc.ts):
|
|
18
|
+
* main -> worker: init | scan | cancel | shutdown
|
|
19
|
+
* worker -> main: ready | result | error
|
|
20
|
+
*/
|
|
21
|
+
import { runRpcWorker } from "../core/boundedRpc.js";
|
|
22
|
+
import { scanControllerResourceInventory } from "./resourceInventoryLinux.js";
|
|
23
|
+
runRpcWorker({
|
|
24
|
+
kindOf: (request) => (request.kind === "scan" ? "request" : request.kind),
|
|
25
|
+
requestIdOf: (request) => (request.kind === "scan" || request.kind === "cancel" ? request.requestId : undefined),
|
|
26
|
+
init: async () => {
|
|
27
|
+
// No resources to initialize: each scan opens what it needs and closes it.
|
|
28
|
+
},
|
|
29
|
+
handle: async (request) => {
|
|
30
|
+
if (request.kind !== "scan") {
|
|
31
|
+
throw new Error(`Unexpected inventory request: ${request.kind}`);
|
|
32
|
+
}
|
|
33
|
+
return scanControllerResourceInventory(request.options);
|
|
34
|
+
},
|
|
35
|
+
cancel: () => {
|
|
36
|
+
// The /proc scan has no cancellation points. The main thread rejects the
|
|
37
|
+
// aborted call immediately and suppresses the late result; the worker
|
|
38
|
+
// thread finishes the scan on its own time without blocking the main loop.
|
|
39
|
+
},
|
|
40
|
+
shutdown: async () => {
|
|
41
|
+
// Nothing to close: scans own no long-lived resources.
|
|
42
|
+
},
|
|
43
|
+
ready: () => ({ kind: "ready" }),
|
|
44
|
+
result: (requestId, value) => ({
|
|
45
|
+
kind: "result",
|
|
46
|
+
requestId,
|
|
47
|
+
inventory: value
|
|
48
|
+
}),
|
|
49
|
+
error: (requestId, error) => ({ kind: "error", requestId, error })
|
|
50
|
+
});
|
|
@@ -11,6 +11,8 @@ import { effectiveLaunchSnapshotsCompatible, resolveEffectiveLaunch } from "../e
|
|
|
11
11
|
import { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
|
|
12
12
|
import { FileRoleLaunchPlanner } from "../executor/fileRoleLaunchPlanner.js";
|
|
13
13
|
import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
|
|
14
|
+
import { SqliteTaskStore } from "../storage/sqliteStore.js";
|
|
15
|
+
import { AsyncTaskStoreClient, resolveStoreWorkerEnabled } from "../storage/storeRpc.js";
|
|
14
16
|
import { FileTaskWorkspacePreparer } from "../repository/taskWorkspacePreparer.js";
|
|
15
17
|
import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
|
|
16
18
|
import { TmuxManager, yuiTmuxServerName } from "../tmux/tmuxManager.js";
|
|
@@ -18,15 +20,41 @@ import { FileTaskRuntimeIsolation, TmuxPromptPushAdapter, TmuxSessionHost } from
|
|
|
18
20
|
import { startFileTaskController } from "./controller.js";
|
|
19
21
|
import { FileSchedulerStoreAdapter } from "./fileSchedulerStoreAdapter.js";
|
|
20
22
|
import { FileRuntimeEventInbox } from "./runtimeEventInbox.js";
|
|
21
|
-
import { FileRuntimeEventProcessor } from "./runtimeEventProcessor.js";
|
|
23
|
+
import { AsyncRuntimeEventProcessor, FileRuntimeEventProcessor, createAsyncRuntimeObserver } from "./runtimeEventProcessor.js";
|
|
22
24
|
import { RuntimeLaunchCoordinator } from "./runtimeLaunchCoordinator.js";
|
|
23
25
|
import { ephemeralDomainFromEnvironment, recordEphemeralTmuxTarget } from "./domainIdentity.js";
|
|
24
26
|
import { createEphemeralResourceReaper } from "./ephemeralResourceReaper.js";
|
|
25
27
|
import { scanControllerResourceInventory } from "./resourceInventoryLinux.js";
|
|
28
|
+
import { ResourceInventoryClient } from "./resourceInventoryRpc.js";
|
|
26
29
|
import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
|
|
27
30
|
/** Production composition root for the lean FileTaskStore + tmux Controller. */
|
|
28
31
|
export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
29
|
-
|
|
32
|
+
// The persistence worker (task-21, work-item-5) is opt-in: YUI_STORE_BACKEND
|
|
33
|
+
// must be sqlite AND YUI_STORE_WORKER=1. The file store remains the default
|
|
34
|
+
// for CLI tools and tests; rollback is a config flip (§6).
|
|
35
|
+
const useWorker = resolveStoreWorkerEnabled(options.environment ?? process.env);
|
|
36
|
+
const store = options.store
|
|
37
|
+
?? (useWorker
|
|
38
|
+
// Transitional: the scheduler/planner still use a sync store. The worker
|
|
39
|
+
// owns the event-processing hot path; the scheduler migration is the next
|
|
40
|
+
// step (see work-item-5 remaining call sites). Both connections point at
|
|
41
|
+
// the same WAL db and serialize via BEGIN IMMEDIATE + busy_timeout.
|
|
42
|
+
? new SqliteTaskStore(home)
|
|
43
|
+
: openCompatibleFileTaskStore(home));
|
|
44
|
+
// When the worker backend is active, the db-touching observer folds run in
|
|
45
|
+
// the worker (off the main event loop). The client is closed on shutdown.
|
|
46
|
+
const asyncStoreClient = useWorker
|
|
47
|
+
? new AsyncTaskStoreClient(home, {
|
|
48
|
+
environment: options.environment,
|
|
49
|
+
observerModule: new URL("./fileSchedulerStoreAdapter.js", import.meta.url)
|
|
50
|
+
})
|
|
51
|
+
: undefined;
|
|
52
|
+
// When the worker backend is active, the blocking /proc inventory scan runs
|
|
53
|
+
// in the inventory worker (off the main event loop); the scheduler and the
|
|
54
|
+
// ephemeral reaper consume the same inventory shape through this client (§3.3).
|
|
55
|
+
const inventoryClient = useWorker
|
|
56
|
+
? new ResourceInventoryClient()
|
|
57
|
+
: undefined;
|
|
30
58
|
const schedulerStore = options.schedulerStore ?? new FileSchedulerStoreAdapter(store);
|
|
31
59
|
const domainIdentity = options.domainIdentity
|
|
32
60
|
?? ephemeralDomainFromEnvironment(options.environment ?? process.env);
|
|
@@ -95,19 +123,32 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
95
123
|
onCleanupRequired: signalRuntimeCleanup,
|
|
96
124
|
runtimeIsolation
|
|
97
125
|
});
|
|
126
|
+
// One inventory scan per scheduler pass. When the worker backend is active
|
|
127
|
+
// the blocking /proc scan runs in the inventory worker; otherwise it runs on
|
|
128
|
+
// the main thread (file backend, unchanged).
|
|
129
|
+
const scanInventory = (panes) => inventoryClient !== undefined
|
|
130
|
+
? inventoryClient.scan({
|
|
131
|
+
currentHome: home,
|
|
132
|
+
scope: "current",
|
|
133
|
+
panes,
|
|
134
|
+
...(options.environment === undefined
|
|
135
|
+
? {}
|
|
136
|
+
: { environment: options.environment })
|
|
137
|
+
})
|
|
138
|
+
: scanControllerResourceInventory({
|
|
139
|
+
currentHome: home,
|
|
140
|
+
scope: "current",
|
|
141
|
+
panes,
|
|
142
|
+
...(options.environment === undefined
|
|
143
|
+
? {}
|
|
144
|
+
: { environment: options.environment })
|
|
145
|
+
});
|
|
98
146
|
const delivery = options.delivery ?? new ExecutorRegistry(planner, tmux, agentProcessReadinessProbe, {
|
|
99
147
|
sessionHost,
|
|
100
148
|
promptPush,
|
|
101
149
|
launchCoordinator,
|
|
102
150
|
roleResourceInventory: async (panes, inputs) => {
|
|
103
|
-
const inventory = await
|
|
104
|
-
currentHome: home,
|
|
105
|
-
scope: "current",
|
|
106
|
-
panes,
|
|
107
|
-
...(options.environment === undefined
|
|
108
|
-
? {}
|
|
109
|
-
: { environment: options.environment })
|
|
110
|
-
});
|
|
151
|
+
const inventory = await scanInventory(panes);
|
|
111
152
|
return inventory.resources.flatMap((resource) => {
|
|
112
153
|
if (resource.kind !== "agent-session")
|
|
113
154
|
return [];
|
|
@@ -182,7 +223,20 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
182
223
|
// recovery bounded to that domain; cross-home cleanup remains an
|
|
183
224
|
// explicit `controller cleanup --all` inventory operation.
|
|
184
225
|
scope: "current",
|
|
185
|
-
environment: options.environment
|
|
226
|
+
environment: options.environment,
|
|
227
|
+
// When the worker backend is active, the reaper's scan runs in the
|
|
228
|
+
// inventory worker too (same cadence, same inventory shape).
|
|
229
|
+
...(inventoryClient === undefined
|
|
230
|
+
? {}
|
|
231
|
+
: {
|
|
232
|
+
scan: () => inventoryClient.scan({
|
|
233
|
+
currentHome: home,
|
|
234
|
+
scope: "current",
|
|
235
|
+
...(options.environment === undefined
|
|
236
|
+
? {}
|
|
237
|
+
: { environment: options.environment })
|
|
238
|
+
})
|
|
239
|
+
})
|
|
186
240
|
}));
|
|
187
241
|
const lifecycleDispatcher = createRuntimeLifecycleDispatcher(store, schedulerStore, sessionHost, options.dispatcher, signalRuntimeCleanup, launchCoordinator, planner);
|
|
188
242
|
const running = await startFileTaskController(home, schedulerStore, delivery, lifecycleDispatcher, {
|
|
@@ -202,11 +256,17 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
202
256
|
},
|
|
203
257
|
workspacePreparer,
|
|
204
258
|
runtimeEventProcessor: options.runtimeEventProcessor
|
|
205
|
-
??
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
259
|
+
?? (useWorker && asyncStoreClient !== undefined
|
|
260
|
+
? new AsyncRuntimeEventProcessor(new FileRuntimeEventInbox(home), createAsyncRuntimeObserver((method, args) => asyncStoreClient.invokeObserver(method, args)), {
|
|
261
|
+
onTaskRuntimeApplied: (input) => {
|
|
262
|
+
planner.refreshTaskRuntimeDescriptor(input);
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
: new FileRuntimeEventProcessor(new FileRuntimeEventInbox(home), schedulerStore, {
|
|
266
|
+
onTaskRuntimeApplied: (input) => {
|
|
267
|
+
planner.refreshTaskRuntimeDescriptor(input);
|
|
268
|
+
}
|
|
269
|
+
})),
|
|
210
270
|
domainIdentity,
|
|
211
271
|
...(options.configuration !== undefined
|
|
212
272
|
? { configuration: options.configuration }
|
|
@@ -222,6 +282,12 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
222
282
|
runningRuntime = running.runtime;
|
|
223
283
|
return {
|
|
224
284
|
...running,
|
|
285
|
+
close: async () => {
|
|
286
|
+
await running.close();
|
|
287
|
+
// Release the worker's database connections when the worker backend is active.
|
|
288
|
+
await asyncStoreClient?.close();
|
|
289
|
+
await inventoryClient?.close();
|
|
290
|
+
},
|
|
225
291
|
store,
|
|
226
292
|
schedulerStore,
|
|
227
293
|
planner,
|
|
@@ -246,3 +246,271 @@ function isObsoleteRuntimeTurnObservation(value) {
|
|
|
246
246
|
&& value !== null
|
|
247
247
|
&& value.disposition === "obsolete";
|
|
248
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Build an {@link AsyncRuntimeTurnEventObserver} that forwards every fold to the
|
|
251
|
+
* worker-hosted adapter via {@link AsyncObserverInvoker}. The adapter's fold
|
|
252
|
+
* logic (read-then-write transactions) runs in the worker, off the main event
|
|
253
|
+
* loop; the main thread only awaits the result.
|
|
254
|
+
*/
|
|
255
|
+
export function createAsyncRuntimeObserver(invoke) {
|
|
256
|
+
const call = (method, args) => invoke(method, args);
|
|
257
|
+
const withNow = (method, input, now) => now === undefined ? call(method, [input]) : call(method, [input, now]);
|
|
258
|
+
return {
|
|
259
|
+
getTask: (taskId) => call("getTask", [taskId]),
|
|
260
|
+
observeRuntimeTurnCompleted: (input, now) => withNow("observeRuntimeTurnCompleted", input, now),
|
|
261
|
+
observeGlobalRuntimeTurnCompleted: (input, now) => withNow("observeGlobalRuntimeTurnCompleted", input, now),
|
|
262
|
+
observeClaudeStopFailureEvent: (input, now) => withNow("observeClaudeStopFailureEvent", input, now),
|
|
263
|
+
observeObsoleteRuntimeEvent: (input, now) => withNow("observeObsoleteRuntimeEvent", input, now),
|
|
264
|
+
observeProviderSessionLifecycle: (input, now) => withNow("observeProviderSessionLifecycle", input, now),
|
|
265
|
+
observeProviderPromptAccepted: (input, now) => withNow("observeProviderPromptAccepted", input, now),
|
|
266
|
+
observeProviderTurnProgress: (input, now) => withNow("observeProviderTurnProgress", input, now)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The async counterpart to {@link FileRuntimeEventProcessor}: folds immutable
|
|
271
|
+
* Hook facts one at a time (awaiting the worker-hosted observer) before
|
|
272
|
+
* acknowledging them. The inbox itself is file-based and stays on the main
|
|
273
|
+
* thread; only the db-touching folds are proxied to the worker.
|
|
274
|
+
*/
|
|
275
|
+
export class AsyncRuntimeEventProcessor {
|
|
276
|
+
inbox;
|
|
277
|
+
observer;
|
|
278
|
+
options;
|
|
279
|
+
constructor(inbox, observer, options = {}) {
|
|
280
|
+
this.inbox = inbox;
|
|
281
|
+
this.observer = observer;
|
|
282
|
+
this.options = options;
|
|
283
|
+
}
|
|
284
|
+
async drainAsync(now) {
|
|
285
|
+
const acknowledgedEventIds = [];
|
|
286
|
+
const deferred = [];
|
|
287
|
+
const failed = [];
|
|
288
|
+
let events;
|
|
289
|
+
try {
|
|
290
|
+
events = this.inbox.list();
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
return { acknowledgedEventIds, deferred, failed: [{ error }] };
|
|
294
|
+
}
|
|
295
|
+
for (const event of events) {
|
|
296
|
+
try {
|
|
297
|
+
if (event.type === "native-turn-completed") {
|
|
298
|
+
const outcome = await this.applyCodex(event, now);
|
|
299
|
+
if (outcome === "deferred") {
|
|
300
|
+
deferred.push(event);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else if (event.type === "claude-stop-failure") {
|
|
305
|
+
await this.applyClaudeStopFailure(event, now);
|
|
306
|
+
}
|
|
307
|
+
else if (event.type === "native-session-lifecycle") {
|
|
308
|
+
const outcome = await this.applySessionLifecycle(event, now);
|
|
309
|
+
if (outcome === "deferred") {
|
|
310
|
+
deferred.push(event);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else if (event.type === "native-turn-progress") {
|
|
315
|
+
const outcome = await this.applyProviderTurnProgress(event, now);
|
|
316
|
+
if (outcome === "deferred") {
|
|
317
|
+
deferred.push(event);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
const outcome = await this.applyPromptAccepted(event, now);
|
|
323
|
+
if (outcome === "deferred") {
|
|
324
|
+
deferred.push(event);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
this.acknowledge(event.id, acknowledgedEventIds);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
failed.push({ eventId: event.id, error });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { acknowledgedEventIds, deferred, failed };
|
|
335
|
+
}
|
|
336
|
+
async applySessionLifecycle(event, now) {
|
|
337
|
+
const task = await this.observer.getTask(event.taskId);
|
|
338
|
+
if (task === null || task.status !== "active")
|
|
339
|
+
return "obsolete";
|
|
340
|
+
if (this.observer.observeProviderSessionLifecycle === undefined)
|
|
341
|
+
return "obsolete";
|
|
342
|
+
const outcome = await this.observer.observeProviderSessionLifecycle({
|
|
343
|
+
eventId: event.id,
|
|
344
|
+
taskId: event.taskId,
|
|
345
|
+
roleName: event.roleName,
|
|
346
|
+
agentId: event.agentId,
|
|
347
|
+
adapterId: event.adapterId,
|
|
348
|
+
launchId: event.launchId,
|
|
349
|
+
nativeSessionId: event.nativeSessionId,
|
|
350
|
+
...(event.runId === undefined ? {} : { runId: event.runId }),
|
|
351
|
+
...(event.sessionSource === undefined ? {} : { sessionSource: event.sessionSource })
|
|
352
|
+
}, now);
|
|
353
|
+
if (outcome === "applied") {
|
|
354
|
+
this.options.onTaskRuntimeApplied?.({
|
|
355
|
+
taskId: event.taskId,
|
|
356
|
+
roleName: event.roleName
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return outcome === "deferred" ? "deferred" : outcome;
|
|
360
|
+
}
|
|
361
|
+
async applyPromptAccepted(event, now) {
|
|
362
|
+
const task = await this.observer.getTask(event.taskId);
|
|
363
|
+
if (task === null || task.status !== "active")
|
|
364
|
+
return "obsolete";
|
|
365
|
+
if (this.observer.observeProviderPromptAccepted === undefined)
|
|
366
|
+
return "obsolete";
|
|
367
|
+
const outcome = await this.observer.observeProviderPromptAccepted({
|
|
368
|
+
eventId: event.id,
|
|
369
|
+
taskId: event.taskId,
|
|
370
|
+
roleName: event.roleName,
|
|
371
|
+
agentId: event.agentId,
|
|
372
|
+
adapterId: event.adapterId,
|
|
373
|
+
launchId: event.launchId,
|
|
374
|
+
nativeSessionId: event.nativeSessionId,
|
|
375
|
+
runId: event.runId,
|
|
376
|
+
receiptId: event.receiptId
|
|
377
|
+
}, now);
|
|
378
|
+
if (outcome === "applied") {
|
|
379
|
+
this.options.onTaskRuntimeApplied?.({
|
|
380
|
+
taskId: event.taskId,
|
|
381
|
+
roleName: event.roleName
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return outcome === "deferred" ? "deferred" : outcome;
|
|
385
|
+
}
|
|
386
|
+
async applyProviderTurnProgress(event, now) {
|
|
387
|
+
const task = await this.observer.getTask(event.taskId);
|
|
388
|
+
if (task === null || task.status !== "active")
|
|
389
|
+
return "obsolete";
|
|
390
|
+
if (this.observer.observeProviderTurnProgress === undefined)
|
|
391
|
+
return "obsolete";
|
|
392
|
+
const outcome = await this.observer.observeProviderTurnProgress({
|
|
393
|
+
eventId: event.id,
|
|
394
|
+
receivedAt: event.receivedAt,
|
|
395
|
+
taskId: event.taskId,
|
|
396
|
+
roleName: event.roleName,
|
|
397
|
+
agentId: event.agentId,
|
|
398
|
+
adapterId: event.adapterId,
|
|
399
|
+
launchId: event.launchId,
|
|
400
|
+
nativeSessionId: event.nativeSessionId,
|
|
401
|
+
runId: event.runId,
|
|
402
|
+
progressId: event.progressId,
|
|
403
|
+
...(event.sequence === undefined ? {} : { sequence: event.sequence })
|
|
404
|
+
}, now);
|
|
405
|
+
return outcome === "deferred" ? "deferred" : "applied";
|
|
406
|
+
}
|
|
407
|
+
async applyCodex(event, now) {
|
|
408
|
+
if (event.scope === "task") {
|
|
409
|
+
const task = await this.observer.getTask(event.taskId);
|
|
410
|
+
const input = {
|
|
411
|
+
eventId: event.id,
|
|
412
|
+
taskId: event.taskId,
|
|
413
|
+
roleName: event.roleName,
|
|
414
|
+
agentId: event.agentId,
|
|
415
|
+
adapterId: event.adapterId,
|
|
416
|
+
...(event.launchId === undefined ? {} : { launchId: event.launchId }),
|
|
417
|
+
nativeSessionId: event.nativeSessionId,
|
|
418
|
+
turnId: event.turnId,
|
|
419
|
+
...(event.runId === undefined ? {} : { runId: event.runId }),
|
|
420
|
+
summary: event.summary
|
|
421
|
+
};
|
|
422
|
+
if (task === null)
|
|
423
|
+
return "obsolete";
|
|
424
|
+
if (task.status !== "active") {
|
|
425
|
+
await this.recordObsolete(event, task.status === "archived" ? "task-archived" : "task-retired", now);
|
|
426
|
+
return "obsolete";
|
|
427
|
+
}
|
|
428
|
+
const disposition = (await this.observer.classifyRuntimeTurnCompleted?.(input)) ?? "apply";
|
|
429
|
+
if (disposition === "deferred")
|
|
430
|
+
return disposition;
|
|
431
|
+
if (disposition === "obsolete") {
|
|
432
|
+
await this.recordObsolete(event, "identity-mismatch-or-terminal", now);
|
|
433
|
+
return disposition;
|
|
434
|
+
}
|
|
435
|
+
const observed = await this.observer.observeRuntimeTurnCompleted(input, now);
|
|
436
|
+
if (isObsoleteRuntimeTurnObservation(observed)) {
|
|
437
|
+
await this.recordObsolete(event, "runtime-cleanup-or-stopped-session", now);
|
|
438
|
+
return "obsolete";
|
|
439
|
+
}
|
|
440
|
+
this.options.onTaskRuntimeApplied?.({
|
|
441
|
+
taskId: event.taskId,
|
|
442
|
+
roleName: event.roleName
|
|
443
|
+
});
|
|
444
|
+
return "applied";
|
|
445
|
+
}
|
|
446
|
+
const input = {
|
|
447
|
+
eventId: event.id,
|
|
448
|
+
roleName: event.roleName,
|
|
449
|
+
agentId: event.agentId,
|
|
450
|
+
adapterId: event.adapterId,
|
|
451
|
+
...(event.launchId === undefined ? {} : { launchId: event.launchId }),
|
|
452
|
+
nativeSessionId: event.nativeSessionId,
|
|
453
|
+
turnId: event.turnId,
|
|
454
|
+
...(event.title === undefined ? {} : { title: event.title }),
|
|
455
|
+
summary: event.summary
|
|
456
|
+
};
|
|
457
|
+
if ((await this.observer.classifyGlobalRuntimeTurnCompleted?.(input)) !== "obsolete") {
|
|
458
|
+
await this.observer.observeGlobalRuntimeTurnCompleted(input, now);
|
|
459
|
+
return "applied";
|
|
460
|
+
}
|
|
461
|
+
return "obsolete";
|
|
462
|
+
}
|
|
463
|
+
async applyClaudeStopFailure(event, now) {
|
|
464
|
+
const task = await this.observer.getTask(event.taskId);
|
|
465
|
+
if (task === null)
|
|
466
|
+
return;
|
|
467
|
+
const input = {
|
|
468
|
+
eventId: event.id,
|
|
469
|
+
type: event.type,
|
|
470
|
+
taskId: event.taskId,
|
|
471
|
+
roleName: event.roleName,
|
|
472
|
+
agentId: event.agentId,
|
|
473
|
+
adapterId: event.adapterId,
|
|
474
|
+
launchId: event.launchId,
|
|
475
|
+
nativeSessionId: event.nativeSessionId,
|
|
476
|
+
runId: event.runId,
|
|
477
|
+
error: event.error,
|
|
478
|
+
...(event.errorDetails === undefined ? {} : { errorDetails: event.errorDetails }),
|
|
479
|
+
...(event.lastAssistantMessage === undefined
|
|
480
|
+
? {}
|
|
481
|
+
: { lastAssistantMessage: event.lastAssistantMessage })
|
|
482
|
+
};
|
|
483
|
+
if (task.status !== "active"
|
|
484
|
+
|| (await this.observer.classifyClaudeStopFailureEvent?.(input)) === "obsolete") {
|
|
485
|
+
await this.recordObsolete(event, task.status === "archived"
|
|
486
|
+
? "task-archived"
|
|
487
|
+
: task.status !== "active"
|
|
488
|
+
? "task-retired"
|
|
489
|
+
: "identity-mismatch-or-terminal", now);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (this.observer.observeClaudeStopFailureEvent === undefined) {
|
|
493
|
+
throw new Error("Claude StopFailure observer is unavailable.");
|
|
494
|
+
}
|
|
495
|
+
await this.observer.observeClaudeStopFailureEvent(input, now);
|
|
496
|
+
}
|
|
497
|
+
async recordObsolete(event, reason, now) {
|
|
498
|
+
if (event.scope !== "task" || event.taskId === undefined)
|
|
499
|
+
return;
|
|
500
|
+
await this.observer.observeObsoleteRuntimeEvent?.({
|
|
501
|
+
eventId: event.id,
|
|
502
|
+
eventType: event.type,
|
|
503
|
+
taskId: event.taskId,
|
|
504
|
+
roleName: event.roleName,
|
|
505
|
+
agentId: event.agentId,
|
|
506
|
+
...(event.runId === undefined ? {} : { runId: event.runId }),
|
|
507
|
+
...(event.launchId === undefined ? {} : { launchId: event.launchId }),
|
|
508
|
+
nativeSessionId: event.nativeSessionId,
|
|
509
|
+
reason
|
|
510
|
+
}, now);
|
|
511
|
+
}
|
|
512
|
+
acknowledge(id, acknowledged) {
|
|
513
|
+
this.inbox.acknowledge(id);
|
|
514
|
+
acknowledged.push(id);
|
|
515
|
+
}
|
|
516
|
+
}
|