@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,14 +34,17 @@
34
34
  * authoritative input byte-for-byte unchanged.
35
35
  */
36
36
  import { spawn } from "node:child_process";
37
- import { existsSync, readFileSync, readdirSync } from "node:fs";
37
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
38
+ import { tmpdir } from "node:os";
38
39
  import { dirname, join } from "node:path";
40
+ import Database from "better-sqlite3";
39
41
  import { describeReport, runMigration } from "../migration/index.js";
40
42
  import { validateCompatibleFileTaskStore } from "../compatibleTaskStore.js";
41
43
  import { stopFileTaskController, ensureFileTaskController, ensureFileTaskControllerIdentity } from "../../controller/clientRuntime.js";
42
44
  import { callController } from "../../core/controllerClient.js";
43
45
  import { FileTaskStore, STORAGE_STATE_FILE, withStorageWriteLock } from "../taskStore.js";
44
46
  import { SqliteTaskStore } from "../sqliteStore.js";
47
+ import { migrateSqliteSchema } from "../sqliteSchema.js";
45
48
  import { clearUpgradeFence, placeUpgradeFence, readUpgradeFence, UpgradeFenceError } from "../upgradeFence.js";
46
49
  import { withUpgradeCoordinationLock } from "../upgradeCoordination.js";
47
50
  import { clearUpgradeReceipt, writeUpgradeReceipt, upgradeReceiptPath } from "./upgradeReceipt.js";
@@ -52,7 +55,7 @@ import { repairPseudoLayout7 } from "./pseudoLayoutRepair.js";
52
55
  import { createSqliteMigrationTarget } from "./sqliteMigrationTarget.js";
53
56
  import { createSqliteRecordMigrationTarget } from "./sqliteRecordMigrationTarget.js";
54
57
  import { COMMITTED_DATABASE_FILENAME } from "./sqliteStateMigration.js";
