diff --git a/src/carrier-binding.test.ts b/src/carrier-binding.test.ts index ddd5adc56..a54503912 100644 --- a/src/carrier-binding.test.ts +++ b/src/carrier-binding.test.ts @@ -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 { @@ -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); diff --git a/src/durable-operations.ts b/src/durable-operations.ts index 49107c1ee..fe5f29b6b 100644 --- a/src/durable-operations.ts +++ b/src/durable-operations.ts @@ -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); @@ -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;