@zq-silk/yui 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +8 -9
  2. package/dist/cli/commandCatalog.js +11 -6
  3. package/dist/cli/interactionPolicy.js +5 -6
  4. package/dist/cli/updateCommand.js +3 -1
  5. package/dist/cli/updateOrchestrator.js +173 -28
  6. package/dist/cli/updatePorts.js +137 -8
  7. package/dist/cli/upgradeCommand.js +19 -9
  8. package/dist/cli.js +51 -14
  9. package/dist/commands/configCommands.js +1 -1
  10. package/dist/commands/executionAuditCommands.js +2 -1
  11. package/dist/commands/taskCommands.js +80 -29
  12. package/dist/commands/taskContextCommand.js +6 -2
  13. package/dist/commands/taskRoleRuntimeStatus.js +31 -7
  14. package/dist/config/configCatalog.js +1 -1
  15. package/dist/controller/clientRuntime.js +38 -2
  16. package/dist/controller/controller.js +23 -15
  17. package/dist/controller/fileSchedulerStoreAdapter.js +248 -138
  18. package/dist/controller/runtime.js +56 -1
  19. package/dist/controller/runtimeHookRunFence.js +19 -4
  20. package/dist/controller/structuredProviderObservation.js +20 -3
  21. package/dist/core/controllerClient.js +20 -2
  22. package/dist/core/controllerServer.js +1 -0
  23. package/dist/executor/agentExecutor.js +48 -46
  24. package/dist/executor/fileRoleLaunchPlanner.js +94 -30
  25. package/dist/lifecycle/exactRunTerminalization.js +68 -3
  26. package/dist/observability/executionAudit.js +5 -0
  27. package/dist/release/runtimeRelease.js +20 -0
  28. package/dist/run/recoveryProjection.js +45 -6
  29. package/dist/runtime/agentHost.js +159 -85
  30. package/dist/runtime/conversationSwitch.js +277 -0
  31. package/dist/runtime/index.js +1 -1
  32. package/dist/runtime/launchBroker.js +12 -0
  33. package/dist/runtime/processExitOutbox.js +88 -0
  34. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  35. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  36. package/dist/runtime/runtimeObservation.js +15 -0
  37. package/dist/runtime/runtimeProjection.js +6 -7
  38. package/dist/runtime/tmuxAdapters.js +4 -1
  39. package/dist/scheduler/activeRoleRunDelivery.js +31 -196
  40. package/dist/scheduler/leaderWakeupProcessor.js +39 -133
  41. package/dist/scheduler/roleRunStall.js +53 -17
  42. package/dist/storage/sqliteSchema.js +57 -24
  43. package/dist/storage/sqliteStore.js +23 -4
  44. package/dist/storage/upgrade/homeClassification.js +52 -0
  45. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  46. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  47. package/dist/task/nextAction.js +0 -34
  48. package/dist/web/webSnapshot.js +3 -1
  49. package/package.json +1 -1
  50. package/skills/yui-leader/SKILL.md +7 -4
  51. package/skills/yui-operator/SKILL.md +4 -4
  52. package/skills/yui-reviewer/SKILL.md +7 -4
  53. package/dist/lifecycle/taskRoleSessionReset.js +0 -118