55
- import { inspectOfflineUpgradeInventory } from "./offlineUpgradeInventory.js";
58
+ import { inspectOfflineUpgradeInventory, inspectSqliteDurableUpgradeInventory } from "./offlineUpgradeInventory.js";
56
59
  /**
57
60
  * Run the storage upgrade for one Home. Never throws for an expected blocker;
58
61
  * it returns a structured `blocked` result instead. It only throws on a truly
@@ -95,6 +98,9 @@ export async function runStorageUpgrade(options) {
95
98
  action: blocker.action
96
99
  }, classification);
97
100
  }
101
+ if (classification.sqliteMigration !== undefined) {
102
+ return runSqliteInPlaceUpgrade(options, classification, now, callerPid);
103
+ }
98
104
  // A pseudo-layout-7 Home (manifest 7, no yui.db, readable state.json) needs
99
105
  // the deterministic staged state.json→SQLite repair, not the version
100
106
  // migration engine (Issue 01). The update preflight reports it as
@@ -357,6 +363,329 @@ function offlineInventoryBlocker(inventory, sceneUnchanged) {
357
363
  ...(sceneUnchanged ? { sceneUnchanged: true } : {})
358
364
  };
359
365
  }
366
+ /**
367
+ * A valid SQLite migration prefix advances the authoritative database in
368
+ * place. It still uses the offline runtime gate because the old Controller
369
+ * cannot safely resume after the schema transaction commits, but it never
370
+ * snapshots, copies, rebuilds, or swaps the database.
371
+ */
372
+ async function runSqliteInPlaceUpgrade(options, classification, now, callerPid) {
373
+ const migration = classification.sqliteMigration;
374
+ const inventory = await readOfflineInventory(options, options.home);
375
+ if (inventory.total > 0) {
376
+ return withClassification(offlineInventoryBlocker(inventory, true), classification);
377
+ }
378
+ if (options.mode === "update-preflight") {
379
+ return {
380
+ outcome: "update-preflight",
381
+ status: "in-place-migration",
382
+ stepCount: migration.pendingVersions.length,
383
+ classification,
384
+ sqliteMigration: migration
385
+ };
386
+ }
387
+ if (options.mode === "dry-run") {
388
+ return validateSqliteInPlaceDryRun(options, classification, now);
389
+ }
390
+ return executeSqliteInPlaceUpgrade(options, classification, now, callerPid);
391
+ }
392
+ /**
393
+ * Exercise every pending SQLite migration against a consistent disposable
394
+ * snapshot, then open that snapshot through the current production loader.
395
+ * The authoritative database is opened read-only and is never migrated.
396
+ */
397
+ async function validateSqliteInPlaceDryRun(options, classification, now) {
398
+ const migration = classification.sqliteMigration;
399
+ const stagingRoot = mkdtempSync(join(tmpdir(), "yui-sqlite-upgrade-dry-run-"));
400
+ const stagedDatabasePath = join(stagingRoot, COMMITTED_DATABASE_FILENAME);
401
+ let source;
402
+ let staged;
403
+ let loader;
404
+ try {
405
+ source = new Database(join(options.home, COMMITTED_DATABASE_FILENAME), {
406
+ readonly: true,
407
+ fileMustExist: true
408
+ });
409
+ source.pragma("busy_timeout = 5000");
410
+ await source.backup(stagedDatabasePath);
411
+ source.close();
412
+ source = undefined;
413
+ staged = new Database(stagedDatabasePath);
414
+ staged.pragma("journal_mode = WAL");
415
+ staged.pragma("synchronous = FULL");
416
+ staged.pragma("foreign_keys = ON");
417
+ const applied = migrateSqliteSchema(staged, { mode: "apply" }).applied;
418
+ if (applied.join(",") !== migration.pendingVersions.join(",")) {
419
+ throw new Error(`staged SQLite migration applied versions ${applied.join(",") || "none"}; `
420
+ + `expected ${migration.pendingVersions.join(",")}`);
421
+ }
422
+ staged.close();
423
+ staged = undefined;
424
+ loader = new SqliteTaskStore(stagingRoot);
425
+ const quickCheck = loader.databaseHandle().pragma("quick_check", { simple: true });
426
+ if (quickCheck !== "ok") {
427
+ throw new Error(`staged SQLite quick_check returned ${String(quickCheck)}`);
428
+ }
429
+ loader.close();
430
+ loader = undefined;
431
+ return {
432
+ outcome: "dry-run",
433
+ classification,
434
+ report: sqliteInPlaceReport(options.latest, migration, "dry-run", now)
435
+ };
436
+ }
437
+ catch (error) {
438
+ return withClassification({
439
+ outcome: "blocked",
440
+ stage: "validate",
441
+ message: `SQLite dry-run validation failed: ${messageOf(error)}`,
442
+ action: "The authoritative database was not changed. Resolve the reported migration or loader failure, then retry the dry run."
443
+ }, classification);
444
+ }
445
+ finally {
446
+ try {
447
+ loader?.close();
448
+ }
449
+ catch { /* best-effort disposable cleanup */ }
450
+ try {
451
+ staged?.close();
452
+ }
453
+ catch { /* best-effort disposable cleanup */ }
454
+ try {
455
+ source?.close();
456
+ }
457
+ catch { /* best-effort disposable cleanup */ }
458
+ rmSync(stagingRoot, { recursive: true, force: true });
459
+ }
460
+ }
461
+ /** Acquire a local write fence or prove the exact parent-owned fence exists. */
462
+ function acquireUpgradeAdmission(options, reason, now, callerPid) {
463
+ const externalOwnerPid = options.externalUpgradeFenceOwnerPid;
464
+ if (externalOwnerPid !== undefined) {
465
+ const fence = readUpgradeFence(options.home);
466
+ if (!isPositivePid(externalOwnerPid) || fence?.ownerPid !== externalOwnerPid) {
467
+ throw new UpgradeFenceError(`expected parent-owned upgrade fence for PID ${String(externalOwnerPid)}`);
468
+ }
469
+ return () => { };
470
+ }
471
+ return placeUpgradeFence(options.home, {
472
+ reason,
473
+ createdAt: now().toISOString(),
474
+ ownerPid: callerPid
475
+ });
476
+ }
477
+ async function executeSqliteInPlaceUpgrade(options, classification, now, callerPid) {
478
+ const { home } = options;
479
+ const migration = classification.sqliteMigration;
480
+ let releaseFence;
481
+ try {
482
+ releaseFence = acquireUpgradeAdmission(options, "SQLite schema migration in progress", now, callerPid);
483
+ }
484
+ catch (error) {
485
+ if (!(error instanceof UpgradeFenceError))
486
+ throw error;
487
+ return withClassification({
488
+ outcome: "blocked",
489
+ stage: "coordination",
490
+ message: `Upgrade coordination could not be acquired: ${error.message}`,
491
+ action: "Wait for the current maintenance operation to finish, then retry."
492
+ }, classification);
493
+ }
494
+ const externallyQuiesced = options.controllerLifecycle === "externally-quiesced";
495
+ let controllerWasRunning = false;
496
+ let controllerStopConfirmed = false;
497
+ let controllerIdentity;
498
+ let committed = false;
499
+ let result;
500
+ let unexpected;
501
+ try {
502
+ if (!externallyQuiesced) {
503
+ const controllerStatus = options.controllerStatus
504
+ ?? ((targetHome) => defaultControllerStatus(targetHome, options.controllerOptions));
505
+ let status;
506
+ try {
507
+ status = await controllerStatus(home);
508
+ }
509
+ catch (error) {
510
+ result = withClassification(controllerLifecycleBlocker("Controller status could not be verified", error), classification);
511
+ }
512
+ if (result === undefined && !isControllerLifecycleStatus(status)) {
513
+ result = withClassification(controllerLifecycleBlocker("Controller status was malformed", new Error("expected a boolean running field")), classification);
514
+ }
515
+ if (result === undefined && status.running && !isControllerLaunchIdentity(status.identity)) {
516
+ result = withClassification(controllerLifecycleBlocker("Controller launch identity could not be authenticated", new Error("executable/argv/version identity is unavailable")), classification);
517
+ }
518
+ if (result === undefined && status.running && !isPositivePid(status.pid)) {
519
+ result = withClassification(controllerLifecycleBlocker("Controller PID could not be authenticated", new Error("a positive status PID is unavailable for fenced stop")), classification);
520
+ }
521
+ if (result === undefined) {
522
+ controllerWasRunning = status.running;
523
+ controllerIdentity = status.running ? status.identity : undefined;
524
+ controllerStopConfirmed = !controllerWasRunning;
525
+ }
526
+ if (result === undefined && controllerWasRunning) {
527
+ const stopController = options.stopController
528
+ ?? ((targetHome, expectedPid) => defaultStopController(targetHome, expectedPid, options.controllerOptions));
529
+ try {
530
+ const expectedPid = status.pid;
531
+ const stopped = await stopController(home, expectedPid);
532
+ if (!confirmedControllerStopped(stopped, expectedPid)) {
533
+ result = withClassification(controllerLifecycleBlocker("Controller stop did not confirm a drained process", new Error(`stop did not confirm captured PID ${expectedPid} with stopped:true`)), classification);
534
+ }
535
+ else {
536
+ controllerStopConfirmed = true;
537
+ }
538
+ }
539
+ catch (error) {
540
+ result = withClassification(controllerLifecycleBlocker("Controller stop/drain failed", error), classification);
541
+ }
542
+ }
543
+ }
544
+ else {
545
+ controllerStopConfirmed = true;
546
+ }
547
+ // The first inventory is a cheap/read-only preflight. Re-read after the
548
+ // Controller has fully drained so a lifecycle request that committed while
549
+ // the fence was being placed cannot slip through on stale evidence.
550
+ if (result === undefined) {
551
+ const finalInventory = await readOfflineInventory(options, home);
552
+ if (finalInventory.total > 0) {
553
+ result = withClassification(offlineInventoryBlocker(finalInventory, true), classification);
554
+ }
555
+ }
556
+ if (result === undefined) {
557
+ try {
558
+ result = withUpgradeCoordinationLock(home, () => {
559
+ const quiesce = verifyQuiesced(home, callerPid);
560
+ if (quiesce !== null)
561
+ return withClassification(quiesce, classification);
562
+ const db = new Database(join(home, COMMITTED_DATABASE_FILENAME));
563
+ try {
564
+ db.pragma("journal_mode = WAL");
565
+ db.pragma("synchronous = FULL");
566
+ db.pragma("foreign_keys = ON");
567
+ db.pragma("busy_timeout = 5000");
568
+ const apply = db.transaction(() => {
569
+ // BEGIN IMMEDIATE waits for every older SQLite writer. With the
570
+ // upgrade fence still held, the state read here is the final
571
+ // authoritative durable gate for the same schema transaction.
572
+ const durableInventory = inspectSqliteDurableUpgradeInventory(home, db);
573
+ if (durableInventory.total > 0) {
574
+ return withClassification(offlineInventoryBlocker(durableInventory, true), classification);
575
+ }
576
+ migrateSqliteSchema(db, { mode: "apply" });
577
+ return {
578
+ outcome: "upgraded",
579
+ classification,
580
+ migrationMode: "in-place",
581
+ report: sqliteInPlaceReport(options.latest, migration, "execute", now)
582
+ };
583
+ });
584
+ const applied = apply.immediate();
585
+ if (applied.outcome === "upgraded")
586
+ committed = true;
587
+ return applied;
588
+ }
589
+ finally {
590
+ db.close();
591
+ }
592
+ });
593
+ }
594
+ catch (error) {
595
+ unexpected = error;
596
+ }
597
+ }
598
+ }
599
+ finally {
600
+ try {
601
+ releaseFence();
602
+ }
603
+ catch (error) {
604
+ if (unexpected === undefined)
605
+ unexpected = error;
606
+ }
607
+ }
608
+ if (unexpected !== undefined) {
609
+ if (committed) {
610
+ return withClassification({
611
+ outcome: "blocked",
612
+ stage: "post-verify",
613
+ message: `SQLite migration committed but completion failed: ${messageOf(unexpected)}`,
614
+ action: "Do not restore the old Controller. Re-run this Yui version; the SQLite migration ledger is the commit record.",
615
+ storageCommitted: true
616
+ }, classification);
617
+ }
618
+ if (controllerWasRunning && controllerStopConfirmed) {
619
+ await restoreController(home, options, unexpected, controllerIdentity);
620
+ }
621
+ throw unexpected;
622
+ }
623
+ if (result === undefined)
624
+ throw new Error("SQLite in-place upgrade did not produce a result.");
625
+ if (result.outcome === "upgraded" && !externallyQuiesced && controllerWasRunning) {
626
+ try {
627
+ await (options.startController
628
+ ?? ((targetHome) => defaultStartController(targetHome, options.controllerOptions)))(home);
629
+ }
630
+ catch (error) {
631
+ return withClassification({
632
+ outcome: "blocked",
633
+ stage: "post-verify",
634
+ message: `SQLite migration committed but the replacement Controller did not start: ${messageOf(error)}`,
635
+ action: "Do not restore the old Controller. Start the current Yui Controller; the SQLite migration ledger already committed.",
636
+ storageCommitted: true
637
+ }, classification);
638
+ }
639
+ return result;
640
+ }
641
+ if (result.outcome === "blocked"
642
+ && !committed
643
+ && !externallyQuiesced
644
+ && controllerWasRunning
645
+ && controllerStopConfirmed) {
646
+ await restoreController(home, options, new Error(result.message), controllerIdentity);
647
+ }
648
+ return result;
649
+ }
650
+ function sqliteInPlaceReport(latest, migration, mode, now) {
651
+ const detail = `SQLite schema ${migration.currentVersion}->${migration.targetVersion}; `
652
+ + `pending versions ${migration.pendingVersions.join(", ")}`;
653
+ if (mode === "dry-run") {
654
+ return {
655
+ outcome: "dry-run",
656
+ mode,
657
+ source: latest,
658
+ target: latest,
659
+ steps: [],
660
+ effects: [],
661
+ derived: { rebuiltEffects: [] },
662
+ validation: {
663
+ checks: [{
664
+ name: "SQLite staged migration and loader gate",
665
+ outcome: "passed",
666
+ detail: `${detail}; snapshot migrated and reopened by the current SQLite loader`
667
+ }]
668
+ }
669
+ };
670
+ }
671
+ return {
672
+ outcome: "migrated",
673
+ mode,
674
+ source: latest,
675
+ target: latest,
676
+ steps: [],
677
+ effects: [],
678
+ derived: { rebuiltEffects: [] },
679
+ validation: {
680
+ checks: [{ name: "SQLite migration transaction", outcome: "passed", detail }]
681
+ },
682
+ switch: {
683
+ status: "switched",
684
+ detail: "SQLite schema migrated in place; no database copy or rebuild was created."
685
+ },
686
+ completedAt: now().toISOString()
687
+ };
688
+ }
360
689
  /** Dry run: validate through the staged gate, then discard; never switch. */
