@zq-silk/yui 0.6.16 → 0.7.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.
Files changed (69) hide show
  1. package/dist/cli/commandCatalog.js +3 -7
  2. package/dist/cli.js +12 -33
  3. package/dist/commands/executionAuditCommands.js +19 -0
  4. package/dist/commands/globalRoleCommands.js +70 -0
  5. package/dist/commands/taskActor.js +3 -2
  6. package/dist/commands/taskCommands.js +160 -41
  7. package/dist/commands/taskContextCommand.js +1 -1
  8. package/dist/commands/taskInputCommands.js +3 -2
  9. package/dist/commands/taskRoleRuntimeStatus.js +3 -3
  10. package/dist/context/contextSnapshot.js +228 -0
  11. package/dist/context/roleSessionContext.js +3 -1
  12. package/dist/context/runContextContract.js +162 -0
  13. package/dist/context/runContextPack.js +322 -0
  14. package/dist/context/sessionBootstrapManifest.js +81 -0
  15. package/dist/context/sessionProtocolIdentity.js +23 -0
  16. package/dist/controller/agentRuntimeObserver.js +6 -1
  17. package/dist/controller/controller.js +4 -3
  18. package/dist/controller/fileSchedulerStoreAdapter.js +446 -145
  19. package/dist/controller/jobControl.js +2 -1
  20. package/dist/controller/runtime.js +83 -0
  21. package/dist/controller/runtimeHookRunFence.js +6 -2
  22. package/dist/controller/sessionOwnerReconciliation.js +5 -0
  23. package/dist/executor/agentAdapter.js +7 -2
  24. package/dist/executor/agentExecutor.js +23 -0
  25. package/dist/executor/effectiveLaunch.js +24 -0
  26. package/dist/executor/executorRegistry.js +7 -1
  27. package/dist/executor/fileRoleLaunchPlanner.js +73 -27
  28. package/dist/lifecycle/exactRunTerminalization.js +2 -3
  29. package/dist/lifecycle/providerErrorClass.js +8 -3
  30. package/dist/observability/executionAudit.js +87 -2
  31. package/dist/repository/taskWorkspacePreparer.js +2 -2
  32. package/dist/run/agentRun.js +101 -16
  33. package/dist/run/providerRetry.js +167 -56
  34. package/dist/run/providerRetryConfig.js +5 -1
  35. package/dist/run/runControlRequest.js +50 -0
  36. package/dist/runtime/agentDriver.js +47 -0
  37. package/dist/runtime/agentHost.js +327 -0
  38. package/dist/runtime/builtinAgentDrivers.js +23 -1
  39. package/dist/runtime/builtinTranscriptObserver.js +4 -0
  40. package/dist/runtime/builtinTranscriptUsage.js +2 -0
  41. package/dist/runtime/exactControlPlane.js +2 -2
  42. package/dist/runtime/globalProcessExitStore.js +38 -0
  43. package/dist/runtime/launchBroker.js +95 -0
  44. package/dist/runtime/processExitObservation.js +60 -0
  45. package/dist/runtime/runtimeBinding.js +6 -0
  46. package/dist/runtime/runtimeObservation.js +27 -6
  47. package/dist/runtime/runtimeProjection.js +6 -3
  48. package/dist/runtime/runtimeStopReceipt.js +42 -0
  49. package/dist/runtime/sessionTerminationGuard.js +13 -0
  50. package/dist/runtime/tmuxAdapters.js +203 -220
  51. package/dist/scheduler/activeRoleRunDelivery.js +24 -3
  52. package/dist/scheduler/leaderWakeupProcessor.js +18 -60
  53. package/dist/scheduler/roleRunLiveness.js +61 -27
  54. package/dist/storage/migration/productionRegistry.js +264 -0
  55. package/dist/storage/sqliteSchema.js +23 -2
  56. package/dist/storage/sqliteStore.js +39 -2
  57. package/dist/storage/taskStore.js +54 -5
  58. package/dist/storage/upgrade/recordVersions.js +3 -1
  59. package/dist/storage/upgrade/sqliteStateMigration.js +10 -0
  60. package/dist/task/taskRecordReference.js +1 -0
  61. package/dist/tmux/tmuxManager.js +15 -4
  62. package/dist/web/assets/client/components.js +1 -1
  63. package/package.json +1 -1
  64. package/skills/yui-leader/SKILL.md +10 -5
  65. package/skills/yui-operator/SKILL.md +4 -0
  66. package/skills/yui-reviewer/SKILL.md +4 -0
  67. package/skills/yui-runtime/SKILL.md +61 -0
  68. package/skills/yui-worker/SKILL.md +82 -218
  69. package/dist/executor/managedClaudeRunner.js +0 -121
