@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
@@ -1,5 +1,6 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { basename, dirname, join } from "node:path";
2
+ import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
3
+ import Database from "better-sqlite3";
3
4
  import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation } from "../../runtime/runtimeObservation.js";
4
5
  import { RUNTIME_PROCESS_EXIT_TASK_EVENT, validateRuntimeProcessExitObservation } from "../../runtime/processExitObservation.js";
5
6
  import { validateAgentProfile } from "../../profile/agentProfile.js";
@@ -8,56 +9,168 @@ import { validateReviewRound } from "../../review/reviewRound.js";
8
9
  import { validateTurn } from "../../turn/turn.js";
9
10
  import { validateWorkItem } from "../../workItem/workItem.js";
10
11
  import { SqliteTaskStore } from "../sqliteStore.js";
11
- import { inspectStorageSchema } from "../storageSchema.js";
12
- const CURRENT_DATABASE_FILENAME = "yui.db";
12
+ import { migrateSqliteSchema, storageMigrationPlan } from "../sqliteSchema.js";
13
+ import { CURRENT_DATABASE_FILENAME, inspectStorageSchema } from "../storageSchema.js";
14
+ import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "../storageVersions.js";
13
15
  /**
14
- * Validate the one supported storage contract.
16
+ * Upgrade one valid Home through the complete linear migration chain.
15
17
  *
16
- * Aggregate 31 deliberately has no historical migration path. `upgrade`
17
- * remains as a read-only compatibility/preflight surface for current Homes;
18
- * every non-current contract is rejected without touching the Home.
18
+ * Supported earlier versions are readable only here. Ordinary stores still
19
+ * admit exactly {@link CURRENT_STORAGE_VERSION}; no old-shape normalizer or
20
+ * dual read path enters runtime code. Execute mode is an offline primitive:
21
+ * its caller must own the maintenance fence and keep the Controller quiesced
22
+ * for the whole backup, migration, and validation interval.
19
23
  */
