@zq-silk/yui 0.14.2 → 0.15.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 (44) hide show
  1. package/ARCHITECTURE.md +27 -12
  2. package/README.md +85 -61
  3. package/dist/cli/commandCatalog.js +6 -6
  4. package/dist/cli/updateCommand.js +17 -9
  5. package/dist/cli/updateOrchestrator.js +81 -15
  6. package/dist/cli/updatePorts.js +72 -10
  7. package/dist/cli/upgradeCommand.js +104 -19
  8. package/dist/cli.js +2 -2
  9. package/dist/commands/agentCommands.js +13 -6
  10. package/dist/commands/controllerCommands.js +1 -1
  11. package/dist/commands/globalRoleCommands.js +11 -3
  12. package/dist/commands/roleConfiguration.js +7 -0
  13. package/dist/commands/roleRuntimeGuard.js +30 -0
  14. package/dist/commands/taskCommands.js +10 -3
  15. package/dist/controller/fileSchedulerStoreAdapter.js +4 -4
  16. package/dist/controller/runtime.js +11 -25
  17. package/dist/controller/runtimeLaunchCoordinator.js +9 -30
  18. package/dist/controller/sessionNotify.js +5 -0
  19. package/dist/core/controllerServer.js +5 -5
  20. package/dist/doctor/doctor.js +37 -14
  21. package/dist/executor/agentExecutor.js +8 -11
  22. package/dist/executor/effectiveLaunch.js +34 -17
  23. package/dist/executor/fileRoleLaunchPlanner.js +11 -8
  24. package/dist/observability/runtimeIdentity.js +48 -50
  25. package/dist/release/runtimeRelease.js +9 -1
  26. package/dist/runtime/agentHost.js +7 -0
  27. package/dist/runtime/codexInteractiveHost.js +191 -0
  28. package/dist/runtime/exactControlPlane.js +20 -29
  29. package/dist/runtime/structuredProviderHost.js +35 -0
  30. package/dist/runtime/tmuxAdapters.js +51 -9
  31. package/dist/scheduler/activeRoleTurnDelivery.js +4 -4
  32. package/dist/scheduler/leaderWakeupProcessor.js +3 -4
  33. package/dist/storage/currentTaskStore.js +6 -4
  34. package/dist/storage/sqliteSchema.js +134 -59
  35. package/dist/storage/sqliteStore.js +7 -5
  36. package/dist/storage/storageSchema.js +92 -223
  37. package/dist/storage/storageVersions.js +12 -16
  38. package/dist/storage/upgrade/upgradeOrchestrator.js +224 -62
  39. package/dist/tmux/tmuxManager.js +43 -28
  40. package/dist/version.js +3 -3
  41. package/docs/task-local-identity.md +9 -9
  42. package/i18n/README.zh-CN.md +33 -17
  43. package/package.json +1 -1
  44. package/dist/storage/upgrade/recordVersions.js +0 -82
@@ -7,9 +7,10 @@
7
7
  * `npm install --global --prefix <tmp>`, so the live global install is never
8
8
  * touched until the binary-activation step. Preflight invokes the STAGED binary's
9
9
  * internal `yui upgrade --update-preflight` contract so the target version
10
- * proves that the Home exactly matches its current storage contract. After the
11
- * parent stops the exact old Controller, the binary is promoted and the
12
- * activated binary verifies the unchanged Home.
10
+ * proves that the Home is current or has a complete supported migration path.
11
+ * After the parent stops the exact old Controller, the binary is promoted,
12
+ * required migrations run through that same staged artifact, and the activated
13
+ * binary verifies the resulting current Home.
13
14
  *
14
15
  * Two hardening guarantees this module enforces:
15
16
  *
@@ -146,13 +147,26 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
146
147
  const result = run("npm", ["install", "--global", spec], { cwd: process.cwd(), env: environment, shell: false, stdio: "inherit" });
147
148
  assertSpawnOk(result, "activate the new binary");
148
149
  },
150
+ migrateStorage(staged, home) {
151
+ const result = run(staged.binaryPath, ["--json", "upgrade", "--update-apply"], {
152
+ cwd: process.cwd(),
153
+ env: {
154
+ ...environment,
155
+ YUI_HOME: home,
156
+ YUI_UPDATE_HANDOVER_OWNER_PID: String(process.pid)
157
+ },
158
+ shell: false
159
+ });
160
+ return interpretStorageMigration(result);
161
+ },
149
162
  verify(staged, home) {
150
163
  // Verify the ACTUALLY-ACTIVATED global binary, not the staging path (P1-3).
151
164
  const activeBinary = resolveGlobalBinary(environment, run);
152
165
  if (activeBinary === null || !existsSync(activeBinary)) {
153
166
  throw runtimeError("Post-update health check failed: could not locate the activated global `yui` binary.");
154
167
  }
155
- // 1) Health check the unchanged current Home through the activated binary.
168
+ // 1) Health check the current (possibly just migrated) Home through the
169
+ // activated binary.
156
170
  // POST-VERIFY PARSES THE MACHINE-READABLE RESULT FIRST, THEN THE EXIT STATUS
157
171
  // (R2-F2). `yui --json doctor` deliberately sets a non-zero exit when storage
158
172
  // is unhealthy, so interpreting the exit status before the envelope would
@@ -784,21 +798,69 @@ function interpretPreflight(result) {
784
798
  ...(data.sceneUnchanged === true ? { sceneUnchanged: true } : {})
785
799
  };
786
800
  }
