@zq-silk/yui 0.6.16 → 0.7.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 (70) hide show
  1. package/dist/cli/commandCatalog.js +3 -7
  2. package/dist/cli.js +12 -33
  3. package/dist/commands/executionAuditCommands.js +19 -0
  4. package/dist/commands/globalRoleCommands.js +70 -0
  5. package/dist/commands/taskActor.js +3 -2
  6. package/dist/commands/taskCommands.js +160 -41
  7. package/dist/commands/taskContextCommand.js +1 -1
  8. package/dist/commands/taskInputCommands.js +3 -2
  9. package/dist/commands/taskRoleRuntimeStatus.js +3 -3
  10. package/dist/context/contextSnapshot.js +228 -0
  11. package/dist/context/roleSessionContext.js +3 -1
  12. package/dist/context/runContextContract.js +162 -0
  13. package/dist/context/runContextPack.js +322 -0
  14. package/dist/context/sessionBootstrapManifest.js +81 -0
  15. package/dist/context/sessionProtocolIdentity.js +23 -0
  16. package/dist/controller/agentRuntimeObserver.js +6 -1
  17. package/dist/controller/controller.js +4 -3
  18. package/dist/controller/fileSchedulerStoreAdapter.js +446 -145
  19. package/dist/controller/jobControl.js +2 -1
  20. package/dist/controller/runtime.js +83 -0
  21. package/dist/controller/runtimeHookRunFence.js +6 -2
  22. package/dist/controller/sessionOwnerReconciliation.js +5 -0
  23. package/dist/coordination/workMailbox.js +25 -22
  24. package/dist/executor/agentAdapter.js +7 -2
  25. package/dist/executor/agentExecutor.js +23 -0
  26. package/dist/executor/effectiveLaunch.js +24 -0
  27. package/dist/executor/executorRegistry.js +7 -1
  28. package/dist/executor/fileRoleLaunchPlanner.js +73 -27
  29. package/dist/lifecycle/exactRunTerminalization.js +2 -3
  30. package/dist/lifecycle/providerErrorClass.js +8 -3
  31. package/dist/observability/executionAudit.js +87 -2
  32. package/dist/repository/taskWorkspacePreparer.js +2 -2
  33. package/dist/run/agentRun.js +101 -16
  34. package/dist/run/providerRetry.js +167 -56
  35. package/dist/run/providerRetryConfig.js +5 -1
  36. package/dist/run/runControlRequest.js +50 -0
  37. package/dist/runtime/agentDriver.js +47 -0
  38. package/dist/runtime/agentHost.js +327 -0
  39. package/dist/runtime/builtinAgentDrivers.js +23 -1
  40. package/dist/runtime/builtinTranscriptObserver.js +4 -0
  41. package/dist/runtime/builtinTranscriptUsage.js +2 -0
  42. package/dist/runtime/exactControlPlane.js +2 -2
  43. package/dist/runtime/globalProcessExitStore.js +38 -0
  44. package/dist/runtime/launchBroker.js +95 -0
  45. package/dist/runtime/processExitObservation.js +60 -0
  46. package/dist/runtime/runtimeBinding.js +6 -0
  47. package/dist/runtime/runtimeObservation.js +27 -6
  48. package/dist/runtime/runtimeProjection.js +6 -3
  49. package/dist/runtime/runtimeStopReceipt.js +42 -0
  50. package/dist/runtime/sessionTerminationGuard.js +13 -0
  51. package/dist/runtime/tmuxAdapters.js +203 -220
  52. package/dist/scheduler/activeRoleRunDelivery.js +24 -3
  53. package/dist/scheduler/leaderWakeupProcessor.js +18 -60
  54. package/dist/scheduler/roleRunLiveness.js +61 -27
  55. package/dist/storage/migration/productionRegistry.js +264 -0
  56. package/dist/storage/sqliteSchema.js +23 -2
  57. package/dist/storage/sqliteStore.js +48 -4
  58. package/dist/storage/taskStore.js +54 -5
  59. package/dist/storage/upgrade/recordVersions.js +3 -1
  60. package/dist/storage/upgrade/sqliteStateMigration.js +10 -0
  61. package/dist/task/taskRecordReference.js +1 -0
  62. package/dist/tmux/tmuxManager.js +15 -4
  63. package/dist/web/assets/client/components.js +1 -1
  64. package/package.json +1 -1
  65. package/skills/yui-leader/SKILL.md +10 -5
  66. package/skills/yui-operator/SKILL.md +4 -0
  67. package/skills/yui-reviewer/SKILL.md +4 -0
  68. package/skills/yui-runtime/SKILL.md +61 -0
  69. package/skills/yui-worker/SKILL.md +82 -218
  70. package/dist/executor/managedClaudeRunner.js +0 -121
@@ -1,9 +1,6 @@
1
1
  import { selectedSchedulerRoles, selectedActiveSchedulerTasks } from "./ports.js";
2
2
  import { formatTaskRecordReference } from "../task/taskRecordReference.js";
3
- import { queueLeaderWakeup } from "./wakeupQueue.js";
4
- import { wakeReason } from "./wakeReason.js";
5
3
  import { currentRoleRunProgressAt, DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS } from "./roleRunStall.js";
