@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
@@ -3,10 +3,10 @@ import { fileURLToPath } from "node:url";
3
3
  import { resolve } from "node:path";
4
4
  import { createRuntimeBinding } from "./runtimeBinding.js";
5
5
  import { normalizeRuntimeOwner } from "./runtimeOwner.js";
6
- import { RuntimeGenerationMismatchError, RuntimeHostContentionError } from "./ports.js";
6
+ import { RuntimeGenerationMismatchError, RuntimeHostContentionError, RuntimeHostUnavailableError, promptPushOutcome } from "./ports.js";
7
+ import { providerDeliveryFailureFrom } from "./agentError.js";
7
8
  import { toRuntimeLaunchFailure } from "./launchDiagnostics.js";
8
9
  import { requireSafeIdentity } from "./validation.js";
9
- import { YUI_CONTROL_PLANE_DESCRIPTOR } from "./exactControlPlane.js";
10
10
  import { launchBrokerForHome } from "./launchBroker.js";
11
11
  import { AGENT_HOST_CONTROL_PROTOCOL, sendAgentHostLaunchControl, sendAgentHostTurnControl, sendAgentHostSteerControl, waitForAgentHostLaunchAck } from "./agentHost.js";
12
12
  const DEFAULT_INACTIVITY_TIMEOUT_MS = 300_000;
@@ -307,11 +307,9 @@ export class TmuxSessionHost {
307
307
  }
308
308
  const broker = launchBrokerForHome(yuiHome);
309
309
  const sessionManifest = planned.launch.env.YUI_SESSION_MANIFEST;
310
- const frozenControlPlane = planned.launch.env[YUI_CONTROL_PLANE_DESCRIPTOR];
311
310
  if (request.owner.scope === "task" && request.turnId !== undefined
312
- && (sessionManifest === undefined
313
- || frozenControlPlane === undefined)) {
314
- throw new Error("Managed Task Agent Host launch is missing its Session Manifest or frozen control descriptor.");
311
+ && sessionManifest === undefined) {
312
+ throw new Error("Managed Task Agent Host launch is missing its Session Manifest.");
315
313
  }
316
314
  const reservation = broker.reserve(Object.freeze({
317
315
  schemaVersion: 2,
@@ -352,10 +350,7 @@ export class TmuxSessionHost {
352
350
  : { YUI_WORKSPACE: planned.launch.env.YUI_WORKSPACE }),
353
351
  ...(sessionManifest === undefined
354
352
  ? {}
355
- : { YUI_SESSION_MANIFEST: sessionManifest }),
356
- ...(frozenControlPlane === undefined
357
- ? {}
358
- : { [YUI_CONTROL_PLANE_DESCRIPTOR]: frozenControlPlane })
353
+ : { YUI_SESSION_MANIFEST: sessionManifest })
359
354
  }
360
355
  };
361
356
  let hostCreated = false;
