@zq-silk/yui 0.15.3 → 0.15.6

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 (41) hide show
  1. package/dist/agent/managedRuntimeEnvironment.js +3 -0
  2. package/dist/cli.js +35 -127
  3. package/dist/commands/executionAuditCommands.js +6 -0
  4. package/dist/commands/taskContextCommand.js +4 -2
  5. package/dist/context/sessionBootstrapManifest.js +12 -21
  6. package/dist/controller/clientRuntime.js +1 -1
  7. package/dist/controller/fileSchedulerStoreAdapter.js +270 -162
  8. package/dist/controller/runtimeEventInbox.js +8 -0
  9. package/dist/controller/runtimeEventProcessor.js +31 -4
  10. package/dist/controller/runtimeHookTurnFence.js +101 -62
  11. package/dist/controller/runtimeLaunchCoordinator.js +38 -12
  12. package/dist/controller/runtimeObservationHook.js +17 -1
  13. package/dist/controller/structuredProviderObservation.js +39 -27
  14. package/dist/core/controllerClient.js +5 -0
  15. package/dist/core/controllerServer.js +7 -4
  16. package/dist/domain/agentResultTransport.js +2 -2
  17. package/dist/executor/agentExecutor.js +22 -38
  18. package/dist/executor/executorRegistry.js +16 -5
  19. package/dist/executor/fileRoleLaunchPlanner.js +22 -28
  20. package/dist/lifecycle/exactTurnTerminalization.js +3 -3
  21. package/dist/observability/executionAudit.js +12 -0
  22. package/dist/repository/executionLaneGitSnapshot.js +4 -3
  23. package/dist/repository/taskWorkspacePreparer.js +12 -4
  24. package/dist/review/taskFinalReviewContract.js +13 -32
  25. package/dist/runtime/agentError.js +299 -12
  26. package/dist/runtime/agentHost.js +419 -41
  27. package/dist/runtime/builtinAgentDrivers.js +5 -0
  28. package/dist/runtime/index.js +1 -1
  29. package/dist/runtime/ports.js +16 -2
  30. package/dist/runtime/providerRuntimeIdentity.js +34 -28
  31. package/dist/runtime/runtimeCoherence.js +91 -0
  32. package/dist/runtime/runtimeObservation.js +8 -5
  33. package/dist/runtime/structuredProviderHost.js +53 -44
  34. package/dist/runtime/tmuxAdapters.js +72 -43
  35. package/dist/scheduler/activeRoleTurnDelivery.js +59 -11
  36. package/dist/scheduler/leaderWakeupProcessor.js +61 -7
  37. package/dist/storage/sqliteSchema.js +9 -0
  38. package/dist/storage/storageVersions.js +1 -1
  39. package/dist/turn/turn.js +7 -1
  40. package/package.json +1 -1
  41. package/dist/runtime/exactControlPlane.js +0 -232
@@ -39,6 +39,22 @@ export class FileRuntimeEventProcessor {
39
39
  offset = wave.nextOffset;
40
40
  if (wave.candidates.length === 0)
41
41
  continue;
42
+ if (ownsResultTransaction(wave.candidates[0].event)) {
43
+ // Terminal preparation may inspect external workspace resources.
44
+ // Its observer owns the atomic result commit after that inspection;
45
+ // never enclose preparation in the cross-Task batch transaction.
46
+ const candidate = wave.candidates[0];
47
+ try {
48
+ stateTransactions += 1;
49
+ const failure = this.finalizeOne(this.foldOne(candidate, now), acknowledgedEventIds, deferred);
50
+ if (failure !== undefined)
51
+ recordDrainFailure(failure, failed, failedTaskIds);
52
+ }
53
+ catch (error) {
54
+ recordDrainFailure(candidateDrainFailure(candidate.event, error), failed, failedTaskIds);
55
+ }
56
+ continue;
57
+ }
42
58
  try {
43
59
  stateTransactions += 1;
44
60
  const folded = this.observer.withRuntimeEventTransaction(() => (wave.candidates.map((candidate) => this.foldOne(candidate, now))));
@@ -131,8 +147,9 @@ export class FileRuntimeEventProcessor {
131
147
  if (taskId !== undefined) {
132
148
  const task = this.observer.getTask(taskId);
133
149
  if (task === null
134
- || task.status !== "active"
135
- || task.executionGate.state !== "enabled")
150
+ || task.status === "archived"
151
+ || (!["turn.completed", "turn.failed", "turn.cancelled"].includes(event.observation.kind)
152
+ && (task.status !== "active" || task.executionGate.state !== "enabled")))
136
153
  return "obsolete";
137
154
  }
138
155
  return this.observer.observeRuntimeObservation?.(event.observation, now) ?? "obsolete";
@@ -452,15 +469,24 @@ function selectTaskOrderedWave(candidates, offset, failedTaskIds) {
452
469
  nextOffset += 1;
453
470
  continue;
454
471
  }
472
+ if (ownsResultTransaction(candidate.event) && selected.length > 0)
473
+ break;
455
474
  if (taskId !== undefined && selectedTaskIds.has(taskId))
456
475
  break;
457
476
  selected.push(candidate);
458
477
  if (taskId !== undefined)
459
478
  selectedTaskIds.add(taskId);
460
479
  nextOffset += 1;
480
+ if (ownsResultTransaction(candidate.event))
481
+ break;
461
482
  }
462
483
  return { candidates: selected, nextOffset };
463
484
  }
