@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
@@ -22,6 +22,7 @@
22
22
  * dropped, duplicated, or mutated record.
23
23
  */
24
24
  import { createHash } from "node:crypto";
25
+ import { existsSync, rmSync } from "node:fs";
25
26
  import { join } from "node:path";
26
27
  import Database from "better-sqlite3";
27
28
  import { mailboxTargetKey } from "../../coordination/workMailbox.js";
@@ -33,6 +34,50 @@ import { CURRENT_STORED_TASK_SCHEMA_VERSION } from "../taskStore.js";
33
34
  export const STAGED_DATABASE_FILENAME = "yui.db.staged";
34
35
  /** The committed database filename. */
35
36
  export const COMMITTED_DATABASE_FILENAME = "yui.db";
37
+ /** Disposable, schema-current snapshot used to reconstruct an older source. */
38
+ export const MIGRATION_SOURCE_DATABASE_FILENAME = "yui.db.migration-source";
39
+ /**
40
+ * Reconstruct a migration snapshot without advancing the authoritative DB.
41
+ *
42
+ * `VACUUM INTO` takes a consistent SQLite snapshot, including committed WAL
43
+ * state, without writing the source. Pending physical migrations are then
44
+ * applied only to that disposable copy before the current reader enumerates
45
+ * record families that may not exist in the older source schema.
46
+ */
47
+ export function readMigrationSourceStateFromSqlite(home) {
48
+ const sourcePath = join(home, COMMITTED_DATABASE_FILENAME);
49
+ const snapshotPath = join(home, MIGRATION_SOURCE_DATABASE_FILENAME);
50
+ if (existsSync(snapshotPath)) {
51
+ throw new Error(`Refusing to overwrite an existing SQLite migration-source snapshot: ${snapshotPath}. `
52
+ + "Discard it and retry.");
53
+ }
54
+ try {
55
+ const source = new Database(sourcePath, { readonly: true });
56
+ try {
57
+ source.prepare("VACUUM INTO ?").run(snapshotPath);
58
+ }
59
+ finally {
60
+ source.close();
61
+ }
62
+ // Physical migration 14 rewrites the WorkMailbox family while adding its
63
+ // current columns. Preserve the source rows before applying it so the
64
+ // logical record migration still sees and transforms v1 exactly once.
65
+ const sourceMailboxes = readWorkMailboxesFromSqlite(snapshotPath);
66
+ const stagedStore = new SqliteTaskStore(home, {
67
+ databaseFilename: MIGRATION_SOURCE_DATABASE_FILENAME,
68
+ migration: true
69
+ });
70
+ stagedStore.close();
71
+ const state = readStateFromSqlite(home, MIGRATION_SOURCE_DATABASE_FILENAME);
72
+ state.mailboxes = sourceMailboxes;
73
+ return state;
74
+ }
75
+ finally {
76
+ rmSync(snapshotPath, { force: true });
77
+ rmSync(`${snapshotPath}-wal`, { force: true });
78
+ rmSync(`${snapshotPath}-shm`, { force: true });
79
+ }
80
+ }
36
81
  /**
37
82
  * Preserve SQLite-owned durable state that intentionally sits outside the
38
83
  * state.json-shaped Task aggregate snapshot. Volatile coordination locks and
@@ -351,9 +396,6 @@ export function populateSqliteFromState(home, state, databaseFilename) {
351
396
  if (stored.leaderFailure !== null) {
352
397
  store.saveLeaderFailure(stored.leaderFailure);
353
398
  }
354
- if (stored.operatorNotification !== null) {
355
- store.saveOperatorNotification(stored.operatorNotification);
356
- }
357
399
  // Per-task ID high-water marks.
358
400
  for (const [kind, highWater] of Object.entries(stored.idHighWaterMarks)) {
359
401
  if (typeof highWater === "number" && Number.isFinite(highWater)) {
@@ -595,6 +637,20 @@ function readWorkMailboxRows(db) {
595
637
  const hasInputDelivery = db.prepare("PRAGMA table_info(mailboxes)").all().some(({ name }) => name === "input_delivery");
596
638
  return db.prepare(`SELECT target_kind, task_id, role_name, next_sequence, processing, pending${hasInputDelivery ? ", input_delivery" : ""} FROM mailboxes ORDER BY target_key`).all();
597
639
  }
640
+ function readWorkMailboxesFromSqlite(dbPath) {
641
+ const db = new Database(dbPath, { readonly: true });
642
+ try {
643
+ const mailboxes = {};
644
+ for (const row of readWorkMailboxRows(db)) {
645
+ const mailbox = rowToMailbox(row);
646
+ mailboxes[mailboxTargetKey(mailbox.target)] = mailbox;
647
+ }
648
+ return mailboxes;
649
+ }
650
+ finally {
651
+ db.close();
652
+ }
653
+ }
598
654
  /**
599
655
  * Compute per-family checksums from the SQLite database. The database is
600
656
  * opened read-only; this is the independent verification read.
@@ -692,8 +748,8 @@ export function verifySqliteChecksums(state, home, databaseFilename) {
692
748
  *
693
749
  * The database is opened read-only; this function never writes.
694
750
  */
