@zq-silk/yui 0.6.0 → 0.6.2

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 (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -1,24 +1,26 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
3
  import { basename, join, resolve } from "node:path";
5
4
  import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
6
5
  import { inspectStorageSchema } from "../storage/storageSchema.js";
7
6
  import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
8
7
  import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
9
8
  import { TmuxManager, yuiTmuxServerName } from "../tmux/tmuxManager.js";
9
+ import { tmuxSocketDirectory, tmuxSocketEnvironment } from "../tmux/tmuxSocketEndpoint.js";
10
10
  import { readControllerDiscovery } from "../core/controllerClient.js";
11
11
  import { CONTROLLER_DISCOVERY_PATH } from "../core/protocol.js";
12
12
  import { controllerSocketPath } from "../core/controllerEndpoint.js";
13
13
  import { buildControllerResourceInventory } from "./resourceInventory.js";
14
14
  import { CONTROLLER_DOMAIN_PATH, EPHEMERAL_DOMAIN_GRACE_MS, ephemeralDomainFingerprint, readEphemeralDomainIdentity, readLinuxProcessStartIdentity } from "./domainIdentity.js";
15
+ const LINUX_PROCESS_SCAN_BATCH_SIZE = 64;
16
+ export const INVENTORY_EVENT_LOOP_TURN_BUDGET_MS = 25;
15
17
  export async function scanControllerResourceInventory(options) {
16
18
  const currentHome = resolve(options.currentHome);
17
19
  const environment = options.environment ?? process.env;
18
20
  const observedAt = (options.now ?? (() => new Date()))();
19
21
  const warnings = [];
20
22
  const activeSockets = readActiveUnixSocketPaths(warnings);
21
- const processes = listLinuxProcesses(warnings)
23
+ const processes = (await listLinuxProcesses(warnings))
22
24
  .filter(({ pid }) => pid !== process.pid);
23
25
  const homes = new Set([currentHome]);
24
26
  if (options.scope === "all") {
@@ -27,22 +29,31 @@ export async function scanControllerResourceInventory(options) {
27
29
  homes.add(process.yuiHome);
28
30
  }
29
31
  }
30
- const uid = typeof process.getuid === "function" ? process.getuid() : 0;
31
- const rawTmuxArtifacts = listSocketArtifacts(join(tmpdir(), `tmux-${uid}`), /^yui-[a-f0-9]{24}$/u, "tmux-socket", activeSockets);
32
+ const tmuxDirectory = tmuxSocketDirectory(environment);
33
+ const listTmuxSocketArtifacts = options.listTmuxSocketArtifacts
34
+ ?? listSharedTmuxSocketArtifacts;
35
+ // scope=current owns exactly one YUI_HOME, so only its exact tmux server
36
+ // socket is observable. Enumerating the whole shared directory (readdir +
37
+ // lstat per entry) blocked the event loop for seconds under a large
38
+ // unrelated yui-* population and starved control commands. scope=all keeps
39
+ // the full cross-domain enumeration for global cleanup reporting.
40
+ const rawTmuxArtifacts = options.scope === "all"
41
+ ? listTmuxSocketArtifacts(tmuxDirectory, activeSockets)
42
+ : inspectExactTmuxSocket(currentHome, tmuxDirectory, activeSockets);
32
43
  const homeFacts = [];
33
44
  const associatedArtifacts = new Set();
34
45
  for (const home of [...homes].sort()) {
35
46
  const matchingProcesses = processes.filter(({ yuiHome }) => yuiHome === home);
36
47
  const state = loadHomeState(home, warnings, options);
37
48
  const domain = inspectRuntimeDomain(home, matchingProcesses, state.roles, state.storageStatus, observedAt);
38
- const tmuxSocketPath = join(tmpdir(), `tmux-${uid}`, yuiTmuxServerName(home));
49
+ const tmuxSocketPath = join(tmuxDirectory, yuiTmuxServerName(home));
39
50
  const tmuxArtifact = rawTmuxArtifacts.find(({ path }) => path === tmuxSocketPath);
40
51
  if (tmuxArtifact !== undefined)
41
52
  associatedArtifacts.add(tmuxArtifact.path);
42
53
  const panes = options.panes !== undefined
43
54
  ? options.panes
44
55
  : tmuxArtifact?.active === true
45
- ? inspectHomePanes(home, environment.YUI_TMUX_BIN ?? "tmux", warnings)
56
+ ? inspectHomePanes(home, environment, warnings)
46
57
  : [];
47
58
  const discovery = await inspectDiscovery(home, matchingProcesses, activeSockets);
48
59
  if (discovery.status === "valid"
@@ -164,7 +175,7 @@ export function classifyRuntimeProcess(args, command) {
164
175
  return "agent";
165
176
  return "other";
166
177
  }
167
- function listLinuxProcesses(warnings) {
178
+ async function listLinuxProcesses(warnings) {
168
179
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
169
180
  const clockTicks = readClockTicks();
170
181
  const uptimeMs = readSystemUptimeMs();
@@ -179,21 +190,21 @@ function listLinuxProcesses(warnings) {
179
190
  warnings.push(`Cannot enumerate Linux processes: ${message(error)}`);
180
191
  return [];
181
192
  }
182
- return entries.flatMap((entryName) => {
193
+ const result = [];
194
+ await forEachInEventLoopBatches(entries, LINUX_PROCESS_SCAN_BATCH_SIZE, (entryName) => {
195
+ const pid = linuxProcessEntryPid(entryName);
196
+ if (pid === undefined)
197
+ return;
183
198
  try {
184
- const pid = linuxProcessEntryPid(entryName);
185
- if (pid === undefined)
186
- return [];
187
199
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
188
200
  const processUid = parseStatusNumber(status, "Uid");
189
- if (processUid !== uid)
190
- return [];
191
- const parsed = parseLinuxProcessStat(readFileSync(`/proc/${pid}/stat`, "utf8"), clockTicks, uptimeMs);
192
- const args = splitNullDelimited(readFileSync(`/proc/${pid}/cmdline`));
193
- const command = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
194
- const yuiHome = readYuiHome(pid);
195
- const io = readProcessIo(pid);
196
- return [{
201
+ if (processUid === uid) {
202
+ const parsed = parseLinuxProcessStat(readFileSync(`/proc/${pid}/stat`, "utf8"), clockTicks, uptimeMs);
203
+ const args = splitNullDelimited(readFileSync(`/proc/${pid}/cmdline`));
204
+ const command = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
205
+ const yuiHome = readYuiHome(pid);
206
+ const io = readProcessIo(pid);
207
+ result.push({
197
208
  pid,
198
209
  ppid: parsed.ppid,
199
210
  uid: processUid,
@@ -206,14 +217,35 @@ function listLinuxProcesses(warnings) {
206
217
  cpuTimeMs: parsed.cpuTimeMs,
207
218
  ...(io === undefined ? {} : io),
208
219
  ageMs: parsed.ageMs
209
- }];
220
+ });
221
+ }
210
222
  }
211
223
  catch {
212
224
  // Processes commonly exit during a /proc scan. A point-in-time inventory
213
225
  // omits those races instead of turning them into persistent warnings.
214
- return [];
215
226
  }
216
227
  });
228
+ return result;
229
+ }
230
+ /** Cooperatively visits synchronous inventory entries in bounded turns. */
231
+ export async function forEachInEventLoopBatches(entries, batchSize, visit, timing = {}) {
232
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1) {
233
+ throw new Error("Event-loop batch size must be a positive integer.");
234
+ }
235
+ const now = timing.now ?? (() => performance.now());
236
+ let turnStartedAt = now();
237
+ let entriesInTurn = 0;
238
+ for (let index = 0; index < entries.length; index += 1) {
239
+ visit(entries[index], index);
240
+ entriesInTurn += 1;
241
+ if (index + 1 < entries.length
242
+ && (entriesInTurn >= batchSize
243
+ || now() - turnStartedAt >= INVENTORY_EVENT_LOOP_TURN_BUDGET_MS)) {
244
+ await new Promise((resolve) => setImmediate(resolve));
245
+ turnStartedAt = now();
246
+ entriesInTurn = 0;
247
+ }
248
+ }
217
249
  }
218
250
  /**
219
251
  * Resolve one numeric /proc entry name without asking the filesystem for
@@ -304,28 +336,36 @@ function listSocketArtifacts(directory, pattern, artifactKind, activeSockets) {
304
336
  return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
305
337
  if (!pattern.test(entry.name))
306
338
  return [];
307
- const path = join(directory, entry.name);
308
- let metadata;
309
- try {
310
- metadata = lstatSync(path);
311
- }
312
- catch {
313
- return [];
314
- }
315
- if (!metadata.isSocket())
316
- return [];
317
- return [{
318
- artifactKind,
319
- path,
320
- active: activeSockets.has(path),
321
- fingerprint: statFingerprint(metadata)
322
- }];
339
+ return inspectSocketArtifact(join(directory, entry.name), artifactKind, activeSockets);
323
340
  });
324
341
  }
325
342
  catch {
326
343
  return [];
327
344
  }
328
345
  }
346
+ function listSharedTmuxSocketArtifacts(directory, activeSockets) {
347
+ return listSocketArtifacts(directory, /^yui-[a-f0-9]{24}$/u, "tmux-socket", activeSockets);
348
+ }
349
+ function inspectExactTmuxSocket(currentHome, tmuxDirectory, activeSockets) {
350
+ return inspectSocketArtifact(join(tmuxDirectory, yuiTmuxServerName(currentHome)), "tmux-socket", activeSockets);
351
+ }
352
+ function inspectSocketArtifact(path, artifactKind, activeSockets) {
353
+ let metadata;
354
+ try {
355
+ metadata = lstatSync(path);
356
+ }
357
+ catch {
358
+ return [];
359
+ }
360
+ if (!metadata.isSocket())
361
+ return [];
362
+ return [{
363
+ artifactKind,
364
+ path,
365
+ active: activeSockets.has(path),
366
+ fingerprint: statFingerprint(metadata)
367
+ }];
368
+ }
329
369
  async function inspectDiscovery(home, processes, activeSockets) {
330
370
  const path = join(home, CONTROLLER_DISCOVERY_PATH);
331
371
  try {
@@ -576,9 +616,20 @@ function loadHomeState(home, warnings, options) {
576
616
  return { storageStatus: "invalid", roles: [] };
577
617
  }
578
618
  }
579
- function inspectHomePanes(home, tmuxBin, warnings) {
619
+ function inspectHomePanes(home, environment, warnings) {
580
620
  try {
581
- return new TmuxManager(tmuxBin, new NodeCommandExecutor(), {
621
+ const executor = new NodeCommandExecutor();
622
+ const commandEnvironment = tmuxSocketEnvironment(environment);
623
+ return new TmuxManager(environment.YUI_TMUX_BIN ?? "tmux", {
624
+ run: (command, args, options) => executor.run(command, args, {
625
+ ...options,
626
+ environment: {
627
+ ...commandEnvironment,
628
+ ...options?.environment,
629
+ TMUX_TMPDIR: commandEnvironment.TMUX_TMPDIR
630
+ }
631
+ })
632
+ }, {
582
633
  yuiHome: home
583
634
  }).inspectRolePaneInventory();
584
635
  }
@@ -7,18 +7,21 @@ import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, YUI_M
7
7
  import { hasRuntimeCleanupObligation, runtimeLifecycleSignalKey, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
8
8
  import { agentProcessReadinessProbe, ExecutorRegistry } from "../executor/executorRegistry.js";
9
9
  import { activeLiveRoleAgentSession, roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
10
- import { effectiveLaunchSnapshotsCompatible, resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
10
+ import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain, resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
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
14
  import { SqliteTaskStore } from "../storage/sqliteStore.js";
15
- import { AsyncTaskStoreClient, resolveStoreWorkerEnabled } from "../storage/storeRpc.js";
15
+ import { AsyncTaskStoreClient, resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
16
16
  import { FileTaskWorkspacePreparer } from "../repository/taskWorkspacePreparer.js";
17
17
  import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
18
18
  import { TmuxManager, yuiTmuxServerName } from "../tmux/tmuxManager.js";
19
19
  import { FileTaskRuntimeIsolation, TmuxPromptPushAdapter, TmuxSessionHost } from "../runtime/index.js";
20
20
  import { startFileTaskController } from "./controller.js";
21
21
  import { FileSchedulerStoreAdapter } from "./fileSchedulerStoreAdapter.js";
22
+ import { openSchedulerTelemetry } from "../telemetry/telemetryWiring.js";
23
+ import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
24
+ import { createDurableJobControl } from "./jobControl.js";
22
25
  import { FileRuntimeEventInbox } from "./runtimeEventInbox.js";
23
26
  import { AsyncRuntimeEventProcessor, FileRuntimeEventProcessor, createAsyncRuntimeObserver } from "./runtimeEventProcessor.js";
24
27
  import { RuntimeLaunchCoordinator } from "./runtimeLaunchCoordinator.js";
@@ -26,13 +29,52 @@ import { ephemeralDomainFromEnvironment, recordEphemeralTmuxTarget } from "./dom
26
29
  import { createEphemeralResourceReaper } from "./ephemeralResourceReaper.js";
27
30
  import { scanControllerResourceInventory } from "./resourceInventoryLinux.js";
28
31
  import { ResourceInventoryClient } from "./resourceInventoryRpc.js";
32
+ import { createResourceAutoGc } from "../resources/autoResourceGc.js";
29
33
  import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
34
+ import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
35
+ /** Refreshes only the exact Task runtime generation folded by the event transaction. */
36
+ export function refreshAppliedTaskRuntimeDescriptor(store, planner, input) {
37
+ if (input.launchId === undefined)
38
+ return;
39
+ const run = input.runId === undefined
40
+ ? null
41
+ : store.getAgentRun(input.taskId, input.runId);
42
+ if (input.runId !== undefined && run === null) {
43
+ throw new Error("Prepared Task runtime generation is not current.");
44
+ }
45
+ // A terminal completion has already settled this exact Run and no later
46
+ // prompt can use its descriptor. Acknowledge the applied provider fact
47
+ // without republishing a dead generation.
48
+ if (run !== null && run.status !== "active")
49
+ return;
50
+ const session = store.getTaskRoleSessionSet(input.taskId, input.roleName)
51
+ ?.sessions[input.agentId];
52
+ const effective = run?.effective ?? session?.effective;
53
+ if (effective === undefined
54
+ || session === undefined
55
+ || session.agentId !== input.agentId
56
+ || session.adapterId !== input.adapterId
57
+ || session.launchId !== input.launchId
58
+ || session.nativeSessionId !== input.nativeSessionId
59
+ || (run !== null && (run.roleName !== input.roleName
60
+ || run.effective.agentId !== input.agentId
61
+ || run.effective.adapterId !== input.adapterId))) {
62
+ throw new Error("Prepared Task runtime generation is not current.");
63
+ }
64
+ planner.refreshTaskRuntimeDescriptor({
65
+ ...input,
66
+ launchId: input.launchId,
67
+ workspace: effective.workspace.root
68
+ });
69
+ }
30
70
  /** Production composition root for the lean FileTaskStore + tmux Controller. */
31
71
  export async function startFileTaskControllerRuntime(home, options = {}) {
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);
72
+ // The Home decides the backend (Issue 01): a layout-7 Home runs SQLite with
73
+ // the persistence worker on by default; YUI_STORE_WORKER=0/false forces the
74
+ // in-process SQLite connection. The non-worker path opens the Home-decided
75
+ // backend through the compatibility opener (SQLite for layout 7, file store
76
+ // with normalization for older layouts).
77
+ const useWorker = resolveStoreWorkerEnabledForHome(home, options.environment ?? process.env);
36
78
  const store = options.store
37
79
  ?? (useWorker
38
80
  // Transitional: the scheduler/planner still use a sync store. The worker
@@ -55,7 +97,8 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
55
97
  const inventoryClient = useWorker
56
98
  ? new ResourceInventoryClient()
57
99
  : undefined;
58
- const schedulerStore = options.schedulerStore ?? new FileSchedulerStoreAdapter(store);
100
+ const schedulerStore = options.schedulerStore
101
+ ?? new FileSchedulerStoreAdapter(store, openSchedulerTelemetry(home, options.environment ?? process.env));
59
102
  const domainIdentity = options.domainIdentity
60
103
  ?? ephemeralDomainFromEnvironment(options.environment ?? process.env);
61
104
  const planner = options.planner ?? new FileRoleLaunchPlanner(home, store, {
@@ -73,7 +116,27 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
73
116
  }
74
117
  })
75
118
  });
76
- const sessionHost = options.sessionHost ?? new TmuxSessionHost(planner, tmux);
119
+ const sessionOwners = new SessionOwnerReconciliation({
120
+ home,
121
+ store,
122
+ environment: options.environment,
123
+ tmux,
124
+ onWarning: options.onError
125
+ });
126
+ const sessionHost = options.sessionHost ?? new TmuxSessionHost(planner, tmux, {
127
+ onHostCreated: ({ binding, pane }) => {
128
+ sessionOwners.recordHostOwner({
129
+ owner: binding.owner,
130
+ agentId: binding.agentId,
131
+ adapterId: binding.adapterId,
132
+ launchId: binding.launchId,
133
+ ...(binding.nativeSessionId === undefined
134
+ ? {}
135
+ : { nativeSessionId: binding.nativeSessionId }),
136
+ ...(pane.pid === undefined ? {} : { panePid: pane.pid })
137
+ });
138
+ }
139
+ });
77
140
  const promptPush = options.promptPush
78
141
  ?? new TmuxPromptPushAdapter(tmux, agentProcessReadinessProbe);
79
142
  const runtimeIsolation = options.runtimeIsolation
@@ -95,7 +158,19 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
95
158
  : {
96
159
  inspectOwners: (owners) => sessionHost.inspectOwners(owners)
97
160
  }),
98
- stopOwner: (owner) => (sessionHost.stopOwner(owner)),
161
+ stopOwner: (owner) => {
162
+ // Issue 03: the durable `stopped` transition is gated on physical
163
+ // exit proof. A blocked result keeps the Session non-terminal and
164
+ // preserves owner records for Operator recovery.
165
+ return sessionOwners.terminateOwner(owner).then((result) => {
166
+ if (result.outcome === "stop-blocked") {
167
+ (options.onError ?? (() => undefined))(new Error(`Role runtime cleanup could not prove physical exit: ${result.remaining
168
+ .map(({ record, detail }) => `${record.launchId}: ${detail}`)
169
+ .join("; ")}`));
170
+ }
171
+ return result.outcome === "stop-confirmed";
172
+ });
173
+ },
99
174
  ...(runtimeIsolation.cleanupTaskLaunch === undefined
100
175
  ? {}
101
176
  : {
@@ -238,7 +313,61 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
238
313
  })
239
314
  })
240
315
  }));
