@zq-silk/yui 0.12.0 → 0.12.1

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 (53) hide show
  1. package/README.md +8 -9
  2. package/dist/cli/commandCatalog.js +11 -6
  3. package/dist/cli/interactionPolicy.js +5 -6
  4. package/dist/cli/updateCommand.js +3 -1
  5. package/dist/cli/updateOrchestrator.js +173 -28
  6. package/dist/cli/updatePorts.js +137 -8
  7. package/dist/cli/upgradeCommand.js +19 -9
  8. package/dist/cli.js +51 -14
  9. package/dist/commands/configCommands.js +1 -1
  10. package/dist/commands/executionAuditCommands.js +2 -1
  11. package/dist/commands/taskCommands.js +80 -29
  12. package/dist/commands/taskContextCommand.js +6 -2
  13. package/dist/commands/taskRoleRuntimeStatus.js +31 -7
  14. package/dist/config/configCatalog.js +1 -1
  15. package/dist/controller/clientRuntime.js +38 -2
  16. package/dist/controller/controller.js +23 -15
  17. package/dist/controller/fileSchedulerStoreAdapter.js +248 -138
  18. package/dist/controller/runtime.js +56 -1
  19. package/dist/controller/runtimeHookRunFence.js +19 -4
  20. package/dist/controller/structuredProviderObservation.js +20 -3
  21. package/dist/core/controllerClient.js +20 -2
  22. package/dist/core/controllerServer.js +1 -0
  23. package/dist/executor/agentExecutor.js +48 -46
  24. package/dist/executor/fileRoleLaunchPlanner.js +94 -30
  25. package/dist/lifecycle/exactRunTerminalization.js +68 -3
  26. package/dist/observability/executionAudit.js +5 -0
  27. package/dist/release/runtimeRelease.js +20 -0
  28. package/dist/run/recoveryProjection.js +45 -6
  29. package/dist/runtime/agentHost.js +159 -85
  30. package/dist/runtime/conversationSwitch.js +277 -0
  31. package/dist/runtime/index.js +1 -1
  32. package/dist/runtime/launchBroker.js +12 -0
  33. package/dist/runtime/processExitOutbox.js +88 -0
  34. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  35. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  36. package/dist/runtime/runtimeObservation.js +15 -0
  37. package/dist/runtime/runtimeProjection.js +6 -7
  38. package/dist/runtime/tmuxAdapters.js +4 -1
  39. package/dist/scheduler/activeRoleRunDelivery.js +31 -196
  40. package/dist/scheduler/leaderWakeupProcessor.js +39 -133
  41. package/dist/scheduler/roleRunStall.js +53 -17
  42. package/dist/storage/sqliteSchema.js +57 -24
  43. package/dist/storage/sqliteStore.js +23 -4
  44. package/dist/storage/upgrade/homeClassification.js +52 -0
  45. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  46. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  47. package/dist/task/nextAction.js +0 -34
  48. package/dist/web/webSnapshot.js +3 -1
  49. package/package.json +1 -1
  50. package/skills/yui-leader/SKILL.md +7 -4
  51. package/skills/yui-operator/SKILL.md +4 -4
  52. package/skills/yui-reviewer/SKILL.md +7 -4
  53. package/dist/lifecycle/taskRoleSessionReset.js +0 -118
@@ -9,6 +9,7 @@ import { resolveRuntimeHealth } from "../config/yuiConfig.js";
9
9
  import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
10
10
  import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
11
11
  import { projectSessionTokenMetrics, resolveSessionTokenIdentity } from "../runtime/sessionTokenMetrics.js";
