@zq-silk/yui 0.11.3 → 0.12.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 (108) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +44 -22
  3. package/dist/cli/commandCatalog.js +13 -10
  4. package/dist/cli/interactionPolicy.js +5 -6
  5. package/dist/cli/updateCommand.js +3 -1
  6. package/dist/cli/updateOrchestrator.js +173 -28
  7. package/dist/cli/updatePorts.js +137 -8
  8. package/dist/cli/upgradeCommand.js +19 -9
  9. package/dist/cli.js +156 -57
  10. package/dist/commands/configCommands.js +13 -46
  11. package/dist/commands/executionAuditCommands.js +3 -2
  12. package/dist/commands/jobCommands.js +4 -10
  13. package/dist/commands/taskCommands.js +234 -200
  14. package/dist/commands/taskCompletionGate.js +29 -54
  15. package/dist/commands/taskContextCommand.js +61 -13
  16. package/dist/commands/taskInputCommands.js +8 -5
  17. package/dist/commands/taskNextActionCommand.js +56 -6
  18. package/dist/commands/taskOverviewCommand.js +17 -31
  19. package/dist/commands/taskRoleRuntimeStatus.js +55 -7
  20. package/dist/commands/taskWorkspaceCommands.js +5 -2
  21. package/dist/config/configCatalog.js +3 -3
  22. package/dist/config/yuiConfig.js +3 -7
  23. package/dist/context/wakeNotification.js +20 -5
  24. package/dist/controller/agentRuntimeObserver.js +247 -51
  25. package/dist/controller/clientRuntime.js +39 -3
  26. package/dist/controller/controller.js +31 -27
  27. package/dist/controller/fileSchedulerStoreAdapter.js +390 -332
  28. package/dist/controller/runtime.js +58 -3
  29. package/dist/controller/runtimeEventInbox.js +17 -7
  30. package/dist/controller/runtimeEventProcessor.js +12 -19
  31. package/dist/controller/runtimeHookRunFence.js +19 -4
  32. package/dist/controller/runtimeObservationHook.js +45 -0
  33. package/dist/controller/structuredProviderObservation.js +20 -3
  34. package/dist/core/controllerClient.js +20 -2
  35. package/dist/core/controllerServer.js +1 -0
  36. package/dist/execution/resourceBroker.js +5 -4
  37. package/dist/executor/agentExecutor.js +52 -50
  38. package/dist/executor/effectiveLaunch.js +8 -48
  39. package/dist/executor/fileRoleLaunchPlanner.js +129 -45
  40. package/dist/executor/workspacePreflightClassification.js +23 -2
  41. package/dist/interaction/operatorPresentation.js +33 -89
  42. package/dist/lifecycle/exactRunTerminalization.js +69 -5
  43. package/dist/observability/executionAudit.js +5 -0
  44. package/dist/observability/orchestrationMetrics.js +8 -3
  45. package/dist/profile/agentProfile.js +1 -1
  46. package/dist/release/cliHomeReleaseFence.js +123 -0
  47. package/dist/release/runtimeRelease.js +20 -0
  48. package/dist/repository/taskWorkspacePreparer.js +176 -60
  49. package/dist/resources/sqliteResourceRegistry.js +1 -1
  50. package/dist/review/deltaRecheck.js +12 -51
  51. package/dist/review/reviewAcceptance.js +26 -0
  52. package/dist/review/reviewConfig.js +0 -31
  53. package/dist/review/reviewDecision.js +113 -0
  54. package/dist/review/reviewOutcomeClassifier.js +1 -1
  55. package/dist/review/reviewRound.js +1 -1
  56. package/dist/review/reviewerAvailability.js +69 -0
  57. package/dist/run/recoveryProjection.js +45 -6
  58. package/dist/runtime/agentDriverObservation.js +24 -10
  59. package/dist/runtime/agentHost.js +159 -85
  60. package/dist/runtime/builtinAgentDrivers.js +4 -3
  61. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  62. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  63. package/dist/runtime/conversationSwitch.js +277 -0
  64. package/dist/runtime/index.js +2 -1
  65. package/dist/runtime/launchBroker.js +12 -0
  66. package/dist/runtime/processExitOutbox.js +88 -0
  67. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  68. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  69. package/dist/runtime/runtimeObservation.js +28 -0
  70. package/dist/runtime/runtimeProjection.js +22 -32
  71. package/dist/runtime/sessionTokenMetrics.js +181 -0
  72. package/dist/runtime/structuredProviderHost.js +7 -1
  73. package/dist/runtime/tmuxAdapters.js +4 -1
  74. package/dist/scheduler/activeRoleRunDelivery.js +34 -199
  75. package/dist/scheduler/activeTaskProgress.js +7 -6
  76. package/dist/scheduler/leaderWakeupProcessor.js +42 -138
  77. package/dist/scheduler/operatorEvent.js +34 -0
  78. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  79. package/dist/scheduler/roleRunStall.js +53 -31
  80. package/dist/scheduler/taskExecutionProjection.js +97 -76
  81. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  82. package/dist/storage/migration/productionRegistry.js +379 -0
  83. package/dist/storage/sqliteSchema.js +66 -23
  84. package/dist/storage/sqliteStore.js +46 -23
  85. package/dist/storage/storeRpc.js +0 -1
  86. package/dist/storage/taskStore.js +15 -30
  87. package/dist/storage/upgrade/homeClassification.js +52 -0
  88. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  89. package/dist/storage/upgrade/recordVersions.js +3 -2
  90. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  91. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  92. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  93. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  94. package/dist/task/completionReadiness.js +10 -6
  95. package/dist/task/nextAction.js +82 -58
  96. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  97. package/dist/web/assets/client/components.js +13 -3
  98. package/dist/web/assets/client/i18n.js +6 -2
  99. package/dist/web/webSnapshot.js +7 -1
  100. package/i18n/README.zh-CN.md +2 -2
  101. package/package.json +1 -1
  102. package/skills/yui-leader/SKILL.md +60 -20
  103. package/skills/yui-operator/SKILL.md +13 -4
  104. package/skills/yui-reviewer/SKILL.md +35 -11
  105. package/dist/context/sessionContextBudget.js +0 -71
  106. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  107. package/dist/lifecycle/taskRoleSessionReset.js +0 -126
  108. package/dist/scheduler/operatorNotification.js +0 -59
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
- import { callController, readControllerDiscovery, stopOrphanedFileTaskController, stopPreviousFileTaskController } from "../core/controllerClient.js";
3
+ import { callController, ControllerClientError, readControllerDiscovery, stopOrphanedFileTaskController, stopPreviousFileTaskController } from "../core/controllerClient.js";
4
4
  import { controllerSocketPath } from "../core/controllerEndpoint.js";