6
- export const EXITED_ROLE_RUN_SUMMARY = "The role's tmux session exited before the run yielded.";
7
4
  /**
8
5
  * Lightweight liveness only. Host absence may fail a Run only before any
9
6
  * prompt bytes were pushed. After push, acceptance may be unknown even when
@@ -11,6 +8,9 @@ export const EXITED_ROLE_RUN_SUMMARY = "The role's tmux session exited before th
11
8
  * evidence, not an application-level outcome. The Run stays active so native
12
9
  * child work or another observer can still contribute facts, while the stall
13
10
  * path raises bounded attention independently.
11
+ * Process liveness is only a recovery signal. An absent pane/Host never proves
12
+ * that the native Session or AgentRun ended; recover the same generation and
13
+ * native identity when possible, otherwise preserve the active Run.
14
14
  */
15
15
  export async function reconcileExitedRoleRuns(store, delivery, now, selection, excludedRunRefs = new Set(), liveStatuses, resourceEvidence, targetedInventory = selection !== undefined && !selection.full) {
16
16
  const failed = [];
@@ -52,7 +52,8 @@ export async function reconcileExitedRoleRuns(store, delivery, now, selection, e
52
52
  && candidates.every(({ task, role }) => liveStatuses.has(`${task.id}\0${role.name}`))
53
53
  ? {
54
54
  statuses: liveStatuses,
55
- resources: resourceEvidence ?? new Map()
55
+ resources: resourceEvidence ?? new Map(),
56
+ hostExits: new Map()
56
57
  }
57
58
  : await inspectRoleStatuses(delivery, candidates, !targetedInventory
58
59
  ? candidates.flatMap(({ task, role, run, session }) => (isResourceCandidate(task, run, now)
@@ -90,27 +91,57 @@ export async function reconcileExitedRoleRuns(store, delivery, now, selection, e
90
91
  throw new Error("Role liveness snapshot is incomplete.");
91
92
  if (status === "present")
92
93
  continue;
93
- const persisted = store.saveExitedRoleRun({
94
- task,
95
- role,
96
- run,
97
- session,
98
- summary: EXITED_ROLE_RUN_SUMMARY,
99
- now
100
- });
101
- if (persisted === "state-changed")
94
+ const hostExit = batchSnapshot.hostExits.get(`${task.id}\0${role.name}`);
95
+ if (hostExit !== undefined) {
96
+ store.saveRoleHostExitObservation?.({
97
+ taskId: task.id,
98
+ roleName: role.name,
99
+ runId: run.id,
100
+ ...(session?.launchId === undefined ? {} : { launchId: session.launchId }),
101
+ ...(session?.nativeSessionId === undefined
102
+ ? {}
103
+ : { nativeSessionId: session.nativeSessionId }),
104
+ ...(hostExit.deadStatus === undefined ? {} : { deadStatus: hostExit.deadStatus }),
105
+ observedAt: now
106
+ });
107
+ }
108
+ if (session?.nativeSessionId === undefined) {
109
+ store.queueTaskProgress(task.id, "host-missing-native-resume-unproven", now);
102
110
  continue;
103
- delivery.forgetPrepared?.({
104
- taskId: task.id,
105
- roleName: role.name,
106
- runId: run.id
107
- });
108
- failed.push(formatTaskRecordReference(task.id, run.id, "agentRun"));
109
- // Compatibility for narrow in-memory/custom ports that predate the
110
- // adapter's atomic failure+wake transition. Production returns
111
- // "failed" and already enqueued this wake in the same transaction.
112
- if (persisted === undefined && task.status === "active") {
113
- queueLeaderWakeup(store, task.id, wakeReason(role.name === "leader" ? "leader-run-failed" : "role-run-failed"), now);
111
+ }
112
+ try {
113
+ delivery.forgetPrepared?.({
114
+ taskId: task.id,
115
+ roleName: role.name,
116
+ runId: run.id
117
+ });
118
+ const recovered = await delivery.prepareRoleSession({
119
+ taskId: task.id,
120
+ roleName: role.name,
121
+ agentId: run.effective.agentId,
122
+ adapterId: run.effective.adapterId,
123
+ effective: run.effective,
124
+ workspace: run.effective.workspace.root,
125
+ ...(run.workspace === undefined ? {} : { managedWorkspace: run.workspace }),
126
+ mode: "resume",
127
+ runId: run.id,
128
+ nativeSessionId: session.nativeSessionId
129
+ });
130
+ store.saveRoleRunPrepared({
131
+ task,
132
+ role,
133
+ run,
134
+ session: recovered.session ?? session,
135
+ ...(recovered.launchId === undefined ? {} : { launchId: recovered.launchId }),
136
+ now
137
+ });
138
+ liveStatuses?.set(`${task.id}\0${role.name}`, "present");
139
+ }
140
+ catch {
141
+ // Absence plus an inconclusive local recovery attempt is still not a
142
+ // native Session death proof. Keep the exact Run active for the next
143
+ // bounded recovery pass and expose the blocked axis separately.
144
+ store.queueTaskProgress(task.id, "host-missing-native-resume-pending", now);
114
145
  }
115
146
  }
116
147
  return failed;
@@ -129,7 +160,7 @@ async function inspectRoleStatuses(delivery, candidates, resourceInputs, targete
129
160
  }
130
161
  statuses.set(key, await delivery.inspectRole(candidate.inspection));
131
162
  }
132
- return { statuses, resources: new Map() };
163
+ return { statuses, resources: new Map(), hostExits: new Map() };
133
164
  }
134
165
  if (delivery.inspectRoles !== undefined) {
135
166
  return exactBatchInventory(await delivery.inspectRoles(candidates.map(({ inspection }) => inspection), resourceInputs), candidates);
@@ -141,12 +172,13 @@ async function inspectRoleStatuses(delivery, candidates, resourceInputs, targete
141
172
  await delivery.inspectRole(candidate.inspection)
142
173
  ]);
143
174
  }
144
- return { statuses: new Map(entries), resources: new Map() };
175
+ return { statuses: new Map(entries), resources: new Map(), hostExits: new Map() };
145
176
  }
146
177
  function exactBatchInventory(batch, candidates) {
147
178
  const expected = new Set(candidates.map(({ task, role }) => `${task.id}\0${role.name}`));
148
179
  const statuses = new Map();
149
180
  const resources = new Map();
181
+ const hostExits = new Map();
150
182
  for (const entry of batch) {
151
183
  const key = `${entry.taskId}\0${entry.roleName}`;
152
184
  if (!expected.has(key) || statuses.has(key)) {
@@ -155,11 +187,13 @@ function exactBatchInventory(batch, candidates) {
155
187
  statuses.set(key, entry.status);
156
188
  if (entry.resource !== undefined)
157
189
  resources.set(key, entry.resource);
190
+ if (entry.hostExit !== undefined)
191
+ hostExits.set(key, entry.hostExit);
158
192
  }
159
193
  if (statuses.size !== expected.size) {
160
194
  throw new Error("Tmux Role batch liveness snapshot is incomplete.");
161
195
  }
162
- return { statuses, resources };
196
+ return { statuses, resources, hostExits };
163
197
  }
164
198
  function isResourceCandidate(task, run, now) {
165
199
  if (task.status !== "active" || run.status !== "active" || run.deliveredAt === undefined) {
@@ -35,6 +35,10 @@ const AGENT_RUN_TO_VERSION = 6;
35
35
  */
36
36
  const AGENT_RUN_OPTIONAL_FIELDS_FROM_VERSION = 6;
37
37
  const AGENT_RUN_OPTIONAL_FIELDS_TO_VERSION = 7;
38
+ const AGENT_RUN_CONTEXT_PROTOCOL_FROM_VERSION = 7;
39
+ const AGENT_RUN_CONTEXT_PROTOCOL_TO_VERSION = 8;
40
+ const AGENT_RUN_RETRY_EPISODE_FROM_VERSION = 8;
41
+ const AGENT_RUN_RETRY_EPISODE_TO_VERSION = 9;
38
42
  const MESSAGE_WAKE_POLICY_FROM_VERSION = 2;
39
43
  const MESSAGE_WAKE_POLICY_TO_VERSION = 3;
40
44
  const REVIEW_ROUND_FROM_VERSION = 2;
@@ -55,6 +59,8 @@ const INTEGRATION_ATTEMPT_GATE_IDENTITY_FROM_VERSION = 3;
55
59
  const INTEGRATION_ATTEMPT_GATE_IDENTITY_TO_VERSION = 4;
56
60
  const INTEGRATION_QUEUE_FROM_VERSION = 0;
57
61
  const INTEGRATION_QUEUE_TO_VERSION = 1;
62
+ const CONTEXT_SNAPSHOT_FROM_VERSION = 0;
63
+ const CONTEXT_SNAPSHOT_TO_VERSION = 1;
58
64
  const STORED_TASK_DURABLE_JOBS_FROM_VERSION = 14;
59
65
  const STORED_TASK_DURABLE_JOBS_TO_VERSION = 15;
60
66
  const STORED_TASK_JOB_CALLER_KEY_HASHES_FROM_VERSION = 15;
@@ -130,6 +136,8 @@ export function createProductionStorageRegistry() {
130
136
  .registerOfflineMigration(workItemExecutionGroupHistoryStep())
131
137
  .registerOfflineMigration(recordFamilyStep("agentRun", AGENT_RUN_FROM_VERSION, AGENT_RUN_TO_VERSION, "agentRuns"))
132
138
  .registerOfflineMigration(recordFamilyStep("agentRun", AGENT_RUN_OPTIONAL_FIELDS_FROM_VERSION, AGENT_RUN_OPTIONAL_FIELDS_TO_VERSION, "agentRuns"))
139
+ .registerOfflineMigration(agentRunContextProtocolStep())
140
+ .registerOfflineMigration(agentRunRetryEpisodeStep())
133
141
  .registerOfflineMigration(messageWakePolicyStep())
134
142
  .registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_FROM_VERSION, REVIEW_ROUND_TO_VERSION, "reviewRounds"))
135
143
  .registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_GIT_SNAPSHOT_FROM_VERSION, REVIEW_ROUND_GIT_SNAPSHOT_TO_VERSION, "reviewRounds"))
@@ -140,6 +148,7 @@ export function createProductionStorageRegistry() {
140
148
  .registerOfflineMigration(integrationAttemptSupersededStep())
141
149
  .registerOfflineMigration(recordFamilyStep("integrationAttempt", INTEGRATION_ATTEMPT_GATE_IDENTITY_FROM_VERSION, INTEGRATION_ATTEMPT_GATE_IDENTITY_TO_VERSION, "integrationAttempts"))
142
150
  .registerOfflineMigration(integrationQueueIntroductionStep())
151
+ .registerOfflineMigration(contextSnapshotIntroductionStep())
143
152
  .registerCompatible(storedTaskDurableJobsStep())
144
153
  .registerCompatible(storedTaskJobCallerKeyHashesStep())
145
154
  .registerCompatible(storedTaskWakesAndPublicationReferencesStep())
@@ -725,6 +734,71 @@ function introduceIntegrationQueue(snapshot) {
725
734
  state: { ...snapshot.state, tasks: nextTasks }
726
735
  };
727
736
  }
737
+ /** Introduce the immutable Task-scoped ContextSnapshot record family. */
738
+ function contextSnapshotIntroductionStep() {
739
+ return {
740
+ axis: "record",
741
+ recordKind: "contextSnapshot",
742
+ fromVersion: CONTEXT_SNAPSHOT_FROM_VERSION,
743
+ toVersion: CONTEXT_SNAPSHOT_TO_VERSION,
744
+ introduction: true,
745
+ preconditions: requireContextSnapshotIntroduction,
746
+ transform: introduceContextSnapshots,
747
+ declaredEffects: []
748
+ };
749
+ }
750
+ function requireContextSnapshotIntroduction(snapshot) {
751
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
752
+ const namedVersion = manifestVersions.contextSnapshot;
753
+ if (namedVersion !== undefined && namedVersion !== CONTEXT_SNAPSHOT_FROM_VERSION) {
754
+ throw new Error("Record contextSnapshot introduction requires an absent manifest version or 0.");
755
+ }
756
+ if (snapshot.state === null)
757
+ return;
758
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
759
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
760
+ const task = asObject(rawTask, `Task aggregate ${taskId}`);
761
+ if (task.contextSnapshots !== undefined) {
762
+ const records = asObject(task.contextSnapshots, `contextSnapshot map ${taskId}`);
763
+ if (Object.keys(records).length > 0) {
764
+ throw new Error(`Context Snapshot introduction found existing records: ${taskId}.`);
765
+ }
766
+ }
767
+ const marks = asObject(task.idHighWaterMarks ?? {}, `Task id high-water marks ${taskId}`);
768
+ const mark = marks.contextSnapshot;
769
+ if (mark !== undefined && mark !== CONTEXT_SNAPSHOT_FROM_VERSION) {
770
+ throw new Error(`Context Snapshot introduction found a high-water mark: ${taskId}.`);
771
+ }
772
+ }
773
+ }
774
+ function introduceContextSnapshots(snapshot) {
775
+ requireContextSnapshotIntroduction(snapshot);
776
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
777
+ const schemaManifest = {
778
+ ...snapshot.schemaManifest,
779
+ recordVersions: {
780
+ ...manifestVersions,
781
+ contextSnapshot: CONTEXT_SNAPSHOT_TO_VERSION
782
+ }
783
+ };
784
+ if (snapshot.state === null)
785
+ return { schemaManifest, state: null };
786
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
787
+ const nextTasks = {};
788
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
789
+ const task = asObject(rawTask, `Task aggregate ${taskId}`);
790
+ const marks = asObject(task.idHighWaterMarks ?? {}, `Task id high-water marks ${taskId}`);
791
+ nextTasks[taskId] = {
792
+ ...task,
793
+ idHighWaterMarks: { ...marks, contextSnapshot: 0 },
794
+ contextSnapshots: {}
795
+ };
796
+ }
797
+ return {
798
+ schemaManifest,
799
+ state: { ...snapshot.state, tasks: nextTasks }
800
+ };
801
+ }
728
802
  function legacyLaneKeyParts(key) {
729
803
  const match = /^lane:([^:]+):([^:]+)$/u.exec(key);
730
804
  if (match === null)
@@ -1411,6 +1485,196 @@ function recordFamilyStep(recordKind, fromVersion, toVersion, taskMapKey) {
1411
1485
  declaredEffects: []
1412
1486
  };
1413
1487
  }
1488
+ function agentRunContextProtocolStep() {
1489
+ return {
1490
+ axis: "record",
1491
+ recordKind: "agentRun",
1492
+ fromVersion: AGENT_RUN_CONTEXT_PROTOCOL_FROM_VERSION,
1493
+ toVersion: AGENT_RUN_CONTEXT_PROTOCOL_TO_VERSION,
1494
+ preconditions: (snapshot) => requireRecordFamilyVersion(snapshot, "agentRun", AGENT_RUN_CONTEXT_PROTOCOL_FROM_VERSION, "agentRuns"),
1495
+ transform: migrateAgentRunsToContextProtocol,
1496
+ declaredEffects: []
1497
+ };
1498
+ }
1499
+ function migrateAgentRunsToContextProtocol(snapshot) {
1500
+ requireRecordFamilyVersion(snapshot, "agentRun", AGENT_RUN_CONTEXT_PROTOCOL_FROM_VERSION, "agentRuns");
1501
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
1502
+ const schemaManifest = {
1503
+ ...snapshot.schemaManifest,
1504
+ recordVersions: {
1505
+ ...manifestVersions,
1506
+ agentRun: AGENT_RUN_CONTEXT_PROTOCOL_TO_VERSION
1507
+ }
1508
+ };
1509
+ if (snapshot.state === null)
1510
+ return { schemaManifest, state: null };
1511
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
1512
+ const nextTasks = {};
1513
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
1514
+ const task = asObject(rawTask, `Task aggregate ${taskId}`);
1515
+ const records = asObject(task.agentRuns ?? {}, `agentRun map ${taskId}`);
1516
+ const nextRecords = {};
1517
+ for (const [runId, rawRecord] of Object.entries(records)) {
1518
+ const record = asObject(rawRecord, `agentRun ${taskId}/${runId}`);
1519
+ const { input: rawInput, schemaVersion: _schemaVersion, ...rest } = record;
1520
+ const roleName = requiredMigrationText(record.roleName, "Agent Run roleName");
1521
+ const purpose = record.purpose === "review" ? "review" : "execution";
1522
+ const workItemId = optionalMigrationText(record.workItemId, "Agent Run workItemId");
1523
+ const reviewRoundId = optionalMigrationText(record.reviewRoundId, "Agent Run reviewRoundId");
1524
+ const executionGroupId = optionalMigrationText(record.executionGroupId, "Agent Run executionGroupId");
1525
+ const executionLaneId = optionalMigrationText(record.executionLaneId, "Agent Run executionLaneId");
1526
+ const action = purpose === "review"
1527
+ ? "review-round"
1528
+ : roleName === "leader"
1529
+ ? "leader-wake"
1530
+ : workItemId === undefined
1531
+ ? "lead-task"
1532
+ : "execute-work-item";
1533
+ const subject = {
1534
+ taskId,
1535
+ ...(workItemId === undefined ? {} : { workItemId }),
1536
+ ...(reviewRoundId === undefined ? {} : { reviewRoundId }),
1537
+ ...(executionGroupId === undefined ? {} : { executionGroupId }),
1538
+ ...(executionLaneId === undefined ? {} : { executionLaneId })
1539
+ };
1540
+ const assignment = {
1541
+ schemaVersion: 1,
1542
+ runId,
1543
+ roleName,
1544
+ purpose,
1545
+ action,
1546
+ subject,
1547
+ ...(typeof rawInput === "string" && rawInput.trim().length > 0
1548
+ ? { directive: rawInput }
1549
+ : {}),
1550
+ deltaRefIds: []
1551
+ };
1552
+ nextRecords[runId] = {
1553
+ ...rest,
1554
+ schemaVersion: AGENT_RUN_CONTEXT_PROTOCOL_TO_VERSION,
1555
+ assignment,
1556
+ bootstrapEnvelope: {
1557
+ protocol: "yui-run/v1",
1558
+ runId,
1559
+ roleName,
1560
+ purpose,
1561
+ action,
1562
+ subject,
1563
+ deltaRefIds: []
1564
+ }
1565
+ };
1566
+ }
1567
+ nextTasks[taskId] = { ...task, agentRuns: nextRecords };
1568
+ }
1569
+ return {
1570
+ schemaManifest,
1571
+ state: { ...snapshot.state, tasks: nextTasks }
1572
+ };
1573
+ }
1574
+ function agentRunRetryEpisodeStep() {
1575
+ return {
1576
+ axis: "record",
1577
+ recordKind: "agentRun",
1578
+ fromVersion: AGENT_RUN_RETRY_EPISODE_FROM_VERSION,
1579
+ toVersion: AGENT_RUN_RETRY_EPISODE_TO_VERSION,
1580
+ preconditions: (snapshot) => requireRecordFamilyVersion(snapshot, "agentRun", AGENT_RUN_RETRY_EPISODE_FROM_VERSION, "agentRuns"),
1581
+ transform: migrateAgentRunsToRetryEpisodes,
1582
+ declaredEffects: []
1583
+ };
1584
+ }
1585
+ function migrateAgentRunsToRetryEpisodes(snapshot) {
1586
+ requireRecordFamilyVersion(snapshot, "agentRun", AGENT_RUN_RETRY_EPISODE_FROM_VERSION, "agentRuns");
1587
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
1588
+ const schemaManifest = {
1589
+ ...snapshot.schemaManifest,
1590
+ recordVersions: { ...manifestVersions, agentRun: AGENT_RUN_RETRY_EPISODE_TO_VERSION }
1591
+ };
1592
+ if (snapshot.state === null)
1593
+ return { schemaManifest, state: null };
1594
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
1595
+ const nextTasks = {};
1596
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
1597
+ const task = asObject(rawTask, `Task aggregate ${taskId}`);
1598
+ const records = asObject(task.agentRuns ?? {}, `agentRun map ${taskId}`);
1599
+ const nextRecords = {};
1600
+ for (const [runId, rawRecord] of Object.entries(records)) {
1601
+ const record = asObject(rawRecord, `agentRun ${taskId}/${runId}`);
1602
+ const legacyRetry = record.providerRetry === undefined
1603
+ ? undefined
1604
+ : asObject(record.providerRetry, `providerRetry ${taskId}/${runId}`);
1605
+ let providerRetry = undefined;
1606
+ let deliveryReceiptId;
1607
+ if (legacyRetry !== undefined) {
1608
+ const attempt = requiredMigrationPositiveInteger(legacyRetry.attempt, `providerRetry attempt ${taskId}/${runId}`);
1609
+ const firstFailureAt = requiredMigrationTimestamp(legacyRetry.firstFailureAt, `providerRetry firstFailureAt ${taskId}/${runId}`);
1610
+ const lastFailureAt = requiredMigrationTimestamp(legacyRetry.lastFailureAt, `providerRetry lastFailureAt ${taskId}/${runId}`);
1611
+ const nextAttemptAt = optionalMigrationTimestamp(legacyRetry.nextAttemptAt, `providerRetry nextAttemptAt ${taskId}/${runId}`);
1612
+ const policyBlocked = legacyRetry.errorClass === "policy-denied";
1613
+ const deadlineMs = Math.max(Date.parse(firstFailureAt) + 600_000, nextAttemptAt === undefined ? 0 : Date.parse(nextAttemptAt) + 1);
1614
+ deliveryReceiptId = nextAttemptAt !== undefined || policyBlocked
1615
+ ? undefined
1616
+ : `legacy-retry-receipt-${runId}-${attempt}`;
1617
+ providerRetry = {
1618
+ schemaVersion: 2,
1619
+ episodeId: `legacy-retry-${runId}`,
1620
+ failureEventId: `legacy-provider-failure-${runId}-${attempt}`,
1621
+ policyVersion: 1,
1622
+ state: nextAttemptAt !== undefined
1623
+ ? "scheduled"
1624
+ : policyBlocked ? "blocked" : "awaiting-progress",
1625
+ errorClass: legacyRetry.errorClass,
1626
+ consecutiveFailures: attempt,
1627
+ dispatchedRetries: Math.min(3, nextAttemptAt === undefined ? attempt : attempt - 1),
1628
+ maxRetries: 3,
1629
+ firstFailureAt,
1630
+ lastFailureAt,
1631
+ episodeDeadlineAt: new Date(deadlineMs).toISOString(),
1632
+ ...(nextAttemptAt === undefined ? {} : { nextAttemptAt }),
1633
+ ...(deliveryReceiptId === undefined
1634
+ ? {}
1635
+ : { lastRetryReceiptId: deliveryReceiptId }),
1636
+ ...(legacyRetry.launchId === undefined ? {} : { launchId: legacyRetry.launchId }),
1637
+ ...(legacyRetry.nativeSessionId === undefined
1638
+ ? {}
1639
+ : { nativeSessionId: legacyRetry.nativeSessionId }),
1640
+ lastErrorSummary: legacyRetry.lastErrorSummary
1641
+ };
1642
+ }
1643
+ nextRecords[runId] = {
1644
+ ...record,
1645
+ schemaVersion: AGENT_RUN_RETRY_EPISODE_TO_VERSION,
1646
+ ...(deliveryReceiptId === undefined ? {} : { deliveryReceiptId }),
1647
+ ...(providerRetry === undefined ? {} : { providerRetry })
1648
+ };
1649
+ }
1650
+ nextTasks[taskId] = { ...task, agentRuns: nextRecords };
1651
+ }
1652
+ return { schemaManifest, state: { ...snapshot.state, tasks: nextTasks } };
1653
+ }
1654
+ function requiredMigrationPositiveInteger(value, label) {
1655
+ if (!Number.isSafeInteger(value) || value < 1) {
1656
+ throw new Error(`${label} must be a positive integer.`);
1657
+ }
1658
+ return value;
1659
+ }
1660
+ function requiredMigrationTimestamp(value, label) {
1661
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
1662
+ throw new Error(`${label} must be a timestamp.`);
1663
+ }
1664
+ return value;
1665
+ }
1666
+ function optionalMigrationTimestamp(value, label) {
1667
+ return value === undefined ? undefined : requiredMigrationTimestamp(value, label);
1668
+ }
1669
+ function requiredMigrationText(value, label) {
1670
+ if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {
1671
+ throw new Error(`${label} is invalid.`);
1672
+ }
1673
+ return value.trim();
1674
+ }
1675
+ function optionalMigrationText(value, label) {
1676
+ return value === undefined ? undefined : requiredMigrationText(value, label);
1677
+ }
1414
1678
  /**
1415
1679
  * The snapshot boundary is nested inside WorkItem/ReviewRound ExecutionGroup
1416
1680
  * results. The parent record versions make that persisted shape explicit;
@@ -27,7 +27,7 @@ export const SQLITE_LAYOUT_VERSION = 7;
27
27
  /** The aggregate version of the normalized SQLite schema. */
