@zq-silk/yui 0.9.0 → 0.10.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.
package/ARCHITECTURE.md CHANGED
@@ -98,9 +98,9 @@ leaves every candidate for the Leader to accept directly or review explicitly.
98
98
  `final` keeps WorkItem acceptance and Integration independent and supplies the
99
99
  default Reviewer Role when the Leader decides the frozen Task result warrants
100
100
  an independent final Review. An immutable Task-final contract can require that
101
- Review. Once a Task-final Round exists, it remains an obligation; a changed
102
- Task head requires a new Round and the previous report remains evidence. This
103
- final Reviewer evaluates the whole
101
+ Review. A Leader-requested Round remains evidence without becoming policy: a
102
+ later Task head does not require another Round unless the Leader requests one
103
+ or an explicit Task contract requires it. This final Reviewer evaluates the whole
104
104
  Task, so normal delivery does not pay for a complete review of every WorkItem.
105
105
  Review Runs complete only their exact ReviewRound, leave the WorkItem awaiting
106
106
  acceptance, and never trigger another review or append a Candidate. Successful
package/README.md CHANGED
@@ -144,6 +144,21 @@ Input by itself do not block. A blocker reports the total and exact available
144
144
  Task/Role/Run/native-session/launch identities and reason, leaves the scene
145
145
  unchanged, and tells the user to re-run `yui update` after the listed work clears;
146
146
  it never kills, resets, rebinds, retries, or drains on the user's behalf.
147
+ After every listed Turn or Run finishes, the user can stop all idle managed
148
+ Sessions from a normal shell and retry the update:
149
+
150
+ ```sh
151
+ yui session stop --all
152
+ yui update
153
+ ```
154
+
155
+ `session stop --all` first checks every current Session and refuses without
156
+ stopping anything while any Session still has a running Turn/Run or pending
157
+ lifecycle work. Once clear, it fences new Leader dispatch, stops and drains the
158
+ Controller, rechecks the runtime facts, then stops every exact idle Session. The
159
+ Controller remains stopped so `yui update` can enter its offline window. When the
160
+ installed version predates this command, exit every listed managed Session
161
+ manually instead; a staged new CLI cannot write the older migration-required Home.
147
162
 
148
163
  Only that user re-run may enter the existing full migration. Once its preflight
149
164
  is clear, the update parent captures and stops the exact old Controller PID. The
@@ -266,9 +281,11 @@ feature before creating WorkItems. The Leader decides whether a feature is
266
281
  small enough to deliver on Task main or large enough for independently owned WorkItems. A WorkItem is
267
282
  one substantial requirement for one Worker, not a development step, test run,
268
283
  review finding, or local fix. Multiple WorkItems are useful only when distinct
269
- Workers can advance meaningful requirements independently. Once WorkItems
270
- produce Git results, their ChangeSets must be integrated into the Leader's
271
- Task main before Task-final Review or completion.
284
+ Workers can advance meaningful requirements independently. A WorkItem's
285
+ governing Candidate defines its delivery obligation: its current ChangeSets
286
+ must reach committed Integration or an explicit superseded queue disposition
287
+ before Task-final Review or completion. Older Candidate and ChangeSet records
288
+ remain audit evidence without keeping the Task open.
272
289
 
273
290
  `project refresh` is the explicit network operation for a stable Project checkout. It fetches the
274
291
  configured stable branch directly from the Project remote URL and advances only through a clean,
@@ -402,7 +419,9 @@ yui task base status <task-id> --refresh
402
419
  The default check is offline and uses local remote-tracking refs. `--refresh`
403
420
  is the explicit authorization to query the configured remote; Yui never
404
421
  fetches, rebases, merges, or force-pushes as a hidden side effect of Task
405
- completion.
422
+ completion. Behind, diverged, or unavailable remote state is reported as
423
+ delivery-risk evidence for the Leader; it does not replace the Leader's choice
424
+ of delivery base. A dirty Task workspace remains a completion blocker.
406
425
 
407
426
  Implementation WorkItems declare the Projects they may modify. Their workspace
408
427
  keeps the same relative layout, creates isolated worktrees only for that write
@@ -704,7 +723,7 @@ yui task complete <task-id> --summary-file delivery.txt \
704
723
  --accept-published-tree <publication-id>
