@zq-silk/yui 0.11.3 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +44 -22
  3. package/dist/cli/commandCatalog.js +13 -10
  4. package/dist/cli/interactionPolicy.js +5 -6
  5. package/dist/cli/updateCommand.js +3 -1
  6. package/dist/cli/updateOrchestrator.js +173 -28
  7. package/dist/cli/updatePorts.js +137 -8
  8. package/dist/cli/upgradeCommand.js +19 -9
  9. package/dist/cli.js +156 -57
  10. package/dist/commands/configCommands.js +13 -46
  11. package/dist/commands/executionAuditCommands.js +3 -2
  12. package/dist/commands/jobCommands.js +4 -10
  13. package/dist/commands/taskCommands.js +234 -200
  14. package/dist/commands/taskCompletionGate.js +29 -54
  15. package/dist/commands/taskContextCommand.js +61 -13
  16. package/dist/commands/taskInputCommands.js +8 -5
  17. package/dist/commands/taskNextActionCommand.js +56 -6
  18. package/dist/commands/taskOverviewCommand.js +17 -31
  19. package/dist/commands/taskRoleRuntimeStatus.js +55 -7
  20. package/dist/commands/taskWorkspaceCommands.js +5 -2
  21. package/dist/config/configCatalog.js +3 -3
  22. package/dist/config/yuiConfig.js +3 -7
  23. package/dist/context/wakeNotification.js +20 -5
  24. package/dist/controller/agentRuntimeObserver.js +247 -51
  25. package/dist/controller/clientRuntime.js +39 -3
  26. package/dist/controller/controller.js +31 -27
  27. package/dist/controller/fileSchedulerStoreAdapter.js +390 -332
  28. package/dist/controller/runtime.js +58 -3
  29. package/dist/controller/runtimeEventInbox.js +17 -7
  30. package/dist/controller/runtimeEventProcessor.js +12 -19
  31. package/dist/controller/runtimeHookRunFence.js +19 -4
  32. package/dist/controller/runtimeObservationHook.js +45 -0
  33. package/dist/controller/structuredProviderObservation.js +20 -3
  34. package/dist/core/controllerClient.js +20 -2
  35. package/dist/core/controllerServer.js +1 -0
  36. package/dist/execution/resourceBroker.js +5 -4
  37. package/dist/executor/agentExecutor.js +52 -50
  38. package/dist/executor/effectiveLaunch.js +8 -48
  39. package/dist/executor/fileRoleLaunchPlanner.js +129 -45
  40. package/dist/executor/workspacePreflightClassification.js +23 -2
  41. package/dist/interaction/operatorPresentation.js +33 -89
  42. package/dist/lifecycle/exactRunTerminalization.js +69 -5
  43. package/dist/observability/executionAudit.js +5 -0
  44. package/dist/observability/orchestrationMetrics.js +8 -3
  45. package/dist/profile/agentProfile.js +1 -1
  46. package/dist/release/cliHomeReleaseFence.js +123 -0
  47. package/dist/release/runtimeRelease.js +20 -0
  48. package/dist/repository/taskWorkspacePreparer.js +176 -60
  49. package/dist/resources/sqliteResourceRegistry.js +1 -1
  50. package/dist/review/deltaRecheck.js +12 -51
  51. package/dist/review/reviewAcceptance.js +26 -0
  52. package/dist/review/reviewConfig.js +0 -31
  53. package/dist/review/reviewDecision.js +113 -0
  54. package/dist/review/reviewOutcomeClassifier.js +1 -1
  55. package/dist/review/reviewRound.js +1 -1
  56. package/dist/review/reviewerAvailability.js +69 -0
  57. package/dist/run/recoveryProjection.js +45 -6
  58. package/dist/runtime/agentDriverObservation.js +24 -10
  59. package/dist/runtime/agentHost.js +159 -85
  60. package/dist/runtime/builtinAgentDrivers.js +4 -3
  61. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  62. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  63. package/dist/runtime/conversationSwitch.js +277 -0
  64. package/dist/runtime/index.js +2 -1
  65. package/dist/runtime/launchBroker.js +12 -0
  66. package/dist/runtime/processExitOutbox.js +88 -0
  67. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  68. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  69. package/dist/runtime/runtimeObservation.js +28 -0
  70. package/dist/runtime/runtimeProjection.js +22 -32
  71. package/dist/runtime/sessionTokenMetrics.js +181 -0
  72. package/dist/runtime/structuredProviderHost.js +7 -1
  73. package/dist/runtime/tmuxAdapters.js +4 -1
  74. package/dist/scheduler/activeRoleRunDelivery.js +34 -199
  75. package/dist/scheduler/activeTaskProgress.js +7 -6
  76. package/dist/scheduler/leaderWakeupProcessor.js +42 -138
  77. package/dist/scheduler/operatorEvent.js +34 -0
  78. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  79. package/dist/scheduler/roleRunStall.js +53 -31
  80. package/dist/scheduler/taskExecutionProjection.js +97 -76
  81. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  82. package/dist/storage/migration/productionRegistry.js +379 -0
  83. package/dist/storage/sqliteSchema.js +66 -23
  84. package/dist/storage/sqliteStore.js +46 -23
  85. package/dist/storage/storeRpc.js +0 -1
  86. package/dist/storage/taskStore.js +15 -30
  87. package/dist/storage/upgrade/homeClassification.js +52 -0
  88. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  89. package/dist/storage/upgrade/recordVersions.js +3 -2
  90. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  91. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  92. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  93. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  94. package/dist/task/completionReadiness.js +10 -6
  95. package/dist/task/nextAction.js +82 -58
  96. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  97. package/dist/web/assets/client/components.js +13 -3
  98. package/dist/web/assets/client/i18n.js +6 -2
  99. package/dist/web/webSnapshot.js +7 -1
  100. package/i18n/README.zh-CN.md +2 -2
  101. package/package.json +1 -1
  102. package/skills/yui-leader/SKILL.md +60 -20
  103. package/skills/yui-operator/SKILL.md +13 -4
  104. package/skills/yui-reviewer/SKILL.md +35 -11
  105. package/dist/context/sessionContextBudget.js +0 -71
  106. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  107. package/dist/lifecycle/taskRoleSessionReset.js +0 -126
  108. package/dist/scheduler/operatorNotification.js +0 -59
