@zq-silk/yui 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +36 -13
  3. package/dist/cli/commandCatalog.js +2 -4
  4. package/dist/cli.js +105 -43
  5. package/dist/commands/configCommands.js +12 -45
  6. package/dist/commands/executionAuditCommands.js +1 -1
  7. package/dist/commands/jobCommands.js +4 -10
  8. package/dist/commands/taskCommands.js +154 -171
  9. package/dist/commands/taskCompletionGate.js +29 -54
  10. package/dist/commands/taskContextCommand.js +55 -11
  11. package/dist/commands/taskInputCommands.js +8 -5
  12. package/dist/commands/taskNextActionCommand.js +56 -6
  13. package/dist/commands/taskOverviewCommand.js +17 -31
  14. package/dist/commands/taskRoleRuntimeStatus.js +24 -0
  15. package/dist/commands/taskWorkspaceCommands.js +5 -2
  16. package/dist/config/configCatalog.js +2 -2
  17. package/dist/config/yuiConfig.js +3 -7
  18. package/dist/context/wakeNotification.js +20 -5
  19. package/dist/controller/agentRuntimeObserver.js +247 -51
  20. package/dist/controller/clientRuntime.js +1 -1
  21. package/dist/controller/controller.js +8 -12
  22. package/dist/controller/fileSchedulerStoreAdapter.js +146 -198
  23. package/dist/controller/runtime.js +3 -3
  24. package/dist/controller/runtimeEventInbox.js +17 -7
  25. package/dist/controller/runtimeEventProcessor.js +12 -19
  26. package/dist/controller/runtimeObservationHook.js +45 -0
  27. package/dist/execution/resourceBroker.js +5 -4
  28. package/dist/executor/agentExecutor.js +4 -4
  29. package/dist/executor/effectiveLaunch.js +8 -48
  30. package/dist/executor/fileRoleLaunchPlanner.js +36 -16
  31. package/dist/executor/workspacePreflightClassification.js +23 -2
  32. package/dist/interaction/operatorPresentation.js +33 -89
  33. package/dist/lifecycle/exactRunTerminalization.js +1 -2
  34. package/dist/lifecycle/taskRoleSessionReset.js +5 -13
  35. package/dist/observability/orchestrationMetrics.js +8 -3
  36. package/dist/profile/agentProfile.js +1 -1
  37. package/dist/release/cliHomeReleaseFence.js +123 -0
  38. package/dist/repository/taskWorkspacePreparer.js +176 -60
  39. package/dist/resources/sqliteResourceRegistry.js +1 -1
  40. package/dist/review/deltaRecheck.js +12 -51
  41. package/dist/review/reviewAcceptance.js +26 -0
  42. package/dist/review/reviewConfig.js +0 -31
  43. package/dist/review/reviewDecision.js +113 -0
  44. package/dist/review/reviewOutcomeClassifier.js +1 -1
  45. package/dist/review/reviewRound.js +1 -1
  46. package/dist/review/reviewerAvailability.js +69 -0
  47. package/dist/runtime/agentDriverObservation.js +24 -10
  48. package/dist/runtime/builtinAgentDrivers.js +4 -3
  49. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  50. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  51. package/dist/runtime/index.js +1 -0
  52. package/dist/runtime/runtimeObservation.js +13 -0
  53. package/dist/runtime/runtimeProjection.js +16 -25
  54. package/dist/runtime/sessionTokenMetrics.js +181 -0
  55. package/dist/runtime/structuredProviderHost.js +7 -1
  56. package/dist/scheduler/activeRoleRunDelivery.js +4 -4
  57. package/dist/scheduler/activeTaskProgress.js +7 -6
  58. package/dist/scheduler/leaderWakeupProcessor.js +3 -5
  59. package/dist/scheduler/operatorEvent.js +34 -0
  60. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  61. package/dist/scheduler/roleRunStall.js +0 -14
  62. package/dist/scheduler/taskExecutionProjection.js +97 -76
  63. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  64. package/dist/storage/migration/productionRegistry.js +379 -0
  65. package/dist/storage/sqliteSchema.js +18 -8
  66. package/dist/storage/sqliteStore.js +24 -20
  67. package/dist/storage/storeRpc.js +0 -1
  68. package/dist/storage/taskStore.js +15 -30
  69. package/dist/storage/upgrade/recordVersions.js +3 -2
  70. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  71. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  72. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  73. package/dist/task/completionReadiness.js +10 -6
  74. package/dist/task/nextAction.js +82 -24
  75. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  76. package/dist/web/assets/client/components.js +13 -3
  77. package/dist/web/assets/client/i18n.js +6 -2
  78. package/dist/web/webSnapshot.js +4 -0
  79. package/i18n/README.zh-CN.md +2 -2
  80. package/package.json +1 -1
  81. package/skills/yui-leader/SKILL.md +53 -16
  82. package/skills/yui-operator/SKILL.md +9 -0
  83. package/skills/yui-reviewer/SKILL.md +28 -7
  84. package/dist/context/sessionContextBudget.js +0 -71
  85. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  86. package/dist/scheduler/operatorNotification.js +0 -59
