@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
@@ -2,7 +2,7 @@ import { createAgentRun } from "../run/agentRun.js";
2
2
  import { markYuiRunInput } from "../run/runIdentity.js";
3
3
  import { taskRoleSessionTitle } from "../runtime/sessionTitle.js";
4
4
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
5
- import { effectiveLaunchSnapshotsCompatible } from "../executor/effectiveLaunch.js";
5
+ import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain } from "../executor/effectiveLaunch.js";
6
6
  import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
7
7
  import { recordLeaderFailure } from "./leaderFailure.js";
8
8
  import { createLeaderRecoveryNotification } from "./operatorNotification.js";
@@ -93,7 +93,9 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
93
93
  }
94
94
  }
95
95
  const compatibleSession = existingSession !== null
96
- && effectiveLaunchSnapshotsCompatible(existingSession.effective, role.effective);
96
+ && (reopening
97
+ ? effectiveLaunchSnapshotsCompatible(existingSession.effective, role.effective)
98
+ : effectiveLaunchSnapshotsCompatibleForTaskMain(existingSession.effective, role.effective, role.managedWorkspace));
97
99
  const reopenIdentityDrift = reopening
98
100
  && hasNativeSession(existingSession)
99
101
  && !compatibleSession;
@@ -146,7 +148,7 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
146
148
  ...(mode === "resume" ? { nativeSessionId: existingSession.nativeSessionId } : {}),
147
149
  beforeHostStart: (preflight) => {
148
150
  persistPreStartFence(store, task, role, run, existingSession, mode, preflight, now);
149
- effectiveSession = preflightSession(run.effective, existingSession, mode, preflight);
151
+ effectiveSession = preflightSession(role, run.effective, existingSession, mode, preflight);
150
152
  preStartFencePersisted = true;
151
153
  }
152
154
  });