@@ -274,8 +274,12 @@ function normalizePayload(kind, input) {
274
274
  if (!["model", "tool", "subagent", "provider", "resource"].includes(input.activity ?? "")) {
275
275
  throw new Error("activity.observed requires an activity kind.");
276
276
  }
277
- if (input.usage !== undefined)
278
- validateUsage(input.usage);
277
+ if (input.usage !== undefined) {
278
+ const usage = input.usage;
279
+ validateUsage(usage.semantics === undefined
280
+ ? { ...usage, semantics: "cumulative-session" }
281
+ : usage);
282
+ }
279
283
  }
280
284
  if (kind === "observer.health") {
281
285
  requireIdentity(input.sourceId, "Runtime observer source id");
@@ -339,7 +343,12 @@ function normalizePayload(kind, input) {
339
343
  ...(input.activityId === undefined
340
344
  ? {}
341
345
  : { activityId: requireIdentity(input.activityId, "Runtime activity id") }),
342
- ...(input.usage === undefined ? {} : { usage: Object.freeze({ ...input.usage }) }),
346
+ ...(input.usage === undefined
347
+ ? {}
348
+ : { usage: Object.freeze({
349
+ ...input.usage,
350
+ semantics: input.usage.semantics ?? "cumulative-session"
351
+ }) }),
343
352
  ...(observerSource === undefined ? {} : { observerSource }),
344
353
  ...(input.sourceId === undefined
345
354
  ? {}
@@ -468,7 +477,10 @@ function normalizeFailure(input) {
468
477
  : { lastOutput: requireText(input.lastOutput, "Runtime failure last output") }),
469
478
  ...(input.runTerminal === undefined
470
479
  ? {}
471
- : { runTerminal: requireBoolean(input.runTerminal, "Runtime failure runTerminal") })
480
+ : { runTerminal: requireBoolean(input.runTerminal, "Runtime failure runTerminal") }),
481
+ ...(input.retryAfterMs === undefined
482
+ ? {}
483
+ : { retryAfterMs: requirePositiveMilliseconds(input.retryAfterMs) })
472
484
  });
473
485
  }