@@ -227,13 +227,9 @@ export function resolveTmuxHistoryLimit(value) {
227
227
  return resolveBoundedPositiveInteger(value, DEFAULT_TMUX_HISTORY_LIMIT, 1_000, 1_000_000, "tmuxHistoryLimit");
228
228
  }
229
229
  /**
230
- * Issue 04 (context token budget): thresholds for one native Session
231
- * generation's observed per-request input peak, measured in tokens. When the
232
- * peak crosses the soft threshold the Leader wake carries a checkpoint
233
- * advisory; when it crosses the hard threshold the scheduler retires the
234
- * generation and starts a fresh one instead of waiting for provider-side
235
- * auto-compaction. The fields are additive and optional — Homes without them
236
- * keep these defaults, so no config migration is required.
230
+ * Compatibility parser for the retired context-budget setting. Existing
231
+ * Homes may retain these values, but no runtime, scheduler, or lifecycle path
232
+ * consumes them. Session Token metrics are an independent read-only view.
237
233
  */
238
234
  export const DEFAULT_CONTEXT_SOFT_TOKENS = 100_000;
239
235
  export const DEFAULT_CONTEXT_HARD_TOKENS = 120_000;
@@ -4,17 +4,18 @@ import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
4
4
  * Issue 04 (context token budget) — long-term design:
5
5
  *
6
6
  * A Leader wake is a NOTIFICATION, not a context dump. The wake envelope
7
- * carries only what the Agent cannot reconstruct from durable records:
8
- * the wake id, the aggregated reason tags, and the delta window. The Agent
9
- * reads the delta content on demand with `yui task wake show <wake-id>` and
10
- * the full projection with `yui task context <task>`.
7
+ * carries only what the Agent needs for immediate orientation: the wake id,
8
+ * aggregated reason tags, delta window, and a bounded list of active frozen
9
+ * Task Reviews. The Agent reads delta content on demand with
10
+ * `yui task wake show <wake-id>` and the full projection with
11
+ * `yui task context <task>`.
11
12
  *
12
13
  * The envelope is mode-agnostic: fresh generations and resumed generations
13
14
  * receive the same minimal text. The native Session is a disposable cache of
14
15
  * working context; Yui's durable Task records (including the wake ledger) are
15
16
  * the checkpoint.
16
17
  */
17
- /** Structural guardrail: the envelope must stay an order of magnitude below any context budget. */
18
+ /** Structural guardrail: the envelope stays far below a normal model context window. */
18
19
  export const WAKE_ENVELOPE_HARD_BYTES = 2_000;
19
20
  /** Maximum reason tags rendered before elision to a count. */
20
21
  const REASON_DISPLAY_LIMIT = 6;
@@ -35,11 +36,25 @@ export function buildTaskWakeEnvelope(reader, request) {
35
36
  runs: operationalTaskRecords(reader.listAgentRuns(request.taskId), events, "agent-run")
36
37
  .filter((record) => Date.parse(record.createdAt) > fromTime).length
37
38
  };
