Skip to content

Commit 879d369

Browse files
authored
Merge pull request #191 from pylon-code/feat/prime-negotiated-capability-proof
feat(prime): require negotiated correlated lifecycle proof
2 parents 05cf20c + f34744a commit 879d369

9 files changed

Lines changed: 9911 additions & 3735 deletions

apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ interface FakeCaptures {
172172
followUpFailure: boolean;
173173
inputRecoveryPending: boolean;
174174
inputAdmissionBusy: boolean;
175+
correlatedPromptLifecycleAdmissionBlocked: boolean;
176+
correlatedPromptLifecycleAdmissionBlockAfterReads: number | undefined;
177+
correlatedPromptLifecycleAdmissionReads: number;
175178
correlatedPromptLifecycleAvailable: boolean;
176179
readonly correlatedPromptSubmissions: Array<{
177180
readonly text: string;
@@ -327,6 +330,9 @@ interface FakeCaptures {
327330
rlmQuiescenceFailure: boolean;
328331
rlmConnectionGeneration: number;
329332
rlmContinuityValid: boolean;
333+
correlatedRecoveryProofCurrent: boolean;
334+
correlatedRecoveryProofEpoch: number;
335+
reconnectSnapshotResolutionAccepted: boolean;
330336
readonly reconnectResolutions: Array<{
331337
readonly generation: number;
332338
readonly reconciled: boolean;
@@ -356,6 +362,9 @@ function makeCaptures(): FakeCaptures {
356362
followUpFailure: false,
357363
inputRecoveryPending: false,
358364
inputAdmissionBusy: false,
365+
correlatedPromptLifecycleAdmissionBlocked: false,
366+
correlatedPromptLifecycleAdmissionBlockAfterReads: undefined,
367+
correlatedPromptLifecycleAdmissionReads: 0,
359368
correlatedPromptLifecycleAvailable: false,
360369
correlatedPromptSubmissions: [],
361370
correlatedPromptCancellations: [],
@@ -477,6 +486,9 @@ function makeCaptures(): FakeCaptures {
477486
rlmQuiescenceFailure: false,
478487
rlmConnectionGeneration: 0,
479488
rlmContinuityValid: true,
489+
correlatedRecoveryProofCurrent: true,
490+
correlatedRecoveryProofEpoch: 1,
491+
reconnectSnapshotResolutionAccepted: true,
480492
reconnectResolutions: [],
481493
retryWorkerRecoverySnapshots: false,
482494
retryWorkerRecoverySnapshotCalls: [],
@@ -836,7 +848,14 @@ function fakeRuntimeFactory(
836848
const resolution = { generation, reconciled, terminalResponseObserved };
837849
captures.reconnectResolutions.push(resolution);
838850
captures.reconnectSnapshotResolutionObserved?.(resolution);
839-
if (generation !== captures.rlmConnectionGeneration) return false;
851+
if (
852+
generation !== captures.rlmConnectionGeneration ||
853+
!captures.reconnectSnapshotResolutionAccepted ||
854+
(captures.correlatedPromptLifecycleAvailable &&
855+
!captures.correlatedRecoveryProofCurrent)
856+
) {
857+
return false;
858+
}
840859
captures.rlmContinuityValid = reconciled;
841860
return true;
842861
},
@@ -848,8 +867,12 @@ function fakeRuntimeFactory(
848867
noteWorkerRecoveryTerminalResponse: () => {
849868
captures.workerRecoveryTerminalResponseObserved?.();
850869
},
851-
isConnectionGenerationCurrent: (generation) =>
852-
generation === captures.rlmConnectionGeneration,
870+
isConnectionGenerationCurrent: (generation, proofEpoch) =>
871+
generation === captures.rlmConnectionGeneration &&
872+
(!captures.correlatedPromptLifecycleAvailable ||
873+
(captures.correlatedRecoveryProofCurrent &&
874+
(proofEpoch ?? captures.correlatedRecoveryProofEpoch) ===
875+
captures.correlatedRecoveryProofEpoch)),
853876
get correlatedPromptLifecycleAvailable() {
854877
return captures.correlatedPromptLifecycleAvailable;
855878
},
@@ -897,6 +920,15 @@ function fakeRuntimeFactory(
897920
}
898921
);
899922
}),
923+
get correlatedPromptLifecycleAdmissionBlocked() {
924+
captures.correlatedPromptLifecycleAdmissionReads += 1;
925+
return (
926+
captures.correlatedPromptLifecycleAdmissionBlocked ||
927+
(captures.correlatedPromptLifecycleAdmissionBlockAfterReads !== undefined &&
928+
captures.correlatedPromptLifecycleAdmissionReads >=
929+
captures.correlatedPromptLifecycleAdmissionBlockAfterReads)
930+
);
931+
},
900932
get inputAdmissionBusy() {
901933
return captures.inputAdmissionBusy;
902934
},
@@ -1121,6 +1153,32 @@ function offer(captures: FakeCaptures, event: PrimeDaemonEvent) {
11211153
}
11221154

11231155
describe("PrimeAgentDaemonAdapter", () => {
1156+
it.effect("rechecks correlated recovery before committing a new strict turn", () =>
1157+
Effect.scoped(
1158+
Effect.gen(function* () {
1159+
const captures = makeCaptures();
1160+
captures.correlatedPromptLifecycleAvailable = true;
1161+
captures.correlatedPromptLifecycleAdmissionBlockAfterReads = 2;
1162+
const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, {
1163+
instanceId,
1164+
runtimeFactory: fakeRuntimeFactory(captures),
1165+
});
1166+
yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" });
1167+
1168+
const error = yield* adapter
1169+
.sendTurn({ threadId, input: "must not cross pending resync" })
1170+
.pipe(Effect.flip);
1171+
1172+
expect(error).toMatchObject({
1173+
_tag: "ProviderAdapterValidationError",
1174+
reason: "busy",
1175+
});
1176+
expect(captures.correlatedPromptLifecycleAdmissionReads).toBeGreaterThanOrEqual(2);
1177+
expect(captures.correlatedPromptSubmissions).toEqual([]);
1178+
}),
1179+
).pipe(Effect.provide(testLayer)),
1180+
);
1181+
11241182
it.effect("settles only the delivered correlated owner and uses terminal lifecycle usage", () =>
11251183
Effect.scoped(
11261184
Effect.gen(function* () {
@@ -1376,6 +1434,8 @@ describe("PrimeAgentDaemonAdapter", () => {
13761434
...initialSnapshot(),
13771435
lastEventSequence: 2,
13781436
replayContinuity: "complete",
1437+
connectionGeneration: 0,
1438+
correlatedProofEpoch: 1,
13791439
promptLifecycles: {
13801440
records: [lifecycleSnapshot(correlationId, "failed", 2, { usage })],
13811441
expired: [],
@@ -1428,6 +1488,8 @@ describe("PrimeAgentDaemonAdapter", () => {
14281488
...initialSnapshot(),
14291489
lastEventSequence: 2,
14301490
replayContinuity: "complete",
1491+
connectionGeneration: 0,
1492+
correlatedProofEpoch: 1,
14311493
children: [{ id: "background-child", label: "background", status: "running" }],
14321494
promptLifecycles: {
14331495
records: [lifecycleSnapshot(correlationId, "queued", 2)],
@@ -1931,6 +1993,69 @@ describe("PrimeAgentDaemonAdapter", () => {
19311993
).pipe(Effect.provide(testLayer)),
19321994
);
19331995

1996+
it.effect("does not apply a terminal correlated snapshot when proof settlement is rejected", () =>
1997+
Effect.scoped(
1998+
Effect.gen(function* () {
1999+
const captures = makeCaptures();
2000+
captures.correlatedPromptLifecycleAvailable = true;
2001+
captures.correlatedPromptObserved = yield* Queue.unbounded<string>();
2002+
captures.rlmConnectionGeneration = 1;
2003+
captures.rlmContinuityValid = false;
2004+
captures.reconnectSnapshotResolutionAccepted = false;
2005+
const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, {
2006+
instanceId,
2007+
runtimeFactory: fakeRuntimeFactory(captures),
2008+
});
2009+
const subscription = yield* subscribe(adapter);
2010+
yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" });
2011+
const turnFiber = yield* adapter
2012+
.sendTurn({ threadId, input: "retired proof must not settle" })
2013+
.pipe(Effect.forkChild);
2014+
const correlationId = yield* Queue.take(captures.correlatedPromptObserved);
2015+
yield* offer(captures, {
2016+
_tag: "PromptLifecycleUpdated",
2017+
lifecycle: lifecycleSnapshot(correlationId, "delivered", 2),
2018+
});
2019+
const answer = assistantMessage("stale terminal answer");
2020+
yield* offer(captures, {
2021+
_tag: "MessageCompleted",
2022+
message: answer,
2023+
attribution: { scope: "prompt", correlationId },
2024+
});
2025+
2026+
yield* offer(captures, {
2027+
...initialSnapshot(),
2028+
state: { ...initialSnapshot().state, messageCount: 1 },
2029+
messages: [answer],
2030+
replayContinuity: "complete",
2031+
connectionGeneration: 1,
2032+
correlatedProofEpoch: 1,
2033+
promptLifecycles: {
2034+
records: [lifecycleSnapshot(correlationId, "completed", 3, { usage })],
2035+
expired: [],
2036+
},
2037+
});
2038+
yield* offer(captures, {
2039+
_tag: "SessionClosed",
2040+
error: "Prime Agent correlated prompt capability proof was lost during recovery.",
2041+
});
2042+
2043+
const result = yield* Fiber.join(turnFiber);
2044+
expect(captures.reconnectResolutions).toContainEqual({
2045+
generation: 1,
2046+
reconciled: true,
2047+
terminalResponseObserved: false,
2048+
});
2049+
const terminal = subscription.events.findLast(
2050+
(event) => event.turnId === result.turnId && event.type === "turn.completed",
2051+
);
2052+
expect(terminal).toMatchObject({ payload: { state: "failed" } });
2053+
expect(terminal?.payload).not.toHaveProperty("usage");
2054+
expect(terminal?.payload).not.toHaveProperty("totalCostUsd");
2055+
}),
2056+
).pipe(Effect.provide(testLayer)),
2057+
);
2058+
19342059
it.effect("keeps control mismatch busy and scopes pre-delivery cancellation", () =>
19352060
Effect.scoped(
19362061
Effect.gen(function* () {

apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2004,6 +2004,16 @@ export function makePrimeAgentDaemonAdapter(
20042004
context.threadId,
20052005
Effect.gen(function* () {
20062006
if (sessions.get(context.threadId) === context && !context.stopped) {
2007+
const reconnectGeneration = event.connectionGeneration;
2008+
if (
2009+
reconnectGeneration !== undefined &&
2010+
!context.runtime.isConnectionGenerationCurrent(
2011+
reconnectGeneration,
2012+
event.correlatedProofEpoch,
2013+
)
2014+
) {
2015+
return;
2016+
}
20072017
if (!managedSourceVerified) {
20082018
const activeTurn = context.activeTurn;
20092019
if (activeTurn !== undefined) {
@@ -2019,13 +2029,6 @@ export function makePrimeAgentDaemonAdapter(
20192029
reconnectRecoveryFailed = true;
20202030
return;
20212031
}
2022-
const reconnectGeneration = event.connectionGeneration;
2023-
if (
2024-
reconnectGeneration !== undefined &&
2025-
!context.runtime.isConnectionGenerationCurrent(reconnectGeneration)
2026-
) {
2027-
return;
2028-
}
20292032
context.managedPlanProjectionEnabled = true;
20302033
const activeTurn = context.activeTurn;
20312034
if (context.runtime.correlatedPromptLifecycleAvailable) {
@@ -2047,36 +2050,39 @@ export function makePrimeAgentDaemonAdapter(
20472050
reconnectRecoveryFailed = true;
20482051
return;
20492052
}
2050-
if (activeTurn?.correlationId !== undefined) {
2051-
const lifecycle = event.promptLifecycles?.records.find(
2052-
(candidate) => candidate.correlationId === activeTurn.correlationId,
2053-
);
2054-
if (lifecycle === undefined) {
2055-
if (reconnectGeneration !== undefined) {
2056-
context.runtime.resolveReconnectSnapshot(
2057-
reconnectGeneration,
2058-
false,
2059-
false,
2053+
const lifecycle =
2054+
activeTurn?.correlationId === undefined
2055+
? undefined
2056+
: event.promptLifecycles?.records.find(
2057+
(candidate) => candidate.correlationId === activeTurn.correlationId,
20602058
);
2061-
}
2062-
yield* settleActiveTurnLocked(context, activeTurn, {
2063-
state: "failed",
2064-
errorMessage:
2065-
"Prime Agent could not recover the correlated prompt lifecycle after synchronizing.",
2066-
runtimeErrorMessage:
2067-
"Prime Agent could not recover the correlated prompt lifecycle after synchronizing.",
2068-
});
2069-
context.stopRequested = true;
2070-
reconnectRecoveryFailed = true;
2071-
return;
2059+
if (activeTurn?.correlationId !== undefined && lifecycle === undefined) {
2060+
if (reconnectGeneration !== undefined) {
2061+
context.runtime.resolveReconnectSnapshot(reconnectGeneration, false, false);
20722062
}
2063+
yield* settleActiveTurnLocked(context, activeTurn, {
2064+
state: "failed",
2065+
errorMessage:
2066+
"Prime Agent could not recover the correlated prompt lifecycle after synchronizing.",
2067+
runtimeErrorMessage:
2068+
"Prime Agent could not recover the correlated prompt lifecycle after synchronizing.",
2069+
});
2070+
context.stopRequested = true;
2071+
reconnectRecoveryFailed = true;
2072+
return;
2073+
}
2074+
if (
2075+
reconnectGeneration === undefined ||
2076+
!context.runtime.resolveReconnectSnapshot(reconnectGeneration, true, false)
2077+
) {
2078+
reconnectRecoveryFailed = true;
2079+
return;
2080+
}
2081+
if (lifecycle !== undefined) {
20732082
yield* applyCorrelatedPromptLifecycleLocked(context, lifecycle, {
20742083
authoritativeSnapshot: true,
20752084
});
20762085
}
2077-
if (reconnectGeneration !== undefined) {
2078-
context.runtime.resolveReconnectSnapshot(reconnectGeneration, true, false);
2079-
}
20802086
}
20812087
} else if (reconnectGeneration !== undefined) {
20822088
const pendingRunCompletionBefore = activeTurn?.pendingRunCompletionHandoff;
@@ -3876,13 +3882,16 @@ export function makePrimeAgentDaemonAdapter(
38763882
const knownCompactionBusy =
38773883
context.activeCompactionScope !== undefined ||
38783884
context.manualCompactionRequestActive;
3885+
const correlatedRecoveryBusy =
3886+
context.runtime.correlatedPromptLifecycleAdmissionBlocked;
38793887
const nativeInputBusy = context.runtime.inputAdmissionBusy;
38803888
const admissionBusy =
38813889
nativeInputBusy ||
38823890
(context.runtime.correlatedPromptLifecycleAvailable && knownCompactionBusy);
38833891
if (
3884-
admissionBusy &&
3885-
(!context.runtime.correlatedPromptLifecycleAvailable || !controlsMatchCurrent)
3892+
correlatedRecoveryBusy ||
3893+
(admissionBusy &&
3894+
(!context.runtime.correlatedPromptLifecycleAvailable || !controlsMatchCurrent))
38863895
) {
38873896
return yield* new ProviderAdapterValidationError({
38883897
provider: PROVIDER,
@@ -3961,6 +3970,14 @@ export function makePrimeAgentDaemonAdapter(
39613970
projectedPlanToolCallIds: new Set(),
39623971
...(/^\/compact(?:\s|$)/.test(text) ? { command: "compact" as const } : {}),
39633972
};
3973+
if (context.runtime.correlatedPromptLifecycleAdmissionBlocked) {
3974+
return yield* new ProviderAdapterValidationError({
3975+
provider: PROVIDER,
3976+
operation: "sendTurn",
3977+
reason: "busy",
3978+
issue: "Prime Agent is still reconciling its correlated prompt lifecycle.",
3979+
});
3980+
}
39643981
context.activeTurn = turn;
39653982
context.session = {
39663983
...context.session,

0 commit comments

Comments
 (0)