485
+ function ownsResultTransaction(event) {
486
+ return event.type === "native-turn-terminal"
487
+ || (event.type === "runtime-observation"
488
+ && ["turn.completed", "turn.failed", "turn.cancelled"].includes(event.observation.kind));
489
+ }
464
490
  function recordDrainFailure(failure, failed, failedTaskIds) {
465
491
  failed.push(failure);
466
492
  if (failure.scope === "task")
@@ -588,8 +614,9 @@ export class AsyncRuntimeEventProcessor {
588
614
  if (taskId !== undefined) {
589
615
  const task = await this.observer.getTask(taskId);
590
616
  if (task === null
591
- || task.status !== "active"
592
- || task.executionGate.state !== "enabled")
617
+ || task.status === "archived"
618
+ || (!["turn.completed", "turn.failed", "turn.cancelled"].includes(event.observation.kind)
619
+ && (task.status !== "active" || task.executionGate.state !== "enabled")))
593
620
  return "obsolete";
594
621
  }
595
622
  const outcome = (await this.observer.observeRuntimeObservation?.(event.observation, now))
@@ -26,22 +26,56 @@ export function resolveRuntimeHookTurnFence(environment, adapterId, payloadNativ
26
26
  const nativeSessionId = requireIdentity(payloadNativeSessionId, "Provider session id");
27
27
  const store = openCurrentTaskStore(home);
28
28
  const task = store.getTask(taskId);
29
- if (task === null
30
- || (!(task.status === "active" && task.executionGate.state === "enabled")
31
- && !(task.status === "completed" && (options.terminal === true || options.sessionOnly === true)))) {
29
+ if (task === null) {
32
30
  throw new Error("Runtime observation Hook Task does not accept this lifecycle boundary.");
33
31
  }
34
32
  const role = store.getRole(taskId, roleName);
35
- if (role === null || role.activeAgentId !== agentId) {
36
- throw new Error("Runtime observation Hook Role or Agent is not current.");
37
- }
38
33
  const sessions = store.getTaskRoleSessionSet(taskId, roleName);
39
- if (sessions !== null && sessions.activeAgentId !== agentId) {
40
- throw new Error("Runtime observation Hook Session Agent is not current.");
41
- }
42
34
  const session = sessions?.sessions[agentId];
35
+ const executionSession = session?.nativeSessionId === nativeSessionId
36
+ ? session
37
+ : sessions?.history?.find((entry) => (entry.agentId === agentId && entry.nativeSessionId === nativeSessionId));
43
38
  const activeTurn = store.getActiveTurn(taskId, roleName);
44
39
  const providerTurn = sessions?.providerBinding?.turn;
40
+ // An input belongs to the Activation recorded at registration, even after
41
+ // detach or a successor Host launch. "Current activation" is not its owner.
42
+ const activation = providerTurn?.activationId === undefined
43
+ ? undefined
44
+ : sessions?.providerBinding?.activations.find((entry) => entry.activationId === providerTurn.activationId);
45
+ const matchesProviderTurn = providerTurn !== null
46
+ && providerTurn !== undefined
47
+ && executionSession?.adapterId === adapterId
48
+ && executionSession.effective.workspace.root === workspace
49
+ && activation?.conversationId === nativeSessionId
50
+ && ((options.attemptId !== undefined && providerTurn.attemptId === options.attemptId)
51
+ || (options.nativeTurnId !== undefined && providerTurn.nativeTurnId === options.nativeTurnId))
52
+ && (options.attemptId === undefined || providerTurn.attemptId === options.attemptId)
53
+ && (options.nativeTurnId === undefined || providerTurn.nativeTurnId === undefined
54
+ || providerTurn.nativeTurnId === options.nativeTurnId);
55
+ const acceptedTurn = acceptedTurnBinding(store.listEvents(taskId), {
56
+ taskId, roleName, agentId, nativeSessionId,
57
+ ...(options.nativeTurnId === undefined ? {} : { nativeTurnId: options.nativeTurnId }),
58
+ ...(options.attemptId === undefined ? {} : { attemptId: options.attemptId })
59
+ });
60
+ const acceptedBinding = acceptedTurn ?? (options.continuationId === undefined ? null : knownContinuationBinding(store.listEvents(taskId), {
61
+ taskId, roleName, agentId, nativeSessionId,
62
+ continuationId: options.continuationId,
63
+ continuationGeneration: options.continuationGeneration ?? 1
64
+ }));
65
+ // Collection of an exactly accepted terminal fact is not a new action by
66
+ // the Role. A successor's active Agent or revoked execution permission
67
+ // cannot erase the original Turn's evidence.
68
+ const existingExecutionObservation = (options.terminal === true || options.attemptId !== undefined)
69
+ && (acceptedBinding !== null || matchesProviderTurn);
70
+ if (!(task.status === "active" && task.executionGate.state === "enabled")
71
+ && !(task.status === "completed" && options.sessionOnly === true)
72
+ && !existingExecutionObservation) {
73
+ throw new Error("Runtime observation Hook Task does not accept this lifecycle boundary.");
74
+ }
75
+ if (!existingExecutionObservation && (role === null || role.activeAgentId !== agentId
76
+ || (sessions !== null && sessions.activeAgentId !== agentId))) {
77
+ throw new Error("Runtime observation Hook Role or Agent is not current.");
78
+ }
45
79
  // Launch generation and Turn are durable facts, never envelope facts. A
46
80
  // native pane outlives both, so anything it inherited at launch is stale for
47
81
  // every later generation; the Session's own record (or the in-flight launch
@@ -54,22 +88,25 @@ export function resolveRuntimeHookTurnFence(environment, adapterId, payloadNativ
54
88
  const reservedRuntimeGenerationId = isRuntimeLaunchReservation(lifecycleMailbox?.processing)
55
89
  ? lifecycleMailbox?.processing?.batchId
56
90
  : undefined;
57
- const runtimeGenerationId = requireIdentity(session?.runtimeGenerationId ?? reservedRuntimeGenerationId, "Runtime generation id");
58
- const durableTurnId = activeTurn?.id
59
- ?? managedProviderTurnId(providerTurn)
60
- ?? undefined;
61
- const directProviderTurn = providerTurn !== null
62
- && providerTurn !== undefined
63
- && providerTurn.turnId === undefined
64
- && ((options.attemptId !== undefined && providerTurn.attemptId === options.attemptId)
65
- || (options.nativeTurnId !== undefined && providerTurn.nativeTurnId === options.nativeTurnId));
91
+ // Startup has a fresh process envelope and must prove the exact persisted
92
+ // launch. Existing/late execution events instead retain their old binding.
93
+ const reservedStartup = options.startupSession !== undefined
94
+ && activeTurn !== null
95
+ && reservedRuntimeGenerationId !== undefined
96
+ && environment.YUI_RUNTIME_GENERATION_ID === reservedRuntimeGenerationId
97
+ && !hasRuntimeCleanupObligation(lifecycleMailbox);
98
+ const runtimeGenerationId = requireIdentity(acceptedBinding?.fence.runtimeGenerationId
99
+ ?? (matchesProviderTurn ? activation.activationId : undefined)
100
+ ?? (reservedStartup ? reservedRuntimeGenerationId : session?.runtimeGenerationId), "Runtime generation id");
101
+ const directProviderTurn = matchesProviderTurn && providerTurn.turnId === undefined;
66
102
  const sessionOnlyObservation = options.sessionOnly === true && activeTurn === null;
67
- if (directProviderTurn || sessionOnlyObservation) {
68
- if (session === undefined
69
- || session.adapterId !== adapterId
70
- || session.runtimeGenerationId !== runtimeGenerationId
71
- || session.nativeSessionId !== nativeSessionId
72
- || session.effective.workspace.root !== workspace) {
103
+ if (acceptedBinding === null && (directProviderTurn || sessionOnlyObservation)) {
104
+ const observedSession = directProviderTurn ? executionSession : session;
105
+ if (observedSession === undefined
106
+ || observedSession.adapterId !== adapterId
107
+ || (!directProviderTurn && observedSession.runtimeGenerationId !== runtimeGenerationId)
108
+ || observedSession.nativeSessionId !== nativeSessionId
109
+ || observedSession.effective.workspace.root !== workspace) {
73
110
  throw new Error("Runtime observation Hook Session does not match durable state.");
74
111
  }
75
112
  return {
@@ -82,35 +119,19 @@ export function resolveRuntimeHookTurnFence(environment, adapterId, payloadNativ
82
119
  workspace
83
120
  };
84
121
  }
85
- const activationReceiptId = providerTurn !== null
86
- && providerTurn !== undefined
87
- && managedProviderTurnId(providerTurn) === activeTurn?.id
122
+ const activationReceiptId = matchesProviderTurn
88
123
  ? providerTurn.attemptId
89
- : activeTurn === null ? undefined : formatTurnReceiptId(taskId, activeTurn.id);
90
- const acceptedTurn = options.nativeTurnId === undefined
91
- ? null
92
- : acceptedTurnBinding(store.listEvents(taskId), {
93
- taskId,
94
- roleName,
95
- agentId,
96
- nativeSessionId,
97
- nativeTurnId: options.nativeTurnId
98
- });
99
- const acceptedBinding = acceptedTurn ?? (options.continuationId === undefined ? null : knownContinuationBinding(store.listEvents(taskId), {
100
- taskId,
101
- roleName,
102
- agentId,
103
- nativeSessionId,
104
- continuationId: options.continuationId,
105
- continuationGeneration: options.continuationGeneration ?? 1
106
- }));
124
+ : providerTurn !== null
125
+ && providerTurn !== undefined
126
+ && managedProviderTurnId(providerTurn) === activeTurn?.id
127
+ ? providerTurn.attemptId
128
+ : activeTurn === null ? undefined : formatTurnReceiptId(taskId, activeTurn.id);
107
129
  const mailbox = lifecycleMailbox;
108
130
  const exactReservation = isRuntimeLaunchReservation(mailbox?.processing, runtimeGenerationId)
109
131
  && !hasRuntimeCleanupObligation(mailbox);
110
- const startupTurnId = options.startupSession === undefined
111
- ? undefined
112
- : requireIdentity(durableTurnId, "Turn id");
132
+ const startupTurnId = reservedStartup ? activeTurn.id : undefined;
113
133
  const startupReservation = startupTurnId !== undefined
134
+ && reservedStartup
114
135
  && exactReservation
115
136
  && !hasRuntimeCleanupObligation(mailbox);
116
137
  const startupTurn = startupTurnId === undefined
@@ -122,6 +143,12 @@ export function resolveRuntimeHookTurnFence(environment, adapterId, payloadNativ
122
143
  && startupReservation
123
144
  && startupTurn?.mode === "new"
124
145
  && session.status === "ended";
146
+ const resumedStartup = startupReservation
147
+ && startupTurn?.mode === "resume"
148
+ && session !== undefined
149
+ && session.adapterId === adapterId
150
+ && session.nativeSessionId === nativeSessionId
151
+ && session.effective.workspace.root === workspace;
125
152
  // The startup mode itself says whether Yui preallocated the native Session
126
153
  // id or the Provider reports it. A preallocated startup is proven against
127
154
  // Yui's deterministic runtime generation identity, which is stronger than comparing a
@@ -133,14 +160,17 @@ export function resolveRuntimeHookTurnFence(environment, adapterId, payloadNativ
133
160
  const discoveredStartup = options.startupSession === "discovered"
134
161
  && (session === undefined || replacementStartup)
135
162
  && startupReservation;
136
- const terminalTurnId = options.terminal === true && acceptedBinding === null
137
- ? requireIdentity(durableTurnId, "Turn id")
163
+ const registeredTurnId = acceptedBinding === null && matchesProviderTurn
164
+ ? managedProviderTurnId(providerTurn) ?? undefined
138
165
  : undefined;
166
+ if (options.terminal === true && acceptedBinding === null && registeredTurnId === undefined) {
167
+ throw new Error("Runtime observation Hook terminal has no exact accepted execution binding.");
168
+ }
139
169
  const terminalTurn = acceptedBinding !== null
140
170
  ? store.getTurn(taskId, acceptedBinding.fence.turnId)
141
- : terminalTurnId === undefined
171
+ : registeredTurnId === undefined
142
172
  ? null
143
- : store.getTurn(taskId, terminalTurnId);
173
+ : store.getTurn(taskId, registeredTurnId);
144
174
  const exactTerminal = terminalTurn !== null
145
175
  && terminalTurn.status !== "active"
146
176
  && terminalTurn.roleName === roleName
@@ -150,21 +180,24 @@ export function resolveRuntimeHookTurnFence(environment, adapterId, payloadNativ
150
180
  if (activeTurn === null
151
181
  && !preallocatedStartup
152
182
  && !discoveredStartup
183
+ && !resumedStartup
153
184
  && !exactTerminal
185
+ && registeredTurnId === undefined
154
186
  && acceptedBinding === null) {
155
187
  throw new Error("Runtime observation Hook has no matching durable in-flight Turn.");
156
188
  }
157
189
  const turnId = acceptedBinding?.fence.turnId
190
+ ?? registeredTurnId
158
191
  ?? activeTurn?.id
159
- ?? startupTurnId
160
- ?? terminalTurnId;
192
+ ?? startupTurnId;
161
193
  const effectiveRuntimeGenerationId = acceptedBinding?.fence.runtimeGenerationId ?? runtimeGenerationId;
162
- const turn = acceptedBinding !== null || exactTerminal
194
+ const turn = acceptedBinding !== null || registeredTurnId !== undefined
163
195
  ? terminalTurn
164
196
  : store.getActiveTurn(taskId, roleName);
165
197
  if (turn === null
166
198
  || turn.id !== turnId
167
- || (acceptedBinding === null && !exactTerminal && turn.status !== "active")
199
+ || (acceptedBinding === null && registeredTurnId === undefined && !exactTerminal && turn.status !== "active")
200
+ || turn.roleName !== roleName
168
201
  || turn.effective.agentId !== agentId
169
202
  || turn.effective.adapterId !== adapterId) {
170
203
  throw new Error("Runtime observation Hook Turn does not match durable active state.");
@@ -172,7 +205,7 @@ export function resolveRuntimeHookTurnFence(environment, adapterId, payloadNativ
172
205
  if (turn.effective.workspace.root !== workspace) {
173
206
  throw new Error("Runtime observation Hook workspace does not match the durable Turn snapshot.");
174
207
  }
175
- if (session !== undefined && acceptedBinding === null && !replacementStartup) {
208
+ if (session !== undefined && acceptedBinding === null && !matchesProviderTurn && !replacementStartup && !resumedStartup) {
176
209
  if (session.adapterId !== adapterId
177
210
  || session.runtimeGenerationId !== effectiveRuntimeGenerationId
178
211
  || session.nativeSessionId !== nativeSessionId
@@ -180,7 +213,7 @@ export function resolveRuntimeHookTurnFence(environment, adapterId, payloadNativ
180
213
  throw new Error("Runtime observation Hook Session does not match its durable generation.");
181
214
  }
182
215
  }
183
- else if (acceptedBinding === null && (session === undefined || replacementStartup)) {
216
+ else if (acceptedBinding === null && !matchesProviderTurn && (session === undefined || replacementStartup)) {
184
217
  if (!discoveredStartup && !preallocatedStartup) {
185
218
  throw new Error("Runtime observation Hook launch is not durably reserved.");
186
219
  }
@@ -225,15 +258,18 @@ function knownContinuationBinding(events, expected) {
225
258
  return binding;
226
259
  }
227
260
  function acceptedTurnBinding(events, expected) {
261
+ if (expected.nativeTurnId === undefined && expected.attemptId === undefined)
262
+ return null;
228
263
  const matches = events
229
264
  .map(runtimeObservationFromTaskEvent)
230
265
  .filter((observation) => observation !== null
231
- && observation.kind === "turn.accepted"
266
+ && ["turn.accepted", "turn.completed", "turn.failed", "turn.cancelled"].includes(observation.kind)
232
267
  && observation.fence.taskId === expected.taskId
233
268
  && observation.fence.roleName === expected.roleName
234
269
  && observation.fence.agentId === expected.agentId
235
270
  && observation.fence.nativeSessionId === expected.nativeSessionId
236
- && observation.fence.nativeTurnId === expected.nativeTurnId
271
+ && ((expected.attemptId !== undefined && observation.fence.receiptId === expected.attemptId)
272
+ || (expected.nativeTurnId !== undefined && observation.fence.nativeTurnId === expected.nativeTurnId))
237
273
  && observation.fence.turnId !== undefined)
238
274
  .sort((left, right) => (left.receivedAt.localeCompare(right.receivedAt)
239
275
  || (left.sequence ?? -1) - (right.sequence ?? -1)
@@ -242,9 +278,12 @@ function acceptedTurnBinding(events, expected) {
242
278
  const binding = matches.at(-1) ?? null;
243
279
  if (binding === null)
244
280
  return null;
245
- if (matches.some((candidate) => candidate.fence.turnId !== binding.fence.turnId
281
+ if (matches.some((candidate) => ((expected.attemptId !== undefined && candidate.fence.receiptId !== expected.attemptId)
282
+ || (expected.nativeTurnId !== undefined && candidate.fence.nativeTurnId !== undefined
283
+ && candidate.fence.nativeTurnId !== expected.nativeTurnId)
284
+ || candidate.fence.turnId !== binding.fence.turnId
246
285
  || candidate.fence.runtimeGenerationId !== binding.fence.runtimeGenerationId
247
- || candidate.fence.receiptId !== binding.fence.receiptId)) {
286
+ || candidate.fence.receiptId !== binding.fence.receiptId))) {
248
287
  throw new Error("Runtime observation Hook native Turn has conflicting durable Turn bindings.");
249
288
  }
250
289
  return binding;
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
3
- import { createRuntimeBinding, RuntimeGenerationMismatchError, RuntimeHostContentionError, RuntimeLaunchError } from "../runtime/index.js";
3
+ import { createRuntimeBinding, RuntimeGenerationMismatchError, RuntimeHostContentionError, RuntimeHostUnavailableError, RuntimeLaunchError } from "../runtime/index.js";
4
4
  import { sameEffectiveLaunch, validateEffectiveLaunchSnapshot } from "../executor/effectiveLaunch.js";
5
5
  class RuntimeBindingContractError extends Error {
6
6
  constructor(message, options) {
@@ -76,8 +76,9 @@ export class RuntimeLaunchCoordinator {
76
76
  if (request.owner.scope === "global" && request.managedWorkspace !== undefined) {
77
77
  throw new Error("A global runtime cannot use a Task ManagedWorkspace.");
78
78
  }
79
+ let currentRequest = request;
79
80
  const assertLaunchCurrent = () => {
80
- this.#assertCurrent?.(request);
81
+ this.#assertCurrent?.(currentRequest);
81
82
  assertCurrent?.();
82
83
  };
83
84
  const proposedGenerationId = requireText(this.#createGenerationId(), "Launch generation id");
@@ -155,8 +156,20 @@ export class RuntimeLaunchCoordinator {
155
156
  throw new Error("Runtime host reported its pre-start launch fence more than once.");
156
157
  }
157
158
  validateRuntimeLaunchPreflight(preflight, request, runtimeGenerationId);
159
+ this.reservations.confirmRuntimeLaunchReservation({
160
+ owner: request.owner,
161
+ runtimeGenerationId
162
+ }, assertLaunchCurrent);
158
163
  preflightObserved = true;
159
164
  beforeHostStart?.(preflight);
165
+ // The pre-start persistence callback hands the fixed native Session to
166
+ // this reserved activation. From here on, fence against that exact new
167
+ // identity, not the historical Host we originally set out to restore.
168
+ // Do not accept both identities: a later change back is stale as well.
169
+ if (request.mode === "resume" && request.hostActivationId !== undefined) {
170
+ currentRequest = { ...request, hostActivationId: runtimeGenerationId };
171
+ }
172
+ assertLaunchCurrent();
160
173
  };
161
174
  let binding;
162
175
  try {
@@ -206,15 +219,21 @@ export class RuntimeLaunchCoordinator {
206
219
  }
207
220
  }
208
221
  catch (error) {
222
+ if (error instanceof RuntimeHostUnavailableError) {
223
+ // A failed reused attachment provides no authority to detach its
224
+ // native Conversation or schedule owner-wide physical cleanup.
225
+ throw error;
226
+ }
209
227
  if (error instanceof RuntimeGenerationMismatchError) {
210
- await this.#settleFailedStart(request, runtimeGenerationId, reusedConfirmedRunningHost, runtimeIsolation, true);
211
- throw new RuntimeLaunchError(false, runtimeGenerationId, error.message, "generation-mismatch");
228
+ // The observed Host is not the requested activation. Neither killing
229
+ // it nor scheduling owner-wide cleanup is justified by that conflict.
230
+ throw new RuntimeLaunchError(false, runtimeGenerationId, error.message, "generation-mismatch", { cause: error });
212
231
  }
213
232
  if (error instanceof RuntimeHostContentionError && reusedConfirmedRunningHost) {
214
233
  // The exact recovered generation remains authoritative. A late human
215
234
  // writer is transient backpressure and must not settle, clean, or
216
235
  // terminalize that reservation.
217
- throw new RuntimeLaunchError(true, runtimeGenerationId, error.message, error.reason);
236
+ throw new RuntimeLaunchError(true, runtimeGenerationId, error.message, error.reason, { cause: error });
218
237
  }
219
238
  if (error instanceof RuntimeHostContentionError
220
239
  && !reusedConfirmedRunningHost) {
@@ -239,7 +258,7 @@ export class RuntimeLaunchCoordinator {
239
258
  }
240
259
  throw new RuntimeLaunchError(true, runtimeGenerationId, error.message, error instanceof RuntimeHostContentionError
241
260
  ? error.reason
242
- : "previous-process");
261
+ : "previous-process", { cause: error });
243
262
  }
244
263
  if (error instanceof RuntimeBindingContractError) {
245
264
  // Never pass an untrusted hostRef to stop(). Reconcile the requested
@@ -251,7 +270,7 @@ export class RuntimeLaunchCoordinator {
251
270
  throw error;
252
271
  }
253
272
  if (reusedConfirmedRunningHost && binding.hostCreated === true) {
254
- await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, new Error(`Runtime host was recreated while recovering an existing generation: ${request.owner.roleName}.`));
273
+ await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, new Error(`Runtime host was recreated while recovering an existing generation: ${request.owner.roleName}.`));
255
274
  }
256
275
  try {
257
276
  assertLaunchCurrent();
@@ -276,11 +295,11 @@ export class RuntimeLaunchCoordinator {
276
295
  }
277
296
  }
278
297
  catch (error) {
279
- await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, error);
298
+ await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, error);
280
299
  }
281
300
  return binding;
282
301
  }
283
- async #settleFailedStart(request, runtimeGenerationId, reusedConfirmedRunningHost, runtimeIsolation, forceCleanup = false) {
302
+ async #settleFailedStart(request, runtimeGenerationId, reusedConfirmedRunningHost, runtimeIsolation) {
284
303
  let inspection;
285
304
  try {
286
305
  inspection = await this.host.inspectOwner(request.owner);
@@ -288,7 +307,7 @@ export class RuntimeLaunchCoordinator {
288
307
  catch {
289
308
  // A rebind/preflight failure did not create the already-confirmed host.
290
309
  // Preserve its generation when the follow-up probe is merely unknown.
291
- if (!reusedConfirmedRunningHost || forceCleanup)
310
+ if (!reusedConfirmedRunningHost)
292
311
  this.#requireCleanup(request.owner);
293
312
  return;
294
313
  }
@@ -307,11 +326,18 @@ export class RuntimeLaunchCoordinator {
307
326
  }
308
327
  return;
309
328
  }
310
- if (reusedConfirmedRunningHost && !forceCleanup)
329
+ if (reusedConfirmedRunningHost)
311
330
  return;
312
331
  this.#requireCleanup(request.owner);
313
332
  }
314
- async #compensateStartedHost(owner, binding, runtimeGenerationId, runtimeIsolation, cause) {
333
+ async #compensateStartedHost(owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, cause) {
334
+ if (reusedConfirmedRunningHost && binding.hostCreated !== true) {
335
+ // Reattaching an existing activation gives this launch no ownership of
336
+ // it. A fresh activation inside a persistent Host is different: this
337
+ // launch still owns its startup and isolation cleanup even when the
338
+ // physical Host did not need to be created.
339
+ throw new RuntimeLaunchStateChangedError(`Runtime launch state changed while reusing a Host: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
340
+ }
315
341
  try {
316
342
  await this.host.stop(binding);
317
343
  }
@@ -18,6 +18,8 @@ export async function runRuntimeObservationHookCommand(stdinJson, environment =
18
18
  return;
19
19
  }
20
20
  const parsed = parseRuntimeObservationHook(stdinJson, environment, now, dependencies);
21
+ if (parsed.observations.length === 0)
22
+ return;
21
23
  const inbox = new FileRuntimeEventInbox(parsed.home);
22
24
  for (const observation of parsed.observations)
23
25
  inbox.enqueueObservation(observation);
@@ -84,6 +86,20 @@ export function parseRuntimeObservationHook(stdinJson, environment, now = new Da
84
86
  const driverId = requireIdentity(environment.YUI_DRIVER_ID, "Agent Driver id");
85
87
  const driver = drivers.require(driverId);
86
88
  const hookEventName = requireIdentity(payload.hook_event_name, "Agent Driver Hook event name");
89
+ if (environment.YUI_SESSION_SCOPE === "task" && driver.adapterId === "claude"
90
+ && environment.YUI_ADAPTER_ID === "claude") {
91
+ // Existing native Conversations may retain an old Yui observer plugin.
92
+ // Only the managed stream owns execution facts: Hooks do not expose its
93
+ // input attempt, and associating them with the currently active Turn can
94
+ // steal a successor's result. This observer is not a permission handler;
95
+ // Claude's configured tool policy and other user plugins remain intact.
96
+ return {
97
+ home: requireIdentity(environment.YUI_HOME, "YUI_HOME"),
98
+ taskId: requireIdentity(environment.YUI_TASK_ID, "Task id"),
99
+ roleName: requireIdentity(environment.YUI_ROLE, "Role name"),
100
+ observations: Object.freeze([])
101
+ };
102
+ }
87
103
  const receivedAt = now.toISOString();
88
104
  const sequence = (dependencies.sequence ?? monotonicSequence)();
89
105
  const occurrenceId = `${receivedAt}:${sequence}`;
@@ -124,7 +140,7 @@ export function parseRuntimeObservationHook(stdinJson, environment, now = new Da
124
140
  conversationId: fence.nativeSessionId,
125
141
  activationId: fence.runtimeGenerationId,
126
142
  nativeSessionId: fence.nativeSessionId,
127
- nativeTurnId: nativeTurnId ?? fence.turnId,
143
+ ...(nativeTurnId === undefined ? {} : { nativeTurnId }),
128
144
  receiptId: fence.receiptId ?? formatTurnReceiptId(fence.taskId, fence.turnId)
129
145
  },
130
146
  payload
@@ -8,6 +8,7 @@ import { runtimeLifecycleSignalKey } from "../runtime/lifecycleReservation.js";
8
8
  import { isForeignHandoverLockHeld } from "../release/runtimeRelease.js";
9
9
  import { FileRuntimeEventInbox } from "./runtimeEventInbox.js";
10
10
  import { resolveRuntimeHookTurnFence } from "./runtimeHookTurnFence.js";
11
+ import { openCurrentTaskStore } from "../storage/currentTaskStore.js";
11
12
  let structuredSequence = 0;
12
13
  export async function publishStructuredProviderStarted(input) {
13
14
  if (input.started.clientOwned)
@@ -58,39 +59,25 @@ export async function publishStructuredProviderAccepted(input) {
58
59
  driverId: driver.id,
59
60
  runtimeGenerationId: fence.runtimeGenerationId,
60
61
  conversationId: input.receipt.conversationId,
61
- activationId: requireIdentity(input.activationId, "Provider Activation id"),
62
+ activationId: executionActivationId(input.home, fence, input.activationId, input.receipt.nativeSessionId),
62
63
  nativeSessionId: input.receipt.nativeSessionId,
63
- nativeTurnId: input.receipt.nativeTurnId,
64
+ ...(input.receipt.nativeTurnId === undefined ? {} : {
65
+ nativeTurnId: input.receipt.nativeTurnId
66
+ }),
64
67
  // The structured Host owns the exact input attempt identity. Runtime
65
68
  // descriptor receipts name the Turn bootstrap and must not overwrite a
66
69
  // continuation or human-takeover Turn.
67
70
  receiptId: input.receipt.attemptId
68
71
  };
69
72
  const baseSequence = nextStructuredSequence();
73
+ // Opening owns the Session/Activation lifecycle. A receipt arriving after
74
+ // its terminal must not reopen either lifecycle as an acceptance side effect.
70
75
  const observations = [observation({
71
- kind: startupSession === "preallocated" ? "session.ready" : "session.started",
72
- observedAt,
73
- sequence: baseSequence,
74
- ordinal: 0,
75
- fence: commonFence
76
- }), observation({
77
- kind: "conversation.observed",
78
- observedAt,
79
- sequence: baseSequence,
80
- ordinal: 1,
81
- fence: commonFence,
82
- payload: { recoverability: "recoverable" }
83
- }), observation({
84
- kind: "activation.started",
85
- observedAt,
86
- sequence: baseSequence,
87
- ordinal: 2,
88
- fence: commonFence
89
- }), observation({
90
76
  kind: "turn.accepted",
77
+ authority: input.receipt.acceptance === "transport" ? "transport" : "provider-structured",
91
78
  observedAt,
92
79
  sequence: baseSequence,
93
- ordinal: 3,
80
+ ordinal: 0,
94
81
  fence: commonFence
95
82
  })];
96
83
  await persistAndApply(input.home, observations, fence.taskId, fence.roleName);
@@ -215,6 +202,7 @@ export async function publishStructuredProviderTerminal(input) {
215
202
  const fence = resolveRuntimeHookTurnFence(input.environment, adapterId, input.terminal.nativeSessionId, {
216
203
  terminal: true,
217
204
  nativeTurnId: input.terminal.nativeTurnId,
205
+ attemptId: input.terminal.attemptId,
218
206
  ...(input.terminal.clientOwned ? {} : { sessionOnly: true })
219
207
  });
220
208
  const kind = input.terminal.status === "completed"
@@ -226,7 +214,9 @@ export async function publishStructuredProviderTerminal(input) {
226
214
  ...(input.terminal.input === undefined ? {} : { input: input.terminal.input }),
227
215
  ...(transported.status === "completed"
228
216
  ? { output: transported.output }
229
- : { resultTransportDiagnostic: transported.diagnostic })
217
+ : transported.failureReason === "runtime-failed"
218
+ ? { resultTransportDiagnostic: transported.diagnostic }
219
+ : {})
230
220
  }
231
221
  : kind === "turn.failed"
232
222
  ? {
@@ -264,10 +254,14 @@ export async function publishStructuredProviderTerminal(input) {
264
254
  driverId: driver.id,
265
255
  runtimeGenerationId: fence.runtimeGenerationId,
266
256
  conversationId: input.terminal.conversationId,
267
- activationId: requireIdentity(input.activationId, "Provider Activation id"),
257
+ activationId: executionActivationId(input.home, fence, input.activationId, input.terminal.nativeSessionId),
268
258
  nativeSessionId: input.terminal.nativeSessionId,
269
- nativeTurnId: input.terminal.nativeTurnId,
270
- ...(fence.receiptId === undefined ? {} : { receiptId: fence.receiptId })
259
+ ...(input.terminal.nativeTurnId === undefined ? {} : {
260
+ nativeTurnId: input.terminal.nativeTurnId
261
+ }),
262
+ ...(input.terminal.attemptId === undefined
263
+ ? fence.receiptId === undefined ? {} : { receiptId: fence.receiptId }
264
+ : { receiptId: input.terminal.attemptId })
271
265
  },
272
266
  payload
273
267
  });
@@ -312,7 +306,7 @@ function observation(input) {
312
306
  eventId,
313
307
  semanticKey: runtimeObservationSemanticKey(partial),
314
308
  kind: input.kind,
315
- authority: "provider-structured",
309
+ authority: input.authority ?? "provider-structured",
316
310
  receivedAt: new Date().toISOString(),
317
311
  observedAt: input.observedAt,
318
312
  sequence: input.sequence,
@@ -336,6 +330,24 @@ function requireIdentity(value, label) {
336
330
  }
337
331
  return value.trim();
338
332
  }
333
+ function executionActivationId(home, fence, sourceActivationId, nativeSessionId) {
334
+ const sourceId = requireIdentity(sourceActivationId, "Provider Activation id");
335
+ const store = openCurrentTaskStore(home);
336
+ try {
337
+ const binding = store.getTaskRoleSessionSet(fence.taskId, fence.roleName)?.providerBinding;
338
+ const source = binding?.activations.find(entry => entry.activationId === sourceId);
339
+ const original = binding?.activations.find(entry => entry.activationId === fence.runtimeGenerationId);
340
+ if (source?.conversationId !== nativeSessionId || original?.conversationId !== nativeSessionId) {
341
+ throw new Error("Structured execution observation source does not belong to its native Conversation.");
342
+ }
343
+ // A reattached collector can observe the old execution, but its current
344
+ // attachment must not replace that execution's durable Activation identity.
345
+ return original.activationId;
346
+ }
347
+ finally {
348
+ store.close();
349
+ }
350
+ }
339
351
  async function persistAndApply(home, observations, taskId, roleName) {
340
352
  const inbox = new FileRuntimeEventInbox(home);
341
353
  for (const entry of observations)
@@ -20,6 +20,11 @@ export function controllerCallMayHaveApplied(error) {
20
20
  && [
21
21
  "CONTROLLER_TIMEOUT",
22
22
  "CONTROLLER_UNAVAILABLE",
23
+ // Raised only after the request write began, so the Controller may well
24
+ // have committed it. Omitting it reported a genuinely unknown outcome as
25
+ // a definite failure, which is the one classification callers must not
26
+ // make: it invites treating possibly-applied work as never applied.
27
+ "CONTROLLER_DELIVERY_UNKNOWN",
23
28
  "INVALID_RESPONSE"
24
29
  ].includes(error.code);
25
30
  }