@zq-silk/yui 0.6.2 → 0.6.4

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 (63) hide show
  1. package/ARCHITECTURE.md +28 -4
  2. package/README.md +60 -97
  3. package/dist/agent/argumentPolicy.js +1 -1
  4. package/dist/agent/managedRuntimeEnvironment.js +1 -0
  5. package/dist/cli/commandCatalog.js +19 -9
  6. package/dist/cli/interactionPolicy.js +4 -2
  7. package/dist/cli.js +77 -32
  8. package/dist/commands/taskCommands.js +46 -11
  9. package/dist/commands/taskContextCommand.js +1 -1
  10. package/dist/commands/taskRoleRuntimeStatus.js +170 -10
  11. package/dist/controller/agentRuntimeObserver.js +210 -0
  12. package/dist/controller/clientRuntime.js +3 -21
  13. package/dist/controller/controller.js +47 -7
  14. package/dist/controller/fileSchedulerStoreAdapter.js +522 -388
  15. package/dist/controller/runtime.js +9 -3
  16. package/dist/controller/runtimeEventInbox.js +49 -295
  17. package/dist/controller/runtimeEventProcessor.js +184 -321
  18. package/dist/controller/runtimeHookRunFence.js +226 -0
  19. package/dist/controller/runtimeLaunchCoordinator.js +91 -26
  20. package/dist/controller/runtimeObservationHook.js +112 -0
  21. package/dist/core/controllerServer.js +5 -0
  22. package/dist/executor/agentAdapter.js +18 -3
  23. package/dist/executor/fileRoleLaunchPlanner.js +64 -15
  24. package/dist/executor/managedClaudeRunner.js +121 -0
  25. package/dist/observability/executionAudit.js +6 -3
  26. package/dist/repository/taskWorkspacePreparer.js +1 -4
  27. package/dist/run/providerRetryConfig.js +8 -3
  28. package/dist/runtime/agentDriver.js +229 -0
  29. package/dist/runtime/agentDriverObservation.js +57 -0
  30. package/dist/runtime/builtinAgentDrivers.js +235 -0
  31. package/dist/runtime/builtinTranscriptObserver.js +290 -0
  32. package/dist/runtime/builtinTranscriptUsage.js +97 -0
  33. package/dist/runtime/exactControlPlane.js +2 -2
  34. package/dist/runtime/index.js +1 -1
  35. package/dist/runtime/ports.js +12 -1
  36. package/dist/runtime/runtimeObservation.js +297 -0
  37. package/dist/runtime/runtimeProjection.js +277 -0
  38. package/dist/runtime/sessionTerminationGuard.js +78 -22
  39. package/dist/runtime/tmuxAdapters.js +35 -0
  40. package/dist/scheduler/activeRoleRunDelivery.js +28 -13
  41. package/dist/scheduler/leaderWakeupProcessor.js +21 -2
  42. package/dist/scheduler/roleRunLiveness.js +2 -2
  43. package/dist/scheduler/roleRunStall.js +62 -114
  44. package/dist/storage/migration/productionRegistry.js +85 -0
  45. package/dist/storage/sqliteStore.js +3 -3
  46. package/dist/storage/storageVersions.js +1 -1
  47. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +2 -1
  48. package/dist/storage/upgrade/sqliteStateMigration.js +123 -0
  49. package/dist/telemetry/sqliteTelemetryStore.js +0 -28
  50. package/dist/telemetry/telemetryCompaction.js +1 -0
  51. package/dist/telemetry/telemetryConfig.js +4 -5
  52. package/dist/tmux/tmuxManager.js +136 -22
  53. package/dist/web/assets/client/view.js +1 -1
  54. package/dist/web/tmuxWebTerminal.js +17 -12
  55. package/dist/web/webSnapshot.js +1 -1
  56. package/dist/worktree/managedWorkspace.js +14 -0
  57. package/i18n/README.zh-CN.md +12 -7
  58. package/package.json +1 -1
  59. package/dist/controller/claudeLifecycleHook.js +0 -203
  60. package/dist/controller/codexLifecycleHook.js +0 -108
  61. package/dist/controller/providerHookRunFence.js +0 -156
  62. package/dist/lifecycle/providerLifecycleMapping.js +0 -190
  63. package/dist/telemetry/telemetryRouter.js +0 -32
@@ -5,22 +5,17 @@ import { selectedSchedulerRoles, selectedSchedulerTasks } from "./ports.js";
5
5
  * is simply slow keeps its structured checkpoint fresh and never crosses it.
6
6
  */
7
7
  export const DEFAULT_STALL_WINDOW_MS = 30 * 60_000;
8
- /** Cheap execution-stall candidate filter; the real threshold remains 30m. */
9
- export const DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS = 10 * 60_000;
10
- /** Resource activity may postpone attention only within this semantic gap. */
11
- export const DEFAULT_RESOURCE_ONLY_SEMANTIC_GAP_MS = 90 * 60_000;
8
+ /** Cheap workflow-stall candidate filter; the real threshold remains 30m. */
9
+ export const DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS = 10 * 60_000;
12
10
  export const RUN_PROGRESS_EVENT = "run.progress";
