@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.
- package/README.md +8 -9
- package/dist/cli/commandCatalog.js +11 -6
- package/dist/cli/interactionPolicy.js +5 -6
- package/dist/cli/updateCommand.js +3 -1
- package/dist/cli/updateOrchestrator.js +173 -28
- package/dist/cli/updatePorts.js +137 -8
- package/dist/cli/upgradeCommand.js +19 -9
- package/dist/cli.js +51 -14
- package/dist/commands/configCommands.js +1 -1
- package/dist/commands/executionAuditCommands.js +2 -1
- package/dist/commands/taskCommands.js +80 -29
- package/dist/commands/taskContextCommand.js +6 -2
- package/dist/commands/taskRoleRuntimeStatus.js +31 -7
- package/dist/config/configCatalog.js +1 -1
- package/dist/controller/clientRuntime.js +38 -2
- package/dist/controller/controller.js +23 -15
- package/dist/controller/fileSchedulerStoreAdapter.js +248 -138
- package/dist/controller/runtime.js +56 -1
- package/dist/controller/runtimeHookRunFence.js +19 -4
- package/dist/controller/structuredProviderObservation.js +20 -3
- package/dist/core/controllerClient.js +20 -2
- package/dist/core/controllerServer.js +1 -0
- package/dist/executor/agentExecutor.js +48 -46
- package/dist/executor/fileRoleLaunchPlanner.js +94 -30
- package/dist/lifecycle/exactRunTerminalization.js +68 -3
- package/dist/observability/executionAudit.js +5 -0
- package/dist/release/runtimeRelease.js +20 -0
- package/dist/run/recoveryProjection.js +45 -6
- package/dist/runtime/agentHost.js +159 -85
- package/dist/runtime/conversationSwitch.js +277 -0
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/launchBroker.js +12 -0
- package/dist/runtime/processExitOutbox.js +88 -0
- package/dist/runtime/providerRuntimeIdentity.js +29 -1
- package/dist/runtime/runtimeHealthPolicy.js +5 -5
- package/dist/runtime/runtimeObservation.js +15 -0
- package/dist/runtime/runtimeProjection.js +6 -7
- package/dist/runtime/tmuxAdapters.js +4 -1
- package/dist/scheduler/activeRoleRunDelivery.js +31 -196
- package/dist/scheduler/leaderWakeupProcessor.js +39 -133
- package/dist/scheduler/roleRunStall.js +53 -17
- package/dist/storage/sqliteSchema.js +62 -26
- package/dist/storage/sqliteStore.js +23 -4
- package/dist/storage/upgrade/homeClassification.js +52 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
- package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
- package/dist/task/nextAction.js +0 -34
- package/dist/web/webSnapshot.js +3 -1
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +7 -4
- package/skills/yui-operator/SKILL.md +4 -4
- package/skills/yui-reviewer/SKILL.md +7 -4
- 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
|
+
}
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
13
|
-
export const RUNTIME_DIAGNOSTIC_AFTER_MS =
|
|
14
|
-
/** No durable semantic progress
|
|
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
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|