@zq-silk/yui 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +57 -24
- 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
|
@@ -12,6 +12,7 @@ import { createYieldReceipt } from "../run/yieldReceipt.js";
|
|
|
12
12
|
import { recordExecutionLaneResult } from "../execution/executionGroup.js";
|
|
13
13
|
import { isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
14
14
|
import { runOwnsBlockingProviderContinuation } from "../runtime/runtimeContinuationProjection.js";
|
|
15
|
+
import { runHasActiveRuntimeOperations } from "../runtime/runtimeObservation.js";
|
|
15
16
|
import { latestRunDurableProgressAt, RUN_RECOVERY_APPLIED_EVENT, RUN_RECOVERY_REQUESTED_EVENT } from "../scheduler/roleRunStall.js";
|
|
16
17
|
import { markTaskWakeConsumed } from "../scheduler/taskWake.js";
|
|
17
18
|
import { workItemExecutionGroupById, workItemOwnsUnresolvedExecutionLane, updateWorkItemExecutionGroup, updateWorkItemStatus } from "../workItem/workItem.js";
|
|
@@ -352,7 +353,7 @@ export function terminalizeExactTaskRun(store, input, now) {
|
|
|
352
353
|
/**
|
|
353
354
|
* Leader-controlled recovery boundary for one active AgentRun. This primitive
|
|
354
355
|
* validates every durable fence in one transaction and records only a
|
|
355
|
-
* structured request for retry
|
|
356
|
+
* structured request for same-Run diagnosis/retry. It never writes terminal
|
|
356
357
|
* bytes, retries a provider input, kills a host, or silently rebinds a native
|
|
357
358
|
* generation. Explicit termination is the sole action that changes Run state.
|
|
358
359
|
*/
|
|
@@ -420,6 +421,35 @@ function recoverExactAgentRunInTransaction(store, input) {
|
|
|
420
421
|
if (!matchesRecoverySessionFence(store, input)) {
|
|
421
422
|
return stateChanged("session-or-launch-fence-mismatch");
|
|
422
423
|
}
|
|
424
|
+
const recoveryBlocker = exactRecoveryExecutionBlocker(store, current);
|
|
425
|
+
if (recoveryBlocker !== null) {
|
|
426
|
+
return {
|
|
427
|
+
disposition: "blocked",
|
|
428
|
+
action: input.action,
|
|
429
|
+
run: current,
|
|
430
|
+
progressAt: progress.progressAt,
|
|
431
|
+
reason: recoveryBlocker
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (input.action === "terminate") {
|
|
435
|
+
const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
|
|
436
|
+
const session = sessions?.sessions[input.agentId];
|
|
437
|
+
const providerTurn = sessions?.providerBinding?.turn;
|
|
438
|
+
const exactTerminalEvidence = session?.status === "stopped"
|
|
439
|
+
|| session?.status === "broken"
|
|
440
|
+
|| providerTurn?.status === "failed"
|
|
441
|
+
|| providerTurn?.status === "cancelled"
|
|
442
|
+
|| providerTurn?.status === "rejected";
|
|
443
|
+
if (!exactTerminalEvidence) {
|
|
444
|
+
return {
|
|
445
|
+
disposition: "blocked",
|
|
446
|
+
action: input.action,
|
|
447
|
+
run: current,
|
|
448
|
+
progressAt: progress.progressAt,
|
|
449
|
+
reason: "runtime-not-terminal"
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
423
453
|
if (input.providerAcceptance === "ambiguous"
|
|
424
454
|
&& input.action !== "diagnose") {
|
|
425
455
|
return {
|
|
@@ -506,6 +536,42 @@ function recoverExactAgentRunInTransaction(store, input) {
|
|
|
506
536
|
progressAt: progress.progressAt
|
|
507
537
|
};
|
|
508
538
|
}
|
|
539
|
+
function exactRecoveryExecutionBlocker(store, run) {
|
|
540
|
+
const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
|
|
541
|
+
const binding = sessions?.providerBinding;
|
|
542
|
+
const mailbox = store.getWorkMailbox({
|
|
543
|
+
kind: "role",
|
|
544
|
+
taskId: run.taskId,
|
|
545
|
+
roleName: run.roleName
|
|
546
|
+
});
|
|
547
|
+
if (mailbox?.inputDelivery != null)
|
|
548
|
+
return "provider-input-delivery-unsettled";
|
|
549
|
+
if (runOwnsBlockingProviderContinuation(store.listEvents(run.taskId), {
|
|
550
|
+
taskId: run.taskId,
|
|
551
|
+
roleName: run.roleName,
|
|
552
|
+
runId: run.id,
|
|
553
|
+
agentId: run.effective.agentId
|
|
554
|
+
}))
|
|
555
|
+
return "provider-continuation-writer-owned";
|
|
556
|
+
if (runHasActiveRuntimeOperations(store.listEvents(run.taskId), {
|
|
557
|
+
taskId: run.taskId,
|
|
558
|
+
roleName: run.roleName,
|
|
559
|
+
runId: run.id,
|
|
560
|
+
agentId: run.effective.agentId
|
|
561
|
+
}))
|
|
562
|
+
return "provider-operation-active";
|
|
563
|
+
if (run.deliveredAt !== undefined
|
|
564
|
+
&& (binding === null || binding?.turn === null))
|
|
565
|
+
return "provider-turn-state-missing";
|
|
566
|
+
if (binding === null || binding === undefined)
|
|
567
|
+
return null;
|
|
568
|
+
if (["submitting", "accepted", "running", "delivery-unknown"].includes(binding.turn?.status ?? ""))
|
|
569
|
+
return "provider-turn-unsettled";
|
|
570
|
+
if (binding.authority.owner === "human" || binding.authority.owner === "unknown") {
|
|
571
|
+
return "provider-writer-authority-unavailable";
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
509
575
|
function matchesRecoverySessionFence(store, input) {
|
|
510
576
|
const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
|
|
511
577
|
// Recovery must never proceed without a durable Session fence. A missing
|
|
@@ -520,8 +586,7 @@ function matchesRecoverySessionFence(store, input) {
|
|
|
520
586
|
return false;
|
|
521
587
|
if (session.agentId !== input.agentId || session.adapterId !== input.adapterId)
|
|
522
588
|
return false;
|
|
523
|
-
//
|
|
524
|
-
// needed. Preserve its exact identity as the CAS fence; only same-Session
|
|
589
|
+
// Preserve a dead Session's exact identity as the CAS fence; only same-Session
|
|
525
590
|
// retry is invalid once the native process is stopped or broken.
|
|
526
591
|
if ((session.status === "stopped" || session.status === "broken")
|
|
527
592
|
&& input.action === "retry")
|
|
@@ -363,6 +363,7 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
|
|
|
363
363
|
let stopped = 0;
|
|
364
364
|
let other = 0;
|
|
365
365
|
let resets = 0;
|
|
366
|
+
let conversationSwitches = 0;
|
|
366
367
|
let lifecycleEvents = 0;
|
|
367
368
|
let stopFailures = 0;
|
|
368
369
|
const terminalByRunRelation = {
|
|
@@ -393,6 +394,9 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
|
|
|
393
394
|
continue;
|
|
394
395
|
if (event.type === "runtime.role-session-reset")
|
|
395
396
|
resets += 1;
|
|
397
|
+
else if (event.type === "runtime.conversation-switch-resolved"
|
|
398
|
+
&& event.payload.status === "applied")
|
|
399
|
+
conversationSwitches += 1;
|
|
396
400
|
else if (runtimeObservationFromTaskEvent(event)?.kind.startsWith("session.")) {
|
|
397
401
|
lifecycleEvents += 1;
|
|
398
402
|
}
|
|
@@ -406,6 +410,7 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
|
|
|
406
410
|
stopped,
|
|
407
411
|
other,
|
|
408
412
|
resets,
|
|
413
|
+
conversationSwitches,
|
|
409
414
|
lifecycleEvents,
|
|
410
415
|
stopFailures,
|
|
411
416
|
terminalByRunRelation
|
|
@@ -267,6 +267,26 @@ export function isHandoverLockHeld(home) {
|
|
|
267
267
|
return !isEnoent(error);
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* True when a live handover is owned by a process other than `allowedOwnerPid`.
|
|
272
|
+
*
|
|
273
|
+
* Foreground maintenance commands acquire the lock and still need to call the
|
|
274
|
+
* Controller they are draining, while every unrelated CLI/managed Session must
|
|
275
|
+
* wait rather than starting a second Controller during the replacement window.
|
|
276
|
+
* An unreadable lock remains a foreign live lock so callers fail closed.
|
|
277
|
+
*/
|
|
278
|
+
export function isForeignHandoverLockHeld(home, allowedOwnerPid = process.pid) {
|
|
279
|
+
const lockPath = join(resolve(home), "runtime", "handover.lock");
|
|
280
|
+
try {
|
|
281
|
+
const owner = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
282
|
+
if (!isHandoverLockLive(owner))
|
|
283
|
+
return false;
|
|
284
|
+
return owner.pid !== allowedOwnerPid;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
return !isEnoent(error);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
270
290
|
export function acquireHandoverLock(home) {
|
|
271
291
|
const lockPath = join(resolve(home), "runtime", "handover.lock");
|
|
272
292
|
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { latestRunDurableProgressAt } from "../scheduler/roleRunStall.js";
|
|
3
3
|
import { actionableExecutionLaneRecoveries } from "../execution/executionHealth.js";
|
|
4
|
+
import { runOwnsBlockingProviderContinuation } from "../runtime/runtimeContinuationProjection.js";
|
|
5
|
+
import { runHasActiveRuntimeOperations } from "../runtime/runtimeObservation.js";
|
|
4
6
|
export const RUN_RECOVERY_ACTIONS = [
|
|
5
7
|
"diagnose",
|
|
6
8
|
"retry",
|
|
7
|
-
"replace-session",
|
|
8
9
|
"terminate"
|
|
9
10
|
];
|
|
10
11
|
/**
|
|
@@ -17,13 +18,32 @@ export function readRunRecoveryFacts(store, taskId, runId) {
|
|
|
17
18
|
return null;
|
|
18
19
|
const task = store.getTask(taskId);
|
|
19
20
|
const sessionSet = store.getTaskRoleSessionSet(taskId, run.roleName);
|
|
21
|
+
const events = store.listEvents(taskId);
|
|
22
|
+
const roleMailbox = store.getWorkMailbox?.({
|
|
23
|
+
kind: "role",
|
|
24
|
+
taskId,
|
|
25
|
+
roleName: run.roleName
|
|
26
|
+
}) ?? null;
|
|
20
27
|
const progress = latestRunDurableProgressAt(store, taskId, run.roleName, runId);
|
|
21
28
|
return {
|
|
22
29
|
run,
|
|
23
30
|
task: task === null ? null : { id: task.id, status: task.status },
|
|
24
31
|
sessionSet,
|
|
32
|
+
inputDeliveryUnsettled: roleMailbox?.inputDelivery != null,
|
|
33
|
+
blockingProviderContinuation: runOwnsBlockingProviderContinuation(events, {
|
|
34
|
+
taskId,
|
|
35
|
+
roleName: run.roleName,
|
|
36
|
+
runId: run.id,
|
|
37
|
+
agentId: run.effective.agentId
|
|
38
|
+
}),
|
|
39
|
+
activeRuntimeOperation: runHasActiveRuntimeOperations(events, {
|
|
40
|
+
taskId,
|
|
41
|
+
roleName: run.roleName,
|
|
42
|
+
runId: run.id,
|
|
43
|
+
agentId: run.effective.agentId
|
|
44
|
+
}),
|
|
25
45
|
progress,
|
|
26
|
-
latestProviderObservation: latestRunProviderObservation(
|
|
46
|
+
latestProviderObservation: latestRunProviderObservation(events, runId)
|
|
27
47
|
};
|
|
28
48
|
}
|
|
29
49
|
/**
|
|
@@ -72,9 +92,12 @@ export function projectRunRecovery(facts) {
|
|
|
72
92
|
? ["accepted", "ambiguous"]
|
|
73
93
|
: ["rejected", "ambiguous"];
|
|
74
94
|
const blocked = recoveryBlocker(facts, session, canonicalProgressAt);
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
const sessionTerminal = session?.status === "stopped" || session?.status === "broken";
|
|
96
|
+
const providerTerminal = sessionSet?.providerBinding?.turn?.status === "failed"
|
|
97
|
+
|| sessionSet?.providerBinding?.turn?.status === "cancelled"
|
|
98
|
+
|| sessionSet?.providerBinding?.turn?.status === "rejected";
|
|
99
|
+
const supportedActions = RUN_RECOVERY_ACTIONS.filter((action) => ((action !== "retry" || !sessionTerminal)
|
|
100
|
+
&& (action !== "terminate" || sessionTerminal || providerTerminal)));
|
|
78
101
|
const actions = blocked === null
|
|
79
102
|
? supportedActions.map((action) => buildActionPlan(facts, action, session, canonicalProgressAt))
|
|
80
103
|
: [];
|
|
@@ -139,6 +162,23 @@ function recoveryBlocker(facts, session, canonicalProgressAt) {
|
|
|
139
162
|
return "progress-unavailable";
|
|
140
163
|
if (session === null)
|
|
141
164
|
return "session-missing";
|
|
165
|
+
if (facts.inputDeliveryUnsettled)
|
|
166
|
+
return "provider-input-delivery-unsettled";
|
|
167
|
+
if (facts.blockingProviderContinuation)
|
|
168
|
+
return "provider-continuation-writer-owned";
|
|
169
|
+
if (facts.activeRuntimeOperation)
|
|
170
|
+
return "provider-operation-active";
|
|
171
|
+
const binding = facts.sessionSet?.providerBinding;
|
|
172
|
+
if (run.deliveredAt !== undefined
|
|
173
|
+
&& (binding === null || binding?.turn === null))
|
|
174
|
+
return "provider-turn-state-missing";
|
|
175
|
+
if (binding !== null && binding !== undefined) {
|
|
176
|
+
if (["submitting", "accepted", "running", "delivery-unknown"].includes(binding.turn?.status ?? ""))
|
|
177
|
+
return "provider-turn-unsettled";
|
|
178
|
+
if (binding.authority.owner === "human" || binding.authority.owner === "unknown") {
|
|
179
|
+
return "provider-writer-authority-unavailable";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
142
182
|
return null;
|
|
143
183
|
}
|
|
144
184
|
function buildActionPlan(facts, action, session, canonicalProgressAt) {
|
|
@@ -208,6 +248,5 @@ function actionAcceptance(facts, action) {
|
|
|
208
248
|
const ACTION_REASONS = {
|
|
209
249
|
diagnose: "Collect bounded diagnostics before any state-changing recovery.",
|
|
210
250
|
retry: "Request another provider turn on the same native Session when the failure is transient.",
|
|
211
|
-
"replace-session": "Request a fresh native Session when the current one is unusable.",
|
|
212
251
|
terminate: "Fail the Run explicitly when recovery is not viable."
|
|
213
252
|
};
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { readdir, readFile, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { chmodSync, mkdirSync, rmSync } from "node:fs";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
5
4
|
import { dirname, join, resolve } from "node:path";
|
|
6
5
|
import { createConnection, createServer } from "node:net";
|
|
7
6
|
import { createInterface } from "node:readline";
|
|
8
|
-
import { callController, ControllerClientError } from "../core/controllerClient.js";
|
|
7
|
+
import { callController, controllerCallMayHaveApplied, ControllerClientError } from "../core/controllerClient.js";
|
|
9
8
|
import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
|
|
9
|
+
import { callFileTaskController } from "../controller/clientRuntime.js";
|
|
10
|
+
import { isForeignHandoverLockHeld } from "../release/runtimeRelease.js";
|
|
10
11
|
import { publishStructuredProviderAccepted, publishStructuredProviderActivationTerminal, publishStructuredConversationRecoverability, publishStructuredProviderOpened, publishStructuredProviderTerminal } from "../controller/structuredProviderObservation.js";
|
|
11
12
|
import { validateAgentHostLaunchPayload } from "./launchBroker.js";
|
|
12
13
|
import { ProviderDeliveryUnknownError, ProviderConversationMissingError, ProviderTurnRejectedError, startStructuredProviderSession } from "./structuredProviderHost.js";
|
|
13
14
|
import { sameProviderAuthorityFence, validateProviderAuthorityFence } from "./providerAuthorityFence.js";
|
|
14
15
|
import { validateRuntimeProcessExitObservation } from "./processExitObservation.js";
|
|
15
|
-
import {
|
|
16
|
+
import { persistRuntimeProcessExitObservation, replayRuntimeProcessExitOutbox } from "./processExitOutbox.js";
|
|
17
|
+
import { readRuntimeStopReceipt, removeRuntimeStopReceipt, writeRuntimeStopReceipt } from "./runtimeStopReceipt.js";
|
|
16
18
|
import { AGENT_HOST_CONTROL_TIMEOUT_MS, AGENT_HOST_READY_TIMEOUT_MS } from "./runtimeDeadlines.js";
|
|
17
19
|
export const AGENT_HOST_CONTROL_PROTOCOL = "yui-agent-host/v2";
|
|
18
20
|
const HOST_CONTROL_MAX_BYTES = 32 * 1024;
|
|
@@ -26,6 +28,7 @@ export async function runAgentHost(input) {
|
|
|
26
28
|
let session;
|
|
27
29
|
let sessionPayload;
|
|
28
30
|
let activeTurnPayload;
|
|
31
|
+
const switchDetachedSessions = new WeakSet();
|
|
29
32
|
let activationId;
|
|
30
33
|
let conversationRecoverability = "unknown";
|
|
31
34
|
let authority;
|
|
@@ -116,19 +119,21 @@ export async function runAgentHost(input) {
|
|
|
116
119
|
const stopReceipt = readRuntimeStopReceipt(input.home, currentPayload.launchId);
|
|
117
120
|
const observedAt = new Date().toISOString();
|
|
118
121
|
const failures = [];
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
if (!switchDetachedSessions.has(providerSession)) {
|
|
123
|
+
try {
|
|
124
|
+
await publishStructuredProviderActivationTerminal({
|
|
125
|
+
home: input.home,
|
|
126
|
+
environment: currentPayload.environment,
|
|
127
|
+
conversationId: providerSession.conversationId,
|
|
128
|
+
nativeSessionId: providerSession.nativeSessionId,
|
|
129
|
+
activationId: currentActivationId,
|
|
130
|
+
status: stopReceipt !== null || hostStopRequested ? "ended" : "failed",
|
|
131
|
+
observedAt
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
failures.push(`activation terminal: ${errorText(error)}`);
|
|
136
|
+
}
|
|
132
137
|
}
|
|
133
138
|
let exitPersisted = false;
|
|
134
139
|
try {
|
|
@@ -163,7 +168,7 @@ export async function runAgentHost(input) {
|
|
|
163
168
|
if (stopReceipt !== null && exitPersisted) {
|
|
164
169
|
removeRuntimeStopReceipt(input.home, currentPayload.launchId);
|
|
165
170
|
}
|
|
166
|
-
if (hostStopRequested)
|
|
171
|
+
if (hostStopRequested || !ownsCurrentSession)
|
|
167
172
|
return;
|
|
168
173
|
updateSnapshot(hostSnapshot(failures.length === 0 ? "exited" : "failed", {
|
|
169
174
|
launchId: currentPayload.launchId,
|
|
@@ -196,9 +201,12 @@ export async function runAgentHost(input) {
|
|
|
196
201
|
throw new Error("Agent Host still owns an unsettled Provider Turn.");
|
|
197
202
|
}
|
|
198
203
|
const requestedAuthority = validateProviderAuthorityFence(providerControl.authority);
|
|
199
|
-
|
|
204
|
+
const replacesCurrentConversation = session !== undefined && providerControl.kind === "new";
|
|
205
|
+
if (!replacesCurrentConversation && authority === undefined)
|
|
200
206
|
authority = requestedAuthority;
|
|
201
|
-
else if (!
|
|
207
|
+
else if (!replacesCurrentConversation
|
|
208
|
+
&& authority !== undefined
|
|
209
|
+
&& !sameProviderAuthorityFence(authority, requestedAuthority)) {
|
|
202
210
|
throw new Error("Agent Host launch carries a stale Provider authority fence.");
|
|
203
211
|
}
|
|
204
212
|
updateSnapshot(hostSnapshot("starting", {
|
|
@@ -217,6 +225,43 @@ export async function runAgentHost(input) {
|
|
|
217
225
|
let providerAcceptedAttemptId;
|
|
218
226
|
let durableInitialTurn;
|
|
219
227
|
try {
|
|
228
|
+
if (replacesCurrentConversation) {
|
|
229
|
+
const previousSession = session;
|
|
230
|
+
const previousPayload = sessionPayload;
|
|
231
|
+
const previousActivationId = activationId ?? previousPayload?.launchId;
|
|
232
|
+
if (previousPayload === undefined || previousActivationId === undefined
|
|
233
|
+
|| authority?.owner !== "controller") {
|
|
234
|
+
throw new Error("Agent Host cannot detach an inexact Provider Activation.");
|
|
235
|
+
}
|
|
236
|
+
await detachDurableProviderForConversationSwitch(input.home, {
|
|
237
|
+
taskId: requiredEnvironment(next.environment.YUI_TASK_ID, "Task id"),
|
|
238
|
+
roleName: requiredEnvironment(next.environment.YUI_ROLE, "Role name"),
|
|
239
|
+
runId: requiredEnvironment(next.environment.YUI_RUN_ID, "Run id"),
|
|
240
|
+
agentId: requiredEnvironment(next.environment.YUI_AGENT_ID, "Agent id"),
|
|
241
|
+
launchId: next.launchId,
|
|
242
|
+
previousConversationId: previousSession.conversationId,
|
|
243
|
+
previousNativeSessionId: previousSession.nativeSessionId,
|
|
244
|
+
previousActivationId,
|
|
245
|
+
nextAuthorityEpoch: requestedAuthority.epoch,
|
|
246
|
+
nextAuthorityHolderId: requestedAuthority.holderId,
|
|
247
|
+
observedAt: new Date().toISOString()
|
|
248
|
+
});
|
|
249
|
+
switchDetachedSessions.add(previousSession);
|
|
250
|
+
// The persistent Provider child belongs to the Activation that first
|
|
251
|
+
// created it, not to the most recent resume Run payload. The exit
|
|
252
|
+
// observer carries that Activation launch id, so the stop receipt must
|
|
253
|
+
// use the same identity or the expected switch detach is misclassified
|
|
254
|
+
// as an abnormal child exit and generic cleanup can kill the Host.
|
|
255
|
+
writeRuntimeStopReceipt(input.home, previousActivationId, new Date());
|
|
256
|
+
authority = undefined;
|
|
257
|
+
await terminateProviderSessionForConversationSwitch(previousSession);
|
|
258
|
+
if (session === previousSession)
|
|
259
|
+
session = undefined;
|
|
260
|
+
sessionPayload = undefined;
|
|
261
|
+
activationId = undefined;
|
|
262
|
+
conversationRecoverability = "unknown";
|
|
263
|
+
authority = requestedAuthority;
|
|
264
|
+
}
|
|
220
265
|
if (session !== undefined) {
|
|
221
266
|
if (session.adapterId !== providerControl.adapterId
|
|
222
267
|
|| providerControl.mode !== "resume"
|
|
@@ -493,30 +538,12 @@ export async function runAgentHost(input) {
|
|
|
493
538
|
}
|
|
494
539
|
};
|
|
495
540
|
const durableTurn = hostTurnControlParams(currentPayload, currentSession.nativeSessionId, currentAuthority, attemptId);
|
|
496
|
-
|
|
497
|
-
await beginDurableProviderTurn(input.home, durableTurn);
|
|
498
|
-
}
|
|
499
|
-
catch (error) {
|
|
500
|
-
await callController(input.home, "runtime.provider-turn-submission-resolve", {
|
|
501
|
-
...durableTurn,
|
|
502
|
-
status: "rejected",
|
|
503
|
-
reason: `Human Turn intent acknowledgement failed before Provider write: ${errorText(error)}`,
|
|
504
|
-
observedAt: new Date().toISOString()
|
|
505
|
-
}).catch(() => { });
|
|
506
|
-
throw error;
|
|
507
|
-
}
|
|
541
|
+
await beginDurableProviderTurn(input.home, durableTurn);
|
|
508
542
|
try {
|
|
509
543
|
await submitTurn(turnControl);
|
|
510
544
|
}
|
|
511
545
|
catch (error) {
|
|
512
|
-
await
|
|
513
|
-
...durableTurn,
|
|
514
|
-
status: error instanceof ProviderDeliveryUnknownError
|
|
515
|
-
? "delivery-unknown"
|
|
516
|
-
: "rejected",
|
|
517
|
-
reason: errorText(error),
|
|
518
|
-
observedAt: new Date().toISOString()
|
|
519
|
-
}).catch(() => { });
|
|
546
|
+
await resolveProviderTurnSubmission(input.home, durableTurn, error);
|
|
520
547
|
throw error;
|
|
521
548
|
}
|
|
522
549
|
process.stdout.write("Provider accepted the human Turn; waiting for its terminal boundary.\n");
|
|
@@ -670,50 +697,19 @@ async function redeem(home, launchId, ticket) {
|
|
|
670
697
|
return validateAgentHostLaunchPayload(result);
|
|
671
698
|
}
|
|
672
699
|
async function persistAndSubmitExit(home, observation) {
|
|
673
|
-
const directory = resolve(join(home, "runtime", "agent-host-outbox"));
|
|
674
|
-
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
675
|
-
const path = join(directory, `${observation.hostInstanceId}.jsonl`);
|
|
676
|
-
const descriptor = openSync(path, "a", 0o600);
|
|
677
700
|
try {
|
|
678
|
-
|
|
679
|
-
fsyncSync(descriptor);
|
|
680
|
-
}
|
|
681
|
-
finally {
|
|
682
|
-
closeSync(descriptor);
|
|
683
|
-
}
|
|
684
|
-
chmodSync(path, 0o600);
|
|
685
|
-
await callController(home, "runtime.process-exit-observe", observation);
|
|
686
|
-
rmSync(path, { force: true });
|
|
687
|
-
}
|
|
688
|
-
async function replayExitOutbox(home) {
|
|
689
|
-
const directory = resolve(join(home, "runtime", "agent-host-outbox"));
|
|
690
|
-
let entries;
|
|
691
|
-
try {
|
|
692
|
-
entries = await readdir(directory);
|
|
701
|
+
await persistRuntimeProcessExitObservation(home, observation, (value) => callController(home, "runtime.process-exit-observe", value).then(() => undefined));
|
|
693
702
|
}
|
|
694
703
|
catch (error) {
|
|
695
|
-
|
|
704
|
+
// The durable outbox is the acknowledgement during a planned Controller
|
|
705
|
+
// gap. The replacement Controller drains it before accepting new work.
|
|
706
|
+
if (isForeignHandoverLockHeld(home))
|
|
696
707
|
return;
|
|
697
708
|
throw error;
|
|
698
709
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
try {
|
|
703
|
-
await rename(path, claimed);
|
|
704
|
-
}
|
|
705
|
-
catch (error) {
|
|
706
|
-
if (error.code === "ENOENT")
|
|
707
|
-
continue;
|
|
708
|
-
throw error;
|
|
709
|
-
}
|
|
710
|
-
const lines = (await readFile(claimed, "utf8")).split("\n").filter(Boolean);
|
|
711
|
-
for (const line of lines) {
|
|
712
|
-
const observation = validateRuntimeProcessExitObservation(JSON.parse(line));
|
|
713
|
-
await callController(home, "runtime.process-exit-observe", observation);
|
|
714
|
-
}
|
|
715
|
-
await unlink(claimed);
|
|
716
|
-
}
|
|
710
|
+
}
|
|
711
|
+
async function replayExitOutbox(home) {
|
|
712
|
+
await replayRuntimeProcessExitOutbox(home, (observation) => callController(home, "runtime.process-exit-observe", observation).then(() => undefined));
|
|
717
713
|
}
|
|
718
714
|
/** Internal socket boundary exported for transport-level verification. */
|
|
719
715
|
export async function openAgentHostControl(home, payload, snapshot, dispatch) {
|
|
@@ -906,26 +902,104 @@ function hostTurnControlParams(payload, nativeSessionId, authority, attemptId) {
|
|
|
906
902
|
}
|
|
907
903
|
async function beginDurableProviderTurn(home, durableTurn) {
|
|
908
904
|
try {
|
|
909
|
-
await
|
|
905
|
+
await callControllerIdempotently(home, "runtime.provider-turn-begin", durableTurn);
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
if (!(error instanceof ControllerAcknowledgementUnknownError))
|
|
909
|
+
throw error;
|
|
910
|
+
await resolveProviderTurnSubmission(home, durableTurn, new Error(`Provider Turn intent acknowledgement failed before Provider write: ${errorText(error)}`));
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
async function detachDurableProviderForConversationSwitch(home, request) {
|
|
915
|
+
await callControllerIdempotently(home, "runtime.conversation-switch-detach", request);
|
|
916
|
+
}
|
|
917
|
+
async function callControllerIdempotently(home, method, request) {
|
|
918
|
+
try {
|
|
919
|
+
await callAgentController(home, method, request);
|
|
910
920
|
}
|
|
911
921
|
catch (error) {
|
|
912
|
-
if (!(error instanceof ControllerClientError)
|
|
922
|
+
if (!(error instanceof ControllerClientError)
|
|
923
|
+
|| (error.code !== "INTERNAL_ERROR" && !controllerCallMayHaveApplied(error))) {
|
|
913
924
|
throw error;
|
|
914
925
|
}
|
|
915
|
-
|
|
916
|
-
//
|
|
917
|
-
|
|
926
|
+
const firstCallMayHaveApplied = controllerCallMayHaveApplied(error);
|
|
927
|
+
// These methods carry exact attempt, launch, and authority fences. A
|
|
928
|
+
// bounded replay confirms a commit whose acknowledgement may have been lost.
|
|
929
|
+
try {
|
|
930
|
+
await callAgentController(home, method, request);
|
|
931
|
+
}
|
|
932
|
+
catch (replayError) {
|
|
933
|
+
if (firstCallMayHaveApplied || controllerCallMayHaveApplied(replayError)) {
|
|
934
|
+
throw new ControllerAcknowledgementUnknownError(`${method} may have been committed, but its acknowledgement could not be confirmed: ${errorText(replayError)}`);
|
|
935
|
+
}
|
|
936
|
+
throw replayError;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
class ControllerAcknowledgementUnknownError extends Error {
|
|
941
|
+
name = "ControllerAcknowledgementUnknownError";
|
|
942
|
+
}
|
|
943
|
+
async function terminateProviderSessionForConversationSwitch(providerSession) {
|
|
944
|
+
providerSession.terminate("SIGTERM");
|
|
945
|
+
const forceKill = setTimeout(() => providerSession.terminate("SIGKILL"), 3_000);
|
|
946
|
+
forceKill.unref();
|
|
947
|
+
let hardTimeout;
|
|
948
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
949
|
+
hardTimeout = setTimeout(() => reject(new Error("Old Provider Activation did not exit after its switch authority was revoked.")), 8_000);
|
|
950
|
+
hardTimeout.unref();
|
|
951
|
+
});
|
|
952
|
+
try {
|
|
953
|
+
await Promise.race([providerSession.waitForExit(), timeout]);
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
clearTimeout(forceKill);
|
|
957
|
+
if (hardTimeout !== undefined)
|
|
958
|
+
clearTimeout(hardTimeout);
|
|
918
959
|
}
|
|
919
960
|
}
|
|
920
961
|
async function resolveProviderTurnSubmission(home, durableTurn, error) {
|
|
921
|
-
|
|
962
|
+
const attemptId = durableTurn.attemptId;
|
|
963
|
+
if (typeof attemptId !== "string") {
|
|
964
|
+
throw new Error("Provider Turn resolution has no attempt id.");
|
|
965
|
+
}
|
|
966
|
+
const request = {
|
|
922
967
|
...durableTurn,
|
|
923
968
|
status: error instanceof ProviderDeliveryUnknownError
|
|
924
969
|
? "delivery-unknown"
|
|
925
970
|
: "rejected",
|
|
926
971
|
reason: errorText(error),
|
|
927
972
|
observedAt: new Date().toISOString()
|
|
928
|
-
}
|
|
973
|
+
};
|
|
974
|
+
try {
|
|
975
|
+
await callControllerIdempotently(home, "runtime.provider-turn-submission-resolve", request);
|
|
976
|
+
}
|
|
977
|
+
catch (resolutionError) {
|
|
978
|
+
throw new ProviderDeliveryUnknownError(`Provider submission outcome could not be durably resolved: ${errorText(resolutionError)}. Original outcome: ${errorText(error)}`, attemptId);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Existing in-flight launches keep using the old Controller while it drains.
|
|
983
|
+
* If the socket is already gone under an explicit handover fence, wait for
|
|
984
|
+
* the replacement and retry the domain-idempotent Agent Host operation.
|
|
985
|
+
*/
|
|
986
|
+
async function callAgentController(home, method, params) {
|
|
987
|
+
try {
|
|
988
|
+
await callController(home, method, params);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
catch (error) {
|
|
992
|
+
if (!isControllerUnavailable(error) || !isForeignHandoverLockHeld(home)) {
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
await callFileTaskController(home, method, params);
|
|
997
|
+
}
|
|
998
|
+
function isControllerUnavailable(error) {
|
|
999
|
+
return error instanceof ControllerClientError
|
|
1000
|
+
&& (error.code === "CONTROLLER_NOT_RUNNING"
|
|
1001
|
+
|| error.code === "CONTROLLER_UNAVAILABLE"
|
|
1002
|
+
|| error.code === "CONTROLLER_DRAINING");
|
|
929
1003
|
}
|
|
930
1004
|
function requiredEnvironment(value, label) {
|
|
931
1005
|
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {
|