@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
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Pure-memory, fixed-space command observation for the Controller socket.
3
+ *
4
+ * The core server owns route/built-in command observation: every authenticated
5
+ * request is counted once at the routing layer, and a bounded unrefed scheduler
6
+ * samples event-loop delay so an already-written request's pre-dispatch wait is
7
+ * observable even when the loop is saturated by scheduler projections. The
8
+ * FileTaskController composes these snapshots into its runtime status metric;
9
+ * nothing here is persisted and every snapshot is O(1).
10
+ */
11
+ const CONTROLLER_DELAY_BUCKETS_MS = [10, 50, 100, 250, 500, 1_000, 3_000];
12
+ const DEFAULT_EVENT_LOOP_DELAY_INTERVAL_MS = 50;
13
+ const BUILTIN_METHODS = new Set([
14
+ "controller.status",
15
+ "controller.identity",
16
+ "controller.stop",
17
+ "controller.begin-handover",
18
+ "controller.commit-handover",
19
+ "controller.rollback-handover",
20
+ "controller.handover-state"
21
+ ]);
22
+ export function isBuiltinControllerMethod(method) {
23
+ return BUILTIN_METHODS.has(method);
24
+ }
25
+ export function monotonicMilliseconds() {
26
+ return Number(process.hrtime.bigint()) / 1_000_000;
27
+ }
28
+ export const productionTelemetryScheduler = {
29
+ setInterval: (callback, delayMs) => {
30
+ const timer = setInterval(callback, delayMs);
31
+ timer.unref();
32
+ return {
33
+ unref: () => timer.unref(),
34
+ close: () => clearInterval(timer)
35
+ };
36
+ }
37
+ };
38
+ /**
39
+ * Counts routed commands once at the routing layer. Built-in routes
40
+ * (controller.status/identity/stop) and dispatcher routes are observed
41
+ * separately so the dispatcher's own service-time metric cannot be mistaken
42
+ * for end-to-end latency and is never double-counted.
43
+ */
44
+ export class ControllerCommandObserver {
45
+ #clock;
46
+ #received = 0;
47
+ #completed = 0;
48
+ #failed = 0;
49
+ #inFlight = 0;
50
+ /** Monotonic arrival timestamps of in-flight observations (FIFO). */
51
+ #inFlightStarted = [];
52
+ #builtinCompleted = 0;
53
+ #builtinFailed = 0;
54
+ #dispatchedCompleted = 0;
55
+ #dispatchedFailed = 0;
56
+ constructor(clock = monotonicMilliseconds) {
57
+ this.#clock = clock;
58
+ }
59
+ start() {
60
+ this.#received += 1;
61
+ this.#inFlight += 1;
62
+ this.#inFlightStarted.push(this.#clock());
63
+ let completed = false;
64
+ return {
65
+ complete: (kind, outcome) => {
66
+ if (completed)
67
+ return;
68
+ completed = true;
69
+ this.#inFlight = Math.max(0, this.#inFlight - 1);
70
+ this.#inFlightStarted.shift();
71
+ this.#completed += 1;
72
+ if (outcome === "failure")
73
+ this.#failed += 1;
74
+ if (kind === "builtin") {
75
+ if (outcome === "failure")
76
+ this.#builtinFailed += 1;
77
+ else
78
+ this.#builtinCompleted += 1;
79
+ }
80
+ else if (outcome === "failure") {
81
+ this.#dispatchedFailed += 1;
82
+ }
83
+ else {
84
+ this.#dispatchedCompleted += 1;
85
+ }
86
+ }
87
+ };
88
+ }
89
+ snapshot() {
90
+ const oldest = this.#inFlightStarted.length === 0
91
+ ? null
92
+ : Math.max(0, this.#clock() - this.#inFlightStarted[0]);
93
+ return {
94
+ received: this.#received,
95
+ completed: this.#completed,
96
+ failed: this.#failed,
97
+ inFlight: this.#inFlight,
98
+ oldestInFlightAgeMs: oldest,
99
+ builtin: {
100
+ completed: this.#builtinCompleted,
101
+ failed: this.#builtinFailed
102
+ },
103
+ dispatched: {
104
+ completed: this.#dispatchedCompleted,
105
+ failed: this.#dispatchedFailed
106
+ }
107
+ };
108
+ }
109
+ }
110
+ /**
111
+ * Bounded event-loop delay sampler. A fixed-cadence unrefed interval measures
112
+ * how much later than expected it fires; that lag is the pre-dispatch wait an
113
+ * already-written socket request experiences while the loop is busy. State is
114
+ * a fixed counter set plus seven bucket counters, so memory is constant and
115
+ * snapshots are O(1).
116
+ */
117
+ export class ControllerEventLoopDelay {
118
+ #clock;
119
+ #scheduler;
120
+ #intervalMs;
121
+ #handle;
122
+ #expectedAt = 0;
123
+ #samples = 0;
124
+ #maximumLagMs = 0;
125
+ #buckets = new Map(CONTROLLER_DELAY_BUCKETS_MS.map((threshold) => [threshold, 0]));
126
+ #stopped = false;
127
+ constructor(clock = monotonicMilliseconds, scheduler = productionTelemetryScheduler, intervalMs = DEFAULT_EVENT_LOOP_DELAY_INTERVAL_MS) {
128
+ this.#clock = clock;
129
+ this.#scheduler = scheduler;
130
+ this.#intervalMs = intervalMs;
131
+ }
132
+ start() {
133
+ if (this.#handle !== undefined || this.#stopped)
134
+ return;
135
+ this.#expectedAt = this.#clock() + this.#intervalMs;
136
+ this.#handle = this.#scheduler.setInterval(() => this.#sample(), this.#intervalMs);
137
+ }
138
+ #sample() {
139
+ if (this.#stopped)
140
+ return;
141
+ const lag = Math.max(0, this.#clock() - this.#expectedAt);
142
+ this.#expectedAt = this.#clock() + this.#intervalMs;
143
+ this.#samples += 1;
144
+ const bounded = Math.ceil(lag);
145
+ this.#maximumLagMs = Math.max(this.#maximumLagMs, bounded);
146
+ for (const threshold of CONTROLLER_DELAY_BUCKETS_MS) {
147
+ if (bounded <= threshold) {
148
+ this.#buckets.set(threshold, (this.#buckets.get(threshold) ?? 0) + 1);
149
+ }
150
+ }
151
+ }
152
+ stop() {
153
+ this.#stopped = true;
154
+ this.#handle?.close();
155
+ this.#handle = undefined;
156
+ }
157
+ snapshot() {
158
+ return {
159
+ samples: this.#samples,
160
+ maximumLagMs: this.#maximumLagMs,
161
+ lagBuckets: Object.fromEntries(CONTROLLER_DELAY_BUCKETS_MS.map((threshold) => [
162
+ `le${threshold}ms`,
163
+ this.#buckets.get(threshold) ?? 0
164
+ ]))
165
+ };
166
+ }
167
+ }
@@ -1,5 +1,6 @@
1
1
  import { accessSync, constants, existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
+ import Database from "better-sqlite3";
3
4
  import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
4
5
  import { operationalAgentEnvironment } from "../agent/launchEnvironment.js";
5
6
  import { inspectAgentCapabilities, resolveAgentAdapter } from "../executor/agentAdapter.js";
@@ -11,8 +12,12 @@ import { defaultTableWidth, renderTable } from "../output/table.js";
11
12
  import { resolveYuiHome, STORAGE_STATE_FILE } from "../storage/taskStore.js";
12
13
  import { createProductionStorageRegistry, openCompatibleFileTaskStore, validateCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
13
14
  import { inspectStorageSchema } from "../storage/storageSchema.js";
15
+ import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
16
+ import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
14
17
  import { classifyHome } from "../storage/upgrade/homeClassification.js";
18
+ import { readMigrationReceipt } from "../storage/upgrade/migrationReceipt.js";
15
19
  import { latestStorageVersionState } from "../storage/upgrade/recordVersions.js";
20
+ import { COMMITTED_DATABASE_FILENAME } from "../storage/upgrade/sqliteStateMigration.js";
16
21
  import { CommandExecutionError } from "../tmux/commandExecutor.js";
17
22
  import { EPHEMERAL_DOMAIN_GRACE_MS, readEphemeralDomainIdentity, readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
18
23
  /**
@@ -42,12 +47,68 @@ export function summarizeStorageHealth(checks) {
42
47
  /** Build the full machine-readable doctor report (checks + storage health). */
43
48
  export function buildDoctorReport(env, executor, storageOptions = {}) {
44
49
  const inspection = inspectDoctor(env, executor, storageOptions);
50
+ const home = resolveYuiHome(env);
45
51
  return {
46
52
  checks: inspection.checks,
47
- storage: summarizeStorageHealth(inspection.checks),
53
+ storage: {
54
+ ...summarizeStorageHealth(inspection.checks),
55
+ details: inspectStorageDetails(home, env)
56
+ },
48
57
  review: inspection.review
49
58
  };
50
59
  }
60
+ /**
61
+ * Read the physical-backend facts for a Home (Issue 01 observability). Every
62
+ * field is best-effort read-only evidence: an unreadable database or manifest
63
+ * yields `null` for that field, and the `storage state` check reports the
64
+ * structural problem separately.
65
+ */
66
+ function inspectStorageDetails(home, env) {
67
+ const schema = inspectStorageSchema(home);
68
+ const logicalLayout = schema.status === "current" || schema.status === "unsupported"
69
+ ? schema.currentLayoutVersion
70
+ : null;
71
+ const authoritativeBackend = resolveTaskStoreBackendForHome(home, env);
72
+ const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
73
+ const databasePath = existsSync(dbPath) ? dbPath : null;
74
+ let journalMode = null;
75
+ let lastCommittedRevision = null;
76
+ if (databasePath !== null) {
77
+ try {
78
+ const db = new Database(databasePath, { readonly: true });
79
+ try {
80
+ const mode = db.pragma("journal_mode", { simple: true });
81
+ journalMode = typeof mode === "string" ? mode : String(mode);
82
+ const row = db.prepare("SELECT revision FROM home_meta WHERE id = 1").get();
83
+ lastCommittedRevision = typeof row?.revision === "number" ? row.revision : null;
84
+ }
85
+ finally {
86
+ db.close();
87
+ }
88
+ }
89
+ catch {
90
+ // Leave both null; the storage state check surfaces the corruption.
91
+ }
92
+ }
93
+ else {
94
+ try {
95
+ const state = JSON.parse(readFileSync(join(home, STORAGE_STATE_FILE), "utf8"));
96
+ lastCommittedRevision = typeof state.revision === "number" ? state.revision : null;
97
+ }
98
+ catch {
99
+ lastCommittedRevision = null;
100
+ }
101
+ }
102
+ return {
103
+ logicalLayout,
104
+ authoritativeBackend,
105
+ databasePath,
106
+ journalMode,
107
+ workerEnabled: resolveStoreWorkerEnabledForHome(home, env),
108
+ migrationReceipt: readMigrationReceipt(home),
109
+ lastCommittedRevision
110
+ };
111
+ }
51
112
  /** Runs the read-only FileTaskStore diagnostics used by `yui doctor`. */
52
113
  export function runDoctorCommand(args, env, executor, storageOptions = {}) {
53
114
  if (args.length !== 0)
@@ -170,13 +231,14 @@ function checkSchema(state, compatibility) {
170
231
  detail: `current=${state.currentVersion} latest=${state.latestVersion}`
171
232
  };
172
233
  case "unsupported":
173
- if (compatibility.storageStatus === "compatible-old"
234
+ if ((compatibility.storageStatus === "compatible-old"
235
+ || compatibility.storageStatus === "needs-storage-repair")
174
236
  && compatibility.check.status === "ok") {
175
237
  return {
176
238
  name: "storage schema",
177
239
  status: "ok",
178
240
  detail: `current=${state.currentVersion} latest=${state.latestVersion} `
179
- + `direction=${state.direction}; compatible-old validated`
241
+ + `direction=${state.direction}; ${compatibility.storageStatus} validated`
180
242
  };
181
243
  }
182
244
  return {
@@ -228,7 +290,11 @@ function inspectCompatibility(home, homeCheck, schema, storageOptions) {
228
290
  return { check: { name, status: "invalid", detail: errorMessage(error) } };
229
291
  }
230
292
  const storageStatus = classification.classification.status;
231
- if (storageStatus === "compatible-old") {
293
+ // A pseudo-layout-7 Home (needs-storage-repair) opens through the same
294
+ // normalization path as compatible-old, so its records get the same eager
295
+ // validation before the schema check can treat a version mismatch as
296
+ // validated (Issue 01).
297
+ if (storageStatus === "compatible-old" || storageStatus === "needs-storage-repair") {
232
298
  try {
233
299
  validateCompatibleFileTaskStore(home, resolvedStorageOptions);
234
300
  }
@@ -239,7 +305,7 @@ function inspectCompatibility(home, homeCheck, schema, storageOptions) {
239
305
  check: {
240
306
  name,
241
307
  status: "invalid",
242
- detail: `unsupported compatible-old shape: ${errorMessage(error)}`
308
+ detail: `unsupported ${storageStatus} shape: ${errorMessage(error)}`
243
309
  }
244
310
  };
245
311
  }
@@ -276,6 +342,21 @@ function inspectCompatibility(home, homeCheck, schema, storageOptions) {
276
342
  detail: `migration-required (MIGRATABLE) ${versions}; run yui update when Sessions are clear`
277
343
  }
278
344
  };
345
+ case "needs-storage-repair":
346
+ return {
347
+ storageStatus,
348
+ storageOptions: resolvedStorageOptions,
349
+ check: {
350
+ name,
351
+ // Diagnostic mode (Issue 01 rollout step 1): the Home is readable
352
+ // and usable, but the manifest claims layout 7 without a yui.db.
353
+ // Surface the repair need in the detail while keeping the Home
354
+ // healthy so ordinary commands keep working.
355
+ status: "ok",
356
+ detail: `needs-storage-repair (NEEDS_STORAGE_REPAIR) ${versions}; `
357
+ + "the manifest claims layout 7 but yui.db is missing. Run `yui upgrade` to rebuild it from state.json."
358
+ }
359
+ };
279
360
  case "unsupported": {
280
361
  const unsupported = classification.classification;
281
362
  return {
@@ -308,25 +389,41 @@ function inspectState(home, homeCheck, schema, compatibility) {
308
389
  return blockedStorage("invalid", schema.detail);
309
390
  if (schema.status === "read-error")
310
391
  return blockedStorage("invalid", schema.detail);
311
- if (compatibility.check.status !== "ok") {
392
+ // A pseudo-layout-7 Home (needs-storage-repair) has a readable state.json;
393
+ // the compatibility check surfaces the repair need, but the store itself is
394
+ // readable, so the state and review checks must still run (Issue 01).
395
+ if (compatibility.check.status !== "ok"
396
+ && compatibility.storageStatus !== "needs-storage-repair") {
312
397
  return blockedStorage(compatibility.check.status, storageBlockerDetail(compatibility.check));
313
398
  }
314
399
  if (compatibility.storageStatus !== "current"
315
- && compatibility.storageStatus !== "compatible-old") {
400
+ && compatibility.storageStatus !== "compatible-old"
401
+ && compatibility.storageStatus !== "needs-storage-repair") {
316
402
  return blockedStorage("unsupported", compatibility.check.detail);
317
403
  }
318
404
  if (compatibility.storageOptions === undefined) {
319
405
  return blockedStorage("invalid", "Storage compatibility options were not resolved.");
320
406
  }
321
407
  const statePath = join(home, STORAGE_STATE_FILE);
322
- if (!existsSync(statePath))
323
- return blockedStorage("missing", "run yui setup");
324
- try {
325
- const metadata = lstatSync(statePath);
326
- if (!metadata.isFile() || metadata.isSymbolicLink()) {
327
- return blockedStorage("invalid", `${STORAGE_STATE_FILE} must be a regular file.`);
408
+ // A repaired layout-7 Home archived state.json; yui.db is the authoritative
409
+ // backend then, so the state.json gate is skipped (Issue 01).
410
+ const sqliteAuthoritative = !existsSync(statePath)
411
+ && existsSync(join(home, COMMITTED_DATABASE_FILENAME));
412
+ if (!sqliteAuthoritative) {
413
+ if (!existsSync(statePath))
414
+ return blockedStorage("missing", "run yui setup");
415
+ try {
416
+ const metadata = lstatSync(statePath);
417
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
418
+ return blockedStorage("invalid", `${STORAGE_STATE_FILE} must be a regular file.`);
419
+ }
420
+ accessSync(statePath, constants.R_OK);
328
421
  }
329
- accessSync(statePath, constants.R_OK);
422
+ catch (error) {
423
+ return blockedStorage("invalid", errorMessage(error));
424
+ }
425
+ }
426
+ try {
330
427
  const store = openCompatibleFileTaskStore(home, compatibility.storageOptions);
331
428
  const config = store.getConfig();
332
429
  const agents = store.listConfiguredAgents();
@@ -337,12 +434,12 @@ function inspectState(home, homeCheck, schema, compatibility) {
337
434
  check: {
338
435
  name: "storage state",
339
436
  status: "ok",
340
- detail: `readable agents=${agents.length} tasks=${tasks.length} roles=${roleCount} globalRoles=${globalRoles.length} defaultAgent=${config.defaultAgent ?? "none"}`
437
+ detail: `${sqliteAuthoritative ? "yui.db readable" : "readable"} agents=${agents.length} tasks=${tasks.length} roles=${roleCount} globalRoles=${globalRoles.length} defaultAgent=${config.defaultAgent ?? "none"}`
341
438
  },
342
439
  agents,
343
440
  review: {
344
441
  storageReady: true,
345
- storageDetail: "state.json is readable",
442
+ storageDetail: sqliteAuthoritative ? "yui.db is readable" : "state.json is readable",
346
443
  ...(config.review === undefined ? {} : { policy: config.review }),
347
444
  ...(config.review === undefined
348
445
  ? {}
@@ -51,3 +51,12 @@ export function normalizedUniqueIdentities(values, label) {
51
51
  export function cloneJson(value) {
52
52
  return JSON.parse(JSON.stringify(value));
53
53
  }
54
+ /**
55
+ * True for a concrete, pinnable package version — a semver-shaped `X.Y.Z` with
56
+ * an optional pre-release/build suffix. Rejects dist-tag sentinels (`latest`,
57
+ * `next`, …), empty/whitespace, and anything not anchored to a numeric
58
+ * `major.minor.patch` so a moving tag can never be frozen into a release plan.
59
+ */
60
+ export function isConcreteVersion(value) {
61
+ return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value.trim());
62
+ }
@@ -72,7 +72,8 @@ export function assertExecutionTargetUnchanged(group, target) {
72
72
  * Enforce the only durable Group/Lane evolution accepted by both domain
73
73
  * helpers and storage. Identity, target, prior results, and a final Leader
74
74
  * resolution never move backwards. A terminal Lane may only reopen as an
75
- * explicit retry with a fresh Run identity.
75
+ * explicit retry with a fresh Run identity, or reset to pending for a
76
+ * Task-final Review execution retry.
76
77
  */
77
78
  export function assertExecutionGroupTransition(existing, candidate) {
78
79
  validateExecutionGroup(existing);
@@ -101,7 +102,7 @@ export function assertExecutionGroupTransition(existing, candidate) {
101
102
  if (next === undefined) {
102
103
  throw new Error(`ExecutionGroup cannot remove Lane: ${lane.id}.`);
103
104
  }
104
- assertExecutionLaneTransition(lane, next, existing.id);
105
+ assertExecutionLaneTransition(lane, next, existing.id, existing.purpose);
105
106
  }
106
107
  }
107
108
  export function isExecutionGroupTransition(existing, candidate) {
@@ -180,6 +181,32 @@ export function restartExecutionLane(group, laneId, patch, now) {
180
181
  updatedAt: timestamp
181
182
  });
182
183
  }
184
+ /**
185
+ * Resets a terminal Reviewer Lane to pending without replacing its ExecutionGroup.
186
+ * The old AgentRun remains the attempt trail; clearing the Lane's Run/session and
187
+ * result lets the same semantic ReviewRound be dispatched again.
188
+ */
189
+ export function resetReviewExecutionLane(group, laneId, now) {
190
+ validateExecutionGroup(group);
191
+ if (group.purpose !== "review") {
192
+ throw new Error(`Only Review ExecutionLanes can reset to pending: ${group.id}/${laneId}.`);
193
+ }
194
+ if (group.resolution !== undefined) {
195
+ throw new Error(`ExecutionGroup is already resolved: ${group.id}.`);
196
+ }
197
+ const existing = group.lanes.find((lane) => lane.id === laneId);
198
+ if (existing === undefined)
199
+ throw new Error(`ExecutionLane not found: ${group.id}/${laneId}.`);
200
+ if (!isTerminalLane(existing.status))
201
+ return existing;
202
+ const timestamp = now.toISOString();
203
+ const { effective: _effective, runId: _runId, sessionId: _sessionId, result: _result, endedAt: _endedAt, ...base } = existing;
204
+ return validateExecutionLane({
205
+ ...base,
206
+ status: "pending",
207
+ updatedAt: timestamp
208
+ }, group);
209
+ }
183
210
  export function recordExecutionLaneResult(group, laneId, result, status, now) {
184
211
  const checked = validateLaneResult(result);
185
212
  validateExecutionGroup(group);
@@ -509,7 +536,7 @@ function validateResolution(resolution, group) {
509
536
  throw new Error("Execution accept resolution cannot retain high-priority findings.");
510
537
  }
511
538
  }
512
- function assertExecutionLaneTransition(existing, candidate, groupId) {
539
+ function assertExecutionLaneTransition(existing, candidate, groupId, groupPurpose) {
513
540
  if (existing.id !== candidate.id
514
541
  || existing.groupId !== candidate.groupId
515
542
  || existing.groupId !== groupId
@@ -525,6 +552,16 @@ function assertExecutionLaneTransition(existing, candidate, groupId) {
525
552
  if (isTerminalLane(existing.status)) {
526
553
  if (isDeepStrictEqual(existing, candidate))
527
554
  return;
555
+ if (groupPurpose === "review"
556
+ && candidate.status === "pending"
557
+ && candidate.effective === undefined
558
+ && candidate.runId === undefined
559
+ && candidate.sessionId === undefined
560
+ && candidate.result === undefined
561
+ && candidate.endedAt === undefined
562
+ && isDeepStrictEqual(existing.workspace, candidate.workspace)) {
563
+ return;
564
+ }
528
565
  if (candidate.status !== "running"
529
566
  || candidate.runId === undefined
530
567
  || candidate.runId === existing.runId) {
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { hasRecentTurnId, rememberRecentTurnId, validatePendingTurnCompletion, validateRecentTurnIds } from "./turnCompletion.js";
3
- import { effectiveLaunchSnapshotsCompatible, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
3
+ import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
4
4
  export function createRoleSessionSet(owner, activeAgentId, now) {
5
5
  const base = {
6
6
  owner: normalizeOwner(owner),
@@ -241,7 +241,7 @@ export function rememberRoleAgentCompletedTurn(set, agentId, nativeSessionId, tu
241
241
  };
242
242
  return validateRoleSessionSet(updated);
243
243
  }
244
- export function roleAgentSessionResumeMode(set, agentId, desired) {
244
+ export function roleAgentSessionResumeMode(set, agentId, desired, workspace) {
245
245
  if (set === null)
246
246
  return "new";
247
247
  validateRoleSessionSet(set);
@@ -260,7 +260,10 @@ export function roleAgentSessionResumeMode(set, agentId, desired) {
260
260
  }
261
261
  return "new";
262
262
  }
263
- if (effectiveLaunchSnapshotsCompatible(session.effective, desired))
263
+ const compatible = set.owner.scope === "task"
264
+ ? effectiveLaunchSnapshotsCompatibleForTaskMain(session.effective, desired, workspace)
265
+ : effectiveLaunchSnapshotsCompatible(session.effective, desired);
266
+ if (compatible)
264
267
  return "resume";
265
268
  if (session.status !== "stopped" && session.status !== "broken") {
266
269
  throw new Error(`Role Agent session is incompatible with the next effective launch: ${agentId}. `
@@ -1,4 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
2
3
  import { resolveAgentAdapter } from "./agentAdapter.js";
3
4
  export function resolveEffectiveLaunch(input) {
4
5
  validateDesiredRole(input.role);
@@ -71,6 +72,57 @@ export function effectiveLaunchSnapshotsCompatible(existing, desired) {
71
72
  };
72
73
  return isDeepStrictEqual(withoutDesiredRevision(existing), withoutDesiredRevision(desired));
73
74
  }
75
+ /**
76
+ * A Task-owned main workspace is a mutable checkout: Integration advances its
77
+ * durable Git heads while an idle native Session keeps the immutable launch
78
+ * configuration with which it started. A later Run may resume that exact
79
+ * Session only when the current durable Task workspace proves that every
80
+ * non-commit workspace identity and every other launch field is unchanged.
81
+ * WorkItem, ReviewRound and ExecutionLane workspaces remain strict.
82
+ */
83
+ export function effectiveLaunchSnapshotsCompatibleForTaskMain(existing, desired, workspace) {
84
+ if (effectiveLaunchSnapshotsCompatible(existing, desired))
85
+ return true;
86
+ validateEffectiveLaunchSnapshot(existing);
87
+ validateEffectiveLaunchSnapshot(desired);
88
+ if (workspace === null || workspace === undefined)
89
+ return false;
90
+ validateManagedWorkspace(workspace);
91
+ if (workspace.owner.type !== "task")
92
+ return false;
93
+ const durableWorkspace = {
94
+ root: workspace.root,
95
+ entries: workspace.entries.map((entry) => ({ ...entry }))
96
+ };
97
+ if (!isDeepStrictEqual(desired.workspace, durableWorkspace))
98
+ return false;
99
+ return isDeepStrictEqual(taskMainCompatibleSnapshot(existing), taskMainCompatibleSnapshot(desired));
100
+ }
101
+ /** Preserves a fixed Session's launch configuration while freezing fresh Task-main Git facts. */
102
+ export function effectiveLaunchWithTaskMainWorkspace(existing, workspace) {
103
+ validateEffectiveLaunchSnapshot(existing);
104
+ validateManagedWorkspace(workspace);
105
+ if (workspace.owner.type !== "task") {
106
+ throw new Error("Only a Task-owned main workspace may refresh fixed Session Run evidence.");
107
+ }
108
+ return validateEffectiveLaunchSnapshot({
109
+ ...existing,
110
+ workspace: {
111
+ root: workspace.root,
112
+ entries: workspace.entries.map((entry) => ({ ...entry }))
113
+ }
114
+ });
115
+ }
116
+ function taskMainCompatibleSnapshot(snapshot) {
117
+ const { sourceDesiredRevision: _sourceDesiredRevision, workspace, ...launch } = snapshot;
118
+ return {
119
+ ...launch,
120
+ workspace: {
121
+ root: workspace.root,
122
+ entries: workspace.entries.map(({ baseCommit: _baseCommit, ...entry }) => entry)
123
+ }
124
+ };
125
+ }
74
126
  export function validateEffectiveLaunchSnapshot(snapshot) {
75
127
  if (snapshot.schemaVersion !== 2) {
76
128
  throw new Error("Effective launch snapshot must use schemaVersion 2.");
@@ -1,5 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createPromptEnvelope, createSessionLaunchRequest } from "../runtime/index.js";
3
+ /**
4
+ * rr13/test: Test-only liveness seam. Integration tests that spawn a real
5
+ * Controller subprocess cannot inject a fake TmuxDeliveryPort, and a saved
6
+ * active Leader Run would be reaped by the startup liveness pass without a
7
+ * real tmux role. When this env var is "1", every role reads "present"
8
+ * without probing tmux. The Controller subprocess inherits it from the
9
+ * test's CLI env. Never set in production.
10
+ */
11
+ const TEST_ROLE_LIVENESS_PRESENT = process.env.YUI_TEST_ROLE_LIVENESS_PRESENT === "1";
3
12
  /**
4
13
  * Scheduler-to-tmux adapter. It retains only in-process prepared launch data;
5
14
  * durable session identity remains owned by FileTaskStore.
@@ -39,6 +48,16 @@ export class ExecutorRegistry {
39
48
  if (this.runtimePorts === undefined) {
40
49
  planned = this.planner.plan(input);
41
50
  sessionStarted = this.tmux.ensureRoleWindow(input.taskId, planned.role, planned.launch);
51
+ if (sessionStarted
52
+ && planned.launch.env.YUI_JOB_CALLER_KEY !== undefined
53
+ && this.planner.commitTaskCallerKey !== undefined) {
54
+ this.planner.commitTaskCallerKey({
55
+ taskId: input.taskId,
56
+ roleName: input.roleName,
57
+ agentId: input.agentId,
58
+ callerKey: planned.launch.env.YUI_JOB_CALLER_KEY
59
+ });
60
+ }
42
61
  session = planned.session;
43
62
  }
44
63
  else {
@@ -108,6 +127,7 @@ export class ExecutorRegistry {
108
127
  this.#prepared.set(delivery.deliveryId, {
109
128
  delivery,
110
129
  session,
130
+ workspace: input.workspace,
111
131
  ...(planned === undefined ? {} : { planned }),
112
132
  ...(binding === undefined ? {} : { binding })
113
133
  });
@@ -127,6 +147,27 @@ export class ExecutorRegistry {
127
147
  if (runId === undefined) {
128
148
  throw new Error("Runtime prompt delivery requires a Task-local Run id.");
129
149
  }
150
+ // A reused native process retains the stable descriptor path from its
151
+ // original control plane. Publish only the current-control source after
152
+ // the Run/Session fence is durable and immediately before provider input;
153
+ // the reused Hook self-refreshes its own source before the volatile
154
+ // fence instead of the Controller scanning history to keep it fresh.
155
+ if (prepared.binding.hostCreated === false
156
+ && this.planner.refreshTaskRuntimeDescriptor !== undefined) {
157
+ if (!hasText(prepared.binding.nativeSessionId)) {
158
+ throw new Error("Runtime prompt delivery requires a native Session id.");
159
+ }
160
+ this.planner.refreshTaskRuntimeDescriptor({
161
+ taskId: input.delivery.prepared.taskId,
162
+ roleName: input.delivery.prepared.roleName,
163
+ runId,
164
+ launchId: prepared.binding.launchId,
165
+ nativeSessionId: prepared.binding.nativeSessionId,
166
+ agentId: prepared.binding.agentId,
167
+ adapterId: prepared.binding.adapterId,
168
+ workspace: prepared.workspace
169
+ });
170
+ }
130
171
  const outcome = await this.runtimePorts.promptPush.tryPush({
131
172
  binding: prepared.binding,
132
173
  envelope: createPromptEnvelope({
@@ -172,6 +213,8 @@ export class ExecutorRegistry {
172
213
  }
173
214
  }
174
215
  async inspectRole(input) {
216
+ if (TEST_ROLE_LIVENESS_PRESENT)
217
+ return "present";
175
218
  const status = this.tmux.probeRoleStatusAsync === undefined
176
219
  ? this.tmux.probeRoleStatus(input.taskId, input.roleName)
177
220
  : await this.tmux.probeRoleStatusAsync(input.taskId, input.roleName);
@@ -198,6 +241,13 @@ export class ExecutorRegistry {
198
241
  }
199
242
  }
200
243
  async inspectRoles(inputs, resourceInputs) {
244
+ if (TEST_ROLE_LIVENESS_PRESENT) {
245
+ return inputs.map((input) => ({
246
+ taskId: input.taskId,
247
+ roleName: input.roleName,
248
+ status: "present"
249
+ }));
250
+ }
201
251
  if (this.tmux.inspectRolePaneInventory === undefined
202
252
  && this.tmux.inspectRolePaneInventoryAsync === undefined) {
203
253
  return Promise.all(inputs.map(async (input) => ({