@zq-silk/yui 0.10.0 → 0.11.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 (46) hide show
  1. package/README.md +53 -0
  2. package/dist/cli/commandCatalog.js +28 -7
  3. package/dist/cli.js +50 -47
  4. package/dist/commands/projectCommands.js +69 -6
  5. package/dist/commands/taskCommands.js +639 -89
  6. package/dist/commands/taskContextCommand.js +78 -27
  7. package/dist/commands/taskNextActionCommand.js +13 -2
  8. package/dist/commands/taskOverviewCommand.js +21 -5
  9. package/dist/commands/taskUpstreamCommands.js +136 -0
  10. package/dist/context/runContextPack.js +184 -17
  11. package/dist/controller/agentRuntimeObserver.js +31 -20
  12. package/dist/controller/fileSchedulerStoreAdapter.js +7 -13
  13. package/dist/execution/candidateConvergence.js +623 -0
  14. package/dist/execution/executionGroup.js +255 -13
  15. package/dist/execution/executionHealth.js +324 -0
  16. package/dist/execution/resourceBroker.js +425 -0
  17. package/dist/executor/fileRoleLaunchPlanner.js +6 -9
  18. package/dist/executor/workspacePreflightClassification.js +117 -0
  19. package/dist/lifecycle/exactRunTerminalization.js +13 -2
  20. package/dist/lifecycle/taskRoleSessionReset.js +4 -2
  21. package/dist/repository/taskBaseFreshness.js +26 -1
  22. package/dist/repository/taskWorkspacePreparer.js +17 -1
  23. package/dist/review/reviewRound.js +27 -6
  24. package/dist/run/agentRun.js +2 -2
  25. package/dist/run/recoveryProjection.js +15 -0
  26. package/dist/runtime/runtimeContinuationProjection.js +7 -0
  27. package/dist/runtime/tmuxAdapters.js +8 -2
  28. package/dist/scheduler/actionability.js +169 -3
  29. package/dist/scheduler/activeTaskProgress.js +15 -10
  30. package/dist/scheduler/leaderWakeupProcessor.js +17 -1
  31. package/dist/scheduler/taskExecutionProjection.js +105 -8
  32. package/dist/scheduler/taskObservabilityProjection.js +282 -0
  33. package/dist/storage/migration/productionRegistry.js +14 -0
  34. package/dist/storage/sqliteStore.js +12 -0
  35. package/dist/storage/taskStore.js +1 -1
  36. package/dist/task/completionReadiness.js +1 -1
  37. package/dist/task/nextAction.js +314 -2
  38. package/dist/web/assets/client/components.js +116 -0
  39. package/dist/web/assets/client/i18n.js +66 -0
  40. package/dist/web/assets/client/view.js +15 -0
  41. package/dist/web/assets/styles/cards.js +23 -0
  42. package/dist/web/assets/styles/responsive.js +2 -0
  43. package/dist/web/webSnapshot.js +8 -2
  44. package/dist/workItem/workItem.js +262 -5
  45. package/i18n/README.zh-CN.md +42 -0
  46. package/package.json +1 -1
@@ -1,14 +1,16 @@
1
1
  import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
2
2
  import { currentWorkItemExecutionGroup } from "../workItem/workItem.js";
3
3
  import { mailboxBatches } from "../coordination/workMailbox.js";
4
- import { summarizeExecutionGroup } from "../execution/executionGroup.js";
4
+ import { actionableExecutionLaneRecoveries, summarizeExecutionGroupHealth } from "../execution/executionHealth.js";
5
+ import { buildTaskObservabilityProjection } from "./taskObservabilityProjection.js";
5
6
  import { isRoleRunStalled, latestStallProgressAt } from "./roleRunStall.js";
7
+ import { resolveRuntimeHealth } from "../config/yuiConfig.js";
6
8
  /**
7
9
  * Build one consistent Task-first projection from the existing durable
8
10
  * aggregate. This function only reads; it never starts a Controller, queues a
9
11
  * wake, writes a Message, or mutates any record.
10
12
  */