20
24
  export async function runStorageUpgrade(options) {
21
- const schema = inspectStorageSchema(options.home);
22
- if (schema.status === "uninitialized") {
23
- return blocked({ ...currentClassification(options.latest), uninitialized: true }, "uninitialized", "Yui storage is not initialized for this Home.", "Run `yui setup` with a new Home.");
25
+ const state = inspectStorageSchema(options.home);
26
+ if (state.status === "uninitialized") {
27
+ return blocked({ ...corruptedClassification("Storage is not initialized."), uninitialized: true }, "uninitialized", "Yui storage is not initialized for this Home.", "Run `yui setup` with a new Home.");
24
28
  }
25
- if (schema.status === "invalid") {
26
- return blocked(corruptedClassification(options.latest, schema.detail), "corruption", `Storage schema is invalid: ${schema.detail}`, "Preserve this Home for diagnosis and restore it from a known-good backup.");
29
+ if (state.status === "invalid") {
30
+ return blocked(corruptedClassification(state.detail), "corruption", `Storage is invalid: ${state.detail}`, "Preserve this Home for diagnosis and restore it from a known-good backup.");
27
31
  }
28
- if (!existsSync(join(options.home, CURRENT_DATABASE_FILENAME))) {
29
- return blocked(corruptedClassification(options.latest, "The SQLite database is missing."), "corruption", "The SQLite Home is incomplete: yui.db is missing.", "Preserve this Home for diagnosis and restore it from a known-good backup.");
32
+ if (state.status === "unsupported") {
33
+ const classification = unsupportedClassification(state);
34
+ return blocked(classification, "unsupported", classification.classification.verdict === "NEEDS_NEW_VERSION"
35
+ ? classification.classification.blocker.message
36
+ : "Storage is unsupported.", classification.classification.verdict === "NEEDS_NEW_VERSION"
37
+ ? classification.classification.blocker.action
38
+ : "Use a compatible Yui release.");
30
39
  }
31
- if (schema.status === "unsupported") {
32
- const classification = unsupportedClassification(schema, options.latest);
33
- return blocked(classification, "unsupported", "This Home does not exactly match the current storage contract; this release provides no migration path.", schema.direction === "newer"
34
- ? "Use a newer Yui release that supports this exact Home."
35
- : "Open it with its matching Yui version, or initialize a new Home.");
40
+ if (state.status === "current") {
41
+ try {
42
+ validateCurrentStore(options.home);
43
+ }
44
+ catch (error) {
45
+ return blocked(corruptedClassification(messageOf(error)), "corruption", `Current storage validation failed: ${messageOf(error)}`, "Preserve this Home for diagnosis and restore it from a known-good backup.");
46
+ }
47
+ const classification = currentClassification();
48
+ if (options.mode === "update-preflight") {
49
+ return {
50
+ outcome: "update-preflight",
51
+ status: "already-current",
52
+ stepCount: 0,
53
+ steps: [],
54
+ classification
55
+ };
56
+ }
57
+ return {
58
+ outcome: "already-current",
59
+ classification,
60
+ report: {
61
+ outcome: "already-current",
62
+ mode: options.mode,
63
+ sourceVersion: CURRENT_STORAGE_VERSION,
64
+ targetVersion: CURRENT_STORAGE_VERSION,
65
+ steps: []
66
+ }
67
+ };
68
+ }
69
+ const plan = storageMigrationPlan(state.currentVersion);
70
+ if (plan === null) {
71
+ return blocked(corruptedClassification(`No complete migration path exists from ${state.currentVersion} `
72
+ + `to ${CURRENT_STORAGE_VERSION}.`), "corruption", "The storage migration registry is incomplete.", "Install a Yui release that carries the complete migration chain.");
73
+ }
74
+ const classification = migratableClassification(state.currentVersion);
75
+ if (options.mode === "update-preflight") {
76
+ return {
77
+ outcome: "update-preflight",
78
+ status: "migration-ready",
79
+ stepCount: plan.length,
80
+ steps: plan,
81
+ classification
82
+ };
36
83
  }
84
+ if (options.mode === "dry-run") {
85
+ return {
86
+ outcome: "upgrade-plan",
87
+ classification,
88
+ report: {
89
+ outcome: "upgrade-plan",
90
+ mode: "dry-run",
91
+ sourceVersion: state.currentVersion,
92
+ targetVersion: CURRENT_STORAGE_VERSION,
93
+ steps: plan
94
+ }
95
+ };
96
+ }
97
+ let backupPath;
37
98
  try {
99
+ backupPath = await createDatabaseBackup(options.home, state.currentVersion, options.now ?? new Date());
100
+ }
101
+ catch (error) {
102
+ return {
103
+ outcome: "failed",
104
+ stage: "backup",
105
+ message: `Storage backup failed: ${messageOf(error)}`,
106
+ action: "Storage was not modified. Resolve the backup path, permissions, or free-space problem "
107
+ + "and rerun `yui upgrade`.",
108
+ classification,
109
+ sceneUnchanged: true
110
+ };
111
+ }
112
+ let migrationCommitted = false;
113
+ try {
114
+ const database = new Database(join(options.home, CURRENT_DATABASE_FILENAME));
115
+ try {
116
+ database.pragma("journal_mode = WAL");
117
+ database.pragma("synchronous = FULL");
118
+ database.pragma("foreign_keys = ON");
119
+ database.pragma("busy_timeout = 5000");
120
+ migrateSqliteSchema(database, { mode: "apply" });
121
+ migrationCommitted = true;
122
+ }
123
+ finally {
124
+ database.close();
125
+ }
38
126
  validateCurrentStore(options.home);
127
+ rmSync(join(options.home, "schema.json"), { force: true });
39
128
  }
40
129
  catch (error) {
41
- return blocked(corruptedClassification(options.latest, messageOf(error)), "corruption", `Current storage validation failed: ${messageOf(error)}`, "Preserve this Home for diagnosis and restore it from a known-good backup.");
130
+ const restoration = migrationCommitted
131
+ ? tryRestoreDatabaseBackup(options.home, backupPath)
132
+ : { restored: true };
133
+ return {
134
+ outcome: "failed",
135
+ stage: "migration",
136
+ message: `Storage migration failed: ${messageOf(error)}`,
137
+ action: restoration.restored
138
+ ? "The original database was restored from the timestamped backup. "
139
+ + "Resolve the reported problem and rerun `yui upgrade`."
140
+ : "Automatic restore also failed. Keep the Home quiesced and restore "
141
+ + `${backupPath} manually before retrying. Restore error: ${restoration.error}`,
142
+ backupPath,
143
+ classification,
144
+ sceneUnchanged: restoration.restored
145
+ };
42
146
  }
43
- const classification = currentClassification(options.latest);
44
- if (options.mode === "update-preflight") {
147
+ const finalState = inspectStorageSchema(options.home);
148
+ if (finalState.status !== "current") {
149
+ const restoration = tryRestoreDatabaseBackup(options.home, backupPath);
45
150
  return {
46
- outcome: "update-preflight",
47
- status: "already-current",
48
- stepCount: 0,
49
- steps: [],
50
- classification
151
+ outcome: "failed",
152
+ stage: "migration",
153
+ message: `Storage migration did not reach version ${CURRENT_STORAGE_VERSION}.`,
154
+ action: restoration.restored
155
+ ? "The original database was restored from the timestamped backup. "
156
+ + "Inspect the migration registry before retrying."
157
+ : "Automatic restore also failed. Keep the Home quiesced and restore "
158
+ + `${backupPath} manually before retrying. Restore error: ${restoration.error}`,
159
+ backupPath,
160
+ classification,
161
+ sceneUnchanged: restoration.restored
51
162
  };
52
163
  }
53
164
  return {
54
- outcome: "already-current",
55
- classification,
165
+ outcome: "upgraded",
166
+ classification: currentClassification(),
56
167
  report: {
57
- outcome: "already-current",
58
- mode: options.mode,
59
- source: options.latest,
60
- target: options.latest
168
+ outcome: "upgraded",
169
+ mode: "execute",
170
+ sourceVersion: state.currentVersion,
171
+ targetVersion: CURRENT_STORAGE_VERSION,
172
+ steps: plan,
173
+ backupPath
61
174
  }
62
175
  };
63
176
  }
@@ -96,59 +209,108 @@ function validateCurrentStore(home) {
96
209
  }
97
210
  }
98
211
  }
212
+ const quickCheck = store.databaseHandle().pragma("quick_check", { simple: true });
213
+ if (quickCheck !== "ok") {
214
+ throw new Error(`SQLite quick_check failed: ${String(quickCheck)}.`);
215
+ }
99
216
  }
100
217
  finally {
101
218
  store.close();
102
219
  }
103
220
  }
104
- function currentClassification(latest) {
221
+ async function createDatabaseBackup(home, sourceVersion, now) {
222
+ const source = join(home, CURRENT_DATABASE_FILENAME);
223
+ if (!existsSync(source))
224
+ throw new Error("The authoritative yui.db is missing.");
225
+ const backupDirectory = join(dirname(home), `${basename(home)}-backups`);
226
+ mkdirSync(backupDirectory, { recursive: true, mode: 0o700 });
227
+ const timestamp = now.toISOString().replaceAll(":", "-");
228
+ const backupPath = join(backupDirectory, `pre-storage-v${sourceVersion}-${timestamp}.db`);
229
+ const database = new Database(source, { readonly: true, fileMustExist: true });
230
+ try {
231
+ await database.backup(backupPath);
232
+ }
233
+ finally {
234
+ database.close();
235
+ }
236
+ return backupPath;
237
+ }
238
+ function restoreDatabaseBackup(home, backupPath) {
239
+ const databasePath = join(home, CURRENT_DATABASE_FILENAME);
240
+ rmSync(`${databasePath}-wal`, { force: true });
241
+ rmSync(`${databasePath}-shm`, { force: true });
242
+ copyFileSync(backupPath, databasePath);
243
+ }
244
+ function tryRestoreDatabaseBackup(home, backupPath) {
245
+ try {
246
+ restoreDatabaseBackup(home, backupPath);
247
+ return { restored: true };
248
+ }
249
+ catch (error) {
250
+ return { restored: false, error: messageOf(error) };
251
+ }
252
+ }
253
+ function currentClassification() {
105
254
  return {
106
255
  classification: { verdict: "USABLE", status: "current" },
107
- layoutVersion: latest.layout,
108
- aggregateVersion: latest.aggregate,
109
- latestLayoutVersion: latest.layout,
110
- latestAggregateVersion: latest.aggregate
256
+ storageVersion: CURRENT_STORAGE_VERSION,
257
+ currentStorageVersion: CURRENT_STORAGE_VERSION,
258
+ minimumSupportedStorageVersion: MIN_SUPPORTED_STORAGE_VERSION
111
259
  };
112
260
  }
113
- function unsupportedClassification(schema, latest) {
114
- const axis = schema.incompatibleComponent;
115
- const found = schema.currentVersion;
116
- const supported = schema.latestVersion;
117
- const future = schema.direction === "newer";
261
+ function migratableClassification(storageVersion) {
262
+ return {
263
+ classification: { verdict: "MIGRATABLE", status: "migration-ready" },
264
+ storageVersion,
265
+ currentStorageVersion: CURRENT_STORAGE_VERSION,
266
+ minimumSupportedStorageVersion: MIN_SUPPORTED_STORAGE_VERSION
267
+ };
268
+ }
269
+ function unsupportedClassification(state) {
270
+ const future = state.direction === "newer";
271
+ const message = future
272
+ ? `Storage version ${state.currentVersion} is newer than this CLI supports `
273
+ + `(${CURRENT_STORAGE_VERSION}).`
274
+ : `Storage version ${state.currentVersion} is older than the minimum supported `
275
+ + `migration version ${MIN_SUPPORTED_STORAGE_VERSION}.`;
118
276
  const action = future
119
- ? "Use a newer Yui release that supports this exact Home."
120
- : "Open it with its matching Yui version, or initialize a new Home.";
277
+ ? "Use a newer Yui release."
278
+ : "Preserve this Home for use with its matching historical Yui release, "
279
+ + "or initialize a new Home with Yui 0.15.0 or later.";
121
280
  return {
122
281
  classification: {
123
282
  verdict: "NEEDS_NEW_VERSION",
124
283
  status: "unsupported",
125
284
  blocker: {
126
- reason: future ? "future-version" : "missing-step",
127
- axis,
128
- ...(schema.recordFamily === undefined ? {} : { recordKind: schema.recordFamily }),
129
- ...(future ? { found, supported } : { from: found, to: supported }),
130
- message: future
131
- ? "The Home is newer than this Yui release."
132
- : "This release provides no migration path for the older Home.",
285
+ reason: future ? "future-version" : "below-minimum",
286
+ found: state.currentVersion,
287
+ current: CURRENT_STORAGE_VERSION,
288
+ minimum: MIN_SUPPORTED_STORAGE_VERSION,
289
+ message,
133
290
  action
134
291
  }
135
292
  },
136
- layoutVersion: schema.currentLayoutVersion,
137
- aggregateVersion: schema.currentAggregateSchemaVersion,
138
- latestLayoutVersion: latest.layout,
139
- latestAggregateVersion: latest.aggregate,
140
- incompatibleComponent: axis
293
+ storageVersion: state.currentVersion,
294
+ currentStorageVersion: CURRENT_STORAGE_VERSION,
295
+ minimumSupportedStorageVersion: MIN_SUPPORTED_STORAGE_VERSION
141
296
  };
142
297
  }
143
- function corruptedClassification(latest, detail) {
298
+ function corruptedClassification(detail) {
144
299
  return {
145
300
  classification: { verdict: "CORRUPTED", status: "unsupported", detail },
146
- latestLayoutVersion: latest.layout,
147
- latestAggregateVersion: latest.aggregate
301
+ currentStorageVersion: CURRENT_STORAGE_VERSION,
302
+ minimumSupportedStorageVersion: MIN_SUPPORTED_STORAGE_VERSION
148
303
  };
149
304
  }
150
305
  function blocked(classification, stage, message, action) {
151
- return { outcome: "blocked", stage, message, action, classification, sceneUnchanged: true };
306
+ return {
307
+ outcome: "blocked",
308
+ stage,
309
+ message,
310
+ action,
311
+ classification,
312
+ sceneUnchanged: true
313
+ };
152
314
  }
153
315
  function messageOf(error) {
154
316
  return error instanceof Error ? error.message : String(error);
@@ -79,10 +79,23 @@ export class TmuxManager {
79
79
  }
80
80
  ensureRoleWindow(taskId, role, launch) {
81
81
  if (this.windowNames(taskId).includes(role.name)) {
82
+ const pane = this.inspectPane(taskId, role.name);
83
+ if (pane.dead && launch === undefined) {
84
+ throw runtimeError(`Role ${role.name} has an exited-retained pane; a launch plan is required to restart it.`);
85
+ }
82
86
  this.recordRoleTarget(taskId, role.name);
83
87
  this.configureServerHistory();
88
+ if (pane.dead) {
89
+ // Without -k tmux refuses to replace a live process, even if the pane
90
+ // changed after inspection. Reads alone never discard the exit scene.
91
+ this.run([
92
+ "respawn-window", "-t", this.exactTarget(taskId, role.name),
93
+ "-c", safeValue(role.cwd ?? role.workspace, "Role cwd"),
94
+ "--", ...launchCommand(launch)
95
+ ]);
96
+ }
84
97
  this.configureRoleWindowSizing(taskId, role.name);
85
- return false;
98
+ return pane.dead;
86
99
  }
87
100
  if (launch === undefined) {
88
101
  throw runtimeError(`Agent launch plan is required to create Role window: ${role.name}.`);
@@ -127,10 +140,21 @@ export class TmuxManager {
127
140
  async ensureRoleWindowAsync(taskId, role, launch) {
128
141
  const snapshot = await this.sessionWindowNamesAsync(taskId);
129
142
  if (snapshot.names.includes(role.name)) {
143
+ const pane = await this.inspectPaneAsync(taskId, role.name);
144
+ if (pane.dead && launch === undefined) {
145
+ throw runtimeError(`Role ${role.name} has an exited-retained pane; a launch plan is required to restart it.`);
146
+ }
130
147
  this.recordRoleTarget(taskId, role.name);
131
148
  await this.configureServerHistoryAsync();
149
+ if (pane.dead) {
150
+ await this.runAsync([
151
+ "respawn-window", "-t", this.exactTarget(taskId, role.name),
152
+ "-c", safeValue(role.cwd ?? role.workspace, "Role cwd"),
153
+ "--", ...launchCommand(launch)
154
+ ]);
155
+ }
132
156
  await this.configureRoleWindowSizingAsync(taskId, role.name);
133
- return false;
157
+ return pane.dead;
134
158
  }
135
159
  if (launch === undefined) {
136
160
  throw runtimeError(`Agent launch plan is required to create Role window: ${role.name}.`);
@@ -364,7 +388,7 @@ export class TmuxManager {
364
388
  inspectPane(taskId, roleName) {
365
389
  const target = this.target(taskId, roleName);
366
390
  const output = this.run([
367
- "display-message", "-p", "-t", target,
391
+ "display-message", "-p", "-t", this.exactTarget(taskId, roleName),
368
392
  [
369
393
  "#{pane_dead}",
370
394
  "#{pane_pid}",
@@ -379,6 +403,9 @@ export class TmuxManager {
379
403
  throw runtimeError(`Tmux returned an invalid pane state for ${roleName}.`);
380
404
  }
381
405
  const dead = output.slice(0, separator);
406
+ if (dead !== "0" && dead !== "1") {
407
+ throw runtimeError(`Tmux returned an invalid pane state for ${roleName}.`);
408
+ }
382
409
  const pidText = output.slice(separator + 1, secondSeparator);
383
410
  const currentCommand = output.slice(secondSeparator + 1, lastSeparator);
384
411
  const exitStatusText = output.slice(lastSeparator + 1);
@@ -405,7 +432,7 @@ export class TmuxManager {
405
432
  : deliveryReceiptOption(receiptId);
406
433
  const receiptFormat = receiptOption === undefined ? "" : `#{${receiptOption}}`;
407
434
  const output = await this.runAsync([
408
- "display-message", "-p", "-t", target,
435
+ "display-message", "-p", "-t", this.exactTarget(taskId, roleName),
409
436
  [
410
437
  PANE_STATE_MARKER,
411
438
  "#{pane_dead}",
@@ -798,22 +825,16 @@ export class TmuxManager {
798
825
  }
799
826
  this.enterRole(taskId, role, launch);
800
827
  }
801
- detectRoleStatus(taskId, roleName, fallback = "exited") {
802
- try {
803
- return this.probeRoleStatus(taskId, roleName);
804
- }
805
- catch {
806
- return fallback;
807
- }
808
- }
809
828
  probeRoleStatus(taskId, roleName) {
810
- if (!this.hasSession(taskId))
829
+ if (!this.windowNames(taskId).includes(roleName))
811
830
  return "exited";
812
- return this.windowNames(taskId).includes(roleName) ? "running" : "exited";
831
+ return this.inspectPane(taskId, roleName).dead ? "exited" : "running";
813
832
  }
814
833
  async probeRoleStatusAsync(taskId, roleName) {
815
834
  const snapshot = await this.sessionWindowNamesAsync(taskId);
816
- return snapshot.names.includes(roleName) ? "running" : "exited";
835
+ if (!snapshot.names.includes(roleName))
836
+ return "exited";
837
+ return (await this.inspectPaneAsync(taskId, roleName)).dead ? "exited" : "running";
817
838
  }
818
839
  /** Exact pane process state for one Role, used by owner-identity recording. */
819
840
  inspectRolePane(taskId, roleName) {
@@ -901,10 +922,6 @@ export class TmuxManager {
901
922
  catch (error) {
902
923
  if (isExplicitlyAbsentTmuxSession(error))
903
924
  return false;
904
- // Older/fake executors do not expose stderr. A failed has-session is the
905
- // tmux absence contract; other operations remain strict.
906
- if (!(error instanceof CommandExecutionError))
907
- return false;
908
925
  throw error;
909
926
  }
910
927
  }
@@ -916,8 +933,6 @@ export class TmuxManager {
916
933
  catch (error) {
917
934
  if (isExplicitlyAbsentTmuxSession(error))
918
935
  return false;
919
- if (!(error instanceof CommandExecutionError))
920
- return false;
921
936
  throw error;
922
937
  }
923
938
  }
@@ -930,7 +945,7 @@ export class TmuxManager {
930
945
  ]).split("\n").map((name) => name.trim()).filter(Boolean);
931
946
  }
932
947
  catch (error) {
933
- if (isUnavailableTmuxStatus(error))
948
+ if (isExplicitlyAbsentTmuxSession(error))
934
949
  return [];
935
950
  throw error;
936
951
  }
@@ -943,7 +958,7 @@ export class TmuxManager {
943
958
  return { exists: true, names };
944
959
  }
945
960
  catch (error) {
946
- if (isUnavailableTmuxStatus(error))
961
+ if (isExplicitlyAbsentTmuxSession(error))
947
962
  return { exists: false, names: [] };
948
963
  throw error;
949
964
  }
@@ -990,6 +1005,11 @@ export class TmuxManager {
990
1005
  target(taskId, roleName) {
991
1006
  return yuiTmuxTarget(this.#yuiHome, taskId, roleName);
992
1007
  }
1008
+ exactTarget(taskId, roleName) {
1009
+ // tmux otherwise accepts prefix/glob matches after the selected Role
1010
+ // disappears. Keep identity inspection and dead-pane replacement exact.
1011
+ return `=${this.sessionName(taskId)}:=${safeValue(roleName, "Role name")}`;
1012
+ }
993
1013
  recordRoleTarget(taskId, roleName) {
994
1014
  this.#onRoleTargetRecorded?.(this.target(taskId, roleName));
995
1015
  }
@@ -1225,11 +1245,6 @@ function isExplicitlyAbsentTmuxSession(error) {
1225
1245
  return /can't find (?:session|window|pane)|no server running|no current target|session not found|error connecting to .+ \(No such file or directory\)/i
1226
1246
  .test(error.stderr);
1227
1247
  }
1228
- function isUnavailableTmuxStatus(error) {
1229
- return isExplicitlyAbsentTmuxSession(error)
1230
- || (error instanceof CommandExecutionError
1231
- && /server exited unexpectedly/i.test(error.stderr));
1232
- }
1233
1248
  function tmuxWord(value) {
1234
1249
  return JSON.stringify(value);
1235
1250
  }
package/dist/version.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "./core/protocol.js";
3
- import { CURRENT_AGGREGATE_SCHEMA_VERSION, CURRENT_STORAGE_LAYOUT_VERSION } from "./storage/storageSchema.js";
3
+ import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "./storage/storageVersions.js";
4
4
  export const YUI_VERSION = readPackageVersion();
5
5
  export function yuiVersionIdentity() {
6
6
  return {
7
7
  version: YUI_VERSION,
8
8
  controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
9
- storageLayoutVersion: CURRENT_STORAGE_LAYOUT_VERSION,
10
- aggregateSchemaVersion: CURRENT_AGGREGATE_SCHEMA_VERSION
9
+ storageVersion: CURRENT_STORAGE_VERSION,
10
+ minimumStorageVersion: MIN_SUPPORTED_STORAGE_VERSION
11
11
  };
12
12
  }
13
13
  function readPackageVersion() {
@@ -51,12 +51,12 @@ There is no compatibility lookup, cross-Task guess, or bare-ID fallback.
51
51
 
52
52
  ## Current-schema boundary
53
53
 
54
- Runtime opens only aggregate v31 / Task v7. It does not convert,
55
- dual-read, or infer records from an older schema. If an existing `YUI_HOME`
56
- does not match the current schema, keep it untouched for external archival
57
- and initialize a fresh home for this runtime.
58
-
59
- This hard cut keeps Task-local references, Role desired configuration, and
60
- immutable Turn/RoleSession effective snapshots under one unambiguous
61
- contract. There is no compatibility lookup, conversion command, or
62
- intermediate storage format.
54
+ Runtime opens only the current Home storage version and current record shapes.
55
+ It does not dual-read or infer historical records during ordinary work.
56
+ Historical decoding and rewriting are confined to the explicit `yui upgrade`
57
+ boundary and the migration phase of `yui update`; every valid Home at or above
58
+ the CLI's minimum supported storage version can advance directly to current.
59
+
60
+ This boundary keeps Task-local references, Role desired configuration, and
61
+ immutable Turn/RoleSession effective snapshots under one unambiguous runtime
62
+ contract while the append-only migration chain preserves supported history.