28
28
  export const SQLITE_AGGREGATE_VERSION = 1;
29
29
  /** The current schema migration version. */
30
- export const SQLITE_SCHEMA_VERSION = 16;
30
+ export const SQLITE_SCHEMA_VERSION = 17;
31
31
  /** Telemetry retention bounds (§4.4). Open question 3 in §11; defaults from the design. */
32
32
  export const TELEMETRY_KEEP_PER_GENERATION = 200;
33
33
  export const TELEMETRY_RUN_CAP = 50_000;
@@ -911,6 +911,25 @@ CREATE TABLE IF NOT EXISTS task_wakes (
911
911
  );
912
912
  CREATE INDEX IF NOT EXISTS idx_task_wakes_seq ON task_wakes(task_id, seq);
913
913
  `;
914
+ /** Migration 17: immutable, Task-scoped ContextSnapshot records. */
915
+ const MIGRATION_17_SQL = `
916
+ CREATE TABLE IF NOT EXISTS context_snapshots (
917
+ task_id TEXT NOT NULL,
918
+ snapshot_id TEXT NOT NULL,
919
+ scope TEXT NOT NULL CHECK (scope IN ('task','workitem','stage')),
920
+ scope_ref TEXT,
921
+ sequence INTEGER NOT NULL CHECK (sequence > 0),
922
+ digest TEXT NOT NULL CHECK (length(digest) = 64),
923
+ payload TEXT NOT NULL,
924
+ frozen_at TEXT NOT NULL,
925
+ PRIMARY KEY (task_id, snapshot_id),
926
+ FOREIGN KEY (task_id) REFERENCES tasks_catalog(task_id) ON DELETE CASCADE,
927
+ CHECK ((scope = 'task' AND scope_ref IS NULL) OR (scope <> 'task' AND scope_ref IS NOT NULL))
928
+ );
929
+
930
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_context_snapshots_scope_sequence
931
+ ON context_snapshots(task_id, scope, COALESCE(scope_ref, ''), sequence);
932
+ `;
914
933
  const MIGRATIONS = [
915
934
  { version: 1, axis: "layout", sql: MIGRATION_1_SQL },
916
935
  { version: 2, axis: "record", recordKind: "durableJob+capability-grant+release-workflow", sql: MIGRATION_2_SQL },
@@ -927,7 +946,8 @@ const MIGRATIONS = [
927
946
  { version: 13, axis: "layout", sql: MIGRATION_13_SQL },
928
947
  { version: 14, axis: "record", recordKind: "workMailbox", sql: MIGRATION_14_SQL },
929
948
  { version: 15, axis: "record", recordKind: "publicationReference", sql: MIGRATION_15_SQL },
930
- { version: 16, axis: "record", recordKind: "taskWake", sql: MIGRATION_16_SQL }
949
+ { version: 16, axis: "record", recordKind: "taskWake", sql: MIGRATION_16_SQL },
950
+ { version: 17, axis: "record", recordKind: "contextSnapshot", sql: MIGRATION_17_SQL }
931
951
  ];
932
952
  /** Current hot-path indexes whose absence would invalidate a current Home. */
933
953
  const REQUIRED_SCHEMA_INDEXES = [
@@ -1169,6 +1189,7 @@ export const SQLITE_SCHEMA_TABLES = [
1169
1189
  "role_session_sets",
1170
1190
  "work_items",
1171
1191
  "work_item_candidates",
1192
+ "context_snapshots",
1172
1193
  "agent_runs",
1173
1194
  "active_runs",
1174
1195
  "review_rounds",
@@ -39,6 +39,7 @@ import { join } from "node:path";
39
39
  import { isDeepStrictEqual } from "node:util";
40
40
  import Database from "better-sqlite3";
41
41
  import { consumePendingBatch, mailboxTargetKey, pendingLane, validateWorkMailbox } from "../coordination/workMailbox.js";
42
+ import { validateContextSnapshot } from "../context/contextSnapshot.js";
42
43
  import { compareRuntimeSessionCandidates, projectRuntimeSessionCandidate } from "../runtime/runtimeSessionCandidate.js";
43
44
  import { validateReviewFinding } from "../review/reviewFinding.js";
44
45
  import { reviewFindingLedgerMode } from "../review/reviewFindingLedger.js";
@@ -1418,6 +1419,37 @@ export class SqliteTaskStore {
1418
1419
  return Object.freeze({ retained, deleted: toDelete.length });
1419
1420
  });
1420
1421
  }
1422
+ // -- context snapshots ------------------------------------------------------
1423
+ nextContextSnapshotId(taskId) {
1424
+ return this.#nextTaskRecordId(taskId, "contextSnapshot");
1425
+ }
1426
+ getContextSnapshot(taskId, snapshotId) {
1427
+ return this.#getPayload("context_snapshots", "task_id = ? AND snapshot_id = ?", [taskId, snapshotId]);
1428
+ }
1429
+ listContextSnapshots(taskId) {
1430
+ return this.#sortById(this.#listPayload("context_snapshots", "task_id = ?", [taskId]), (snapshot) => snapshot.id);
1431
+ }
1432
+ saveContextSnapshot(snapshot) {
1433
+ const stored = validateContextSnapshot(snapshot);
1434
+ this.#requireTask(stored.taskId);
1435
+ const existing = this.getContextSnapshot(stored.taskId, stored.id);
1436
+ if (existing !== null) {
1437
+ if (!isDeepStrictEqual(existing, stored)) {
1438
+ throw new StorageRecordError(`Context Snapshot is immutable: ${stored.id}.`);
1439
+ }
1440
+ return;
1441
+ }
1442
+ const duplicate = this.#db.prepare(`SELECT snapshot_id FROM context_snapshots
1443
+ WHERE task_id = ? AND scope = ? AND COALESCE(scope_ref, '') = COALESCE(?, '') AND sequence = ?`).get(stored.taskId, stored.scope, stored.scopeRef ?? null, stored.sequence);
1444
+ if (duplicate !== undefined) {
1445
+ throw new StorageRecordError(`Context Snapshot sequence already exists: ${stored.taskId}/${stored.scope}/${stored.sequence}.`);
1446
+ }
1447
+ this.#mutate(() => {
1448
+ this.#db.prepare(`INSERT INTO context_snapshots
1449
+ (task_id, snapshot_id, scope, scope_ref, sequence, digest, payload, frozen_at)
1450
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(stored.taskId, stored.id, stored.scope, stored.scopeRef ?? null, stored.sequence, stored.digest, this.#json(stored), stored.frozenAt);
1451
+ });
1452
+ }
1421
1453
  // -- agent runs -------------------------------------------------------------