@@ -34,10 +34,13 @@ import { accessSync, constants, existsSync, mkdtempSync, readFileSync, realpathS
34
34
  import { tmpdir } from "node:os";
35
35
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
36
36
  import { fileURLToPath } from "node:url";
37
+ import Database from "better-sqlite3";
37
38
  import { runtimeError } from "../errors/cliError.js";
38
39
  import { isConcreteVersion } from "../domain/validation.js";
39
40
  import { STORAGE_DOCTOR_CHECK_NAMES } from "../doctor/doctor.js";
41
+ import { acquireHandoverLock } from "../release/runtimeRelease.js";
40
42
  import { inspectStorageSchema } from "../storage/storageSchema.js";
43
+ import { placeUpgradeFence } from "../storage/upgradeFence.js";
41
44
  import { correlateUpgradeReceipt } from "../storage/upgrade/upgradeOrchestrator.js";
42
45
  import { readSwitchProgress } from "../storage/upgrade/switchProgress.js";
43
46
  const PACKAGE_NAME = "@zq-silk/yui";
@@ -91,8 +94,29 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
91
94
  // deliberately not persisted as a retry or recovery protocol.
92
95
  let verifiedActivatedBinary;
93
96
  let verifiedActivatedVersion;
97
+ let storageFenceOwnerPid;
94
98
  const stopReplacementController = (home, pid) => (stopReplacementControllerForUpdate(home, pid, environment, run));
95
99
  return {
100
+ beginControllerHandover(home) {
101
+ return acquireHandoverLock(home).release;
102
+ },
103
+ beginStorageWriteFence(home) {
104
+ const release = placeUpgradeFence(home, {
105
+ reason: "update storage activation in progress",
106
+ createdAt: new Date().toISOString(),
107
+ ownerPid: process.pid
108
+ });
109
+ storageFenceOwnerPid = process.pid;
110
+ let released = false;
111
+ return () => {
112
+ if (released)
113
+ return;
114
+ release();
115
+ released = true;
116
+ if (storageFenceOwnerPid === process.pid)
117
+ storageFenceOwnerPid = undefined;
118
+ };
119
+ },
96
120
  stage(version) {
97
121
  // A caller that names a version (the release workflow, which freezes the
98
122
  // exact version in its plan) installs THAT version — never a moving
@@ -151,7 +175,10 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
151
175
  env: {
152
176
  ...environment,
153
177
  YUI_HOME: home,
154
- YUI_UPDATE_EXTERNALLY_QUIESCED: "1"
178
+ YUI_UPDATE_EXTERNALLY_QUIESCED: "1",
179
+ ...(storageFenceOwnerPid === undefined
180
+ ? {}
181
+ : { YUI_UPDATE_HANDOVER_OWNER_PID: String(storageFenceOwnerPid) })
155
182
  },
156
183
  shell: false
157
184
  });
@@ -219,6 +246,7 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
219
246
  // on-disk schema and report `switched: false` so the caller re-probes the
220
247
  // real state instead of giving a recovery instruction from a stale receipt.
221
248
  const schema = inspectStorageSchema(home);
249
+ const sqliteSchemaHead = inspectSqliteSchemaHead(home);
222
250
  // A crash mid-switch leaves a durable progress marker. A marker of ANY phase
223
251
  // — `backing-up`, `promoting`, or `interrupted` — is only actionable as an
224
252
  // interrupted switch when the FILESYSTEM still corroborates it: the backup
@@ -243,6 +271,7 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
243
271
  switched: false,
244
272
  interrupted: true,
245
273
  schemaCurrent: false,
274
+ ...(sqliteSchemaHead === undefined ? {} : { sqliteSchemaHead }),
246
275
  ...(progress.backupPath === undefined ? {} : { backupPath: progress.backupPath })
247
276
  };
248
277
  }
@@ -252,12 +281,17 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
252
281
  }
253
282
  const correlation = correlateUpgradeReceipt(home);
254
283
  if (!correlation.corresponds) {
255
- return { switched: false, schemaCurrent: schema.status === "current" };
284
+ return {
285
+ switched: false,
286
+ schemaCurrent: schema.status === "current",
287
+ ...(sqliteSchemaHead === undefined ? {} : { sqliteSchemaHead })
288
+ };
256
289
  }
257
290
  const receipt = correlation.receipt;
258
291
  return {
259
292
  switched: true,
260
293
  schemaCurrent: schema.status === "current",
294
+ ...(sqliteSchemaHead === undefined ? {} : { sqliteSchemaHead }),
261
295
  ...(receipt.backupPath === undefined ? {} : { backupPath: receipt.backupPath })
262
296
  };
263
297
  },
@@ -291,6 +325,39 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
291
325
  }
292
326
  };
293
327
  }
328
+ /**
329
+ * Read the generic durable SQLite ledger without asking the old parent binary
330
+ * to understand the staged release's migration registry. The staged preflight
331
+ * supplies the validated source and target heads used to interpret this value.
332
+ */
333
+ function inspectSqliteSchemaHead(home) {
334
+ const path = join(home, "yui.db");
335
+ if (!existsSync(path))
336
+ return undefined;
337
+ const db = new Database(path, { readonly: true, fileMustExist: true });
338
+ try {
339
+ db.pragma("query_only = ON");
340
+ const rows = db.prepare("SELECT version, checksum FROM schema_migrations ORDER BY version").all();
341
+ if (rows.length === 0)
342
+ throw new Error("SQLite migration ledger is empty.");
343
+ for (let index = 0; index < rows.length; index += 1) {
344
+ const row = rows[index];
345
+ if (row.version !== index + 1
346
+ || typeof row.checksum !== "string"
347
+ || row.checksum.length === 0) {
348
+ throw new Error("SQLite migration ledger is not a contiguous checksummed prefix.");
349
+ }
350
+ }
351
+ const head = rows.at(-1);
352
+ return {
353
+ version: head.version,
354
+ checksum: head.checksum
355
+ };
356
+ }
357
+ finally {
358
+ db.close();
359
+ }
360
+ }
294
361
  const UPDATE_CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
295
362
  const UPDATE_CLIENT_RUNTIME_PATH = fileURLToPath(new URL("../controller/clientRuntime.js", import.meta.url));
296
363
  const UPDATE_CONTROLLER_RECONCILIATION_PATH = fileURLToPath(new URL("../controller/updateReconciliation.js", import.meta.url));