@@ -164,7 +166,7 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
164
166
  if (!preStartFencePersisted) {
165
167
  const preparedFenceSession = prepared.session === undefined
166
168
  ? existingSession
167
- : validateReadySession(run.effective, existingSession, mode, prepared.session);
169
+ : validateReadySession(role, run.effective, existingSession, mode, prepared.session);
168
170
  effectiveSession = preparedFenceSession;
169
171
  store.saveRoleRunPrepared({
170
172
  task,
@@ -191,7 +193,7 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
191
193
  results.push({ taskId: task.id, status: "skipped", reason: "unavailable" });
192
194
  continue;
193
195
  }
194
- effectiveSession = validateReadySession(run.effective, existingSession, mode, ready.session);
196
+ effectiveSession = validateReadySession(role, run.effective, existingSession, mode, ready.session);
195
197
  store.saveRoleRunPrepared({
196
198
  task,
197
199
  role,
@@ -309,7 +311,7 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
309
311
  }
310
312
  return results;
311
313
  }
312
- function validateReadySession(effective, existing, mode, session) {
314
+ function validateReadySession(role, effective, existing, mode, session) {
313
315
  if (mode === "new" && session === null)
314
316
  return null;
315
317
  if (session === null)
@@ -317,7 +319,10 @@ function validateReadySession(effective, existing, mode, session) {
317
319
  if (session.agentId !== effective.agentId || session.adapterId !== effective.adapterId) {
318
320
  throw new Error(`Ready session belongs to another Agent: ${session.agentId}.`);
319
321
  }
320
- if (!effectiveLaunchSnapshotsCompatible(session.effective, effective)) {
322
+ const compatible = mode === "resume"
323
+ ? effectiveLaunchSnapshotsCompatibleForTaskMain(session.effective, effective, role.managedWorkspace)
324
+ : effectiveLaunchSnapshotsCompatible(session.effective, effective);
325
+ if (!compatible) {
321
326
  throw new Error("Ready Leader session effective snapshot changed.");
322
327
  }
323
328
  if (!hasNativeSession(session)) {
@@ -326,7 +331,13 @@ function validateReadySession(effective, existing, mode, session) {
326
331
  if (mode === "resume" && session.nativeSessionId !== existing?.nativeSessionId) {
327
332
  throw new Error("Leader resume changed the fixed native session id.");
328
333
  }
329
- return { ...session, status: "running" };
334
+ return {
335
+ ...session,
336
+ status: "running",
337
+ ...(mode === "resume" && existing !== null
338
+ ? { effective: existing.effective }
339
+ : {})
340
+ };
330
341
  }
331
342
  function persistPreStartFence(store, task, role, run, existingSession, mode, preflight, now) {
332
343
  if (preflight.owner.scope !== "task"
@@ -336,7 +347,7 @@ function persistPreStartFence(store, task, role, run, existingSession, mode, pre
336
347
  || preflight.launchId.trim().length === 0) {
337
348
  throw new Error(`Pre-start launch fence changed the Leader Run: ${task.id}/${role.name}.`);
338
349
  }
339
- const session = preflightSession(run.effective, existingSession, mode, preflight);
350
+ const session = preflightSession(role, run.effective, existingSession, mode, preflight);
340
351
  store.saveRoleRunPrepared({
341
352
  task,
342
353
  role,
@@ -346,7 +357,7 @@ function persistPreStartFence(store, task, role, run, existingSession, mode, pre
346
357
  now
347
358
  });
348
359
  }
349
- function preflightSession(effective, existing, mode, preflight) {
360
+ function preflightSession(role, effective, existing, mode, preflight) {
350
361
  const session = preflight.nativeSessionId === undefined
351
362
  ? null
352
363
  : {
@@ -357,7 +368,7 @@ function preflightSession(effective, existing, mode, preflight) {
357
368
  status: "ready",
358
369
  effective: preflight.effective
359
370
  };
360
- return validateReadySession(effective, existing, mode, session);
371
+ return validateReadySession(role, effective, existing, mode, session);
361
372
  }
362
373
  function hasNativeSession(session) {
363
374
  return session !== null &&
@@ -17,6 +17,22 @@ export const RUN_RESOURCE_SUPPRESSED_EVENT = "run.resource-suppressed";
17
17
  /** Structured, non-Message recovery evidence written by an explicit Leader. */
18
18
  export const RUN_RECOVERY_REQUESTED_EVENT = "run.recovery-requested";
19
19
  export const RUN_RECOVERY_APPLIED_EVENT = "run.recovery-applied";
20
+ /** Event types that count as Run activity for the durable progress clock. */
21
+ const ACTIVITY_EVENT_TYPES = new Set([
22
+ RUN_PROGRESS_EVENT,
23
+ "runtime.provider-turn-progress",
24
+ "message.sent",
25
+ "input.answered",
26
+ "input.auto-answered",
27
+ "input.cancelled",
28
+ "work.accepted",
29
+ "work.updated",
30
+ "review.completed",
31
+ "review.failed",
32
+ "integration.updated",
33
+ "integration.completed",
34
+ "integration.failed"
35
+ ]);
20
36
  /**
21
37
  * Clear only the Operator projection raised for this exact Leader Run. A
22
38
  * later Run (or a recovery notification of another kind) must remain intact;
@@ -195,16 +211,23 @@ export function currentRoleRunProgressAt(store, taskId, roleName, run, events =
195
211
  }
196
212
  if (run.deliveredAt === undefined)
197
213
  return { progressAt: run.createdAt };
214
+ // The richer fold is authoritative when present; skip the per-candidate
215
+ // full-history scans that every stall candidate otherwise pays.
216
+ if (richerProgress !== undefined && richerProgress !== null) {
217
+ return {
218
+ progressAt: richerProgress.progressAt,
219
+ ...(richerProgress.evidence === undefined
220
+ ? {}
221
+ : { evidence: richerProgress.evidence })
222
+ };
223
+ }
198
224
  const fallbackProgressAt = latestDurableProgressAt({
199
225
  deliveredAt: run.deliveredAt,
200
226
  latestCheckpointAt: latestRunProgressAt(events, run.id),
201
227
  latestActivityAt: latestRunActivityAt(events, run.id)
202
228
  });
203
229
  return {
204
- progressAt: richerProgress?.progressAt ?? fallbackProgressAt,
205
- ...(richerProgress?.evidence === undefined
206
- ? {}
207
- : { evidence: richerProgress.evidence })
230
+ progressAt: fallbackProgressAt
208
231
  };
209
232
  }
210
233
  /**
@@ -212,13 +235,23 @@ export function currentRoleRunProgressAt(store, taskId, roleName, run, events =
212
235
  * related-record readers mirror the adapter's Work/Review/Integration fold;
213
236
  * narrow scheduler ports can omit them and still retain Run/Event evidence.
214
237
  */
215
- export function latestRunDurableProgressAt(store, taskId, roleName, runId) {
238
+ export function latestRunDurableProgressAt(store, taskId, roleName, runId, precomputed) {
216
239
  const run = store.getAgentRun(taskId, runId);
217
240
  if (run === null || run.taskId !== taskId || run.roleName !== roleName)
218
241
  return null;
219
242
  const events = store.listEvents(taskId);
220
- const latestCheckpointAt = latestRunProgressAt(events, run.id);
221
- const latestActivityAt = latestRunActivityAt(events, run.id);
243
+ // The adapter folds checkpoint/activity once per revision. When the fold
244
+ // port exists, a missing per-Run entry is an authoritative empty fold: use
245
+ // its (possibly undefined) values directly and never re-scan the whole
246
+ // history per candidate. Legacy callers without the port omit precomputed
247
+ // and keep the per-candidate full-history scans.
248
+ const folded = precomputed !== undefined;
249
+ const latestCheckpointAt = folded
250
+ ? precomputed.latestCheckpointAt
251
+ : latestRunProgressAt(events, run.id);
252
+ const latestActivityAt = folded
253
+ ? precomputed.latestActivityAt
254
+ : latestRunActivityAt(events, run.id);
222
255
  const baseline = run.deliveredAt === undefined
223
256
  ? run.createdAt
224
257
  : latestDurableProgressAt({
@@ -280,24 +313,9 @@ export function latestRunEventTime(events, type, runId) {
280
313
  }
281
314
  /** Most recent non-control event carrying a Run identity. */
282
315
  export function latestRunActivityAt(events, runId) {
283
- const semanticTypes = new Set([
284
- RUN_PROGRESS_EVENT,
285
- "runtime.provider-turn-progress",
286
- "message.sent",
287
- "input.answered",
288
- "input.auto-answered",
289
- "input.cancelled",
290
- "work.accepted",
291
- "work.updated",
292
- "review.completed",
293
- "review.failed",
294
- "integration.updated",
295
- "integration.completed",
296
- "integration.failed"
297
- ]);
298
316
  let latest;
299
317
  for (const event of events) {
300
- if (!semanticTypes.has(event.type)
318
+ if (!ACTIVITY_EVENT_TYPES.has(event.type)
301
319
  || event.payload.runId !== runId
302
320
  || event.type === RUN_STALLED_EVENT
303
321
  || event.type === RUN_RECOVERED_EVENT)
@@ -347,6 +365,79 @@ export function latestStallEvidenceKey(events, runId) {
347
365
  evidenceKey: latest.payload.evidenceKey ?? "live-pane-no-progress"
348
366
  };
349
367
  }
368
+ /**
369
+ * Folds a Task's event history into per-Run progress facts in one O(events)
370
+ * pass. The adapter builds this once per durable revision and serves the
371
+ * stall reconciliation from it, replacing the per-candidate full-history
372
+ * clones and scans that turned one pass into an O(candidates x events) hot
373
+ * read. The fold mirrors latestRunProgressAt/latestRunActivityAt/
374
+ * latestStallProgressAt/latestStallEvidenceKey exactly.
375
+ */
376
+ export function foldRunProgressFacts(events) {
377
+ const byRun = new Map();
378
+ const latestStallCreatedAt = new Map();
379
+ for (const event of events) {
380
+ const runId = event.payload.runId;
381
+ if (typeof runId !== "string")
382
+ continue;
383
+ let facts = byRun.get(runId);
384
+ if (facts === undefined) {
385
+ facts = {};
386
+ byRun.set(runId, facts);
387
+ }
388
+ const type = event.type;
389
+ const isProgress = type === RUN_PROGRESS_EVENT
390
+ || type === "runtime.provider-turn-progress";
391
+ if (isProgress) {
392
+ const validProgress = typeof event.payload.progressAt === "string"
393
+ && Number.isFinite(Date.parse(event.payload.progressAt));
394
+ if (type === "runtime.provider-turn-progress" && !validProgress)
395
+ continue;
396
+ const progressAt = validProgress
397
+ ? event.payload.progressAt
398
+ : event.createdAt;
399
+ if (facts.latestCheckpointAt === undefined
400
+ || Date.parse(progressAt) > Date.parse(facts.latestCheckpointAt)) {
401
+ facts.latestCheckpointAt = progressAt;
402
+ }
403
+ // Both progress event types are activity events; the stall/recovered
404
+ // exclusion in latestRunActivityAt is structural here because a progress
405
+ // event can never be either type.
406
+ if (facts.latestActivityAt === undefined
407
+ || Date.parse(progressAt) > Date.parse(facts.latestActivityAt)) {
408
+ facts.latestActivityAt = progressAt;
409
+ }
410
+ }
411
+ else if (type === RUN_STALLED_EVENT) {
412
+ const previousCreatedAt = latestStallCreatedAt.get(runId);
413
+ if (previousCreatedAt === undefined
414
+ || Date.parse(event.createdAt) > Date.parse(previousCreatedAt)) {
415
+ latestStallCreatedAt.set(runId, event.createdAt);
416
+ facts.latestStall = typeof event.payload.progressAt === "string"
417
+ ? {
418
+ progressAt: event.payload.progressAt,
419
+ evidenceKey: typeof event.payload.evidenceKey === "string"
420
+ ? event.payload.evidenceKey
421
+ : "live-pane-no-progress"
422
+ }
423
+ : undefined;
424
+ }
425
+ }
426
+ else if (ACTIVITY_EVENT_TYPES.has(type)
427
+ && type !== RUN_STALLED_EVENT
428
+ && type !== RUN_RECOVERED_EVENT) {
429
+ if (facts.latestActivityAt === undefined
430
+ || Date.parse(event.createdAt) > Date.parse(facts.latestActivityAt)) {
431
+ facts.latestActivityAt = event.createdAt;
432
+ }
433
+ }
434
+ }
435
+ return byRun;
436
+ }
437
+ /** Yield the event loop so already-written control requests stay responsive. */
438
+ function yieldEventLoop() {
439
+ return new Promise((resolve) => setImmediate(resolve));
440
+ }
350
441
  /**
351
442
  * True while a Run remains in an unresolved stall episode: a stall was raised
352
443
  * and no later checkpoint has advanced its durable progress since. This is the
@@ -388,6 +479,11 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
388
479
  return [];
389
480
  if (store.listEvents === undefined || store.recordRoleRunStall === undefined)
390
481
  return [];
482
+ // When the fold port exists, a missing per-Run entry is an authoritative
483
+ // empty fold: the stall reconciliation must not re-scan the whole history
484
+ // per candidate. Legacy stores without the port keep the per-candidate
485
+ // scans.
486
+ const foldPortPresent = store.getRunProgressFacts !== undefined;
391
487
  const candidates = selectedSchedulerTasks(store, selection).flatMap((task) => (task.status !== "active"
392
488
  ? []
393
489
  : selectedSchedulerRoles(store, task.id, selection).flatMap((role) => {
@@ -473,7 +569,12 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
473
569
  }
474
570
  }
475
571
  const raised = [];
476
- for (const candidate of stallCandidates) {
572
+ for (const [candidateIndex, candidate] of stallCandidates.entries()) {
573
+ // A large Task's per-candidate reads are now bounded, but many candidates
574
+ // in one pass still add up. Yield periodically so already-written control
575
+ // requests are not starved by the whole reconciliation.
576
+ if (candidateIndex > 0 && candidateIndex % 16 === 0)
577
+ await yieldEventLoop();
477
578
  const key = `${candidate.task.id}\0${candidate.role.name}`;
478
579
  let live;
479
580
  try {
@@ -514,6 +615,7 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
514
615
  continue;
515
616
  }
516
617
  const events = store.listEvents(candidate.task.id);
618
+ const progressFacts = store.getRunProgressFacts?.(candidate.task.id, candidate.run.id);
517
619
  // Before exact acceptance there is no execution progress clock. Keep the
518
620
  // delivery watch anchored to the Run creation/transport boundary even if
519
621
  // checkpoints, output, or related WorkItem/Review/Integration records are
@@ -526,7 +628,9 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
526
628
  progressAt,
527
629
  now,
528
630
  windowMs,
529
- lastAttentionProgressAt: latestStallProgressAt(events, candidate.run.id)
631
+ lastAttentionProgressAt: foldPortPresent
632
+ ? progressFacts?.latestStall?.progressAt
633
+ : latestStallProgressAt(events, candidate.run.id)
530
634
  });
531
635
  const runAgentId = candidate.run.effective.agentId;
532
636
  const runAdapterId = candidate.run.effective.adapterId;
@@ -605,8 +709,9 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
605
709
  if (current.live !== "present" || !current.stalled || !current.stallCandidate)
606
710
  continue;
607
711
  const { candidate, progressAt } = current;
608
- const events = store.listEvents(candidate.task.id);
609
- const previous = latestStallEvidenceKey(events, candidate.run.id);
712
+ const previous = foldPortPresent
713
+ ? store.getRunProgressFacts?.(candidate.task.id, candidate.run.id)?.latestStall
714
+ : latestStallEvidenceKey(store.listEvents(candidate.task.id), candidate.run.id);
610
715
  if (previous !== undefined
611
716
  && Date.parse(progressAt) > Date.parse(previous.progressAt)) {
612
717
  // A new semantic progress point closes the previous episode first. It
@@ -678,8 +783,9 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
678
783
  if (current.live !== "present" || current.stalled || !current.stallCandidate)
679
784
  continue;
680
785
  const { candidate, progressAt } = current;
681
- const events = store.listEvents(candidate.task.id);
682
- const previous = latestStallEvidenceKey(events, candidate.run.id);
786
+ const previous = foldPortPresent
787
+ ? store.getRunProgressFacts?.(candidate.task.id, candidate.run.id)?.latestStall
788
+ : latestStallEvidenceKey(store.listEvents(candidate.task.id), candidate.run.id);
683
789
  if (previous !== undefined
684
790
  && Date.parse(progressAt) > Date.parse(previous.progressAt)) {
685
791
  store.recordRoleRunProgress?.({
@@ -50,6 +50,17 @@ export function buildTaskExecutionProjection(store, taskId, taskOverride) {
50
50
  roleSessions
51
51
  });
52
52
  }
53
+ /**
54
+ * Fold a Task projection from already-read facts. The Task overview reads each
55
+ * per-Task fact once and reuses it here, instead of letting
56
+ * buildTaskExecutionProjection read the store a second time for the same
57
+ * unchanged revision.
58
+ */
59
+ export function projectTaskExecutionFromFacts(facts) {
60
+ const executionGroups = facts.executionGroups
61
+ ?? collectExecutionGroups(facts.workItems ?? [], facts.reviewRounds ?? []);
62
+ return projectTaskExecution({ ...facts, executionGroups });
63
+ }
53
64
  /** Alias kept intentionally small for scheduler callers and external read models. */
54
65
  export const deriveTaskExecutionProjection = projectTaskExecution;
55
66
  export function projectTaskExecution(facts) {
@@ -1,9 +1,11 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { StorageCompatibilityError, loadCompatibleSnapshot } from "./migration/index.js";
4
4
  import { createProductionStorageRegistry } from "./migration/productionRegistry.js";
5
- import { FileTaskStore, validateCurrentStorageStateSnapshot } from "./taskStore.js";
6
- import { ensureStorageSchema, inspectStorageSchema, STORAGE_SCHEMA_FILE } from "./storageSchema.js";
5
+ import { FileTaskStore, STORAGE_STATE_FILE, stateFileFingerprint, StorageRecordError, validateCurrentStorageStateSnapshot } from "./taskStore.js";
6
+ import { SqliteTaskStore } from "./sqliteStore.js";
7
+ import { COMMITTED_DATABASE_FILENAME } from "./upgrade/sqliteStateMigration.js";
8
+ import { ensureStorageSchema, inspectStorageSchema, readStorageSchemaManifest, STORAGE_SCHEMA_FILE } from "./storageSchema.js";
7
9
  import { classifyHome } from "./upgrade/homeClassification.js";
8
10
  import { inspectSnapshotVersionState } from "./upgrade/homeMigrationTarget.js";
9
11
  import { latestStorageVersionState } from "./upgrade/recordVersions.js";
@@ -23,14 +25,52 @@ export function initializeCompatibleFileTaskStore(home, options = {}) {
23
25
  * Open current or explicitly compatible-old storage. Compatible records are
24
26
  * normalized in memory through strict old-shape validators; FileTaskStore then
25
27
  * runs its one current parser and its existing writer emits only current bytes.
28
+ *
29
+ * A current Home is opened from a single fingerprint-fenced `state.json`
30
+ * snapshot: the same bytes drive the record-version inspection, the strict
31
+ * shape/reference validation, and the returned store's initial cache seed, so
32
+ * the open never re-reads the (large) state merely to construct or first use
33
+ * the store.
26
34
  */
27
35
  export function openCompatibleFileTaskStore(home, options = {}) {
28
36
  const registry = options.registry ?? createProductionStorageRegistry();
29
37
  const latest = options.latest ?? latestStorageVersionState();
38
+ const schema = inspectStorageSchema(home);
39
+ // Issue 01: a layout-7 Home's authoritative backend is SQLite WAL. When
40
+ // `yui.db` exists, open it directly — never fall back to a FileTaskStore
41
+ // over a `state.json` that the repair may already have archived.
42
+ if (!options.forceStateSource
43
+ &&
44
+ (schema.status === "current" || schema.status === "unsupported")
45
+ && schema.currentLayoutVersion >= 7
46
+ && existsSync(join(home, COMMITTED_DATABASE_FILENAME))) {
47
+ return new SqliteTaskStore(home);
48
+ }
49
+ if (schema.status === "current") {
50
+ return openCurrentFileTaskStore(home, latest);
51
+ }
30
52
  const classification = classifyHome({ home, registry, latest });
31
53
  switch (classification.classification.status) {
32
54
  case "current":
33
- return new FileTaskStore(home);
55
+ case "needs-storage-repair":
56
+ // A pseudo-layout-7 Home whose aggregate axis is also older cannot be
57
+ // normalized in memory: the compatible loader (requireCompatibleStorageSchema)
58
+ // only bypasses record-axis mismatches, not aggregate/layout ones. Such a
59
+ // Home needs the offline migration path (repair + aggregate migration in
60
+ // one upgrade), so surface the same migration-required error the pure
61
+ // version classifier would have produced, rather than a lazy strict-schema
62
+ // error from the first store read.
63
+ if (classification.classification.status === "needs-storage-repair"
64
+ && classification.aggregateVersion !== undefined
65
+ && classification.aggregateVersion < latest.aggregate) {
66
+ throw new StorageCompatibilityError("Storage requires an offline migration. Re-run `yui update` when active Sessions are clear.");
67
+ }
68
+ // A pseudo-layout-7 Home (manifest 7, no yui.db) may also carry
69
+ // compatible-old record versions, so open with the same normalization
70
+ // path as `compatible-old` rather than the strict current gate.
71
+ return new FileTaskStore(home, {
72
+ normalizeState: (raw) => normalizeState(home, raw, registry, latest)
73
+ });
34
74
  case "compatible-old":
35
75
  return new FileTaskStore(home, {
36
76
  normalizeState: (raw) => normalizeState(home, raw, registry, latest)
@@ -41,13 +81,80 @@ export function openCompatibleFileTaskStore(home, options = {}) {
41
81
  throw new StorageCompatibilityError(describeUnsupported(classification.classification));
42
82
  }
43
83
  }
84
+ /**
85
+ * The one-snapshot open for a Home whose manifest is already current. A single
86
+ * fingerprint-fenced read of `state.json` supplies:
87
+ * - the record-version inspection (structural scan cross-checked against the
88
+ * durable manifest, never the strict loader),
89
+ * - the strict shape/reference validation (the store's own parser, run while
90
+ * seeding its cache), and
91
+ * - the returned store's initial cache seed under the same fingerprint.
92
+ *
93
+ * A fingerprint that drifts across the read retries once and then fails
94
+ * closed; a later external writer still invalidates the seeded cache on the
95
+ * store's next read. Corruption surfaces as the same `StorageCompatibilityError`
96
+ * diagnosis the classifier produced before this fast path existed.
97
+ */
98
+ function openCurrentFileTaskStore(home, latest) {
99
+ const statePath = join(home, STORAGE_STATE_FILE);
100
+ if (!existsSync(statePath)) {
101
+ // No state.json yet: the store's own missing-file path seeds an empty
102
+ // state without reading anything.
103
+ return new FileTaskStore(home);
104
+ }
105
+ const manifest = readStorageSchemaManifest(home);
106
+ const snapshot = readFingerprintFencedState(statePath);
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(snapshot.raw);
110
+ }
111
+ catch (error) {
112
+ throw new StorageCompatibilityError(`Invalid state.json: state.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
113
+ }
114
+ if (!isRecord(parsed)) {
115
+ throw new StorageCompatibilityError("Invalid state.json: state.json is not a JSON object.");
116
+ }
117
+ const inspection = inspectSnapshotVersionState(Object.freeze({ schemaManifest: manifest, state: parsed }), latest);
118
+ if ("corruption" in inspection) {
119
+ throw new StorageCompatibilityError(`Invalid state.json: ${inspection.corruption.detail}`);
120
+ }
121
+ try {
122
+ return new FileTaskStore(home, {
123
+ initialStateSnapshot: { fingerprint: snapshot.fingerprint, raw: snapshot.raw }
124
+ });
125
+ }
126
+ catch (error) {
127
+ if (error instanceof StorageRecordError) {
128
+ throw new StorageCompatibilityError(`Invalid state.json: ${error.message}`);
129
+ }
130
+ throw error;
131
+ }
132
+ }
133
+ /**
134
+ * Read `state.json` once, fenced by the same fingerprint the store's read cache
135
+ * is keyed on. A fingerprint that changes between the before and after stat
136
+ * means a concurrent writer is mid-update: retry once (bounded), then fail
137
+ * closed rather than seed a store from bytes that may be torn.
138
+ */
139
+ export function readFingerprintFencedState(statePath, io = {}) {
140
+ const fingerprint = io.fingerprint ?? stateFileFingerprint;
141
+ const read = io.read ?? ((path) => readFileSync(path, "utf8"));
142
+ for (let attempt = 0; attempt < 2; attempt += 1) {
143
+ const before = fingerprint(statePath);
144
+ const raw = read(statePath);
145
+ const after = fingerprint(statePath);
146
+ if (before === after)
147
+ return { raw, fingerprint: after };
148
+ }
149
+ throw new StorageCompatibilityError("state.json changed while opening; a concurrent writer is updating the Home. Retry the command.");
150
+ }
44
151
  /**
45
152
  * Eagerly prove that a compatible-old Home reaches the strict current parser.
46
153
  * Staged update/upgrade preflight uses this before any Controller or binary
47
154
  * action; ordinary commands may keep the store's normal lazy-read behavior.
48
155
  */
49
156
  export function validateCompatibleFileTaskStore(home, options = {}) {
50
- openCompatibleFileTaskStore(home, options).getConfig();
157
+ openCompatibleFileTaskStore(home, { ...options, forceStateSource: true }).getConfig();
51
158
  }
52
159
  function normalizeState(home, raw, registry, latest) {
53
160
  let state;