39
+ const activeReviews = reader.listReviewRounds(request.taskId).filter((round) => ((round.scope ?? "work-item") === "task"
40
+ && (round.status === "pending" || round.status === "running")));
41
+ const reviewOrientation = activeReviews.slice(0, 3).map((round) => {
42
+ const projects = round.taskCandidate?.projects ?? [];
43
+ const heads = projects.slice(0, 2).map(({ projectId, commit }) => (`${projectId}@${commit.slice(0, 12)}`)).join("+");
44
+ return `${round.id}/${round.reviewerRoleName}`
45
+ + `/${round.deltaRecheck === undefined ? "full" : "delta"}`
46
+ + `[${round.status}]`
47
+ + `@${heads || round.reviewBaseCommit.slice(0, 12)}`
48
+ + `${projects.length > 2 ? `+${projects.length - 2}` : ""}`;
49
+ }).join(", ");
38
50
  const lines = [
39
51
  `Wake: ${request.wakeId} — delta since ${request.fromCursor}`,
40
52
  ` Reasons: ${renderReasons(request.reasons)}`,
41
53
  ` Changed: ${counts.events} events, ${counts.messages} messages, ${counts.runs} runs`
42
54
  + ` → yui task wake show ${request.taskId} ${request.wakeId}`,
55
+ ` Active Task Reviews: ${activeReviews.length === 0
56
+ ? "none"
57
+ : `${reviewOrientation}${activeReviews.length > 3 ? `, … (+${activeReviews.length - 3})` : ""}`}`,
43
58
  `Full context: yui task context ${request.taskId}`
44
59
  ];