1422
1454
  nextAgentRunId(taskId) { return this.#nextTaskRecordId(taskId, "agentRun"); }
1423
1455
  peekNextAgentRunId(taskId) { return this.#peekTaskRecordId(taskId, "agentRun"); }
@@ -1451,13 +1483,18 @@ export class SqliteTaskStore {
1451
1483
  ? ""
1452
1484
  : ` AND tc.task_id IN (${selectedTaskIds.map(() => "?").join(", ")})`;
1453
1485
  const rows = this.#db.prepare(`SELECT DISTINCT ar.task_id AS taskId, ar.run_id AS runId, ar.role_name AS roleName,
1454
- json_extract(ar.payload, '$.providerRetry.nextAttemptAt') AS nextAttemptAt
1486
+ json_extract(ar.payload, '$.providerRetry.state') AS state,
1487
+ CASE json_extract(ar.payload, '$.providerRetry.state')
1488
+ WHEN 'scheduled' THEN json_extract(ar.payload, '$.providerRetry.nextAttemptAt')
1489
+ ELSE json_extract(ar.payload, '$.providerRetry.episodeDeadlineAt')
1490
+ END AS dueAt
1455
1491
  FROM tasks_catalog tc INDEXED BY idx_tasks_active
1456
1492
  JOIN active_runs ap ON ap.task_id = tc.task_id
1457
1493
  JOIN agent_runs ar ON ar.task_id = ap.task_id AND ar.run_id = ap.run_id
1458
1494
  WHERE tc.is_active = 1
1459
1495
  AND ar.status = 'active'
1460
- AND json_extract(ar.payload, '$.providerRetry.nextAttemptAt') IS NOT NULL${taskPredicate}`).all(...(selectedTaskIds ?? []));
1496
+ AND json_extract(ar.payload, '$.providerRetry.state') IN
1497
+ ('scheduled', 'dispatching', 'awaiting-progress')${taskPredicate}`).all(...(selectedTaskIds ?? []));
1461
1498
  return rows.sort((left, right) => (numericCompare(left.taskId, right.taskId)
1462
1499
  || numericCompare(left.roleName, right.roleName)
1463
1500
  || numericCompare(left.runId, right.runId)));
@@ -1886,13 +1923,20 @@ export class SqliteTaskStore {
1886
1923
  return rows.map((row) => this.#rowToMailbox(row));
1887
1924
  }
1888
1925
  saveWorkMailbox(mailbox) {
1889
- const cols = this.#mailboxCols(mailbox.target);
1926
+ let validated;
1927
+ try {
1928
+ validated = validateWorkMailbox(mailbox);
1929
+ }
1930
+ catch (error) {
1931
+ throw new StorageRecordError(error instanceof Error ? error.message : String(error));
1932
+ }
1933
+ const cols = this.#mailboxCols(validated.target);
1890
1934
  this.#mutate(() => {
1891
1935
  this.#db.prepare(`INSERT INTO mailboxes (target_kind, task_id, role_name, target_key, next_sequence, processing, pending, input_delivery)
1892
1936
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1893
1937
  ON CONFLICT(target_key) DO UPDATE SET next_sequence = excluded.next_sequence,
1894
1938
  processing = excluded.processing, pending = excluded.pending,
1895
- input_delivery = excluded.input_delivery`).run(cols.targetKind, cols.taskId, cols.roleName, cols.targetKey, mailbox.nextSequence, mailbox.processing === null ? null : this.#json(mailbox.processing), this.#json(mailbox.pending), mailbox.inputDelivery === null ? null : this.#json(mailbox.inputDelivery));
1939
+ input_delivery = excluded.input_delivery`).run(cols.targetKind, cols.taskId, cols.roleName, cols.targetKey, validated.nextSequence, validated.processing === null ? null : this.#json(validated.processing), this.#json(validated.pending), validated.inputDelivery === null ? null : this.#json(validated.inputDelivery));
1896
1940
  });
1897
1941
  }
1898
1942
  removeWorkMailbox(target) {