705
724
  ```
706
725
 
707
- This does not weaken normal freshness checks. Yui requires the current,
726
+ This is an independent exact-tree authorization. Yui requires the current,
708
727
  unsuperseded Publication to be merged and verified, its local commit to equal
709
728
  the physical Task head, its remote commit to be ancestry-divergent, and both
710
729
  commits to resolve to the same exact tree. When a Task-final Review obligation
@@ -712,10 +731,10 @@ exists, completion also requires the latest semantic Round to attest the
712
731
  accepted Task head; otherwise completion does not invent a ReviewRound.
713
732
  `--refresh-remote` fetches
714
733
  the remote object graph before resolving that Publication commit. For a Task
715
- governed by a durable exact final-review contract, the user/Operator command
716
- persists the exact authorization tuple and wakes the Task Leader; only that
717
- contract-capable Leader may consume it and complete the Task. Tasks without
718
- that contract retain the one-step explicit completion path. The Task event
734
+ governed by a durable final-review contract, the stored contract continues to
735
+ require its Reviewer policy, but compatible CLI and Controller updates do not
736
+ need to reproduce its historical control-plane digest. Tasks without that
737
+ contract retain the one-step explicit completion path. The Task event
719
738
  audit records the authorization and, on completion, the accepted Project,
720
739
  Publication, optional ReviewRound, both commits, and tree.
721
740
 
@@ -764,14 +783,22 @@ Global Operator and global Role sessions remain native interactive CLIs:
764
783
  yui session enter <global-role>
765
784
  ```
766
785
 
786
+ An offline Home migration requires a short maintenance window with no managed
787
+ Agent Session running. Once current Turns and Runs have finished, use
788
+ `yui session stop --all` from a normal shell. The command stops Task and global
789
+ Role Sessions only after every one is idle, and leaves all Sessions untouched
790
+ when any Role is still busy. On success it also leaves the Controller stopped;
791
+ run `yui update` next rather than resuming ordinary Task work.
792
+
767
793
  tmux fixes a pane's history capacity when that pane is created. Existing panes
768
794
  retain their configured capacity; managed runtime output remains observable in
769
795
  the Agent Host pane without becoming lifecycle or acknowledgement evidence.
770
796
 
771
- Each Role, including a Task-bound Worker instance, can bind multiple configured Agents, has one active Agent, and keeps
772
- a separate native session per Agent binding. Operator narrows this to at most
773
- one Agent per adapter—for example, one Codex and one Claude—so its bindings are
774
- ready-to-switch configurations rather than parallel identities. Operator can
797
+ Each Role, including Operator and a Task-bound Worker instance, can bind multiple
798
+ configured Agents, has one active Agent, and keeps a separate native session per
799
+ Agent binding. Multiple bindings may use the same adapter for different accounts,
800
+ models, profiles, or environment sources. They are ready-to-switch configurations,
801
+ not parallel writers: the active binding remains the unique authority. Operator can
775
802
  keep multiple conversations for each binding. `operator new` and
776
803
  `operator resume` reuse the single Operator tmux pane: when a process is
777
804
  running, Yui asks before stopping it and switching the conversation. On a
@@ -813,7 +840,7 @@ movement cannot conceal a workflow that is not advancing.
813
840
 
814
841
  Stable Role context is also launch metadata, never a bootstrap turn. Yui passes Role policy and `systemPrompt` through the Agent's native system/developer-instruction channel. Task execution Runs receive the generic Leader or Worker Skill, while review Runs receive the generic Reviewer Skill based on durable Run purpose rather than a configured Role name. These Yui-owned Role Skills define portable orchestration only. Project Skills remain ordinary versioned files in the Project and are discovered, selected, and loaded by the Agent through its native project mechanism; Yui does not scan, parse, copy, or inject them.
815
842
 
