@zq-silk/yui 0.11.3 → 0.12.0

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 (86) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +36 -13
  3. package/dist/cli/commandCatalog.js +2 -4
  4. package/dist/cli.js +105 -43
  5. package/dist/commands/configCommands.js +12 -45
  6. package/dist/commands/executionAuditCommands.js +1 -1
  7. package/dist/commands/jobCommands.js +4 -10
  8. package/dist/commands/taskCommands.js +154 -171
  9. package/dist/commands/taskCompletionGate.js +29 -54
  10. package/dist/commands/taskContextCommand.js +55 -11
  11. package/dist/commands/taskInputCommands.js +8 -5
  12. package/dist/commands/taskNextActionCommand.js +56 -6
  13. package/dist/commands/taskOverviewCommand.js +17 -31
  14. package/dist/commands/taskRoleRuntimeStatus.js +24 -0
  15. package/dist/commands/taskWorkspaceCommands.js +5 -2
  16. package/dist/config/configCatalog.js +2 -2
  17. package/dist/config/yuiConfig.js +3 -7
  18. package/dist/context/wakeNotification.js +20 -5
  19. package/dist/controller/agentRuntimeObserver.js +247 -51
  20. package/dist/controller/clientRuntime.js +1 -1
  21. package/dist/controller/controller.js +8 -12
  22. package/dist/controller/fileSchedulerStoreAdapter.js +146 -198
  23. package/dist/controller/runtime.js +3 -3
  24. package/dist/controller/runtimeEventInbox.js +17 -7
  25. package/dist/controller/runtimeEventProcessor.js +12 -19
  26. package/dist/controller/runtimeObservationHook.js +45 -0
  27. package/dist/execution/resourceBroker.js +5 -4
  28. package/dist/executor/agentExecutor.js +4 -4
  29. package/dist/executor/effectiveLaunch.js +8 -48
  30. package/dist/executor/fileRoleLaunchPlanner.js +36 -16
  31. package/dist/executor/workspacePreflightClassification.js +23 -2
  32. package/dist/interaction/operatorPresentation.js +33 -89
  33. package/dist/lifecycle/exactRunTerminalization.js +1 -2
  34. package/dist/lifecycle/taskRoleSessionReset.js +5 -13
  35. package/dist/observability/orchestrationMetrics.js +8 -3
  36. package/dist/profile/agentProfile.js +1 -1
  37. package/dist/release/cliHomeReleaseFence.js +123 -0
  38. package/dist/repository/taskWorkspacePreparer.js +176 -60
  39. package/dist/resources/sqliteResourceRegistry.js +1 -1
  40. package/dist/review/deltaRecheck.js +12 -51
  41. package/dist/review/reviewAcceptance.js +26 -0
  42. package/dist/review/reviewConfig.js +0 -31
  43. package/dist/review/reviewDecision.js +113 -0
  44. package/dist/review/reviewOutcomeClassifier.js +1 -1
  45. package/dist/review/reviewRound.js +1 -1
  46. package/dist/review/reviewerAvailability.js +69 -0
  47. package/dist/runtime/agentDriverObservation.js +24 -10
  48. package/dist/runtime/builtinAgentDrivers.js +4 -3
  49. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  50. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  51. package/dist/runtime/index.js +1 -0
  52. package/dist/runtime/runtimeObservation.js +13 -0
  53. package/dist/runtime/runtimeProjection.js +16 -25
  54. package/dist/runtime/sessionTokenMetrics.js +181 -0
  55. package/dist/runtime/structuredProviderHost.js +7 -1
  56. package/dist/scheduler/activeRoleRunDelivery.js +4 -4
  57. package/dist/scheduler/activeTaskProgress.js +7 -6
  58. package/dist/scheduler/leaderWakeupProcessor.js +3 -5
  59. package/dist/scheduler/operatorEvent.js +34 -0
  60. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  61. package/dist/scheduler/roleRunStall.js +0 -14
  62. package/dist/scheduler/taskExecutionProjection.js +97 -76
  63. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  64. package/dist/storage/migration/productionRegistry.js +379 -0
  65. package/dist/storage/sqliteSchema.js +18 -8
  66. package/dist/storage/sqliteStore.js +24 -20
  67. package/dist/storage/storeRpc.js +0 -1
  68. package/dist/storage/taskStore.js +15 -30
  69. package/dist/storage/upgrade/recordVersions.js +3 -2
  70. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  71. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  72. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  73. package/dist/task/completionReadiness.js +10 -6
  74. package/dist/task/nextAction.js +82 -24
  75. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  76. package/dist/web/assets/client/components.js +13 -3
  77. package/dist/web/assets/client/i18n.js +6 -2
  78. package/dist/web/webSnapshot.js +4 -0
  79. package/i18n/README.zh-CN.md +2 -2
  80. package/package.json +1 -1
  81. package/skills/yui-leader/SKILL.md +53 -16
  82. package/skills/yui-operator/SKILL.md +9 -0
  83. package/skills/yui-reviewer/SKILL.md +28 -7
  84. package/dist/context/sessionContextBudget.js +0 -71
  85. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  86. package/dist/scheduler/operatorNotification.js +0 -59
@@ -149,6 +149,12 @@ export function createSqliteMigrationTarget(options) {
149
149
  }