12
+ import { freshConversationLaunchBlockers, projectConversationSwitch } from "../runtime/conversationSwitch.js";
12
13
  export function inspectTaskRoleRuntimeStatuses(taskId, roles, store, panes, now = new Date()) {
13
14
  const taskOpenInputRequestCount = store.listInputRequests(taskId)
14
15
  .filter((request) => request.status === "open").length;
@@ -93,10 +94,15 @@ export function renderTaskRoleRuntimeStatus(status) {
93
94
  ? `needs-attention (${status.stall.kind ?? "workflow-not-progressing"}; no workflow progress since ${status.stall.progressAt ?? "unknown"})`
94
95
  : "none"}`,
95
96
  ` Native session ${nativeSession}`,
97
+ ` Session switch ${status.conversationSwitch === null
98
+ ? "none"
99
+ : `${status.conversationSwitch.status} (${status.conversationSwitch.requestId}; ${status.conversationSwitch.reason})`}`,
96
100
  ` Agent runtime ${runtime}`,
97
101
  ` Session tokens ${sessionTokens}`,
98
102
  ` Runtime cleanup ${status.runtimeCleanupPending ? "pending" : "none"}`,
99
- ` Fresh launch ${status.freshLaunchAllowed ? "allowed" : "blocked"}`,
103
+ ` Fresh launch ${status.freshLaunchAllowed
104
+ ? "allowed"
105
+ : `blocked (${status.freshLaunchBlockers.join(", ")})`}`,
100
106
  ` tmux pane ${tmux}`,
101
107
  ` Workspace ${status.workspace.managed ? status.workspace.root : status.workspace.path}`,
102
108
  ...workspaceDetails
@@ -117,19 +123,30 @@ export function taskRoleLastRunLabel(status) {
117
123
  }
118
124
  export function taskRoleNativeSessionLabel(status) {
119
125
  if (status.runtimeCleanupPending && status.nativeSession === null)
120
- return "reset (cleanup-pending)";
126
+ return "unbound (cleanup-pending)";
121
127
  return status.nativeSession?.status ?? "unbound";
122
128
  }
123
129
  export function inspectTaskRoleSessionRecovery(taskId, roleName, store) {
124
130
  const sessions = store.getTaskRoleSessionSet(taskId, roleName);
125
131
  const target = runtimeLifecycleTarget({ scope: "task", taskId, roleName });
126
132
  const runtimeMailbox = store.getWorkMailbox(target);
133
+ const roleMailbox = store.getWorkMailbox({ kind: "role", taskId, roleName });
134
+ const switchBlockers = freshConversationLaunchBlockers({
135
+ sessions,
136
+ events: store.listEvents(taskId),
137
+ mailbox: roleMailbox,
138
+ roleName
139
+ });
140
+ const freshLaunchBlockers = [
141
+ ...switchBlockers,
142
+ ...(hasRuntimeLifecycleWork(runtimeMailbox) ? ["runtime-lifecycle-busy"] : [])
143
+ ];
127
144
  return {
128
145
  taskId,
129
146
  roleName,
130
147
  runtimeCleanupPending: hasRuntimeCleanupObligation(runtimeMailbox),
131
- freshLaunchAllowed: (sessions === null
132
- || sessions.sessions[sessions.activeAgentId] === undefined) && !hasRuntimeLifecycleWork(runtimeMailbox)
148
+ freshLaunchAllowed: freshLaunchBlockers.length === 0,
149
+ freshLaunchBlockers
133
150
  };
134
151
  }
135
152
  export function taskRoleOpenInputLabel(status) {
@@ -186,6 +203,7 @@ function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputReques
186
203
  ? { managed: false, path: role.workspace }
187
204
  : { ...managedWorkspace, managed: true };
188
205
  const events = store.listEvents(taskId);
206
+ const conversationSwitch = projectConversationSwitch(events, role.name, sessions);
189
207
  const sessionTokens = projectSessionTokenMetrics(events, resolveSessionTokenIdentity(nativeSession === null
190
208
  ? null
191
209
  : { taskId, roleName: role.name, ...nativeSession }));
@@ -225,6 +243,7 @@ function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputReques
225
243
  tmux,
226
244
  workspace,
227
245
  sessionTokens,
246
+ conversationSwitch,
228
247
  runtime,
229
248
  stall
230
249
  };
@@ -243,7 +262,9 @@ function sessionCumulativeTokenLabel(metrics) {
243
262
  }
244
263
  function latestStallKind(events, runId) {
245
264
  const event = [...events]
246
- .filter((candidate) => candidate.type === "run.stalled" && candidate.payload.runId === runId)
265
+ .filter((candidate) => candidate.type === "run.stalled"
266
+ && candidate.payload.runId === runId
267
+ && candidate.payload.status !== "diagnostic-only")
247
268
  .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0];
248
269
  return event?.payload.kind === "delivery-stalled" || event?.payload.kind === "workflow-not-progressing"
249
270
  ? event.payload.kind
@@ -253,7 +274,7 @@ function calculateHealth(role, activeRun, lastRun, nativeSession, runtimeCleanup
253
274
  if (runtimeCleanupPending && nativeSession === null) {
254
275
  return {
255
276
  health: "needs-attention",
256
- healthReason: "the native Session was reset; verified runtime cleanup is pending"
277
+ healthReason: "the native Session is unbound; verified runtime cleanup is pending"
257
278
  };
258
279
  }
259
280
  if (role.status === "failed" || role.status === "exited") {
@@ -315,12 +336,15 @@ function calculateHealth(role, activeRun, lastRun, nativeSession, runtimeCleanup
315
336
  case "broken":
316
337
  return { health: "failed", healthReason: runtime.healthReason };
317
338
  case "stopped":
318
- case "diagnostic-needed":
319
339
  case "ready":
320
340
  case "awaiting-provider-acceptance":
321
341
  case "runtime-unobservable":
322
342
  case "starting":
323
343
  return { health: "needs-attention", healthReason: runtime.healthReason };
344
+ case "diagnostic-needed":
345
+ return runtime.observerStatus === "degraded" || runtime.observerStatus === "unavailable"
346
+ ? { health: "needs-attention", healthReason: runtime.healthReason }
347
+ : { health: "running", healthReason: runtime.healthReason };
324
348
  case "waiting-user":
325
349
  return { health: "blocked-input", healthReason: runtime.healthReason };
326
350
  case "waiting-permission":
@@ -13,7 +13,7 @@ export const CONFIG_DEFINITIONS = Object.freeze([
13
13
  { key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering; durable JSON remains UTC." },
14
14
  { key: "reconciliation-interval-seconds", domain: "runtime", property: "reconciliationIntervalSeconds", label: "Reconciliation interval", summary: "Recovery reconciliation interval, 5-300 seconds (default: 120).", takesEffect: "The running Controller is refreshed after the value is saved." },
15
15
  { key: "controller-task-concurrency", domain: "runtime", property: "controllerTaskConcurrency", label: "Controller Task concurrency", summary: "Maximum Tasks reconciled concurrently, 1-32 (default: 4).", takesEffect: "After the Controller restarts." },
16
- { key: "runtime-health", domain: "runtime", property: "runtimeHealth", label: "Runtime health thresholds", summary: "Quiet, diagnostic, and semantic-stall thresholds in seconds; values must be strictly increasing (defaults: 300/600/1800).", takesEffect: "CLI and Web projections immediately; scheduler stall handling after the Controller restarts." },
16
+ { key: "runtime-health", domain: "runtime", property: "runtimeHealth", label: "Runtime health thresholds", summary: "Quiet, checkpoint-overdue, and read-only diagnostic thresholds in seconds; values must be strictly increasing (defaults: 300/900/1800).", takesEffect: "CLI and Web projections immediately; scheduler diagnostics after the Controller restarts." },
17
17
  { key: "agent-launch-inactivity-timeout-seconds", domain: "runtime", property: "agentLaunchInactivityTimeoutSeconds", label: "Agent launch inactivity timeout", summary: "Maximum launch silence before startup fails, 15-3600 seconds (default: 300).", takesEffect: "After the Controller restarts." },
18
18
  { key: "delivery-timeout-seconds", domain: "runtime", property: "deliveryTimeoutSeconds", label: "Delivery timeout", summary: "Total control-plane delivery retry budget, 5-600 seconds (default: 120).", takesEffect: "After the Controller restarts; internal retry cadence remains automatic." },
19
19
  { key: "provider-retry-mode", domain: "runtime", property: "providerRetryMode", label: "Provider retry mode", summary: "Provider retry mode: off, shadow, or enforce (default: enforce).", takesEffect: "The next eligible provider-failure decision." },
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
- import { callController, readControllerDiscovery, stopOrphanedFileTaskController, stopPreviousFileTaskController } from "../core/controllerClient.js";
3
+ import { callController, ControllerClientError, readControllerDiscovery, stopOrphanedFileTaskController, stopPreviousFileTaskController } from "../core/controllerClient.js";
4
4
  import { controllerSocketPath } from "../core/controllerEndpoint.js";
5
5
  import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "../core/protocol.js";
6
6
  import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
@@ -14,6 +14,7 @@ import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
14
14
  import { WorkspaceCleanupBlockedError } from "../repository/taskWorkspacePreparer.js";
15
15
  import { CONTROLLER_SHUTDOWN_TIMEOUT_MS, LIFECYCLE_REQUEST_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
16
16
  import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
17
+ import { isForeignHandoverLockHeld } from "../release/runtimeRelease.js";
17
18
  const STARTUP_TIMEOUT_MS = 5_000;
18
19
  const POLL_INTERVAL_MS = 50;
19
20
  const ENVIRONMENT_REFRESH_TIMEOUT_MS = 500;
@@ -54,6 +55,7 @@ export async function assertFileTaskControllerStorageCompatible(home, options =
54
55
  /** Starts the per-home FileTask Controller on demand and waits until callable. */
55
56
  export async function ensureFileTaskController(home, options = {}) {
56
57
  const call = options.call ?? callController;
58
+ await waitForForeignHandover(home, options);
57
59
  try {
58
60
  const status = await call(home, "controller.status", {});
59
61
  assertCompatibleControllerStatus(status, options.expectedVersion);
@@ -63,6 +65,10 @@ export async function ensureFileTaskController(home, options = {}) {
63
65
  if (!isUnavailable(error))
64
66
  throw error;
65
67
  }
68
+ // A handover may have started after the first lock check and before the
69
+ // failed discovery/socket call. Re-check before spawning so a managed
70
+ // Session can never resurrect the old Controller during an update.
71
+ await waitForForeignHandover(home, options);
66
72
  const timeoutMs = positive(options.startupTimeoutMs, STARTUP_TIMEOUT_MS, "startupTimeoutMs");
67
73
  const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
68
74
  const spawnController = options.spawnController ?? spawnDetachedFileTaskController;
@@ -322,15 +328,22 @@ export async function callFileTaskController(home, method, params = {}, options
322
328
  : { stopped: false, alreadyStopped: true };
323
329
  }
324
330
  }
331
+ await waitForForeignHandover(home, options);
325
332
  try {
326
333
  return await call(home, method, params, {
327
334
  timeoutMs: options.requestTimeoutMs
328
335
  });
329
336
  }
330
337
  catch (error) {
331
- if (!isUnavailable(error))
338
+ if (!isUnavailable(error)
339
+ && !(isControllerDraining(error)
340
+ && isForeignHandoverLockHeld(home, options.handoverOwnerPid ?? process.pid))) {
332
341
  throw error;
342
+ }
333
343
  }
344
+ // A pre-connect unavailable result is safe to retry. Ambiguous post-send
345
+ // failures are classified separately by the socket client and surface.
346
+ await waitForForeignHandover(home, options);
334
347
  await ensureFileTaskController(home, options);
335
348
  return call(home, method, params, {
336
349
  timeoutMs: options.requestTimeoutMs
@@ -631,6 +644,29 @@ function isUnavailable(error) {
631
644
  return code === "CONTROLLER_NOT_RUNNING"
632
645
  || code === "CONTROLLER_UNAVAILABLE";
633
646
  }
647
+ function isControllerDraining(error) {
648
+ return typeof error === "object"
649
+ && error !== null
650
+ && "code" in error
651
+ && error.code === "CONTROLLER_DRAINING";
652
+ }
653
+ async function waitForForeignHandover(home, options) {
654
+ const allowedOwnerPid = options.handoverOwnerPid ?? process.pid;
655
+ if (!Number.isSafeInteger(allowedOwnerPid) || allowedOwnerPid < 1) {
656
+ throw new TypeError("handoverOwnerPid must be a positive integer");
657
+ }
658
+ if (!isForeignHandoverLockHeld(home, allowedOwnerPid))
659
+ return;
660
+ const timeoutMs = positive(options.handoverWaitTimeoutMs, LIFECYCLE_REQUEST_TIMEOUT_MS, "handoverWaitTimeoutMs");
661
+ const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
662
+ const deadline = Date.now() + timeoutMs;
663
+ do {
664
+ await delay(pollMs);
665
+ if (!isForeignHandoverLockHeld(home, allowedOwnerPid))
666
+ return;
667
+ } while (Date.now() < deadline);
668
+ throw new ControllerClientError("CONTROLLER_HANDOVER_TIMEOUT", `Controller handover did not finish within ${timeoutMs} ms.`);
669
+ }
634
670
  function isDefinitelyNotRunning(error) {
635
671
  if (typeof error !== "object" || error === null || !("code" in error))
636
672
  return false;
@@ -282,23 +282,27 @@ async function processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost
282
282
  const reservedLaunchId = isRuntimeLaunchReservation(mailbox.processing)
283
283
  ? mailbox.processing.batchId
284
284
  : undefined;
285
- if (reservedLaunchId !== undefined
286
- && session?.launchId !== undefined
287
- && reservedLaunchId !== session.launchId) {
288
- throw new Error(`Task runtime cleanup launch identity is ambiguous: ${target.taskId}.`);
289
- }
290
- const launchId = reservedLaunchId ?? session?.launchId;
291
- if (launchId !== undefined
292
- && lifecycleHost.cleanupTaskLaunch !== undefined) {
285
+ // A failed fresh-Conversation launch can leave two exact resource
286
+ // generations: the detached Session's last committed launch and the
287
+ // replacement reservation. They are not competing authorities. The
288
+ // owner-wide stop has already proven physical zero, so clean both
289
+ // exact launch roots idempotently before settling the mailbox.
290
+ const launchIds = new Set([
291
+ reservedLaunchId,
292
+ session?.launchId
293
+ ].filter((launchId) => launchId !== undefined));
294
+ if (lifecycleHost.cleanupTaskLaunch !== undefined) {
293
295
  const task = store.getTask(target.taskId);
294
296
  const reason = task?.status === "completed"
295
297
  ? "completion"
296
298
  : "interruption";
297
- lifecycleHost.cleanupTaskLaunch({
298
- taskId: target.taskId,
299
- launchId,
300
- reason
301
- });
299
+ for (const launchId of launchIds) {
300
+ lifecycleHost.cleanupTaskLaunch({
301
+ taskId: target.taskId,
302
+ launchId,
303
+ reason
304
+ });
305
+ }
302
306
  }
303
307
  }
304
308
  if (store.completeRuntimeCleanup === undefined
@@ -1504,7 +1508,11 @@ export class FileTaskController {
1504
1508
  }
1505
1509
  for (const wakeup of result.wakeups) {
1506
1510
  const key = `role:${encodeURIComponent(wakeup.taskId)}/leader`;
1507
- if (wakeup.reason === "writer-attached") {
1511
+ if (wakeup.terminalized === true) {
1512
+ settled.add(key);
1513
+ resignal.add(key);
1514
+ }
1515
+ else if (wakeup.reason === "writer-attached") {
1508
1516
  writerBlocked.add(key);
1509
1517
  }
1510
1518
  else if (wakeup.reason === "not-ready") {
@@ -1690,7 +1698,7 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
1690
1698
  dispatcherServiceTime.started();
1691
1699
  try {
1692
1700
  if (stopping) {
1693
- throw controllerApplicationError("METHOD_NOT_FOUND", "Controller is stopping.");
1701
+ throw controllerApplicationError("CONTROLLER_DRAINING", "Controller is draining.");
1694
1702
  }
1695
1703
  if (method === "scheduler.signal") {
1696
1704
  runtime.signal(signalMailboxKey(params));