816
- Native Codex developer instructions carry compact absolute references only for Yui-owned Role Skills, which Codex reads on demand. Because `developer_instructions` is one scalar setting, Yui inspects every supported Linux Codex layer—`/etc/codex/config.toml`, the user config, the selected `$CODEX_HOME/<name>.config.toml`, project configs, and `/etc/codex/managed_config.toml`—and refuses to replace a value found in any of them. Codex sessions opened without a managed Run use Yui's structured `notify` callback for session presentation and therefore require exclusive ownership of that setting. Managed Runs instead use invocation-local Agent Driver Hooks as their sole lifecycle authority. `skills.config` is not misused because it only enables or disables already-discovered Skills. Claude receives the same Yui-owned Role Skill content from a private `0600` managed context file rather than a large or sensitive argv value; retries and resumes reuse the purpose-specific Role path. Non-Operator global Roles stay neutral and receive no Task orchestration Skill. Operator therefore opens at an empty native composer, so the user's text remains its first user message. Leader wakeups and Worker or Reviewer Run assignments remain real mailbox-delivered work messages. An adapter without a native instruction channel must reject this context rather than silently converting it into a first user prompt.
843
+ Native Codex developer instructions carry compact absolute references only for Yui-owned Role Skills, which Codex reads on demand. Yui applies this scalar as an invocation-local override, so existing user, profile, project, and system values do not make the Session unusable and the underlying config file is never mutated. A higher-precedence managed `developer_instructions` value remains a bounded launch blocker because Codex will not let invocation flags replace it. Interactive Codex Sessions apply the same rule to Yui's structured `notify` callback; Doctor reports ordinary overridden sources as context and rejects an effective managed conflict. Managed Runs instead use invocation-local Agent Driver Hooks as their sole lifecycle authority and do not claim `notify`. `skills.config` is not misused because it only enables or disables already-discovered Skills. Claude receives the same Yui-owned Role Skill content from a private `0600` managed context file rather than a large or sensitive argv value; retries and resumes reuse the purpose-specific Role path. Non-Operator global Roles stay neutral and receive no Task orchestration Skill. Operator therefore opens at an empty native composer, so the user's text remains its first user message. Leader wakeups and Worker or Reviewer Run assignments remain real mailbox-delivered work messages. An adapter without a native instruction channel must reject this context rather than silently converting it into a first user prompt.
817
844
 
818
845
  ## Controller and failure handling
819
846
 
@@ -929,6 +956,7 @@ yui config role add|list|show|update|remove|bind|unbind
929
956
  yui config profile add|list|show|update|remove|reset
930
957
  yui config completion [bash|zsh|fish]
931
958
  yui session enter|record|replace|reconcile
959
+ yui session stop --all
932
960
  yui project add|clone|refresh|update|discover|list|show|knowledge