13
11
  export const RUN_STALLED_EVENT = "run.stalled";
14
12
  export const RUN_RECOVERED_EVENT = "run.recovered";
15
- /** Durable one-shot advisory resource evidence; never a progress fact. */
16
- export const RUN_RESOURCE_SUPPRESSED_EVENT = "run.resource-suppressed";
17
13
  /** Structured, non-Message recovery evidence written by an explicit Leader. */
18
14
  export const RUN_RECOVERY_REQUESTED_EVENT = "run.recovery-requested";
19
15
  export const RUN_RECOVERY_APPLIED_EVENT = "run.recovery-applied";
20
- /** Event types that count as Run activity for the durable progress clock. */
16
+ /** Workflow-semantic events that count for the durable progress clock. */
21
17
  const ACTIVITY_EVENT_TYPES = new Set([
22
18
  RUN_PROGRESS_EVENT,
23
- "runtime.provider-turn-progress",
24
19
  "message.sent",
25
20
  "input.answered",
26
21
  "input.auto-answered",
@@ -48,10 +43,9 @@ export function clearMatchingLeaderStallAttention(store, taskId, runId) {
48
43
  return true;
49
44
  }
50
45
  /**
51
- * One pure projection used by every Role. Resource activity can suppress a
52
- * false positive only together with a live host and matching native Session
53
- * or launch generation;
54
- * it never changes the durable progress clock or authorizes recovery.
46
+ * One pure projection used by every Role. Resource activity is retained only
47
+ * as exact-generation diagnostic evidence; it never changes the durable
48
+ * progress clock, suppresses workflow attention, or authorizes recovery.
55
49
  */
56
50
  export function projectRoleRunHealth(input) {
57
51
  const windowMs = input.windowMs ?? DEFAULT_STALL_WINDOW_MS;
@@ -69,7 +63,7 @@ export function projectRoleRunHealth(input) {
69
63
  const candidate = Number.isFinite(candidateAge)
70
64
  && (input.deliveredAt === undefined
71
65
  ? candidateAge >= windowMs
72
- : candidateAge >= DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS);
66
+ : candidateAge >= DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS);
73
67
  const providerAcceptance = input.providerAcceptance
74
68
  ?? (input.deliveredAt === undefined ? "ambiguous" : "accepted");
75
69
  const hostLiveness = input.hostLiveness;
@@ -99,14 +93,13 @@ export function projectRoleRunHealth(input) {
99
93
  // evidence is still actionable in that case. Only an explicit stopped or
100
94
  // broken Session blocks the projection; identity mismatches are fenced by
101
95
  // reconcileStalledRoleRuns below before this projection is routed.
102
- const executionStall = candidate
96
+ const workflowStall = candidate
103
97
  && evaluation.stalled
104
98
  && hostLiveness === "present"
105
99
  && nativeSession !== "stopped"
106
100
  && nativeSession !== "broken"
107
- && providerAcceptance !== "ambiguous"
108
- && !resourceActivity;
109
- const stalled = executionStall
101
+ && providerAcceptance !== "ambiguous";
102
+ const stalled = workflowStall
110
103
  && !waitingUser
111
104
  && !waitingOnWorkers;
112
105
  const classification = waitingUser
@@ -151,9 +144,9 @@ export function evaluateRoleRunStall(input) {
151
144
  }
152
145
  /**
153
146
  * Latest durable progress timestamp for an active Run. Provider acceptance
154
- * (deliveredAt), explicit structured checkpoints, and semantic Run-scoped
155
- * activity all count as progress; bookkeeping-only Run/Session timestamps are
156
- * intentionally excluded. CPU/memory are never inputs here.
147
+ * (deliveredAt), explicit workflow checkpoints, and semantic domain activity
148
+ * count as progress. Provider operations, tokens, CPU/memory and bookkeeping
149
+ * timestamps are intentionally excluded.
157
150
  */
158
151
  export function latestDurableProgressAt(input) {
159
152
  const candidates = [
@@ -170,15 +163,10 @@ export function latestDurableProgressAt(input) {
170
163
  export function latestRunProgressAt(events, runId) {
171
164
  let latest;
172
165
  for (const event of events) {
173
- if (event.type !== RUN_PROGRESS_EVENT
174
- && event.type !== "runtime.provider-turn-progress")
166
+ if (event.type !== RUN_PROGRESS_EVENT)
175
167
  continue;
176
168
  if (event.payload.runId !== runId)
177
169
  continue;
178
- if (event.type === "runtime.provider-turn-progress"
179
- && (typeof event.payload.progressAt !== "string"
180
- || !Number.isFinite(Date.parse(event.payload.progressAt))))
181
- continue;
182
170
  const progressAt = typeof event.payload.progressAt === "string"
183
171
  && Number.isFinite(Date.parse(event.payload.progressAt))
184
172
  ? event.payload.progressAt
@@ -320,12 +308,7 @@ export function latestRunActivityAt(events, runId) {
320
308
  || event.type === RUN_STALLED_EVENT
321
309
  || event.type === RUN_RECOVERED_EVENT)
322
310
  continue;
323
- if (event.type === "runtime.provider-turn-progress"
324
- && (typeof event.payload.progressAt !== "string"
325
- || !Number.isFinite(Date.parse(event.payload.progressAt))))
326
- continue;
327
- const activityAt = (event.type === RUN_PROGRESS_EVENT
328
- || event.type === "runtime.provider-turn-progress")
311
+ const activityAt = event.type === RUN_PROGRESS_EVENT
329
312
  && typeof event.payload.progressAt === "string"
330
313
  && Number.isFinite(Date.parse(event.payload.progressAt))
331
314
  ? event.payload.progressAt
@@ -386,13 +369,10 @@ export function foldRunProgressFacts(events) {
386
369
  byRun.set(runId, facts);
387
370
  }
388
371
  const type = event.type;
389
- const isProgress = type === RUN_PROGRESS_EVENT
390
- || type === "runtime.provider-turn-progress";
372
+ const isProgress = type === RUN_PROGRESS_EVENT;
391
373
  if (isProgress) {
392
374
  const validProgress = typeof event.payload.progressAt === "string"
393
375
  && Number.isFinite(Date.parse(event.payload.progressAt));
394
- if (type === "runtime.provider-turn-progress" && !validProgress)
395
- continue;
396
376
  const progressAt = validProgress
397
377
  ? event.payload.progressAt
398
378
  : event.createdAt;
@@ -400,9 +380,7 @@ export function foldRunProgressFacts(events) {
400
380
  || Date.parse(progressAt) > Date.parse(facts.latestCheckpointAt)) {
401
381
  facts.latestCheckpointAt = progressAt;
402
382
  }
403
- // Both progress event types are activity events; the stall/recovered
404
- // exclusion in latestRunActivityAt is structural here because a progress
405
- // event can never be either type.
383
+ // Workflow checkpoints are also semantic activity.
406
384
  if (facts.latestActivityAt === undefined
407
385
  || Date.parse(progressAt) > Date.parse(facts.latestActivityAt)) {
408
386
  facts.latestActivityAt = progressAt;
@@ -444,34 +422,59 @@ function yieldEventLoop() {
444
422
  * projection the runtime-health view reads to surface needs-attention.
445
423
  */
446
424
  export function isRoleRunStalled(events, runId) {
447
- const stalledAt = latestRunEventTime(events, RUN_STALLED_EVENT, runId);
448
- if (stalledAt === undefined)
425
+ let stalled;
426
+ let recoveredAt;
427
+ let progressAt;
428
+ for (const event of events) {
429
+ if (event.payload.runId !== runId)
430
+ continue;
431
+ if (event.type === RUN_STALLED_EVENT) {
432
+ if (stalled === undefined
433
+ || Date.parse(event.createdAt) > Date.parse(stalled.createdAt)) {
434
+ stalled = event;
435
+ }
436
+ continue;
437
+ }
438
+ if (event.type === RUN_RECOVERED_EVENT) {
439
+ if (recoveredAt === undefined
440
+ || Date.parse(event.createdAt) > Date.parse(recoveredAt)) {
441
+ recoveredAt = event.createdAt;
442
+ }
443
+ continue;
444
+ }
445
+ if (event.type !== RUN_PROGRESS_EVENT)
446
+ continue;
447
+ const candidate = typeof event.payload.progressAt === "string"
448
+ && Number.isFinite(Date.parse(event.payload.progressAt))
449
+ ? event.payload.progressAt
450
+ : event.createdAt;
451
+ if (progressAt === undefined || Date.parse(candidate) > Date.parse(progressAt)) {
452
+ progressAt = candidate;
453
+ }
454
+ }
455
+ if (stalled === undefined)
449
456
  return false;
450
- const stalled = [...events]
451
- .filter((event) => event.type === RUN_STALLED_EVENT && event.payload.runId === runId)
452
- .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0];
453
- const stalledProgressAt = typeof stalled?.payload.progressAt === "string"
457
+ const stalledAt = stalled.createdAt;
458
+ const stalledProgressAt = typeof stalled.payload.progressAt === "string"
454
459
  && Number.isFinite(Date.parse(stalled.payload.progressAt))
455
460
  ? stalled.payload.progressAt
456
461
  : stalledAt;
457
- const recoveredAt = latestRunEventTime(events, RUN_RECOVERED_EVENT, runId);
458
462
  if (recoveredAt !== undefined && Date.parse(recoveredAt) > Date.parse(stalledAt)) {
459
463
  return false;
460
464
  }
461
- const progressAt = latestRunProgressAt(events, runId);
462
465
  return progressAt === undefined || Date.parse(progressAt) <= Date.parse(stalledProgressAt);
463
466
  }
464
467
  /**
465
468
  * Low-frequency health pass for active Task Role Runs. An unaccepted Run is
466
469
  * watched as delivery-stalled after the reasonable delivery window; an
467
- * accepted Run enters the execution-stall candidate scan after ten minutes,
470
+ * accepted Run enters the workflow-stall candidate scan after ten minutes,
468
471
  * while the actual no-progress threshold remains thirty minutes.
469
472
  * Leader Runs are only persisted when classification reaches truly-stalled —
470
473
  * healthy downstream work, open user input, and recent own progress remain
471
474
  * structured waiting/working facts. No branch sends terminal bytes, retries,
472
475
  * replaces a Session, or changes Run status.
473
476
  */
474
- export async function reconcileStalledRoleRuns(store, delivery, now, selection, windowMs = DEFAULT_STALL_WINDOW_MS, liveStatuses, resourceEvidence, resourceSuppressionKeys) {
477
+ export async function reconcileStalledRoleRuns(store, delivery, now, selection, windowMs = DEFAULT_STALL_WINDOW_MS, liveStatuses, resourceEvidence) {
475
478
  // Dirty mailbox passes are intentionally not a second scheduler. Full
476
479
  // reconcile owns the all-active-Run scan; dirty passes may still route the
477
480
  // existing mailbox work without manufacturing another episode.
@@ -655,15 +658,13 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
655
658
  : { launchId: candidate.session.launchId })
656
659
  };
657
660
  const resourceSnapshot = resourceForRun(resourceEvidence, candidate.task.id, candidate.role.name, candidate.run.id);
658
- const resourceCanSuppress = live === "present"
661
+ const resourceIsCurrent = live === "present"
659
662
  && sessionMatchesRun
660
663
  && sessionUsable
661
664
  && expectedResourceIdentity !== undefined
662
665
  && resourceEvidenceMatchesCurrentRun(resourceSnapshot, expectedResourceIdentity, progressAt)
663
666
  && resourceEvidenceIsFresh(resourceSnapshot, now, windowMs);
664
- const resource = resourceCanSuppress
665
- ? await consumeResourceEvidence(store, resourceEvidence, candidate.task.id, candidate.role.name, candidate.run.id, progressAt, expectedResourceIdentity, resourceSuppressionKeys, now)
666
- : undefined;
667
+ const resource = resourceIsCurrent ? resourceSnapshot : undefined;
667
668
  const health = projectRoleRunHealth({
668
669
  progressAt,
669
670
  createdAt: candidate.run.createdAt,
@@ -682,8 +683,7 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
682
683
  });
683
684
  // Delivery-stalled Runs retain the existing delivery clock and immediate
684
685
  // provider-uncertainty path. Accepted execution Runs use the shared
685
- // projection, including its exact Session/host and advisory resource
686
- // conditions.
686
+ // projection. Exact Session/host resource evidence remains diagnostic.
687
687
  const resourceActivity = health.resourceActivity;
688
688
  const stalled = candidate.run.deliveredAt === undefined
689
689
  ? evaluation.stalled && live === "present"
@@ -693,9 +693,8 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
693
693
  live,
694
694
  progressAt,
695
695
  idleMs: evaluation.idleMs,
696
- // Resource activity is advisory: with a live, matching native Session it
697
- // keeps a long inference/remote-IO Run in the working projection, but it
698
- // never advances progress or creates a recovered event by itself.
696
+ // Resource activity is advisory and never advances workflow progress or
697
+ // suppresses a workflow-not-progressing episode.
699
698
  stalled,
700
699
  resourceActivity,
701
700
  evidence: [
@@ -728,7 +727,7 @@ export async function reconcileStalledRoleRuns(store, delivery, now, selection,
728
727
  }
729
728
  const kind = candidate.run.deliveredAt === undefined
730
729
  ? "delivery-stalled"
731
- : "execution-stalled";
730
+ : "workflow-not-progressing";
732
731
  const classification = candidate.role.name === "leader"
733
732
  ? classifyLeaderStall(store, candidate.task.id, observed, now, windowMs)
734
733
  : "truly-stalled";
@@ -806,10 +805,10 @@ function isStallCandidate(run, now, windowMs) {
806
805
  if (!Number.isFinite(ageMs))
807
806
  return false;
808
807
  // Undelivered Runs are watched for a delivery stall on the same reasonable
809
- // window, but they never enter execution-stall candidate filtering.
808
+ // window, but they never enter workflow-stall candidate filtering.
810
809
  return run.deliveredAt === undefined
811
810
  ? ageMs >= windowMs
812
- : ageMs >= DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS;
811
+ : ageMs >= DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS;
813
812
  }
814
813
  function classifyLeaderStall(store, taskId, observed, now, windowMs) {
815
814
  if (store.hasOpenInputRequest(taskId))
@@ -850,7 +849,6 @@ function classifyLeaderStall(store, taskId, observed, now, windowMs) {
850
849
  }
851
850
  const LEADER_ACTION_PROGRESS_TYPES = new Set([
852
851
  RUN_PROGRESS_EVENT,
853
- "runtime.provider-turn-progress",
854
852
  "input.answered",
855
853
  "input.auto-answered",
856
854
  "input.cancelled",
@@ -884,8 +882,7 @@ function latestLeaderActionProgressAt(store, taskId, runId, startedAt, batch, no
884
882
  || createdMs < startedMs
885
883
  || createdMs > now.getTime())
886
884
  continue;
887
- const value = (event.type === RUN_PROGRESS_EVENT
888
- || event.type === "runtime.provider-turn-progress")
885
+ const value = event.type === RUN_PROGRESS_EVENT
889
886
  && typeof event.payload.progressAt === "string"
890
887
  && Number.isFinite(Date.parse(event.payload.progressAt))
891
888
  ? event.payload.progressAt
@@ -996,55 +993,6 @@ function resourceEvidenceMatchesCurrentRun(resource, expected, progressAt) {
996
993
  function hasResourceIdentityText(value) {
997
994
  return typeof value === "string" && value.trim().length > 0;
998
995
  }
999
- /** Persist the first advisory sample and keep it bounded by the semantic gap. */
1000
- async function consumeResourceEvidence(store, snapshot, taskId, roleName, runId, progressAt, expectedIdentity, suppressionKeys, now) {
1001
- const resource = resourceForRun(snapshot, taskId, roleName, runId);
1002
- if (resource === undefined)
1003
- return undefined;
1004
- if (!resourceEvidenceMatchesCurrentRun(resource, expectedIdentity, progressAt)) {
1005
- return resource;
1006
- }
1007
- if (resource.active !== true || resource.changed !== true)
1008
- return resource;
1009
- const key = `${taskId}\0${roleName}\0${runId}\0${progressAt}`;
1010
- if (store.recordRoleRunResourceSuppression !== undefined) {
1011
- const persisted = store.recordRoleRunResourceSuppression({
1012
- taskId,
1013
- roleName,
1014
- runId,
1015
- agentId: expectedIdentity.agentId,
1016
- adapterId: expectedIdentity.adapterId,
1017
- ...(expectedIdentity.nativeSessionId === undefined
1018
- ? {}
1019
- : { nativeSessionId: expectedIdentity.nativeSessionId }),
1020
- ...(expectedIdentity.launchId === undefined
1021
- ? {}
1022
- : { launchId: expectedIdentity.launchId }),
1023
- progressAt,
1024
- observedAt: resource.observedAt,
1025
- now
1026
- });
1027
- if (persisted === "recorded" || persisted === "already-recorded") {
1028
- return resourceFallsWithinSemanticGap(progressAt, now)
1029
- ? resource
1030
- : { ...resource, active: false, changed: false };
1031
- }
1032
- // A concurrent Run/session change invalidates this sample. Do not let the
1033
- // stale changed bit suppress the current Run's attention episode.
1034
- return { ...resource, active: false, changed: false };
1035
- }
1036
- if (suppressionKeys === undefined || !suppressionKeys.has(key)) {
1037
- suppressionKeys?.add(key);
1038
- return resource;
1039
- }
1040
- return { ...resource, active: false, changed: false };
1041
- }
1042
- function resourceFallsWithinSemanticGap(progressAt, now) {
1043
- const progressMs = Date.parse(progressAt);
1044
- return Number.isFinite(progressMs)
1045
- && progressMs <= now.getTime()
1046
- && now.getTime() - progressMs < DEFAULT_RESOURCE_ONLY_SEMANTIC_GAP_MS;
1047
- }
1048
996
  function resourceEvidenceIsFresh(evidence, now, windowMs) {
1049
997
  if (evidence === undefined)
1050
998
  return false;
@@ -1053,5 +1001,5 @@ function resourceEvidenceIsFresh(evidence, now, windowMs) {
1053
1001
  return false;
1054
1002
  // A sample from an earlier scheduler window is not a current health signal.
1055
1003
  return observedAt <= now.getTime()
1056
- && now.getTime() - observedAt < Math.max(windowMs, DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS);
1004
+ && now.getTime() - observedAt < Math.max(windowMs, DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS);
1057
1005
  }
@@ -8,6 +8,8 @@ const FINAL_REVIEW_AGGREGATE_FROM_VERSION = 16;
8
8
  const FINAL_REVIEW_AGGREGATE_TO_VERSION = 17;
9
9
  const HOME_IDENTITY_AGGREGATE_FROM_VERSION = 17;
10
10
  const HOME_IDENTITY_AGGREGATE_TO_VERSION = 18;
11
+ const RUNTIME_OBSERVATION_AGGREGATE_FROM_VERSION = 18;
12
+ const RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION = 19;
11
13
  const SQLITE_LAYOUT_FROM_VERSION = 6;
12
14
  const SQLITE_LAYOUT_TO_VERSION = 7;
13
15
  const PROJECT_FROM_VERSION = 2;
@@ -87,6 +89,14 @@ export function createProductionStorageRegistry() {
87
89
  preconditions: requireAggregateV17Snapshot,
88
90
  transform: migrateAggregateV17ToV18,
89
91
  declaredEffects: []
92
+ })
93
+ .registerOfflineMigration({
94
+ axis: "aggregate",
95
+ fromVersion: RUNTIME_OBSERVATION_AGGREGATE_FROM_VERSION,
96
+ toVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION,
97
+ preconditions: requireAggregateV18Snapshot,
98
+ transform: migrateAggregateV18ToV19,
99
+ declaredEffects: []
90
100
  })
91
101
  .registerOfflineMigration(projectOwnershipStep())
92
102
  .registerOfflineMigration(taskWorkspaceIdentityStep())
@@ -1290,6 +1300,81 @@ function requireAggregateV17Snapshot(snapshot) {
1290
1300
  throw new Error("Aggregate 17->18 migration requires state.json schemaVersion 17 to match schema.json.");
1291
1301
  }
1292
1302
  }
1303
+ /**
1304
+ * Runtime state is now projected exclusively from canonical
1305
+ * `runtime.observation` events. Offline upgrade inventory proves there are no
1306
+ * active Runs or live Sessions. A retired Task has also explicitly abandoned
1307
+ * its runtime, so its stored runtime inconsistencies no longer block the Home;
1308
+ * the anomalous records themselves remain available as history. Non-retired
1309
+ * Tasks still fail closed and receive the supported retirement command.
1310
+ */
1311
+ function migrateAggregateV18ToV19(snapshot) {
1312
+ requireAggregateV18Snapshot(snapshot);
1313
+ const schemaManifest = {
1314
+ ...snapshot.schemaManifest,
1315
+ aggregateSchemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION
1316
+ };
1317
+ if (snapshot.state === null)
1318
+ return { schemaManifest, state: null };
1319
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
1320
+ const nextTasks = {};
1321
+ for (const [taskId, rawStoredTask] of Object.entries(tasks)) {
1322
+ const storedTask = asObject(rawStoredTask, `Task aggregate ${taskId}`);
1323
+ const rawTask = storedTask.task;
1324
+ const task = rawTask !== null && typeof rawTask === "object" && !Array.isArray(rawTask)
1325
+ ? rawTask
1326
+ : undefined;
1327
+ if (task?.status === "retired") {
1328
+ nextTasks[taskId] = { ...storedTask };
1329
+ continue;
1330
+ }
1331
+ requireResolvableActiveRunPointers(taskId, storedTask);
1332
+ nextTasks[taskId] = { ...storedTask };
1333
+ }
1334
+ return {
1335
+ schemaManifest,
1336
+ state: {
1337
+ ...snapshot.state,
1338
+ schemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION,
1339
+ tasks: nextTasks
1340
+ }
1341
+ };
1342
+ }
1343
+ function requireResolvableActiveRunPointers(taskId, storedTask) {
1344
+ if (storedTask.activeRuns === undefined)
1345
+ return;
1346
+ const activeRuns = asObject(storedTask.activeRuns, `activeRunPointer map ${taskId}`);
1347
+ if (Object.keys(activeRuns).length === 0)
1348
+ return;
1349
+ const agentRuns = asObject(storedTask.agentRuns, `agentRun map ${taskId}`);
1350
+ for (const [pointer, rawActiveRun] of Object.entries(activeRuns)) {
1351
+ const activeRun = asObject(rawActiveRun, `Active run ${taskId}/${pointer}`);
1352
+ const runId = typeof activeRun.runId === "string" ? activeRun.runId.trim() : "";
1353
+ if (runId.length === 0) {
1354
+ throw new Error(`Active run pointer ${taskId}/${pointer} has an invalid runId. `
1355
+ + taskRetirementUpgradeHint(taskId));
1356
+ }
1357
+ if (agentRuns[runId] === undefined) {
1358
+ throw new Error(`Active run pointer ${taskId}/${pointer} references missing agent run ${runId}. `
1359
+ + taskRetirementUpgradeHint(taskId));
1360
+ }
1361
+ }
1362
+ }
1363
+ function taskRetirementUpgradeHint(taskId) {
1364
+ return `Retire Task ${taskId} with `
1365
+ + `\`yui task retire ${taskId} --summary "abandon inconsistent runtime state"\`, `
1366
+ + "then retry `yui update`.";
1367
+ }
1368
+ function requireAggregateV18Snapshot(snapshot) {
1369
+ if (snapshot.schemaManifest.aggregateSchemaVersion
1370
+ !== RUNTIME_OBSERVATION_AGGREGATE_FROM_VERSION) {
1371
+ throw new Error("Aggregate 18->19 migration requires schema.json aggregateSchemaVersion 18.");
1372
+ }
1373
+ if (snapshot.state !== null
1374
+ && snapshot.state.schemaVersion !== RUNTIME_OBSERVATION_AGGREGATE_FROM_VERSION) {
1375
+ throw new Error("Aggregate 18->19 migration requires state.json schemaVersion 18 to match schema.json.");
1376
+ }
1377
+ }
1293
1378
  /**
1294
1379
  * Project ownership is a new required field. Every pre-v3 Project is a
1295
1380
  * user-registered checkout, so the historical binding is `external`; a managed
@@ -25,8 +25,8 @@
25
25
  *
26
26
  * Records are stored as full versioned JSON in `payload` columns, with typed
27
27
  * columns for the fields that are queried/filtered/used-for-CAS (§4). A
28
- * high-frequency `runtime.provider-turn-progress` event is a single-row
29
- * upsert into `telemetry` scoped by its primary key — it never rewrites global
28
+ * high-frequency runtime telemetry observation is a single-row upsert into
29
+ * `telemetry` scoped by its primary key — it never rewrites global
30
30
  * state and never touches another Task's rows (§4.4).
31
31
  *
32
32
  * The in-process store is phase 1 of §6. It does not re-run the heavy record
@@ -1542,7 +1542,7 @@ export class SqliteTaskStore {
1542
1542
  /**
1543
1543
  * Upsert one progress row. The PK is (task_id, role_name, run_id, generation,
1544
1544
  * progress_id): a repeated progress id updates in place, so a high-frequency
1545
- * `runtime.provider-turn-progress` event is a single-row write that never
1545
+ * runtime telemetry observation is a single-row write that never
1546
1546
  * rewrites global state or another Task's rows.
1547
1547
  */
1548
1548
  upsertTelemetryProgress(entry) {
@@ -15,4 +15,4 @@
15
15
  */
16
16
  export const CURRENT_STORAGE_LAYOUT_VERSION = 7;
17
17
  /** Version of the authoritative aggregate stored in `state.json`. */
18
- export const CURRENT_AGGREGATE_SCHEMA_VERSION = 18;
18
+ export const CURRENT_AGGREGATE_SCHEMA_VERSION = 19;
@@ -37,7 +37,7 @@ import { planMigration } from "../migration/planner.js";
37
37
  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
- import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
40
+ import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, copySqlitePassthroughState, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
41
41
  /** Build the SQLite-backed record-migration target. */
42
42
  export function createSqliteRecordMigrationTarget(options) {
43
43
  const home = options.home;
@@ -92,6 +92,7 @@ export function createSqliteRecordMigrationTarget(options) {
92
92
  updatedAt: now().toISOString()
93
93
  };
94
94
  populateSqliteFromState(home, snapshot.state ?? {}, STAGED_DATABASE_FILENAME);
95
+ copySqlitePassthroughState(home, COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME);
95
96
  },
96
97
  rebuildDerivedState(effects) {
97
98
  // The SQLite database is fully normalised by populateSqliteFromState;
@@ -33,6 +33,90 @@ import { CURRENT_STORED_TASK_SCHEMA_VERSION } from "../taskStore.js";
33
33
  export const STAGED_DATABASE_FILENAME = "yui.db.staged";
34
34
  /** The committed database filename. */
35
35
  export const COMMITTED_DATABASE_FILENAME = "yui.db";
36
+ /**
37
+ * Preserve SQLite-owned durable state that intentionally sits outside the
38
+ * state.json-shaped Task aggregate snapshot. Volatile coordination locks and
39
+ * derived projections are rebuilt or dropped at the offline boundary.
40
+ */
41
+ export function copySqlitePassthroughState(home, sourceDatabaseFilename, targetDatabaseFilename) {
42
+ if (sourceDatabaseFilename === targetDatabaseFilename) {
43
+ throw new Error("SQLite passthrough copy requires distinct source and target databases.");
44
+ }
45
+ const source = new Database(join(home, sourceDatabaseFilename), { readonly: true });
46
+ const target = new Database(join(home, targetDatabaseFilename));
47
+ try {
48
+ target.pragma("foreign_keys = ON");
49
+ target.transaction(() => {
50
+ mergeGlobalSequences(source, target);
51
+ copyTableRows(source, target, "outbox");
52
+ copyMailboxSignals(source, target);
53
+ copyTableRows(source, target, "work_item_candidates");
54
+ copyTableRows(source, target, "review_findings");
55
+ copyTableRows(source, target, "telemetry");
56
+ if (sqliteTableExists(source, "telemetry_aggregate")) {
57
+ target.exec("DELETE FROM telemetry_aggregate");
58
+ copyTableRows(source, target, "telemetry_aggregate");
59
+ }
60
+ copyTableRows(source, target, "session_owners");
61
+ copyTableRows(source, target, "resource_registry");
62
+ copyTableRows(source, target, "gate_artifacts");
63
+ copyTableRows(source, target, "gate_artifact_logs");
64
+ })();
65
+ }
66
+ finally {
67
+ source.close();
68
+ target.close();
69
+ }
70
+ }
71
+ function mergeGlobalSequences(source, target) {
72
+ if (!sqliteTableExists(source, "global_sequences"))
73
+ return;
74
+ const rows = source.prepare("SELECT name, high_water FROM global_sequences").all();
75
+ const merge = target.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, ?)
76
+ ON CONFLICT(name) DO UPDATE SET
77
+ high_water = MAX(global_sequences.high_water, excluded.high_water)`);
78
+ for (const row of rows)
79
+ merge.run(row.name, row.high_water);
80
+ }
81
+ function copyMailboxSignals(source, target) {
82
+ if (!sqliteTableExists(source, "mailbox_signals"))
83
+ return;
84
+ const rows = source.prepare(`SELECT m.target_key, s.sequence, s.reason, s.ref_type, s.ref_task_id,
85
+ s.ref_id, s.occurred_at, s.request_id
86
+ FROM mailbox_signals s
87
+ JOIN mailboxes m ON m.mailbox_id = s.mailbox_id
88
+ ORDER BY m.target_key, s.sequence`).iterate();
89
+ const findMailbox = target.prepare("SELECT mailbox_id FROM mailboxes WHERE target_key = ?");
90
+ const insert = target.prepare(`INSERT INTO mailbox_signals
91
+ (mailbox_id, sequence, reason, ref_type, ref_task_id, ref_id, occurred_at, request_id)
92
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
93
+ for (const row of rows) {
94
+ const mailbox = findMailbox.get(row.target_key);
95
+ if (mailbox === undefined) {
96
+ throw new Error(`SQLite migration cannot preserve signals for missing mailbox ${row.target_key}.`);
97
+ }
98
+ insert.run(mailbox.mailbox_id, row.sequence, row.reason, row.ref_type, row.ref_task_id, row.ref_id, row.occurred_at, row.request_id);
99
+ }
100
+ }
101
+ function copyTableRows(source, target, table) {
102
+ if (!sqliteTableExists(source, table) || !sqliteTableExists(target, table))
103
+ return;
104
+ const columns = source.prepare(`PRAGMA table_info(${quoteSqliteIdentifier(table)})`).all().map((row) => row.name);
105
+ if (columns.length === 0)
106
+ return;
107
+ const quotedColumns = columns.map(quoteSqliteIdentifier);
108
+ const rows = source.prepare(`SELECT ${quotedColumns.join(", ")} FROM ${quoteSqliteIdentifier(table)}`).iterate();
109
+ const insert = target.prepare(`INSERT INTO ${quoteSqliteIdentifier(table)} (${quotedColumns.join(", ")}) `
110
+ + `VALUES (${columns.map(() => "?").join(", ")})`);
111
+ for (const row of rows)
112
+ insert.run(...columns.map((column) => row[column]));
113
+ }
114
+ function sqliteTableExists(db, table) {
115
+ return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
116
+ }
117
+ function quoteSqliteIdentifier(value) {
118
+ return `"${value.replaceAll("\"", "\"\"")}"`;
119
+ }
36
120
  // ---------------------------------------------------------------------------
37
121
  // Canonical JSON and hashing
38
122
  // ---------------------------------------------------------------------------
@@ -157,6 +241,7 @@ function tasksOf(state) {
157
241
  * sidecar database file.
158
242
  */
159
243
  export function populateSqliteFromState(home, state, databaseFilename) {
244
+ const retiredActiveRuns = [];
160
245
  const store = new SqliteTaskStore(home, { databaseFilename, migration: true });
161
246
  try {
162
247
  store.transaction(() => {
@@ -234,6 +319,17 @@ export function populateSqliteFromState(home, state, databaseFilename) {
234
319
  // Active-run pointers: the document stores { schemaVersion, runId }
235
320
  // keyed by pointer; the store derives the pointer from the Run.
236
321
  for (const [pointer, value] of Object.entries(stored.activeRuns)) {
322
+ if (stored.task.status === "retired") {
323
+ retiredActiveRuns.push({
324
+ taskId,
325
+ pointer,
326
+ value,
327
+ updatedAt: typeof stored.task.updatedAt === "string"
328
+ ? stored.task.updatedAt
329
+ : new Date().toISOString()
330
+ });
331
+ continue;
332
+ }
237
333
  const run = stored.agentRuns[value.runId];
238
334
  if (run === undefined) {
239
335
  throw new Error(`Active run pointer ${taskId}/${pointer} references missing agent run ${value.runId}.`);
@@ -291,6 +387,33 @@ export function populateSqliteFromState(home, state, databaseFilename) {
291
387
  finally {
292
388
  store.close();
293
389
  }
390
+ persistRetiredActiveRunPointers(home, databaseFilename, retiredActiveRuns);
391
+ }
392
+ /**
393
+ * A retired Task is an explicit isolation boundary. Its active-run rows are
394
+ * retained byte-for-byte at the logical record level even when their Run is
395
+ * missing; normal Tasks continue through the referentially strict store path.
396
+ */
397
+ function persistRetiredActiveRunPointers(home, databaseFilename, pointers) {
398
+ if (pointers.length === 0)
399
+ return;
400
+ const db = new Database(join(home, databaseFilename));
401
+ try {
402
+ const insert = db.prepare(`INSERT INTO active_runs (task_id, pointer, run_id, payload, updated_at)
403
+ VALUES (?, ?, ?, ?, ?)`);
404
+ db.transaction(() => {
405
+ for (const entry of pointers) {
406
+ if (typeof entry.value.runId !== "string") {
407
+ throw new Error(`Retired Task active run pointer ${entry.taskId}/${entry.pointer} `
408
+ + "cannot be represented because runId is not a string.");
409
+ }
410
+ insert.run(entry.taskId, entry.pointer, entry.value.runId, JSON.stringify(entry.value), entry.updatedAt);
411
+ }
412
+ })();
413
+ }
414
+ finally {
415
+ db.close();
416
+ }
294
417
  }
295
418
  /**
296
419
  * Seed `global_sequences` from the numeric suffixes of existing task and