@@ -413,18 +408,28 @@ export class TmuxSessionHost {
413
408
  if (controlResult.outcome === "active-same-generation") {
414
409
  broker.revoke(request.runtimeGenerationId);
415
410
  }
416
- const acceptableState = ["idle", "ready", "busy"].includes(controlResult.snapshot.state);
417
- if (!acceptableState
418
- || controlResult.snapshot.runtimeGenerationId !== reservation.runtimeGenerationId) {
411
+ // Identity and readiness are separate facts. Only a genuinely
412
+ // different generation is a conflict that must fail closed, not
413
+ // permission to stop the Host; a matching unsettled generation is this
414
+ // exact activation still coming up, and stopping it would destroy a
415
+ // healthy Session (and any Turn it is carrying).
416
+ const observedGeneration = controlResult.snapshot.runtimeGenerationId;
417
+ if (observedGeneration !== reservation.runtimeGenerationId) {
419
418
  broker.revoke(request.runtimeGenerationId);
420
- try {
421
- await stopExactRole(this.tmux, hostId, request.owner.roleName);
422
- }
423
- catch {
424
- // The coordinator will enqueue durable owner cleanup.
419
+ throw new RuntimeGenerationMismatchError(reservation.runtimeGenerationId, observedGeneration, controlResult.snapshot.state, `Agent Host acknowledgement generation mismatch for ${reservation.runtimeGenerationId}; observed=${observedGeneration ?? "none"}; `
420
+ + `state=${controlResult.snapshot.state}${describeHostFailure(controlResult)}.`);
421
+ }
422
+ if (!["idle", "ready", "busy"].includes(controlResult.snapshot.state)) {
423
+ broker.revoke(request.runtimeGenerationId);
424
+ if (["starting", "settling", "delivery-unknown"].includes(controlResult.snapshot.state)) {
425
+ // The right generation is present but not yet deliverable. This
426
+ // is transient backpressure, so leave the Host running and let
427
+ // the caller retry rather than terminalizing the Turn.
428
+ throw new RuntimeHostContentionError("provider-child-active", `The Agent Host for ${request.owner.roleName} is still ${controlResult.snapshot.state} on this exact generation${describeHostFailure(controlResult)}.`);
425
429
  }
426
- throw new RuntimeGenerationMismatchError(reservation.runtimeGenerationId, controlResult.snapshot.runtimeGenerationId, controlResult.snapshot.state, `Agent Host acknowledgement generation mismatch for ${reservation.runtimeGenerationId}; observed=${controlResult.snapshot.runtimeGenerationId ?? "none"}; `
427
- + `state=${controlResult.snapshot.state}.`);
430
+ // This launch is unusable. It did not create this Host, so failure
431
+ // is not authority to clean its resources or unknown execution.
432
+ throw new RuntimeHostUnavailableError(reservation.runtimeGenerationId, controlResult.snapshot.state, `Agent Host reached ${controlResult.snapshot.state} for ${reservation.runtimeGenerationId}${describeHostFailure(controlResult)}.`, { cause: controlResult.failure ?? controlResult.snapshot });
428
433
  }
429
434
  providerSnapshot = controlResult.snapshot;
430
435
  providerDispatchObserved = true;
@@ -551,6 +556,15 @@ async function deadHostLaunchFailure(tmux, hostId, roleName, pane, context) {
551
556
  ...(stderrTail === undefined || stderrTail.length === 0 ? {} : { stderrTail })
552
557
  });
553
558
  }
559
+ /**
560
+ * Appends the Host's own cause to a launch diagnostic. Without this the
561
+ * caller only learns the state name and the real reason stays trapped in the
562
+ * Host process.
563
+ */
564
+ function describeHostFailure(result) {
565
+ const detail = result.failure?.detail ?? result.snapshot.detail;
566
+ return detail === undefined ? "" : `; detail=${detail}`;
567
+ }
554
568
  /** Structured managed-Turn input; tmux remains presentation/liveness only. */
555
569
  export class AgentHostPromptPushAdapter {
556
570
  home;
@@ -562,7 +576,7 @@ export class AgentHostPromptPushAdapter {
562
576
  if (ref.scope !== "task" || request.binding.nativeSessionId === undefined
563
577
  || request.binding.providerAuthority === undefined
564
578
  || request.binding.providerAuthority.owner !== "controller") {
565
- return "unavailable";
579
+ return promptPushOutcome("unavailable");
566
580
  }
567
581
  try {
568
582
  const result = await sendAgentHostTurnControl({
@@ -583,28 +597,28 @@ export class AgentHostPromptPushAdapter {
583
597
  }
584
598
  }
585
599
  });
586
- if (result.snapshot.state === "delivery-unknown")
587
- return "delivery-unknown";
600
+ // The Host attaches its own structured cause to every non-delivery.
601
+ const failure = result.failure;
602
+ if (result.snapshot.state === "delivery-unknown") {
603
+ return promptPushOutcome("delivery-unknown", failure);
604
+ }
588
605
  if (result.snapshot.state === "busy")
589
- return "busy";
606
+ return promptPushOutcome("busy", failure);
590
607
  if (result.outcome === "rejected")
591
- return "rejected";
608
+ return promptPushOutcome("rejected", failure);
592
609
  if (result.snapshot.attemptId !== request.envelope.id) {
593
610
  return result.snapshot.state === "starting" || result.snapshot.state === "settling"
594
- ? "busy"
595
- : "unavailable";
611
+ ? promptPushOutcome("busy", failure)
612
+ : promptPushOutcome("unavailable", failure);
596
613
  }
597
614
  if (result.snapshot.state === "ready")
598
- return "delivered";
615
+ return promptPushOutcome("delivered");
599
616
  return result.snapshot.state === "starting" || result.snapshot.state === "settling"
600
- ? "busy"
601
- : "unavailable";
617
+ ? promptPushOutcome("busy", failure)
618
+ : promptPushOutcome("unavailable", failure);
602
619
  }
603
620
  catch (error) {
604
- const code = error.code;
605
- return code === "ENOENT" || code === "ECONNREFUSED"
606
- ? "unavailable"
607
- : "delivery-unknown";
621
+ return transportFailureOutcome(error, "turn-submit", request.envelope.id);
608
622
  }
609
623
  }
610
624
  async trySteer(request) {
@@ -628,21 +642,36 @@ export class AgentHostPromptPushAdapter {
628
642
  }
629
643
  });
