@zq-silk/yui 0.12.0 → 0.12.2

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 (53) hide show
  1. package/README.md +8 -9
  2. package/dist/cli/commandCatalog.js +11 -6
  3. package/dist/cli/interactionPolicy.js +5 -6
  4. package/dist/cli/updateCommand.js +3 -1
  5. package/dist/cli/updateOrchestrator.js +173 -28
  6. package/dist/cli/updatePorts.js +137 -8
  7. package/dist/cli/upgradeCommand.js +19 -9
  8. package/dist/cli.js +51 -14
  9. package/dist/commands/configCommands.js +1 -1
  10. package/dist/commands/executionAuditCommands.js +2 -1
  11. package/dist/commands/taskCommands.js +80 -29
  12. package/dist/commands/taskContextCommand.js +6 -2
  13. package/dist/commands/taskRoleRuntimeStatus.js +31 -7
  14. package/dist/config/configCatalog.js +1 -1
  15. package/dist/controller/clientRuntime.js +38 -2
  16. package/dist/controller/controller.js +23 -15
  17. package/dist/controller/fileSchedulerStoreAdapter.js +248 -138
  18. package/dist/controller/runtime.js +56 -1
  19. package/dist/controller/runtimeHookRunFence.js +19 -4
  20. package/dist/controller/structuredProviderObservation.js +20 -3
  21. package/dist/core/controllerClient.js +20 -2
  22. package/dist/core/controllerServer.js +1 -0
  23. package/dist/executor/agentExecutor.js +48 -46
  24. package/dist/executor/fileRoleLaunchPlanner.js +94 -30
  25. package/dist/lifecycle/exactRunTerminalization.js +68 -3
  26. package/dist/observability/executionAudit.js +5 -0
  27. package/dist/release/runtimeRelease.js +20 -0
  28. package/dist/run/recoveryProjection.js +45 -6
  29. package/dist/runtime/agentHost.js +159 -85
  30. package/dist/runtime/conversationSwitch.js +277 -0
  31. package/dist/runtime/index.js +1 -1
  32. package/dist/runtime/launchBroker.js +12 -0
  33. package/dist/runtime/processExitOutbox.js +88 -0
  34. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  35. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  36. package/dist/runtime/runtimeObservation.js +15 -0
  37. package/dist/runtime/runtimeProjection.js +6 -7
  38. package/dist/runtime/tmuxAdapters.js +4 -1
  39. package/dist/scheduler/activeRoleRunDelivery.js +31 -196
  40. package/dist/scheduler/leaderWakeupProcessor.js +39 -133
  41. package/dist/scheduler/roleRunStall.js +53 -17
  42. package/dist/storage/sqliteSchema.js +62 -26
  43. package/dist/storage/sqliteStore.js +23 -4
  44. package/dist/storage/upgrade/homeClassification.js +52 -0
  45. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  46. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  47. package/dist/task/nextAction.js +0 -34
  48. package/dist/web/webSnapshot.js +3 -1
  49. package/package.json +1 -1
  50. package/skills/yui-leader/SKILL.md +7 -4
  51. package/skills/yui-operator/SKILL.md +4 -4
  52. package/skills/yui-reviewer/SKILL.md +7 -4
  53. package/dist/lifecycle/taskRoleSessionReset.js +0 -118