11
- export function buildTaskExecutionProjection(store, taskId, taskOverride) {
13
+ export function buildTaskExecutionProjection(store, taskId, taskOverride, now = new Date()) {
12
14
  const task = store.getTask?.(taskId) ?? taskOverride ?? null;
13
15
  if (task === null)
14
16
  return null;
@@ -34,6 +36,9 @@ export function buildTaskExecutionProjection(store, taskId, taskOverride) {
34
36
  ? []
35
37
  : collectExecutionGroups(store.listWorkItems?.(taskId) ?? [], store.listReviewRounds?.(taskId) ?? []),
36
38
  workItems: store.listWorkItems?.(taskId) ?? [],
39
+ ...(store.listContextSnapshots === undefined
40
+ ? {}
41
+ : { contextSnapshots: store.listContextSnapshots(taskId) }),
37
42
  inputRequests: store.listInputRequests?.(taskId) ?? [],
38
43
  ...(store.listReviewRounds === undefined
39
44
  ? {}
@@ -50,7 +55,9 @@ export function buildTaskExecutionProjection(store, taskId, taskOverride) {
50
55
  leaderMailbox,
51
56
  leaderFailure: store.getLeaderFailure?.(taskId) ?? null,
52
57
  operatorNotification: store.getOperatorNotification?.(taskId) ?? null,
53
- roleSessions
58
+ roleSessions,
59
+ now,
60
+ runtimeHealthPolicy: resolveRuntimeHealth(store.getConfig?.().runtimeHealth)
54
61
  });
55
62
  }
56
63
  /**
@@ -72,10 +79,37 @@ export function projectTaskExecutionFromFacts(facts) {
72
79
  export const deriveTaskExecutionProjection = projectTaskExecution;
73
80
  export function projectTaskExecution(facts) {
74
81
  const { task, roles, runs, workItems = [], inputRequests = [], reviewRounds = [], integrations = [], events = [], pendingWakeup = null, leaderMailbox = null, leaderFailure = null, operatorNotification = null, roleSessions = [], executionGroups = [] } = facts;
75
- const groupSummaries = executionGroups.map((group) => summarizeExecutionGroup(group));
82
+ const now = facts.now ?? new Date();
83
+ const groupSummaries = executionGroups.map((group) => {
84
+ const stageGroups = workItems.find((item) => (item.executionGroups.some(({ id }) => id === group.id)))?.executionGroups;
85
+ return summarizeExecutionGroupHealth({
86
+ group,
87
+ ...(stageGroups === undefined ? {} : { stageGroups }),
88
+ runs,
89
+ sessions: roleSessions,
90
+ events,
91
+ now,
92
+ policy: facts.runtimeHealthPolicy
93
+ });
94
+ });
95
+ const observabilityGroups = uniqueExecutionGroups([
96
+ ...executionGroups,
97
+ ...workItems.flatMap((item) => item.executionGroups)
98
+ ]);
99
+ const observability = buildTaskObservabilityProjection({
100
+ workItems,
101
+ executionGroups: observabilityGroups,
102
+ groupSummaries,
103
+ runs,
104
+ events,
105
+ contextSnapshots: facts.contextSnapshots,
106
+ now
107
+ });
108
+ const laneRecovery = actionableExecutionLaneRecoveries(groupSummaries)[0];
76
109
  const render = (input) => projection({
77
110
  ...input,
78
- executionGroups: groupSummaries
111
+ executionGroups: groupSummaries,
112
+ observability
79
113
  });
80
114
  const activeRuns = runs.filter((run) => run.status === "active");
81
115
  const activeRunViews = activeRuns.map((run) => ({
@@ -138,8 +172,10 @@ export function projectTaskExecution(facts) {
138
172
  || attempt.status === "validating"
139
173
  || attempt.status === "blocked"));
140
174
  const hasLeaderMismatch = attention.some((item) => item.kind === "identity-mismatch");
141
- if (attention.length > 0) {
175
+ const renderAttention = () => {
142
176
  const first = attention[0];
177
+ if (first === undefined)
178
+ throw new Error("Task execution attention disappeared.");
143
179
  const progressingWithAttention = first.kind === "checkpoint-overdue"
144
180
  && healthyExecutionCarriers.length > 0;
145
181
  return render({
@@ -159,6 +195,9 @@ export function projectTaskExecution(facts) {
159
195
  blockers,
160
196
  pendingWakeup
161
197
  });
198
+ };
199
+ if (attention.length > 0 && hasLeaderMismatch) {
200
+ return renderAttention();
162
201
  }
163
202
  if (openInputs.length > 0) {
164
203
  return render({
@@ -177,6 +216,30 @@ export function projectTaskExecution(facts) {
177
216
  pendingWakeup
178
217
  });
179
218
  }
219
+ if (laneRecovery !== undefined) {
220
+ return render({
221
+ task,
222
+ status: laneRecovery.runtimeHealth === "confirmed-dead"
223
+ ? "attention"
224
+ : "needs-leader-action",
225
+ owner: "leader",
226
+ action: "recover-execution",
227
+ summary: `Execution Lane ${laneRecovery.laneId} in ${laneRecovery.groupId}`
228
+ + ` requires ${laneRecovery.recovery}`
229
+ + (laneRecovery.runId === undefined ? "." : ` for exact Run ${laneRecovery.runId}.`),
230
+ reason: `execution-lane-${laneRecovery.recovery}`,
231
+ monitoring,
232
+ failClosed: false,
233
+ activeRuns: activeRunViews,
234
+ activeExecutorCount: activeExecutors.length,
235
+ attention,
236
+ blockers,
237
+ pendingWakeup
238
+ });
239
+ }
240
+ if (attention.length > 0) {
241
+ return renderAttention();
242
+ }
180
243
  if (blockedIntegration || failedWork || hasLeaderMismatch) {
181
244
  return render({
182
245
  task,
@@ -335,19 +398,53 @@ function projection(input) {
335
398
  activeExecutorCount: input.activeExecutorCount,
336
399
  activeRuns: input.activeRuns,
337
400
  executionGroups: input.executionGroups ?? [],
401
+ observability: input.observability,
338
402
  attention: input.attention,
339
403
  blockers: input.blockers,
340
404
  pendingWakeup: input.pendingWakeup,
341
405
  next: { owner: input.owner, action: input.action }
342
406
  };
343
407
  }
408
+ function uniqueExecutionGroups(groups) {
409
+ const seen = new Set();
410
+ return groups.filter((group) => {
411
+ if (seen.has(group.id))
412
+ return false;
413
+ seen.add(group.id);
414
+ return true;
415
+ });
416
+ }
344
417
  function collectExecutionGroups(workItems, reviewRounds) {
418
+ const workItemsById = new Map(workItems.map((item) => [item.id, item]));
419
+ const orderedRounds = [...reviewRounds].sort((left, right) => (left.id.localeCompare(right.id, undefined, { numeric: true })));
420
+ const latestTaskRound = orderedRounds
421
+ .filter((round) => (round.scope ?? "work-item") === "task")
422
+ .at(-1);
423
+ const latestWorkItemRounds = new Map();
424
+ for (const round of orderedRounds) {
425
+ if ((round.scope ?? "work-item") === "task"
426
+ || round.workItemId === undefined
427
+ || round.candidateId === undefined)
428
+ continue;
429
+ const item = workItemsById.get(round.workItemId);
430
+ if (item === undefined
431
+ || item.status === "retired"
432
+ || item.candidates.at(-1)?.id !== round.candidateId)
433
+ continue;
434
+ latestWorkItemRounds.set(`${round.workItemId}\0${round.candidateId}`, round);
435
+ }
436
+ const operationalReviewRoundIds = new Set([
437
+ ...(latestTaskRound === undefined ? [] : [latestTaskRound.id]),
438
+ ...[...latestWorkItemRounds.values()].map(({ id }) => id)
439
+ ]);
345
440
  const groups = [
346
- ...workItems.flatMap((item) => {
441
+ ...workItems.filter(({ status }) => status !== "retired").flatMap((item) => {
347
442
  const group = currentWorkItemExecutionGroup(item);
348
443
  return group === undefined ? [] : [group];
349
444
  }),
350
- ...reviewRounds.flatMap((round) => round.executionGroup === undefined ? [] : [round.executionGroup])
445
+ ...reviewRounds.flatMap((round) => (!operationalReviewRoundIds.has(round.id) || round.executionGroup === undefined
446
+ ? []
447
+ : [round.executionGroup]))
351
448
  ];
352
449
  const seen = new Set();
353
450
  return groups.filter((group) => {
@@ -0,0 +1,282 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { observedExecutionResourceUsage } from "../execution/resourceBroker.js";
3
+ import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
4
+ /**
5
+ * Build the read-only DAG, execution, cost, and context view consumed by CLI
6
+ * and Web. It deliberately derives every value from existing Task records and
7
+ * never persists or repairs a second graph/status authority.
8
+ */
9
+ export function buildTaskObservabilityProjection(input) {
10
+ const now = input.now ?? new Date();
11
+ const summariesById = new Map((input.groupSummaries ?? []).map((summary) => [summary.groupId, summary]));
12
+ const dag = projectDag(input.workItems);
13
+ const workItems = input.workItems.map((item) => {
14
+ const groups = item.executionGroups;
15
+ const executionGroups = groups.flatMap((group) => {
16
+ const summary = summariesById.get(group.id);
17
+ return summary === undefined ? [] : [summary];
18
+ });
19
+ const stages = groups.map((group) => {
20
+ const summary = summariesById.get(group.id);
21
+ return Object.freeze({
22
+ groupId: group.id,
23
+ ...(group.stage === undefined ? {} : {
24
+ mode: group.stage.mode,
25
+ stage: group.stage.stage,
26
+ round: group.stage.round,
27
+ stageAttempt: group.stage.stageAttempt
28
+ }),
29
+ laneCount: group.lanes.length,
30
+ activeLaneCount: group.lanes.filter(({ status }) => status === "pending" || status === "running").length,
31
+ terminalLaneCount: group.lanes.filter(({ status }) => isTerminalLane(status)).length,
32
+ ...(group.resolution === undefined ? {} : { resolution: group.resolution.decision }),
33
+ ...(summary?.resources === undefined ? {} : { resources: summary.resources })
34
+ });
35
+ });
36
+ const itemCost = projectCost(groups, input.runs, input.events, now);
37
+ const itemContext = projectContext(groups, input.runs, input.events, input.contextSnapshots);
38
+ const evidence = groups.flatMap((group) => group.lanes.flatMap((lane) => (lane.result?.evidence ?? [])));
39
+ const openFindingCount = groups.reduce((count, group) => count + group.lanes.reduce((laneCount, lane) => laneCount + (lane.result?.findings ?? [])
40
+ .filter(({ status }) => status === "open").length, 0), 0);
41
+ return Object.freeze({
42
+ workItemId: item.id,
43
+ title: item.title,
44
+ status: item.status,
45
+ ...(item.currentExecutionGroupId === undefined ? {} : { currentGroupId: item.currentExecutionGroupId }),
46
+ groupIds: groups.map(({ id }) => id),
47
+ executionGroups,
48
+ stages,
49
+ cost: itemCost,
50
+ context: itemContext,
51
+ evidenceCount: evidence.length,
52
+ openFindingCount
53
+ });
54
+ });
55
+ const cost = projectCost(input.executionGroups, input.runs, input.events, now);
56
+ const context = projectContext(input.executionGroups, input.runs, input.events, input.contextSnapshots);
57
+ return Object.freeze({ dag, workItems, cost, context });
58
+ }
59
+ function projectDag(workItems) {
60
+ const byId = new Map(workItems.map((item) => [item.id, item]));
61
+ const dependents = new Map();
62
+ for (const item of workItems)
63
+ dependents.set(item.id, []);
64
+ const edges = [];
65
+ for (const item of workItems) {
66
+ for (const dependency of item.dependsOn) {
67
+ const target = byId.get(dependency);
68
+ const status = dependencyEdgeStatus(dependency, byId);
69
+ edges.push({ from: dependency, to: item.id, status });
70
+ dependents.get(dependency)?.push(item.id);
71
+ }
72
+ }
73
+ const nodes = workItems.map((item) => {
74
+ const unresolved = item.dependsOn.filter((dependency) => {
75
+ return !dependencySatisfied(dependency, byId);
76
+ });
77
+ const projectedStatus = item.status === "pending"
78
+ ? unresolved.length === 0 ? "ready" : "blocked"
79
+ : item.status;
80
+ return Object.freeze({
81
+ id: item.id,
82
+ title: item.title,
83
+ status: item.status,
84
+ projectedStatus,
85
+ dependsOn: item.dependsOn,
86
+ dependentIds: Object.freeze([...(dependents.get(item.id) ?? [])]),
87
+ rootCauseIds: Object.freeze(rootCauses(item.id, byId)),
88
+ ...(item.disposition?.replacementWorkItemId === undefined
89
+ ? {}
90
+ : { replacementWorkItemId: item.disposition.replacementWorkItemId })
91
+ });
92
+ });
93
+ return Object.freeze({
94
+ nodes,
95
+ edges: Object.freeze(edges),
96
+ readyIds: Object.freeze(nodes.filter(({ projectedStatus }) => projectedStatus === "ready").map(({ id }) => id)),
97
+ blockedIds: Object.freeze(nodes.filter(({ projectedStatus }) => projectedStatus === "blocked").map(({ id }) => id))
98
+ });
99
+ }
100
+ function rootCauses(id, byId) {
101
+ const result = [];
102
+ const visited = new Set();
103
+ const visit = (currentId) => {
104
+ if (visited.has(currentId))
105
+ return;
106
+ visited.add(currentId);
107
+ const item = byId.get(currentId);
108
+ if (item === undefined) {
109
+ result.push(currentId);
110
+ return;
111
+ }
112
+ const unresolved = item.dependsOn.filter((dependency) => !dependencySatisfied(dependency, byId));
113
+ if (unresolved.length === 0) {
114
+ if (item.status === "failed" || item.status === "awaiting_acceptance")
115
+ result.push(item.id);
116
+ return;
117
+ }
118
+ for (const dependency of unresolved) {
119
+ const target = resolveDependency(dependency, byId);
120
+ if (target?.status === "failed" || target?.status === "awaiting_acceptance")
121
+ result.push(target.id);
122
+ else
123
+ visit(dependency);
124
+ }
125
+ };
126
+ visit(id);
127
+ return [...new Set(result)];
128
+ }
129
+ function dependencySatisfied(id, byId) {
130
+ const target = resolveDependency(id, byId);
131
+ return target !== undefined && (target.status === "completed" || (target.status === "retired" && target.disposition?.replacementWorkItemId === undefined));
132
+ }
133
+ function dependencyEdgeStatus(id, byId) {
134
+ const target = byId.get(id);
135
+ if (target === undefined)
136
+ return "dead";
137
+ if (dependencySatisfied(id, byId))
138
+ return "satisfied";
139
+ const resolved = resolveDependency(id, byId);
140
+ if (resolved === undefined)
141
+ return "dead";
142
+ if (resolved.status === "failed" || resolved.status === "awaiting_acceptance")
143
+ return "failed-open";
144
+ return "active";
145
+ }
146
+ function resolveDependency(id, byId) {
147
+ const visited = new Set();
148
+ let current = byId.get(id);
149
+ while (current?.status === "retired" && current.disposition?.replacementWorkItemId !== undefined) {
150
+ if (visited.has(current.id))
151
+ return undefined;
152
+ visited.add(current.id);
153
+ current = byId.get(current.disposition.replacementWorkItemId);
154
+ }
155
+ return current;
156
+ }
157
+ function projectCost(groups, runs, events, now) {
158
+ const stageGroups = latestStageGroups(groups);
159
+ let tokens = 0;
160
+ let toolCalls = 0;
161
+ let wallClockSeconds = 0;
162
+ let tokensObservable = true;
163
+ let toolCallsObservable = true;
164
+ let laneCount = 0;
165
+ let retryCount = 0;
166
+ for (const group of stageGroups) {
167
+ const lineage = groups.filter((candidate) => sameStage(candidate, group));
168
+ const usage = observedExecutionResourceUsage({ group, stageGroups: lineage, runs, events });
169
+ tokens += usage.tokens;
170
+ toolCalls += usage.toolCalls;
171
+ tokensObservable = tokensObservable && (usage.tokensObservable ?? true);
172
+ toolCallsObservable = toolCallsObservable && (usage.toolCallsObservable ?? true);
173
+ wallClockSeconds += stageDurationSeconds(lineage, now);
174
+ laneCount += group.lanes.length;
175
+ retryCount += Math.max(0, lineage.length - 1);
176
+ }
177
+ return Object.freeze({
178
+ tokens,
179
+ toolCalls,
180
+ wallClockSeconds,
181
+ tokensObservable,
182
+ toolCallsObservable,
183
+ laneCount,
184
+ groupCount: stageGroups.length,
185
+ retryCount,
186
+ marginalValuePercent: null,
187
+ marginalValueStatus: "unavailable"
188
+ });
189
+ }
190
+ function projectContext(groups, runs, events, snapshots) {
191
+ const refs = new Map();
192
+ for (const group of groups) {
193
+ const ref = group.stage?.contextSnapshotRef;
194
+ if (ref !== undefined)
195
+ refs.set(ref.id, ref);
196
+ }
197
+ for (const run of runs) {
198
+ const ref = run.assignment.contextSnapshotRef;
199
+ if (ref !== undefined)
200
+ refs.set(ref.id, ref);
201
+ }
202
+ const snapshotById = new Map((snapshots ?? []).map((snapshot) => [snapshot.id, snapshot]));
203
+ const metrics = [...refs.values()].sort((left, right) => left.sequence - right.sequence || left.id.localeCompare(right.id))
204
+ .map((ref) => {
205
+ const snapshot = snapshotById.get(ref.id);
206
+ const byteSize = snapshot === undefined ? null : Buffer.byteLength(JSON.stringify(snapshot), "utf8");
207
+ return Object.freeze({
208
+ id: ref.id,
209
+ scope: ref.scope,
210
+ sequence: ref.sequence,
211
+ digest: ref.digest,
212
+ refCount: snapshot?.refs.length ?? null,
213
+ resourceCount: snapshot?.resources.length ?? null,
214
+ byteSize,
215
+ ...(snapshot?.parentRef === undefined ? {} : { parentId: snapshot.parentRef.id })
216
+ });
217
+ });
218
+ const sizes = metrics.flatMap(({ byteSize }) => byteSize === null ? [] : [byteSize]);
219
+ const observedInputPeakTokens = events.flatMap((event) => {
220
+ const observation = runtimeObservationFromTaskEvent(event);
221
+ const inputTokens = observation?.payload.usage?.inputTokens;
222
+ return inputTokens === undefined ? [] : [inputTokens];
223
+ }).reduce((peak, value) => Math.max(peak, value), 0);
224
+ return Object.freeze({
225
+ snapshotCount: metrics.length,
226
+ snapshots: Object.freeze(metrics),
227
+ totalBytes: sizes.length === metrics.length ? sizes.reduce((sum, size) => sum + size, 0) : null,
228
+ largestBytes: sizes.length === 0 ? null : Math.max(...sizes),
229
+ observedInputPeakTokens,
230
+ compressionEvents: null,
231
+ compressionRatio: null,
232
+ compressionStatus: "unavailable"
233
+ });
234
+ }
235
+ function latestStageGroups(groups) {
236
+ const latest = new Map();
237
+ for (const group of groups) {
238
+ const key = group.stage === undefined ? `group:${group.id}` : stageKey(group);
239
+ const existing = latest.get(key);
240
+ if (existing === undefined || compareStageGroup(existing, group) < 0)
241
+ latest.set(key, group);
242
+ }
243
+ return [...latest.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
244
+ }
245
+ function sameStage(left, right) {
246
+ if (left.stage === undefined || right.stage === undefined)
247
+ return left.id === right.id;
248
+ return left.taskId === right.taskId
249
+ && executionTargetKey(left) === executionTargetKey(right)
250
+ && left.stage.mode === right.stage.mode
251
+ && left.stage.stage === right.stage.stage
252
+ && left.stage.round === right.stage.round
253
+ && isDeepStrictEqual(left.stage.budget, right.stage.budget)
254
+ && isDeepStrictEqual(left.stage.resources, right.stage.resources);
255
+ }
256
+ function stageKey(group) {
257
+ const stage = group.stage;
258
+ return `${executionTargetKey(group)}\0${stage.mode}\0${stage.stage}\0${stage.round}`;
259
+ }
260
+ function executionTargetKey(group) {
261
+ const target = group.target;
262
+ return `${target.kind}\0${target.workItemId ?? ""}\0${target.candidateId ?? ""}`;
263
+ }
264
+ function compareStageGroup(left, right) {
265
+ return (left.stage?.stageAttempt ?? 1) - (right.stage?.stageAttempt ?? 1)
266
+ || left.updatedAt.localeCompare(right.updatedAt)
267
+ || left.id.localeCompare(right.id);
268
+ }
269
+ function stageDurationSeconds(groups, now) {
270
+ const started = groups.map(({ createdAt }) => Date.parse(createdAt)).filter(Number.isFinite);
271
+ const ended = groups.map((group) => Date.parse(group.updatedAt)).filter(Number.isFinite);
272
+ if (started.length === 0)
273
+ return 0;
274
+ const hasOpenWork = groups.some((group) => group.lanes.some(({ status }) => (status === "pending" || status === "running")));
275
+ const end = hasOpenWork || ended.length === 0
276
+ ? now.getTime()
277
+ : Math.max(...ended);
278
+ return Math.max(0, Math.floor((end - Math.min(...started)) / 1_000));
279
+ }
280
+ function isTerminalLane(status) {
281
+ return status === "yielded" || status === "completed" || status === "failed" || status === "skipped";
282
+ }
@@ -28,6 +28,12 @@ const WORK_ITEM_GIT_SNAPSHOT_FROM_VERSION = 7;
28
28
  const WORK_ITEM_GIT_SNAPSHOT_TO_VERSION = 8;
29
29
  const WORK_ITEM_GROUP_HISTORY_FROM_VERSION = 8;
30
30
  const WORK_ITEM_GROUP_HISTORY_TO_VERSION = 9;
31
+ const WORK_ITEM_EXPLORATION_STAGE_FROM_VERSION = 9;
32
+ const WORK_ITEM_EXPLORATION_STAGE_TO_VERSION = 10;
33
+ const WORK_ITEM_CANDIDATE_CONVERGENCE_FROM_VERSION = 10;
34
+ const WORK_ITEM_CANDIDATE_CONVERGENCE_TO_VERSION = 11;
35
+ const WORK_ITEM_RESOURCE_SCHEDULING_FROM_VERSION = 11;
36
+ const WORK_ITEM_RESOURCE_SCHEDULING_TO_VERSION = 12;
31
37
  const AGENT_RUN_FROM_VERSION = 5;
32
38
  const AGENT_RUN_TO_VERSION = 6;
33
39
  /**
@@ -144,6 +150,14 @@ export function createProductionStorageRegistry() {
144
150
  .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_FROM_VERSION, WORK_ITEM_TO_VERSION, "workItems"))
145
151
  .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_GIT_SNAPSHOT_FROM_VERSION, WORK_ITEM_GIT_SNAPSHOT_TO_VERSION, "workItems"))
146
152
  .registerOfflineMigration(workItemExecutionGroupHistoryStep())
153
+ .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_EXPLORATION_STAGE_FROM_VERSION, WORK_ITEM_EXPLORATION_STAGE_TO_VERSION, "workItems"))
154
+ // Candidate convergence is frozen only on newly created exploration
155
+ // histories. Existing valid T4 histories remain byte-for-byte evidence,
156
+ // so this adjacent persistent transition advances only the family version.
157
+ .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_CANDIDATE_CONVERGENCE_FROM_VERSION, WORK_ITEM_CANDIDATE_CONVERGENCE_TO_VERSION, "workItems"))
158
+ // Existing v11 histories remain valid immutable evidence without a T6
159
+ // resource policy. Newly planned stages always freeze the full policy.
160
+ .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_RESOURCE_SCHEDULING_FROM_VERSION, WORK_ITEM_RESOURCE_SCHEDULING_TO_VERSION, "workItems"))
147
161
  .registerOfflineMigration(recordFamilyStep("agentRun", AGENT_RUN_FROM_VERSION, AGENT_RUN_TO_VERSION, "agentRuns"))
148
162
  .registerOfflineMigration(recordFamilyStep("agentRun", AGENT_RUN_OPTIONAL_FIELDS_FROM_VERSION, AGENT_RUN_OPTIONAL_FIELDS_TO_VERSION, "agentRuns"))
149
163
  .registerOfflineMigration(agentRunContextProtocolStep())
@@ -1480,6 +1480,18 @@ export class SqliteTaskStore {
1480
1480
  saveAgentRun(run) {
1481
1481
  if (run.taskId !== undefined)
1482
1482
  this.#requireTask(run.taskId);
1483
+ if (run.reviewRoundId !== undefined) {
1484
+ const round = this.getReviewRound(run.taskId, run.reviewRoundId);
1485
+ if (round === null) {
1486
+ throw new StorageRecordError(`Agent run ReviewRound not found: ${run.reviewRoundId}.`);
1487
+ }
1488
+ const laneRole = round.executionGroup?.lanes
1489
+ .find(({ id }) => id === run.executionLaneId)?.roleName;
1490
+ if (round.workItemId !== run.workItemId
1491
+ || (round.reviewerRoleName !== run.roleName && laneRole !== run.roleName)) {
1492
+ throw new StorageRecordError(`Agent run does not match ReviewRound: ${run.id}.`);
1493
+ }
1494
+ }
1483
1495
  this.#mutate(() => {
1484
1496
  this.#db.prepare(`INSERT INTO agent_runs (task_id, run_id, role_name, status, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?)
1485
1497
  ON CONFLICT(task_id, run_id) DO UPDATE SET role_name = excluded.role_name, status = excluded.status,
@@ -67,7 +67,7 @@ export const CURRENT_TASK_BRIEF_SCHEMA_VERSION = 2;
67
67
  export const CURRENT_CONTEXT_SNAPSHOT_SCHEMA_VERSION = 1;
68
68
  export const CURRENT_TASK_ROLE_SCHEMA_VERSION = 3;
69
69
  export const CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION = 2;
70
- export const CURRENT_WORK_ITEM_SCHEMA_VERSION = 9;
70
+ export const CURRENT_WORK_ITEM_SCHEMA_VERSION = 12;
71
71
  export const CURRENT_REVIEW_ROUND_SCHEMA_VERSION = 5;
72
72
  export const CURRENT_CHANGE_SET_SCHEMA_VERSION = 3;
73
73
  export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION = 4;
@@ -16,7 +16,7 @@ const UNRESOLVED_INTEGRATION_STATUSES = new Set([
16
16
  "validating"
17
17
  ]);
18
18
  const TERMINAL_REVIEW_STATUSES = new Set(["completed", "failed"]);
19
- const TERMINAL_LANE_STATUSES = new Set(["completed", "failed", "yielded"]);
19
+ const TERMINAL_LANE_STATUSES = new Set(["completed", "failed", "yielded", "skipped"]);
20
20
  export function projectCompletionReadiness(facts, options = {}) {
21
21
  const blockers = [];
22
22
  const advisories = [];