787
- /** Accept only an exact current Home. */
801
+ /** Accept either an exact current Home or a complete supported migration path. */
788
802
  function parseUpdatePreflightResult(data) {
789
- if (data.status !== "already-current"
803
+ if ((data.status !== "already-current" && data.status !== "migration-ready")
790
804
  || !Number.isSafeInteger(data.stepCount)
791
- || data.stepCount !== 0
792
805
  || !Array.isArray(data.steps)
793
- || data.steps.length !== 0)
806
+ || data.steps.length !== data.stepCount)
794
807
  return null;
795
808
  const homeClassification = data.classification;
796
809
  if (!isRecord(homeClassification) || !isRecord(homeClassification.classification))
797
810
  return null;
798
811
  const classification = homeClassification.classification;
799
- if (classification.verdict !== "USABLE" || classification.status !== "current")
812
+ if (data.status === "already-current") {
813
+ if (data.stepCount !== 0
814
+ || classification.verdict !== "USABLE"
815
+ || classification.status !== "current")
816
+ return null;
817
+ return { status: "already-current", stepCount: 0 };
818
+ }
819
+ if (data.stepCount <= 0
820
+ || classification.verdict !== "MIGRATABLE"
821
+ || classification.status !== "migration-ready"
822
+ || !data.steps.every(isStorageMigrationStep))
800
823
  return null;
801
- return { status: "already-current", stepCount: 0 };
824
+ return { status: "migration-ready", stepCount: data.stepCount };
825
+ }
826
+ function interpretStorageMigration(result) {
827
+ const data = parseSuccessEnvelopeData(result);
828
+ if (data === null) {
829
+ throw runtimeError("The staged binary did not return a successful storage migration result "
830
+ + `(exit ${result.status ?? "null"}${result.signal === null ? "" : `, signal ${result.signal}`}).`);
831
+ }
832
+ if (data.outcome === "blocked" || data.outcome === "failed") {
833
+ const message = typeof data.message === "string"
834
+ ? data.message
835
+ : "The staged binary refused the storage migration.";
836
+ const action = typeof data.action === "string" ? ` Action: ${data.action}` : "";
837
+ const backup = typeof data.backupPath === "string"
838
+ ? ` Backup: ${data.backupPath}`
839
+ : "";
840
+ throw runtimeError(`${message}${action}${backup}`);
841
+ }
842
+ if (result.status !== 0) {
843
+ throw runtimeError(`The staged binary returned outcome=${String(data.outcome)} but exited `
844
+ + `with status ${result.status ?? "null"}.`);
845
+ }
846
+ if (data.outcome === "already-current")
847
+ return {};
848
+ if (data.outcome !== "upgraded" || !isRecord(data.report)) {
849
+ throw runtimeError(`The staged binary returned unexpected storage migration outcome=${String(data.outcome)}.`);
850
+ }
851
+ const backupPath = data.report.backupPath;
852
+ if (typeof backupPath !== "string" || backupPath.length === 0) {
853
+ throw runtimeError("The staged binary did not report the required storage backup path.");
854
+ }
855
+ return { backupPath };
856
+ }
857
+ function isStorageMigrationStep(value) {
858
+ return isRecord(value)
859
+ && Number.isSafeInteger(value.fromVersion)
860
+ && Number.isSafeInteger(value.toVersion)
861
+ && value.toVersion === value.fromVersion + 1
862
+ && typeof value.name === "string"
863
+ && value.name.length > 0;
802
864
  }