45
60
  const body = lines.join("\n");
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
3
- import { createRuntimeObservation, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
3
+ import { createRuntimeObservation, runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
4
4
  const DEFAULT_MAX_CONCURRENT_SAMPLES = 8;
5
5
  const MAX_CONCURRENT_SAMPLES = 64;
6
6
  /**
@@ -31,20 +31,30 @@ export class AgentRuntimeObserver {
31
31
  const dirty = new Set();
32
32
  const sequenceBase = this.#sequence;
33
33
  this.#sequence += active.length;
34
- await forEachConcurrent(active, this.#maxConcurrentSamples, async ({ key, fence, source, freshSession, persistedState }, index) => {
34
+ await forEachConcurrent(active, this.#maxConcurrentSamples, async ({ key, fence, source, persistedState }, index) => {
35
35
  const existingState = this.#states.get(key);
36
36
  // Cursor state is intentionally process-local, but the latest canonical
37
37
  // usage/activity baseline is durable. Rehydrate it after Controller
38
38
  // restart so rereading the bounded transcript tail cannot manufacture a
39
39
  // fresh activity edge from tokens that were already observed.
40
40
  const state = existingState ?? { ...persistedState };
41
+ const restoredUsageEventId = existingState === undefined
42
+ ? persistedState.usageEventId
43
+ : undefined;
41
44
  const driver = this.drivers.require(fence.driverId);
42
45
  const observer = driver.runtime.observer;
43
46
  if (observer === undefined)
44
47
  return;
45
48
  let sample;
46
49
  try {
47
- sample = await observer.sample(source, state.cursor);
50
+ sample = await observer.sample(source, state.cursor, {
51
+ ...(state.usageOccurrenceId === undefined
52
+ ? {}
53
+ : { latestOccurrenceId: state.usageOccurrenceId }),
54
+ ...(state.usageOccurrenceCheckpoint === undefined
55
+ ? {}
56
+ : { latestCheckpoint: state.usageOccurrenceCheckpoint })
57
+ });
48
58
  }
49
59
  catch (error) {
50
60
  sample = Object.freeze({
@@ -59,37 +69,70 @@ export class AgentRuntimeObserver {
59
69
  // latency can change completion order without changing observation
60
70
  // identity or the canonical sequence assigned to a source.
61
71
  const sequence = sequenceBase + index;
62
- if (existingState === undefined && state.usage === undefined) {
63
- // A fresh native Session begins at zero. A resumed Session instead
64
- // freezes its first cumulative sample as this Run's lower-bound
65
- // baseline so later samples can prove spend without charging usage
66
- // that belongs to an earlier Run in the same native conversation.
67
- const baseline = freshSession
68
- ? Object.freeze({
69
- semantics: "cumulative-session",
70
- inputTokens: 0,
71
- outputTokens: 0
72
- })
73
- : sample.usage?.semantics === "cumulative-session"
74
- ? sample.usage
75
- : undefined;
76
- if (baseline !== undefined) {
77
- const baselineKey = freshSession ? "zero" : JSON.stringify(baseline);
72
+ let usages = sample.usages ?? [];
73
+ if (state.usage === undefined) {
74
+ // Cumulative counters need a lower bound. An exact initial sample
75
+ // proves the complete Session-generation history even when the
76
+ // current Run resumed that Session, so preserve every occurrence and
77
+ // anchor it at zero. A partial sample cannot prove the omitted request
78
+ // boundaries; retain its latest total as a partial baseline, plus the
79
+ // smallest ordered witness if the visible counter already rolled back.
80
+ // Request snapshots already describe one complete occurrence and must
81
+ // not be mixed with a synthetic cumulative fact.
82
+ const usageOccurrences = usages.filter(hasUsageSnapshot);
83
+ const latestOccurrence = usageOccurrences.at(-1);
84
+ const completeHistory = latestOccurrence?.usage.semantics === "cumulative-session"
85
+ && usages.every(({ observationQuality }) => observationQuality !== "partial");
86
+ const rollbackWitness = latestOccurrence?.usage.semantics === "cumulative-session"
87
+ && !completeHistory
88
+ ? cumulativeRollbackWitness(usageOccurrences)
89
+ : [];
90
+ const baseline = latestOccurrence?.usage.semantics !== "cumulative-session"
91
+ ? undefined
92
+ : completeHistory
93
+ ? Object.freeze({
94
+ semantics: "cumulative-session",
95
+ inputTokens: 0,
96
+ outputTokens: 0
97
+ })
98
+ : latestOccurrence.usage;
99
+ if (baseline !== undefined && rollbackWitness.length === 0) {
100
+ const baselineKey = completeHistory ? "zero" : latestOccurrence.occurrenceId;
101
+ const identity = completeHistory
102
+ ? tokenObservationIdentity("baseline", fence, source.sourceId, baselineKey)
103
+ : usageObservationIdentity(fence, source.sourceId, latestOccurrence);
78
104
  this.inbox.enqueueObservation(createRuntimeObservation({
79
105
  schemaVersion: 2,
80
- eventId: observationId("baseline", fence, source.sourceId, baselineKey),
81
- semanticKey: observationId("baseline", fence, source.sourceId, baselineKey),
106
+ eventId: identity.eventId,
107
+ semanticKey: identity.semanticKey,
82
108
  kind: "activity.observed",
83
- authority: freshSession ? "controller" : "driver-inferred",
109
+ authority: completeHistory ? "controller" : "driver-inferred",
84
110
  receivedAt: at,
85
111
  sequence,
86
112
  ordinal: 1,
87
113
  fence,
88
- payload: { activity: "model", usage: baseline }
114
+ payload: {
115
+ activity: "model",
116
+ sourceId: source.sourceId,
117
+ usage: baseline,
118
+ ...(completeHistory
119
+ ? {}
120
+ : { observationQuality: "partial" })
121
+ }
89
122
  }));
90
123
  state.usage = baseline;
91
- dirty.add(`role:${fence.taskId}/${fence.roleName}`);
124
+ state.usageEventId = identity.eventId;
125
+ state.usageOccurrenceId = completeHistory
126
+ ? undefined
127
+ : latestOccurrence.occurrenceId;
128
+ state.usageOccurrenceCheckpoint = completeHistory
129
+ ? undefined
130
+ : latestOccurrence.resumeCheckpoint;
131
+ if (!completeHistory)
132
+ usages = [];
92
133
  }
134
+ if (rollbackWitness.length > 0)
135
+ usages = rollbackWitness;
93
136
  }
94
137
  const health = JSON.stringify([sample.status, sample.detail ?? null]);
95
138
  if (state.health !== health) {
@@ -112,32 +155,81 @@ export class AgentRuntimeObserver {
112
155
  state.health = health;
113
156
  dirty.add(`role:${fence.taskId}/${fence.roleName}`);
114
157
  }
115
- const usageChanged = sample.usage !== undefined
116
- && !sameUsage(state.usage, sample.usage);
117
158
  const activityChanged = sample.activityId !== undefined
118
159
  && sample.activityId !== state.activityId;
119
- if (usageChanged || (activityChanged && state.cursor !== undefined)) {
120
- const usage = sample.usage;
160
+ if (restoredUsageEventId !== undefined) {
161
+ let persistedIndex = -1;
162
+ for (let usageIndex = usages.length - 1; usageIndex >= 0; usageIndex -= 1) {
163
+ if (usageObservationIdentity(fence, source.sourceId, usages[usageIndex]).eventId
164
+ === restoredUsageEventId) {
165
+ persistedIndex = usageIndex;
166
+ break;
167
+ }
168
+ }
169
+ if (persistedIndex >= 0) {
170
+ usages = usages.slice(persistedIndex + 1);
171
+ }
172
+ else if (state.usage?.semantics === "cumulative-session") {
173
+ // The durable counter proves the latest cumulative total, but an
174
+ // initial bounded read that cannot recover that occurrence may
175
+ // have skipped intermediate request boundaries while the
176
+ // Controller was stopped. Preserve the total and fail closed only
177
+ // for the derived maximum-request metric.
178
+ usages = usages.map((occurrence) => (occurrence.usage?.semantics !== "cumulative-session"
179
+ ? occurrence
180
+ : Object.freeze({ ...occurrence, observationQuality: "partial" })));
181
+ }
182
+ }
183
+ usages.forEach((occurrence, usageIndex) => {
184
+ const identity = usageObservationIdentity(fence, source.sourceId, occurrence);
121
185
  this.inbox.enqueueObservation(createRuntimeObservation({
122
186
  schemaVersion: 2,
123
- eventId: observationId("activity", fence, source.sourceId, JSON.stringify([usage ?? null, sample.activityId ?? null])),
124
- semanticKey: observationId("activity", fence, source.sourceId, JSON.stringify([usage ?? null, sample.activityId ?? null])),
187
+ eventId: identity.eventId,
188
+ semanticKey: identity.semanticKey,
125
189
  kind: "activity.observed",
126
190
  authority: "driver-inferred",
127
191
  receivedAt: at,
128
192
  sequence,
129
- ordinal: 2,
193
+ ordinal: 2 + usageIndex,
130
194
  fence,
131
195
  payload: {
132
196
  activity: sample.activity ?? "model",
133
- ...(sample.activityId === undefined ? {} : { activityId: sample.activityId }),
134
- ...(usage === undefined ? {} : { usage })
197
+ sourceId: source.sourceId,
198
+ ...(occurrence.usage === undefined || occurrence.activityId === undefined
199
+ ? {}
200
+ : { activityId: occurrence.activityId }),
201
+ ...(occurrence.observationQuality === undefined
202
+ ? {}
203
+ : { observationQuality: occurrence.observationQuality }),
204
+ ...(occurrence.usage === undefined ? {} : { usage: occurrence.usage })
205
+ }
206
+ }));
207
+ if (occurrence.usage !== undefined) {
208
+ state.usage = occurrence.usage;
209
+ state.usageEventId = identity.eventId;
210
+ state.usageOccurrenceId = occurrence.occurrenceId;
211
+ state.usageOccurrenceCheckpoint = occurrence.resumeCheckpoint;
212
+ }
213
+ });
214
+ if (activityChanged && state.cursor !== undefined) {
215
+ this.inbox.enqueueObservation(createRuntimeObservation({
216
+ schemaVersion: 2,
217
+ eventId: observationId("activity", fence, source.sourceId, sample.activityId),
218
+ semanticKey: observationId("activity", fence, source.sourceId, sample.activityId),
219
+ kind: "activity.observed",
220
+ authority: "driver-inferred",
221
+ receivedAt: at,
222
+ sequence,
223
+ ordinal: 2 + usages.length,
224
+ fence,
225
+ payload: {
226
+ activity: sample.activity ?? "model",
227
+ sourceId: source.sourceId,
228
+ activityId: sample.activityId
135
229
  }
136
230
  }));
137
231
  dirty.add(`role:${fence.taskId}/${fence.roleName}`);
138
232
  }
139
- if (sample.usage !== undefined)
140
- state.usage = sample.usage;
141
233
  if (sample.activityId !== undefined)
142
234
  state.activityId = sample.activityId;
143
235
  this.#states.set(key, state);
@@ -160,11 +252,13 @@ export class AgentRuntimeObserver {
160
252
  // by Run and sort each group once so every active Run can reuse the same
161
253
  // ordered slice instead of repeatedly filtering/sorting all E events.
162
254
  const observationsByRunId = new Map();
255
+ const taskObservations = [];
163
256
  for (const event of this.store.listEvents(task.id)) {
164
257
  const observation = runtimeObservationFromTaskEvent(event);
165
258
  const runId = observation?.fence.runId;
166
259
  if (observation === null || runId === undefined)
167
260
  continue;
261
+ taskObservations.push(observation);
168
262
  const grouped = observationsByRunId.get(runId);
169
263
  if (grouped === undefined) {
170
264
  observationsByRunId.set(runId, [observation]);
@@ -176,6 +270,7 @@ export class AgentRuntimeObserver {
176
270
  for (const observations of observationsByRunId.values()) {
177
271
  observations.sort(compareObservations);
178
272
  }
273
+ taskObservations.sort(compareObservations);
179
274
  for (const run of this.store.listAgentRuns(task.id)) {
180
275
  if (run.status !== "active"
181
276
  || this.store.getActiveAgentRun(task.id, run.roleName)?.id !== run.id)
@@ -200,12 +295,18 @@ export class AgentRuntimeObserver {
200
295
  continue;
201
296
  }
202
297
  const fence = accepted.fence;
203
- const exact = observations
204
- .filter((observation) => runtimeObservationFenceMatches(fence, observation.fence));
205
- const persistedUsage = exact.filter((observation) => (observation.kind === "activity.observed"
298
+ const generation = taskObservations.filter((observation) => (sessionGenerationFenceMatches(fence, observation.fence)
299
+ && observation.payload.sourceId === source.sourceId));
300
+ const persistedUsage = generation.filter((observation) => (observation.kind === "activity.observed"
206
301
  && observation.payload.usage !== undefined)).at(-1);
207
- const persistedHealth = exact.filter((observation) => (observation.kind === "observer.health"
302
+ const persistedActivity = generation.filter((observation) => (observation.kind === "activity.observed"
303
+ && observation.payload.activityId !== undefined
304
+ && observation.payload.usage === undefined)).at(-1);
305
+ const persistedHealth = generation.filter((observation) => (observation.kind === "observer.health"
208
306
  && observation.payload.sourceId === source.sourceId)).at(-1);
307
+ const persistedUsageResume = persistedUsage === undefined
308
+ ? undefined
309
+ : usageObservationResume(persistedUsage.semanticKey);
209
310
  result.push(Object.freeze({
210
311
  key: JSON.stringify([
211
312
  fence.taskId,
@@ -216,20 +317,30 @@ export class AgentRuntimeObserver {
216
317
  fence.launchId,
217
318
  fence.sessionGenerationId,
218
319
  fence.nativeSessionId,
219
- fence.nativeTurnId,
220
- fence.receiptId,
221
320
  source.sourceId
222
321
  ]),
223
322
  fence,
224
323
  source,
225
- freshSession: run.mode === "new",
226
324
  persistedState: Object.freeze({
227
325
  ...(persistedUsage?.payload.usage === undefined
228
326
  ? {}
229
327
  : { usage: persistedUsage.payload.usage }),
230
- ...(persistedUsage?.payload.activityId === undefined
328
+ ...(persistedUsage === undefined
231
329
  ? {}
232
- : { activityId: persistedUsage.payload.activityId }),
330
+ : {
331
+ usageEventId: persistedUsage.eventId,
332
+ ...(persistedUsageResume === undefined
333
+ ? {}
334
+ : {
335
+ usageOccurrenceId: persistedUsageResume.occurrenceId,
336
+ ...(persistedUsageResume.checkpoint === undefined
337
+ ? {}
338
+ : { usageOccurrenceCheckpoint: persistedUsageResume.checkpoint })
339
+ })
340
+ }),
341
+ ...(persistedActivity?.payload.activityId === undefined
342
+ ? {}
343
+ : { activityId: persistedActivity.payload.activityId }),
233
344
  ...(persistedHealth === undefined
234
345
  ? {}
235
346
  : {
@@ -265,6 +376,28 @@ function sampleConcurrency(value) {
265
376
  }
266
377
  return resolved;
267
378
  }
379
+ function hasUsageSnapshot(occurrence) {
380
+ return occurrence.usage !== undefined;
381
+ }
382
+ function cumulativeRollbackWitness(occurrences) {
383
+ const cumulative = occurrences.filter(({ usage }) => (usage.semantics === "cumulative-session"));
384
+ const latest = cumulative.at(-1);
385
+ for (let index = 1; index < cumulative.length; index += 1) {
386
+ const previous = cumulative[index - 1];
387
+ const current = cumulative[index];
388
+ if (current.usage.inputTokens >= previous.usage.inputTokens
389
+ && current.usage.outputTokens >= previous.usage.outputTokens)
390
+ continue;
391
+ const witness = latest === current
392
+ ? [previous, current]
393
+ : [previous, current, latest];
394
+ return Object.freeze(witness.map((occurrence) => Object.freeze({
395
+ ...occurrence,
396
+ observationQuality: "partial"
397
+ })));
398
+ }
399
+ return Object.freeze([]);
400
+ }
268
401
  function numericCompare(left, right) {
269
402
  return left.localeCompare(right, undefined, { numeric: true });
270
403
  }
@@ -279,11 +412,74 @@ function observationId(kind, fence, sourceId, value) {
279
412
  .update(JSON.stringify([kind, fence, sourceId, value]))
280
413
  .digest("hex")}`;
281
414
  }
282
- function sameUsage(left, right) {
283
- return left !== undefined
284
- && left.semantics === right.semantics
285
- && left.inputTokens === right.inputTokens
286
- && left.outputTokens === right.outputTokens
287
- && left.cachedInputTokens === right.cachedInputTokens
288
- && left.reasoningTokens === right.reasoningTokens;
415
+ function usageObservationIdentity(fence, sourceId, occurrence) {
416
+ const eventId = tokenObservationId("usage", fence, sourceId, occurrence.occurrenceId);
417
+ const encodedOccurrenceId = Buffer.from(occurrence.occurrenceId, "utf8").toString("base64url");
418
+ return Object.freeze({
419
+ eventId,
420
+ semanticKey: `${eventId}:occurrence:${encodedOccurrenceId}${occurrence.resumeCheckpoint === undefined
421
+ ? ""
422
+ : `:checkpoint:${Buffer.from(occurrence.resumeCheckpoint, "utf8").toString("base64url")}`}`
423
+ });
424
+ }
425
+ function usageObservationResume(semanticKey) {
426
+ const occurrenceMarker = ":occurrence:";
427
+ const checkpointMarker = ":checkpoint:";
428
+ const occurrenceIndex = semanticKey.lastIndexOf(occurrenceMarker);
429
+ if (occurrenceIndex < 0)
430
+ return undefined;
431
+ const checkpointIndex = semanticKey.indexOf(checkpointMarker, occurrenceIndex + occurrenceMarker.length);
432
+ const encodedOccurrence = semanticKey.slice(occurrenceIndex + occurrenceMarker.length, checkpointIndex < 0 ? undefined : checkpointIndex);
433
+ if (encodedOccurrence.length === 0)
434
+ return undefined;
435
+ try {
436
+ const occurrenceId = Buffer.from(encodedOccurrence, "base64url").toString("utf8");
437
+ if (occurrenceId.length === 0)
438
+ return undefined;
439
+ if (checkpointIndex < 0)
440
+ return Object.freeze({ occurrenceId });
441
+ const encodedCheckpoint = semanticKey.slice(checkpointIndex + checkpointMarker.length);
442
+ if (encodedCheckpoint.length === 0)
443
+ return undefined;
444
+ const checkpoint = Buffer.from(encodedCheckpoint, "base64url").toString("utf8");
445
+ return checkpoint.length === 0
446
+ ? undefined
447
+ : Object.freeze({ occurrenceId, checkpoint });
448
+ }
449
+ catch {
450
+ return undefined;
451
+ }
452
+ }
453
+ function tokenObservationIdentity(kind, fence, sourceId, value) {
454
+ const eventId = tokenObservationId(kind, fence, sourceId, value);
455
+ return Object.freeze({ eventId, semanticKey: eventId });
456
+ }
457
+ function tokenObservationId(kind, fence, sourceId, value) {
458
+ return `runtime-observer-${createHash("sha256").update(JSON.stringify([
459
+ kind,
460
+ fence.taskId ?? null,
461
+ fence.roleName,
462
+ fence.agentId,
463
+ fence.driverId,
464
+ fence.launchId,
465
+ fence.sessionGenerationId,
466
+ fence.nativeSessionId ?? null,
467
+ sourceId,
468
+ value
469
+ ])).digest("hex")}`;
470
+ }
471
+ function sessionGenerationFenceMatches(expected, actual) {
472
+ for (const field of [
473
+ "taskId",
474
+ "roleName",
475
+ "agentId",
476
+ "driverId",
477
+ "launchId",
478
+ "sessionGenerationId",
479
+ "nativeSessionId"
480
+ ]) {
481
+ if (expected[field] !== actual[field])
482
+ return false;
483
+ }
484
+ return true;
289
485
  }
@@ -582,7 +582,7 @@ export class FileTaskWorkflowRuntime {
582
582
  async #prepareAndScan(taskId) {
583
583
  const task = this.store.getTask(taskId);
584
584
  if (task !== null
585
- && (task.status === "draft" || task.status === "active")
585
+ && task.status === "active"
586
586
  && this.workspacePreparer !== undefined) {
587
587
  await this.workspacePreparer.prepareTaskWorkspace(taskId);
588
588
  }
@@ -1184,11 +1184,12 @@ export class FileTaskController {
1184
1184
  this.#clearDeliveryRetry(key);
1185
1185
  this.#scheduleTaskPassRetry({ taskId, keys: [key] });
1186
1186
  }
1187
+ const runtimeAdvanced = (firstRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0
1188
+ || (secondRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0;
1187
1189
  if (scope.kind === "full"
1188
1190
  || scope.keys.some((key) => key !== "operator")
1189
- || (firstRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0
1190
- || (secondRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0) {
1191
- this.#signalOperatorMailbox();
1191
+ || runtimeAdvanced) {
1192
+ this.#signalOperatorMailbox(runtimeAdvanced);
1192
1193
  }
1193
1194
  }
1194
1195
  catch (error) {
@@ -1311,7 +1312,7 @@ export class FileTaskController {
1311
1312
  this.#clearEmptyOperatorRetry();
1312
1313
  this.#scheduleNextInputDeadline();
1313
1314
  }
1314
- #signalOperatorMailbox() {
1315
+ #signalOperatorMailbox(retryReady = false) {
1315
1316
  if (this.#stopped)
1316
1317
  return;
1317
1318
  const mailbox = this.store.getWorkMailbox({ kind: "operator" });
@@ -1320,7 +1321,8 @@ export class FileTaskController {
1320
1321
  this.#lastOperatorSignalIdentity = undefined;
1321
1322
  return;
1322
1323
  }
1323
- if (identity !== this.#lastOperatorSignalIdentity) {
1324
+ if (identity !== this.#lastOperatorSignalIdentity
1325
+ || (retryReady && this.store.getOperatorDeliveryTarget() !== null)) {
1324
1326
  this.#operatorSignalScheduler.signal("operator");
1325
1327
  this.#lastOperatorSignalIdentity = identity;
1326
1328
  }
@@ -1516,13 +1518,7 @@ export class FileTaskController {
1516
1518
  && this.#operatorStartupRetryArmed));
1517
1519
  if (operatorRetries.length > 0) {
1518
1520
  retry.set("operator", {
1519
- identity: operatorRetries.map((notification) => ("inputRequestId" in notification
1520
- ? `input:${notification.inputRequestId}`
1521
- : "recoveryTaskId" in notification
1522
- ? `recovery:${notification.recoveryTaskId}`
1523
- : "stallTaskId" in notification
1524
- ? `stall:${notification.stallTaskId}`
1525
- : `terminal:${notification.terminalTaskId}`)).join("|")
1521
+ identity: operatorRetries.map((notification) => (`${notification.sourceKind}:${notification.taskId}:${notification.sourceId}`)).join("|")
1526
1522
  });
1527
1523
  }
1528
1524
  else if (result.inputNotifications.some((notification) => notification.status === "sent"