@@ -0,0 +1,277 @@
1
+ import { roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
2
+ import { effectiveLaunchSnapshotsCompatibleForTaskSession } from "../executor/effectiveLaunch.js";
3
+ import { currentProviderActivation, currentProviderConversation } from "./providerRuntimeIdentity.js";
4
+ import { blockingProviderContinuations } from "./runtimeContinuationProjection.js";
5
+ import { runtimeObservationFromTaskEvent } from "./runtimeObservation.js";
6
+ export const CONVERSATION_SWITCH_REQUESTED_EVENT = "runtime.conversation-switch-requested";
7
+ export const CONVERSATION_SWITCH_RESOLVED_EVENT = "runtime.conversation-switch-resolved";
8
+ export const CONVERSATION_SWITCH_DETACHED_EVENT = "runtime.conversation-switch-detached";
9
+ export function providerConversationGeneration(sessions) {
10
+ const binding = sessions?.providerBinding;
11
+ if (binding === null || binding === undefined)
12
+ return null;
13
+ const current = currentProviderConversation(binding);
14
+ return `${binding.providerNamespace}:${binding.accountScope}:${current.epoch}:${current.conversationId}`;
15
+ }
16
+ export function projectConversationSwitch(events, roleName, sessions) {
17
+ const requests = events.filter((event) => (event.type === CONVERSATION_SWITCH_REQUESTED_EVENT
18
+ && event.payload.roleName === roleName));
19
+ const request = requests.at(-1);
20
+ if (request === undefined)
21
+ return null;
22
+ const requestId = request.payload.requestId;
23
+ const generation = request.payload.generation;
24
+ const requestedBy = request.payload.requestedBy;
25
+ const reason = request.payload.reason;
26
+ if (requestId === undefined || generation === undefined || reason === undefined
27
+ || (requestedBy !== "user" && requestedBy !== "operator" && requestedBy !== "leader")) {
28
+ return null;
29
+ }
30
+ const resolution = [...events].reverse().find((event) => (event.type === CONVERSATION_SWITCH_RESOLVED_EVENT
31
+ && event.payload.requestId === requestId));
32
+ const explicitStatus = resolution?.payload.status;
33
+ const currentGeneration = providerConversationGeneration(sessions);
34
+ const status = explicitStatus === "applied" || explicitStatus === "obsolete"
35
+ ? explicitStatus
36
+ : currentGeneration !== null && currentGeneration !== generation
37
+ ? "obsolete"
38
+ : "pending";
39
+ return {
40
+ requestId,
41
+ roleName,
42
+ generation,
43
+ requestedBy,
44
+ reason,
45
+ requestedAt: request.createdAt,
46
+ status,
47
+ ...(resolution === undefined ? {} : { resolvedAt: resolution.createdAt })
48
+ };
49
+ }
50
+ export function pendingConversationSwitch(events, roleName, sessions) {
51
+ const projected = projectConversationSwitch(events, roleName, sessions);
52
+ return projected?.status === "pending" ? projected : null;
53
+ }
54
+ export function roleSessionDispatchModeWithConversationSwitch(sessions, events, mailbox, roleName, agentId, effective) {
55
+ const ordinary = roleAgentSessionResumeMode(sessions, agentId, effective);
56
+ if (ordinary !== "resume") {
57
+ if (sessions?.providerBinding !== null && sessions?.providerBinding !== undefined) {
58
+ if (freshConversationLaunchAllowed({ sessions, events, mailbox, roleName })) {
59
+ return "new";
60
+ }
61
+ const existing = sessions.sessions[agentId];
62
+ if (existing?.nativeSessionId !== undefined
63
+ && (existing.status === "stopped" || existing.status === "broken")
64
+ && effectiveLaunchSnapshotsCompatibleForTaskSession(existing.effective, effective)) {
65
+ // A terminal local Activation does not prove the Provider Conversation
66
+ // is gone. Reattach to the same native identity by default.
67
+ return "resume";
68
+ }
69
+ throw new Error(`Fresh Provider Conversation is not yet safe: ${sessions.owner.taskId}/${roleName}.`);
70
+ }
71
+ return ordinary;
72
+ }
73
+ if (sessions?.providerBinding !== null && sessions?.providerBinding !== undefined
74
+ && freshConversationLaunchAllowed({ sessions, events, mailbox, roleName })) {
75
+ return "new";
76
+ }
77
+ return "resume";
78
+ }
79
+ export function conversationDetachmentBasis(input) {
80
+ const { sessions, mailbox } = input;
81
+ const binding = sessions.providerBinding;
82
+ const activation = binding === null ? null : currentProviderActivation(binding);
83
+ if (input.runMode !== "new" || binding === null || activation === null
84
+ || sessions.inFlight?.runId !== input.runId
85
+ || mailbox?.processing?.batchId !== sessions.inFlight.receiptId
86
+ || mailbox.processing.owner !== "controller"
87
+ || mailbox.processing.executionRef?.type !== "run"
88
+ || mailbox.processing.executionRef.taskId !== sessions.owner.taskId
89
+ || mailbox.processing.executionRef.id !== input.runId
90
+ || !actorRequestedSwitchBoundaryReady(sessions, mailbox)
91
+ || currentConversationExecutionBlockers(sessions, input.events, input.roleName).length > 0
92
+ || pendingConversationSwitch(input.events, input.roleName, sessions) === null) {
93
+ return null;
94
+ }
95
+ return "actor-request";
96
+ }
97
+ export function conversationReplacementBasis(input) {
98
+ const { sessions, mailbox } = input;
99
+ const binding = sessions.providerBinding;
100
+ if (input.runMode !== "new" || binding === null
101
+ || sessions.inFlight?.runId !== input.runId
102
+ || mailbox?.processing?.batchId !== sessions.inFlight.receiptId
103
+ || mailbox.processing.owner !== "controller"
104
+ || mailbox?.processing?.executionRef?.type !== "run"
105
+ || mailbox.processing.executionRef.taskId !== sessions.owner.taskId
106
+ || mailbox.processing.executionRef.id !== input.runId
107
+ || mailbox.inputDelivery !== null
108
+ || !conversationIsQuiescent(sessions, mailbox)
109
+ || currentConversationExecutionBlockers(sessions, input.events, input.roleName).length > 0) {
110
+ return null;
111
+ }
112
+ if (currentConversationIsExactlyUnrecoverable(sessions, input.events, input.roleName))
113
+ return "exact-unrecoverable";
114
+ return pendingConversationSwitch(input.events, input.roleName, sessions) === null
115
+ ? null
116
+ : "actor-request";
117
+ }
118
+ export function freshConversationLaunchAllowed(input) {
119
+ return freshConversationLaunchBlockers(input).length === 0;
120
+ }
121
+ /** Exact reasons a fresh Conversation cannot currently be admitted. */
122
+ export function freshConversationLaunchBlockers(input) {
123
+ const { sessions } = input;
124
+ if (sessions === null)
125
+ return [];
126
+ const session = sessions.sessions[sessions.activeAgentId];
127
+ if (sessions.providerBinding === null) {
128
+ return session === undefined
129
+ || session.status === "stopped"
130
+ || session.status === "broken"
131
+ ? []
132
+ : ["native-session-not-terminal"];
133
+ }
134
+ const request = pendingConversationSwitch(input.events, input.roleName, sessions);
135
+ const binding = sessions.providerBinding;
136
+ const blockers = [];
137
+ if (input.mailbox?.inputDelivery != null)
138
+ blockers.push("provider-input-delivery-unsettled");
139
+ if (binding.turn !== null
140
+ && ["submitting", "accepted", "running", "delivery-unknown"].includes(binding.turn.status))
141
+ blockers.push("provider-turn-unsettled");
142
+ blockers.push(...currentConversationExecutionBlockers(sessions, input.events, input.roleName));
143
+ if (blockers.length > 0)
144
+ return blockers;
145
+ if (request !== null && actorRequestedSwitchBoundaryReady(sessions, input.mailbox, input.candidateRunId))
146
+ return [];
147
+ if (currentProviderActivation(binding) !== null)
148
+ blockers.push("provider-activation-active");
149
+ if (binding.authority.owner !== "none")
150
+ blockers.push("provider-writer-authority-owned");
151
+ if (blockers.length > 0)
152
+ return blockers;
153
+ if (currentProviderConversation(binding).recoverability === "unrecoverable") {
154
+ return currentConversationIsExactlyUnrecoverable(sessions, input.events, input.roleName) ? [] : ["exact-unrecoverable-evidence-missing"];
155
+ }
156
+ if (request !== null)
157
+ return ["actor-switch-boundary-not-ready"];
158
+ return ["current-conversation-recoverable"];
159
+ }
160
+ function actorRequestedSwitchBoundaryReady(sessions, mailbox, candidateRunId) {
161
+ const binding = sessions?.providerBinding;
162
+ if (sessions === null || sessions === undefined
163
+ || binding === null || binding === undefined
164
+ || mailbox?.inputDelivery != null)
165
+ return false;
166
+ const turnSettled = binding.turn === null
167
+ || ["completed", "failed", "cancelled", "rejected"].includes(binding.turn.status);
168
+ if (!turnSettled)
169
+ return false;
170
+ const session = sessions.sessions[sessions.activeAgentId];
171
+ if (session?.status === "running")
172
+ return false;
173
+ const activation = currentProviderActivation(binding);
174
+ if (activation === null)
175
+ return binding.authority.owner === "none";
176
+ if (binding.authority.owner !== "controller"
177
+ || binding.authority.holderId !== activation.activationId)
178
+ return false;
179
+ if (session === undefined)
180
+ return false;
181
+ if (sessions.inFlight === null) {
182
+ if (mailbox?.processing === null)
183
+ return true;
184
+ return candidateRunId !== undefined
185
+ && mailbox?.processing?.owner === "controller"
186
+ && mailbox.processing.executionRef?.type === "run"
187
+ && mailbox.processing.executionRef.taskId === sessions.owner.taskId
188
+ && mailbox.processing.executionRef.id === candidateRunId;
189
+ }
190
+ return mailbox?.processing?.batchId === sessions.inFlight.receiptId
191
+ && mailbox.processing.owner === "controller"
192
+ && mailbox.processing.executionRef?.type === "run"
193
+ && mailbox.processing.executionRef.taskId === sessions.owner.taskId
194
+ && mailbox.processing.executionRef.id === sessions.inFlight.runId;
195
+ }
196
+ function conversationIsQuiescent(sessions, mailbox) {
197
+ const binding = sessions?.providerBinding;
198
+ if (binding === null || binding === undefined)
199
+ return false;
200
+ const turnSettled = binding.turn === null
201
+ || ["completed", "failed", "cancelled", "rejected"].includes(binding.turn.status);
202
+ return turnSettled
203
+ && currentProviderActivation(binding) === null
204
+ && binding.authority.owner === "none"
205
+ && mailbox?.inputDelivery == null;
206
+ }
207
+ /**
208
+ * A projected recoverability flag is necessary but not sufficient to replace
209
+ * a Conversation. Require the exact structured missing-Conversation fact for
210
+ * the current Provider identity and latest Activation generation, so an old
211
+ * observation cannot authorize a later fresh Conversation. The binding's Run
212
+ * id is deliberately excluded: replacement work rebinds that fence before
213
+ * launching, while the missing fact necessarily came from the preceding Run's
214
+ * exact attempt to resume this same Activation.
215
+ */
216
+ function currentConversationIsExactlyUnrecoverable(sessions, events, roleName) {
217
+ const binding = sessions.providerBinding;
218
+ if (binding === null)
219
+ return false;
220
+ const conversation = currentProviderConversation(binding);
221
+ if (conversation.recoverability !== "unrecoverable")
222
+ return false;
223
+ const activation = [...binding.activations].reverse().find((entry) => (entry.conversationId === conversation.conversationId));
224
+ const generationStartedAt = activation?.startedAt ?? conversation.createdAt;
225
+ return events.some((event) => {
226
+ const observation = runtimeObservationFromTaskEvent(event);
227
+ if (observation === null
228
+ || observation.kind !== "conversation.observed"
229
+ || observation.payload.recoverability !== "unrecoverable"
230
+ || (observation.authority !== "provider-structured"
231
+ && observation.authority !== "controller"))
232
+ return false;
233
+ const fence = observation.fence;
234
+ if (fence.taskId !== sessions.owner.taskId
235
+ || fence.roleName !== roleName
236
+ || fence.agentId !== binding.accountScope
237
+ || fence.driverId !== binding.providerNamespace
238
+ || fence.conversationId !== conversation.conversationId
239
+ || (activation !== undefined && fence.activationId !== activation.activationId)) {
240
+ return false;
241
+ }
242
+ return Date.parse(observation.observedAt ?? observation.receivedAt)
243
+ >= Date.parse(generationStartedAt);
244
+ });
245
+ }
246
+ /** Current Provider-owned work that may outlive a foreground Turn. */
247
+ function currentConversationExecutionBlockers(sessions, events, roleName) {
248
+ const binding = sessions.providerBinding;
249
+ if (binding === null)
250
+ return [];
251
+ const conversation = currentProviderConversation(binding);
252
+ const activation = [...binding.activations].reverse().find((entry) => (entry.conversationId === conversation.conversationId));
253
+ if (activation === undefined)
254
+ return ["provider-activation-identity-missing"];
255
+ const activeOperation = events.some((event) => {
256
+ const observation = runtimeObservationFromTaskEvent(event);
257
+ if (observation?.kind !== "operation.started")
258
+ return false;
259
+ const fence = observation.fence;
260
+ return fence.taskId === sessions.owner.taskId
261
+ && fence.roleName === roleName
262
+ && fence.agentId === binding.accountScope
263
+ && fence.driverId === binding.providerNamespace
264
+ && (fence.conversationId ?? fence.nativeSessionId) === conversation.conversationId
265
+ && (fence.activationId ?? fence.launchId) === activation.activationId;
266
+ });
267
+ const blockingContinuation = blockingProviderContinuations(events).some((entry) => (entry.taskId === sessions.owner.taskId
268
+ && entry.roleName === roleName
269
+ && entry.identity.providerNamespace === binding.providerNamespace
270
+ && entry.identity.accountScope === binding.accountScope
271
+ && entry.identity.conversationId === conversation.conversationId
272
+ && entry.identity.activationId === activation.activationId));
273
+ return [
274
+ ...(activeOperation ? ["provider-operation-active"] : []),
275
+ ...(blockingContinuation ? ["provider-continuation-writer-owned"] : [])
276
+ ];
277
+ }
@@ -14,7 +14,7 @@ export { formatRuntimeLaunchDiagnostic, redactLaunchArgument, redactLaunchText,
14
14
  export { DEFAULT_FORCED_GRACE_MS, DEFAULT_GRACEFUL_GRACE_MS, terminateSessionOwners } from "./sessionTerminationGuard.js";
15
15
  export { ProviderContinuationReconciliationService } from "./providerContinuationReconciliationService.js";
16
16
  export { codexNotificationBoundary, codexAppServerErrorIsMissing, CodexAppServerRequestError, CodexAppServerRuntime } from "./codexAppServerRuntime.js";
17
- export { createProviderRuntimeBinding, acceptProviderTurn, beginProviderTurn, currentProviderActivation, currentProviderAuthority, currentProviderConversation, endProviderActivation, markProviderTurnDeliveryUnknown, rebindProviderRuntimeRun, rejectProviderTurn, settleProviderTurn, startProviderActivation, supersedeProviderConversation, transferProviderAuthority, updateProviderConversationRecoverability, validateProviderRuntimeBinding } from "./providerRuntimeIdentity.js";
17
+ export { createProviderRuntimeBinding, acceptProviderTurn, beginProviderTurn, currentProviderActivation, currentProviderAuthority, currentProviderConversation, endProviderActivation, markProviderTurnDeliveryUnknown, rebindProviderRuntimeRun, rejectProviderTurn, settleProviderTurnSubmission, settleProviderTurn, startProviderActivation, supersedeProviderConversation, transferProviderAuthority, updateProviderConversationRecoverability, validateProviderRuntimeBinding } from "./providerRuntimeIdentity.js";
18
18
  export { FencedProviderControl } from "./providerControl.js";
19
19
  export { sameProviderAuthorityFence, validateProviderAuthorityFence } from "./providerAuthorityFence.js";
20
20
  export { decideProviderRecovery } from "./providerRecoveryDecision.js";
@@ -93,6 +93,18 @@ function validateProviderControl(control) {
93
93
  if (control.mode !== "new" && control.mode !== "resume") {
94
94
  throw new Error("Agent Host Provider control mode is invalid.");
95
95
  }
96
+ if (control.kind !== "new" && control.kind !== "resume" && control.kind !== "ensure") {
97
+ throw new Error("Agent Host Provider control kind is invalid.");
98
+ }
99
+ if ((control.kind === "new") !== (control.mode === "new")) {
100
+ throw new Error("Agent Host Provider control kind does not match its transport mode.");
101
+ }
102
+ if (control.kind !== "ensure" && control.initialTurn === undefined) {
103
+ throw new Error("Managed Provider new/resume launch requires its initial Turn.");
104
+ }
105
+ if (control.kind === "ensure" && control.initialTurn !== undefined) {
106
+ throw new Error("Managed Provider ensure launch cannot carry a new Turn.");
107
+ }
96
108
  const requiresNativeSessionId = control.mode === "resume" || control.adapterId === "claude";
97
109
  if (requiresNativeSessionId !== (control.nativeSessionId !== undefined)) {
98
110
  throw new Error("Agent Host Provider resume identity is inconsistent.");
@@ -0,0 +1,88 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, unlink } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { writeTextFileAtomically } from "../storage/durableFile.js";
5
+ import { validateRuntimeProcessExitObservation } from "./processExitObservation.js";
6
+ /**
7
+ * Persist before attempting Controller delivery. The Controller also drains
8
+ * this outbox before it begins serving after a restart, so a Session does not
9
+ * have to remain alive merely to bridge a short handover window.
10
+ */
11
+ export async function persistRuntimeProcessExitObservation(home, observation, submit) {
12
+ const validated = validateRuntimeProcessExitObservation(observation);
13
+ const directory = outboxDirectory(home);
14
+ const identity = createHash("sha256").update(validated.observationId).digest("hex");
15
+ writeTextFileAtomically(join(directory, `${identity}.json`), `${JSON.stringify(validated)}\n`);
16
+ await replayRuntimeProcessExitOutbox(home, submit);
17
+ }
18
+ /**
19
+ * Replays every record through an idempotent observation boundary. A failed
20
+ * record remains durable and is considered again by the next replay.
21
+ */
22
+ export async function replayRuntimeProcessExitOutbox(home, submit) {
23
+ const directory = outboxDirectory(home);
24
+ let entries;
25
+ try {
26
+ entries = await readdir(directory);
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return;
31
+ throw error;
32
+ }
33
+ for (const name of entries.filter((name) => name.endsWith(".json")).sort()) {
34
+ const path = join(directory, name);
35
+ let raw;
36
+ try {
37
+ raw = await readFile(path, "utf8");
38
+ }
39
+ catch (error) {
40
+ // Another Agent Host or the replacement Controller may have submitted
41
+ // and removed this immutable record after our directory snapshot.
42
+ if (error.code === "ENOENT")
43
+ continue;
44
+ throw error;
45
+ }
46
+ const observation = validateRuntimeProcessExitObservation(JSON.parse(raw));
47
+ await submit(observation);
48
+ await unlink(path).catch((error) => {
49
+ if (error.code !== "ENOENT")
50
+ throw error;
51
+ });
52
+ }
53
+ // One-generation bridge for observations written by an Agent Host that was
54
+ // already running before this release. New writers use one immutable file
55
+ // per observation above, so they never share an append/unlink boundary.
56
+ for (const name of entries.filter(isLegacyOutboxEntry).sort()) {
57
+ const path = join(directory, name);
58
+ let raw;
59
+ try {
60
+ raw = await readFile(path, "utf8");
61
+ }
62
+ catch (error) {
63
+ // The old writer removes its own append log only after the Controller
64
+ // acknowledges delivery. If it won that race, there is nothing to replay.
65
+ if (error.code === "ENOENT")
66
+ continue;
67
+ throw error;
68
+ }
69
+ // Only consume newline-terminated records. A legacy writer can be between
70
+ // append bytes and its trailing newline while this snapshot is read.
71
+ const completeLength = raw.lastIndexOf("\n") + 1;
72
+ const lines = raw.slice(0, completeLength).split("\n").filter(Boolean);
73
+ for (const line of lines) {
74
+ await submit(validateRuntimeProcessExitObservation(JSON.parse(line)));
75
+ }
76
+ // Deliberately leave the legacy path in place. A pre-upgrade Agent Host may
77
+ // already hold an append descriptor for this inode; renaming/unlinking it
78
+ // would let a later append disappear into an unlinked file. The old writer
79
+ // removes the path after a successful direct submission. Otherwise the
80
+ // durable file remains for the next idempotent replay.
81
+ }
82
+ }
83
+ function outboxDirectory(home) {
84
+ return resolve(join(home, "runtime", "agent-host-outbox"));
85
+ }
86
+ function isLegacyOutboxEntry(name) {
87
+ return name.endsWith(".jsonl");
88
+ }
@@ -236,6 +236,30 @@ export function rejectProviderTurn(raw, input) {
236
236
  }
237
237
  });
238
238
  }