474
486
  function requireBoolean(value, label) {
@@ -476,9 +488,18 @@ function requireBoolean(value, label) {
476
488
  throw new Error(`${label} must be boolean.`);
477
489
  return value;
478
490
  }
491
+ function requirePositiveMilliseconds(value) {
492
+ if (!Number.isSafeInteger(value) || value <= 0) {
493
+ throw new Error("Runtime failure retryAfterMs must be a positive safe integer.");
494
+ }
495
+ return value;
496
+ }
479
497
  function validateUsage(input) {
480
- for (const [name, value] of Object.entries(input)) {
481
- if (!Number.isSafeInteger(value) || value < 0) {
498
+ if (!["cumulative-session", "request-context", "remaining-context"].includes(input.semantics)) {
499
+ throw new Error("Runtime usage semantics are invalid.");
500
+ }
501
+ for (const [name, value] of Object.entries(input).filter(([name]) => name !== "semantics")) {
502
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
482
503
  throw new Error(`Runtime usage ${name} must be a non-negative safe integer.`);
483
504
  }
484
505
  }
@@ -489,10 +489,13 @@ function next(current, patch) {
489
489
  });
490
490
  }
491
491
  function usageAdvanced(previous, current) {
492
+ if (current.semantics === "remaining-context")
493
+ return false;
494
+ if (current.semantics === "request-context")
495
+ return usageTotal(current) > 0;
492
496
  // A first cumulative snapshot may contain history from a resumed native
493
- // Session. It establishes the generation baseline but cannot prove that
494
- // tokens were consumed during the current observation window.
495
- if (previous === undefined)
497
+ // Session. It establishes a baseline but cannot prove current progress.
498
+ if (previous === undefined || previous.semantics !== "cumulative-session")
496
499
  return false;
497
500
  return usageTotal(current) > usageTotal(previous);
498
501
  }
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ export function writeRuntimeStopReceipt(home, launchId, requestedAt) {
5
+ const receipt = Object.freeze({
6
+ schemaVersion: 1,
7
+ receiptId: `runtime-stop-${createHash("sha256")
8
+ .update(`${launchId}\0${requestedAt.toISOString()}`)
9
+ .digest("hex")}`,
10
+ launchId,
11
+ requestedAt: requestedAt.toISOString()
12
+ });
13
+ const path = stopReceiptPath(home, launchId);
14
+ mkdirSync(resolve(join(home, "runtime", "stop-receipts")), { recursive: true, mode: 0o700 });
15
+ const temporary = `${path}.tmp-${process.pid}`;
16
+ writeFileSync(temporary, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
17
+ renameSync(temporary, path);
18
+ chmodSync(path, 0o600);
19
+ return receipt;
20
+ }
21
+ export function readRuntimeStopReceipt(home, launchId) {
22
+ try {
23
+ const value = JSON.parse(readFileSync(stopReceiptPath(home, launchId), "utf8"));
24
+ if (value.schemaVersion !== 1 || value.launchId !== launchId
25
+ || typeof value.receiptId !== "string" || !Number.isFinite(Date.parse(value.requestedAt))) {
26
+ throw new Error("Runtime stop receipt is invalid.");
27
+ }
28
+ return Object.freeze({ ...value });
29
+ }
30
+ catch (error) {
31
+ if (error.code === "ENOENT")
32
+ return null;
33
+ throw error;
34
+ }
35
+ }
36
+ export function removeRuntimeStopReceipt(home, launchId) {
37
+ rmSync(stopReceiptPath(home, launchId), { force: true });
38
+ }
39
+ function stopReceiptPath(home, launchId) {
40
+ const name = createHash("sha256").update(launchId).digest("hex");
41
+ return resolve(join(home, "runtime", "stop-receipts", `${name}.json`));
42
+ }
@@ -20,6 +20,19 @@ export async function terminateSessionOwners(owner, records, ports, options = {}
20
20
  const pollMs = positiveDuration(options.pollMs, DEFAULT_TERMINATION_POLL_MS, "pollMs");
21
21
  const now = ports.now();
22
22
  ports.emit({ stage: "stop-requested", owner, at: now });
23
+ // Persist one exact launch receipt before any graceful stop can kill the
24
+ // Host. The owner-wide event remains for compatibility and summary display.
25
+ for (const record of records) {
26
+ ports.emit({
27
+ stage: "stop-requested",
28
+ owner,
29
+ launchId: record.launchId,
30
+ ...(record.nativeSessionId === undefined
31
+ ? {}
32
+ : { nativeSessionId: record.nativeSessionId }),
33
+ at: now
34
+ });
35
+ }
23
36
  // A launch-fence scan is a point-in-time observation, not a durable owner
24
37
  // inventory: /proc entries can disappear between the directory and
25
38
  // environment reads. Retain every exact child identity observed during the
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { fileURLToPath } from "node:url";
3
+ import { resolve } from "node:path";
2
4
  import { createRuntimeBinding } from "./runtimeBinding.js";
3
5
  import { normalizeRuntimeOwner } from "./runtimeOwner.js";
4
- import { RuntimeHostContentionError, RuntimeLaunchError } from "./ports.js";
5
- import { toRuntimeLaunchFailure, hasFatalLaunchOutput } from "./launchDiagnostics.js";
6
- import { builtinAgentDriverRegistry } from "./builtinAgentDrivers.js";
6
+ import { RuntimeHostContentionError } from "./ports.js";
7
+ import { toRuntimeLaunchFailure } from "./launchDiagnostics.js";
7
8
  import { requireSafeIdentity } from "./validation.js";
9
+ import { launchBrokerForHome } from "./launchBroker.js";
10
+ import { AGENT_HOST_CONTROL_PROTOCOL, sendAgentHostLaunchControl } from "./agentHost.js";
8
11
  const DEFAULT_INACTIVITY_TIMEOUT_MS = 300_000;
9
12
  /**
10
13
  * Runtime lifecycle adapter for the current tmux host. The returned hostRef is
@@ -163,16 +166,74 @@ export class TmuxSessionHost {
163
166
  if (writableHumanAttached) {
164
167
  throw new RuntimeHostContentionError("writable-client", `A writable human is attached to ${request.owner.taskId}/${request.owner.roleName}.`);
165
168
  }
166
- if (request.owner.scope === "task"
167
- && request.adapterId === "claude"
168
- && request.runId !== undefined
169
- && await probeRoleStatus(this.tmux, hostId, request.owner.roleName) === "running") {
170
- // Managed Claude is process-per-Run. A live Role window here belongs to
171
- // an earlier process (or to recovery of the exact reserved Run); never
172
- // plan, persist pre-start state, or inject input into it. The coordinator
173
- // decides between same-generation recovery and a retry after natural
174
- // exit from the durable reservation identity.
175
- return createRuntimeBinding({
169
+ const input = {
170
+ roleName: request.owner.roleName,
171
+ agentId: request.agentId,
172
+ adapterId: request.adapterId,
173
+ effective: request.effective,
174
+ launchId: request.launchId,
175
+ mode: request.mode,
176
+ ...(request.runId === undefined ? {} : { runId: request.runId }),
177
+ ...(request.runtimeIsolation === undefined
178
+ ? {}
179
+ : { runtimeIsolation: request.runtimeIsolation }),
180
+ ...(request.environment === undefined
181
+ ? {}
182
+ : { environment: request.environment }),
183
+ ...(request.mode === "resume" ? { nativeSessionId: request.nativeSessionId } : {})
184
+ };
185
+ const planned = request.owner.scope === "task"
186
+ ? this.planner.plan({ taskId: request.owner.taskId, ...input })
187
+ : this.planner.planGlobalRole(input);
188
+ if (planned.role.name !== request.owner.roleName) {
189
+ throw new Error("Planned Role does not match the runtime owner.");
190
+ }
191
+ if (planned.role.workspace !== request.workspace) {
192
+ throw new Error("Planned Role workspace does not match the runtime request.");
193
+ }
194
+ const launchContext = diagnosticContext(request, planned);
195
+ if (this.#validateLaunch !== undefined) {
196
+ try {
197
+ await this.#validateLaunch(request);
198
+ }
199
+ catch (error) {
200
+ throw toRuntimeLaunchFailure(error, "validation", launchContext);
201
+ }
202
+ }
203
+ const plannedNativeSessionId = planned.session?.nativeSessionId;
204
+ if (request.mode === "resume"
205
+ && plannedNativeSessionId !== undefined
206
+ && plannedNativeSessionId !== request.nativeSessionId) {
207
+ throw new Error("Planned native session does not match the resume request.");
208
+ }
209
+ const nativeSessionId = plannedNativeSessionId
210
+ ?? (request.mode === "resume" ? request.nativeSessionId : undefined);
211
+ beforeHostStart?.({
212
+ owner: request.owner,
213
+ launchId: request.launchId,
214
+ ...(request.runId === undefined ? {} : { runId: request.runId }),
215
+ agentId: request.agentId,
216
+ adapterId: request.adapterId,
217
+ effective: request.effective,
218
+ ...(nativeSessionId === undefined ? {} : { nativeSessionId }),
219
+ ...(planned.initialPromptRunId === undefined
220
+ ? {}
221
+ : { initialPromptRunId: planned.initialPromptRunId })
222
+ });
223
+ const yuiHome = planned.launch.env.YUI_HOME;
224
+ const childLifecycle = planned.launch.childLifecycle;
225
+ // Custom/legacy planners do not yet expose the Agent Host contract. Keep
226
+ // their direct launch behavior for compatibility while all built-in
227
+ // managed Roles use the persistent Host below.
228
+ if (yuiHome === undefined || childLifecycle === undefined) {
229
+ let hostCreated;
230
+ try {
231
+ hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, planned.launch);
232
+ }
233
+ catch (error) {
234
+ throw toRuntimeLaunchFailure(error, "host-start", launchContext);
235
+ }
236
+ let binding = createRuntimeBinding({
176
237
  id: bindingId,
177
238
  launchId: request.launchId,
178
239
  owner: request.owner,
@@ -183,31 +244,99 @@ export class TmuxSessionHost {
183
244
  hostId,
184
245
  roleName: request.owner.roleName
185
246
  }),
186
- hostCreated: false,
187
- ...(request.mode === "resume"
188
- ? { nativeSessionId: request.nativeSessionId }
189
- : {})
247
+ hostCreated,
248
+ ...(hostCreated && planned.initialPromptRunId !== undefined
249
+ ? { initialPromptRunId: planned.initialPromptRunId }
250
+ : {}),
251
+ ...(nativeSessionId === undefined ? {} : { nativeSessionId })
190
252
  });
191
- }
192
- const { planned, nativeSessionId } = planManagedLaunch(this.planner, request, beforeHostStart);
193
- const launchContext = diagnosticContext(request, planned);
194
- if (this.#validateLaunch !== undefined) {
195
- try {
196
- await this.#validateLaunch(request);
197
- }
198
- catch (error) {
199
- throw toRuntimeLaunchFailure(error, "validation", launchContext);
253
+ if (hostCreated) {
254
+ const pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
255
+ if (pane?.dead === true) {
256
+ await deadHostLaunchFailure(this.tmux, hostId, request.owner.roleName, pane, launchContext);
257
+ }
258
+ if (request.mode === "new"
259
+ && request.owner.scope === "task"
260
+ && request.runId !== undefined
261
+ && planned.initialPromptRunId === request.runId
262
+ && this.#waitForNativeSession !== undefined) {
263
+ binding = createRuntimeBinding({
264
+ ...binding,
265
+ nativeSessionId: await this.waitForNativeSessionDiscovery(request, hostId, launchContext)
266
+ });
267
+ }
268
+ if (pane !== undefined)
269
+ this.#onHostCreated?.({ binding, pane });
200
270
  }
271
+ return binding;
201
272
  }
202
- // Process creation is last: every local invariant has already passed.
273
+ const broker = launchBrokerForHome(yuiHome);
274
+ const reservation = broker.reserve(Object.freeze({
275
+ schemaVersion: 1,
276
+ launchId: request.launchId,
277
+ command: planned.launch.command,
278
+ args: [...planned.launch.args],
279
+ environment: { ...planned.launch.env },
280
+ cwd: planned.role.cwd ?? planned.role.workspace,
281
+ childLifecycle,
282
+ startMode: planned.launch.deferProviderStart === true ? "idle" : "provider",
283
+ ...(planned.launch.providerInput === undefined
284
+ ? {}
285
+ : { providerInput: planned.launch.providerInput })
286
+ }));
287
+ const hostLaunch = {
288
+ command: process.execPath,
289
+ args: [
290
+ fileURLToPath(new URL("../cli.js", import.meta.url)),
291
+ "internal",
292
+ "agent-host",
293
+ reservation.launchId,
294
+ reservation.ticket
295
+ ],
296
+ env: {
297
+ YUI_HOME: resolve(yuiHome),
298
+ YUI_SESSION_SCOPE: request.owner.scope,
299
+ ...(request.owner.scope === "task" ? { YUI_TASK_ID: request.owner.taskId } : {}),
300
+ YUI_ROLE: request.owner.roleName,
301
+ YUI_LAUNCH_ID: request.launchId
302
+ }
303
+ };
203
304
  let hostCreated;
305
+ let providerChildLaunched = false;
306
+ let launchPromptAlreadySubmitted = false;
204
307
  try {
205
- hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, planned.launch);
308
+ hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, hostLaunch);
309
+ providerChildLaunched = hostCreated && planned.launch.deferProviderStart !== true;
310
+ if (!hostCreated) {
311
+ const controlResult = await sendAgentHostLaunchControl({
312
+ home: yuiHome,
313
+ scope: request.owner.scope,
314
+ ...(request.owner.scope === "task" ? { taskId: request.owner.taskId } : {}),
315
+ roleName: request.owner.roleName,
316
+ control: {
317
+ protocol: AGENT_HOST_CONTROL_PROTOCOL,
318
+ type: "launch",
319
+ launchId: reservation.launchId,
320
+ ticket: reservation.ticket
321
+ }
322
+ });
323
+ if (controlResult !== "accepted") {
324
+ broker.revoke(request.launchId);
325
+ launchPromptAlreadySubmitted = controlResult === "active-same-launch";
326
+ if (childLifecycle === "per-turn" && controlResult === "active-other-launch") {
327
+ throw new RuntimeHostContentionError("provider-child-active", `The persistent Agent Host for ${request.owner.roleName} still owns another Provider turn.`);
328
+ }
329
+ }
330
+ else {
331
+ providerChildLaunched = planned.launch.deferProviderStart !== true;
332
+ }
333
+ }
206
334
  }
207
335
  catch (error) {
208
- throw toRuntimeLaunchFailure(error, "host-start", launchContext);
336
+ broker.revoke(request.launchId);
337
+ throw error;
209
338
  }
210
- if (hostCreated
339
+ if (providerChildLaunched
211
340
  && request.owner.scope === "task"
212
341
  && planned.launch.env.YUI_JOB_CALLER_KEY !== undefined
213
342
  && this.planner.commitTaskCallerKey !== undefined) {
@@ -218,7 +347,7 @@ export class TmuxSessionHost {
218
347
  callerKey: planned.launch.env.YUI_JOB_CALLER_KEY
219
348
  });
220
349
  }
221
- let binding = createRuntimeBinding({
350
+ const binding = createRuntimeBinding({
222
351
  id: bindingId,
223
352
  launchId: request.launchId,
224
353
  owner: request.owner,
@@ -230,218 +359,66 @@ export class TmuxSessionHost {
230
359
  roleName: request.owner.roleName
231
360
  }),
232
361
  hostCreated,
233
- ...(hostCreated && planned.initialPromptRunId !== undefined
362
+ ...(providerChildLaunched && hostCreated && planned.initialPromptRunId !== undefined
234
363
  ? { initialPromptRunId: planned.initialPromptRunId }
235
364
  : {}),
365
+ ...((launchPromptAlreadySubmitted || (providerChildLaunched && !hostCreated))
366
+ && planned.initialPromptRunId !== undefined
367
+ ? { launchPromptUncertainRunId: planned.initialPromptRunId }
368
+ : {}),
236
369
  ...(nativeSessionId === undefined ? {} : { nativeSessionId })
237
370
  });
238
- if (hostCreated) {
239
- let pane;
240
- try {
241
- pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
242
- }
243
- catch (error) {
244
- throw toRuntimeLaunchFailure(error, "host-started", launchContext);
245
- }
246
- if (pane === undefined) {
247
- throw toRuntimeLaunchFailure(new Error("Managed host pane could not be inspected after creation."), "host-started", launchContext);
248
- }
249
- this.#onHostCreated?.({ binding, pane });
250
- if (pane.dead) {
251
- await deadHostLaunchFailure(this.tmux, hostId, request.owner.roleName, pane, launchContext);
252
- }
253
- if (requiresNativeSessionDiscovery(request, planned, this.#waitForNativeSession)) {
254
- const discoveredNativeSessionId = await this.waitForNativeSessionDiscovery(request, hostId, launchContext, pane);
255
- binding = createRuntimeBinding({
256
- ...binding,
257
- nativeSessionId: discoveredNativeSessionId
258
- });
259
- }
260
- this.#onHostCreated?.({ binding, pane });
371
+ if (hostCreated && this.#onHostCreated !== undefined) {
372
+ const pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
373
+ if (pane !== undefined)
374
+ this.#onHostCreated({ binding, pane });
261
375
  }
262
376
  return binding;
263
377
  }
264
- async waitForNativeSessionDiscovery(request, hostId, context, pane) {
265
- if (this.#waitForNativeSession === undefined) {
266
- throw new Error("Native session discovery is not configured.");
267
- }
378
+ async waitForNativeSessionDiscovery(request, hostId, context) {
268
379
  const controller = new AbortController();
269
380
  const discovery = this.#waitForNativeSession(request, controller.signal);
270
- // The losing branch of the race rejects when the controller aborts; keep
271
- // that rejection handled so a settled launch does not surface it later.
272
381
  discovery.catch(() => undefined);
382
+ let stopped = false;
383
+ let lastContent = "";
384
+ let lastActivityAt = Date.now();
385
+ const monitor = (async () => {
386
+ while (!stopped) {
387
+ await new Promise((resolve) => setTimeout(resolve, Math.min(1_000, this.#inactivityTimeoutMs)));
388
+ const pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
389
+ if (pane?.dead === true) {
390
+ await deadHostLaunchFailure(this.tmux, hostId, request.owner.roleName, pane, context);
391
+ }
392
+ const content = this.tmux.captureRolePane?.(hostId, request.owner.roleName, 80) ?? "";
393
+ if (content !== lastContent) {
394
+ lastContent = content;
395
+ lastActivityAt = Date.now();
396
+ }
397
+ if (Date.now() - lastActivityAt >= this.#inactivityTimeoutMs) {
398
+ throw new Error(`Agent produced no signal for ${this.#inactivityTimeoutMs}ms. `
399
+ + "The process is alive but emitted no lifecycle hook, output, or exit.");
400
+ }
401
+ }
402
+ throw new Error("Native session discovery monitor stopped.");
403
+ })();
404
+ monitor.catch(() => undefined);
273
405
  try {
274
- return await Promise.race([
275
- discovery,
276
- this.monitorPaneSignals(request, hostId, context, controller.signal)
277
- ]);
406
+ return await Promise.race([discovery, monitor]);
278
407
  }
279
408
  catch (error) {
280
409
  try {
281
410
  await stopExactRole(this.tmux, hostId, request.owner.roleName);
282
411
  }
283
412
  catch {
284
- // The launch failure below is authoritative; durable owner cleanup
285
- // remains responsible for a Provider that rejected immediate stop.
413
+ // Preserve the discovery failure; durable cleanup owns later retries.
286
414
  }
287
- throw toRuntimeLaunchFailure(error, "native-session-discovery", {
288
- ...context,
289
- pane
290
- });
415
+ throw toRuntimeLaunchFailure(error, "native-session-discovery", context);
291
416
  }
292
417
  finally {
418
+ stopped = true;
293
419
  controller.abort();
294
420
  }
295
421
  }
296
- /**
297
- * Monitors the managed pane for agent-emitted signals while native session
298
- * discovery is pending. The agent's own behavior drives the outcome:
299
- *
300
- * - Pane death → failure with exit status and stderr (the agent exited).
301
- * - Fatal output (auth, config, executable errors) → failure with the
302
- * agent's own error text.
303
- * - No signal at all (no hook, no output change, no exit) for the
304
- * inactivity window → backstop failure.
305
- *
306
- * There is no fixed wall-clock timeout: a slow-but-active agent is never
307
- * killed merely for taking too long to start.
308
- */
309
- async monitorPaneSignals(request, hostId, context, signal) {
310
- let lastContent = "";
311
- let lastActivityAt = Date.now();
312
- while (!signal.aborted) {
313
- await abortableDelay(PANE_SIGNAL_POLL_MS, signal);
314
- let pane;
315
- try {
316
- pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
317
- }
318
- catch {
319
- // A tmux inspection hiccup must not fabricate a Provider death; the
320
- // inactivity backstop remains the safety net.
321
- continue;
322
- }
323
- if (pane !== undefined && pane.dead) {
324
- await deadHostLaunchFailure(this.tmux, hostId, request.owner.roleName, pane, context);
325
- }
326
- let content = "";
327
- try {
328
- content = this.tmux.captureRolePane?.(hostId, request.owner.roleName, 80) ?? "";
329
- }
330
- catch {
331
- // Capture is best-effort; pane death and hooks remain authoritative.
332
- }
333
- if (content !== lastContent) {
334
- lastContent = content;
335
- lastActivityAt = Date.now();
336
- if (hasFatalLaunchOutput(content)) {
337
- throw toRuntimeLaunchFailure(new Error("Agent emitted a fatal error before native session discovery completed."), "native-session-discovery", {
338
- ...context,
339
- ...(pane !== undefined ? { pane } : {}),
340
- stderrTail: content
341
- });
342
- }
343
- }
344
- if (Date.now() - lastActivityAt >= this.#inactivityTimeoutMs) {
345
- throw new Error(`Agent produced no signal for ${this.#inactivityTimeoutMs}ms. `
346
- + "The process is alive but emitted no lifecycle hook, output, or exit.");
347
- }
348
- }
349
- throw new Error("Native session discovery was aborted.");
350
- }
351
- }
352
- function requiresNativeSessionDiscovery(request, planned, wait) {
353
- return request.mode === "new"
354
- && request.owner.scope === "task"
355
- && request.runId !== undefined
356
- && planned.initialPromptRunId === request.runId
357
- && wait !== undefined
358
- && builtinAgentDriverRegistry()
359
- .requireByAdapterId(request.adapterId)
360
- .capabilities.observation.sessionBootstrap === "discovered";
361
- }
362
- function positiveInteger(value, label) {
363
- if (!Number.isSafeInteger(value) || value <= 0) {
364
- throw new Error(`${label} must be a positive integer.`);
365
- }
366
- return value;
367
- }
368
- const PANE_SIGNAL_POLL_MS = 1_000;
369
- function abortableDelay(milliseconds, signal) {
370
- return new Promise((resolve, reject) => {
371
- if (signal.aborted) {
372
- reject(new Error("Native session discovery was aborted."));
373
- return;
374
- }
375
- const onAbort = () => {
376
- clearTimeout(timer);
377
- reject(new Error("Native session discovery was aborted."));
378
- };
379
- const timer = setTimeout(() => {
380
- signal.removeEventListener("abort", onAbort);
381
- resolve();
382
- }, milliseconds);
383
- signal.addEventListener("abort", onAbort, { once: true });
384
- });
385
- }
386
- function planManagedLaunch(planner, request, beforeHostStart) {
387
- try {
388
- const input = {
389
- roleName: request.owner.roleName,
390
- agentId: request.agentId,
391
- adapterId: request.adapterId,
392
- effective: request.effective,
393
- launchId: request.launchId,
394
- mode: request.mode,
395
- ...(request.runId === undefined ? {} : { runId: request.runId }),
396
- ...(request.runtimeIsolation === undefined
397
- ? {}
398
- : { runtimeIsolation: request.runtimeIsolation }),
399
- ...(request.environment === undefined
400
- ? {}
401
- : { environment: request.environment }),
402
- ...(request.mode === "resume" ? { nativeSessionId: request.nativeSessionId } : {})
403
- };
404
- const planned = request.owner.scope === "task"
405
- ? planner.plan({ taskId: request.owner.taskId, ...input })
406
- : planner.planGlobalRole(input);
407
- if (planned.role.name !== request.owner.roleName) {
408
- throw new Error("Planned Role does not match the runtime owner.");
409
- }
410
- if (planned.role.workspace !== request.workspace) {
411
- throw new Error("Planned Role workspace does not match the runtime request.");
412
- }
413
- const plannedNativeSessionId = planned.session?.nativeSessionId;
414
- if (request.mode === "resume"
415
- && plannedNativeSessionId !== undefined
416
- && plannedNativeSessionId !== request.nativeSessionId) {
417
- throw new Error("Planned native session does not match the resume request.");
418
- }
419
- const nativeSessionId = plannedNativeSessionId
420
- ?? (request.mode === "resume" ? request.nativeSessionId : undefined);
421
- beforeHostStart?.({
422
- owner: request.owner,
423
- launchId: request.launchId,
424
- ...(request.runId === undefined ? {} : { runId: request.runId }),
425
- agentId: request.agentId,
426
- adapterId: request.adapterId,
427
- effective: request.effective,
428
- ...(nativeSessionId === undefined ? {} : { nativeSessionId }),
429
- ...(planned.initialPromptRunId === undefined
430
- ? {}
431
- : { initialPromptRunId: planned.initialPromptRunId })
432
- });
433
- return { planned, nativeSessionId };
434
- }
435
- catch (error) {
436
- if (error instanceof RuntimeLaunchError
437
- || error instanceof RuntimeHostContentionError) {
438
- throw error;
439
- }
440
- throw toRuntimeLaunchFailure(error, "validation", {
441
- agentId: request.agentId,
442
- cwd: request.workspace
443
- });
444
- }
445
422
  }
446
423
  function diagnosticContext(request, planned) {
447
424
  return {
@@ -490,6 +467,12 @@ async function ensureRoleWindow(tmux, hostId, role, launch) {
490
467
  ? tmux.ensureRoleWindow(hostId, role, launch)
491
468
  : tmux.ensureRoleWindowAsync(hostId, role, launch);
492
469
  }
470
+ function positiveInteger(value, label) {
471
+ if (!Number.isSafeInteger(value) || value <= 0) {
472
+ throw new Error(`${label} must be a positive integer.`);
473
+ }
474
+ return value;
475
+ }
493
476
  async function probeRoleStatus(tmux, hostId, roleName) {
494
477
  return tmux.probeRoleStatusAsync === undefined
495
478
  ? tmux.probeRoleStatus(hostId, roleName)