5
5
  import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "../core/protocol.js";
6
6
  import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
@@ -14,6 +14,7 @@ import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
14
14
  import { WorkspaceCleanupBlockedError } from "../repository/taskWorkspacePreparer.js";
15
15
  import { CONTROLLER_SHUTDOWN_TIMEOUT_MS, LIFECYCLE_REQUEST_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
16
16
  import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
17
+ import { isForeignHandoverLockHeld } from "../release/runtimeRelease.js";
17
18
  const STARTUP_TIMEOUT_MS = 5_000;
18
19
  const POLL_INTERVAL_MS = 50;
19
20
  const ENVIRONMENT_REFRESH_TIMEOUT_MS = 500;
@@ -54,6 +55,7 @@ export async function assertFileTaskControllerStorageCompatible(home, options =
54
55
  /** Starts the per-home FileTask Controller on demand and waits until callable. */
55
56
  export async function ensureFileTaskController(home, options = {}) {
56
57
  const call = options.call ?? callController;
58
+ await waitForForeignHandover(home, options);
57
59
  try {
58
60
  const status = await call(home, "controller.status", {});
59
61
  assertCompatibleControllerStatus(status, options.expectedVersion);
@@ -63,6 +65,10 @@ export async function ensureFileTaskController(home, options = {}) {
63
65
  if (!isUnavailable(error))
64
66
  throw error;
65
67
  }
68
+ // A handover may have started after the first lock check and before the
69
+ // failed discovery/socket call. Re-check before spawning so a managed
70
+ // Session can never resurrect the old Controller during an update.
71
+ await waitForForeignHandover(home, options);
66
72
  const timeoutMs = positive(options.startupTimeoutMs, STARTUP_TIMEOUT_MS, "startupTimeoutMs");
67
73
  const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
68
74
  const spawnController = options.spawnController ?? spawnDetachedFileTaskController;
@@ -322,15 +328,22 @@ export async function callFileTaskController(home, method, params = {}, options
322
328
  : { stopped: false, alreadyStopped: true };
323
329
  }
324
330
  }
331
+ await waitForForeignHandover(home, options);
325
332
  try {
326
333
  return await call(home, method, params, {
327
334
  timeoutMs: options.requestTimeoutMs
328
335
  });
329
336
  }
330
337
  catch (error) {
331
- if (!isUnavailable(error))
338
+ if (!isUnavailable(error)
339
+ && !(isControllerDraining(error)
340
+ && isForeignHandoverLockHeld(home, options.handoverOwnerPid ?? process.pid))) {
332
341
  throw error;
342
+ }
333
343
  }
344
+ // A pre-connect unavailable result is safe to retry. Ambiguous post-send
345
+ // failures are classified separately by the socket client and surface.
346
+ await waitForForeignHandover(home, options);
334
347
  await ensureFileTaskController(home, options);
335
348
  return call(home, method, params, {
336
349
  timeoutMs: options.requestTimeoutMs
@@ -582,7 +595,7 @@ export class FileTaskWorkflowRuntime {
582
595
  async #prepareAndScan(taskId) {
583
596
  const task = this.store.getTask(taskId);
584
597
  if (task !== null
585
- && (task.status === "draft" || task.status === "active")
598
+ && task.status === "active"
586
599
  && this.workspacePreparer !== undefined) {
587
600
  await this.workspacePreparer.prepareTaskWorkspace(taskId);
588
601
  }
@@ -631,6 +644,29 @@ function isUnavailable(error) {
631
644
  return code === "CONTROLLER_NOT_RUNNING"
632
645
  || code === "CONTROLLER_UNAVAILABLE";
633
646
  }
647
+ function isControllerDraining(error) {
648
+ return typeof error === "object"
649
+ && error !== null
650
+ && "code" in error
651
+ && error.code === "CONTROLLER_DRAINING";
652
+ }
653
+ async function waitForForeignHandover(home, options) {
654
+ const allowedOwnerPid = options.handoverOwnerPid ?? process.pid;
655
+ if (!Number.isSafeInteger(allowedOwnerPid) || allowedOwnerPid < 1) {
656
+ throw new TypeError("handoverOwnerPid must be a positive integer");
657
+ }
658
+ if (!isForeignHandoverLockHeld(home, allowedOwnerPid))
659
+ return;
660
+ const timeoutMs = positive(options.handoverWaitTimeoutMs, LIFECYCLE_REQUEST_TIMEOUT_MS, "handoverWaitTimeoutMs");
661
+ const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
662
+ const deadline = Date.now() + timeoutMs;
663
+ do {
664
+ await delay(pollMs);
665
+ if (!isForeignHandoverLockHeld(home, allowedOwnerPid))
666
+ return;
667
+ } while (Date.now() < deadline);
668
+ throw new ControllerClientError("CONTROLLER_HANDOVER_TIMEOUT", `Controller handover did not finish within ${timeoutMs} ms.`);
669
+ }
634
670
  function isDefinitelyNotRunning(error) {
635
671
  if (typeof error !== "object" || error === null || !("code" in error))
636
672
  return false;
@@ -282,23 +282,27 @@ async function processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost
282
282
  const reservedLaunchId = isRuntimeLaunchReservation(mailbox.processing)
283
283
  ? mailbox.processing.batchId
284
284
  : undefined;
285
- if (reservedLaunchId !== undefined
286
- && session?.launchId !== undefined
287
- && reservedLaunchId !== session.launchId) {
288
- throw new Error(`Task runtime cleanup launch identity is ambiguous: ${target.taskId}.`);
289
- }
290
- const launchId = reservedLaunchId ?? session?.launchId;
291
- if (launchId !== undefined
292
- && lifecycleHost.cleanupTaskLaunch !== undefined) {
285
+ // A failed fresh-Conversation launch can leave two exact resource
286
+ // generations: the detached Session's last committed launch and the
287
+ // replacement reservation. They are not competing authorities. The
288
+ // owner-wide stop has already proven physical zero, so clean both
289
+ // exact launch roots idempotently before settling the mailbox.
290
+ const launchIds = new Set([
291
+ reservedLaunchId,
292
+ session?.launchId
293
+ ].filter((launchId) => launchId !== undefined));
294
+ if (lifecycleHost.cleanupTaskLaunch !== undefined) {
293
295
  const task = store.getTask(target.taskId);
294
296
  const reason = task?.status === "completed"
295
297
  ? "completion"
296
298
  : "interruption";
297
- lifecycleHost.cleanupTaskLaunch({
298
- taskId: target.taskId,
299
- launchId,
300
- reason
301
- });
299
+ for (const launchId of launchIds) {
300
+ lifecycleHost.cleanupTaskLaunch({
301
+ taskId: target.taskId,
302
+ launchId,
303
+ reason
304
+ });
305
+ }
302
306
  }
303
307
  }
304
308
  if (store.completeRuntimeCleanup === undefined
@@ -1184,11 +1188,12 @@ export class FileTaskController {
1184
1188
  this.#clearDeliveryRetry(key);
1185
1189
  this.#scheduleTaskPassRetry({ taskId, keys: [key] });
1186
1190
  }
1191
+ const runtimeAdvanced = (firstRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0
1192
+ || (secondRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0;
1187
1193
  if (scope.kind === "full"
1188
1194
  || scope.keys.some((key) => key !== "operator")
1189
- || (firstRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0
1190
- || (secondRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0) {
1191
- this.#signalOperatorMailbox();
1195
+ || runtimeAdvanced) {
1196
+ this.#signalOperatorMailbox(runtimeAdvanced);
1192
1197
  }
1193
1198
  }
1194
1199
  catch (error) {
@@ -1311,7 +1316,7 @@ export class FileTaskController {
1311
1316
  this.#clearEmptyOperatorRetry();
1312
1317
  this.#scheduleNextInputDeadline();
1313
1318
  }
1314
- #signalOperatorMailbox() {
1319
+ #signalOperatorMailbox(retryReady = false) {
1315
1320
  if (this.#stopped)
1316
1321
  return;
1317
1322
  const mailbox = this.store.getWorkMailbox({ kind: "operator" });
@@ -1320,7 +1325,8 @@ export class FileTaskController {
1320
1325
  this.#lastOperatorSignalIdentity = undefined;
1321
1326
  return;
1322
1327
  }
1323
- if (identity !== this.#lastOperatorSignalIdentity) {
1328
+ if (identity !== this.#lastOperatorSignalIdentity
1329
+ || (retryReady && this.store.getOperatorDeliveryTarget() !== null)) {
1324
1330
  this.#operatorSignalScheduler.signal("operator");
1325
1331
  this.#lastOperatorSignalIdentity = identity;
1326
1332
  }
@@ -1502,7 +1508,11 @@ export class FileTaskController {
1502
1508
  }
1503
1509
  for (const wakeup of result.wakeups) {
1504
1510
  const key = `role:${encodeURIComponent(wakeup.taskId)}/leader`;
1505
- if (wakeup.reason === "writer-attached") {
1511
+ if (wakeup.terminalized === true) {
1512
+ settled.add(key);
1513
+ resignal.add(key);
1514
+ }
1515
+ else if (wakeup.reason === "writer-attached") {
1506
1516
  writerBlocked.add(key);
1507
1517
  }
1508
1518
  else if (wakeup.reason === "not-ready") {
@@ -1516,13 +1526,7 @@ export class FileTaskController {
1516
1526
  && this.#operatorStartupRetryArmed));
1517
1527
  if (operatorRetries.length > 0) {
1518
1528
  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("|")
1529
+ identity: operatorRetries.map((notification) => (`${notification.sourceKind}:${notification.taskId}:${notification.sourceId}`)).join("|")
1526
1530
  });
1527
1531
  }
1528
1532
  else if (result.inputNotifications.some((notification) => notification.status === "sent"
@@ -1694,7 +1698,7 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
1694
1698
  dispatcherServiceTime.started();
1695
1699
  try {
1696
1700
  if (stopping) {
1697
- throw controllerApplicationError("METHOD_NOT_FOUND", "Controller is stopping.");
1701
+ throw controllerApplicationError("CONTROLLER_DRAINING", "Controller is draining.");
1698
1702
  }
1699
1703
  if (method === "scheduler.signal") {
1700
1704
  runtime.signal(signalMailboxKey(params));