695
- export function readStateFromSqlite(home) {
696
- const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
751
+ export function readStateFromSqlite(home, databaseFilename = COMMITTED_DATABASE_FILENAME) {
752
+ const dbPath = join(home, databaseFilename);
697
753
  const db = new Database(dbPath, { readonly: true });
698
754
  try {
699
755
  const manifest = readStorageSchemaManifest(home);
@@ -737,7 +793,7 @@ export function readStateFromSqlite(home) {
737
793
  const tasks = state.tasks;
738
794
  const taskRows = db.prepare("SELECT task_id, payload, brief FROM task_records").all();
739
795
  for (const row of taskRows) {
740
- tasks[row.task_id] = {
796
+ const aggregate = {
741
797
  schemaVersion: storedTaskVersion,
742
798
  task: JSON.parse(row.payload),
743
799
  brief: row.brief === null ? null : JSON.parse(row.brief),
@@ -761,12 +817,14 @@ export function readStateFromSqlite(home) {
761
817
  events: {},
762
818
  wakes: {},
763
819
  leaderFailure: null,
764
- operatorNotification: null,
765
820
  idHighWaterMarks: {},
766
821
  capabilityGrants: {},
767
822
  releaseWorkflows: {},
768
823
  publicationReferences: {}
769
824
  };
825
+ if (storedTaskVersion <= 17)
826
+ aggregate.operatorNotification = null;
827
+ tasks[row.task_id] = aggregate;
770
828
  }
771
829
  // Per-task record families, keyed by their state.json map keys.
772
830
  loadTaskPayloadMap(db, "task_roles", tasks, "roles", (record) => record.name);
@@ -808,7 +866,7 @@ export function readStateFromSqlite(home) {
808
866
  if (row.kind === "leader-failure") {
809
867
  stored.leaderFailure = JSON.parse(row.payload);
810
868
  }
811
- else {
869
+ else if (storedTaskVersion <= 17) {
812
870
  stored.operatorNotification = JSON.parse(row.payload);
813
871
  }
814
872
  }
@@ -34,14 +34,17 @@
34
34
  * authoritative input byte-for-byte unchanged.
35
35
  */
36
36
  import { spawn } from "node:child_process";
37
- import { existsSync, readFileSync, readdirSync } from "node:fs";
37
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
38
+ import { tmpdir } from "node:os";
38
39
  import { dirname, join } from "node:path";
40
+ import Database from "better-sqlite3";
39
41
  import { describeReport, runMigration } from "../migration/index.js";
40
42
  import { validateCompatibleFileTaskStore } from "../compatibleTaskStore.js";
41
43
  import { stopFileTaskController, ensureFileTaskController, ensureFileTaskControllerIdentity } from "../../controller/clientRuntime.js";
42
44
  import { callController } from "../../core/controllerClient.js";
43
45
  import { FileTaskStore, STORAGE_STATE_FILE, withStorageWriteLock } from "../taskStore.js";
44
46
  import { SqliteTaskStore } from "../sqliteStore.js";
47
+ import { migrateSqliteSchema } from "../sqliteSchema.js";
45
48
  import { clearUpgradeFence, placeUpgradeFence, readUpgradeFence, UpgradeFenceError } from "../upgradeFence.js";
46
49
  import { withUpgradeCoordinationLock } from "../upgradeCoordination.js";
47
50
  import { clearUpgradeReceipt, writeUpgradeReceipt, upgradeReceiptPath } from "./upgradeReceipt.js";
@@ -52,7 +55,7 @@ import { repairPseudoLayout7 } from "./pseudoLayoutRepair.js";
52
55
  import { createSqliteMigrationTarget } from "./sqliteMigrationTarget.js";
53
56
  import { createSqliteRecordMigrationTarget } from "./sqliteRecordMigrationTarget.js";
54
57
  import { COMMITTED_DATABASE_FILENAME } from "./sqliteStateMigration.js";
55
- import { inspectOfflineUpgradeInventory } from "./offlineUpgradeInventory.js";
58
+ import { inspectOfflineUpgradeInventory, inspectSqliteDurableUpgradeInventory } from "./offlineUpgradeInventory.js";
56
59
  /**
57
60
  * Run the storage upgrade for one Home. Never throws for an expected blocker;
58
61
  * it returns a structured `blocked` result instead. It only throws on a truly
@@ -95,6 +98,9 @@ export async function runStorageUpgrade(options) {
95
98
  action: blocker.action
96
99
  }, classification);
97
100
  }
101
+ if (classification.sqliteMigration !== undefined) {
102
+ return runSqliteInPlaceUpgrade(options, classification, now, callerPid);
103
+ }
98
104
  // A pseudo-layout-7 Home (manifest 7, no yui.db, readable state.json) needs
99
105
  // the deterministic staged state.json→SQLite repair, not the version
100
106
  // migration engine (Issue 01). The update preflight reports it as
@@ -357,6 +363,329 @@ function offlineInventoryBlocker(inventory, sceneUnchanged) {
357
363
  ...(sceneUnchanged ? { sceneUnchanged: true } : {})
358
364
  };
359
365
  }
366
+ /**
367
+ * A valid SQLite migration prefix advances the authoritative database in
368
+ * place. It still uses the offline runtime gate because the old Controller
369
+ * cannot safely resume after the schema transaction commits, but it never
370
+ * snapshots, copies, rebuilds, or swaps the database.
371
+ */
372
+ async function runSqliteInPlaceUpgrade(options, classification, now, callerPid) {
373
+ const migration = classification.sqliteMigration;
374
+ const inventory = await readOfflineInventory(options, options.home);
375
+ if (inventory.total > 0) {
376
+ return withClassification(offlineInventoryBlocker(inventory, true), classification);
377
+ }
378
+ if (options.mode === "update-preflight") {
379
+ return {
380
+ outcome: "update-preflight",
381
+ status: "in-place-migration",
382
+ stepCount: migration.pendingVersions.length,
383
+ classification,
384
+ sqliteMigration: migration
385
+ };
386
+ }
387
+ if (options.mode === "dry-run") {
388
+ return validateSqliteInPlaceDryRun(options, classification, now);
389
+ }
390
+ return executeSqliteInPlaceUpgrade(options, classification, now, callerPid);
391
+ }
392
+ /**
393
+ * Exercise every pending SQLite migration against a consistent disposable
394
+ * snapshot, then open that snapshot through the current production loader.
395
+ * The authoritative database is opened read-only and is never migrated.
396
+ */
397
+ async function validateSqliteInPlaceDryRun(options, classification, now) {
398
+ const migration = classification.sqliteMigration;
399
+ const stagingRoot = mkdtempSync(join(tmpdir(), "yui-sqlite-upgrade-dry-run-"));
400
+ const stagedDatabasePath = join(stagingRoot, COMMITTED_DATABASE_FILENAME);
401
+ let source;
402
+ let staged;
403
+ let loader;
404
+ try {
405
+ source = new Database(join(options.home, COMMITTED_DATABASE_FILENAME), {
406
+ readonly: true,
407
+ fileMustExist: true
408
+ });
409
+ source.pragma("busy_timeout = 5000");
410
+ await source.backup(stagedDatabasePath);
411
+ source.close();
412
+ source = undefined;
413
+ staged = new Database(stagedDatabasePath);
414
+ staged.pragma("journal_mode = WAL");
415
+ staged.pragma("synchronous = FULL");
416
+ staged.pragma("foreign_keys = ON");
417
+ const applied = migrateSqliteSchema(staged, { mode: "apply" }).applied;
418
+ if (applied.join(",") !== migration.pendingVersions.join(",")) {
419
+ throw new Error(`staged SQLite migration applied versions ${applied.join(",") || "none"}; `
420
+ + `expected ${migration.pendingVersions.join(",")}`);
421
+ }
422
+ staged.close();
423
+ staged = undefined;
424
+ loader = new SqliteTaskStore(stagingRoot);
425
+ const quickCheck = loader.databaseHandle().pragma("quick_check", { simple: true });
426
+ if (quickCheck !== "ok") {
427
+ throw new Error(`staged SQLite quick_check returned ${String(quickCheck)}`);
428
+ }
429
+ loader.close();
430
+ loader = undefined;
431
+ return {
432
+ outcome: "dry-run",
433
+ classification,
434
+ report: sqliteInPlaceReport(options.latest, migration, "dry-run", now)
435
+ };
436
+ }
437
+ catch (error) {
438
+ return withClassification({
439
+ outcome: "blocked",
440
+ stage: "validate",
441
+ message: `SQLite dry-run validation failed: ${messageOf(error)}`,
442
+ action: "The authoritative database was not changed. Resolve the reported migration or loader failure, then retry the dry run."
443
+ }, classification);
444
+ }
445
+ finally {
446
+ try {
447
+ loader?.close();
448
+ }
449
+ catch { /* best-effort disposable cleanup */ }
450
+ try {
451
+ staged?.close();
452
+ }
453
+ catch { /* best-effort disposable cleanup */ }
454
+ try {
455
+ source?.close();
456
+ }
457
+ catch { /* best-effort disposable cleanup */ }
458
+ rmSync(stagingRoot, { recursive: true, force: true });
459
+ }
460
+ }
461
+ /** Acquire a local write fence or prove the exact parent-owned fence exists. */
462
+ function acquireUpgradeAdmission(options, reason, now, callerPid) {
463
+ const externalOwnerPid = options.externalUpgradeFenceOwnerPid;
464
+ if (externalOwnerPid !== undefined) {
465
+ const fence = readUpgradeFence(options.home);
466
+ if (!isPositivePid(externalOwnerPid) || fence?.ownerPid !== externalOwnerPid) {
467
+ throw new UpgradeFenceError(`expected parent-owned upgrade fence for PID ${String(externalOwnerPid)}`);
468
+ }
469
+ return () => { };
470
+ }
471
+ return placeUpgradeFence(options.home, {
472
+ reason,
473
+ createdAt: now().toISOString(),
474
+ ownerPid: callerPid
475
+ });
476
+ }
477
+ async function executeSqliteInPlaceUpgrade(options, classification, now, callerPid) {
478
+ const { home } = options;
479
+ const migration = classification.sqliteMigration;
480
+ let releaseFence;
481
+ try {
482
+ releaseFence = acquireUpgradeAdmission(options, "SQLite schema migration in progress", now, callerPid);
483
+ }
484
+ catch (error) {
485
+ if (!(error instanceof UpgradeFenceError))
486
+ throw error;
487
+ return withClassification({
488
+ outcome: "blocked",
489
+ stage: "coordination",
490
+ message: `Upgrade coordination could not be acquired: ${error.message}`,
491
+ action: "Wait for the current maintenance operation to finish, then retry."
492
+ }, classification);
493
+ }
494
+ const externallyQuiesced = options.controllerLifecycle === "externally-quiesced";
495
+ let controllerWasRunning = false;
496
+ let controllerStopConfirmed = false;
497
+ let controllerIdentity;
498
+ let committed = false;
499
+ let result;
500
+ let unexpected;
501
+ try {
502
+ if (!externallyQuiesced) {
503
+ const controllerStatus = options.controllerStatus
504
+ ?? ((targetHome) => defaultControllerStatus(targetHome, options.controllerOptions));
505
+ let status;
506
+ try {
507
+ status = await controllerStatus(home);
508
+ }
509
+ catch (error) {
510
+ result = withClassification(controllerLifecycleBlocker("Controller status could not be verified", error), classification);
511
+ }
512
+ if (result === undefined && !isControllerLifecycleStatus(status)) {
513
+ result = withClassification(controllerLifecycleBlocker("Controller status was malformed", new Error("expected a boolean running field")), classification);
514
+ }
515
+ if (result === undefined && status.running && !isControllerLaunchIdentity(status.identity)) {
516
+ result = withClassification(controllerLifecycleBlocker("Controller launch identity could not be authenticated", new Error("executable/argv/version identity is unavailable")), classification);
517
+ }
518
+ if (result === undefined && status.running && !isPositivePid(status.pid)) {
519
+ result = withClassification(controllerLifecycleBlocker("Controller PID could not be authenticated", new Error("a positive status PID is unavailable for fenced stop")), classification);
520
+ }
521
+ if (result === undefined) {
522
+ controllerWasRunning = status.running;
523
+ controllerIdentity = status.running ? status.identity : undefined;
524
+ controllerStopConfirmed = !controllerWasRunning;
525
+ }
526
+ if (result === undefined && controllerWasRunning) {
527
+ const stopController = options.stopController
528
+ ?? ((targetHome, expectedPid) => defaultStopController(targetHome, expectedPid, options.controllerOptions));
529
+ try {
530
+ const expectedPid = status.pid;
531
+ const stopped = await stopController(home, expectedPid);
532
+ if (!confirmedControllerStopped(stopped, expectedPid)) {
533
+ result = withClassification(controllerLifecycleBlocker("Controller stop did not confirm a drained process", new Error(`stop did not confirm captured PID ${expectedPid} with stopped:true`)), classification);
534
+ }
535
+ else {
536
+ controllerStopConfirmed = true;
537
+ }
538
+ }
539
+ catch (error) {
540
+ result = withClassification(controllerLifecycleBlocker("Controller stop/drain failed", error), classification);
541
+ }
542
+ }
543
+ }
544
+ else {
545
+ controllerStopConfirmed = true;
546
+ }
547
+ // The first inventory is a cheap/read-only preflight. Re-read after the
548
+ // Controller has fully drained so a lifecycle request that committed while
549
+ // the fence was being placed cannot slip through on stale evidence.
550
+ if (result === undefined) {
551
+ const finalInventory = await readOfflineInventory(options, home);
552
+ if (finalInventory.total > 0) {
553
+ result = withClassification(offlineInventoryBlocker(finalInventory, true), classification);
554
+ }
555
+ }
556
+ if (result === undefined) {
557
+ try {
558
+ result = withUpgradeCoordinationLock(home, () => {
559
+ const quiesce = verifyQuiesced(home, callerPid);
560
+ if (quiesce !== null)
561
+ return withClassification(quiesce, classification);
562
+ const db = new Database(join(home, COMMITTED_DATABASE_FILENAME));
563
+ try {
564
+ db.pragma("journal_mode = WAL");
565
+ db.pragma("synchronous = FULL");
566
+ db.pragma("foreign_keys = ON");
567
+ db.pragma("busy_timeout = 5000");
568
+ const apply = db.transaction(() => {
569
+ // BEGIN IMMEDIATE waits for every older SQLite writer. With the
570
+ // upgrade fence still held, the state read here is the final
571
+ // authoritative durable gate for the same schema transaction.
572
+ const durableInventory = inspectSqliteDurableUpgradeInventory(home, db);
573
+ if (durableInventory.total > 0) {
574
+ return withClassification(offlineInventoryBlocker(durableInventory, true), classification);
575
+ }
576
+ migrateSqliteSchema(db, { mode: "apply" });
577
+ return {
578
+ outcome: "upgraded",
579
+ classification,
580
+ migrationMode: "in-place",
581
+ report: sqliteInPlaceReport(options.latest, migration, "execute", now)
582
+ };
583
+ });
584
+ const applied = apply.immediate();
585
+ if (applied.outcome === "upgraded")
586
+ committed = true;
587
+ return applied;
588
+ }
589
+ finally {
590
+ db.close();
591
+ }
592
+ });
593
+ }
594
+ catch (error) {
595
+ unexpected = error;
596
+ }
597
+ }
598
+ }
599
+ finally {
600
+ try {
601
+ releaseFence();
602
+ }
603
+ catch (error) {
604
+ if (unexpected === undefined)
605
+ unexpected = error;
606
+ }
607
+ }
608
+ if (unexpected !== undefined) {
609
+ if (committed) {
610
+ return withClassification({
611
+ outcome: "blocked",
612
+ stage: "post-verify",
613
+ message: `SQLite migration committed but completion failed: ${messageOf(unexpected)}`,
614
+ action: "Do not restore the old Controller. Re-run this Yui version; the SQLite migration ledger is the commit record.",
615
+ storageCommitted: true
616
+ }, classification);
617
+ }
618
+ if (controllerWasRunning && controllerStopConfirmed) {
619
+ await restoreController(home, options, unexpected, controllerIdentity);
620
+ }
621
+ throw unexpected;
622
+ }
623
+ if (result === undefined)
624
+ throw new Error("SQLite in-place upgrade did not produce a result.");
625
+ if (result.outcome === "upgraded" && !externallyQuiesced && controllerWasRunning) {
626
+ try {
627
+ await (options.startController
628
+ ?? ((targetHome) => defaultStartController(targetHome, options.controllerOptions)))(home);
629
+ }
630
+ catch (error) {
631
+ return withClassification({
632
+ outcome: "blocked",
633
+ stage: "post-verify",
634
+ message: `SQLite migration committed but the replacement Controller did not start: ${messageOf(error)}`,
635
+ action: "Do not restore the old Controller. Start the current Yui Controller; the SQLite migration ledger already committed.",
636
+ storageCommitted: true
637
+ }, classification);
638
+ }
639
+ return result;
640
+ }
641
+ if (result.outcome === "blocked"
642
+ && !committed
643
+ && !externallyQuiesced
644
+ && controllerWasRunning
645
+ && controllerStopConfirmed) {
646
+ await restoreController(home, options, new Error(result.message), controllerIdentity);
647
+ }
648
+ return result;
649
+ }
650
+ function sqliteInPlaceReport(latest, migration, mode, now) {
651
+ const detail = `SQLite schema ${migration.currentVersion}->${migration.targetVersion}; `
652
+ + `pending versions ${migration.pendingVersions.join(", ")}`;
653
+ if (mode === "dry-run") {
654
+ return {
655
+ outcome: "dry-run",
656
+ mode,
657
+ source: latest,
658
+ target: latest,
659
+ steps: [],
660
+ effects: [],
661
+ derived: { rebuiltEffects: [] },
662
+ validation: {
663
+ checks: [{
664
+ name: "SQLite staged migration and loader gate",
665
+ outcome: "passed",
666
+ detail: `${detail}; snapshot migrated and reopened by the current SQLite loader`
667
+ }]
668
+ }
669
+ };
670
+ }
671
+ return {
672
+ outcome: "migrated",
673
+ mode,
674
+ source: latest,
675
+ target: latest,
676
+ steps: [],
677
+ effects: [],
678
+ derived: { rebuiltEffects: [] },
679
+ validation: {
680
+ checks: [{ name: "SQLite migration transaction", outcome: "passed", detail }]
681
+ },
682
+ switch: {
683
+ status: "switched",
684
+ detail: "SQLite schema migrated in place; no database copy or rebuild was created."
685
+ },
686
+ completedAt: now().toISOString()
687
+ };
688
+ }
360
689
  /** Dry run: validate through the staged gate, then discard; never switch. */
361
690
  function dryRun(options, classification, target) {
362
691
  // Refuse to reuse a stale staging directory from an interrupted run.
@@ -390,11 +719,7 @@ async function execute(options, classification, target, callerPid, now) {
390
719
  // the other upgrader's fence.
391
720
  let releaseFence;
392
721
  try {
393
- releaseFence = placeUpgradeFence(home, {
394
- reason: "storage upgrade in progress",
395
- createdAt: now().toISOString(),
396
- ownerPid: callerPid
397
- });
722
+ releaseFence = acquireUpgradeAdmission(options, "storage upgrade in progress", now, callerPid);
398
723
  }
399
724
  catch (error) {
400
725
  if (!(error instanceof UpgradeFenceError))
@@ -615,11 +940,7 @@ async function executePseudoLayout7Repair(options, classification, now) {
615
940
  const callerPid = options.callerPid ?? process.pid;
616
941
  let releaseFence;
617
942
  try {
618
- releaseFence = placeUpgradeFence(home, {
619
- reason: "storage repair in progress",
620
- createdAt: now().toISOString(),
621
- ownerPid: callerPid
622
- });
943
+ releaseFence = acquireUpgradeAdmission(options, "storage repair in progress", now, callerPid);
623
944
  }
624
945
  catch (error) {
625
946
  if (!(error instanceof UpgradeFenceError))
@@ -4,6 +4,7 @@ import { deltaRecheckBlocksAcceptance } from "../review/reviewRound.js";
4
4
  import { isSemanticReviewRound } from "../review/reviewOutcomeClassifier.js";
5
5
  import { reviewFindingLedgerWriteFailedFromEvents } from "../review/reviewFindingLedger.js";
6
6
  import { resolveRecordedTaskFinalReviewContract } from "../review/taskFinalReviewContractRebind.js";
7
+ import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
7
8
  import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
8
9
  const ACTIVE_JOB_STATUSES = new Set([
9
10
  "queued",
@@ -22,7 +23,8 @@ export function projectCompletionReadiness(facts, options = {}) {
22
23
  const advisories = [];
23
24
  const { task } = facts;
24
25
  const findingsGate = options.findingsGate ?? true;
25
- const taskFinalReviewRequired = resolveRecordedTaskFinalReviewContract(task.id, facts.workItems, facts.reviewRounds, facts.taskFinalReviewContractEvents) !== undefined;
26
+ const taskFinalReviewContract = resolveRecordedTaskFinalReviewContract(task.id, facts.workItems, facts.reviewRounds, facts.taskFinalReviewContractEvents)?.effective;
27
+ const taskFinalReviewRequired = taskFinalReviewContract !== undefined;
26
28
  // A pending/running Task-final Review must be resumed or blocked first.
27
29
  for (const round of facts.reviewRounds) {
28
30
  if ((round.scope ?? "work-item") !== "task")
@@ -43,11 +45,13 @@ export function projectCompletionReadiness(facts, options = {}) {
43
45
  // escalations remain audit evidence, but a later completed full Review (or
44
46
  // accepted delta) supersedes them instead of blocking completion forever.
45
47
  const latestCompletedTaskReview = facts.reviewRounds
46
- .filter((round) => ((round.scope ?? "work-item") === "task" && isSemanticReviewRound(round, {
47
- listAgentRuns: () => facts.agentRuns,
48
- listReviewFindings: () => facts.reviewFindings,
49
- listEvents: () => facts.events
50
- })))
48
+ .filter((round) => ((round.scope ?? "work-item") === "task"
49
+ && (taskFinalReviewContract === undefined || sameTaskFinalReviewContract(round.taskFinalReviewContract, taskFinalReviewContract))
50
+ && isSemanticReviewRound(round, {
51
+ listAgentRuns: () => facts.agentRuns,
52
+ listReviewFindings: () => facts.reviewFindings,
53
+ listEvents: () => facts.events
54
+ })))
51
55
  .slice()
52
56
  .sort((left, right) => (left.createdAt.localeCompare(right.createdAt)
53
57
  || left.id.localeCompare(right.id, undefined, { numeric: true })))