933
961
  ```
934
962
 
@@ -199,6 +199,12 @@ const globalSessionChildren = [
199
199
  usage: "yui session replace <role> --native-id <id> --reason <text>",
200
200
  options: ["--native-id", "--reason"]
201
201
  },
202
+ {
203
+ name: "stop",
204
+ summary: "Stop all idle managed Sessions and the Controller before an offline update.",
205
+ usage: "yui session stop --all",
206
+ options: ["--all"]
207
+ },
202
208
  {
203
209
  name: "reconcile",
204
210
  summary: "Reconcile durable Session owners with native sessions.",
@@ -1357,14 +1363,16 @@ export const ROOT_COMMAND = buildNode({
1357
1363
  },
1358
1364
  {
1359
1365
  name: "session",
1360
- summary: "Load and enter global Role sessions, and reconcile their durable identities.",
1366
+ summary: "Enter, stop, and reconcile managed Role sessions.",
1361
1367
  examples: [
1362
1368
  "yui session context operator --json",
1363
1369
  "yui session enter operator",
1370
+ "yui session stop --all",
1364
1371
  "yui session reconcile --report"
1365
1372
  ],
1366
1373
  sections: [
1367
1374
  { id: "global", title: "Global Role sessions", entries: ["context", "enter", "record", "replace"] },
1375
+ { id: "maintenance", title: "Maintenance", entries: ["stop"] },
1368
1376
  { id: "recovery", title: "Recovery", entries: ["reconcile"] }
1369
1377
  ],
1370
1378
  children: globalSessionChildren
package/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ import { CONFIG_DOMAINS } from "./config/configCatalog.js";
28
28
  import { runConfigOverview } from "./commands/configOverview.js";
29
29
  import { parseControllerCleanupOptions, parseControllerStatusOptions, parseControllerRuntimeSnapshot, renderControllerResourceStatus, renderRuntimeIdentitySection, summarizeDurablePhysicalMismatch, runInteractiveControllerCleanup } from "./commands/controllerCommands.js";
30
30
  import { parseExecutionAuditOptions, runExecutionAuditCommand } from "./commands/executionAuditCommands.js";
31
- import { parseSessionReconcileOptions, runSessionReconcileCommand } from "./commands/sessionCommands.js";
31
+ import { parseSessionReconcileOptions, parseSessionStopOptions, runSessionReconcileCommand, runSessionStopCommand } from "./commands/sessionCommands.js";
32
32
  import { SessionOwnerReconciliation } from "./controller/sessionOwnerReconciliation.js";
33
33
  import { runJobCommand } from "./commands/jobCommands.js";
34
34
  import { runDurableJobCommand } from "./commands/durableJobCommands.js";
@@ -667,6 +667,31 @@ export async function main() {
667
667
  return;
668
668
  }
669
669
  if (resolved[0] === "session") {
670
+ if (resolved[1] === "stop") {
671
+ const options = parseSessionStopOptions(resolved.slice(2));
672
+ const result = await runSessionStopCommand({
673
+ options,
674
+ runtime: {
675
+ beginMaintenance: () => acquireHandoverLock(home),
676
+ snapshot: () => ({
677
+ candidates: schedulerStore.listRuntimeSessionCandidates(),
678
+ dormant: schedulerStore.listDormantRuntimeOwners()
679
+ }),
680
+ drainController: () => runtime.drainController(),
681
+ stopController: () => stopFileTaskController(home, {
682
+ environment: process.env
683
+ }),
684
+ startController: async () => {
685
+ await ensureFileTaskController(home, { environment: process.env });
686
+ },
687
+ stopDormantSession: (candidate) => runtime.stopDormantSession(candidate)
688
+ },
689
+ environment: process.env
690
+ });
691
+ process.exitCode = result.exitCode;
692
+ emit(result.output, false, result.data);
693
+ return;
694
+ }
670
695
  const roleOptions = {
671
696
  yuiHome: home,
672
697
  env: process.env,
@@ -198,7 +198,6 @@ function updateRole(args, store, options) {
198
198
  if (changesAgentConfig) {
199
199
  const agentId = parsed.one("--agent")?.trim() || role.activeAgentId;
200
200
  const agent = requireAgent(agentId, tx);
201
- assertOperatorAdapterAvailable(role, agent);
202
201
  const binding = role.agentBindings[agentId] ?? createRoleAgentBinding(definition(agent));
203
202
  bindings = { ...role.agentBindings, [agentId]: patchRoleAgentBinding(binding, parsed) };
204
203
  }
@@ -231,7 +230,6 @@ function bindRole(args, store) {
231
230
  roleName: role.name
232
231
  }, "desired Agent binding update");
233
232
  const agent = requireAgent(agentId, tx);
234
- assertOperatorAdapterAvailable(role, agent);
235
233
  const binding = role.agentBindings[agentId] ?? createRoleAgentBinding(definition(agent));
236
234
  const withBinding = updateGlobalRole(role, {
237
235
  agentBindings: { ...role.agentBindings, [agentId]: binding }
@@ -260,16 +258,6 @@ function bindRole(args, store) {
260
258
  });
261
259
  return presentRole(result.message, result.role, store);
262
260
  }
263
- function assertOperatorAdapterAvailable(role, agent) {
264
- if (role.name !== "operator" || Object.hasOwn(role.agentBindings, agent.id))
265
- return;
266
- const existing = Object.values(role.agentBindings).find((binding) => binding.adapterId === agent.adapterId);
267
- if (existing !== undefined) {
268
- throw usageError(`Operator already has a ${agent.adapterId} Agent: ${existing.agentId}. `
269
- + "Update that Agent's configuration, or activate another adapter and "
270
- + "unbind it before binding this Agent.");
271
- }
272
- }
273
261
  function removeRole(args, store) {
274
262
  const [rawName, ...rest] = args;
275
263
  const name = roleName(rawName);
@@ -1,5 +1,121 @@
1
1
  import { usageError } from "../errors/cliError.js";
2
2
  import { defaultTableWidth, renderTable } from "../output/table.js";
3
+ export function parseSessionStopOptions(args) {
4
+ if (args.length !== 1 || args[0] !== "--all") {
5
+ throw usageError("Session stop usage: yui session stop --all.");
6
+ }
7
+ return { all: true };
8
+ }
9
+ /**
10
+ * Stop every current managed Session only when all of them are idle. The
11
+ * all-or-nothing preflight prevents a maintenance command from silently
12
+ * stopping half the topology while another Role is still working.
13
+ */
14
+ export async function runSessionStopCommand(input) {
15
+ void input.options;
16
+ if (input.environment?.YUI_SESSION_SCOPE !== undefined) {
17
+ throw usageError("Session stop must be run by the user from a normal shell, outside a managed Yui Session.");
18
+ }
19
+ const maintenance = input.runtime.beginMaintenance();
20
+ let completed = false;
21
+ try {
22
+ const initial = input.runtime.snapshot();
23
+ const initiallyBlocked = blockedSessions(initial);
24
+ if (initiallyBlocked.length > 0)
25
+ return blockedStopResult(initiallyBlocked);
26
+ // Stop and drain the Controller while the handover fence prevents a new
27
+ // Leader dispatch. The explicit full pass settles pending runtime inbox and
28
+ // lifecycle work that may exist without a current Session candidate.
29
+ await input.runtime.drainController();
30
+ await input.runtime.stopController();
31
+ // Re-read the authoritative facts after the drain because a Run that was
32
+ // already inside a pass may have changed state meanwhile.
33
+ const settled = input.runtime.snapshot();
34
+ const blockedAfterDrain = blockedSessions(settled);
35
+ if (blockedAfterDrain.length > 0) {
36
+ await input.runtime.startController();
37
+ return blockedStopResult(blockedAfterDrain);
38
+ }
39
+ for (const session of settled.candidates) {
40
+ const candidate = settled.dormant.find((entry) => sameStopCandidate(session, entry));
41
+ if (candidate === undefined) {
42
+ throw new Error(`Dormant Session changed during maintenance: ${renderSessionOwner(session)}.`);
43
+ }
44
+ await input.runtime.stopDormantSession(candidate);
45
+ }
46
+ const count = settled.candidates.length;
47
+ completed = true;
48
+ return {
49
+ output: count === 0
50
+ ? "No managed Sessions were running. The Controller remains stopped; run `yui update` now.\n"
51
+ : `Stopped ${count} managed Sessions. The Controller remains stopped; run \`yui update\` now.\n`,
52
+ data: { stopped: settled.candidates, blocked: [] },
53
+ exitCode: 0
54
+ };
55
+ }
56
+ catch (error) {
57
+ if (!completed) {
58
+ try {
59
+ await input.runtime.startController();
60
+ }
61
+ catch (restartError) {
62
+ throw new AggregateError([error, restartError], "Session maintenance failed and the Controller could not be restarted.");
63
+ }
64
+ }
65
+ throw error;
66
+ }
67
+ finally {
68
+ maintenance.release();
69
+ }
70
+ }
71
+ function blockedSessions(input) {
72
+ return input.candidates.flatMap((session) => {
73
+ const idle = input.dormant.some((candidate) => sameStopCandidate(session, candidate));
74
+ if (idle)
75
+ return [];
76
+ return [{
77
+ session,
78
+ reason: session.status === "running"
79
+ ? "running"
80
+ : "runtime-work-pending"
81
+ }];
82
+ });
83
+ }
84
+ function blockedStopResult(blocked) {
85
+ const details = blocked.map(({ session, reason }) => (`- ${renderSessionOwner(session)} (${reason === "running"
86
+ ? "a Turn or Run is still running"
87
+ : "an active Run or lifecycle operation is still pending"})`));
88
+ return {
89
+ output: [
90
+ `Cannot stop managed Sessions: ${blocked.length} Session(s) are still busy.`,
91
+ ...details,
92
+ "No Session was stopped. Let the listed work finish, then run `yui session stop --all` again."
93
+ ].join("\n") + "\n",
94
+ data: { stopped: [], blocked },
95
+ exitCode: 5
96
+ };
97
+ }
98
+ function sameStopCandidate(session, candidate) {
99
+ if (session.owner.scope !== candidate.owner.scope)
100
+ return false;
101
+ if (session.owner.roleName !== candidate.owner.roleName)
102
+ return false;
103
+ if (session.owner.scope === "task"
104
+ && candidate.owner.scope === "task"
105
+ && session.owner.taskId !== candidate.owner.taskId) {
106
+ return false;
107
+ }
108
+ return session.agentId === candidate.agentId
109
+ && session.adapterId === candidate.adapterId
110
+ && session.nativeSessionId === candidate.nativeSessionId
111
+ && session.launchId === candidate.launchId
112
+ && session.sessionUpdatedAt === candidate.sessionUpdatedAt;
113
+ }
114
+ function renderSessionOwner(session) {
115
+ return session.owner.scope === "task"
116
+ ? `${session.owner.taskId}/${session.owner.roleName}`
117
+ : `global/${session.owner.roleName}`;
118
+ }
3
119
  export function parseSessionReconcileOptions(args) {
4
120
  const allowed = new Set(["--report", "--cleanup"]);
5
121
  if (args.some((argument) => !allowed.has(argument))) {
@@ -1,5 +1,5 @@
1
1
  import { usageError } from "../errors/cliError.js";
2
- import { assertTaskBaseFreshnessForCompletion, inspectTaskBaseFreshness, renderTaskBaseFreshnessReport } from "../repository/taskBaseFreshness.js";
2
+ import { inspectTaskBaseFreshness, renderTaskBaseFreshnessReport } from "../repository/taskBaseFreshness.js";
3
3
  export async function runTaskBaseStatusCommand(args, store, options = {}) {
4
4
  const usage = "Task base status usage: yui task base status <task> [--refresh].";
5
5
  const refresh = args.includes("--refresh");
@@ -11,19 +11,9 @@ export async function runTaskBaseStatusCommand(args, store, options = {}) {
11
11
  git: options.git,
12
12
  refresh
13
13
  });
14
- assertReportIsDeliverySafe(report);
15
14
  return {
16
15
  kind: "output",
17
16
  output: renderTaskBaseFreshnessReport(report),
18
17
  data: report
19
18
  };
20
19
  }
21
- function assertReportIsDeliverySafe(report) {
22
- try {
23
- assertTaskBaseFreshnessForCompletion(report);
24
- }
25
- catch (error) {
26
- const message = error instanceof Error ? error.message : String(error);
27
- throw usageError(`${renderTaskBaseFreshnessReport(report)}${message}`);
28
- }
29
- }
@@ -102,7 +102,7 @@ function storedTaskFinalReviewContractResolution(store, taskId) {
102
102
  * acceptance, and completion mutation must present the same verified
103
103
  * capability; shared review-config drift is intentionally irrelevant.
104
104
  */
105
- function taskFinalReviewContractForMutation(store, taskId, options, authorization = {}) {
105
+ function taskFinalReviewContractForMutation(store, taskId, options) {
106
106
  const supplied = options.taskFinalReviewContract;
107
107
  if (supplied !== undefined) {
108
108
  validateTaskFinalReviewContract(supplied);
@@ -117,11 +117,12 @@ function taskFinalReviewContractForMutation(store, taskId, options, authorizatio
117
117
  }
118
118
  if (stored === undefined)
119
119
  return supplied;
120
- if (supplied === undefined) {
121
- if (authorization.allowStoredWithoutSupplied === true)
122
- return stored;
123
- throw usageError(`Task final-review contract is missing for ${taskId}.`);
124
- }
120
+ // Historical exact contracts now express only a durable Reviewer policy.
121
+ // The persisted contract remains the audit authority, but ordinary
122
+ // protocol/storage-compatible CLIs may continue the Task without presenting
123
+ // a release-bound capability on every mutation.
124
+ if (supplied === undefined)
125
+ return stored;
125
126
  if (!sameTaskFinalReviewContract(stored, supplied)) {
126
127
  throw usageError(`Task final-review contract control-plane digest mismatch for ${taskId}.`);
127
128
  }
@@ -168,18 +169,10 @@ export function preflightTaskCompletion(taskId, store, options = {}, request = {
168
169
  throw usageError(`Task is archived: ${task.id}.`);
169
170
  if (task.status !== "active")
170
171
  throw usageError(`Task is not active: ${task.id}.`);
171
- // Resolve and authenticate the durable Task-local gate before any remote
172
- // fetch or Integration write. All checks below mirror the transactional
173
- // completion path, which remains the final CAS fence after reconciliation.
174
- // A human/global Operator cannot present the exact managed Leader contract.
175
- // For the explicit published-tree path only, let that caller authenticate
176
- // the stored contract far enough to persist an exact authorization fact.
177
- // The same command must return before any contract-governed completion
178
- // mutation; the exact Leader later consumes the authorization with the real
179
- // contract capability.
180
- const authorizingPublishedTree = request.acceptedPublishedTreePublicationId !== undefined
181
- && actor !== "leader";
182
- const taskFinalReviewContract = taskFinalReviewContractForMutation(store, task.id, options, { allowStoredWithoutSupplied: authorizingPublishedTree });
172
+ // Resolve the durable Task-local Reviewer policy before any remote fetch or
173
+ // Integration write. Historical control-plane identity is audit evidence,
174
+ // not a capability that every compatible CLI must reproduce.
175
+ const taskFinalReviewContract = taskFinalReviewContractForMutation(store, task.id, options);
183
176
  const activeTaskReview = store.listReviewRounds(task.id).some((round) => ((round.scope ?? "work-item") === "task"
184
177
  && (round.status === "pending" || round.status === "running")));
185
178
  // Issue 06: one shared readiness projection enumerates every blocker.
@@ -4375,30 +4368,17 @@ function queueTaskReviewRound(store, task, config, taskCandidate, options, now,
4375
4368
  * (or a failed round is surfaced as a blocker).
4376
4369
  */
4377
4370
  function prepareFinalTaskReview(store, task, now, taskFinalContract, options) {
4378
- // Any Task-final ReviewRound is durable completion evidence/obligation.
4379
- // Once one exists, later changes to the mutable global review config cannot
4380
- // weaken the requirement or change its reviewer. Before the first such
4381
- // Round, the current global `final` config remains an available Reviewer
4382
- // default but does not establish an obligation by itself.
4371
+ // A Leader-requested ReviewRound is evidence, not policy. Only an explicit
4372
+ // immutable Task contract creates a durable completion obligation; optional
4373
+ // historical Rounds never cause completion to manufacture another Round for
4374
+ // a later head.
4375
+ if (taskFinalContract === undefined || task.projectBindings.length === 0)
4376
+ return null;
4383
4377
  const taskRounds = reviewRoundsByIdentity(store.listReviewRounds(task.id))
4384
4378
  .filter((round) => ((round.scope ?? "work-item") === "task"
4385
- && (taskFinalContract === undefined || sameTaskFinalReviewContract(round.taskFinalReviewContract, taskFinalContract))));
4379
+ && sameTaskFinalReviewContract(round.taskFinalReviewContract, taskFinalContract)));
4386
4380
  const establishedRound = taskRounds.at(-1);
4387
- let config;
4388
- if (taskFinalContract !== undefined) {
4389
- config = taskFinalReviewConfig(taskFinalContract);
4390
- }
4391
- else if (establishedRound === undefined) {
4392
- // The configured Reviewer is available to the Leader, but does not choose
4393
- // the Task topology. A managed final Review becomes an obligation only
4394
- // after the Leader requests one (or an immutable Task contract requires it).
4395
- config = null;
4396
- }
4397
- else {
4398
- config = { roleName: establishedRound.reviewerRoleName, trigger: "final" };
4399
- }
4400
- if (config === null || task.projectBindings.length === 0)
4401
- return null;
4381
+ const config = taskFinalReviewConfig(taskFinalContract);
4402
4382
  const taskCandidate = taskReviewProvenance(store, task, options).candidate;
4403
4383
  const latest = establishedRound;
4404
4384
  if (latest?.status === "running") {
@@ -1,9 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { callController, readControllerDiscovery, stopOrphanedFileTaskController, stopPreviousFileTaskController } from "../core/controllerClient.js";
4
+ import { controllerSocketPath } from "../core/controllerEndpoint.js";
4
5
  import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "../core/protocol.js";
5
6
  import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
6
7
  import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
8
+ import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
7
9
  import { hasRuntimeLifecycleWork } from "../runtime/lifecycleReservation.js";
8
10
  import { assertControllerStatusIdentity } from "../runtime/exactControlPlane.js";
9
11
  import { EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES } from "./domainIdentity.js";
@@ -11,6 +13,7 @@ import { yuiVersionIdentity } from "../version.js";
11
13
  import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
12
14
  import { WorkspaceCleanupBlockedError } from "../repository/taskWorkspacePreparer.js";
13
15
  import { CONTROLLER_SHUTDOWN_TIMEOUT_MS, LIFECYCLE_REQUEST_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
16
+ import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
14
17
  const STARTUP_TIMEOUT_MS = 5_000;
15
18
  const POLL_INTERVAL_MS = 50;
16
19
  const ENVIRONMENT_REFRESH_TIMEOUT_MS = 500;
@@ -502,6 +505,63 @@ export class FileTaskWorkflowRuntime {
502
505
  throw new Error(`Global Role runtime session is still active: ${roleName}.`);
503
506
  }
504
507
  }
508
+ /** Drain pending runtime facts while an external maintenance fence is held. */
509
+ async drainController() {
510
+ await ensureFileTaskController(this.home, {
511
+ environment: this.clientOptions.environment
512
+ });
513
+ await callFileTaskController(this.home, "scheduler.scan", {}, {
514
+ ...this.clientOptions,
515
+ requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
516
+ });
517
+ }
518
+ /**
519
+ * Maintenance-only exact stop used after the Controller has fully exited.
520
+ * The dormant candidate fences the durable cleanup request; physical owner
521
+ * records and Task runtime isolation are cleared through the same primitives
522
+ * as the Controller lifecycle path before the Session becomes stopped.
523
+ */
524
+ async stopDormantSession(candidate) {
525
+ const queuedAt = new Date();
526
+ const target = this.schedulerStore.enqueueRuntimeCleanup(candidate.owner, queuedAt, candidate);
527
+ if (target === null) {
528
+ throw new Error(`Dormant Session changed before maintenance cleanup: ${runtimeOwnerLabel(candidate.owner)}.`);
529
+ }
530
+ const reconciliation = new SessionOwnerReconciliation({
531
+ home: this.home,
532
+ store: this.store,
533
+ environment: this.clientOptions.environment,
534
+ tmux: this.tmux
535
+ });
536
+ const termination = await reconciliation.terminateOwner(candidate.owner);
537
+ if (termination.outcome !== "stop-confirmed") {
538
+ throw new Error(`Role runtime cleanup could not prove physical exit: ${runtimeOwnerLabel(candidate.owner)}; `
539
+ + termination.remaining
540
+ .map(({ record, detail }) => `${record.launchId}: ${detail}`)
541
+ .join("; "));
542
+ }
543
+ if (candidate.owner.scope === "task" && candidate.launchId !== undefined) {
544
+ const isolation = new FileTaskRuntimeIsolation({
545
+ runtimeRoot: `${this.home}.task-runtimes`,
546
+ controlPlane: {
547
+ yuiHome: this.home,
548
+ controllerSocketPath: controllerSocketPath(this.store.getHomeIdentity().homeId),
549
+ tmuxNamespace: yuiTmuxServerName(this.home),
550
+ globalInstallPaths: [process.execPath]
551
+ }
552
+ });
553
+ isolation.cleanupTaskLaunch({
554
+ taskId: candidate.owner.taskId,
555
+ launchId: candidate.launchId,
556
+ reason: this.store.getTask(candidate.owner.taskId)?.status === "completed"
557
+ ? "completion"
558
+ : "interruption"
559
+ });
560
+ }
561
+ if (!this.schedulerStore.completeRuntimeCleanup(target, new Date())) {
562
+ throw new Error(`Role runtime cleanup state changed before completion: ${runtimeOwnerLabel(candidate.owner)}.`);
563
+ }
564
+ }
505
565
  inspectTaskRolePanes(taskId) {
506
566
  return this.tmux.inspectTaskRolePanes(taskId);
507
567
  }
@@ -532,6 +592,11 @@ export class FileTaskWorkflowRuntime {
532
592
  });
533
593
  }
534
594
  }
595
+ function runtimeOwnerLabel(owner) {
596
+ return owner.scope === "task"
597
+ ? `${owner.taskId}/${owner.roleName}`
598
+ : `global/${owner.roleName}`;
599
+ }
535
600
  const MANAGED_RUNTIME_ENVIRONMENT = new Set(YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES);
536
601
  function foregroundGlobalRoleEnvironment(store, roleName, source) {
537
602
  const role = store.getGlobalRole?.(roleName);