361
690
  function dryRun(options, classification, target) {
362
691
  // Refuse to reuse a stale staging directory from an interrupted run.
@@ -390,11 +719,7 @@ async function execute(options, classification, target, callerPid, now) {
390
719
  // the other upgrader's fence.
391
720
  let releaseFence;
392
721
  try {
393
- releaseFence = placeUpgradeFence(home, {
394
- reason: "storage upgrade in progress",
395
- createdAt: now().toISOString(),
396
- ownerPid: callerPid
397
- });
722
+ releaseFence = acquireUpgradeAdmission(options, "storage upgrade in progress", now, callerPid);
398
723
  }
399
724
  catch (error) {
400
725
  if (!(error instanceof UpgradeFenceError))
@@ -615,11 +940,7 @@ async function executePseudoLayout7Repair(options, classification, now) {
615
940
  const callerPid = options.callerPid ?? process.pid;
616
941
  let releaseFence;
617
942
  try {
618
- releaseFence = placeUpgradeFence(home, {
619
- reason: "storage repair in progress",
620
- createdAt: now().toISOString(),
621
- ownerPid: callerPid
622
- });
943
+ releaseFence = acquireUpgradeAdmission(options, "storage repair in progress", now, callerPid);
623
944
  }
624
945
  catch (error) {
625
946
  if (!(error instanceof UpgradeFenceError))
@@ -50,40 +50,6 @@ export function projectNextAction(facts) {
50
50
  if (laneRecovery !== undefined) {
51
51
  return buildExecutionLaneRecoveryAction(facts, laneRecovery);
52
52
  }
53
- // Quick Win (EXE-03): a resume Run that failed before durable Provider
54
- // acceptance must not be retried against the same native Session. The
55
- // authoritative next action is to replace the Session, not to retry the
56
- // same delivery. The guard only applies while the failed resume Run is the
57
- // *latest* Leader Run: once a newer Run exists (the fresh-Session launch),
58
- // the historical failure is stale and must not keep recommending a Session
59
- // replacement.
60
- const latestLeaderRun = facts.leaderRuns.at(-1);
61
- const failedResumeWithoutAcceptance = latestLeaderRun !== undefined
62
- && latestLeaderRun.mode === "resume"
63
- && latestLeaderRun.status === "failed"
64
- && latestLeaderRun.deliveredAt === undefined
65
- ? latestLeaderRun
66
- : undefined;
67
- if (failedResumeWithoutAcceptance !== undefined) {
68
- return buildAction(facts, {
69
- kind: "replace-leader-session",
70
- reason: `Leader resume Run ${failedResumeWithoutAcceptance.id} failed before Provider acceptance; `
71
- + "the native Session is proven unusable for this delivery. Replace it with a fresh Session "
72
- + "after exact cleanup/reset.",
73
- refs: [ref("agent-run", failedResumeWithoutAcceptance.id)],
74
- preconditions: [
75
- { fact: "Resume Run failed without durable acceptance", satisfied: true, ref: ref("agent-run", failedResumeWithoutAcceptance.id) },
76
- { fact: "Old Session is cleaned up or reset before fresh launch", satisfied: false }
77
- ],
78
- // The failed resume Run is already terminal, so `task run recover
79
- // --action replace-session` (which requires an active Run) cannot act
80
- // on it. The working recovery is to reset the Role's Session
81
- // generation, then clear the Leader failure so the next wake launches
82
- // a fresh Session (the failed-resume guard in the wakeup processor
83
- // forces mode=new for the next launch).
84
- recommendedCommand: `yui task role reset ${task.id} leader --reason "resume failed before acceptance" && yui jobs retry leader-recovery:${task.id}`
85
- });
86
- }
87
53
  const activeLeader = facts.activeRuns.find((run) => run.roleName === "leader");
88
54
  if (activeLeader !== undefined) {
89
55
  return buildAction(facts, {
@@ -224,7 +224,9 @@ function projectWebRunRuntimeHealth(reader, taskId, run, events, now, policy) {
224
224
  }
225
225
  function latestStallField(events, runId, field) {
226
226
  const stalled = events
227
- .filter((event) => event.type === "run.stalled" && event.payload.runId === runId)
227
+ .filter((event) => event.type === "run.stalled"
228
+ && event.payload.runId === runId
229
+ && event.payload.status !== "diagnostic-only")
228
230
  .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0];
229
231
  return stalled?.payload[field];
230
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -338,7 +338,9 @@ creating an explicit Task Role binding, also set and read back the required
338
338
  model and effort instead of relying on CLI defaults.
339
339
  Every managed reviewer must deliver through the current Run's exact
340
340
  `--summary-file -` yield command; a final response alone is not a durable
341
- handoff.
341
+ handoff. Read the completed result as one review batch and route all reported
342
+ findings together; do not manufacture another ReviewRound merely because one
343
+ finding was handled before the rest of the batch.
342
344
 
343
345
  A direct or native-subagent WorkItem is roleless. A Task Role WorkItem must be
344
346
  created with `--role <role>`; do not retrofit the Role later. Reuse a compatible
@@ -782,8 +784,9 @@ yui task complete <task-id> --summary "<outcome, validation, and remaining risks
782
784
  ```
783
785
 
784
786
  Retire obsolete WorkItems with `yui task work retire <task>/<work> --summary
785
- "..."`, optionally using `--replacement`. If the current Role generation is
786
- unusable, reset it with `yui task role reset <task> <role> --reason "..."` and
787
- let Yui derive all runtime identities from durable state. Archiving is a
787
+ "..."`, optionally using `--replacement`. If the current Provider Conversation
788
+ cannot continue, request a bounded switch with
789
+ `yui task role session switch <task> <role> --reason "..."`; the current
790
+ Conversation remains authoritative until Yui safely binds the replacement. Archiving is a
788
791
  separate global Operator lifecycle action. It performs the final Task-owned
789
792
  runtime and clean-worktree teardown, including this Leader.
@@ -345,10 +345,10 @@ the workflow without claiming that version was delivered.
345
345
  conflicts on the Leader's behalf.
346
346
  - Reconcile a disappeared native Session with `task reconcile`; inspect the Run
347
347
  before retrying a confirmed failure.
348
- - If the current Task Role native generation cannot continue, use
349
- `yui task role reset <task> <role> --reason "..."`. Let Yui derive the exact
350
- Run, Agent, receipt, launch, and Session identities; never reconstruct them
351
- from terminal text or ask the user to paste them.
348
+ - If the current Provider Conversation cannot continue, use
349
+ `yui task role session switch <task> <role> --reason "..."`. The request is
350
+ audited, and Yui keeps the old Conversation authoritative until the exact
351
+ replacement bind succeeds; never reconstruct identities from terminal text.
352
352
  - Retry only an explicitly failed recovery Job.
353
353
  - When a Leader first-progress advisory is reported, inspect its native
354
354
  generations and absence of durable progress. It is cost evidence rather than
@@ -88,10 +88,13 @@ only its named resource, effect, and isolation boundary; never broaden it. A
88
88
  real Agent may develop or review code, but that does not authorize a real
89
89
  provider/model test.
90
90
 
91
- Report reachable material defects, verification gaps, checks actually run, and
92
- bounded next actions. A review result is evidence for Leader judgment; it does
93
- not accept the WorkItem or complete the Task. Preserve the ReviewRound record
94
- and explicitly clean its workspace after the round is terminal.
91
+ Complete the assigned frozen-scope review before yielding. Accumulate all
92
+ reachable findings, verification gaps, checks actually run, and bounded next
93
+ actions, then submit them together in one Review Run result; do not yield as
94
+ soon as the first finding is discovered. A review result is evidence for Leader
95
+ judgment; it does not accept the WorkItem or complete the Task. Preserve the
96
+ ReviewRound record and explicitly clean its workspace after the round is
97
+ terminal.
95
98
 
96
99
  For normal software delivery, follow the applicable Project Policy. The
97
100
  Leader decides whether risk warrants one independent Task-final Review of the
@@ -1,118 +0,0 @@
1
- import { enqueueWork } from "../coordination/workMailboxQueue.js";
2
- import { resetTaskRoleSession } from "../executor/agentExecutor.js";
3
- import { createTaskEvent } from "../event/taskEvent.js";
4
- import { createTaskMessage } from "../message/message.js";
5
- import { updateRoleStatus } from "../role/role.js";
6
- import { enqueueOperatorEvent } from "../scheduler/operatorEvent.js";
7
- import { recordLeaderFailure } from "../scheduler/leaderFailure.js";
8
- import { RUNTIME_CLEANUP_REQUIRED_REASON, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
9
- import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
10
- import { updateWorkItemStatus, workItemOwnsUnresolvedExecutionLane } from "../workItem/workItem.js";
11
- import { terminalizeExactTaskRun } from "./exactRunTerminalization.js";
12
- /**
13
- * Resets the current native generation using Yui's own persisted identities.
14
- * The caller supplies intent only; the Controller verifies process cleanup.
15
- */
16
- export function resetTaskRoleSessionGeneration(store, taskId, roleName, reason, now) {
17
- const task = store.getTask(requiredIdentity(taskId, "Task id"));
18
- if (task === null)
19
- throw new Error(`Task not found: ${taskId}.`);
20
- if (task.status !== "active")
21
- throw new Error(`Task is not active: ${task.id}/${task.status}.`);
22
- const normalizedRole = requiredIdentity(roleName, "Role name");
23
- const role = store.getRole(task.id, normalizedRole);
24
- if (role === null)
25
- throw new Error(`Role not found: ${task.id}/${normalizedRole}.`);
26
- const summary = `Reset native Session: ${requiredText(reason, "Reset reason")}`;
27
- let sessions = store.getTaskRoleSessionSet(task.id, role.name);
28
- const current = sessions?.sessions[sessions.activeAgentId];
29
- const activeRun = store.getActiveAgentRun(task.id, role.name);
30
- if (activeRun !== null) {
31
- const receiptId = sessions?.inFlight?.runId === activeRun.id
32
- ? sessions.inFlight.receiptId
33
- : formatAgentRunReceiptId(task.id, activeRun.id);
34
- const terminal = terminalizeExactTaskRun(store, {
35
- taskId: task.id,
36
- roleName: role.name,
37
- agentId: activeRun.effective.agentId,
38
- runId: activeRun.id,
39
- receiptId,
40
- ...(current === undefined ? {} : {
41
- nativeSessionId: current.nativeSessionId,
42
- ...(current.launchId === undefined ? {} : { launchId: current.launchId })
43
- }),
44
- outcome: { status: "failed", summary }
45
- }, now);
46
- if (terminal.disposition !== "applied" || terminal.run === null) {
47
- throw new Error(`Task Role reset lost its exact Run fence: ${terminal.reason ?? "obsolete"}.`);
48
- }
49
- if (activeRun.purpose === "execution" && activeRun.workItemId !== undefined) {
50
- const item = store.getWorkItem(task.id, activeRun.workItemId);
51
- if (item !== null
52
- && !["completed", "failed", "retired"].includes(item.status)
53
- && !workItemOwnsUnresolvedExecutionLane(item, activeRun.executionGroupId, activeRun.executionLaneId)) {
54
- store.saveWorkItem(task.id, updateWorkItemStatus(item, "failed", now, summary));
55
- }
56
- }
57
- }
58
- enqueueWork(store, runtimeLifecycleTarget({
59
- scope: "task",
60
- taskId: task.id,
61
- roleName: role.name
62
- }), RUNTIME_CLEANUP_REQUIRED_REASON, now, [{ type: "task", id: task.id }]);
63
- sessions = store.getTaskRoleSessionSet(task.id, role.name);
64
- if (sessions !== null)
65
- store.saveTaskRoleSessionSet(resetTaskRoleSession(sessions, now));
66
- const updatedRole = store.getRole(task.id, role.name);
67
- store.saveRole(task.id, updateRoleStatus(updatedRole, role.name === "leader" ? "failed" : "idle", now));
68
- const message = createTaskMessage(store.nextMessageId(task.id), task.id, summary, "system", { type: "system" }, now, activeRun === null ? {} : {
69
- runId: activeRun.id,
70
- ...(activeRun.workItemId === undefined ? {} : { workItemId: activeRun.workItemId })
71
- });
72
- store.saveMessage(task.id, message);
73
- const resetEvent = createTaskEvent(store.nextEventId(task.id), task.id, "runtime.role-session-reset", {
74
- roleName: role.name,
75
- reason: summary,
76
- ...(activeRun === null ? {} : { runId: activeRun.id }),
77
- ...(current?.nativeSessionId === undefined
78
- ? {}
79
- : { nativeSessionId: current.nativeSessionId })
80
- }, now);
81
- store.saveEvent(task.id, resetEvent);
82
- if (role.name === "leader") {
83
- const nativeSessionId = current?.nativeSessionId ?? `reset-${task.id}`;
84
- store.saveLeaderFailure(recordLeaderFailure(task.id, nativeSessionId, summary, now, store.getLeaderFailure(task.id)));
85
- enqueueOperatorEvent(store, resetEvent, "leader-run-failed", now);
86
- }
87
- else {
88
- enqueueWork(store, { kind: "role", taskId: task.id, roleName: "leader" }, "role-run-failed", now, [
89
- { type: "message", taskId: task.id, id: message.id },
90
- ...(activeRun === null ? [] : [{
91
- type: "run",
92
- taskId: task.id,
93
- id: activeRun.id
94
- }])
95
- ]);
96
- }
97
- return {
98
- taskId: task.id,
99
- roleName: role.name,
100
- run: activeRun === null ? null : store.getAgentRun(task.id, activeRun.id),
101
- ...(current === undefined ? {} : { nativeSessionId: current.nativeSessionId })
102
- };
103
- }
104
- function requiredIdentity(value, label) {
105
- const normalized = requiredText(value, label);
106
- if (["__proto__", "prototype", "constructor", ".", ".."].includes(normalized)
107
- || /[\/\\\0]/u.test(normalized))
108
- throw new Error(`${label} is invalid.`);
109
- return normalized;
110
- }
111
- function requiredText(value, label) {
112
- if (typeof value !== "string" || value.includes("\0"))
113
- throw new Error(`${label} is invalid.`);
114
- const normalized = value.trim();
115
- if (normalized.length === 0)
116
- throw new Error(`${label} is required.`);
117
- return normalized;
118
- }