@@ -581,7 +648,17 @@ function runControllerCommand(home, environment, spawn, method, cliBinary) {
581
648
  const args = cliBinary === undefined
582
649
  ? [UPDATE_CLI_PATH, "--json", "controller", method]
583
650
  : ["--json", "controller", method];
584
- const result = spawn(command, args, { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
651
+ const result = spawn(command, args, {
652
+ cwd: process.cwd(),
653
+ env: {
654
+ ...environment,
655
+ YUI_HOME: home,
656
+ // This exact lifecycle child is part of the update process that owns
657
+ // the handover lock. Managed Sessions never receive this bypass.
658
+ YUI_UPDATE_HANDOVER_OWNER_PID: String(process.pid)
659
+ },
660
+ shell: false
661
+ });
585
662
  if (result.error !== undefined || result.status !== 0) {
586
663
  const error = new Error(`Controller ${method} failed (exit ${result.status ?? "null"}).`);
587
664
  const code = controllerErrorCodeFromResult(result);
@@ -828,11 +905,14 @@ function interpretPreflight(result) {
828
905
  ...(data.sceneUnchanged === true ? { sceneUnchanged: true } : {})
829
906
  };
830
907
  }
831
- /** Strictly parse the three green states of the internal update preflight. */
908
+ /** Strictly parse the green states of the internal update preflight. */
832
909
  function parseUpdatePreflightResult(data) {
833
910
  const status = data.status;
834
911
  const stepCount = data.stepCount;
835
- if ((status !== "already-current" && status !== "compatible" && status !== "migration-required")
912
+ if ((status !== "already-current"
913
+ && status !== "compatible"
914
+ && status !== "in-place-migration"
915
+ && status !== "migration-required")
836
916
  || !Number.isSafeInteger(stepCount)
837
917
  || stepCount < 0) {
838
918
  return null;
@@ -857,11 +937,57 @@ function parseUpdatePreflightResult(data) {
857
937
  return { status };
858
938
  const evidence = status === "compatible"
859
939
  ? `${stepCount} compatible step(s) classified and the compatible source validated in memory`
860
- : `${stepCount} offline migration step(s) classified and the offline runtime inventory confirmed clear`;
940
+ : status === "in-place-migration"
941
+ ? `${stepCount} SQLite migration step(s) classified for one in-place transaction and the offline runtime inventory confirmed clear`
942
+ : `${stepCount} offline migration step(s) classified and the offline runtime inventory confirmed clear`;
943
+ const summary = `${evidence}. `
944
+ + "No staged Home or staged-output loader validation was performed during update preflight.";
945
+ if (status === "in-place-migration") {
946
+ const sqliteMigration = parseSqliteMigrationBoundary(data.sqliteMigration, stepCount);
947
+ if (sqliteMigration === null)
948
+ return null;
949
+ return {
950
+ status,
951
+ summary,
952
+ sqliteMigration
953
+ };
954
+ }
861
955
  return {
862
956
  status,
863
- summary: `${evidence}. `
864
- + "No staged Home or staged-output loader validation was performed during update preflight."
957
+ summary
958
+ };
959
+ }
960
+ function parseSqliteMigrationBoundary(value, stepCount) {
961
+ if (!isRecord(value))
962
+ return null;
963
+ const currentVersion = value.currentVersion;
964
+ const currentChecksum = value.currentChecksum;
965
+ const targetVersion = value.targetVersion;
966
+ const targetChecksum = value.targetChecksum;
967
+ const pendingVersions = value.pendingVersions;
968
+ if (!Number.isSafeInteger(currentVersion)
969
+ || currentVersion < 1
970
+ || typeof currentChecksum !== "string"
971
+ || currentChecksum.length === 0
972
+ || !Number.isSafeInteger(targetVersion)
973
+ || targetVersion <= currentVersion
974
+ || typeof targetChecksum !== "string"
975
+ || targetChecksum.length === 0
976
+ || !Array.isArray(pendingVersions)
977
+ || pendingVersions.length !== stepCount
978
+ || targetVersion !== currentVersion + stepCount
979
+ || pendingVersions.some((version, index) => !Number.isSafeInteger(version) || version !== currentVersion + index + 1)) {
980
+ return null;
981
+ }
982
+ return {
983
+ current: {
984
+ version: currentVersion,
985
+ checksum: currentChecksum
986
+ },
987
+ target: {
988
+ version: targetVersion,
989
+ checksum: targetChecksum
990
+ }
865
991
  };
866
992
  }
867
993
  function parseUpdateBlockers(value) {
@@ -933,6 +1059,9 @@ function interpretActivation(result) {
933
1059
  if (outcome === "already-current")
934
1060
  return { status: "already-current" };
935
1061
  if (outcome === "upgraded") {
1062
+ if (data.migrationMode === "in-place" && data.backupPath === undefined) {
1063
+ return { status: "migrated-in-place" };
1064
+ }
936
1065
  const backupPath = data.backupPath;
937
1066
  if (typeof backupPath !== "string"
938
1067
  || backupPath.length === 0
@@ -35,6 +35,9 @@ export async function runUpgradeCommand(args, home, options = {}) {
35
35
  ...(options.controllerLifecycle === undefined
36
36
  ? {}
37
37
  : { controllerLifecycle: options.controllerLifecycle }),
38
+ ...(options.externalUpgradeFenceOwnerPid === undefined
39
+ ? {}
40
+ : { externalUpgradeFenceOwnerPid: options.externalUpgradeFenceOwnerPid }),
38
41
  ...(options.now === undefined ? {} : { now: options.now })
39
42
  });
40
43
  return {
@@ -68,28 +71,35 @@ export function renderUpgradeResult(result, mode) {
68
71
  ? "four-state classification"
69
72
  : result.status === "compatible"
70
73
  ? "four-state classification plus compatible-source validation"
71
- : "four-state classification plus a clear offline runtime inventory";
74
+ : result.status === "in-place-migration"
75
+ ? "SQLite ledger classification plus a clear offline runtime inventory"
76
+ : "four-state classification plus a clear offline runtime inventory";
72
77
  return `${header}\nUpdate preflight: ${result.status} (${result.stepCount} step(s)); `
73
78
  + `${evidence}. No staged Home was created, `
74
79
  + "no staged-output loader validation was performed, and storage was not switched.";
75
80
  }
76
81
  case "dry-run": {
77
- const steps = result.report.outcome === "dry-run" ? result.report.steps.length : 0;
82
+ const steps = result.classification.sqliteMigration?.pendingVersions.length
83
+ ?? (result.report.outcome === "dry-run" ? result.report.steps.length : 0);
78
84
  return `${header}\nDry run: validated ${steps} migration step(s) through the loader gate. `
79
85
  + "Staged output discarded; storage was not switched.";
80
86
  }
81
87
  case "upgraded":
82
- return `${header}\nUpgraded storage. Original Home backed up at `
83
- + `${result.backupPath ?? "(unspecified)"}.`;
88
+ return result.migrationMode === "in-place"
89
+ ? `${header}\nUpgraded SQLite in place in one transaction; no database rebuild or backup copy was created.`
90
+ : `${header}\nUpgraded storage. Original Home backed up at `
91
+ + `${result.backupPath ?? "(unspecified)"}.`;
84
92
  case "blocked": {
85
93
  // Most blockers guarantee the source is untouched. A partial switch or a
86
94
  // post-switch ambiguity explicitly carries the committed boundary and
87
95
  // named recovery evidence; never print a false unchanged claim there.
88
- const unchangedNote = result.switchCommitted === true
89
- ? "The storage switch committed, but post-switch completion is ambiguous; the old Controller was not restored (see Action and recovery evidence)."
90
- : result.stage === "switch-ambiguous"
91
- ? "The switch did not complete and could not be rolled back; the authoritative Home is NOT intact (see Action)."
92
- : "Storage was not switched; the authoritative Home is unchanged.";
96
+ const unchangedNote = result.storageCommitted === true
97
+ ? "The SQLite transaction committed in place; the old Controller was not restored (see Action)."
98
+ : result.switchCommitted === true
99
+ ? "The storage switch committed, but post-switch completion is ambiguous; the old Controller was not restored (see Action and recovery evidence)."
100
+ : result.stage === "switch-ambiguous"
101
+ ? "The switch did not complete and could not be rolled back; the authoritative Home is NOT intact (see Action)."
102
+ : "Storage was not switched; the authoritative Home is unchanged.";
93
103
  return [
94
104
  header,
95
105
  `${mode === "dry-run" ? "Dry run" : mode === "update-preflight" ? "Update preflight" : "Upgrade"} blocked at ${result.stage}: ${result.message}`,
package/dist/cli.js CHANGED
@@ -301,19 +301,44 @@ export async function main() {
301
301
  if (args[0] === "upgrade") {
302
302
  // Mirror doctor/controller: needs a Home but self-manages the schema check,
303
303
  // because upgrade must run against a non-current Home.
304
- const result = await runUpgradeCommand(args.slice(1), home, process.env.YUI_UPDATE_EXTERNALLY_QUIESCED === "1"
305
- ? { controllerLifecycle: "externally-quiesced" }
306
- : {});
307
- // Public execute upgrades leave the Home operational even when no
308
- // Controller existed before the command. Dry-run and the staged updater's
309
- // externally-quiesced preflight must remain read-only/lifecycle-neutral.
310
- if (args.length === 1
311
- && result.exitCode === 0
312
- && process.env.YUI_UPDATE_EXTERNALLY_QUIESCED !== "1") {
313
- await ensureFileTaskController(home, { environment: process.env });
304
+ const externallyQuiesced = process.env.YUI_UPDATE_EXTERNALLY_QUIESCED === "1";
305
+ const ownsHandover = args.length === 1 && !externallyQuiesced;
306
+ const externalFenceOwner = externallyQuiesced
307
+ ? process.env.YUI_UPDATE_HANDOVER_OWNER_PID
308
+ : undefined;
309
+ const externalUpgradeFenceOwnerPid = externalFenceOwner === undefined
310
+ ? undefined
311
+ : Number(externalFenceOwner);
312
+ if (externalUpgradeFenceOwnerPid !== undefined
313
+ && (!Number.isSafeInteger(externalUpgradeFenceOwnerPid)
314
+ || externalUpgradeFenceOwnerPid < 1)) {
315
+ throw runtimeError("Update storage-fence owner PID is invalid.");
316
+ }
317
+ const handover = ownsHandover ? acquireHandoverLock(home) : undefined;
318
+ try {
319
+ const result = await runUpgradeCommand(args.slice(1), home, externallyQuiesced
320
+ ? {
321
+ controllerLifecycle: "externally-quiesced",
322
+ ...(externalUpgradeFenceOwnerPid === undefined
323
+ ? {}
324
+ : { externalUpgradeFenceOwnerPid })
325
+ }
326
+ : {});
327
+ // Public execute upgrades leave the Home operational even when no
328
+ // Controller existed before the command. Dry-run and the staged updater's
329
+ // externally-quiesced preflight must remain read-only/lifecycle-neutral.
330
+ if (ownsHandover && result.exitCode === 0) {
331
+ await ensureFileTaskController(home, {
332
+ environment: process.env,
333
+ handoverOwnerPid: process.pid
334
+ });
335
+ }
336
+ process.exitCode = result.exitCode;
337
+ emit(result.output, false, result.data);
338
+ }
339
+ finally {
340
+ handover?.release();
314
341
  }
315
- process.exitCode = result.exitCode;
316
- emit(result.output, false, result.data);
317
342
  return;
318
343
  }
319
344
  if (args[0] === "internal") {
@@ -521,9 +546,21 @@ export async function main() {
521
546
  }
522
547
  validateCompatibleFileTaskStore(home);
523
548
  const controllerMethod = method;
549
+ const updateHandoverOwner = process.env.YUI_UPDATE_HANDOVER_OWNER_PID;
550
+ const updateHandoverOwnerPid = updateHandoverOwner === undefined
551
+ ? undefined
552
+ : Number(updateHandoverOwner);
553
+ if (updateHandoverOwnerPid !== undefined
554
+ && (!Number.isSafeInteger(updateHandoverOwnerPid) || updateHandoverOwnerPid < 1)) {
555
+ throw runtimeError("Update Controller handover owner PID is invalid.");
556
+ }
557
+ const controllerOptions = {
558
+ environment: process.env,
559
+ ...(updateHandoverOwnerPid === undefined ? {} : { handoverOwnerPid: updateHandoverOwnerPid })
560
+ };
524
561
  const result = controllerMethod === "restart"
525
- ? await restartFileTaskController(home, { environment: process.env })
526
- : await stopFileTaskController(home, { environment: process.env });
562
+ ? await restartFileTaskController(home, controllerOptions)
563
+ : await stopFileTaskController(home, controllerOptions);
527
564
  // The update lifecycle needs the authenticated replacement PID returned by
528
565
  // restart/readiness. Keep stop's long-standing text envelope, while
529
566
  // exposing restart's structured result alongside its human output.
@@ -455,7 +455,7 @@ const CONFIG_KEY_HANDLERS = [
455
455
  const { runtimeHealth: _removed, ...rest } = config;
456
456
  return rest;
457
457
  });
458
- return "Runtime health thresholds reset to quiet 300s / diagnostic 600s / stall 1800s\n";
458
+ return "Runtime health thresholds reset to quiet 300s / diagnostic 900s / stall 1800s\n";
459
459
  }
460
460
  },
461
461
  {
@@ -139,7 +139,8 @@ export function renderExecutionAudit(report, width = defaultTableWidth()) {
139
139
  }
140
140
  if (report.sessions.status === "ok" && report.sessions.data !== undefined) {
141
141
  const sessions = report.sessions.data;
142
- lines.push("", `Sessions: ${sessions.generations} generations · ${sessions.broken} broken · ${sessions.stopped} stopped · ${sessions.other} other`, `Resets: ${sessions.resets} · lifecycle events ${sessions.lifecycleEvents} · stop failures ${sessions.stopFailures}`, `Terminal by Run relation: ${sessions.terminalByRunRelation.postRunYielded} post-run-yielded`
142
+ lines.push("", `Sessions: ${sessions.generations} generations · ${sessions.broken} broken · ${sessions.stopped} stopped · ${sessions.other} other`, `Resets: ${sessions.resets} · Conversation switches ${sessions.conversationSwitches}`
143
+ + ` · lifecycle events ${sessions.lifecycleEvents} · stop failures ${sessions.stopFailures}`, `Terminal by Run relation: ${sessions.terminalByRunRelation.postRunYielded} post-run-yielded`
143
144
  + ` · ${sessions.terminalByRunRelation.runFailed} run-failed`
144
145
  + ` · ${sessions.terminalByRunRelation.activeRun} active-run`
145
146
  + ` · ${sessions.terminalByRunRelation.noRun} no-run`);
@@ -9,8 +9,9 @@ import { createTaskRecordRetirement, isTaskRecordRetired, taskRecordRetirement }
9
9
  import { isRoleRunStalled, RUN_PROGRESS_EVENT, RUN_RECOVERED_EVENT } from "../scheduler/roleRunStall.js";
10
10
  import { readCommandText } from "./textInput.js";
11
11
  import { assertTaskCompletionPublishedTreeProof } from "./taskCompletionGate.js";
12
- import { createRoleSessionSet, retireTaskRoleSessionsForWorkspace, roleAgentSessionResumeMode, updateTaskRoleProviderRuntime } from "../executor/agentExecutor.js";
12
+ import { createRoleSessionSet, retireTaskRoleSessionsForWorkspace, updateTaskRoleProviderRuntime } from "../executor/agentExecutor.js";
13
13
  import { currentProviderActivation, transferProviderAuthority } from "../runtime/providerRuntimeIdentity.js";
14
+ import { CONVERSATION_SWITCH_REQUESTED_EVENT, CONVERSATION_SWITCH_RESOLVED_EVENT, projectConversationSwitch, providerConversationGeneration, roleSessionDispatchModeWithConversationSwitch } from "../runtime/conversationSwitch.js";
14
15
  import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
15
16
  import { defaultTableWidth, renderTable } from "../output/table.js";
16
17
  import { formatTimestamp } from "../output/timePresentation.js";
@@ -18,7 +19,6 @@ import { renderRoleDetails } from "../output/rolePresentation.js";
18
19
  import { createTaskMessage, taskMessageAuthorLabel } from "../message/message.js";
19
20
  import { cancelInputRequest } from "../input/inputRequest.js";
20
21
  import { recoverExactAgentRun, terminalizeExactTaskRun, validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
21
- import { resetTaskRoleSessionGeneration } from "../lifecycle/taskRoleSessionReset.js";
22
22
  import { copyGlobalRoleToTaskRole, createRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateRole, updateRoleStatus } from "../role/role.js";
23
23
  import { agentRunDeliveryReceiptId, createAgentRun, withAgentRunContextSnapshot } from "../run/agentRun.js";
24
24
  import { projectRunRecovery, readRunRecoveryFacts } from "../run/recoveryProjection.js";
@@ -1373,8 +1373,8 @@ function taskRoleCommand(args, store, options) {
1373
1373
  return output(bindTaskRole(rest, store, options));
1374
1374
  if (command === "unbind")
1375
1375
  return output(unbindTaskRole(rest, store, options));
1376
- if (command === "reset")
1377
- return resetTaskRole(rest, store, options);
1376
+ if (command === "session")
1377
+ return taskRoleSessionCommand(rest, store, options);
1378
1378
  if (command === "view")
1379
1379
  return viewTaskRole(rest, store);
1380
1380
  if (command === "takeover")
@@ -1385,29 +1385,78 @@ function taskRoleCommand(args, store, options) {
1385
1385
  ? "Task role command is required."
1386
1386
  : `Unknown command: task role ${command}`);
1387
1387
  }
1388
- function resetTaskRole(args, store, options) {
1389
- const usage = "Task role reset usage: yui task role reset <task> <role> --reason <text>.";
1388
+ function taskRoleSessionCommand(args, store, options) {
1389
+ const [command, ...rest] = args;
1390
+ if (command === "switch")
1391
+ return switchTaskRoleSession(rest, store, options);
1392
+ throw usageError(command === undefined
1393
+ ? "Task role session command is required."
1394
+ : `Unknown command: task role session ${command}`);
1395
+ }
1396
+ function switchTaskRoleSession(args, store, options) {
1397
+ const usage = "Task role session switch usage: yui task role session switch <task> <role> --reason <text>.";
1390
1398
  const parsed = parseTail(args, new Set(["--reason"]), usage);
1391
1399
  exactPositionals(parsed.positionals, 2, usage);
1392
- const reason = requiredOption(parsed.options, "--reason");
1400
+ const reason = truncateEventNote(requiredOption(parsed.options, "--reason"));
1393
1401
  const now = clock(options);
1394
- let result;
1395
- try {
1396
- result = store.transaction((tx) => resetTaskRoleSessionGeneration(tx, parsed.positionals[0], parsed.positionals[1], reason, now));
1397
- }
1398
- catch (error) {
1399
- throw usageError(error instanceof Error ? error.message : String(error), usage);
1400
- }
1401
- const target = runtimeLifecycleTarget({
1402
- scope: "task",
1402
+ const result = store.transaction((tx) => {
1403
+ const task = requireTask(tx, parsed.positionals[0]);
1404
+ if (task.status !== "active") {
1405
+ throw usageError(inactiveTaskMessage(task, "switching a Provider Conversation"), usage);
1406
+ }
1407
+ const role = requireRole(tx, task.id, parsed.positionals[1]);
1408
+ const requestedBy = taskActor(options, task.id);
1409
+ const leaderRunId = requestedBy === "leader"
1410
+ ? taskLeaderActionRunId(tx, task.id, options.environment, options.yuiHome)
1411
+ : undefined;
1412
+ if (requestedBy === "leader" && leaderRunId === undefined) {
1413
+ throw usageError("Leader Conversation switching requires the exact current-Turn assertion.", usage);
1414
+ }
1415
+ const sessions = tx.getTaskRoleSessionSet(task.id, role.name);
1416
+ const generation = providerConversationGeneration(sessions);
1417
+ if (sessions === null || generation === null) {
1418
+ throw usageError(`Task Role has no current Provider Conversation: ${task.id}/${role.name}.`, usage);
1419
+ }
1420
+ const events = tx.listEvents(task.id);
1421
+ const previous = projectConversationSwitch(events, role.name, sessions);
1422
+ if (previous?.status === "pending"
1423
+ && previous.generation === generation
1424
+ && previous.requestedBy === requestedBy
1425
+ && previous.reason === reason) {
1426
+ return { taskId: task.id, roleName: role.name, request: previous, replayed: true };
1427
+ }
1428
+ if (previous !== null && previous.resolvedAt === undefined) {
1429
+ tx.saveEvent(task.id, createTaskEvent(tx.nextEventId(task.id), task.id, CONVERSATION_SWITCH_RESOLVED_EVENT, {
1430
+ requestId: previous.requestId,
1431
+ roleName: role.name,
1432
+ generation: previous.generation,
1433
+ status: "obsolete"
1434
+ }, now));
1435
+ }
1436
+ const requestId = tx.nextEventId(task.id);
1437
+ const event = createTaskEvent(requestId, task.id, CONVERSATION_SWITCH_REQUESTED_EVENT, {
1438
+ requestId,
1439
+ roleName: role.name,
1440
+ generation,
1441
+ requestedBy,
1442
+ reason,
1443
+ ...(leaderRunId === undefined ? {} : { leaderRunId })
1444
+ }, now);
1445
+ tx.saveEvent(task.id, event);
1446
+ return {
1447
+ taskId: task.id,
1448
+ roleName: role.name,
1449
+ request: projectConversationSwitch([...events, event], role.name, sessions),
1450
+ replayed: false
1451
+ };
1452
+ });
1453
+ options.runtime?.notifyStateChanged(result.taskId);
1454
+ options.runtime?.notifyMailboxChanged?.({
1455
+ kind: "role",
1403
1456
  taskId: result.taskId,
1404
1457
  roleName: result.roleName
1405
1458
  });
1406
- options.runtime?.notifyMailboxChanged?.(target);
1407
- notifyMailbox(options.runtime, result.roleName === LEADER_ROLE
1408
- ? { kind: "operator" }
1409
- : leaderMailbox(result.taskId), result.taskId);
1410
- return output(`Reset Task Role Session ${result.taskId}/${result.roleName}; runtime cleanup is pending.\n`, result);
1459
+ return output(`${result.replayed ? "Reused" : "Recorded"} pending Provider Conversation switch ${result.request.requestId} for ${result.taskId}/${result.roleName}. The current Conversation remains authoritative until safe replacement binding.\n`, result);
1411
1460
  }
1412
1461
  function addTaskRole(args, store, options) {
1413
1462
  const usage = "Task role add usage: yui task role add <task> <name> [Role and Agent settings].";
@@ -2337,7 +2386,7 @@ function dispatchWork(args, store, options) {
2337
2386
  workItemWriteProjectIds: item.writeProjectIds
2338
2387
  });
2339
2388
  const sessions = tx.getTaskRoleSessionSet(task.id, plan.role.name);
2340
- const dispatchMode = roleAgentSessionResumeMode(sessions, effective.agentId, effective);
2389
+ const dispatchMode = taskRoleDispatchMode(tx, task.id, plan.role.name, sessions, effective.agentId, effective);
2341
2390
  const laneWorkspace = laneManagedWorkspace === undefined
2342
2391
  ? undefined
2343
2392
  : {
@@ -4576,7 +4625,7 @@ function retryRun(args, store, options) {
4576
4625
  contextSnapshotRef: contextSnapshotRef(retrySnapshot),
4577
4626
  deltaRefIds: contextSnapshotDeltaRefIds(tx, retrySnapshot)
4578
4627
  });
4579
- const created = createAgentRun(runId, task.id, role.name, roleAgentSessionResumeMode(sessions, effective.agentId, effective), assignment, now, {
4628
+ const created = createAgentRun(runId, task.id, role.name, taskRoleDispatchMode(tx, task.id, role.name, sessions, effective.agentId, effective), assignment, now, {
4580
4629
  ...(previous.workItemId === undefined ? {} : { workItemId: previous.workItemId }),
4581
4630
  ...(runningGroup === undefined ? {} : {
4582
4631
  executionGroupId: runningGroup.id,
@@ -5125,11 +5174,11 @@ function retryFailedReviewRun(previous, store, options, now) {
5125
5174
  }
5126
5175
  /**
5127
5176
  * Records one explicit Leader recovery decision against an exact live Run.
5128
- * Retry and Session replacement remain requests: the Controller never sends
5129
- * another input or changes a native generation from this command.
5177
+ * Diagnose and retry remain same-Run requests: this command never changes a
5178
+ * native Conversation generation.
5130
5179
  */
5131
5180
  function recoverRun(args, store, options) {
5132
- const usage = "Task run recover usage: yui task run recover <task>/<run> --action <diagnose|retry|replace-session|terminate> (--expected-progress-at <timestamp>|--from-next-action <fingerprint>) --provider-acceptance <accepted|rejected|ambiguous> --reason <text> [--agent-id <id>] [--adapter-id <id>] [--native-session-id <id>] [--launch-id <id>].";
5181
+ const usage = "Task run recover usage: yui task run recover <task>/<run> --action <diagnose|retry|terminate> (--expected-progress-at <timestamp>|--from-next-action <fingerprint>) --provider-acceptance <accepted|rejected|ambiguous> --reason <text> [--agent-id <id>] [--adapter-id <id>] [--native-session-id <id>] [--launch-id <id>].";
5133
5182
  const parsed = parseTail(args, new Set([
5134
5183
  "--action",
5135
5184
  "--expected-progress-at",
@@ -6153,7 +6202,7 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
6153
6202
  effective,
6154
6203
  workspace: laneWorkspace
6155
6204
  }, now);
6156
- createdRuns.push(createAgentRun(runId, taskId, laneReviewer.name, roleAgentSessionResumeMode(sessions, effective.agentId, effective), assignment, now, {
6205
+ createdRuns.push(createAgentRun(runId, taskId, laneReviewer.name, taskRoleDispatchMode(tx, taskId, laneReviewer.name, sessions, effective.agentId, effective), assignment, now, {
6157
6206
  ...(item === undefined ? {} : { workItemId: item.id }),
6158
6207
  purpose: "review",
6159
6208
  reviewRoundId: round.id,
@@ -6519,6 +6568,9 @@ function assertTaskOpen(task) {
6519
6568
  function taskActor(options, taskId) {
6520
6569
  return resolveTaskActor(options.environment, taskId);
6521
6570
  }
6571
+ function taskRoleDispatchMode(store, taskId, roleName, sessions, agentId, effective) {
6572
+ return roleSessionDispatchModeWithConversationSwitch(sessions, store.listEvents(taskId), store.getWorkMailbox({ kind: "role", taskId, roleName }), roleName, agentId, effective);
6573
+ }
6522
6574
  function inactiveTaskMessage(task, action) {
6523
6575
  if (task.status === "draft") {
6524
6576
  return `Task ${task.id} is a Draft; activate it before ${action}.`;
@@ -6549,10 +6601,9 @@ function parseWorkStatus(value) {
6549
6601
  function parseRecoveryAction(value, usage) {
6550
6602
  if (value === "diagnose"
6551
6603
  || value === "retry"
6552
- || value === "replace-session"
6553
6604
  || value === "terminate")
6554
6605
  return value;
6555
- throw usageError("--action must be diagnose, retry, replace-session, or terminate.", usage);
6606
+ throw usageError("--action must be diagnose, retry, or terminate.", usage);
6556
6607
  }
6557
6608
  function parseProviderAcceptance(value, usage) {
6558
6609
  if (value === "accepted" || value === "rejected" || value === "ambiguous")
@@ -269,7 +269,9 @@ export function runTaskContextCommand(args, store, currentTaskReviewCandidate =
269
269
  : [` Runtime source at creation: ${creation.payload.runtimeSource}`]),
270
270
  ...(recovery === undefined ? [] : [
271
271
  ` Runtime cleanup: ${recovery.runtimeCleanupPending ? "pending" : "none"}`,
272
- ` Fresh launch: ${recovery.freshLaunchAllowed ? "allowed" : "blocked"}`
272
+ ` Fresh launch: ${recovery.freshLaunchAllowed
273
+ ? "allowed"
274
+ : `blocked (${recovery.freshLaunchBlockers.join(", ")})`}`
273
275
  ])
274
276
  ];
275
277
  })),
@@ -420,7 +422,9 @@ function renderCoordinationMailbox(mailbox) {
420
422
  }
421
423
  function latestStallKind(events, runId) {
422
424
  const event = [...events]
423
- .filter((candidate) => candidate.type === "run.stalled" && candidate.payload.runId === runId)
425
+ .filter((candidate) => candidate.type === "run.stalled"
426
+ && candidate.payload.runId === runId
427
+ && candidate.payload.status !== "diagnostic-only")
424
428
  .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0];
425
429
  return event?.payload.kind ?? "workflow-not-progressing";
426
430
  }