@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
package/ARCHITECTURE.md CHANGED
@@ -19,6 +19,16 @@ units. For a feature, the Leader judges whether the whole result is small
19
19
  enough to own in the same way or large enough to need independently owned
20
20
  delivery units.
21
21
 
22
+ Operator updates have one durable path. Domain and runtime transitions first
23
+ append an immutable TaskEvent; user-owned questions append an InputRequest.
24
+ Only those record references enter the global Operator mailbox. The Controller
25
+ batches pending references into one receipt-backed synthetic user message for
26
+ the existing interactive Operator and defers the complete batch while that
27
+ Operator is busy or unavailable. The message carries CLI read pointers rather
28
+ than copied Task or Provider narrative. No lower layer calls the Operator, no
29
+ mutable notification projection duplicates the event history, and no Goal or
30
+ polling protocol is required to follow work.
31
+
22
32
  `WorkItem` means one substantial, independently acceptable requirement with a
23
33
  clear owner. Create multiple WorkItems only when multiple Workers can own and
24
34
  advance those requirements independently, normally in parallel. Internal
@@ -128,10 +138,14 @@ late results until explicitly reopened.
128
138
 
129
139
  Stable Project checkouts are read-only references. Task identity follows one
130
140
  bounded outcome rather than Project count. A Task binds zero or more Projects,
