Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/carrier-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,36 @@ for(const scenario of ["current","expired","close-response","terminal-write","re
} finally {resumedManager.close();resumedStore.close();}
} finally {f.close();}
});
test("active coordination-bound prepared cutover can terminally reconcile an exact observed replacement",async()=>{
const f=fixture();
try {
const context={clientId:"shared-oauth",sessionId:"active-prepared-replacement-controller"};
const pairing=f.store.requestPairing(context);
const cutover={stateRoot:f.root,attemptKey:"active-prepared-replacement",
currentIdentity:{serverInstanceId:"original",sourceCommit:f.contract.baseRevision,buildId:"old",capabilityManifestSha256:"c".repeat(64)},
expectedIdentity:{sourceCommit:"b".repeat(40),buildId:"new",capabilityManifestSha256:"d".repeat(64)},
expiresAt:new Date(f.clock()+60000).toISOString(),
restart:{buildReady:{verifiedBy:"independent",verifiedAt:new Date(f.clock()).toISOString(),evidence:"exact package digest"},actuator:"launchd-self" as const,serviceLabel:"test.service",launchdTarget:"gui/501/test.service"},
finish:{workspaceId:"ws_active",agentId:"agt_active"}};
f.store.approveLocal(pairing.pendingId,{...f.contract,scope:[f.root],operations:["cutover_start" as const],cutover});
f.store.redeem(context,pairing.credential);
const config=loadConfig({DEVSPACE_CONFIG_DIR:join(f.root,"config"),DEVSPACE_ALLOWED_ROOTS:f.workspace,DEVSPACE_WORKTREE_ROOT:join(f.root,"worktrees"),DEVSPACE_STATE_DIR:f.root,DEVSPACE_OAUTH_OWNER_TOKEN:"test-owner-token-long-enough",PORT:"1"});
const manager=new DurableOperationManager(config,undefined,undefined,undefined,f.store.readers);
try {
f.store.prepareEffect(context,planCutoverStart(f.root,cutover).subject);
const start=manager.startCutover(cutover,context),id=start.receipt!.cutoverId as string;
const replacement={serverInstanceId:"replacement",...cutover.expectedIdentity};
const witness={workspaceQueryable:true,agentQueryable:true,agentReconciled:true,witnessWorkspaceId:cutover.finish.workspaceId,witnessAgentId:cutover.finish.agentId};
const closed=await manager.finishCutover(id,replacement,cutover.finish,async()=>witness,context);
assert.equal(closed.phase,"closed");
assert.equal(closed.drainEvidence,undefined);
assert.equal(closed.restartRequest,undefined);
assert.equal(f.store.ownership.get(closed.coordinationBinding!.leaseId)?.operationHandle,undefined);
assert.equal(manager.store.getByOperationId(start.operationId)?.receipt?.lifecycleTerminal,true);
} finally {manager.close();}
} finally {f.close();}
});

test("expired coordination-bound prepared cutover can terminally reconcile an exact observed replacement",async()=>{
const f=fixture();
try {
Expand All @@ -206,7 +236,7 @@ test("expired coordination-bound prepared cutover can terminally reconcile an ex
const replacement={serverInstanceId:"replacement",...cutover.expectedIdentity};
const witness={workspaceQueryable:true,agentQueryable:true,agentReconciled:true,witnessWorkspaceId:cutover.finish.workspaceId,witnessAgentId:cutover.finish.agentId};
let witnessCalls=0;
await assert.rejects(manager.finishCutover(id,replacement,cutover.finish,async()=>{witnessCalls++;return witness;},context),/drained generation|prepared recovery/);
await assert.rejects(manager.finishCutover(id,{...replacement,serverInstanceId:cutover.currentIdentity.serverInstanceId},cutover.finish,async()=>{witnessCalls++;return witness;},context),/terminal recovery approval|required|runtime identity mismatch/);
assert.equal(witnessCalls,0);
assert.equal(new CutoverStateStore(f.root).get()?.phase,"prepared");
f.advance(70000);
Expand Down
7 changes: 4 additions & 3 deletions src/durable-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,9 +602,10 @@ export class DurableOperationManager {
const comparison=compareServerIdentity(file,identity);
if(!Object.values(comparison).every(Boolean)) throw new ControlPlaneOwnershipError("CAS_CONFLICT","finish replacement runtime identity mismatch");
const expiredPreparedRecovery=recovery&&file.phase==="prepared"&&!file.drainEvidence&&!file.restartRequest;
if(file.phase!=="drained"&&file.phase!=="closed"&&!expiredPreparedRecovery) throw new ControlPlaneOwnershipError("CAS_CONFLICT","bound finish requires drained generation unless exact expired prepared recovery applies");
const activePreparedReplacement=!recovery&&file.phase==="prepared"&&!file.drainEvidence&&!file.restartRequest&&Object.values(comparison).every(Boolean);
if(file.phase!=="drained"&&file.phase!=="closed"&&!expiredPreparedRecovery&&!activePreparedReplacement) throw new ControlPlaneOwnershipError("CAS_CONFLICT","bound finish requires drained generation unless an exact prepared replacement or expired prepared recovery applies");
if(replay&&(file.phase!=="closed"||intent.receipt?.terminalRecordHash!==digest(file)||!isDeepStrictEqual(intent.receipt?.lifecycleAction,action))) throw new ControlPlaneOwnershipError("CAS_CONFLICT","terminal replay receipt mismatch");
return {file,intent,subject,binding,replay,recovery,expiredPreparedRecovery};
return {file,intent,subject,binding,replay,recovery,expiredPreparedRecovery,activePreparedReplacement};
};
const validWitness=(w:DurableReconciliationWitness|undefined)=>!!w&&w.workspaceQueryable===true&&w.agentQueryable===true&&w.agentReconciled===true&&w.witnessWorkspaceId===pair.workspaceId&&w.witnessAgentId===pair.agentId;
const initial=this.store.atomic(readBound);
Expand All @@ -616,7 +617,7 @@ export class DurableOperationManager {
if(current.file.phase!=="closed") {
if(!validWitness(witness)) throw new ControlPlaneOwnershipError("AUTHORITY_REQUIRED","finish requires positive exact workspace/agent witness");
const controller=new McpCutoverController(cutoverStore,identity);
if(current.expiredPreparedRecovery) controller.finishExpiredPreparedRecoveryWithWitness(cutoverId,witness!);
if(current.expiredPreparedRecovery||current.activePreparedReplacement) controller.finishExpiredPreparedRecoveryWithWitness(cutoverId,witness!);
else controller.finishWithWitness(cutoverId,witness!);
}
const closed=cutoverStore.get();const receipt=closed?.reconciliationReceipt;
Expand Down
Loading