@zq-silk/yui 0.15.3 → 0.15.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/managedRuntimeEnvironment.js +3 -0
- package/dist/cli.js +35 -127
- package/dist/commands/executionAuditCommands.js +6 -0
- package/dist/commands/taskContextCommand.js +4 -2
- package/dist/context/sessionBootstrapManifest.js +12 -21
- package/dist/controller/clientRuntime.js +1 -1
- package/dist/controller/fileSchedulerStoreAdapter.js +270 -162
- package/dist/controller/runtimeEventInbox.js +8 -0
- package/dist/controller/runtimeEventProcessor.js +31 -4
- package/dist/controller/runtimeHookTurnFence.js +101 -62
- package/dist/controller/runtimeLaunchCoordinator.js +38 -12
- package/dist/controller/runtimeObservationHook.js +17 -1
- package/dist/controller/structuredProviderObservation.js +39 -27
- package/dist/core/controllerClient.js +5 -0
- package/dist/core/controllerServer.js +7 -4
- package/dist/domain/agentResultTransport.js +2 -2
- package/dist/executor/agentExecutor.js +22 -38
- package/dist/executor/executorRegistry.js +16 -5
- package/dist/executor/fileRoleLaunchPlanner.js +22 -28
- package/dist/lifecycle/exactTurnTerminalization.js +3 -3
- package/dist/observability/executionAudit.js +12 -0
- package/dist/repository/executionLaneGitSnapshot.js +4 -3
- package/dist/repository/taskWorkspacePreparer.js +12 -4
- package/dist/review/taskFinalReviewContract.js +13 -32
- package/dist/runtime/agentError.js +299 -12
- package/dist/runtime/agentHost.js +419 -41
- package/dist/runtime/builtinAgentDrivers.js +5 -0
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/ports.js +16 -2
- package/dist/runtime/providerRuntimeIdentity.js +34 -28
- package/dist/runtime/runtimeCoherence.js +91 -0
- package/dist/runtime/runtimeObservation.js +8 -5
- package/dist/runtime/structuredProviderHost.js +53 -44
- package/dist/runtime/tmuxAdapters.js +72 -43
- package/dist/scheduler/activeRoleTurnDelivery.js +59 -11
- package/dist/scheduler/leaderWakeupProcessor.js +61 -7
- package/dist/storage/sqliteSchema.js +9 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/turn/turn.js +7 -1
- package/package.json +1 -1
- package/dist/runtime/exactControlPlane.js +0 -232
|
@@ -16,7 +16,7 @@ import { validateRuntimeProcessExitObservation } from "./processExitObservation.
|
|
|
16
16
|
import { persistRuntimeProcessExitObservation, replayRuntimeProcessExitOutbox } from "./processExitOutbox.js";
|
|
17
17
|
import { readRuntimeStopReceipt, removeRuntimeStopReceipt } from "./runtimeStopReceipt.js";
|
|
18
18
|
import { AGENT_HOST_CONTROL_TIMEOUT_MS, AGENT_HOST_READY_TIMEOUT_MS } from "./runtimeDeadlines.js";
|
|
19
|
-
import { serializeAgentErrorRaw } from "./agentError.js";
|
|
19
|
+
import { providerDeliveryFailure, providerDeliveryFailureFrom, redactAgentErrorText, serializeAgentErrorRaw } from "./agentError.js";
|
|
20
20
|
import { runCodexInteractiveHost } from "./codexInteractiveHost.js";
|
|
21
21
|
export const AGENT_HOST_CONTROL_PROTOCOL = "yui-agent-host/v4";
|
|
22
22
|
const HOST_CONTROL_MAX_BYTES = 32 * 1024;
|
|
@@ -48,7 +48,7 @@ export async function runAgentHost(input) {
|
|
|
48
48
|
let snapshot = hostSnapshot("idle");
|
|
49
49
|
let dispatchTail = Promise.resolve();
|
|
50
50
|
let promptHuman = () => { };
|
|
51
|
-
const recentSteerAttempts = new
|
|
51
|
+
const recentSteerAttempts = new Map();
|
|
52
52
|
await replayExitOutbox(input.home);
|
|
53
53
|
const updateSnapshot = (next) => {
|
|
54
54
|
snapshot = validateSnapshot(next);
|
|
@@ -114,11 +114,34 @@ export async function runAgentHost(input) {
|
|
|
114
114
|
return;
|
|
115
115
|
}
|
|
116
116
|
const terminalPayload = activeTurnPayload;
|
|
117
|
-
if (
|
|
117
|
+
if (terminal.attemptId !== undefined && terminal.attemptId !== activeTurnAttemptId
|
|
118
|
+
&& sessionPayload !== undefined && terminal.nativeSessionId === session?.nativeSessionId) {
|
|
119
|
+
// A duplicate/late terminal retains the Driver's exact old attempt.
|
|
120
|
+
// The durable observer validates that binding; it must not alter the
|
|
121
|
+
// current Host occupancy or inherit the successor's managed Turn.
|
|
122
|
+
const observedPayload = sessionPayload;
|
|
123
|
+
void enqueueSerialized(async () => {
|
|
124
|
+
await publishStructuredProviderTerminal({
|
|
125
|
+
home: input.home,
|
|
126
|
+
environment: observedPayload.environment,
|
|
127
|
+
activationId: activationId ?? observedPayload.runtimeGenerationId,
|
|
128
|
+
terminal
|
|
129
|
+
});
|
|
130
|
+
signalRoleMailbox(input.home, observedPayload);
|
|
131
|
+
}).catch(() => { });
|
|
118
132
|
return;
|
|
133
|
+
}
|
|
134
|
+
if (terminalPayload === undefined
|
|
135
|
+
|| terminal.attemptId !== activeTurnAttemptId
|
|
136
|
+
|| terminal.nativeSessionId !== session?.nativeSessionId
|
|
137
|
+
|| (terminal.nativeTurnId !== undefined && activeNativeTurnId !== undefined
|
|
138
|
+
&& terminal.nativeTurnId !== activeNativeTurnId))
|
|
139
|
+
return;
|
|
140
|
+
const terminalAttemptId = terminal.attemptId;
|
|
119
141
|
const terminalActivationId = activationId ?? terminalPayload.runtimeGenerationId;
|
|
120
142
|
void enqueueSerialized(async () => {
|
|
121
|
-
if (activeTurnPayload !== terminalPayload
|
|
143
|
+
if (activeTurnPayload !== terminalPayload
|
|
144
|
+
|| activeTurnAttemptId !== terminalAttemptId)
|
|
122
145
|
return;
|
|
123
146
|
if (session !== undefined) {
|
|
124
147
|
updateSnapshot(hostSnapshot("settling", {
|
|
@@ -127,6 +150,7 @@ export async function runAgentHost(input) {
|
|
|
127
150
|
processInstanceId: session.processInstanceId,
|
|
128
151
|
nativeSessionId: terminal.nativeSessionId,
|
|
129
152
|
conversationId: terminal.conversationId,
|
|
153
|
+
attemptId: terminalAttemptId,
|
|
130
154
|
nativeTurnId: terminal.nativeTurnId,
|
|
131
155
|
...authorityFields()
|
|
132
156
|
}));
|
|
@@ -569,21 +593,60 @@ export async function runAgentHost(input) {
|
|
|
569
593
|
return operation;
|
|
570
594
|
};
|
|
571
595
|
const enqueueDispatch = (next) => (enqueueSerialized(() => dispatch(next)));
|
|
572
|
-
const submitTurn = async (request) => {
|
|
596
|
+
const submitTurn = async (request, operation) => {
|
|
573
597
|
if (session === undefined || sessionPayload === undefined) {
|
|
574
598
|
throw new Error("Agent Host has no live Provider Conversation.");
|
|
575
599
|
}
|
|
576
600
|
if (request.nativeSessionId !== session.nativeSessionId) {
|
|
577
601
|
throw new Error("Agent Host Turn targets a different Provider Conversation.");
|
|
578
602
|
}
|
|
603
|
+
// A superseded generation is a real identity conflict, and the writer
|
|
604
|
+
// fence does not catch it: a Turn can carry an old generation under a
|
|
605
|
+
// fence that is still current. Without this the Host registered the stale
|
|
606
|
+
// Turn, wrote it to the Provider, and adopted the old generation into its
|
|
607
|
+
// own snapshot. Same generation is contention, which is decided below.
|
|
608
|
+
if (request.runtimeGenerationId !== sessionPayload.runtimeGenerationId) {
|
|
609
|
+
operation.failure = providerDeliveryFailure({
|
|
610
|
+
detail: "Agent Host Turn targets a superseded runtime generation.",
|
|
611
|
+
errorName: "ProviderGenerationConflictError",
|
|
612
|
+
phase: "turn-submit",
|
|
613
|
+
hostState: snapshot.state,
|
|
614
|
+
expectedRuntimeGenerationId: sessionPayload.runtimeGenerationId,
|
|
615
|
+
observedRuntimeGenerationId: request.runtimeGenerationId,
|
|
616
|
+
attemptId: request.turn.attemptId,
|
|
617
|
+
inputDisposition: "not-accepted",
|
|
618
|
+
registrationDisposition: "not-committed",
|
|
619
|
+
// A stale writer does not make the live Session unusable.
|
|
620
|
+
sessionDisposition: "recoverable"
|
|
621
|
+
});
|
|
622
|
+
throw new Error("Agent Host Turn targets a superseded runtime generation: expected "
|
|
623
|
+
+ `${sessionPayload.runtimeGenerationId}, observed ${request.runtimeGenerationId}.`);
|
|
624
|
+
}
|
|
579
625
|
if (authority === undefined
|
|
580
626
|
|| !sameProviderAuthorityFence(authority, request.authority)) {
|
|
581
627
|
throw new Error("Agent Host rejected a stale Provider writer fence.");
|
|
582
628
|
}
|
|
583
629
|
if (activeTurnPayload !== undefined || snapshot.state === "settling") {
|
|
630
|
+
operation.failure = providerDeliveryFailure({
|
|
631
|
+
detail: "Agent Host still owns an unsettled Provider Turn.",
|
|
632
|
+
errorName: "ProviderTurnBusyError",
|
|
633
|
+
phase: "turn-submit",
|
|
634
|
+
hostState: snapshot.state === "settling" ? "settling" : snapshot.state,
|
|
635
|
+
expectedRuntimeGenerationId: sessionPayload.runtimeGenerationId,
|
|
636
|
+
observedRuntimeGenerationId: request.runtimeGenerationId,
|
|
637
|
+
attemptId: request.turn.attemptId,
|
|
638
|
+
inputDisposition: "not-accepted",
|
|
639
|
+
registrationDisposition: "not-committed",
|
|
640
|
+
// Busy is a healthy Session doing work, never a reason to stop it.
|
|
641
|
+
sessionDisposition: "recoverable"
|
|
642
|
+
});
|
|
584
643
|
throw new ProviderTurnBusyError("Agent Host still owns an unsettled Provider Turn.", request.turn.attemptId, activeNativeTurnId);
|
|
585
644
|
}
|
|
586
645
|
const { YUI_TURN_ID: _launchTurnId, ...baseEnvironment } = sessionPayload.environment;
|
|
646
|
+
// Steer attempts belong to the active native Turn. Never evict an unknown
|
|
647
|
+
// attempt while that Turn remains active, but don't retain another Turn's
|
|
648
|
+
// completed steering history after a new exact submission begins.
|
|
649
|
+
recentSteerAttempts.clear();
|
|
587
650
|
activeTurnPayload = {
|
|
588
651
|
...sessionPayload,
|
|
589
652
|
environment: {
|
|
@@ -603,11 +666,70 @@ export async function runAgentHost(input) {
|
|
|
603
666
|
...authorityFields()
|
|
604
667
|
}));
|
|
605
668
|
const durableTurn = hostTurnControlParams(sessionPayload, session.nativeSessionId, request.authority, request.turn.attemptId, request.turnId);
|
|
606
|
-
|
|
669
|
+
// Registration precedes the Provider write, so its failure modes are not
|
|
670
|
+
// the Provider's. A definite failure means the Provider never saw this
|
|
671
|
+
// input and the occupancy must be released; anything unconfirmed leaves
|
|
672
|
+
// durable state ambiguous and keeps it held.
|
|
673
|
+
try {
|
|
674
|
+
await beginDurableProviderTurn(input.home, durableTurn);
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
// Registration ends in one of three states and they are not
|
|
678
|
+
// interchangeable. A definite refusal committed nothing. A lost
|
|
679
|
+
// acknowledgement whose compensating resolve also failed is genuinely
|
|
680
|
+
// unknown, and releasing that occupancy would discard a durable record
|
|
681
|
+
// that may exist. A lost acknowledgement whose resolve succeeded is
|
|
682
|
+
// neither: the resolve is fenced on this exact attempt id and could not
|
|
683
|
+
// have succeeded unless the registration committed, so that attempt is
|
|
684
|
+
// known committed and already settled.
|
|
685
|
+
const registrationSettled = error instanceof ProviderTurnRegistrationSettledError;
|
|
686
|
+
const registrationUnknown = !registrationSettled
|
|
687
|
+
&& (error instanceof ControllerAcknowledgementUnknownError
|
|
688
|
+
|| error instanceof ProviderDeliveryUnknownError);
|
|
689
|
+
updateSnapshot(hostSnapshot(registrationUnknown ? "delivery-unknown" : "failed", {
|
|
690
|
+
runtimeGenerationId: request.runtimeGenerationId,
|
|
691
|
+
adapterId: session.adapterId,
|
|
692
|
+
processInstanceId: session.processInstanceId,
|
|
693
|
+
nativeSessionId: session.nativeSessionId,
|
|
694
|
+
conversationId: session.conversationId,
|
|
695
|
+
attemptId: request.turn.attemptId,
|
|
696
|
+
...authorityFields(),
|
|
697
|
+
detail: registrationUnknown
|
|
698
|
+
? `Provider Turn registration acknowledgement is unconfirmed; the Provider was not sent this input: ${errorText(error)}`
|
|
699
|
+
: registrationSettled
|
|
700
|
+
? `Provider Turn registration was settled before any Provider write: ${errorText(error)}`
|
|
701
|
+
: `Provider Turn registration failed before any Provider write: ${errorText(error)}`
|
|
702
|
+
}));
|
|
703
|
+
// The Provider never saw the input in any branch. Only the durable
|
|
704
|
+
// registration is in question, so that is the fact that differs.
|
|
705
|
+
operation.failure = providerDeliveryFailureFrom(error, {
|
|
706
|
+
phase: "turn-submit",
|
|
707
|
+
hostState: snapshot.state,
|
|
708
|
+
expectedRuntimeGenerationId: sessionPayload.runtimeGenerationId,
|
|
709
|
+
observedRuntimeGenerationId: request.runtimeGenerationId,
|
|
710
|
+
attemptId: request.turn.attemptId,
|
|
711
|
+
inputDisposition: "not-accepted",
|
|
712
|
+
registrationDisposition: registrationUnknown
|
|
713
|
+
? "unknown"
|
|
714
|
+
: registrationSettled ? "committed" : "not-committed",
|
|
715
|
+
sessionDisposition: "recoverable"
|
|
716
|
+
});
|
|
717
|
+
if (!registrationUnknown) {
|
|
718
|
+
// The Provider provably holds nothing, and the durable record either
|
|
719
|
+
// never existed or is settled. Release the Session for the next
|
|
720
|
+
// attempt instead of stranding it in a false `starting`.
|
|
721
|
+
activeTurnPayload = undefined;
|
|
722
|
+
activeTurnAttemptId = undefined;
|
|
723
|
+
activeNativeTurnId = undefined;
|
|
724
|
+
}
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
607
727
|
let providerAccepted = false;
|
|
728
|
+
let transportAccepted = false;
|
|
608
729
|
try {
|
|
609
730
|
const receipt = await session.submitTurn(request.turn);
|
|
610
|
-
|
|
731
|
+
transportAccepted = true;
|
|
732
|
+
providerAccepted = receipt.acceptance === "provider";
|
|
611
733
|
activeNativeTurnId = receipt.nativeTurnId;
|
|
612
734
|
try {
|
|
613
735
|
await publishStructuredProviderAccepted({
|
|
@@ -618,7 +740,10 @@ export async function runAgentHost(input) {
|
|
|
618
740
|
});
|
|
619
741
|
}
|
|
620
742
|
catch (error) {
|
|
621
|
-
const unknown = new ProviderDeliveryUnknownError(
|
|
743
|
+
const unknown = new ProviderDeliveryUnknownError("Input submission returned a receipt but its durable acknowledgement could not be confirmed.", request.turn.attemptId, { cause: error });
|
|
744
|
+
// A resolve failure here must not escape before the snapshot is
|
|
745
|
+
// written: the Provider has already accepted, and losing that fact
|
|
746
|
+
// would report a definite non-acceptance for a live Turn.
|
|
622
747
|
await resolveProviderTurnSubmission(input.home, durableTurn, unknown);
|
|
623
748
|
throw unknown;
|
|
624
749
|
}
|
|
@@ -635,10 +760,19 @@ export async function runAgentHost(input) {
|
|
|
635
760
|
return snapshot;
|
|
636
761
|
}
|
|
637
762
|
catch (error) {
|
|
638
|
-
|
|
639
|
-
|
|
763
|
+
let failureError = error;
|
|
764
|
+
let settlementUnknown = false;
|
|
765
|
+
if (!transportAccepted) {
|
|
766
|
+
try {
|
|
767
|
+
await resolveProviderTurnSubmission(input.home, durableTurn, error);
|
|
768
|
+
}
|
|
769
|
+
catch (resolutionError) {
|
|
770
|
+
settlementUnknown = true;
|
|
771
|
+
failureError = resolutionError;
|
|
772
|
+
}
|
|
640
773
|
}
|
|
641
|
-
const
|
|
774
|
+
const inputUnknown = error instanceof ProviderDeliveryUnknownError;
|
|
775
|
+
const deliveryUnknown = inputUnknown || transportAccepted || settlementUnknown;
|
|
642
776
|
const state = deliveryUnknown
|
|
643
777
|
? "delivery-unknown"
|
|
644
778
|
: error instanceof ProviderTurnBusyError
|
|
@@ -652,39 +786,113 @@ export async function runAgentHost(input) {
|
|
|
652
786
|
conversationId: session.conversationId,
|
|
653
787
|
attemptId: request.turn.attemptId,
|
|
654
788
|
...authorityFields(),
|
|
655
|
-
detail: errorText(
|
|
789
|
+
detail: errorText(failureError)
|
|
656
790
|
}));
|
|
791
|
+
operation.failure = providerDeliveryFailureFrom(failureError, {
|
|
792
|
+
phase: "turn-submit",
|
|
793
|
+
hostState: state,
|
|
794
|
+
expectedRuntimeGenerationId: sessionPayload.runtimeGenerationId,
|
|
795
|
+
observedRuntimeGenerationId: request.runtimeGenerationId,
|
|
796
|
+
attemptId: request.turn.attemptId,
|
|
797
|
+
// Acceptance is observed, never inferred from the error class.
|
|
798
|
+
inputDisposition: providerAccepted
|
|
799
|
+
? "accepted"
|
|
800
|
+
: inputUnknown || transportAccepted ? "unknown" : "not-accepted",
|
|
801
|
+
registrationDisposition: "committed",
|
|
802
|
+
sessionDisposition: state === "failed" ? "unknown" : "recoverable"
|
|
803
|
+
});
|
|
657
804
|
if (state !== "delivery-unknown") {
|
|
658
805
|
activeTurnPayload = undefined;
|
|
659
806
|
activeTurnAttemptId = undefined;
|
|
660
807
|
activeNativeTurnId = undefined;
|
|
661
808
|
}
|
|
662
|
-
if (deliveryUnknown && !(
|
|
663
|
-
throw new ProviderDeliveryUnknownError(
|
|
809
|
+
if (deliveryUnknown && !(failureError instanceof ProviderDeliveryUnknownError)) {
|
|
810
|
+
throw new ProviderDeliveryUnknownError("Provider accepted input but its durable acknowledgement could not be confirmed.", request.turn.attemptId, { cause: failureError });
|
|
664
811
|
}
|
|
665
|
-
throw
|
|
812
|
+
throw failureError;
|
|
666
813
|
}
|
|
667
814
|
};
|
|
668
|
-
const steerTurn = async (request) => {
|
|
815
|
+
const steerTurn = async (request, operation) => {
|
|
669
816
|
if (session === undefined || sessionPayload === undefined || activeTurnPayload === undefined) {
|
|
817
|
+
operation.failure = providerDeliveryFailure({
|
|
818
|
+
detail: "Agent Host has no active Provider Turn to steer.",
|
|
819
|
+
errorName: "ProviderTurnRejectedError",
|
|
820
|
+
phase: "turn-submit",
|
|
821
|
+
hostState: snapshot.state,
|
|
822
|
+
expectedRuntimeGenerationId: sessionPayload?.runtimeGenerationId
|
|
823
|
+
?? snapshot.runtimeGenerationId ?? "none",
|
|
824
|
+
observedRuntimeGenerationId: request.runtimeGenerationId,
|
|
825
|
+
attemptId: request.turn.attemptId,
|
|
826
|
+
inputDisposition: "not-accepted",
|
|
827
|
+
sessionDisposition: "recoverable"
|
|
828
|
+
});
|
|
670
829
|
throw new ProviderTurnRejectedError("Agent Host has no active Provider Turn to steer.", request.turn.attemptId);
|
|
671
830
|
}
|
|
831
|
+
// Steer carried no generation check, so a steer issued against a
|
|
832
|
+
// superseded activation was written into the current Provider Turn. The
|
|
833
|
+
// native Turn id alone does not catch it: a late steer can arrive while
|
|
834
|
+
// the id it names is still the active one under a newer generation.
|
|
835
|
+
if (request.runtimeGenerationId !== sessionPayload.runtimeGenerationId) {
|
|
836
|
+
operation.failure = providerDeliveryFailure({
|
|
837
|
+
detail: "Agent Host steer targets a superseded runtime generation.",
|
|
838
|
+
errorName: "ProviderGenerationConflictError",
|
|
839
|
+
phase: "turn-submit",
|
|
840
|
+
hostState: snapshot.state,
|
|
841
|
+
expectedRuntimeGenerationId: sessionPayload.runtimeGenerationId,
|
|
842
|
+
observedRuntimeGenerationId: request.runtimeGenerationId,
|
|
843
|
+
attemptId: request.turn.attemptId,
|
|
844
|
+
inputDisposition: "not-accepted",
|
|
845
|
+
sessionDisposition: "recoverable"
|
|
846
|
+
});
|
|
847
|
+
throw new ProviderTurnRejectedError("Agent Host steer targets a superseded runtime generation: expected "
|
|
848
|
+
+ `${sessionPayload.runtimeGenerationId}, observed ${request.runtimeGenerationId}.`, request.turn.attemptId);
|
|
849
|
+
}
|
|
672
850
|
if (request.nativeSessionId !== session.nativeSessionId
|
|
673
851
|
|| request.nativeTurnId !== activeNativeTurnId) {
|
|
852
|
+
operation.failure = providerDeliveryFailure({
|
|
853
|
+
detail: "Agent Host steer targets a different Provider Turn.",
|
|
854
|
+
errorName: "ProviderTurnRejectedError",
|
|
855
|
+
phase: "turn-submit",
|
|
856
|
+
hostState: snapshot.state,
|
|
857
|
+
expectedRuntimeGenerationId: sessionPayload.runtimeGenerationId,
|
|
858
|
+
observedRuntimeGenerationId: request.runtimeGenerationId,
|
|
859
|
+
attemptId: request.turn.attemptId,
|
|
860
|
+
inputDisposition: "not-accepted",
|
|
861
|
+
sessionDisposition: "recoverable"
|
|
862
|
+
});
|
|
674
863
|
throw new ProviderTurnRejectedError("Agent Host steer targets a different Provider Turn.", request.turn.attemptId);
|
|
675
864
|
}
|
|
676
865
|
if (authority === undefined || !sameProviderAuthorityFence(authority, request.authority)) {
|
|
677
866
|
throw new Error("Agent Host rejected a stale Provider writer fence.");
|
|
678
867
|
}
|
|
679
|
-
if (recentSteerAttempts.has(request.turn.attemptId))
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
868
|
+
if (recentSteerAttempts.has(request.turn.attemptId)) {
|
|
869
|
+
const previous = recentSteerAttempts.get(request.turn.attemptId);
|
|
870
|
+
if (previous === undefined)
|
|
871
|
+
return snapshot;
|
|
872
|
+
operation.failure = previous;
|
|
873
|
+
throw new ProviderDeliveryUnknownError(previous.detail, request.turn.attemptId);
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
const receipt = await session.steerTurn(request.turn);
|
|
877
|
+
if (receipt.nativeTurnId !== request.nativeTurnId) {
|
|
878
|
+
throw new ProviderDeliveryUnknownError("Provider accepted steer against an unexpected native Turn.", request.turn.attemptId);
|
|
879
|
+
}
|
|
880
|
+
recentSteerAttempts.set(request.turn.attemptId, undefined);
|
|
684
881
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
882
|
+
catch (error) {
|
|
883
|
+
const unknown = error instanceof ProviderDeliveryUnknownError;
|
|
884
|
+
operation.failure = providerDeliveryFailureFrom(error, {
|
|
885
|
+
phase: "turn-submit",
|
|
886
|
+
hostState: snapshot.state,
|
|
887
|
+
expectedRuntimeGenerationId: sessionPayload.runtimeGenerationId,
|
|
888
|
+
observedRuntimeGenerationId: request.runtimeGenerationId,
|
|
889
|
+
attemptId: request.turn.attemptId,
|
|
890
|
+
inputDisposition: unknown ? "unknown" : "not-accepted",
|
|
891
|
+
sessionDisposition: "unknown"
|
|
892
|
+
});
|
|
893
|
+
if (unknown)
|
|
894
|
+
recentSteerAttempts.set(request.turn.attemptId, operation.failure);
|
|
895
|
+
throw error;
|
|
688
896
|
}
|
|
689
897
|
return snapshot;
|
|
690
898
|
};
|
|
@@ -721,17 +929,37 @@ export async function runAgentHost(input) {
|
|
|
721
929
|
return controlResult("status", snapshot);
|
|
722
930
|
}
|
|
723
931
|
if (request.type === "submit-turn") {
|
|
724
|
-
const accepted = await enqueueSerialized(() =>
|
|
932
|
+
const accepted = await enqueueSerialized(async () => {
|
|
933
|
+
const operation = {};
|
|
934
|
+
try {
|
|
935
|
+
return await submitTurn(request, operation);
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
throw new AgentHostOperationError(error, snapshot, operation.failure);
|
|
939
|
+
}
|
|
940
|
+
});
|
|
725
941
|
return controlResult("accepted", accepted);
|
|
726
942
|
}
|
|
727
943
|
if (request.type === "steer-turn") {
|
|
728
|
-
const accepted = await enqueueSerialized(() =>
|
|
944
|
+
const accepted = await enqueueSerialized(async () => {
|
|
945
|
+
const operation = {};
|
|
946
|
+
try {
|
|
947
|
+
return await steerTurn(request, operation);
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
throw new AgentHostOperationError(error, snapshot, operation.failure);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
729
953
|
return controlResult("accepted", accepted);
|
|
730
954
|
}
|
|
731
955
|
if (request.type === "set-authority") {
|
|
732
956
|
const accepted = await enqueueSerialized(async () => setAuthority(request));
|
|
733
957
|
return controlResult("accepted", accepted);
|
|
734
958
|
}
|
|
959
|
+
// Same generation means this exact activation is already live here: the
|
|
960
|
+
// control request was received and the identity matches. Whether the
|
|
961
|
+
// Conversation is usable yet is a separate fact the caller reads from
|
|
962
|
+
// `snapshot.state`; it is never an identity conflict.
|
|
735
963
|
if (snapshot.runtimeGenerationId === request.runtimeGenerationId
|
|
736
964
|
&& ["starting", "ready", "settling", "delivery-unknown"].includes(snapshot.state)) {
|
|
737
965
|
return controlResult("active-same-generation", snapshot);
|
|
@@ -781,7 +1009,7 @@ export async function runAgentHost(input) {
|
|
|
781
1009
|
boundedText
|
|
782
1010
|
}
|
|
783
1011
|
};
|
|
784
|
-
await submitTurn(turnControl);
|
|
1012
|
+
await submitTurn(turnControl, {});
|
|
785
1013
|
process.stdout.write("Provider accepted the human Turn; waiting for its terminal boundary.\n");
|
|
786
1014
|
}).catch((error) => {
|
|
787
1015
|
process.stderr.write(`Provider input failed: ${errorText(error)}\n`);
|
|
@@ -954,6 +1182,15 @@ async function replayExitOutbox(home) {
|
|
|
954
1182
|
await replayRuntimeProcessExitOutbox(home, (observation) => callController(home, "runtime.process-exit-observe", observation).then(() => undefined));
|
|
955
1183
|
}
|
|
956
1184
|
/** Internal socket boundary exported for transport-level verification. */
|
|
1185
|
+
class AgentHostOperationError extends Error {
|
|
1186
|
+
snapshot;
|
|
1187
|
+
failure;
|
|
1188
|
+
constructor(cause, snapshot, failure) {
|
|
1189
|
+
super(errorText(cause), { cause });
|
|
1190
|
+
this.snapshot = snapshot;
|
|
1191
|
+
this.failure = failure;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
957
1194
|
export async function openAgentHostControl(home, payload, snapshot, dispatch) {
|
|
958
1195
|
const path = agentHostControlSocketPath({
|
|
959
1196
|
home,
|
|
@@ -985,17 +1222,49 @@ export async function openAgentHostControl(home, payload, snapshot, dispatch) {
|
|
|
985
1222
|
void (async () => {
|
|
986
1223
|
try {
|
|
987
1224
|
const request = validateControl(JSON.parse(body.trim()));
|
|
988
|
-
socket.end(`${JSON.stringify(await dispatch(request))}\n`);
|
|
1225
|
+
socket.end(`${JSON.stringify(boundControlResponse(await dispatch(request)))}\n`);
|
|
989
1226
|
}
|
|
990
|
-
catch (
|
|
991
|
-
const
|
|
1227
|
+
catch (caught) {
|
|
1228
|
+
const error = caught instanceof AgentHostOperationError ? caught.cause : caught;
|
|
1229
|
+
const current = caught instanceof AgentHostOperationError ? caught.snapshot : snapshot();
|
|
992
1230
|
const busy = error instanceof ProviderTurnBusyError;
|
|
993
|
-
|
|
1231
|
+
// Two different facts can be unknown: whether the Provider accepted
|
|
1232
|
+
// the input, and whether its durable registration was committed.
|
|
1233
|
+
// Both leave the outcome ambiguous, so neither may be reported as a
|
|
1234
|
+
// definite non-acceptance.
|
|
1235
|
+
const unknown = error instanceof ProviderDeliveryUnknownError
|
|
1236
|
+
|| error instanceof ControllerAcknowledgementUnknownError;
|
|
1237
|
+
// The failing operation already recorded the exact facts. Prefer its
|
|
1238
|
+
// record; only synthesize one when the failure came from outside a
|
|
1239
|
+
// Turn operation (a malformed control, say), where those facts do
|
|
1240
|
+
// not exist.
|
|
1241
|
+
const recorded = caught instanceof AgentHostOperationError ? caught.failure : undefined;
|
|
1242
|
+
const failure = recorded ?? providerDeliveryFailureFrom(error, {
|
|
1243
|
+
phase: controlRequestPhase(body),
|
|
1244
|
+
hostState: busy ? "busy" : unknown ? "delivery-unknown" : current.state,
|
|
1245
|
+
...(current.runtimeGenerationId === undefined
|
|
1246
|
+
? {}
|
|
1247
|
+
: { observedRuntimeGenerationId: current.runtimeGenerationId }),
|
|
1248
|
+
...(current.attemptId === undefined
|
|
1249
|
+
? {}
|
|
1250
|
+
: { attemptId: current.attemptId }),
|
|
1251
|
+
// The Host rejected this request before writing to the
|
|
1252
|
+
// Provider, except when it explicitly reported an ambiguous
|
|
1253
|
+
// delivery. Never silently upgrade that to not-accepted.
|
|
1254
|
+
inputDisposition: unknown ? "unknown" : "not-accepted"
|
|
1255
|
+
});
|
|
1256
|
+
// A bare `rejected` here is indistinguishable from the Provider
|
|
1257
|
+
// refusing the input. Carry the real cause and an explicit input
|
|
1258
|
+
// disposition so no consumer has to guess from the message text.
|
|
1259
|
+
socket.end(`${JSON.stringify(boundControlResponse(controlResult(busy ? "accepted" : "rejected", validateSnapshot({
|
|
994
1260
|
...current,
|
|
995
1261
|
...(busy ? { state: "busy" } : {}),
|
|
996
|
-
|
|
1262
|
+
...(unknown ? { state: "delivery-unknown" } : {}),
|
|
1263
|
+
// snapshot.detail reaches the same public read chain as the
|
|
1264
|
+
// failure record, so it passes the same redaction boundary.
|
|
1265
|
+
detail: redactAgentErrorText(errorText(error)),
|
|
997
1266
|
updatedAt: new Date().toISOString()
|
|
998
|
-
})))}\n`);
|
|
1267
|
+
}), failure)))}\n`);
|
|
999
1268
|
}
|
|
1000
1269
|
})();
|
|
1001
1270
|
});
|
|
@@ -1012,6 +1281,49 @@ export async function openAgentHostControl(home, payload, snapshot, dispatch) {
|
|
|
1012
1281
|
}
|
|
1013
1282
|
});
|
|
1014
1283
|
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Keeps a control response inside the socket bound the client enforces.
|
|
1286
|
+
*
|
|
1287
|
+
* A large `raw` chain could push the response past `HOST_CONTROL_MAX_BYTES`,
|
|
1288
|
+
* and the client destroys anything over it — turning a precise structured
|
|
1289
|
+
* failure into a bare transport error and losing the cause entirely. Bound
|
|
1290
|
+
* display projections first; if the raw payload still exceeds the UTF-8 byte
|
|
1291
|
+
* budget, retain its head and tail with an explicit truncation marker.
|
|
1292
|
+
*/
|
|
1293
|
+
function boundControlResponse(result) {
|
|
1294
|
+
// Every public response, including status and successful controls, crosses
|
|
1295
|
+
// the same redaction/size boundary. An earlier failed operation may remain
|
|
1296
|
+
// visible through an otherwise successful status request.
|
|
1297
|
+
result = controlResult(result.outcome, validateSnapshot(result.snapshot), result.failure === undefined ? undefined : providerDeliveryFailure(result.failure));
|
|
1298
|
+
if (withinControlBound(result))
|
|
1299
|
+
return result;
|
|
1300
|
+
const clip = (text, chars) => {
|
|
1301
|
+
if (text.length <= chars)
|
|
1302
|
+
return text;
|
|
1303
|
+
const head = Math.floor(chars * 0.75);
|
|
1304
|
+
return `${text.slice(0, head)}…[truncated ${text.length - chars} chars: control byte bound]${text.slice(text.length - (chars - head))}`;
|
|
1305
|
+
};
|
|
1306
|
+
let bounded = controlResult(result.outcome, {
|
|
1307
|
+
...result.snapshot,
|
|
1308
|
+
...(result.snapshot.detail === undefined ? {} : { detail: clip(result.snapshot.detail, 1200) })
|
|
1309
|
+
}, result.failure === undefined ? undefined : { ...result.failure, detail: clip(result.failure.detail, 1200) });
|
|
1310
|
+
const raw = bounded.failure?.raw;
|
|
1311
|
+
if (withinControlBound(bounded) || raw === undefined)
|
|
1312
|
+
return bounded;
|
|
1313
|
+
let chars = raw.length;
|
|
1314
|
+
while (!withinControlBound(bounded) && chars > 0) {
|
|
1315
|
+
chars = Math.floor(chars / 2);
|
|
1316
|
+
bounded = controlResult(bounded.outcome, bounded.snapshot, {
|
|
1317
|
+
...bounded.failure,
|
|
1318
|
+
raw: clip(raw, chars)
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
return bounded;
|
|
1322
|
+
}
|
|
1323
|
+
function withinControlBound(result) {
|
|
1324
|
+
// The newline the socket appends counts against the same bound.
|
|
1325
|
+
return Buffer.byteLength(`${JSON.stringify(result)}\n`, "utf8") <= HOST_CONTROL_MAX_BYTES;
|
|
1326
|
+
}
|
|
1015
1327
|
async function hostControlSocketIsLive(path) {
|
|
1016
1328
|
return await new Promise((resolvePromise, reject) => {
|
|
1017
1329
|
const client = createConnection(path);
|
|
@@ -1088,7 +1400,27 @@ function validateControlResult(result) {
|
|
|
1088
1400
|
|| !["status", "accepted", "rejected", "active-same-generation", "active-other-generation"].includes(result.outcome)) {
|
|
1089
1401
|
throw new Error("Agent Host control response is invalid.");
|
|
1090
1402
|
}
|
|
1091
|
-
return Object.freeze({
|
|
1403
|
+
return Object.freeze({
|
|
1404
|
+
...result,
|
|
1405
|
+
snapshot: validateSnapshot(result.snapshot),
|
|
1406
|
+
// A malformed failure record must never mask the outcome it describes.
|
|
1407
|
+
// Drop it and let the consumer fall back to snapshot.detail.
|
|
1408
|
+
...(isProviderDeliveryFailure(result.failure)
|
|
1409
|
+
? { failure: Object.freeze({ ...result.failure }) }
|
|
1410
|
+
: {})
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
function isProviderDeliveryFailure(value) {
|
|
1414
|
+
if (value === null || typeof value !== "object")
|
|
1415
|
+
return false;
|
|
1416
|
+
const failure = value;
|
|
1417
|
+
return typeof failure.detail === "string"
|
|
1418
|
+
&& failure.detail.trim().length > 0
|
|
1419
|
+
&& typeof failure.phase === "string"
|
|
1420
|
+
&& (failure.raw === undefined || typeof failure.raw === "string")
|
|
1421
|
+
&& (failure.registrationDisposition === undefined
|
|
1422
|
+
|| ["committed", "not-committed", "unknown"].includes(failure.registrationDisposition))
|
|
1423
|
+
&& ["accepted", "not-accepted", "unknown"].includes(failure.inputDisposition ?? "");
|
|
1092
1424
|
}
|
|
1093
1425
|
function validateSnapshot(snapshot) {
|
|
1094
1426
|
if (snapshot.schemaVersion !== 2
|
|
@@ -1114,7 +1446,13 @@ function validateSnapshot(snapshot) {
|
|
|
1114
1446
|
holderId: snapshot.authorityHolderId
|
|
1115
1447
|
});
|
|
1116
1448
|
}
|
|
1117
|
-
|
|
1449
|
+
// Store only the redacted diagnostic in the live snapshot. Sanitizing the
|
|
1450
|
+
// immediate error response alone leaves status/launch acknowledgements able
|
|
1451
|
+
// to expose the original exception on their normal success paths.
|
|
1452
|
+
return Object.freeze({
|
|
1453
|
+
...snapshot,
|
|
1454
|
+
...(snapshot.detail === undefined ? {} : { detail: redactAgentErrorText(snapshot.detail) })
|
|
1455
|
+
});
|
|
1118
1456
|
}
|
|
1119
1457
|
function hostSnapshot(state, fields = {}) {
|
|
1120
1458
|
return validateSnapshot({
|
|
@@ -1124,8 +1462,31 @@ function hostSnapshot(state, fields = {}) {
|
|
|
1124
1462
|
updatedAt: new Date().toISOString()
|
|
1125
1463
|
});
|
|
1126
1464
|
}
|
|
1127
|
-
function controlResult(outcome, snapshot) {
|
|
1128
|
-
return Object.freeze({
|
|
1465
|
+
function controlResult(outcome, snapshot, failure) {
|
|
1466
|
+
return Object.freeze({
|
|
1467
|
+
protocol: AGENT_HOST_CONTROL_PROTOCOL,
|
|
1468
|
+
outcome,
|
|
1469
|
+
snapshot,
|
|
1470
|
+
...(failure === undefined ? {} : { failure })
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Best-effort failure phase for a request that failed before or during
|
|
1475
|
+
* parsing. An unparsable body cannot be attributed to a specific control, so
|
|
1476
|
+
* it stays `turn-submit` only when the body actually claims that type.
|
|
1477
|
+
*/
|
|
1478
|
+
function controlRequestPhase(body) {
|
|
1479
|
+
try {
|
|
1480
|
+
const type = JSON.parse(body.trim()).type;
|
|
1481
|
+
if (type === "submit-turn" || type === "steer-turn")
|
|
1482
|
+
return "turn-submit";
|
|
1483
|
+
if (type === "launch")
|
|
1484
|
+
return "session-restore";
|
|
1485
|
+
}
|
|
1486
|
+
catch {
|
|
1487
|
+
// An unparsable control never reached the Provider.
|
|
1488
|
+
}
|
|
1489
|
+
return "host-start";
|
|
1129
1490
|
}
|
|
1130
1491
|
function definedFields(value) {
|
|
1131
1492
|
return Object.fromEntries(Object.entries(value).filter(([, member]) => member !== undefined));
|
|
@@ -1173,8 +1534,13 @@ async function beginDurableProviderTurn(home, durableTurn) {
|
|
|
1173
1534
|
catch (error) {
|
|
1174
1535
|
if (!(error instanceof ControllerAcknowledgementUnknownError))
|
|
1175
1536
|
throw error;
|
|
1176
|
-
await resolveProviderTurnSubmission(home, durableTurn, new Error(`Provider Turn intent acknowledgement failed before Provider write: ${errorText(error)}
|
|
1177
|
-
|
|
1537
|
+
await resolveProviderTurnSubmission(home, durableTurn, new Error(`Provider Turn intent acknowledgement failed before Provider write: ${errorText(error)}`, { cause: error }));
|
|
1538
|
+
// The resolve is fenced on this exact attempt id, so its success proves
|
|
1539
|
+
// the registration did commit and is now settled with nothing written to
|
|
1540
|
+
// the Provider. That is a known outcome; reporting it as unknown would
|
|
1541
|
+
// strand a Session whose durable record is in fact resolved.
|
|
1542
|
+
throw new ProviderTurnRegistrationSettledError("Provider Turn registration acknowledgement was lost and its durable record has been "
|
|
1543
|
+
+ `settled before any Provider write: ${errorText(error)}`, { cause: error });
|
|
1178
1544
|
}
|
|
1179
1545
|
}
|
|
1180
1546
|
async function callControllerIdempotently(home, method, request) {
|
|
@@ -1194,7 +1560,7 @@ async function callControllerIdempotently(home, method, request) {
|
|
|
1194
1560
|
}
|
|
1195
1561
|
catch (replayError) {
|
|
1196
1562
|
if (firstCallMayHaveApplied || controllerCallMayHaveApplied(replayError)) {
|
|
1197
|
-
throw new ControllerAcknowledgementUnknownError(`${method} may have been committed, but its acknowledgement could not be confirmed: ${errorText(replayError)}
|
|
1563
|
+
throw new ControllerAcknowledgementUnknownError(`${method} may have been committed, but its acknowledgement could not be confirmed: ${errorText(replayError)}`, { cause: new AggregateError([error, replayError], "Controller acknowledgement could not be confirmed by its exact idempotent replay.", { cause: replayError }) });
|
|
1198
1564
|
}
|
|
1199
1565
|
throw replayError;
|
|
1200
1566
|
}
|
|
@@ -1203,6 +1569,14 @@ async function callControllerIdempotently(home, method, request) {
|
|
|
1203
1569
|
class ControllerAcknowledgementUnknownError extends Error {
|
|
1204
1570
|
name = "ControllerAcknowledgementUnknownError";
|
|
1205
1571
|
}
|
|
1572
|
+
/**
|
|
1573
|
+
* The registration acknowledgement was lost, but the compensating resolve
|
|
1574
|
+
* then succeeded against the same attempt fence. Both facts are therefore
|
|
1575
|
+
* known: the durable record is settled and the Provider was never written to.
|
|
1576
|
+
*/
|
|
1577
|
+
class ProviderTurnRegistrationSettledError extends Error {
|
|
1578
|
+
name = "ProviderTurnRegistrationSettledError";
|
|
1579
|
+
}
|
|
1206
1580
|
async function resolveProviderTurnSubmission(home, durableTurn, error) {
|
|
1207
1581
|
const attemptId = durableTurn.attemptId;
|
|
1208
1582
|
if (typeof attemptId !== "string") {
|
|
@@ -1221,7 +1595,11 @@ async function resolveProviderTurnSubmission(home, durableTurn, error) {
|
|
|
1221
1595
|
await callControllerIdempotently(home, "runtime.provider-turn-submission-resolve", request);
|
|
1222
1596
|
}
|
|
1223
1597
|
catch (resolutionError) {
|
|
1224
|
-
throw new ProviderDeliveryUnknownError(`Provider submission outcome could not be durably resolved: ${errorText(resolutionError)}. Original outcome: ${errorText(error)}`, attemptId
|
|
1598
|
+
throw new ProviderDeliveryUnknownError(`Provider submission outcome could not be durably resolved: ${errorText(resolutionError)}. Original outcome: ${errorText(error)}`, attemptId,
|
|
1599
|
+
// Without this the causal chain ends at the wrapper, and the reason the
|
|
1600
|
+
// resolution failed — the fact that decides whether a retry is safe —
|
|
1601
|
+
// survives only as prose inside the message.
|
|
1602
|
+
{ cause: new AggregateError([error, resolutionError], "Provider submission outcome and its failed durable settlement.", { cause: resolutionError }) });
|
|
1225
1603
|
}
|
|
1226
1604
|
}
|
|
1227
1605
|
/**
|