@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.
@@ -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(`${renderTable(title, [{ header: "#", minWidth: 1, maxWidth: 4 }, ...COLUMNS], choices.map((choice, index) => [
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
- if (taskId === undefined || args.length !== 1)
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
- const result = await preparer.rebuildTaskWorkspace(taskId);
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 result = this.#runtimeEventProcessor?.drain(this.#now());
835
- if (result !== undefined && result.failed.length > 0) {
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: () => scanControllerResourceInventory(options),
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
- const store = options.store ?? openCompatibleFileTaskStore(home);
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 scanControllerResourceInventory({
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
- ?? new FileRuntimeEventProcessor(new FileRuntimeEventInbox(home), schedulerStore, {
206
- onTaskRuntimeApplied: (input) => {
207
- planner.refreshTaskRuntimeDescriptor(input);
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
+ }