@@ -97,7 +97,6 @@ const READ_ONLY_STORE_METHODS = new Set([
97
97
  "getPendingWakeup",
98
98
  "listPendingWakeups",
99
99
  "getLeaderFailure",
100
- "getOperatorNotification",
101
100
  "listTelemetry",
102
101
  "countTelemetry",
103
102
  "hasOutboxEntry",
@@ -31,7 +31,6 @@ import { validateIntegrationQueueEntry } from "../integration/integrationQueueEn
31
31
  import { CURRENT_DURABLE_JOB_SCHEMA_VERSION, validDurableJobTransition, validateDurableJob } from "../job/durableJob.js";
32
32
  import { validateGlobalRole, validateTaskRole } from "../role/role.js";
33
33
  import { CURRENT_LEADER_FAILURE_SCHEMA_VERSION } from "../scheduler/leaderFailure.js";
34
- import { CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION } from "../scheduler/operatorNotification.js";
35
34
  import { CURRENT_TASK_WAKE_SCHEMA_VERSION, validateTaskWake } from "../scheduler/taskWake.js";
36
35
  import { validateTask } from "../task/task.js";
37
36
  import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
@@ -46,7 +45,7 @@ import { CURRENT_AGGREGATE_SCHEMA_VERSION, requireCompatibleStorageSchema, requi
46
45
  export const STORAGE_STATE_FILE = "state.json";
47
46
  /** The root StorageState schema is the persisted aggregate document version. */
48
47
  export const CURRENT_STORAGE_STATE_SCHEMA_VERSION = CURRENT_AGGREGATE_SCHEMA_VERSION;
49
- export const CURRENT_CONFIG_SCHEMA_VERSION = 2;
48
+ export const CURRENT_CONFIG_SCHEMA_VERSION = 3;
50
49
  export const CURRENT_HOME_IDENTITY_SCHEMA_VERSION = 1;
51
50
  export const CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION = 3;
52
51
  /**
@@ -113,7 +112,7 @@ export function executionLaneActiveRunKeyParts(key) {
113
112
  }
114
113
  }
115
114
  /** The schema version of each persisted `state.json#/tasks/*` aggregate. */
116
- export const CURRENT_STORED_TASK_SCHEMA_VERSION = 17;
115
+ export const CURRENT_STORED_TASK_SCHEMA_VERSION = 18;
117
116
  /**
118
117
  * Persisted nested-record versions consumed by this store's strict parser.
119
118
  * Keep these named at the storage boundary so the upgrade record-axis map can
@@ -1730,22 +1729,16 @@ export class FileTaskStore {
1730
1729
  this.saveWorkMailbox(consumePendingBatch(mailbox, "normal"));
1731
1730
  }
1732
1731
  getLeaderFailure(taskId) { return optional(this.#state().tasks[taskId]?.leaderFailure ?? undefined); }
1733
- saveLeaderFailure(value) { this.#saveSingleton(value.taskId, "leaderFailure", value, "Leader failure"); }
1734
- clearLeaderFailure(taskId) { this.#clearSingleton(taskId, "leaderFailure"); }
1735
- getOperatorNotification(taskId) { return optional(this.#state().tasks[taskId]?.operatorNotification ?? undefined); }
1736
- saveOperatorNotification(value) { this.#saveSingleton(value.taskId, "operatorNotification", value, "Operator notification"); }
1737
- clearOperatorNotification(taskId) { this.#clearSingleton(taskId, "operatorNotification"); }
1738
- #saveSingleton(taskId, key, value, label) {
1739
- const schemaVersion = key === "leaderFailure"
1740
- ? CURRENT_LEADER_FAILURE_SCHEMA_VERSION
1741
- : CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION;
1742
- const stored = identified(value, schemaVersion, "taskId", taskId, label);
1743
- this.#requireTaskForWrite(taskId);
1744
- this.#mutate((state) => { state.tasks[taskId][key] = stored; });
1732
+ saveLeaderFailure(value) {
1733
+ const stored = identified(value, CURRENT_LEADER_FAILURE_SCHEMA_VERSION, "taskId", value.taskId, "Leader failure");
1734
+ this.#requireTaskForWrite(value.taskId);
1735
+ this.#mutate((state) => { state.tasks[value.taskId].leaderFailure = stored; });
1745
1736
  }
1746
- #clearSingleton(key, field) {
1747
- this.#mutate((state) => { if (state.tasks[key] !== undefined)
1748
- state.tasks[key][field] = null; });
1737
+ clearLeaderFailure(taskId) {
1738
+ this.#mutate((state) => {
1739
+ if (state.tasks[taskId] !== undefined)
1740
+ state.tasks[taskId].leaderFailure = null;
1741
+ });
1749
1742
  }
1750
1743
  #saveTaskRecord(taskId, key, value, label) {
1751
1744
  const record = versioned(value, CURRENT_MESSAGE_SCHEMA_VERSION, label);
@@ -2014,8 +2007,7 @@ function emptyStoredTask(task) {
2014
2007
  capabilityGrants: {},
2015
2008
  releaseWorkflows: {},
2016
2009
  publicationReferences: {},
2017
- leaderFailure: null,
2018
- operatorNotification: null
2010
+ leaderFailure: null
2019
2011
  };
2020
2012
  }
2021
2013
  function emptyTaskIdHighWaterMarks() {
@@ -2233,8 +2225,7 @@ function parseStoredTask(value, taskId) {
2233
2225
  "capabilityGrants",
2234
2226
  "releaseWorkflows",
2235
2227
  "publicationReferences",
2236
- "leaderFailure",
2237
- "operatorNotification"
2228
+ "leaderFailure"
2238
2229
  ], `Task aggregate ${taskId}`);
2239
2230
  parseMap(aggregate.changeSets, (record, key) => {
2240
2231
  const changeSet = identifiedChangeSet(record, key);
@@ -2434,14 +2425,8 @@ function parseStoredTask(value, taskId) {
2434
2425
  }
2435
2426
  return reference;
2436
2427
  }, "publicationReferences");
2437
- for (const [key, label] of [["leaderFailure", "Leader failure"], ["operatorNotification", "Operator notification"]]) {
2438
- const record = aggregate[key];
2439
- if (record !== null) {
2440
- const schemaVersion = key === "leaderFailure"
2441
- ? CURRENT_LEADER_FAILURE_SCHEMA_VERSION
2442
- : CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION;
2443
- identified(record, schemaVersion, "taskId", taskId, label);
2444
- }
2428
+ if (aggregate.leaderFailure !== null) {
2429
+ identified(aggregate.leaderFailure, CURRENT_LEADER_FAILURE_SCHEMA_VERSION, "taskId", taskId, "Leader failure");
2445
2430
  }
2446
2431
  validateTaskIdHighWaterCoverage(aggregate, taskId);
2447
2432
  return aggregate;
@@ -30,6 +30,7 @@ import { classifyStorage } from "../migration/index.js";
30
30
  import { inspectStorageSchema } from "../storageSchema.js";
31
31
  import { FileTaskStore, STORAGE_STATE_FILE, StorageRecordError } from "../taskStore.js";
32
32
  import { SqliteTaskStore } from "../sqliteStore.js";
33
+ import { inspectSqliteSchemaMigrations } from "../sqliteSchema.js";
33
34
  import { inspectSourceVersionState } from "./homeMigrationTarget.js";
34
35
  import { readMigrationReceipt } from "./migrationReceipt.js";
35
36
  /**
@@ -111,6 +112,44 @@ export function classifyHome(options) {
111
112
  };
112
113
  }
113
114
  }
115
+ // Once the logical Home axes are current, the SQLite ledger is the only
116
+ // remaining upgrade coordinate. A valid applied prefix with pending entries
117
+ // is upgradeable in place, not corruption and not a reason to rebuild the
118
+ // database. Ordinary store opens still validate-and-refuse this state; only
119
+ // the explicit updater is allowed to apply it.
120
+ if (schema.status === "current"
121
+ && schema.currentLayoutVersion >= 7
122
+ && isFullyCurrent(source, latest)) {
123
+ let sqliteMigration;
124
+ try {
125
+ sqliteMigration = inspectLayout7SqliteMigrations(home);
126
+ }
127
+ catch (error) {
128
+ return {
129
+ ...base,
130
+ classification: {
131
+ verdict: "CORRUPTED",
132
+ status: "unsupported",
133
+ detail: error instanceof Error ? error.message : String(error)
134
+ },
135
+ layoutVersion: schema.currentLayoutVersion,
136
+ aggregateVersion: schema.currentAggregateSchemaVersion
137
+ };
138
+ }
139
+ if (sqliteMigration.pendingVersions.length > 0) {
140
+ return {
141
+ ...base,
142
+ classification: {
143
+ verdict: "MIGRATABLE",
144
+ status: "migration-required",
145
+ stepCount: sqliteMigration.pendingVersions.length
146
+ },
147
+ layoutVersion: schema.currentLayoutVersion,
148
+ aggregateVersion: schema.currentAggregateSchemaVersion,
149
+ sqliteMigration
150
+ };
151
+ }
152
+ }
114
153
  // The reference graph can only be validated by the strict loader, which only
115
154
  // understands the current versions. So run it exactly when every axis is
116
155
  // already current (the plan would be a no-op); a throw there is genuine
@@ -136,6 +175,19 @@ export function classifyHome(options) {
136
175
  : { incompatibleComponent: incompatibleComponentOf(schema) })
137
176
  };
138
177
  }
178
+ function inspectLayout7SqliteMigrations(home) {
179
+ const db = new Database(join(home, "yui.db"), {
180
+ readonly: true,
181
+ fileMustExist: true
182
+ });
183
+ try {
184
+ db.pragma("query_only = ON");
185
+ return inspectSqliteSchemaMigrations(db);
186
+ }
187
+ finally {
188
+ db.close();
189
+ }
190
+ }
139
191
  /**
140
192
  * Load a Home whose every axis is already current through the strict store
141
193
  * gate to detect real structural/reference corruption. This is only ever
@@ -1,7 +1,10 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import Database from "better-sqlite3";
3
4
  import { scanControllerResourceInventory } from "../../controller/resourceInventoryLinux.js";
4
5
  import { STORAGE_STATE_FILE } from "../taskStore.js";
6
+ import { SQLITE_LAYOUT_VERSION } from "../sqliteSchema.js";
7
+ import { inspectStorageSchema } from "../storageSchema.js";
5
8
  /** Pure blocker policy shared by synthetic tests and the real read-only scan. */
6
9
  export function classifyOfflineUpgradeFacts(facts) {
7
10
  const blockers = [];
@@ -45,14 +48,13 @@ export async function inspectOfflineUpgradeInventory(home, environment = process
45
48
  const raw = readRawFacts(home, inventory);
46
49
  return classifyOfflineUpgradeFacts(raw);
47
50
  }
48
- function readRawFacts(home, inventory) {
51
+ function readRawFacts(home, inventory, state = readDurableStateObject(home), conservativeDurableSessions = false) {
49
52
  const runs = [];
50
53
  const sessions = [];
51
54
  const inFlight = [];
52
55
  const pendingCompletions = [];
53
56
  const lifecycle = [];
54
57
  const unknownRuntime = [];
55
- const state = readStateObject(home);
56
58
  // Failure to enumerate processes, panes, or sockets means absence cannot be
57
59
  // proven. Ownership-load warnings are expected for an old record shape and
58
60
  // are handled by the raw durable scan plus unowned live-pane check below.
@@ -61,6 +63,8 @@ function readRawFacts(home, inventory) {
61
63
  }
62
64
  for (const [taskId, aggregateValue] of Object.entries(record(state.tasks))) {
63
65
  const aggregate = record(aggregateValue);
66
+ if (text(record(aggregate.task).status) === "retired")
67
+ continue;
64
68
  for (const runValue of Object.values(record(aggregate.agentRuns))) {
65
69
  const run = record(runValue);
66
70
  const roleName = text(run.roleName);
@@ -75,6 +79,7 @@ function readRawFacts(home, inventory) {
75
79
  roleName,
76
80
  set: record(setValue),
77
81
  inventory,
82
+ conservativeDurableSessions,
78
83
  sessions,
79
84
  inFlight,
80
85
  pendingCompletions
@@ -86,6 +91,7 @@ function readRawFacts(home, inventory) {
86
91
  roleName,
87
92
  set: record(setValue),
88
93
  inventory,
94
+ conservativeDurableSessions,
89
95
  sessions,
90
96
  inFlight,
91
97
  pendingCompletions
@@ -97,7 +103,7 @@ function readRawFacts(home, inventory) {
97
103
  const kind = text(target.kind);
98
104
  if (kind !== "role-runtime" && kind !== "global-role-runtime")
99
105
  continue;
100
- if (mailbox.pending === null && mailbox.processing === null)
106
+ if (!mailboxHasRuntimeWork(mailbox))
101
107
  continue;
102
108
  lifecycle.push({
103
109
  ...(text(target.taskId) === undefined ? {} : { taskId: text(target.taskId) }),
@@ -170,7 +176,7 @@ function isUndeterminableNativeInventoryWarning(warning) {
170
176
  || warning.startsWith("Cannot inspect Unix sockets");
171
177
  }
172
178
  function collectSessionSet(options) {
173
- const { taskId, roleName, set, inventory } = options;
179
+ const { taskId, roleName, set, inventory, conservativeDurableSessions } = options;
174
180
  const currentSessions = Object.entries(record(set.sessions));
175
181
  const activeAgentId = text(set.activeAgentId);
176
182
  const historyValue = set.history;
@@ -199,7 +205,7 @@ function collectSessionSet(options) {
199
205
  };
200
206
  options.sessions.push({
201
207
  ...base,
202
- processState: processState(inventory, base)
208
+ processState: processState(inventory, base, conservativeDurableSessions)
203
209
  });
204
210
  }
205
211
  const flight = record(set.inFlight);
@@ -245,12 +251,22 @@ function activeCurrentSession(sessions, taskId, roleName) {
245
251
  return current.find(({ active }) => active === true)
246
252
  ?? (current.length === 1 ? current[0] : undefined);
247
253
  }
248
- function processState(inventory, session) {
254
+ function processState(inventory, session, conservativeDurableSessions) {
249
255
  const matching = inventory.resources.filter((resource) => (resource.kind === "agent-session" && resourceMatchesSession(resource, session)));
250
256
  if (matching.some((resource) => resource.processes.length > 0))
251
257
  return "live";
252
258
  if (matching.some((resource) => resource.paneDead === false))
253
259
  return "unknown";
260
+ // The final SQLite gate runs after BEGIN IMMEDIATE, so no older writer can
261
+ // commit another Session record behind it. Treat every current non-terminal
262
+ // durable Session as active even when the earlier native-process snapshot
263
+ // did not contain it; this closes the inventory-scan -> transaction race.
264
+ if (conservativeDurableSessions
265
+ && !session.history
266
+ && session.status !== "stopped"
267
+ && session.status !== "broken") {
268
+ return "unknown";
269
+ }
254
270
  return "stopped";
255
271
  }
256
272
  function resourceMatchesSession(resource, session) {
@@ -272,13 +288,135 @@ function resourceMatchesSession(resource, session) {
272
288
  return false;
273
289
  return true;
274
290
  }
275
- function readStateObject(home) {
291
+ function readDurableStateObject(home) {
292
+ const databasePath = join(home, "yui.db");
293
+ const schema = inspectStorageSchema(home);
294
+ const sqliteIsAuthoritative = (schema.status === "current" || schema.status === "unsupported")
295
+ && schema.currentLayoutVersion >= SQLITE_LAYOUT_VERSION;
296
+ if (sqliteIsAuthoritative && existsSync(databasePath)) {
297
+ return readSqliteStateObject(databasePath);
298
+ }
276
299
  const path = join(home, STORAGE_STATE_FILE);
277
300
  if (!existsSync(path))
278
301
  return {};
279
302
  const parsed = JSON.parse(readFileSync(path, "utf8"));
280
303
  return record(parsed);
281
304
  }
305
+ /**
306
+ * Read only the durable runtime families needed by the offline gate. This raw
307
+ * adapter deliberately does not open SqliteTaskStore: a valid pending schema
308
+ * prefix is exactly the state the explicit upgrader must be able to inspect.
309
+ */
310
+ function readSqliteStateObject(databasePath) {
311
+ const db = new Database(databasePath, { readonly: true, fileMustExist: true });
312
+ try {
313
+ db.pragma("query_only = ON");
314
+ return readSqliteStateObjectFromDatabase(db);
315
+ }
316
+ finally {
317
+ db.close();
318
+ }
319
+ }
320
+ /**
321
+ * Re-check the authoritative SQLite runtime families on an already-open
322
+ * connection. The in-place upgrader calls this inside its write transaction,
323
+ * after every older writer has either committed or rolled back.
324
+ */
325
+ export function inspectSqliteDurableUpgradeInventory(home, db) {
326
+ return classifyOfflineUpgradeFacts(readRawFacts(home, { resources: [], warnings: [] }, readSqliteStateObjectFromDatabase(db), true));
327
+ }
328
+ function readSqliteStateObjectFromDatabase(db) {
329
+ const taskRows = db.prepare("SELECT task_id, status FROM tasks_catalog").all();
330
+ // Explicit retirement is the only isolation boundary for anomalous
331
+ // historical runtime state. Completed/archived/draft Tasks still fail
332
+ // closed if they retain an active Run or unfinished lifecycle record.
333
+ const retiredTaskIds = new Set(taskRows.filter(({ status }) => status === "retired").map(({ task_id }) => task_id));
334
+ const tasks = Object.fromEntries(taskRows
335
+ .filter(({ task_id }) => !retiredTaskIds.has(task_id))
336
+ .map(({ task_id, status }) => [task_id, {
337
+ task: { status },
338
+ agentRuns: {},
339
+ roleSessionSets: {}
340
+ }]));
341
+ const taskAggregate = (taskId) => {
342
+ const existing = tasks[taskId];
343
+ if (existing !== undefined)
344
+ return existing;
345
+ const created = { task: {}, agentRuns: {}, roleSessionSets: {} };
346
+ tasks[taskId] = created;
347
+ return created;
348
+ };
349
+ for (const row of db.prepare("SELECT task_id, run_id, role_name, status, payload FROM agent_runs").all()) {
350
+ if (retiredTaskIds.has(row.task_id))
351
+ continue;
352
+ const aggregate = taskAggregate(row.task_id);
353
+ record(aggregate.agentRuns)[row.run_id] = {
354
+ ...parseJsonRecord(row.payload, "agent_runs.payload"),
355
+ id: row.run_id,
356
+ roleName: row.role_name,
357
+ status: row.status
358
+ };
359
+ }
360
+ for (const row of db.prepare("SELECT task_id, role_name, payload FROM role_session_sets").all()) {
361
+ if (retiredTaskIds.has(row.task_id))
362
+ continue;
363
+ const aggregate = taskAggregate(row.task_id);
364
+ record(aggregate.roleSessionSets)[row.role_name] = parseJsonRecord(row.payload, "role_session_sets.payload");
365
+ }
366
+ const globalRoleSessionSets = {};
367
+ for (const row of db.prepare("SELECT name, payload FROM global_role_session_sets").all()) {
368
+ globalRoleSessionSets[row.name] = parseJsonRecord(row.payload, "global_role_session_sets.payload");
369
+ }
370
+ const mailboxColumns = new Set(db.pragma("table_info(mailboxes)").map(({ name }) => name));
371
+ const hasInputDelivery = mailboxColumns.has("input_delivery");
372
+ const mailboxes = {};
373
+ for (const row of db.prepare(`SELECT target_key, target_kind, task_id, role_name, processing, pending${hasInputDelivery ? ", input_delivery" : ""} FROM mailboxes`).all()) {
374
+ if (row.task_id !== null && retiredTaskIds.has(row.task_id))
375
+ continue;
376
+ mailboxes[row.target_key] = {
377
+ target: {
378
+ kind: row.target_kind,
379
+ ...(row.task_id === null ? {} : { taskId: row.task_id }),
380
+ ...(row.role_name === null ? {} : { roleName: row.role_name })
381
+ },
382
+ processing: parseNullableJson(row.processing, "mailboxes.processing"),
383
+ pending: parseNullableJson(row.pending, "mailboxes.pending"),
384
+ inputDelivery: parseNullableJson(row.input_delivery ?? null, "mailboxes.input_delivery")
385
+ };
386
+ }
387
+ return { tasks, globalRoleSessionSets, mailboxes };
388
+ }
389
+ function mailboxHasRuntimeWork(mailbox) {
390
+ if (mailbox.processing !== null && mailbox.processing !== undefined)
391
+ return true;
392
+ if (mailbox.inputDelivery !== null && mailbox.inputDelivery !== undefined)
393
+ return true;
394
+ if (mailbox.pending === null || mailbox.pending === undefined)
395
+ return false;
396
+ const pending = record(mailbox.pending);
397
+ if (Object.hasOwn(pending, "normal") || Object.hasOwn(pending, "userCorrection")) {
398
+ return pending.normal !== null || pending.userCorrection !== null;
399
+ }
400
+ // Pre-v14 mailboxes store one pending batch directly.
401
+ return Object.keys(pending).length > 0;
402
+ }
403
+ function parseJsonRecord(raw, label) {
404
+ const parsed = JSON.parse(raw);
405
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
406
+ throw new Error(`${label} is not a JSON object.`);
407
+ }
408
+ return parsed;
409
+ }
410
+ function parseNullableJson(raw, label) {
411
+ if (raw === null)
412
+ return null;
413
+ try {
414
+ return JSON.parse(raw);
415
+ }
416
+ catch (error) {
417
+ throw new Error(`${label} is not valid JSON.`, { cause: error });
418
+ }
419
+ }
282
420
  function countPendingInbox(home) {
283
421
  return [join(home, "runtime", "inbox"), join(home, "runtime", "inbox-invalid")]
284
422
  .reduce((total, directory) => total + countDirectory(directory), 0);
@@ -18,7 +18,6 @@
18
18
  import { CURRENT_AGGREGATE_SCHEMA_VERSION, CURRENT_STORAGE_LAYOUT_VERSION } from "../storageVersions.js";
19
19
  import { CURRENT_AGENT_RUN_SCHEMA_VERSION, CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, CURRENT_CAPABILITY_GRANT_SCHEMA_VERSION, CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, CURRENT_CHANGE_SET_SCHEMA_VERSION, CURRENT_CONTEXT_SNAPSHOT_SCHEMA_VERSION, CURRENT_DECISION_SCHEMA_VERSION, CURRENT_EVENT_SCHEMA_VERSION, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION, CURRENT_INPUT_REQUEST_SCHEMA_VERSION, CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION, CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION, CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION, CURRENT_MESSAGE_SCHEMA_VERSION, CURRENT_MILESTONE_SCHEMA_VERSION, CURRENT_PUBLICATION_REFERENCE_SCHEMA_VERSION, CURRENT_PROJECT_SCHEMA_VERSION, CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION, CURRENT_REVIEW_ROUND_SCHEMA_VERSION, CURRENT_STORED_TASK_SCHEMA_VERSION, CURRENT_TASK_BRIEF_SCHEMA_VERSION, CURRENT_TASK_ROLE_SCHEMA_VERSION, CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION, CURRENT_TASK_SCHEMA_VERSION, CURRENT_WORK_ITEM_SCHEMA_VERSION, CURRENT_WORK_MAILBOX_SCHEMA_VERSION } from "../taskStore.js";
20
20
  import { CURRENT_LEADER_FAILURE_SCHEMA_VERSION } from "../../scheduler/leaderFailure.js";
21
- import { CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION } from "../../scheduler/operatorNotification.js";
22
21
  import { CURRENT_TASK_WAKE_SCHEMA_VERSION } from "../../scheduler/taskWake.js";
23
22
  import { CURRENT_DURABLE_JOB_SCHEMA_VERSION } from "../../job/durableJob.js";
24
23
  function descriptor(version, path) {
@@ -118,7 +117,9 @@ function getCurrentRecordDescriptors() {
118
117
  releaseWorkflow: descriptor(CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION, "state.json#/tasks/*/releaseWorkflows"),
119
118
  publicationReference: descriptor(CURRENT_PUBLICATION_REFERENCE_SCHEMA_VERSION, "state.json#/tasks/*/publicationReferences"),
120
119
  leaderFailure: descriptor(CURRENT_LEADER_FAILURE_SCHEMA_VERSION, "state.json#/tasks/*/leaderFailure"),
121
- operatorNotification: descriptor(CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION, "state.json#/tasks/*/operatorNotification"),
120
+ // Historical family retained in the version graph so StoredTask v17
121
+ // Homes can migrate; current v18 aggregates never store this field.
122
+ operatorNotification: descriptor(1, "state.json#/tasks/*/operatorNotification"),
122
123
  workMailbox: descriptor(CURRENT_WORK_MAILBOX_SCHEMA_VERSION, "state.json#/mailboxes")
123
124
  });
124
125
  }
@@ -149,6 +149,12 @@ export function createSqliteMigrationTarget(options) {
149
149
  }
150
150
  const stamp = now().toISOString().replace(/[:.]/g, "-");
151
151
  let backupPath;
152
+ const schemaPath = join(home, STORAGE_SCHEMA_FILE);
153
+ const sourceSchemaText = readFileSync(schemaPath, "utf8");
154
+ const receiptPath = migrationReceiptPath(home);
155
+ const sourceReceiptText = existsSync(receiptPath)
156
+ ? readFileSync(receiptPath, "utf8")
157
+ : null;
152
158
  // Phase 1: back up any existing yui.db, then promote the sidecar.
153
159
  try {
154
160
  if (existsSync(committedDbPath)) {
@@ -185,7 +191,7 @@ export function createSqliteMigrationTarget(options) {
185
191
  // Phase 2: advance schema.json to layout 7 in the same critical section.
186
192
  try {
187
193
  if (stagedSchemaManifest !== null) {
188
- writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
194
+ writeTextFileAtomically(schemaPath, `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
189
195
  }
190
196
  // Certify the dual-copy state (Issue 01): the 6→7 migration retains
191
197
  // state.json read-only, so the Home legitimately holds both copies.
@@ -204,9 +210,22 @@ export function createSqliteMigrationTarget(options) {
204
210
  }
205
211
  }
206
212
  catch (error) {
207
- // The database is promoted but schema.json could not be advanced.
208
- // Attempt to restore the original database; if that fails, the Home
209
- // is ambiguous and must be recovered manually.
213
+ // A sidecar or receipt writer can fail after an atomic rename. Restore
214
+ // the exact source metadata as well as the database before claiming the
215
+ // layout switch did not commit.
216
+ let rollbackError;
217
+ try {
218
+ writeTextFileAtomically(schemaPath, sourceSchemaText);
219
+ if (sourceReceiptText === null) {
220
+ rmSync(receiptPath, { force: true });
221
+ }
222
+ else {
223
+ writeTextFileAtomically(receiptPath, sourceReceiptText);
224
+ }
225
+ }
226
+ catch (restoreError) {
227
+ rollbackError = restoreError;
228
+ }
210
229
  try {
211
230
  if (backupPath !== undefined && existsSync(backupPath)) {
212
231
  removeSqliteFileSet(committedDbPath);
@@ -216,14 +235,17 @@ export function createSqliteMigrationTarget(options) {
216
235
  removeSqliteFileSet(committedDbPath);
217
236
  }
218
237
  }
219
- catch {
238
+ catch (restoreError) {
239
+ rollbackError ??= restoreError;
240
+ }
241
+ if (rollbackError !== undefined) {
220
242
  throw new AmbiguousSwitchError({
221
243
  homePath: home,
222
244
  backupPath: backupPath ?? committedDbPath,
223
245
  stagingPath: stagedDbPath,
224
- detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
225
- `and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
226
- `recover by advancing schema.json storageVersion to ${latest.layout} or restoring the backup.`
246
+ detail: `SQLite database was promoted but schema.json or its receipt could not be durably advanced (${messageOf(error)}), ` +
247
+ `and the database/metadata rollback also failed (${messageOf(rollbackError)}). ` +
248
+ `Inspect ${committedDbPath}, ${schemaPath}, ${receiptPath}, and the backup before recovery.`
227
249
  });
228
250
  }
229
251
  throw error;
@@ -38,7 +38,7 @@ import { AmbiguousSwitchError } from "../migration/index.js";
38
38
  import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
39
39
  import { writeSwitchProgress } from "./switchProgress.js";
40
40
  import { moveSqliteFileSet, removeSqliteFileSet } from "./sqliteFileSet.js";
41
- import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, copySqlitePassthroughState, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
41
+ import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, copySqlitePassthroughState, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readMigrationSourceStateFromSqlite } from "./sqliteStateMigration.js";
42
42
  /** Build the SQLite-backed record-migration target. */
43
43
  export function createSqliteRecordMigrationTarget(options) {
44
44
  const home = options.home;
@@ -70,7 +70,7 @@ export function createSqliteRecordMigrationTarget(options) {
70
70
  readSource() {
71
71
  const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
72
72
  const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
73
- const state = readStateFromSqlite(home);
73
+ const state = readMigrationSourceStateFromSqlite(home);
74
74
  return Object.freeze({ schemaManifest, state });
75
75
  },
76
76
  writeFreshOutput(snapshot) {
@@ -129,6 +129,8 @@ export function createSqliteRecordMigrationTarget(options) {
129
129
  }
130
130
  const stamp = now().toISOString().replace(/[:.]/g, "-");
131
131
  let backupPath;
132
+ const schemaPath = join(home, STORAGE_SCHEMA_FILE);
133
+ const sourceSchemaText = readFileSync(schemaPath, "utf8");
132
134
  // Phase 1: back up the existing yui.db, then promote the sidecar.
133
135
  try {
134
136
  if (existsSync(committedDbPath)) {
@@ -167,13 +169,20 @@ export function createSqliteRecordMigrationTarget(options) {
167
169
  // critical section. The layout version is unchanged.
168
170
  try {
169
171
  if (stagedSchemaManifest !== null) {
170
- writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
172
+ writeTextFileAtomically(schemaPath, `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
171
173
  }
172
174
  }
173
175
  catch (error) {
174
- // The database is promoted but schema.json could not be advanced.
175
- // Attempt to restore the original database; if that fails, the Home
176
- // is ambiguous and must be recovered manually.
176
+ // A durable writer can report an fsync failure after its atomic rename,
177
+ // so "write threw" does not prove schema.json stayed old. Restore both
178
+ // the exact source sidecar and database before claiming no switch.
179
+ let rollbackError;
180
+ try {
181
+ writeTextFileAtomically(schemaPath, sourceSchemaText);
182
+ }
183
+ catch (restoreError) {
184
+ rollbackError = restoreError;
185
+ }
177
186
  try {
178
187
  if (backupPath !== undefined && existsSync(backupPath)) {
179
188
  removeSqliteFileSet(committedDbPath);
@@ -183,15 +192,18 @@ export function createSqliteRecordMigrationTarget(options) {
183
192
  removeSqliteFileSet(committedDbPath);
184
193
  }
185
194
  }
186
- catch {
195
+ catch (restoreError) {
196
+ rollbackError ??= restoreError;
197
+ }
198
+ if (rollbackError !== undefined) {
187
199
  writeInterruptedMarker(home, backupPath ?? committedDbPath, stagedDbPath, now);
188
200
  throw new AmbiguousSwitchError({
189
201
  homePath: home,
190
202
  backupPath: backupPath ?? committedDbPath,
191
203
  stagingPath: stagedDbPath,
192
- detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
193
- `and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
194
- `recover by advancing schema.json recordVersions or restoring the backup.`
204
+ detail: `SQLite database was promoted but schema.json could not be durably advanced (${messageOf(error)}), ` +
205
+ `and the database/schema rollback also failed (${messageOf(rollbackError)}). ` +
206
+ `Inspect ${committedDbPath}, ${schemaPath}, and the backup before recovery.`
195
207
  });
196
208
  }
197
209
  throw error;
@@ -214,7 +226,7 @@ export function createSqliteRecordMigrationTarget(options) {
214
226
  function readSourceFresh() {
215
227
  const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
216
228
  const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
217
- const state = readStateFromSqlite(home);
229
+ const state = readMigrationSourceStateFromSqlite(home);
218
230
  return Object.freeze({ schemaManifest, state });
219
231
  }
220
232
  /**