803
865
  function parseUpdateBlockers(value) {
804
866
  if (value === undefined)
@@ -1,23 +1,24 @@
1
- /** `yui upgrade` validates the exact storage contract supported by this release. */
2
- import { latestStorageVersionState } from "../storage/upgrade/recordVersions.js";
1
+ /** `yui upgrade` plans or applies the supported linear storage migration chain. */
3
2
  import { runStorageUpgrade } from "../storage/upgrade/upgradeOrchestrator.js";
4
- import { usageError } from "../errors/cliError.js";
3
+ import { ensureFileTaskController, stopFileTaskController } from "../controller/clientRuntime.js";
4
+ import { runtimeError, usageError } from "../errors/cliError.js";
5
+ import { acquireHandoverLock, isForeignHandoverLockHeld, isHandoverLockHeld } from "../release/runtimeRelease.js";
5
6
  /**
6
7
  * Run the upgrade command. Parses the public `[--dry-run]` form plus the staged
7
8
  * updater's internal `--update-preflight` form, then returns rendered text,
8
9
  * structured data, and an exit code (0 for a safe result, 5 for a blocker).
9
10
  */
10
- export async function runUpgradeCommand(args, home) {
11
+ export async function runUpgradeCommand(args, home, environment = process.env) {
11
12
  const mode = parseUpgradeArgs(args);
12
- const result = await runStorageUpgrade({
13
- home,
14
- latest: latestStorageVersionState(),
15
- mode
16
- });
13
+ const result = mode === "execute"
14
+ ? await runInteractiveUpgrade(home, environment)
15
+ : mode === "update-apply"
16
+ ? await runUpdateOwnedUpgrade(home, environment)
17
+ : await runStorageUpgrade({ home, mode });
17
18
  return {
18
19
  output: renderUpgradeResult(result, mode),
19
20
  data: result,
20
- exitCode: result.outcome === "blocked" ? 5 : 0
21
+ exitCode: result.outcome === "blocked" || result.outcome === "failed" ? 5 : 0
21
22
  };
22
23
  }
23
24
  function parseUpgradeArgs(args) {
@@ -26,37 +27,121 @@ function parseUpgradeArgs(args) {
26
27
  if (args.length === 1 && args[0] === "--dry-run")
27
28
  return "dry-run";
28
29
  // Intentionally omitted from public command help: this is the machine contract
29
- // used by a staged `yui update`, not a replacement for user-facing dry-turn.
30
+ // used by a staged `yui update`, not a replacement for user-facing dry-run.
30
31
  if (args.length === 1 && args[0] === "--update-preflight")
31
32
  return "update-preflight";
33
+ if (args.length === 1 && args[0] === "--update-apply")
34
+ return "update-apply";
32
35
  throw usageError("Upgrade usage: yui upgrade [--dry-run]");
33
36
  }
37
+ async function runInteractiveUpgrade(home, environment) {
38
+ const preflight = await runStorageUpgrade({ home, mode: "update-preflight" });
39
+ if (preflight.outcome !== "update-preflight"
40
+ || preflight.status === "already-current") {
41
+ return runStorageUpgrade({ home, mode: "execute" });
42
+ }
43
+ const handover = acquireHandoverLock(home);
44
+ let controllerWasRunning = false;
45
+ try {
46
+ const stopped = await stopFileTaskController(home, {
47
+ environment,
48
+ handoverOwnerPid: process.pid
49
+ });
50
+ controllerWasRunning = stopped.stopped;
51
+ const result = await runStorageUpgrade({ home, mode: "execute" });
52
+ if (controllerWasRunning
53
+ && (result.outcome === "upgraded" || result.outcome === "already-current")) {
54
+ try {
55
+ await ensureFileTaskController(home, {
56
+ environment,
57
+ handoverOwnerPid: process.pid
58
+ });
59
+ }
60
+ catch (error) {
61
+ throw runtimeError(`Storage reached version ${result.report.targetVersion}, but the current `
62
+ + `Controller could not restart: ${messageOf(error)} Backup: `
63
+ + `${result.report.backupPath ?? "none"}. Keep the Home quiesced, inspect `
64
+ + "Controller ownership, and start only the current Yui Controller.");
65
+ }
66
+ }
67
+ return result;
68
+ }
69
+ finally {
70
+ handover.release();
71
+ }
72
+ }
73
+ async function runUpdateOwnedUpgrade(home, environment) {
74
+ const ownerText = environment.YUI_UPDATE_HANDOVER_OWNER_PID;
75
+ const ownerPid = ownerText === undefined ? Number.NaN : Number(ownerText);
76
+ if (!Number.isSafeInteger(ownerPid)
77
+ || ownerPid < 1
78
+ || process.ppid !== ownerPid
79
+ || !isHandoverLockHeld(home)
80
+ || isForeignHandoverLockHeld(home, ownerPid)) {
81
+ throw runtimeError("The internal update migration requires its direct parent to own the live "
82
+ + "Controller handover lock.");
83
+ }
84
+ return runStorageUpgrade({ home, mode: "execute" });
85
+ }
34
86
  /** Render an {@link UpgradeResult} as concise, CLI-style text. */
35
87
  export function renderUpgradeResult(result, mode) {
36
88
  const header = versionHeader(result);
37
89
  switch (result.outcome) {
38
90
  case "already-current":
39
91
  return `${header}\nStorage is already at the current version; nothing to upgrade.`;
92
+ case "upgrade-plan":
93
+ return [
94
+ header,
95
+ `Upgrade plan: ${renderSteps(result.report.steps)}. Storage was not modified.`
96
+ ].join("\n");
97
+ case "upgraded":
98
+ return [
99
+ header,
100
+ `Storage upgraded through ${renderSteps(result.report.steps)}.`,
101
+ `Backup: ${result.report.backupPath ?? "none"}`
102
+ ].join("\n");
40
103
  case "update-preflight":
41
104
  return `${header}\nUpdate preflight: ${result.status} (${result.stepCount} steps). Storage was not modified.`;
42
105
  case "blocked": {
43
106
  return [
44
107
  header,
45
- `${mode === "dry-run" ? "Dry run" : mode === "update-preflight" ? "Update preflight" : "Upgrade"} blocked at ${result.stage}: ${result.message}`,
108
+ `${mode === "dry-run"
109
+ ? "Dry run"
110
+ : mode === "update-preflight"
111
+ ? "Update preflight"
112
+ : "Upgrade"} blocked at ${result.stage}: ${result.message}`,
46
113
  `Action: ${result.action}`,
47
114
  "The authoritative Home is unchanged."
48
115
  ].join("\n");
49
116
  }
117
+ case "failed":
118
+ return [
119
+ header,
120
+ `Upgrade failed at ${result.stage}: ${result.message}`,
121
+ `Action: ${result.action}`,
122
+ `Backup: ${result.backupPath ?? "none"}`,
123
+ result.sceneUnchanged
124
+ ? "The authoritative Home was restored."
125
+ : "The authoritative Home may have changed; keep it quiesced."
126
+ ].join("\n");
50
127
  }
51
128
  }
52
129
  function versionHeader(result) {
53
130
  const classification = result.classification;
54
- const layout = classification.layoutVersion ?? classification.latestLayoutVersion;
55
- const aggregate = classification.aggregateVersion ?? classification.latestAggregateVersion;
131
+ const storage = classification.storageVersion === undefined
132
+ ? "unknown"
133
+ : String(classification.storageVersion);
56
134
  const verdict = classification.classification.verdict;
57
- const incompatible = classification.incompatibleComponent === undefined
58
- ? ""
59
- : ` incompatibleComponent=${classification.incompatibleComponent}`;
60
- return `Storage: ${verdict} layout=${layout}/${classification.latestLayoutVersion} `
61
- + `aggregate=${aggregate}/${classification.latestAggregateVersion}${incompatible}`;
135
+ return `Storage: ${verdict} version=${storage}/${classification.currentStorageVersion} `
136
+ + `minimum=${classification.minimumSupportedStorageVersion}`;
137
+ }
138
+ function renderSteps(steps) {
139
+ if (steps.length === 0)
140
+ return "no migrations";
141
+ return steps
142
+ .map(({ fromVersion, toVersion, name }) => `${fromVersion}->${toVersion} ${name}`)
143
+ .join(", ");
144
+ }
145
+ function messageOf(error) {
146
+ return error instanceof Error ? error.message : String(error);
62
147
  }
package/dist/cli.js CHANGED
@@ -2092,10 +2092,10 @@ async function executeOperatorSessionControl(control, home, store, runtime, tmux
2092
2092
  throw usageError("Operator is not configured. Run yui setup first.");
2093
2093
  const sessionSet = store.getGlobalRoleSessionSet(role.name);
2094
2094
  const active = sessionSet?.sessions[sessionSet.activeAgentId];
2095
- const paneRunning = tmux.detectRoleStatus("operator", "operator") === "running";
2095
+ const paneRunning = tmux.probeRoleStatus("operator", "operator") === "running";
2096
2096
  if (paneRunning && active === undefined) {
2097
2097
  throw usageError("Operator is running but its native session has not been recorded yet. "
2098
- + "Wait for the first turn to settle before switching sessions.");
2098
+ + "Record the exact native session before switching sessions.");
2099
2099
  }
2100
2100
  if (control.action === "resume"
2101
2101
  && paneRunning
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { agentNotFound, usageError } from "../errors/cliError.js";
3
3
  import { defaultTableWidth, renderTable } from "../output/table.js";
4
4
  import { createConfiguredAgent, validateConfiguredAgent } from "../agent/agent.js";
5
+ import { LIVE_SESSION_ACKNOWLEDGEMENT_OPTION } from "./roleRuntimeGuard.js";
5
6
  import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
6
7
  const SUPPORTED_ADAPTERS = Object.freeze(["codex", "claude"]);
7
8
  export function runAgentCommand(args, store) {
@@ -72,7 +73,8 @@ function updateAgent(args, store) {
72
73
  const [rawId, ...tail] = args;
73
74
  const id = agentId(rawId);
74
75
  const parsed = parseAgentOptions(tail, "update");
75
- if (parsed.seen.size === 0) {
76
+ if (parsed.seen.size === 0
77
+ || [...parsed.seen].every((option) => option === LIVE_SESSION_ACKNOWLEDGEMENT_OPTION)) {
76
78
  throw usageError("Agent update requires at least one operational option.");
77
79
  }
78
80
  if (parsed.has("--arg") && parsed.has("--clear-args")) {
@@ -113,10 +115,13 @@ function updateAgent(args, store) {
113
115
  + "Wait for lifecycle reconciliation to finish before changing Agent launch settings.");
114
116
  }
115
117
  const liveSession = findNonStoppedSessionReference(tx, id);
116
- if (liveSession !== null) {
117
- throw usageError(`Agent ${id} cannot be updated because ${describeReference(liveSession)} `
118
- + `retains a non-stopped native session (${liveSession.status}). `
119
- + "Stop that Role session before changing Agent launch settings.");
118
+ if (liveSession !== null && !parsed.has(LIVE_SESSION_ACKNOWLEDGEMENT_OPTION)) {
119
+ throw usageError(`${describeReference(liveSession)} runs a live native session (${liveSession.status}) on `
120
+ + `Agent ${id}, so this change applies to its next Host activation instead of the `
121
+ + "running one.\n"
122
+ + `Re-run with ${LIVE_SESSION_ACKNOWLEDGEMENT_OPTION} to record the change and keep that `
123
+ + "session.\n"
124
+ + "Stop the affected Role session first to apply it to a fresh session instead.");
120
125
  }
121
126
  if (changes.adapter) {
122
127
  const profile = findAgentProfileReference(tx, id);
@@ -273,7 +278,9 @@ function parseAgentOptions(args, mode) {
273
278
  ["--arg", { repeatable: true, allowOptionLikeValue: true }],
274
279
  ["--env", { repeatable: true, allowOptionLikeValue: false }]
275
280
  ]);
276
- const flags = mode === "update" ? new Set(["--clear-args", "--clear-env"]) : new Set();
281
+ const flags = mode === "update"
282
+ ? new Set(["--clear-args", "--clear-env", LIVE_SESSION_ACKNOWLEDGEMENT_OPTION])
283
+ : new Set();
277
284
  const seen = new Set();
278
285
  const values = new Map();
279
286
  for (let index = 0; index < args.length; index += 1) {
@@ -201,7 +201,7 @@ export function renderRuntimeIdentitySection(input) {
201
201
  ` Entry digest ${build.entryDigest}`,
202
202
  ` Source commit ${build.sourceCommit}`,
203
203
  ` Node ${build.nodeVersion} (${build.platform})`,
204
- ` Storage layout ${storage.logicalLayout} (manifest ${storage.manifestStatus}) · backend ${storage.configuredBackend} · worker ${storage.workerEnabled ? "on" : "off"}`,
204
+ ` Storage version ${storage.storageVersion} (status ${storage.storageStatus}, minimum ${storage.minimumStorageVersion}) · backend ${storage.configuredBackend} · worker ${storage.workerEnabled ? "on" : "off"}`,
205
205
  ` Store files state.json ${storage.physicalStateJson.present ? "present" : "absent"} · yui.db ${storage.physicalDatabase.present ? "present" : "absent"}${storage.physicalDatabase.wal ? " +WAL" : ""}${storage.physicalDatabase.present && storage.physicalDatabase.health !== UNSUPPORTED ? ` (${storage.physicalDatabase.health})` : ""}`
206
206
  ];
207
207
  for (const finding of storage.findings) {
@@ -5,9 +5,9 @@ import { defaultTableWidth, renderTable } from "../output/table.js";
5
5
  import { activeRoleSummary, renderRoleDetails } from "../output/rolePresentation.js";
6
6
  import { activeRoleAgentBinding, createGlobalRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateGlobalRole } from "../role/role.js";
7
7
  import { isSystemRoleName, SYSTEM_ROLE_NAMES, systemRoleDescription } from "../role/systemRoles.js";
8
- import { hasAgentConfigOptions, parseRoleOptions, patchRoleAgentBinding, roleOptionSpecs, roleProfileFrom, roleProfilePatch } from "./roleConfiguration.js";
8
+ import { hasAgentConfigOptions, hasNoRoleMutation, parseRoleOptions, patchRoleAgentBinding, roleOptionSpecs, roleProfileFrom, roleProfilePatch } from "./roleConfiguration.js";
9
9
  import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./roleSkillValidation.js";
10
- import { assertRoleRuntimeMutationAllowed } from "./roleRuntimeGuard.js";
10
+ import { assertLiveRoleSessionAcknowledged, assertRoleRuntimeMutationAllowed, LIVE_SESSION_ACKNOWLEDGEMENT_OPTION } from "./roleRuntimeGuard.js";
11
11
  export function runGlobalRoleCommand(args, store, options = {}) {
12
12
  const [command, ...rest] = args;
13
13
  switch (command) {
@@ -175,7 +175,7 @@ function updateRole(args, store, options) {
175
175
  if (parsed.has("--workspace") && trimmed(parsed.one("--workspace")) === undefined) {
176
176
  throw usageError("--workspace is required.");
177
177
  }
178
- if ([...parsed.seen].every((option) => option === "--agent")) {
178
+ if (hasNoRoleMutation(parsed)) {
179
179
  throw usageError("At least one role update option is required.");
180
180
  }
181
181
  const workspace = trimmed(parsed.one("--workspace"));
@@ -188,6 +188,14 @@ function updateRole(args, store, options) {
188
188
  scope: "global",
189
189
  roleName: role.name
190
190
  }, "desired launch configuration update");
191
+ assertLiveRoleSessionAcknowledged({
192
+ sessions: tx.getGlobalRoleSessionSet(role.name),
193
+ roleName: role.name,
194
+ desiredRevision: role.launchRevision,
195
+ acknowledged: parsed.has(LIVE_SESSION_ACKNOWLEDGEMENT_OPTION),
196
+ stopCommand: "yui session stop --all",
197
+ endsSession: workspace !== undefined && workspace !== role.workspace
198
+ });
191
199
  }
192
200
  let bindings = role.agentBindings;
193
201
  if (changesAgentConfig) {
@@ -42,6 +42,12 @@ const AGENT_CONFIG_OPTIONS = new Set([
42
42
  ...AGENT_REPEATABLE_OPTIONS.map(([option]) => option),
43
43
  ...AGENT_CLEAR_OPTIONS.map(([option]) => option)
44
44
  ]);
45
+ /** Options that select a target or acknowledge a fact instead of changing the Role. */
46
+ const NON_MUTATING_OPTIONS = ["--agent", "--yes"];
47
+ /** Whether the parsed options change nothing about the Role itself. */
48
+ export function hasNoRoleMutation(parsed) {
49
+ return [...parsed.seen].every((option) => NON_MUTATING_OPTIONS.includes(option));
50
+ }
45
51
  export function roleOptionSpecs(input) {
46
52
  const agentValueOptions = input.agentOptions === "execution"
47
53
  ? AGENT_VALUE_OPTIONS.filter(([option]) => option === "--model" || option === "--effort")
@@ -57,6 +63,7 @@ export function roleOptionSpecs(input) {
57
63
  return new Map([
58
64
  ...(input.includeAgent === true ? [["--agent", "value"]] : []),
59
65
  ...(input.includeWorkspace === true ? [["--workspace", "value"]] : []),
66
+ ...(input.update ? [["--yes", "flag"]] : []),
60
67
  ...PROFILE_OPTIONS,
61
68
  ...agentValueOptions,
62
69
  ...agentRepeatableOptions,
@@ -1,4 +1,5 @@
1
1
  import { usageError } from "../errors/cliError.js";
2
+ import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
2
3
  import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
3
4
  /**
4
5
  * Runtime launch reservation and cleanup are ownership obligations for a Role
@@ -10,3 +11,32 @@ export function assertRoleRuntimeMutationAllowed(store, owner, action) {
10
11
  return;
11
12
  throw usageError(`Role ${action} is blocked while a runtime lifecycle transition is pending or processing.`);
12
13
  }
14
+ export const LIVE_SESSION_ACKNOWLEDGEMENT_OPTION = "--yes";
15
+ /**
16
+ * A live native Session keeps the launch configuration it started with. Changing
17
+ * the desired configuration is therefore a decision about the next Host
18
+ * activation, and the Agent making the change is the one that knows whether the
19
+ * running Session should keep going or be replaced. Report that fact once, here,
20
+ * instead of refusing the Turn that later resumes the Session.
21
+ */
22
+ export function assertLiveRoleSessionAcknowledged(input) {
23
+ if (input.acknowledged)
24
+ return;
25
+ const session = activeLiveRoleAgentSession(input.sessions);
26
+ if (session === null)
27
+ return;
28
+ throw usageError([
29
+ input.endsSession === true
30
+ ? `Role ${input.roleName} has a live native Session that cannot continue after this `
31
+ + "change; its next Turn will need a new Session."
32
+ : `Role ${input.roleName} has a live native Session, so this change applies to its `
33
+ + "next Host activation instead of the running one.",
34
+ ` Agent: ${session.agentId} (${session.adapterId})`,
35
+ ` Native Session: ${session.nativeSessionId}`,
36
+ ` Host activation: ${session.runtimeGenerationId ?? "not recorded"}`,
37
+ ` Session launched from desired revision r${session.effective.sourceDesiredRevision}; `
38
+ + `current desired revision r${input.desiredRevision}`,
39
+ `Re-run with ${LIVE_SESSION_ACKNOWLEDGEMENT_OPTION} to record the change and keep the Session.`,
40
+ `Run \`${input.stopCommand}\` first to apply it to a fresh Session instead.`
41
+ ].join("\n"));
42
+ }
@@ -50,9 +50,9 @@ import { projectWorkItemExecution } from "../execution/workItemExecutionProjecti
50
50
  import { sameTaskFinalReviewContract, taskFinalReviewConfig, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
51
51
  import { resolveRecordedTaskFinalReviewContract } from "../review/taskFinalReviewContractResolution.js";
52
52
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
53
- import { hasAgentConfigOptions, parseRoleOptions, patchRoleAgentBinding, roleOptionSpecs, roleProfilePatch } from "./roleConfiguration.js";
53
+ import { hasAgentConfigOptions, hasNoRoleMutation, parseRoleOptions, patchRoleAgentBinding, roleOptionSpecs, roleProfilePatch } from "./roleConfiguration.js";
54
54
  import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./roleSkillValidation.js";
55
- import { assertRoleRuntimeMutationAllowed } from "./roleRuntimeGuard.js";
55
+ import { assertLiveRoleSessionAcknowledged, assertRoleRuntimeMutationAllowed, LIVE_SESSION_ACKNOWLEDGEMENT_OPTION } from "./roleRuntimeGuard.js";
56
56
  import { runTaskContextCommand } from "./taskContextCommand.js";
57
57
  import { runTaskNextActionCommand } from "./taskNextActionCommand.js";
58
58
  import { runDeliveryGuardPreflight, withGuardWarnings } from "./deliveryGuardPreflight.js";
@@ -1643,7 +1643,7 @@ function updateTaskRole(args, store, options) {
1643
1643
  if (parsed.has("--agent") && (parsed.one("--agent")?.trim().length ?? 0) === 0) {
1644
1644
  throw usageError("--agent is required.", usage);
1645
1645
  }
1646
- if ([...parsed.seen].every((option) => option === "--agent")) {
1646
+ if (hasNoRoleMutation(parsed)) {
1647
1647
  throw usageError("At least one role update option is required.", usage);
1648
1648
  }
1649
1649
  const now = clock(options);
@@ -1660,6 +1660,13 @@ function updateTaskRole(args, store, options) {
1660
1660
  taskId: task.id,
1661
1661
  roleName: role.name
1662
1662
  }, "desired launch configuration update");
1663
+ assertLiveRoleSessionAcknowledged({
1664
+ sessions: tx.getTaskRoleSessionSet(task.id, role.name),
1665
+ roleName: role.name,
1666
+ desiredRevision: role.launchRevision,
1667
+ acknowledged: parsed.has(LIVE_SESSION_ACKNOWLEDGEMENT_OPTION),
1668
+ stopCommand: `yui task role session stop ${task.id} ${role.name} --reason "<decision>"`
1669
+ });
1663
1670
  }
1664
1671
  const profileId = parsed.one("--profile");
1665
1672
  const agentProfile = profileId === undefined
@@ -9,7 +9,7 @@ import { buildTaskWakeEnvelope } from "../context/wakeNotification.js";
9
9
  import { createTaskWake, fallbackWakeCursor, latestTaskWake } from "../scheduler/taskWake.js";
10
10
  import { answerInputRequest } from "../input/inputRequest.js";
11
11
  import { activeRoleAgentBinding } from "../role/role.js";
12
- import { effectiveLaunchWithTaskMainWorkspace, effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskSession, resolveEffectiveLaunch, validateEffectiveLaunchSnapshot } from "../executor/effectiveLaunch.js";
12
+ import { effectiveLaunchWithTaskMainWorkspace, roleSessionMayContinue, resolveEffectiveLaunch, validateEffectiveLaunchSnapshot } from "../executor/effectiveLaunch.js";
13
13
  import { SYSTEM_OPERATOR_ROLE } from "../role/systemRoles.js";
14
14
  import { appendTurnInput, createTurn } from "../turn/turn.js";
15
15
  import { transportAgentResult } from "../domain/agentResultTransport.js";
@@ -2603,7 +2603,7 @@ function recordTaskRuntimeNativeSession(store, input, now) {
2603
2603
  const effective = input.effective === undefined
2604
2604
  ? resolvedEffective
2605
2605
  : validateEffectiveLaunchSnapshot(input.effective);
2606
- if (!effectiveLaunchSnapshotsCompatibleForTaskSession(resolvedEffective, effective)) {
2606
+ if (!roleSessionMayContinue(resolvedEffective, effective)) {
2607
2607
  throw new Error("Reserved native Session effective launch changed before persistence.");
2608
2608
  }
2609
2609
  if (effective.agentId !== input.agentId || effective.adapterId !== input.adapterId) {
@@ -2636,7 +2636,7 @@ function recordGlobalRuntimeNativeSession(store, input, now) {
2636
2636
  const effective = input.effective === undefined
2637
2637
  ? resolvedEffective
2638
2638
  : validateEffectiveLaunchSnapshot(input.effective);
2639
- if (!effectiveLaunchSnapshotsCompatible(resolvedEffective, effective)) {
2639
+ if (!roleSessionMayContinue(resolvedEffective, effective)) {
2640
2640
  throw new Error("Reserved global native Session effective launch changed before persistence.");
2641
2641
  }
2642
2642
  if (effective.agentId !== input.agentId || effective.adapterId !== input.adapterId) {
@@ -2719,7 +2719,7 @@ function taskSessionEffective(store, taskId, roleName, agentId, existing) {
2719
2719
  throw new Error(`Native Session registration does not match the effective Turn Agent: ${taskId}/${roleName}.`);
2720
2720
  }
2721
2721
  if (existing !== undefined) {
2722
- if (!effectiveLaunchSnapshotsCompatibleForTaskSession(existing.effective, active.effective)) {
2722
+ if (!roleSessionMayContinue(existing.effective, active.effective)) {
2723
2723
  throw new Error(`Native Session effective launch does not match the active Turn: ${taskId}/${roleName}.`);
2724
2724
  }
2725
2725
  return existing.effective;
@@ -1,5 +1,4 @@
1
1
  import { reconciliationIntervalMilliseconds, resolveAgentLaunchInactivityTimeoutSeconds, resolveControllerTaskConcurrency, resolveDeliveryTimeoutSeconds, resolveRuntimeHealth, resolveTmuxBin, resolveTmuxHistoryLimit } from "../config/yuiConfig.js";
2
- import { createHash } from "node:crypto";
3
2
  import { resolve } from "node:path";
4
3
  import { isDeepStrictEqual } from "node:util";
5
4
  import { controllerSocketPath } from "../core/controllerEndpoint.js";
@@ -7,9 +6,9 @@ import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, YUI_M
7
6
  import { hasRuntimeCleanupObligation, runtimeLifecycleSignalKey, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
8
7
  import { agentProcessReadinessProbe, ExecutorRegistry } from "../executor/executorRegistry.js";
9
8
  import { activeLiveRoleAgentSession, roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
10
- import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskSession, effectiveLaunchConfig, resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
9
+ import { roleSessionMayContinue, effectiveLaunchConfig, resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
11
10
  import { AgentConfigurationCatalogService, validateAgentLaunchConfiguration } from "../executor/agentConfigurationCatalog.js";
12
- import { isTaskOwnedWorkspace, managedWorkspaceIdentity, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
11
+ import { isTaskOwnedWorkspace, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
13
12
  import { FileRoleLaunchPlanner } from "../executor/fileRoleLaunchPlanner.js";
14
13
  import { openCurrentTaskStore } from "../storage/currentTaskStore.js";
15
14
  import { SqliteTaskStore } from "../storage/sqliteStore.js";
@@ -216,7 +215,6 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
216
215
  assertCurrent: (request) => {
217
216
  assertRuntimeLaunchRequestCurrent(store, request);
218
217
  },
219
- launchFingerprint: (request) => (runtimeLaunchFingerprint(store, request)),
220
218
  onCleanupRequired: signalRuntimeCleanup,
221
219
  runtimeIsolation
222
220
  });
@@ -501,7 +499,6 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
501
499
  assertCurrent: (request) => {
502
500
  assertRuntimeLaunchRequestCurrent(store, request);
503
501
  },
504
- launchFingerprint: (request) => (runtimeLaunchFingerprint(store, request)),
505
502
  onCleanupRequired
506
503
  });
507
504
  const lifecycleTails = new Map();
@@ -862,11 +859,15 @@ function assertRuntimeLaunchRequestCurrent(store, request) {
862
859
  || session.nativeSessionId !== request.nativeSessionId) {
863
860
  throw new Error(`Native session changed: ${request.owner.roleName}.`);
864
861
  }
865
- const sessionEffectiveCompatible = request.owner.scope === "task"
866
- ? effectiveLaunchSnapshotsCompatibleForTaskSession(session.effective, request.effective)
867
- : effectiveLaunchSnapshotsCompatible(session.effective, request.effective);
868
- if (!sessionEffectiveCompatible) {
869
- throw new Error(`Native session effective launch changed: ${request.owner.roleName}.`);
862
+ // The Role's durable Session record is the only authority for which Host
863
+ // activation may be restored. Targeting anything else would revive a
864
+ // historical activation.
865
+ if (request.hostActivationId !== undefined
866
+ && session.runtimeGenerationId !== request.hostActivationId) {
867
+ throw new Error(`Session restore does not target the Role's current Host activation: ${request.owner.roleName}.`);
868
+ }
869
+ if (!roleSessionMayContinue(session.effective, request.effective)) {
870
+ throw new Error(`Native session cannot continue under this launch: ${request.owner.roleName}.`);
870
871
  }
871
872
  }
872
873
  }
@@ -888,21 +889,6 @@ function currentDesiredEffective(store, request, role) {
888
889
  ...(item === null ? {} : { workItemWriteProjectIds: item.writeProjectIds })
889
890
  });
890
891
  }
891
- function runtimeLaunchFingerprint(store, request) {
892
- const agent = store.getConfiguredAgent(request.effective.agentId);
893
- if (agent === null) {
894
- throw new Error(`Agent no longer exists: ${request.effective.agentId}.`);
895
- }
896
- return createHash("sha256").update(JSON.stringify([
897
- request.owner,
898
- request.effective,
899
- request.managedWorkspace === undefined
900
- ? undefined
901
- : managedWorkspaceIdentity(request.managedWorkspace),
902
- request.runtimePolicy,
903
- agent
904
- ])).digest("hex");
905
- }
906
892
  function currentDesiredManagedWorkspace(store, taskId, roleName) {
907
893
  const item = store.listWorkItems(taskId).find((candidate) => (candidate.assignee === roleName
908
894
  && !["completed", "failed", "retired"].includes(candidate.status))) ?? null;