131
- records an independent base ref for each binding, and owns one workspace root
132
- containing a managed main worktree for each binding. The
133
- `<workspace>/tasks/<task>/main` root is a logical multi-Project container, not a
134
- Git repository. Each Project child (for example
141
+ records an independent base ref for each binding, and adopts one workspace root
142
+ only when it becomes active. A Draft owns planning state and Project bindings,
143
+ not a writable Workspace. Activation prepares physical worktrees first and then
144
+ commits status, workspace identity, cwd, and durable Workspace ownership in one
145
+ TaskStore transaction; failure discards unadopted resources and leaves the Task
146
+ Draft. The active Workspace contains a managed main worktree for each binding.
147
+ The `<workspace>/tasks/<task>/main` root is a logical multi-Project container,
148
+ not a Git repository. Each Project child (for example
135
149
  `<workspace>/tasks/<task>/main/yui`) is the supported Git cwd and points to
136
150
  `<workspace>/worktree/<project>/<task>/main`; Git commands run in that child.
137
151
  For a single-Project workspace, the native Agent starts in that Project's
@@ -142,6 +156,11 @@ additional-directory mechanism. The active Leader may append a Project when the
142
156
  same outcome expands; replacing an existing binding is not a scope-repair
143
157
  mechanism.
144
158
 
159
+ Before a Role launch, Yui verifies that every physical Project HEAD still
160
+ descends from its Workspace's recorded base. Normal committed progress is
161
+ allowed; a reset or repoint outside that lineage is reported as
162
+ `physical-drift` and fails closed before Provider launch.
163
+
145
164
  A WorkItem can read the full Task workspace but has an explicit Project write
146
165
  scope. Isolation creates a second root with independent worktrees for writable
147
166
  Projects and Task-main context for the rest. The managed dispatch and
package/README.md CHANGED
@@ -274,6 +274,12 @@ yui task context <task-id>
274
274
  yui task activate <task-id>
275
275
  ```
276
276
 
277
+ A Draft stores planning state and Project bindings only; it does not adopt a
278
+ writable managed Workspace. `task activate` prepares every bound Project first,
279
+ then commits the Task's `active` status and Task-owned Workspace together. A
280
+ preparation or consistency failure leaves the Task Draft and reports the
281
+ workspace diagnosis instead of exposing a partially adopted execution root.
282
+
277
283
  Task type describes intent rather than selecting an execution protocol.
278
284
  Software Projects use `bugfix` or `feature`: a bugfix is Leader-owned; if it
279
285
  grows into independently owned delivery requirements, reclassify it as a
@@ -390,14 +396,16 @@ acceptance so the Leader can accept it directly or run
390
396
  Leader-managed candidates awaiting a decision instead of marking them done.
391
397
  `final` does not create WorkItem ReviewRounds or decide Task topology. The
392
398
  Leader explicitly requests a Task-scoped Review, unless an immutable Task
393
- contract requires one. The Round freezes Task main directly, so even a
394
- Leader-owned Task with no WorkItem can be reviewed. A changed frozen head needs
395
- a new semantic Round; the same compatible Reviewer Session may continue in its
399
+ contract requires one. The Round snapshots the exact Task-main Project heads
400
+ directly, so even a Leader-owned Task with no WorkItem can be reviewed without
401
+ locking the mutable Task workspace. A changed frozen head needs a new semantic
402
+ Round; the same Reviewer Session continues in its
396
403
  stable workspace, while every Run remains bound to its exact Round and head.
397
404
  The Reviewer follows Project Policy/Knowledge and reports reachable, material,
398
405
  actionable findings across the complete Task.
399
- A ReviewRound freezes the Candidate's exact Git commit and creates a fresh,
400
- ReviewRound-owned writable worktree on a unique branch. Its AgentRun may edit,
406
+ A ReviewRound freezes the Candidate's exact Git commit and updates the
407
+ Reviewer Role's stable writable workspace to that head while recording exact
408
+ Round-owned workspace evidence. Its AgentRun may edit,
401
409
  test, and optionally commit diagnostic evidence there, but never changes the
402
410
  Candidate or Worker workspace and never creates another WorkItem, Candidate,
403
411
  ChangeSet, or recursive review. The result wakes the Leader, who decides whether
@@ -405,6 +413,17 @@ to route evidence to the original Worker, accept, reject and redispatch that
405
413
  Worker in its existing Session, review again, or request user input.
406
414
  A failed review remains visible evidence and wakes the Leader, but does not
407
415
  take that decision away from the Leader.
416
+ Task context and next-action expose direct Review facts: every frozen Project
417
+ commit, its relation to the current candidate, the active Run, and the Reviewer
418
+ workspace. A request that fails after Round creation retains the ReviewRound
419
+ and reports its exact reason; the Leader opens that Round and decides whether
420
+ to retry, inspect or clean the workspace, use another Reviewer, or continue
421
+ other work.
422
+ An active Task-final Review freezes only its own candidate; it does not prevent
423
+ the Leader from processing new input or advancing a later candidate. Delta
424
+ Recheck is always available when Yui can prove an accepted contiguous baseline
425
+ and exact diff. Yui does not select a mode from generic size thresholds;
426
+ `requires-full-review` returns to the Leader without creating another Round.
408
427
  Candidate history, every ReviewRound, and the Leader decision remain grouped
409
428
  under the original WorkItem. A rejected result creates a new Candidate on the
410
429
  next dispatch while reusing the original execution Role, Session, and
@@ -530,19 +549,18 @@ yui operator new
530
549
  yui operator enter
531
550
  ```
532
551
 
533
- When a Task Role's current native Session cannot continue, reset it by intent:
552
+ When a Task Role's current Provider Conversation cannot continue, request a
553
+ bounded switch with an audited reason:
534
554
 
535
555
  ```sh
536
- yui task role reset <task-id> <role> --reason "<why this generation cannot continue>"
556
+ yui task role session switch <task-id> <role> --reason "<why this conversation cannot continue>"
537
557
  ```
538
558
 
539
- Yui derives the current Run, Agent, launch, receipt, and native Session from its
540
- own records. It fails only that exact active Run (and its execution WorkItem),
541
- stores the current Session as broken history, and asks the Controller to stop
542
- only the Role-owned runtime. The command never creates a Candidate, accepts
543
- work, or completes the Task. While cleanup is pending, `task role status` and
544
- `task context` block a fresh launch. Existing messages, reviews, and delivery
545
- history remain durable.
559
+ The command records intent only. It does not fail active work, stop a live
560
+ runtime, or forget the current Conversation. Once the Role has ready work and
561
+ the current writer/Turn is settled, Yui creates and binds the replacement; a
562
+ failure before that bind leaves the old Conversation authoritative. Existing
563
+ messages, reviews, and delivery history remain durable.
546
564
 
547
565
  Without `--task`, `operator submit` creates a new Draft. Drafts accept planning changes but must be activated before Agent execution.
548
566
  Operator resolves every request against the Project catalog and existing Task
@@ -594,11 +612,14 @@ Every stage is a new immutable ExecutionGroup. Its ContextSnapshot and selected
594
612
  parent Lane results are durable references; `retry` repeats a stage within its
595
613
  attempt budget, while `retry` at Resolve begins the next bounded round.
596
614
 
597
- Each new stage also freezes one Resource Broker contract: token, tool-call and
598
- wall-clock budgets; quorum and deadline; a straggler window; and the minimum
599
- marginal value for more Lane spend. Omitted values reuse the existing context
600
- budget and runtime-health windows; stage retries share the original cumulative
601
- spend and absolute deadline. Execution, Lane retry, and Reviewer-panel
615
+ Each new stage also freezes one Resource Broker contract: behavioral tool-call
616
+ and wall-clock budgets; a display-only token threshold; quorum and deadline; a
617
+ straggler window; and the minimum marginal value for more Lane spend. Omitted
618
+ values reuse the existing context budget and runtime-health windows; stage
619
+ retries share the original cumulative observations and absolute deadline.
620
+ Observed token totals and the configured token threshold are cost context only:
621
+ they never close spend, block admission, suppress scheduling, or stop a Lane.
622
+ Execution, Lane retry, and Reviewer-panel
602
623
  admission all count active Lanes at Home, Task, WorkItem, Group, Provider,
603
624
  Agent, and model scopes. Capacity pressure keeps the excess Lane durably
604
625
  pending instead of failing the Group. Capacity release or deadline arrival
@@ -613,8 +634,9 @@ The Leader may add `--early-stop <0-100>` to an accepting Group resolution.
613
634
  Yui permits it only after quorum and T5's passed Verify/Resolve evidence prove
614
635
  sufficiency. It may skip Lanes that never started; active stragglers are
615
636
  reported and retained, never killed automatically for cost. If evidence is
616
- insufficient, budget or deadline exhaustion blocks the stage for Leader
617
- judgment instead of turning thin evidence into success.
637
+ insufficient, behavioral tool-call/wall-clock budget or deadline exhaustion
638
+ blocks the stage for Leader judgment instead of turning thin evidence into
639
+ success.
618
640
 
619
641
  New exploration histories also freeze the structured candidate-convergence
620
642
  contract. Yui appends the exact stage-local JSON shape to every Lane assignment
@@ -792,7 +814,7 @@ yui task input request <task-id> --question "Which format should be the default?
792
814
 
793
815
  The recommendation is shown to the user. If no answer arrives, the nearest-deadline timer wakes the Controller to atomically apply that exact choice and queue the fixed Leader session to resume. Free-text and user-required requests never auto-resolve.
794
816
 
795
- `task input list` is the authoritative global open-input Inbox; add a Task ID to scope it, or `--all` to include answered and cancelled requests. The Controller also makes one receipt-backed, best-effort delivery to an already-running Operator process. It never starts or interrupts an Operator for this notification; unavailable process state or a changed pane fence falls back to the durable Inbox and is reconsidered on a later Controller pass. It does not inspect or classify Agent terminal text. Answers may be submitted by the user or Operator. An open request prevents unrelated pending wakes and Task completion or archival. The originating Leader may instead run `yui task input cancel <task-id> <input-id> --reason "..."`; cancellation queues that fixed Leader session to resume.
817
+ `task input list` is the authoritative global open-input Inbox; add a Task ID to scope it, or `--all` to include answered and cancelled requests. Task completion, retirement, Leader attention, stalls, and open input are queued to the global Operator mailbox only as immutable TaskEvent or InputRequest references. The Controller merges one pending mailbox batch into one receipt-backed `[Yui updates]` user message for an existing ready Operator; the Operator reads the referenced records through the CLI and decides what is worth presenting. A running or unavailable Operator is never started or interrupted: the whole batch remains durable and is retried after native turn completion or a later Controller pass. This path is a user message, not a tool call, and it never inspects or classifies Agent terminal text. Answers may be submitted by the user or Operator. An open request prevents unrelated pending wakes and Task completion or archival. The originating Leader may instead run `yui task input cancel <task-id> <input-id> --reason "..."`; cancellation queues that fixed Leader session to resume.
796
818
 
797
819
  Inspect the result:
798
820
 
@@ -59,8 +59,7 @@ function durableConfigDomainNode(domain) {
59
59
  const values = CONFIG_KEY_VALUES.filter(({ name }) => keys.includes(name));
60
60
  const options = domain === "workflow"
61
61
  ? [
62
- "--soft-tokens", "--hard-tokens", "--role", "--trigger", "--finding-ledger",
63
- "--delta-recheck", "--delta-recheck-max-lines", "--delta-recheck-max-files"
62
+ "--soft-tokens", "--hard-tokens", "--role", "--trigger", "--finding-ledger"
64
63
  ]
65
64
  : domain === "runtime"
66
65
  ? ["--quiet-after-seconds", "--diagnostic-after-seconds", "--stall-after-seconds"]
@@ -86,8 +85,7 @@ function durableConfigDomainNode(domain) {
86
85
  optionValues: domain === "workflow"
87
86
  ? {
88
87
  "--trigger": ["always", "leader", "final"],
89
- "--finding-ledger": ["shadow", "enforce"],
90
- "--delta-recheck": ["enabled", "disabled"]
88
+ "--finding-ledger": ["shadow", "enforce"]
91
89
  }
92
90
  : {}
93
91
  },
@@ -542,7 +540,7 @@ const taskChildren = [
542
540
  name: "role",
543
541
  summary: "Manage Roles within a Task.",
544
542
  sections: [{ id: "manage", title: "Commands", entries: [
545
- "add", "list", "status", "show", "update", "remove", "bind", "unbind", "reset",
543
+ "add", "list", "status", "show", "update", "remove", "bind", "unbind", "session",
546
544
  "view", "takeover", "release"
547
545
  ] }],
548
546
  children: [
@@ -572,10 +570,15 @@ const taskChildren = [
572
570
  { name: "bind", summary: "Bind and activate an Agent for a Task Role.", usage: "yui task role bind <task> <role> <agent-id>" },
573
571
  { name: "unbind", summary: "Unbind a dormant Agent from a Task Role.", usage: "yui task role unbind <task> <role> <agent-id>" },
574
572
  {
575
- name: "reset",
576
- summary: "Fail current work, forget the native Session, and request verified cleanup.",
577
- usage: "yui task role reset <task> <role> --reason <text>",
578
- options: ["--reason"]
573
+ name: "session",
574
+ summary: "Manage the Provider Conversation bound to a Task Role.",
575
+ sections: [{ id: "manage", title: "Commands", entries: ["switch"] }],
576
+ children: [{
577
+ name: "switch",
578
+ summary: "Request a fresh Conversation after the current one reaches a safe boundary.",
579
+ usage: "yui task role session switch <task> <role> --reason <text>",
580
+ options: ["--reason"]
581
+ }]
579
582
  },
580
583
  {
581
584
  name: "view",
@@ -738,7 +741,7 @@ const taskChildren = [
738
741
  {
739
742
  name: "recover",
740
743
  summary: "Record one exact Leader-controlled Run recovery decision.",
741
- usage: "yui task run recover <task>/<run> --action <diagnose|retry|replace-session|terminate> (--expected-progress-at <timestamp>|--from-next-action <fingerprint>) --provider-acceptance <accepted|rejected|ambiguous> --reason <text>",
744
+ usage: "yui task run recover <task>/<run> --action <diagnose|retry|terminate> (--expected-progress-at <timestamp>|--from-next-action <fingerprint>) --provider-acceptance <accepted|rejected|ambiguous> --reason <text>",
742
745
  options: ["--action", "--expected-progress-at", "--progress-at", "--from-next-action", "--provider-acceptance", "--reason", "--role", "--agent-id", "--adapter-id", "--native-session-id", "--launch-id"]
743
746
  },
744
747
  {
@@ -347,19 +347,18 @@ export const INTERACTION_POLICIES = Object.freeze([
347
347
  ]
348
348
  })),
349
349
  {
350
- commandPath: ["task", "role", "reset"],
350
+ commandPath: ["task", "role", "session", "switch"],
351
351
  selectors: [
352
- { argumentIndex: 3, entity: "task", provider: "tasks", actionTarget: true, statuses: ["active"] },
352
+ { argumentIndex: 4, entity: "task", provider: "tasks", actionTarget: true, statuses: ["active"] },
353
353
  {
354
- argumentIndex: 4,
354
+ argumentIndex: 5,
355
355
  entity: "task-role",
356
356
  provider: "task-roles",
357
- dependsOn: 3,
357
+ dependsOn: 4,
358
358
  actionTarget: true
359
359
  }
360
360
  ],
361
- trailingOptions: { "--reason": "value" },
362
- confirmation: { action: "Reset Task Role Session", targetArgumentIndex: 4 }
361
+ trailingOptions: { "--reason": "value" }
363
362
  },
364
363
  ...["view", "takeover", "release"].map((command) => ({
365
364
  commandPath: ["task", "role", command],
@@ -38,7 +38,9 @@ export function renderUpdateResult(result) {
38
38
  return result.storageBackupPath === undefined
39
39
  ? result.path === "compatible-fast"
40
40
  ? `Updated Yui to ${result.version} via the compatible fast path; the Home was not migrated.`
41
- : `Updated Yui to ${result.version}; storage was already current.`
41
+ : result.path === "in-place-migration"
42
+ ? `Updated Yui to ${result.version}; SQLite was migrated in place without rebuilding the database.`
43
+ : `Updated Yui to ${result.version}; storage was already current.`
42
44
  : `Updated Yui to ${result.version}. Storage was migrated; original Home backed up at `
43
45
  + `${result.storageBackupPath}.`;
44
46
  case "aborted":
@@ -116,6 +116,64 @@ function runStagedUpdate(ports, staged, home) {
116
116
  ...(preflight.sceneUnchanged === true ? { sceneUnchanged: true } : {})
117
117
  };
118
118
  }
119
+ let releaseHandover;
120
+ try {
121
+ releaseHandover = ports.beginControllerHandover?.(home);
122
+ }
123
+ catch (error) {
124
+ return {
125
+ outcome: "aborted",
126
+ phase: "coordination",
127
+ message: `Controller handover could not be acquired: ${messageOf(error)}`,
128
+ action: "Another maintenance operation owns this Home. Wait for it to finish, then retry; no Controller, binary, or storage change was made.",
129
+ recoverable: true,
130
+ version: staged.version
131
+ };
132
+ }
133
+ let releaseStorageWriteFence;
134
+ const migrationPath = preflight.status === "in-place-migration"
135
+ || preflight.status === "migration-required"
136
+ || preflight.status === "migratable";
137
+ if (migrationPath && ports.beginControllerHandover !== undefined) {
138
+ if (ports.beginStorageWriteFence === undefined) {
139
+ releaseHandover?.();
140
+ return {
141
+ outcome: "aborted",
142
+ phase: "coordination",
143
+ message: "The update lifecycle does not provide a storage write-admission fence.",
144
+ action: "Use an updater that holds write admission through storage migration, binary activation, and loader verification; no Controller, binary, or storage change was made.",
145
+ recoverable: true,
146
+ version: staged.version
147
+ };
148
+ }
149
+ try {
150
+ releaseStorageWriteFence = ports.beginStorageWriteFence(home);
151
+ }
152
+ catch (error) {
153
+ releaseHandover?.();
154
+ return {
155
+ outcome: "aborted",
156
+ phase: "coordination",
157
+ message: `Storage write admission could not be acquired: ${messageOf(error)}`,
158
+ action: "Another maintenance operation owns this Home. Wait for it to finish, then retry; no Controller, binary, or storage change was made.",
159
+ recoverable: true,
160
+ version: staged.version
161
+ };
162
+ }
163
+ }
164
+ const resumeWrites = () => {
165
+ releaseStorageWriteFence?.();
166
+ releaseStorageWriteFence = undefined;
167
+ };
168
+ try {
169
+ return runPreflightedUpdate(ports, staged, home, preflight, resumeWrites);
170
+ }
171
+ finally {
172
+ resumeWrites();
173
+ releaseHandover?.();
174
+ }
175
+ }
176
+ function runPreflightedUpdate(ports, staged, home, preflight, resumeWrites) {
119
177
  // Capture and stop the old Controller exactly once after preflight but
120
178
  // before either storage activation or binary promotion. This parent update
121
179
  // process remains the sole lifecycle owner for both binary-only and
@@ -127,7 +185,7 @@ function runStagedUpdate(ports, staged, home) {
127
185
  // Current and all-compatible chains share the lean path: no Home copy,
128
186
  // backup, rename, or command replay. The exact Controller handoff prevents
129
187
  // old/new writers from overlapping while the same staged artifact activates.
130
- return activateAndVerify(ports, staged, home, undefined, lifecycle.lifecycle, preflight.status === "compatible" ? "compatible-fast" : "current-fast");
188
+ return activateAndVerify(ports, staged, home, undefined, lifecycle.lifecycle, preflight.status === "compatible" ? "compatible-fast" : "current-fast", resumeWrites);
131
189
  }
132
190
  // 3) Activate storage — recoverable: atomic switch + timestamped backup. An
133
191
  // activation port that throws unexpectedly may have committed the switch
@@ -139,7 +197,9 @@ function runStagedUpdate(ports, staged, home) {
139
197
  activation = ports.activateStorage(staged, home);
140
198
  }
141
199
  catch (error) {
142
- return resolveAmbiguousActivation(ports, staged, home, `the activation step threw unexpectedly: ${messageOf(error)}`);
200
+ return resolveAmbiguousActivation(ports, staged, home, `the activation step threw unexpectedly: ${messageOf(error)}`, preflight.status === "in-place-migration"
201
+ ? { kind: "in-place", migration: preflight.sqliteMigration }
202
+ : { kind: "switch" }, lifecycle.lifecycle, resumeWrites);
143
203
  }
144
204
  if (activation.status === "blocked") {
145
205
  const failure = {
@@ -160,24 +220,30 @@ function runStagedUpdate(ports, staged, home) {
160
220
  // The child was externally quiesced by this parent. A clean pre-switch
161
221
  // refusal therefore restores the exact captured identity; an ambiguous
162
222
  // activation is handled separately and never restores blindly.
163
- return restoreBeforeSwitchOrReport(ports, home, lifecycle.lifecycle, undefined, failure);
223
+ resumeWrites();
224
+ return restoreBeforeSwitchOrReport(ports, home, lifecycle.lifecycle, undefined, false, failure);
164
225
  }
165
226
  if (activation.status === "ambiguous") {
166
227
  // The activation child left no parseable receipt: the switch may or may not
167
228
  // have committed. Resolve the true state from the durable on-disk evidence
168
229
  // and report an explicit manual recovery — never a false "recoverable".
169
- return resolveAmbiguousActivation(ports, staged, home, activation.detail);
230
+ return resolveAmbiguousActivation(ports, staged, home, activation.detail, preflight.status === "in-place-migration"
231
+ ? { kind: "in-place", migration: preflight.sqliteMigration }
232
+ : { kind: "switch" }, lifecycle.lifecycle, resumeWrites);
170
233
  }
171
234
  if (activation.status === "migrated" && !isValidBackupPath(activation.backupPath)) {
172
235
  // A migrated/upgraded success without a concrete backup path violates
173
236
  // the recoverable storage-activation contract. Resolve it through the
174
237
  // existing durable receipt/schema probe instead of inferring that the
175
238
  // Home was untouched.
176
- return resolveAmbiguousActivation(ports, staged, home, "the activation reported migrated without a non-empty absolute backupPath");
239
+ return resolveAmbiguousActivation(ports, staged, home, "the activation reported migrated without a non-empty absolute backupPath", { kind: "switch" }, lifecycle.lifecycle, resumeWrites);
177
240
  }
178
241
  const backupPath = activation.status === "migrated" ? activation.backupPath : undefined;
242
+ const path = activation.status === "migrated-in-place"
243
+ ? "in-place-migration"
244
+ : "offline-migration";
179
245
  // 4/5) Promote the binary, then post-verify with the new binary's loader.
180
- return activateAndVerify(ports, staged, home, backupPath, lifecycle.lifecycle, "offline-migration");
246
+ return activateAndVerify(ports, staged, home, backupPath, lifecycle.lifecycle, path, resumeWrites);
181
247
  }
182
248
  /**
183
249
  * Promote the staged binary and run the new-binary health check. This is the
@@ -185,7 +251,8 @@ function runStagedUpdate(ports, staged, home) {
185
251
  * made atomic with it, so a failure here is reported with the exact backup-based
186
252
  * recovery and is NOT auto-reverted once the new version would resume writes.
187
253
  */
188
- function activateAndVerify(ports, staged, home, storageBackupPath, lifecycle, path) {
254
+ function activateAndVerify(ports, staged, home, storageBackupPath, lifecycle, path, resumeWrites) {
255
+ const inPlaceCommitted = path === "in-place-migration";
189
256
  try {
190
257
  ports.activateBinary(staged);
191
258
  }
@@ -194,9 +261,11 @@ function activateAndVerify(ports, staged, home, storageBackupPath, lifecycle, pa
194
261
  outcome: "aborted",
195
262
  phase: "activate-binary",
196
263
  message: `Failed to activate the new binary: ${messageOf(error)}`,
197
- action: storageBackupPath === undefined
198
- ? binaryActivationUncertainAction()
199
- : postSwitchRecoveryAction(home, storageBackupPath),
264
+ action: inPlaceCommitted
265
+ ? inPlaceMigrationRecoveryAction(home)
266
+ : storageBackupPath === undefined
267
+ ? binaryActivationUncertainAction()
268
+ : postSwitchRecoveryAction(home, storageBackupPath),
200
269
  // Once binary activation begins, its outcome is not knowable from a
201
270
  // failed npm process. Home-not-switched is useful evidence, but it does
202
271
  // not prove the current installation remains usable.
@@ -204,7 +273,9 @@ function activateAndVerify(ports, staged, home, storageBackupPath, lifecycle, pa
204
273
  version: staged.version,
205
274
  ...(storageBackupPath === undefined ? {} : { storageBackupPath })
206
275
  };
207
- return restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, failure);
276
+ if (!inPlaceCommitted && storageBackupPath === undefined)
277
+ resumeWrites();
278
+ return restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, inPlaceCommitted || storageBackupPath !== undefined, failure);
208
279
  }
209
280
  try {
210
281
  ports.verify(staged, home);
@@ -214,31 +285,41 @@ function activateAndVerify(ports, staged, home, storageBackupPath, lifecycle, pa
214
285
  outcome: "aborted",
215
286
  phase: "post-verify",
216
287
  message: `Post-update health check failed: ${messageOf(error)}`,
217
- action: storageBackupPath === undefined
218
- ? binaryHealthUncertainAction()
219
- : postSwitchRecoveryAction(home, storageBackupPath),
288
+ action: inPlaceCommitted
289
+ ? inPlaceMigrationRecoveryAction(home)
290
+ : storageBackupPath === undefined
291
+ ? binaryHealthUncertainAction()
292
+ : postSwitchRecoveryAction(home, storageBackupPath),
220
293
  recoverable: false,
221
294
  version: staged.version,
222
295
  ...(storageBackupPath === undefined ? {} : { storageBackupPath })
223
296
  };
224
- return restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, failure);
225
- }
297
+ if (!inPlaceCommitted && storageBackupPath === undefined)
298
+ resumeWrites();
299
+ return restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, inPlaceCommitted || storageBackupPath !== undefined, failure);
300
+ }
301
+ // The migrated database has now been opened successfully by the activated
302
+ // binary. Release direct-write admission before starting the replacement
303
+ // Controller, whose startup scheduler is itself an authoritative writer.
304
+ resumeWrites();
226
305
  if (lifecycle?.ensureRunning === true) {
227
306
  try {
228
307
  ports.startController(home);
229
308
  }
230
309
  catch (error) {
231
310
  const unknownActive = isUnknownActiveControllerFailure(error);
232
- const startFailureAction = lifecycle.wasRunning
233
- ? "The Home was not migrated. Keep writes quiesced and restore the previously running Controller identity before retrying."
234
- : "The Home was not migrated. Keep writes quiesced and start the replacement Controller after verifying the activated binary.";
311
+ const startFailureAction = inPlaceCommitted
312
+ ? inPlaceMigrationRecoveryAction(home)
313
+ : lifecycle.wasRunning
314
+ ? "The Home was not migrated. Keep writes quiesced and restore the previously running Controller identity before retrying."
315
+ : "The Home was not migrated. Keep writes quiesced and start the replacement Controller after verifying the activated binary.";
235
316
  const failure = {
236
317
  outcome: "aborted",
237
318
  phase: "post-verify",
238
319
  message: `${unknownActive ? "Replacement Controller ownership could not be authenticated safely" : "The replacement Controller could not start after activation and health verification"}: `
239
320
  + `${messageOf(error)}.`,
240
321
  action: unknownActive
241
- ? unknownActiveControllerAction(home, storageBackupPath)
322
+ ? unknownActiveControllerAction(home, storageBackupPath, inPlaceCommitted)
242
323
  : storageBackupPath === undefined
243
324
  ? startFailureAction
244
325
  : postSwitchRecoveryAction(home, storageBackupPath),
@@ -260,7 +341,7 @@ function activateAndVerify(ports, staged, home, storageBackupPath, lifecycle, pa
260
341
  // and preserve the explicit manual blocker.
261
342
  return unknownActive
262
343
  ? failure
263
- : restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, failure);
344
+ : restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, inPlaceCommitted || storageBackupPath !== undefined, failure);
264
345
  }
265
346
  }
266
347
  return storageBackupPath === undefined
@@ -377,10 +458,10 @@ function captureControllerLifecycle(ports, version, home) {
377
458
  }
378
459
  };
379
460
  }
380
- function restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, failure) {
461
+ function restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, storageCommitted, failure) {
381
462
  // Once storage switched, the old Controller is never safe to restore. Keep
382
463
  // the failure structured and point at all durable recovery evidence instead.
383
- if (storageBackupPath !== undefined || lifecycle?.wasRunning !== true)
464
+ if (storageCommitted || lifecycle?.wasRunning !== true)
384
465
  return failure;
385
466
  try {
386
467
  ports.restoreController(home, lifecycle.identity);
@@ -416,10 +497,16 @@ function binaryHealthUncertainAction() {
416
497
  + "assume the current install is usable. Reinstall Yui, verify `yui version` and `yui doctor`, "
417
498
  + "then retry `yui update` before resuming writes.";
418
499
  }
419
- function unknownActiveControllerAction(home, backupPath) {
420
- const storageEvidence = backupPath === undefined
421
- ? "The Home was not migrated."
422
- : `The storage switch is committed (backup at ${backupPath}); do not restore the old Controller. `;
500
+ function inPlaceMigrationRecoveryAction(home) {
501
+ return `The SQLite migration for ${home} committed in place and has no rollback backup. `
502
+ + "Do not restore the old Controller; finish installing this or a newer Yui version, run `yui doctor`, then start the Controller.";
503
+ }
504
+ function unknownActiveControllerAction(home, backupPath, inPlaceCommitted) {
505
+ const storageEvidence = inPlaceCommitted
506
+ ? "The SQLite migration committed in place; do not restore the old Controller. "
507
+ : backupPath === undefined
508
+ ? "The Home was not migrated."
509
+ : `The storage switch is committed (backup at ${backupPath}); do not restore the old Controller. `;
423
510
  return `${storageEvidence} A replacement Controller may still be active under unknown ownership. `
424
511
  + `Keep writes quiesced and do not claim recovery or resume writes. Inspect the authenticated `
425
512
  + `Controller status for ${home}, stop only the PID proven to belong to this update, then `
@@ -463,13 +550,26 @@ function isControllerIdentity(value) {
463
550
  * - no receipt, schema not current -> genuinely indeterminate; give the
464
551
  * operator the exact files to inspect.
465
552
  */
466
- function resolveAmbiguousActivation(ports, staged, home, detail) {
553
+ function resolveAmbiguousActivation(ports, staged, home, detail, expected, lifecycle, resumeWrites) {
467
554
  let probe;
468
555
  try {
469
556
  probe = ports.probeStorage(home);
470
557
  }
471
558
  catch (error) {
472
559
  // Even the probe failed: report maximum uncertainty with the raw evidence.
560
+ if (expected.kind === "in-place") {
561
+ return {
562
+ outcome: "ambiguous",
563
+ phase: "activate-storage",
564
+ message: `SQLite activation result is unknown (${detail}); reading its migration ledger also failed: ${messageOf(error)}.`,
565
+ action: `Do not restore the old Controller or assume the migration committed. Inspect the `
566
+ + `schema_migrations ledger in "${home}/yui.db" with the staged/current Yui version, `
567
+ + `then re-run "yui update" only after the ledger state is known.`,
568
+ version: staged.version,
569
+ schemaCurrent: false,
570
+ switched: false
571
+ };
572
+ }
473
573
  return {
474
574
  outcome: "ambiguous",
475
575
  phase: "activate-storage",
@@ -484,6 +584,48 @@ function resolveAmbiguousActivation(ports, staged, home, detail) {
484
584
  switched: false
485
585
  };
486
586
  }
587
+ if (expected.kind === "in-place"
588
+ && sameSqliteLedgerHead(probe.sqliteSchemaHead, expected.migration.target)) {
589
+ return {
590
+ outcome: "ambiguous",
591
+ phase: "activate-storage",
592
+ message: `The SQLite transaction committed according to its migration ledger, but the activation process did not confirm success (${detail}). The new binary was NOT promoted.`,
593
+ action: "Do not restore the old Controller. Re-run `yui update` with this or a newer version; the current ledger will make the storage step a no-op, then the binary and Controller handoff can finish.",
594
+ version: staged.version,
595
+ schemaCurrent: probe.schemaCurrent,
596
+ switched: false
597
+ };
598
+ }
599
+ if (expected.kind === "in-place"
600
+ && sameSqliteLedgerHead(probe.sqliteSchemaHead, expected.migration.current)) {
601
+ const failure = {
602
+ outcome: "aborted",
603
+ phase: "activate-storage",
604
+ message: `The SQLite activation process did not confirm success (${detail}), but the atomic migration ledger proves that no schema transaction committed.`,
605
+ action: "The database remains at its previous schema. The captured Controller identity was restored when possible; retry the update after diagnosing the activation child.",
606
+ recoverable: true,
607
+ version: staged.version
608
+ };
609
+ resumeWrites();
610
+ return restoreBeforeSwitchOrReport(ports, home, lifecycle, undefined, false, failure);
611
+ }
612
+ if (expected.kind === "in-place") {
613
+ const actual = probe.sqliteSchemaHead === undefined
614
+ ? "unavailable"
615
+ : `${probe.sqliteSchemaHead.version}:${probe.sqliteSchemaHead.checksum}`;
616
+ return {
617
+ outcome: "ambiguous",
618
+ phase: "activate-storage",
619
+ message: `SQLite activation did not confirm a result (${detail}), and its migration ledger head `
620
+ + `(${actual}) matches neither the validated source nor target boundary.`,
621
+ action: `Do not restore the old Controller or resume writes. Inspect the schema_migrations ledger `
622
+ + `in "${home}/yui.db" with the staged/current Yui version and resolve the unexpected `
623
+ + `ledger state before re-running "yui update".`,
624
+ version: staged.version,
625
+ schemaCurrent: probe.schemaCurrent,
626
+ switched: false
627
+ };
628
+ }
487
629
  if (probe.interrupted === true) {
488
630
  // A partially-applied, interrupted switch: the original was moved to the
489
631
  // backup and neither promotion nor rollback completed, so the Home path may
@@ -541,6 +683,9 @@ function resolveAmbiguousActivation(ports, staged, home, detail) {
541
683
  function messageOf(error) {
542
684
  return error instanceof Error ? error.message : String(error);
543
685
  }
686
+ function sameSqliteLedgerHead(actual, expected) {
687
+ return actual?.version === expected.version && actual.checksum === expected.checksum;
688
+ }
544
689
  function isValidBackupPath(value) {
545
690
  return typeof value === "string"
546
691
  && value.length > 0