630
644
  if (result.outcome === "accepted")
631
- return "delivered";
632
- if (result.snapshot.state === "delivery-unknown")
633
- return "delivery-unknown";
645
+ return promptPushOutcome("delivered");
646
+ const failure = result.failure;
647
+ if (result.snapshot.state === "delivery-unknown") {
648
+ return promptPushOutcome("delivery-unknown", failure);
649
+ }
634
650
  if (result.snapshot.state === "busy")
635
- return "busy";
636
- return result.outcome === "rejected" ? "rejected" : "unavailable";
651
+ return promptPushOutcome("busy", failure);
652
+ return result.outcome === "rejected"
653
+ ? promptPushOutcome("rejected", failure)
654
+ : promptPushOutcome("unavailable", failure);
637
655
  }
638
656
  catch (error) {
639
- const code = error.code;
640
- return code === "ENOENT" || code === "ECONNREFUSED"
641
- ? "unavailable"
642
- : "delivery-unknown";
657
+ return transportFailureOutcome(error, "turn-submit", request.envelope.id);
643
658
  }
644
659
  }
645
660
  }
661
+ /**
662
+ * A transport failure decides the input disposition. A refused or absent
663
+ * socket proves the Host never received the request; anything else leaves
664
+ * delivery genuinely ambiguous and must not be replayed automatically.
665
+ */
666
+ function transportFailureOutcome(error, phase, attemptId) {
667
+ const code = error.code;
668
+ const unreachable = code === "ENOENT" || code === "ECONNREFUSED";
669
+ return promptPushOutcome(unreachable ? "unavailable" : "delivery-unknown", providerDeliveryFailureFrom(error, {
670
+ phase,
671
+ attemptId,
672
+ inputDisposition: unreachable ? "not-accepted" : "unknown"
673
+ }));
674
+ }
646
675
  async function ensureRoleWindow(tmux, hostId, role, launch) {
647
676
  return tmux.ensureRoleWindowAsync === undefined
648
677
  ? tmux.ensureRoleWindow(hostId, role, launch)
@@ -2,9 +2,9 @@ import { serializeTurnInputEnvelope } from "../context/turnInputContract.js";
2
2
  import { roleSessionMayContinue, sameEffectiveLaunch } from "../executor/effectiveLaunch.js";
3
3
  import { RuntimeLifecycleBusyError } from "../runtime/lifecycleReservation.js";
4
4
  import { managedProviderTurnId } from "../runtime/providerRuntimeIdentity.js";
5
- import { serializeAgentErrorRaw } from "../runtime/agentError.js";
5
+ import { formatProviderDeliveryFailure, providerDeliveryFailureFacts, innermostCauseName, serializeAgentErrorRaw } from "../runtime/agentError.js";
6
6
  import { RuntimeLaunchFailure } from "../runtime/launchDiagnostics.js";
7
- import { RuntimeLaunchError } from "../runtime/ports.js";
7
+ import { RuntimeGenerationMismatchError, RuntimeLaunchError } from "../runtime/ports.js";
8
8
  import { formatTurnReceiptId } from "../task/taskRecordReference.js";
9
9
  import { turnInputEnvelope } from "../turn/turn.js";
10
10
  import { captureRoleTurnDispatch } from "../coordination/workMailboxQueue.js";
@@ -63,7 +63,10 @@ async function deliverActiveTurn(store, delivery, task, role, turn, now) {
63
63
  if (currentProviderTurn !== null) {
64
64
  const reason = currentProviderTurn.terminalReason
65
65
  ?? `Provider Turn ended with status ${currentProviderTurn.status} without recording its Turn result.`;
66
- return failTurnDelivery(store, turn, now, "missing-result", reason);
66
+ // A terminal Provider projection without an application result is a
67
+ // framework consistency failure, not evidence that the Agent omitted its
68
+ // report. Keep the exact input fenced; never submit it again.
69
+ return { ...base, status: "skipped", reason: "delivery-uncertain", error: reason };
67
70
  }
68
71
  const attemptId = initialAttemptId;
69
72
  const mode = turn.mode;
@@ -117,29 +120,59 @@ async function deliverActiveTurn(store, delivery, task, role, turn, now) {
117
120
  receiptId: attemptId,
118
121
  text: serializeTurnInputEnvelope(turnInputEnvelope(turn))
119
122
  });
120
- if (outcome === "busy" || outcome === "unavailable") {
123
+ if (outcome.status === "busy" || outcome.status === "unavailable") {
121
124
  forget(delivery, task.id, role.name, turn.id, ready.prepared.runtimeGenerationId);
122
125
  return {
123
126
  ...base,
124
127
  status: "skipped",
125
- reason: outcome === "busy" ? "not-ready" : "runtime-unavailable"
128
+ reason: outcome.status === "busy" ? "not-ready" : "runtime-unavailable"
126
129
  };
127
130
  }
128
- if (outcome === "rejected" || outcome === "delivery-unknown") {
131
+ if (outcome.status === "rejected" || outcome.status === "delivery-unknown") {
129
132
  forget(delivery, task.id, role.name, turn.id, ready.prepared.runtimeGenerationId);
130
- return failTurnDelivery(store, turn, now, outcome === "delivery-unknown" ? "delivery-unknown" : "runtime-failed", outcome === "delivery-unknown"
131
- ? "Provider Turn delivery is ambiguous; Yui will not replay it automatically."
132
- : "Provider rejected the managed Turn.");
133
+ const unknown = outcome.status === "delivery-unknown";
134
+ const failure = outcome.failure;
135
+ // The Host's own account of the failure. Without it the only honest
136
+ // statement is that delivery did not complete — never that the Provider
137
+ // rejected the input, which is one specific cause among many.
138
+ const cause = failure === undefined
139
+ ? `The Agent Host did not deliver the managed Turn (${outcome.status}) and reported no cause.`
140
+ : formatProviderDeliveryFailure(failure);
141
+ // This path is the common Provider write failure and previously left no
142
+ // durable fact at all, so the cause was unrecoverable after the fact.
143
+ store.recordAgentError?.({
144
+ taskId: task.id,
145
+ roleName: role.name,
146
+ turnId: turn.id,
147
+ source: "host",
148
+ phase: failure?.phase ?? "turn-submit",
149
+ message: cause,
150
+ // The Host already serialized the complete redacted chain. Re-serializing
151
+ // the failure object here would persist this layer's wrapper instead of
152
+ // the original cause, which is the detail an authorized reader needs.
153
+ raw: failure?.raw ?? serializeAgentErrorRaw(failure ?? cause),
154
+ inputDisposition: failure?.inputDisposition ?? (unknown ? "unknown" : "not-accepted"),
155
+ ...providerDeliveryFailureFacts(failure)
156
+ }, now);
157
+ return failTurnDelivery(store, turn, now, unknown ? "delivery-unknown" : "runtime-failed", unknown
158
+ ? `Provider Turn delivery is ambiguous; Yui will not replay it automatically. ${cause}`
159
+ : cause);
133
160
  }
134
161
  forget(delivery, task.id, role.name, turn.id, ready.prepared.runtimeGenerationId);
135
162
  settleAcceptedRoleTurnDispatch(store, turn, dispatchToken);
136
163
  return {
137
164
  ...base,
138
- status: outcome === "sent" ? "delivered" : "already-delivered"
165
+ status: outcome.status === "sent" ? "delivered" : "already-delivered"
139
166
  };
140
167
  }
141
168
  catch (error) {
142
169
  const message = error instanceof Error ? error.message : String(error);
170
+ // A launch failure carries its own structure — the class that threw, the
171
+ // innermost cause, and for a generation mismatch both generations. Recording
172
+ // only `message` flattened all of it into prose that no reader could
173
+ // reliably parse back.
174
+ const causeName = innermostCauseName(error);
175
+ const mismatch = error instanceof RuntimeGenerationMismatchError ? error : undefined;
143
176
  store.recordAgentError?.({
144
177
  taskId: task.id,
145
178
  roleName: role.name,
@@ -148,7 +181,22 @@ async function deliverActiveTurn(store, delivery, task, role, turn, now) {
148
181
  phase: submitted ? "turn-submit" : mode === "new" ? "session-start" : "session-restore",
149
182
  message,
150
183
  raw: serializeAgentErrorRaw(error),
151
- inputDisposition: submitted ? "unknown" : "not-accepted"
184
+ inputDisposition: submitted ? "unknown" : "not-accepted",
185
+ // Nothing was submitted, so the Provider provably holds no registration
186
+ // for this attempt; after a submit the Host owns that fact, not this layer.
187
+ ...(submitted ? {} : { registrationDisposition: "not-committed" }),
188
+ ...(error instanceof Error ? { errorName: error.name } : {}),
189
+ ...(causeName === undefined ? {} : { causeName }),
190
+ ...(mismatch === undefined ? {} : {
191
+ expectedRuntimeGenerationId: mismatch.expectedRuntimeGenerationId,
192
+ ...(mismatch.observedRuntimeGenerationId === undefined
193
+ ? {}
194
+ : { observedRuntimeGenerationId: mismatch.observedRuntimeGenerationId })
195
+ }),
196
+ ...(error instanceof RuntimeLaunchError
197
+ ? { expectedRuntimeGenerationId: error.runtimeGenerationId }
198
+ : {}),
199
+ attemptId
152
200
  }, now);
153
201
  if (error instanceof RuntimeLifecycleBusyError
154
202
  || (error instanceof RuntimeLaunchError && error.retryable)) {
@@ -3,6 +3,7 @@ import { roleSessionMayContinue } from "../executor/effectiveLaunch.js";
3
3
  import { roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
4
4
  import { createTurn } from "../turn/turn.js";
5
5
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
6
+ import { formatProviderDeliveryFailure, providerDeliveryFailureFacts, serializeAgentErrorRaw, innermostCauseName } from "../runtime/agentError.js";
6
7
  export const LEADER_WAKE_AGGREGATION_MS = 60_000;
7
8
  export const LEADER_WAKE_FORCE_MS = 10 * 60_000;
8
9
  /**
@@ -146,6 +147,15 @@ async function forceLeaderSteer(store, delivery, taskId, roleName, active, now)
146
147
  }
147
148
  }
148
149
  const owner = `leader-steer:${active.id}`;
150
+ if (existing?.owner === owner) {
151
+ // A previous pass already claimed this input. If it could not settle the
152
+ // claim, delivery may have happened even when the Host has since restarted.
153
+ // Do not turn a mailbox read into an automatic Provider retry.
154
+ return {
155
+ taskId, turnId: active.id, status: "skipped", reason: "not-ready",
156
+ error: "The existing Leader steer attempt is unresolved; its mailbox claim and error evidence are preserved."
157
+ };
158
+ }
149
159
  const batchId = existing?.batchId
150
160
  ?? `leader-steer:${encodeURIComponent(taskId)}:${encodeURIComponent(active.id)}:${pending.fromSequence}-${pending.toSequence}`;
151
161
  const claim = store.claimWorkMailbox({ target, batchId, owner, now });
@@ -156,6 +166,11 @@ async function forceLeaderSteer(store, delivery, taskId, roleName, active, now)
156
166
  if (processing.batchId !== batchId || processing.owner !== owner) {
157
167
  return { taskId, turnId: active.id, status: "skipped", reason: "busy" };
158
168
  }
169
+ const receiptId = `turn-input:${taskId}/${active.id}/${batchId}`;
170
+ let inputMayBeAccepted = false;
171
+ let inputAccepted = false;
172
+ let deliveryReturned = false;
173
+ let runtimeGenerationId;
159
174
  try {
160
175
  const sessions = store.getTaskRoleSessionSet?.(taskId, roleName) ?? null;
161
176
  const session = sessions?.sessions[active.effective.agentId];
@@ -192,6 +207,8 @@ async function forceLeaderSteer(store, delivery, taskId, roleName, active, now)
192
207
  directive,
193
208
  deltaRefIds: []
194
209
  });
210
+ runtimeGenerationId = session.runtimeGenerationId;
211
+ inputMayBeAccepted = true;
195
212
  const outcome = await delivery.steerOnce({
196
213
  taskId,
197
214
  roleName,
@@ -205,18 +222,40 @@ async function forceLeaderSteer(store, delivery, taskId, roleName, active, now)
205
222
  owner: "controller",
206
223
  holderId: authority.holderId
207
224
  },
208
- receiptId: `turn-input:${taskId}/${active.id}/${batchId}`,
225
+ receiptId,
209
226
  text: directive
210
227
  });
211
- if (outcome !== "sent" && outcome !== "already-sent") {
212
- if (outcome !== "delivery-unknown")
228
+ deliveryReturned = true;
229
+ inputAccepted = outcome.status === "sent" || outcome.status === "already-sent"
230
+ || outcome.failure?.inputDisposition === "accepted";
231
+ inputMayBeAccepted = outcome.status === "delivery-unknown" || inputAccepted;
232
+ if (outcome.status !== "sent" && outcome.status !== "already-sent") {
233
+ if (!inputMayBeAccepted)
213
234
  store.releaseWorkMailbox(target, batchId);
235
+ const failure = outcome.failure;
236
+ if (failure !== undefined || outcome.status === "rejected" || outcome.status === "delivery-unknown") {
237
+ store.recordAgentError?.({
238
+ taskId, roleName, turnId: active.id,
239
+ source: "host",
240
+ phase: failure?.phase ?? "turn-submit",
241
+ message: failure === undefined ? `Leader steer ${outcome.status}.` : formatProviderDeliveryFailure(failure),
242
+ raw: failure?.raw ?? serializeAgentErrorRaw(failure ?? outcome.status),
243
+ inputDisposition: failure?.inputDisposition
244
+ ?? (outcome.status === "delivery-unknown" ? "unknown" : "not-accepted"),
245
+ ...providerDeliveryFailureFacts(failure),
246
+ attemptId: receiptId
247
+ }, now);
248
+ }
214
249
  return {
215
250
  taskId,
216
251
  turnId: active.id,
217
- status: outcome === "busy" ? "skipped" : "failed",
218
- reason: outcome === "busy" ? "busy" : "not-ready",
219
- error: outcome
252
+ status: outcome.status === "busy" ? "skipped" : "failed",
253
+ reason: outcome.status === "busy" ? "busy" : "not-ready",
254
+ // Report the Host's cause when it supplied one; the bare status word
255
+ // alone cannot distinguish a rejection from a lost transport.
256
+ error: outcome.failure === undefined
257
+ ? outcome.status
258
+ : `${outcome.status}: ${formatProviderDeliveryFailure(outcome.failure)}`
220
259
  };
221
260
  }
222
261
  const saved = store.saveLeaderSteer({ taskId, turnId: active.id, batchId, input, now });
@@ -227,7 +266,22 @@ async function forceLeaderSteer(store, delivery, taskId, roleName, active, now)
227
266
  : { taskId, turnId: active.id, status: "skipped", reason: saved };
228
267
  }
229
268
  catch (error) {
230
- store.releaseWorkMailbox(target, batchId);
269
+ // A transport exception (or failure after delivery) is not evidence that
270
+ // the input was unsubmitted. Preserve its exact mailbox attempt.
271
+ if (!inputMayBeAccepted)
272
+ store.releaseWorkMailbox(target, batchId);
273
+ store.recordAgentError?.({
274
+ taskId, roleName, turnId: active.id,
275
+ source: inputMayBeAccepted && !deliveryReturned ? "host" : "yui",
276
+ phase: "turn-submit",
277
+ message: error instanceof Error ? error.message : String(error),
278
+ raw: serializeAgentErrorRaw(error),
279
+ inputDisposition: inputAccepted ? "accepted" : inputMayBeAccepted ? "unknown" : "not-accepted",
280
+ ...(error instanceof Error ? { errorName: error.name } : {}),
281
+ causeName: innermostCauseName(error),
282
+ expectedRuntimeGenerationId: runtimeGenerationId,
283
+ attemptId: receiptId
284
+ }, now);
231
285
  return {
232
286
  taskId,
233
287
  turnId: active.id,
@@ -688,6 +688,15 @@ CREATE UNIQUE INDEX idx_durable_jobs_request
688
688
  ON durable_jobs(task_id, json_extract(payload, '$.operation.actorId'),
689
689
  json_extract(payload, '$.operation.requestId'));
690
690
  `
691
+ },
692
+ {
693
+ version: 3,
694
+ name: "exact-attempt-result-identity",
695
+ introducedIn: "0.15.5",
696
+ // Append after the released T01 migration without changing its checksum.
697
+ // Accepted inputs/results can use an exact attempt without a native Turn id.
698
+ // Preserve all valid historical records; never repair failed Turns or logs.
699
+ sql: "SELECT 1; -- exact attempt identity without a fabricated native Turn id"
691
700
  }
692
701
  ]);
693
702
  for (let index = 0; index < MIGRATIONS.length; index += 1) {
@@ -13,4 +13,4 @@
13
13
  * intermediate Yui releases.
14
14
  */
15
15
  export const MIN_SUPPORTED_STORAGE_VERSION = 1;
16
- export const CURRENT_STORAGE_VERSION = 2;
16
+ export const CURRENT_STORAGE_VERSION = 3;
package/dist/turn/turn.js CHANGED
@@ -354,7 +354,13 @@ function validateTurnProviderResult(provider) {
354
354
  requireText(provider.accountScope, "Provider account scope");
355
355
  requireText(provider.conversationId, "Provider Conversation id");
356
356
  requireText(provider.activationId, "Provider Activation id");
357
- requireText(provider.nativeTurnId, "Provider native Turn id");
357
+ if (provider.nativeTurnId !== undefined)
358
+ requireText(provider.nativeTurnId, "Provider native Turn id");
359
+ if (provider.attemptId !== undefined)
360
+ requireText(provider.attemptId, "Provider attempt id");
361
+ if (provider.nativeTurnId === undefined && provider.attemptId === undefined) {
362
+ throw new Error("Provider result requires a native Turn or exact attempt identity.");
363
+ }
358
364
  if (!["completed", "failed", "cancelled"].includes(provider.status)) {
359
365
  throw new Error(`Provider Turn result status is invalid: ${String(provider.status)}.`);
360
366
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.15.3",
3
+ "version": "0.15.6",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,