239
+ /** Resolves an Agent Host submission exactly once and accepts an exact acknowledgement replay. */
240
+ export function settleProviderTurnSubmission(raw, input) {
241
+ const binding = validateProviderRuntimeBinding(raw);
242
+ const attemptId = identity(input.attemptId, "Provider input attempt id");
243
+ if (binding.turn?.attemptId !== attemptId) {
244
+ throw new Error("Provider Turn does not match a resolvable delivery state.");
245
+ }
246
+ if (binding.turn.status === input.status)
247
+ return binding;
248
+ if (binding.turn.status !== "submitting") {
249
+ throw new Error("Provider Turn does not match a resolvable delivery state.");
250
+ }
251
+ return input.status === "delivery-unknown"
252
+ ? markProviderTurnDeliveryUnknown(binding, {
253
+ attemptId,
254
+ observedAt: input.resolvedAt,
255
+ reason: input.reason
256
+ })
257
+ : rejectProviderTurn(binding, {
258
+ attemptId,
259
+ rejectedAt: input.resolvedAt,
260
+ reason: input.reason
261
+ });
262
+ }
239
263
  export function settleProviderTurn(raw, input) {
240
264
  const binding = validateProviderRuntimeBinding(raw);
241
265
  const turn = binding.turn;
@@ -270,7 +294,11 @@ export function updateProviderConversationRecoverability(raw, recoverability) {
270
294
  export function supersedeProviderConversation(raw, input) {
271
295
  const binding = validateProviderRuntimeBinding(raw);
272
296
  const current = currentProviderConversation(binding);
273
- if (current.recoverability !== "unrecoverable") {
297
+ const basis = input.basis ?? "exact-unrecoverable";
298
+ if (basis !== "actor-request" && basis !== "exact-unrecoverable") {
299
+ throw new Error("Provider Conversation switch basis is invalid.");
300
+ }
301
+ if (basis === "exact-unrecoverable" && current.recoverability !== "unrecoverable") {
274
302
  throw new Error("Current Provider Conversation is not exactly unrecoverable.");
275
303
  }
276
304
  if (!input.noUnsettledInputDelivery) {
@@ -4,14 +4,14 @@
4
4
  *
5
5
  * The layers are deliberately time-based and conservative: short silence is
6
6
  * normal for high-reasoning-effort turns, large reviews, and tool waits.
7
- * Only deterministic dead/broken evidence or the durable semantic stall
8
- * window authorizes recovery; quiet time alone never resets a Run.
7
+ * Time-based layers authorize display hints and coalesced read-only diagnosis
8
+ * only. They never reset a Run or replace a Provider Conversation.
9
9
  */
10
10
  /** Runtime silence after which a live turn is surfaced as "quiet" (hint only). */
11
11
  export const RUNTIME_QUIET_AFTER_MS = 5 * 60_000;
12
- /** No durable semantic progress after which a read-only diagnostic is warranted. */
13
- export const RUNTIME_DIAGNOSTIC_AFTER_MS = 10 * 60_000;
14
- /** No durable semantic progress after which the scheduler raises a stall candidate. */
12
+ /** No durable semantic progress after which the checkpoint is displayed as overdue. */
13
+ export const RUNTIME_DIAGNOSTIC_AFTER_MS = 15 * 60_000;
14
+ /** No durable semantic progress before one coalesced read-only runtime probe is due. */
15
15
  export const SEMANTIC_STALL_WINDOW_MS = 30 * 60_000;
16
16
  export const DEFAULT_RUNTIME_HEALTH_POLICY = Object.freeze({
17
17
  quietAfterMs: RUNTIME_QUIET_AFTER_MS,
@@ -223,6 +223,21 @@ export function runtimeObservationFromTaskEvent(event) {
223
223
  return null;
224
224
  }
225
225
  }
226
+ /**
227
+ * Runtime observation events are compacted by operation identity: a terminal
228
+ * operation replaces its matching start. Any retained exact start therefore
229
+ * represents Provider-owned work that is still active for this Run.
230
+ */
231
+ export function runHasActiveRuntimeOperations(events, owner) {
232
+ return events.some((event) => {
233
+ const observation = runtimeObservationFromTaskEvent(event);
234
+ return observation?.kind === "operation.started"
235
+ && observation.fence.taskId === owner.taskId
236
+ && observation.fence.roleName === owner.roleName
237
+ && observation.fence.runId === owner.runId
238
+ && observation.fence.agentId === owner.agentId;
239
+ });
240
+ }
226
241
  function normalizeFence(input) {
227
242
  return Object.freeze({
228
243
  ...(input.taskId === undefined ? {} : { taskId: requireIdentity(input.taskId, "Task id") }),
@@ -328,11 +328,9 @@ export function runtimeDisplayStatus(current) {
328
328
  * Integration checkpoints), so token/tool/CPU activity can never masquerade
329
329
  * as business progress.
330
330
  *
331
- * Layers below the durable stall window are advisory: `quiet` and
332
- * `diagnostic-needed` never authorize a reset. The 30-minute semantic stall is
333
- * persisted by the scheduler and surfaced as `stalled-candidate` by the
334
- * caller; this function deliberately stops at `diagnostic-needed` so the
335
- * Leader's waiting-user/waiting-on-workers classification stays authoritative.
331
+ * Time-derived layers are advisory: `quiet` and `diagnostic-needed` never
332
+ * authorize a reset or Session switch. The scheduler owns the separate,
333
+ * coalesced 30-minute read-only diagnostic window.
336
334
  */
337
335
  export function classifyRuntimeHealth(input) {
338
336
  const policy = input.policy ?? DEFAULT_RUNTIME_HEALTH_POLICY;
@@ -387,7 +385,8 @@ function classifyLayer(current, operation, runtimeIdleMs, semanticIdleMs, policy
387
385
  if (current.turn === "waiting") {
388
386
  return `waiting-${current.waitingReason ?? "external"}`;
389
387
  }
390
- // No durable semantic progress past the diagnostic window: read-only look.
388
+ // No durable semantic progress past fifteen minutes: display only. The
389
+ // scheduler's coalesced read-only diagnostic remains a separate 30m clock.
391
390
  if (semanticIdleMs >= policy.diagnosticAfterMs)
392
391
  return "diagnostic-needed";
393
392
  // A live turn with no recent structured activity is quiet, not dead.
@@ -420,7 +419,7 @@ function runtimeHealthReason(layer, current) {
420
419
  if (current.observer.status === "degraded" || current.observer.status === "unavailable") {
421
420
  return `the runtime observer is ${current.observer.status}; read-only diagnostic recommended`;
422
421
  }
423
- return "no durable semantic progress in the diagnostic window; read-only diagnostic recommended";
422
+ return "no durable semantic checkpoint in the overdue window; the runtime remains undisturbed";
424
423
  case "quiet":
425
424
  return "the Agent turn is active but has reported no structured runtime activity recently";
426
425
  case "active-quiet":
@@ -408,7 +408,10 @@ export class TmuxSessionHost {
408
408
  throw error;
409
409
  }
410
410
  if (providerDispatchObserved
411
- && hostCreated
411
+ // A fresh Host always starts a Provider process. mode=new also starts
412
+ // one inside a reused Host by replacing (or creating) the native
413
+ // Conversation. Same-Conversation resume must retain its inherited key.
414
+ && (hostCreated || request.mode === "new")
412
415
  && request.owner.scope === "task"
413
416
  && planned.launch.env.YUI_JOB_CALLER_KEY !== undefined
414
417
  && this.planner.commitTaskCallerKey !== undefined) {