316
+ // Issue 10: automatic Resource GC. The runner self-skips unless
317
+ // resourcesGcMode=quarantine and resourcesGcAutoQuarantine=true, so wiring
318
+ // it unconditionally costs one config read per full pass when disabled.
319
+ const resourceAutoGc = options.resourceAutoGc
320
+ ?? createResourceAutoGc({
321
+ home,
322
+ store,
323
+ environment: options.environment
324
+ });
241
325
  const lifecycleDispatcher = createRuntimeLifecycleDispatcher(store, schedulerStore, sessionHost, options.dispatcher, signalRuntimeCleanup, launchCoordinator, planner);
326
+ // f7/rr5: Share one inbox between the supervisor's terminal channel and
327
+ // the runtime event processor. When a Job reaches a terminal state, the
328
+ // supervisor enqueues a durable-job-terminal event; the processor drains
329
+ // it on the next pass, waking the Controller immediately instead of
330
+ // waiting for the poll interval.
331
+ const runtimeEventInbox = new FileRuntimeEventInbox(home);
332
+ const jobSupervisor = new DurableJobSupervisor({
333
+ store: schedulerStore,
334
+ process: createLinuxProcessPort(),
335
+ artifacts: createFileArtifactPort(home),
336
+ // rr6/f1: Bounded supervision wake. The supervisor signals the Controller
337
+ // after spawning a runner (queued→running adoption) and when a runner
338
+ // exits (terminal harvest), so a quick job converges without waiting for
339
+ // the recovery interval. Closes over runningRuntime, which is assigned
340
+ // once startFileTaskController resolves; a wake during shutdown is a
341
+ // no-op. The recovery interval stays the cross-restart fallback.
342
+ wake: (taskId) => {
343
+ try {
344
+ runningRuntime?.signal(`task:${taskId}`);
345
+ }
346
+ catch {
347
+ // Controller stopped; the recovery interval remains the fallback.
348
+ }
349
+ },
350
+ terminalEvents: {
351
+ deliverTerminalEvent(notice) {
352
+ try {
353
+ runtimeEventInbox.enqueueDurableJobTerminal({
354
+ scope: "task",
355
+ taskId: notice.taskId,
356
+ jobId: notice.jobId,
357
+ status: notice.status,
358
+ outcome: notice.outcome
359
+ });
360
+ }
361
+ catch (error) {
362
+ // Best-effort terminal channel: the terminal transition already
363
+ // committed. A delivery failure must not fail the reconcile pass.
364
+ (options.onError ?? (() => undefined))(error);
365
+ }
366
+ }
367
+ },
368
+ onError: options.onError
369
+ });
370
+ const jobControl = createDurableJobControl(store);
242
371
  const running = await startFileTaskController(home, schedulerStore, delivery, lifecycleDispatcher, {
243
372
  intervalMs: options.intervalMs
244
373
  ?? reconciliationIntervalMilliseconds(store.getConfig().reconciliationIntervalSeconds),
@@ -248,7 +377,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
248
377
  now: options.now,
249
378
  onError: options.onError,
250
379
  lifecycleHost,
380
+ jobSupervisor,
381
+ jobControl,
251
382
  ...(resourceReaper === undefined ? {} : { resourceReaper }),
383
+ resourceAutoGc,
252
384
  onExpiredEphemeralDomain: (domain) => {
253
385
  if (domain.yuiHome !== home)
254
386
  return;
@@ -257,14 +389,14 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
257
389
  workspacePreparer,
258
390
  runtimeEventProcessor: options.runtimeEventProcessor
259
391
  ?? (useWorker && asyncStoreClient !== undefined
260
- ? new AsyncRuntimeEventProcessor(new FileRuntimeEventInbox(home), createAsyncRuntimeObserver((method, args) => asyncStoreClient.invokeObserver(method, args)), {
392
+ ? new AsyncRuntimeEventProcessor(runtimeEventInbox, createAsyncRuntimeObserver((method, args) => asyncStoreClient.invokeObserver(method, args)), {
261
393
  onTaskRuntimeApplied: (input) => {
262
- planner.refreshTaskRuntimeDescriptor(input);
394
+ refreshAppliedTaskRuntimeDescriptor(store, planner, input);
263
395
  }
264
396
  })
265
- : new FileRuntimeEventProcessor(new FileRuntimeEventInbox(home), schedulerStore, {
397
+ : new FileRuntimeEventProcessor(runtimeEventInbox, schedulerStore, {
266
398
  onTaskRuntimeApplied: (input) => {
267
- planner.refreshTaskRuntimeDescriptor(input);
399
+ refreshAppliedTaskRuntimeDescriptor(store, planner, input);
268
400
  }
269
401
  })),
270
402
  domainIdentity,
@@ -280,6 +412,21 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
280
412
  });
281
413
  runningController = running;
282
414
  runningRuntime = running.runtime;
415
+ // Issue 03: read-only startup reconciliation. Surfaces durable/physical
416
+ // Session mismatches (including generations whose durable map was cleared)
417
+ // without changing stop or archive behavior. Cleanup stays an explicit
418
+ // Operator action in exact-owner-cleanup mode.
419
+ try {
420
+ const startupReport = sessionOwners.report();
421
+ if (startupReport.summary.livePhysicalRoots > 0) {
422
+ (options.onError ?? (() => undefined))(new Error(`Session reconciliation: ${startupReport.summary.livePhysicalRoots} `
423
+ + `live physical root(s) across ${startupReport.summary.owners} owner record(s); `
424
+ + "run `yui session reconcile --report` for details."));
425
+ }
426
+ }
427
+ catch (error) {
428
+ (options.onError ?? (() => undefined))(error);
429
+ }
283
430
  return {
284
431
  ...running,
285
432
  close: async () => {
@@ -391,7 +538,7 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
391
538
  throw applicationError("INVALID_PARAMS", `Configured Agent does not match Role: ${effective.agentId}.`);
392
539
  }
393
540
  validateLifecycleEnvironment(request.environment, agent);
394
- const mode = roleAgentSessionResumeMode(sessions, effective.agentId, effective);
541
+ const mode = roleAgentSessionResumeMode(sessions, effective.agentId, effective, managedWorkspace);
395
542
  const session = sessions?.sessions[effective.agentId];
396
543
  const owner = request.scope === "task"
397
544
  ? { scope: "task", taskId: request.taskId, roleName: request.roleName }
@@ -513,7 +660,10 @@ function assertRuntimeLaunchRequestCurrent(store, request) {
513
660
  || session.nativeSessionId !== request.nativeSessionId) {
514
661
  throw new Error(`Native session changed: ${request.owner.roleName}.`);
515
662
  }
516
- if (!effectiveLaunchSnapshotsCompatible(session.effective, request.effective)) {
663
+ const sessionEffectiveCompatible = request.owner.scope === "task"
664
+ ? effectiveLaunchSnapshotsCompatibleForTaskMain(session.effective, request.effective, request.managedWorkspace)
665
+ : effectiveLaunchSnapshotsCompatible(session.effective, request.effective);
666
+ if (!sessionEffectiveCompatible) {
517
667
  throw new Error(`Native session effective launch changed: ${request.owner.roleName}.`);
518
668
  }
519
669
  }