150
150
  const stamp = now().toISOString().replace(/[:.]/g, "-");
151
151
  let backupPath;
152
+ const schemaPath = join(home, STORAGE_SCHEMA_FILE);
153
+ const sourceSchemaText = readFileSync(schemaPath, "utf8");
154
+ const receiptPath = migrationReceiptPath(home);
155
+ const sourceReceiptText = existsSync(receiptPath)
156
+ ? readFileSync(receiptPath, "utf8")
157
+ : null;
152
158
  // Phase 1: back up any existing yui.db, then promote the sidecar.
153
159
  try {
154
160
  if (existsSync(committedDbPath)) {
@@ -185,7 +191,7 @@ export function createSqliteMigrationTarget(options) {
185
191
  // Phase 2: advance schema.json to layout 7 in the same critical section.
186
192
  try {
187
193
  if (stagedSchemaManifest !== null) {
188
- writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
194
+ writeTextFileAtomically(schemaPath, `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
189
195
  }
190
196
  // Certify the dual-copy state (Issue 01): the 6→7 migration retains
191
197
  // state.json read-only, so the Home legitimately holds both copies.
@@ -204,9 +210,22 @@ export function createSqliteMigrationTarget(options) {
204
210
  }
205
211
  }
206
212
  catch (error) {
207
- // The database is promoted but schema.json could not be advanced.
208
- // Attempt to restore the original database; if that fails, the Home
209
- // is ambiguous and must be recovered manually.
213
+ // A sidecar or receipt writer can fail after an atomic rename. Restore
214
+ // the exact source metadata as well as the database before claiming the
215
+ // layout switch did not commit.
216
+ let rollbackError;
217
+ try {
218
+ writeTextFileAtomically(schemaPath, sourceSchemaText);
219
+ if (sourceReceiptText === null) {
220
+ rmSync(receiptPath, { force: true });
221
+ }
222
+ else {
223
+ writeTextFileAtomically(receiptPath, sourceReceiptText);
224
+ }
225
+ }
226
+ catch (restoreError) {
227
+ rollbackError = restoreError;
228
+ }
210
229
  try {
211
230
  if (backupPath !== undefined && existsSync(backupPath)) {
212
231
  removeSqliteFileSet(committedDbPath);
@@ -216,14 +235,17 @@ export function createSqliteMigrationTarget(options) {
216
235
  removeSqliteFileSet(committedDbPath);
217
236
  }
218
237
  }
219
- catch {
238
+ catch (restoreError) {
239
+ rollbackError ??= restoreError;
240
+ }
241
+ if (rollbackError !== undefined) {
220
242
  throw new AmbiguousSwitchError({
221
243
  homePath: home,
222
244
  backupPath: backupPath ?? committedDbPath,
223
245
  stagingPath: stagedDbPath,
224
- detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
225
- `and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
226
- `recover by advancing schema.json storageVersion to ${latest.layout} or restoring the backup.`
246
+ detail: `SQLite database was promoted but schema.json or its receipt could not be durably advanced (${messageOf(error)}), ` +
247
+ `and the database/metadata rollback also failed (${messageOf(rollbackError)}). ` +
248
+ `Inspect ${committedDbPath}, ${schemaPath}, ${receiptPath}, and the backup before recovery.`
227
249
  });
228
250
  }
229
251
  throw error;
@@ -38,7 +38,7 @@ import { AmbiguousSwitchError } from "../migration/index.js";
38
38
  import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
39
39
  import { writeSwitchProgress } from "./switchProgress.js";
40
40
  import { moveSqliteFileSet, removeSqliteFileSet } from "./sqliteFileSet.js";
41
- import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, copySqlitePassthroughState, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
41
+ import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, copySqlitePassthroughState, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readMigrationSourceStateFromSqlite } from "./sqliteStateMigration.js";
42
42
  /** Build the SQLite-backed record-migration target. */
43
43
  export function createSqliteRecordMigrationTarget(options) {
44
44
  const home = options.home;
@@ -70,7 +70,7 @@ export function createSqliteRecordMigrationTarget(options) {
70
70
  readSource() {
71
71
  const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
72
72
  const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
73
- const state = readStateFromSqlite(home);
73
+ const state = readMigrationSourceStateFromSqlite(home);
74
74
  return Object.freeze({ schemaManifest, state });
75
75
  },
76
76
  writeFreshOutput(snapshot) {
@@ -129,6 +129,8 @@ export function createSqliteRecordMigrationTarget(options) {
129
129
  }
130
130
  const stamp = now().toISOString().replace(/[:.]/g, "-");
131
131
  let backupPath;
132
+ const schemaPath = join(home, STORAGE_SCHEMA_FILE);
133
+ const sourceSchemaText = readFileSync(schemaPath, "utf8");
132
134
  // Phase 1: back up the existing yui.db, then promote the sidecar.
133
135
  try {
134
136
  if (existsSync(committedDbPath)) {
@@ -167,13 +169,20 @@ export function createSqliteRecordMigrationTarget(options) {
167
169
  // critical section. The layout version is unchanged.
168
170
  try {
169
171
  if (stagedSchemaManifest !== null) {
170
- writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
172
+ writeTextFileAtomically(schemaPath, `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
171
173
  }
172
174
  }
173
175
  catch (error) {
174
- // The database is promoted but schema.json could not be advanced.
175
- // Attempt to restore the original database; if that fails, the Home
176
- // is ambiguous and must be recovered manually.
176
+ // A durable writer can report an fsync failure after its atomic rename,
177
+ // so "write threw" does not prove schema.json stayed old. Restore both
178
+ // the exact source sidecar and database before claiming no switch.
179
+ let rollbackError;
180
+ try {
181
+ writeTextFileAtomically(schemaPath, sourceSchemaText);
182
+ }
183
+ catch (restoreError) {
184
+ rollbackError = restoreError;
185
+ }
177
186
  try {
178
187
  if (backupPath !== undefined && existsSync(backupPath)) {
179
188
  removeSqliteFileSet(committedDbPath);
@@ -183,15 +192,18 @@ export function createSqliteRecordMigrationTarget(options) {
183
192
  removeSqliteFileSet(committedDbPath);
184
193
  }
185
194
  }
186
- catch {
195
+ catch (restoreError) {
196
+ rollbackError ??= restoreError;
197
+ }
198
+ if (rollbackError !== undefined) {
187
199
  writeInterruptedMarker(home, backupPath ?? committedDbPath, stagedDbPath, now);
188
200
  throw new AmbiguousSwitchError({
189
201
  homePath: home,
190
202
  backupPath: backupPath ?? committedDbPath,
191
203
  stagingPath: stagedDbPath,
192
- detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
193
- `and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
194
- `recover by advancing schema.json recordVersions or restoring the backup.`
204
+ detail: `SQLite database was promoted but schema.json could not be durably advanced (${messageOf(error)}), ` +
205
+ `and the database/schema rollback also failed (${messageOf(rollbackError)}). ` +
206
+ `Inspect ${committedDbPath}, ${schemaPath}, and the backup before recovery.`
195
207
  });
196
208
  }
197
209
  throw error;
@@ -214,7 +226,7 @@ export function createSqliteRecordMigrationTarget(options) {
214
226
  function readSourceFresh() {
215
227
  const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
216
228
  const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
217
- const state = readStateFromSqlite(home);
229
+ const state = readMigrationSourceStateFromSqlite(home);
218
230
  return Object.freeze({ schemaManifest, state });
219
231
  }
220
232
  /**
@@ -22,6 +22,7 @@
22
22
  * dropped, duplicated, or mutated record.
23
23
  */
24
24
  import { createHash } from "node:crypto";
25
+ import { existsSync, rmSync } from "node:fs";
25
26
  import { join } from "node:path";
26
27
  import Database from "better-sqlite3";
27
28
  import { mailboxTargetKey } from "../../coordination/workMailbox.js";
@@ -33,6 +34,50 @@ import { CURRENT_STORED_TASK_SCHEMA_VERSION } from "../taskStore.js";
33
34
  export const STAGED_DATABASE_FILENAME = "yui.db.staged";
34
35
  /** The committed database filename. */
35
36
  export const COMMITTED_DATABASE_FILENAME = "yui.db";
37
+ /** Disposable, schema-current snapshot used to reconstruct an older source. */
38
+ export const MIGRATION_SOURCE_DATABASE_FILENAME = "yui.db.migration-source";
39
+ /**
40
+ * Reconstruct a migration snapshot without advancing the authoritative DB.
41
+ *
42
+ * `VACUUM INTO` takes a consistent SQLite snapshot, including committed WAL
43
+ * state, without writing the source. Pending physical migrations are then
44
+ * applied only to that disposable copy before the current reader enumerates
45
+ * record families that may not exist in the older source schema.
46
+ */
47
+ export function readMigrationSourceStateFromSqlite(home) {
48
+ const sourcePath = join(home, COMMITTED_DATABASE_FILENAME);
49
+ const snapshotPath = join(home, MIGRATION_SOURCE_DATABASE_FILENAME);
50
+ if (existsSync(snapshotPath)) {
51
+ throw new Error(`Refusing to overwrite an existing SQLite migration-source snapshot: ${snapshotPath}. `
52
+ + "Discard it and retry.");
53
+ }
54
+ try {
55
+ const source = new Database(sourcePath, { readonly: true });
56
+ try {
57
+ source.prepare("VACUUM INTO ?").run(snapshotPath);
58
+ }
59
+ finally {
60
+ source.close();
61
+ }
62
+ // Physical migration 14 rewrites the WorkMailbox family while adding its
63
+ // current columns. Preserve the source rows before applying it so the
64
+ // logical record migration still sees and transforms v1 exactly once.
65
+ const sourceMailboxes = readWorkMailboxesFromSqlite(snapshotPath);
66
+ const stagedStore = new SqliteTaskStore(home, {
67
+ databaseFilename: MIGRATION_SOURCE_DATABASE_FILENAME,
68
+ migration: true
69
+ });
70
+ stagedStore.close();
71
+ const state = readStateFromSqlite(home, MIGRATION_SOURCE_DATABASE_FILENAME);
72
+ state.mailboxes = sourceMailboxes;
73
+ return state;
74
+ }
75
+ finally {
76
+ rmSync(snapshotPath, { force: true });
77
+ rmSync(`${snapshotPath}-wal`, { force: true });
78
+ rmSync(`${snapshotPath}-shm`, { force: true });
79
+ }
80
+ }
36
81
  /**
37
82
  * Preserve SQLite-owned durable state that intentionally sits outside the
38
83
  * state.json-shaped Task aggregate snapshot. Volatile coordination locks and
@@ -351,9 +396,6 @@ export function populateSqliteFromState(home, state, databaseFilename) {
351
396
  if (stored.leaderFailure !== null) {
352
397
  store.saveLeaderFailure(stored.leaderFailure);
353
398
  }
354
- if (stored.operatorNotification !== null) {
355
- store.saveOperatorNotification(stored.operatorNotification);
356
- }
357
399
  // Per-task ID high-water marks.
358
400
  for (const [kind, highWater] of Object.entries(stored.idHighWaterMarks)) {
359
401
  if (typeof highWater === "number" && Number.isFinite(highWater)) {
@@ -595,6 +637,20 @@ function readWorkMailboxRows(db) {
595
637
  const hasInputDelivery = db.prepare("PRAGMA table_info(mailboxes)").all().some(({ name }) => name === "input_delivery");
596
638
  return db.prepare(`SELECT target_kind, task_id, role_name, next_sequence, processing, pending${hasInputDelivery ? ", input_delivery" : ""} FROM mailboxes ORDER BY target_key`).all();
597
639
  }
640
+ function readWorkMailboxesFromSqlite(dbPath) {
641
+ const db = new Database(dbPath, { readonly: true });
642
+ try {
643
+ const mailboxes = {};
644
+ for (const row of readWorkMailboxRows(db)) {
645
+ const mailbox = rowToMailbox(row);
646
+ mailboxes[mailboxTargetKey(mailbox.target)] = mailbox;
647
+ }
648
+ return mailboxes;
649
+ }
650
+ finally {
651
+ db.close();
652
+ }
653
+ }
598
654
  /**
599
655
  * Compute per-family checksums from the SQLite database. The database is
600
656
  * opened read-only; this is the independent verification read.
@@ -692,8 +748,8 @@ export function verifySqliteChecksums(state, home, databaseFilename) {
692
748
  *
693
749
  * The database is opened read-only; this function never writes.
694
750
  */
695
- export function readStateFromSqlite(home) {
696
- const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
751
+ export function readStateFromSqlite(home, databaseFilename = COMMITTED_DATABASE_FILENAME) {
752
+ const dbPath = join(home, databaseFilename);
697
753
  const db = new Database(dbPath, { readonly: true });
698
754
  try {
699
755
  const manifest = readStorageSchemaManifest(home);
@@ -737,7 +793,7 @@ export function readStateFromSqlite(home) {
737
793
  const tasks = state.tasks;
738
794
  const taskRows = db.prepare("SELECT task_id, payload, brief FROM task_records").all();
739
795
  for (const row of taskRows) {
740
- tasks[row.task_id] = {
796
+ const aggregate = {
741
797
  schemaVersion: storedTaskVersion,
742
798
  task: JSON.parse(row.payload),
743
799
  brief: row.brief === null ? null : JSON.parse(row.brief),
@@ -761,12 +817,14 @@ export function readStateFromSqlite(home) {
761
817
  events: {},
762
818
  wakes: {},
763
819
  leaderFailure: null,
764
- operatorNotification: null,
765
820
  idHighWaterMarks: {},
766
821
  capabilityGrants: {},
767
822
  releaseWorkflows: {},
768
823
  publicationReferences: {}
769
824
  };
825
+ if (storedTaskVersion <= 17)
826
+ aggregate.operatorNotification = null;
827
+ tasks[row.task_id] = aggregate;
770
828
  }
771
829
  // Per-task record families, keyed by their state.json map keys.
772
830
  loadTaskPayloadMap(db, "task_roles", tasks, "roles", (record) => record.name);
@@ -808,7 +866,7 @@ export function readStateFromSqlite(home) {
808
866
  if (row.kind === "leader-failure") {
809
867
  stored.leaderFailure = JSON.parse(row.payload);
810
868
  }
811
- else {
869
+ else if (storedTaskVersion <= 17) {
812
870
  stored.operatorNotification = JSON.parse(row.payload);
813
871
  }
814
872
  }
@@ -4,6 +4,7 @@ import { deltaRecheckBlocksAcceptance } from "../review/reviewRound.js";
4
4
  import { isSemanticReviewRound } from "../review/reviewOutcomeClassifier.js";
5
5
  import { reviewFindingLedgerWriteFailedFromEvents } from "../review/reviewFindingLedger.js";
6
6
  import { resolveRecordedTaskFinalReviewContract } from "../review/taskFinalReviewContractRebind.js";
7
+ import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
7
8
  import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
8
9
  const ACTIVE_JOB_STATUSES = new Set([
9
10
  "queued",
@@ -22,7 +23,8 @@ export function projectCompletionReadiness(facts, options = {}) {
22
23
  const advisories = [];
23
24
  const { task } = facts;
24
25
  const findingsGate = options.findingsGate ?? true;
25
- const taskFinalReviewRequired = resolveRecordedTaskFinalReviewContract(task.id, facts.workItems, facts.reviewRounds, facts.taskFinalReviewContractEvents) !== undefined;
26
+ const taskFinalReviewContract = resolveRecordedTaskFinalReviewContract(task.id, facts.workItems, facts.reviewRounds, facts.taskFinalReviewContractEvents)?.effective;
27
+ const taskFinalReviewRequired = taskFinalReviewContract !== undefined;
26
28
  // A pending/running Task-final Review must be resumed or blocked first.
27
29
  for (const round of facts.reviewRounds) {
28
30
  if ((round.scope ?? "work-item") !== "task")
@@ -43,11 +45,13 @@ export function projectCompletionReadiness(facts, options = {}) {
43
45
  // escalations remain audit evidence, but a later completed full Review (or
44
46
  // accepted delta) supersedes them instead of blocking completion forever.
45
47
  const latestCompletedTaskReview = facts.reviewRounds
46
- .filter((round) => ((round.scope ?? "work-item") === "task" && isSemanticReviewRound(round, {
47
- listAgentRuns: () => facts.agentRuns,
48
- listReviewFindings: () => facts.reviewFindings,
49
- listEvents: () => facts.events
50
- })))
48
+ .filter((round) => ((round.scope ?? "work-item") === "task"
49
+ && (taskFinalReviewContract === undefined || sameTaskFinalReviewContract(round.taskFinalReviewContract, taskFinalReviewContract))
50
+ && isSemanticReviewRound(round, {
51
+ listAgentRuns: () => facts.agentRuns,
52
+ listReviewFindings: () => facts.reviewFindings,
53
+ listEvents: () => facts.events
54
+ })))
51
55
  .slice()
52
56
  .sort((left, right) => (left.createdAt.localeCompare(right.createdAt)
53
57
  || left.id.localeCompare(right.id, undefined, { numeric: true })))
@@ -1,10 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import { changeSetDeliverySettled, governingChangeSets } from "../integration/deliveryObligation.js";
3
- import { deltaRecheckBlocksAcceptance } from "../review/reviewRound.js";
4
- import { classifyReviewRoundOutcome, isSemanticReviewRound } from "../review/reviewOutcomeClassifier.js";
4
+ import { isAcceptedTaskReviewBaselineFromEvidence } from "../review/reviewAcceptance.js";
5
+ import { classifyReviewRoundOutcome } from "../review/reviewOutcomeClassifier.js";
5
6
  import { actionableExecutionLaneRecoveries } from "../execution/executionHealth.js";
6
7
  import { candidateConvergenceDisagreement, candidateConvergenceEvidenceSufficient, candidateConvergenceStageResultsValid } from "../execution/candidateConvergence.js";
7
8
  import { executionStageSpendClosed, routeExecutionStage } from "../execution/resourceBroker.js";
9
+ import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
8
10
  import { resolveRecordedTaskFinalReviewContract } from "../review/taskFinalReviewContractRebind.js";
9
11
  import { currentWorkItemCandidate, currentWorkItemExecutionGroup, governingWorkItemCandidate } from "../workItem/workItem.js";
10
12
  const OPEN_WORK_ITEM_STATUSES = new Set(["pending", "running", "awaiting_acceptance"]);
@@ -232,13 +234,15 @@ export function projectNextAction(facts) {
232
234
  judgmentRequired: `Leader must judge Candidate ${candidateReady.id}/${candidate?.id ?? "unknown"} against the Task objective, acceptance criteria, and delivery risk.`
233
235
  });
234
236
  }
235
- const activeWorkers = facts.activeRuns.filter((run) => run.roleName !== "leader");
236
- if (activeWorkers.length > 0) {
237
+ // AgentRun purpose owns routing. Review Runs remain attached to their exact
238
+ // ReviewRound branches below instead of being mistaken for Worker delivery.
239
+ const activeDelegatedExecutions = facts.activeRuns.filter((run) => (run.purpose === "execution" && run.roleName !== "leader"));
240
+ if (activeDelegatedExecutions.length > 0) {
237
241
  return buildAction(facts, {
238
242
  kind: "wait-for-owned-execution",
239
- reason: `${activeWorkers.length} delegated Run(s) are active; wait for their delivery.`,
240
- refs: activeWorkers.map((run) => ref("agent-run", run.id)),
241
- preconditions: activeWorkers.map((run) => ({ fact: `Delegated Run ${run.id} is active`, satisfied: true, ref: ref("agent-run", run.id) }))
243
+ reason: `${activeDelegatedExecutions.length} delegated execution Run(s) are active; wait for their delivery.`,
244
+ refs: activeDelegatedExecutions.map((run) => ref("agent-run", run.id)),
245
+ preconditions: activeDelegatedExecutions.map((run) => ({ fact: `Execution Run ${run.id} is active`, satisfied: true, ref: ref("agent-run", run.id) }))
242
246
  });
243
247
  }
244
248
  const failedWork = facts.workItems.find((item) => item.status === "failed");
@@ -386,7 +390,8 @@ export function projectNextAction(facts) {
386
390
  });
387
391
  }
388
392
  const finalReviewRequired = taskFinalReviewRequired(facts);
389
- const failedFinal = latestTaskFinalReview(facts.reviewRounds);
393
+ const finalReviewContract = taskFinalReviewContract(facts);
394
+ const failedFinal = latestTaskFinalReview(facts.reviewRounds, finalReviewContract);
390
395
  const failedFinalOutcome = failedFinal === undefined
391
396
  ? null
392
397
  : classifyReviewRoundOutcome(failedFinal, nextActionReviewOutcomeEvidence(facts));
@@ -414,11 +419,38 @@ export function projectNextAction(facts) {
414
419
  ]
415
420
  });
416
421
  }
422
+ if (finalReviewRequired
423
+ && failedFinal !== undefined
424
+ && failedFinalOutcome?.kind === "semantic"
425
+ && failedFinal.deltaRecheck?.disposition === "requires-full-review") {
426
+ return buildAction(facts, {
427
+ kind: "request-final-review",
428
+ reason: `Delta Recheck ${failedFinal.id} could not establish equivalence; Yui recorded the result and left the next Review action to the Leader.`,
429
+ refs: [ref("review-round", failedFinal.id)],
430
+ preconditions: [
431
+ { fact: "Current frozen head has accepting final Review evidence", satisfied: false }
432
+ ],
433
+ alternatives: [
434
+ {
435
+ kind: "request-full-review",
436
+ reason: "Request a full Review when independent evidence is still required.",
437
+ recommendedCommand: `yui task review request ${task.id} --role ${failedFinal.reviewerRoleName}`,
438
+ refs: [ref("review-round", failedFinal.id)]
439
+ },
440
+ {
441
+ kind: "continue-leader-work",
442
+ reason: "Inspect directly, change the candidate, or choose another Reviewer as Task risk requires.",
443
+ refs: [ref("review-round", failedFinal.id)]
444
+ }
445
+ ],
446
+ judgmentRequired: "Leader must choose full Review, another Reviewer, direct inspection, or more development; Core will not auto-escalate."
447
+ });
448
+ }
417
449
  if (finalReviewRequired
418
450
  && failedFinal !== undefined
419
451
  && failedFinalOutcome?.kind === "semantic"
420
452
  && ((failedFinal.checks ?? []).some(({ outcome }) => outcome === "failed")
421
- || deltaRecheckBlocksAcceptance(failedFinal))) {
453
+ || failedFinal.deltaRecheck?.disposition === "finding")) {
422
454
  return buildAction(facts, {
423
455
  kind: "route-review-findings",
424
456
  reason: `Task-final Review ${failedFinal.id} delivered semantic negative evidence; route its open findings into a repair wave on one frozen head.`,
@@ -429,7 +461,7 @@ export function projectNextAction(facts) {
429
461
  recommendedCommand: `yui task review finding repair-wave ${task.id} --create`
430
462
  });
431
463
  }
432
- const activeFinal = latestTaskFinalReview(facts.reviewRounds);
464
+ const activeFinal = latestTaskFinalReview(facts.reviewRounds, finalReviewContract);
433
465
  if (activeFinal !== undefined
434
466
  && (activeFinal.status === "pending" || activeFinal.status === "running")) {
435
467
  const reviewRef = ref("review-round", activeFinal.id);
@@ -475,12 +507,26 @@ export function projectNextAction(facts) {
475
507
  }
476
508
  return buildAction(facts, {
477
509
  kind: "wait-for-owned-execution",
478
- reason: `Reviewer Run ${reviewRun.id} is executing Task-final Review ${activeFinal.id}.`,
510
+ reason: `Reviewer Run ${reviewRun.id} is executing frozen Task-final Review ${activeFinal.id}; this Review does not globally pause Leader decisions on newer facts.`,
479
511
  refs: [reviewRef, ref("agent-run", reviewRun.id)],
480
512
  preconditions: [
481
513
  { fact: "Task-final ReviewRound is running", satisfied: true, ref: reviewRef },
482
514
  { fact: "Reviewer Run is active", satisfied: true, ref: ref("agent-run", reviewRun.id) }
483
- ]
515
+ ],
516
+ alternatives: [
517
+ {
518
+ kind: "continue-leader-work",
519
+ reason: "Process new user input or advance a later candidate while preserving this frozen Review.",
520
+ refs: [reviewRef]
521
+ },
522
+ {
523
+ kind: "request-another-reviewer",
524
+ reason: "Use another available Reviewer slot when an independent view adds value.",
525
+ recommendedCommand: `yui task review request ${task.id} --role <other-reviewer-role>`,
526
+ refs: [reviewRef]
527
+ }
528
+ ],
529
+ judgmentRequired: "Leader decides whether the current facts justify waiting, continuing development, direct review, or another Reviewer."
484
530
  });
485
531
  }
486
532
  if (activeFinal.reviewerRunId !== undefined) {
@@ -530,11 +576,16 @@ export function projectNextAction(facts) {
530
576
  }
531
577
  const finalReviewOptional = !finalReviewRequired
532
578
  && !hasValidFinalReview(facts);
533
- const finalReviewAlternative = finalReviewOptional && facts.reviewConfig !== null
579
+ const unresolvedDelta = failedFinal?.deltaRecheck?.disposition === "requires-full-review";
580
+ const optionalReviewer = facts.reviewConfig?.roleName
581
+ ?? (unresolvedDelta ? failedFinal?.reviewerRoleName : undefined);
582
+ const finalReviewAlternative = finalReviewOptional && optionalReviewer !== undefined
534
583
  ? [{
535
584
  kind: "request-final-review",
536
- reason: "Request an independent Task-final Review when the Leader wants extra assurance before completion.",
537
- recommendedCommand: `yui task review request ${task.id} --role ${facts.reviewConfig.roleName}`,
585
+ reason: unresolvedDelta
586
+ ? `Delta Recheck ${failedFinal.id} returned requires-full-review; request full independent evidence when the Leader judges it necessary.`
587
+ : "Request an independent Task-final Review when the Leader wants extra assurance before completion.",
588
+ recommendedCommand: `yui task review request ${task.id} --role ${optionalReviewer}`,
538
589
  refs: [ref("task", task.id)]
539
590
  }]
540
591
  : [];
@@ -564,7 +615,9 @@ export function projectNextAction(facts) {
564
615
  ...(!finalReviewOptional
565
616
  ? {}
566
617
  : {
567
- judgmentRequired: "Leader must decide whether the frozen Task result is safe to complete or needs one optional Task-final Review."
618
+ judgmentRequired: unresolvedDelta
619
+ ? "Leader must route the non-accepting Delta result: full Review, another Reviewer, direct inspection, more development, or completion when policy permits."
620
+ : "Leader must decide whether the frozen Task result is safe to complete or needs one optional Task-final Review."
568
621
  }),
569
622
  recommendedCommand: `yui task complete ${task.id} --summary-file -`
570
623
  });
@@ -1013,10 +1066,11 @@ function latestFailedReviewFor(rounds, workItemId) {
1013
1066
  .reverse()
1014
1067
  .find((round) => round.workItemId === workItemId && round.status === "failed");
1015
1068
  }
1016
- function latestTaskFinalReview(rounds) {
1069
+ function latestTaskFinalReview(rounds, contract) {
1017
1070
  return [...rounds]
1018
1071
  .reverse()
1019
- .find((round) => (round.scope ?? "work-item") === "task");
1072
+ .find((round) => ((round.scope ?? "work-item") === "task"
1073
+ && (contract === undefined || sameTaskFinalReviewContract(round.taskFinalReviewContract, contract))));
1020
1074
  }
1021
1075
  function needsChangeSetCapture(facts, item) {
1022
1076
  if (item.status !== "completed")
@@ -1036,13 +1090,17 @@ function needsChangeSetCapture(facts, item) {
1036
1090
  return candidate.workspace !== undefined || candidate.gitSnapshot !== undefined;
1037
1091
  }
1038
1092
  function hasValidFinalReview(facts) {
1039
- const final = latestTaskFinalReview(facts.reviewRounds);
1040
- if (final === undefined
1041
- || !isSemanticReviewRound(final, nextActionReviewOutcomeEvidence(facts)))
1042
- return false;
1043
- if ((final.checks ?? []).some(({ outcome }) => outcome === "failed")
1044
- || deltaRecheckBlocksAcceptance(final))
1093
+ const contract = taskFinalReviewContract(facts);
1094
+ const final = [...facts.reviewRounds]
1095
+ .reverse()
1096
+ .find((round) => ((round.scope ?? "work-item") === "task"
1097
+ && (contract === undefined || sameTaskFinalReviewContract(round.taskFinalReviewContract, contract))));
1098
+ if (final === undefined || !isAcceptedTaskReviewBaselineFromEvidence(final, nextActionReviewOutcomeEvidence(facts)))
1045
1099
  return false;
1100
+ if (facts.currentTaskReviewCandidate !== undefined) {
1101
+ return facts.currentTaskReviewCandidate !== null
1102
+ && isDeepStrictEqual(final.taskCandidate, facts.currentTaskReviewCandidate);
1103
+ }
1046
1104
  const reviewedCommits = new Set((final.taskCandidate?.projects ?? []).map((project) => project.commit));
1047
1105
  if (reviewedCommits.size === 0)
1048
1106
  return false;
@@ -246,7 +246,7 @@ export class SqliteTelemetryStore {
246
246
  db.pragma("foreign_keys = ON");
247
247
  db.pragma("busy_timeout = 5000");
248
248
  db.pragma("wal_autocheckpoint = 1000");
249
- migrateSqliteSchema(db);
249
+ migrateSqliteSchema(db, { mode: "validate" });
250
250
  this.#db = db;
251
251
  return db;
252
252
  }
@@ -114,9 +114,9 @@ export function executionBand(projection, t, locale) {
114
114
  head.append(pill(t, "exec.status", projection.status));
115
115
  head.append(node("span", "exec-band-owner",
116
116
  t("exec.owner." + projection.owner) + " · " + t("exec.action." + projection.action)));
117
- if (projection.activeExecutorCount > 0) {
117
+ if (projection.activeRuns && projection.activeRuns.length > 0) {
118
118
  head.append(node("span", "exec-band-executors",
119
- projection.activeExecutorCount + " " + t("exec.executors")));
119
+ projection.activeRuns.length + " " + t("exec.executors")));
120
120
  }
121
121
  if (projection.monitoring === "stopped") {
122
122
  head.append(node("span", "exec-band-stopped", t("exec.monitoring.stopped")));
@@ -242,7 +242,6 @@ export function observabilityMetricCard(observability, t) {
242
242
  card.append(metricTile(t("detail.wallClock"), cost.wallClockSeconds + "s"));
243
243
  card.append(metricTile(t("detail.ready"), (observability.dag?.readyIds || []).length, { hot: true }));
244
244
  card.append(metricTile(t("detail.contextSnapshots"), context.snapshotCount));
245
- card.append(metricTile(t("detail.contextPeak"), context.observedInputPeakTokens));
246
245
  const contextMeta = node("div", "record-meta observability-context-meta");
247
246
  contextMeta.append(node("span", "", t("detail.contextBytes") + " · "
248
247
  + (context.totalBytes === null ? t("detail.partial") : context.totalBytes + " B")));
@@ -775,6 +774,17 @@ export function roleCard(role, task, t, locale, actions) {
775
774
  card.append(eff);
776
775
  }
777
776
 
777
+ if (role.sessionTokens) {
778
+ const tokenMeta = node("div", "record-meta");
779
+ const cumulative = role.sessionTokens.cumulativeTotal || {};
780
+ const maximum = role.sessionTokens.maximumRequestInput || {};
781
+ tokenMeta.append(node("span", "", t("detail.sessionTotalTokens") + " · "
782
+ + (cumulative.status === "observed" ? cumulative.totalTokens : t("detail.unobserved"))));
783
+ tokenMeta.append(node("span", "", t("detail.maximumRequestInputTokens") + " · "
784
+ + (maximum.status === "observed" ? maximum.inputTokens : t("detail.unobserved"))));
785
+ card.append(tokenMeta);
786
+ }
787
+
778
788
  if (role.description) card.append(richText(null, role.description, t, { muted: true }));
779
789
 
780
790
  const cols = node("div", "record-cols");
@@ -61,17 +61,19 @@ const messages = {
61
61
  "detail.stageAttempt": "Attempt",
62
62
  "detail.cost": "Cost",
63
63
  "detail.tokens": "Tokens",
64
+ "detail.sessionTotalTokens": "Session total tokens",
65
+ "detail.maximumRequestInputTokens": "Maximum request input",
64
66
  "detail.toolCalls": "Tool calls",
65
67
  "detail.wallClock": "Wall clock",
66
68
  "detail.ready": "Ready",
67
69
  "detail.quorum": "Quorum",
68
70
  "detail.contextSnapshots": "Context snapshots",
69
- "detail.contextPeak": "Context peak",
70
71
  "detail.contextBytes": "Context bytes",
71
72
  "detail.compression": "Compression",
72
73
  "detail.marginalValue": "Marginal value",
73
74
  "detail.partial": "partial",
74
75
  "detail.unavailable": "unavailable",
76
+ "detail.unobserved": "unobserved",
75
77
  "detail.openFindings": "Open findings",
76
78
  "detail.desired": "Desired launch",
77
79
  "detail.desiredAgent": "Desired Agent",
@@ -397,17 +399,19 @@ const messages = {
397
399
  "detail.stageAttempt": "阶段尝试",
398
400
  "detail.cost": "成本",
399
401
  "detail.tokens": "Token",
402
+ "detail.sessionTotalTokens": "Session 累计 Token",
403
+ "detail.maximumRequestInputTokens": "单次请求最大输入",
400
404
  "detail.toolCalls": "工具调用",
401
405
  "detail.wallClock": "墙钟时间",
402
406
  "detail.ready": "可就绪",
403
407
  "detail.quorum": "法定数量",
404
408
  "detail.contextSnapshots": "上下文快照",
405
- "detail.contextPeak": "上下文峰值",
406
409
  "detail.contextBytes": "上下文字节",
407
410
  "detail.compression": "压缩",
408
411
  "detail.marginalValue": "边际价值",
409
412
  "detail.partial": "部分可见",
410
413
  "detail.unavailable": "不可用",
414
+ "detail.unobserved": "未观测",
411
415
  "detail.openFindings": "未解决发现",
412
416
  "detail.desired": "期望启动配置",
413
417
  "detail.desiredAgent": "期望 Agent",
@@ -7,6 +7,7 @@ import { classifyRuntimeHealth, projectRuntimeTaskEvents } from "../runtime/runt
7
7
  import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
8
8
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
9
9
  import { resolveRuntimeHealth } from "../config/yuiConfig.js";
10
+ import { projectSessionTokenMetrics, resolveSessionTokenIdentity } from "../runtime/sessionTokenMetrics.js";
10
11
  export function buildWebDashboardSnapshot(store, now = new Date()) {
11
12
  return store.transaction((reader) => {
12
13
  const statusCounts = {
@@ -97,6 +98,9 @@ export function buildWebTaskDetail(store, taskId, now = new Date()) {
97
98
  const effectiveLaunch = activeRun?.effective ?? activeSession?.effective ?? null;
98
99
  return {
99
100
  ...role,
101
+ sessionTokens: projectSessionTokenMetrics(events, resolveSessionTokenIdentity(activeSession === undefined
102
+ ? null
103
+ : { taskId, roleName: role.name, ...activeSession })),
100
104
  effectiveLaunch,
101
105
  effectiveLaunchSource: activeRun === undefined
102
106
  ? activeSession === undefined ? null : "session"