@@ -740,6 +740,37 @@ function pressureReclaimReason (envelope, entryName, options, now) {
740740 return null
741741}
742742
743+ function canReplaceUnconsumedBootstrapEnvelope ( existing , expected = { } ) {
744+ const state = existing ?. state
745+ if ( ! state || state . project !== expected . project ||
746+ state . activeRoot !== portable ( expected . activeRoot ) ||
747+ state . turnBinding !== expected . turnBinding ||
748+ state . contextEpoch !== expected . contextEpoch ) {
749+ return false
750+ }
751+ if ( expected . hostSessionId && state . hostSessionId &&
752+ state . hostSessionId !== expected . hostSessionId ) {
753+ return false
754+ }
755+ if ( expected . paths ) {
756+ const progress = readCatalogProgress ( expected . paths , existing , expected . fs || fs )
757+ if ( progress . status === 'invalid' ) return false
758+ if ( Array . isArray ( progress . progress ?. servedCatalogPages ) &&
759+ progress . progress . servedCatalogPages . length ) {
760+ return false
761+ }
762+ } else if ( Array . isArray ( state . servedCatalogPages ) && state . servedCatalogPages . length ) {
763+ return false
764+ }
765+ if ( state . decision || state . plan || state . routeRetirement ) return false
766+ if ( state . stageProgress && Object . keys ( state . stageProgress ) . length ) return false
767+ if ( Array . isArray ( state . bodyChargeLedger ?. items ) && state . bodyChargeLedger . items . length ) return false
768+ if ( Number ( state . bodyChargeLedger ?. unattributedBodyBytes || 0 ) > 0 ) return false
769+ if ( Array . isArray ( state . obligationLedger ?. requiredStageIds ) &&
770+ state . obligationLedger . requiredStageIds . length ) return false
771+ return true
772+ }
773+
743774function collectExpiredTurns ( activeRoot , options = { } ) {
744775 const fsImpl = options . fs || fs
745776 const routeRoot = routeRootForActiveRoot ( activeRoot )
@@ -1168,9 +1199,91 @@ function bootstrapSkillRoute (input, options = {}) {
11681199 existing . state ?. catalog ?. catalogDigest === catalog . catalogDigest &&
11691200 existing . state ?. bootstrap ?. bootstrapDigest === bootstrap . bootstrapDigest
11701201 if ( ! sameIdentity ) {
1171- const error = new Error ( 'BOOTSTRAP_IDENTITY_COLLISION' )
1172- error . code = 'BOOTSTRAP_IDENTITY_COLLISION'
1173- throw error
1202+ const replaceable = canReplaceUnconsumedBootstrapEnvelope ( existing , {
1203+ project,
1204+ activeRoot,
1205+ turnBinding,
1206+ contextEpoch,
1207+ hostSessionId,
1208+ paths,
1209+ fs : fsImpl
1210+ } )
1211+ if ( ! replaceable ) {
1212+ const error = new Error ( 'BOOTSTRAP_IDENTITY_COLLISION' )
1213+ error . code = 'BOOTSTRAP_IDENTITY_COLLISION'
1214+ throw error
1215+ }
1216+ const replacementNow = options . now == null ? new Date ( ) : new Date ( options . now )
1217+ const replacement = {
1218+ ...existing ,
1219+ version : Number ( existing . version || 1 ) + 1 ,
1220+ state : {
1221+ project,
1222+ activeRoot : portable ( activeRoot ) ,
1223+ turnBinding,
1224+ contextEpoch,
1225+ hostSessionId,
1226+ mode : input . mode ,
1227+ modeReceipt,
1228+ runtimeContractDigest,
1229+ bootstrap,
1230+ index,
1231+ catalog,
1232+ explicit : {
1233+ requestedSkillId : explicitSkillId ,
1234+ status : explicitStatus ,
1235+ skillId : explicitEntry ?. skillId || null
1236+ } ,
1237+ servedCatalogPages : [ ] ,
1238+ decision : null ,
1239+ plan : null ,
1240+ stageProgress : { } ,
1241+ budget : {
1242+ bodyBytesConsumed : 0 ,
1243+ bodyLimitBytes : TURN_BODY_LIMIT_BYTES
1244+ } ,
1245+ bodyChargeLedger : {
1246+ schemaVersion : 'SkillRouteBodyChargeLedgerV1' ,
1247+ items : [ ] ,
1248+ unattributedBodyBytes : 0
1249+ } ,
1250+ contributionLedger : {
1251+ schemaVersion : 'ContributionLedgerV1' ,
1252+ items : [ {
1253+ channel : 'runtime-bootstrap' ,
1254+ observedAt : replacementNow . toISOString ( ) ,
1255+ outcome : 'replaced-unconsumed-stale-bootstrap' ,
1256+ priorRuntimeContractDigest : existing . state ?. runtimeContractDigest || null ,
1257+ runtimeContractDigest
1258+ } ]
1259+ } ,
1260+ obligationLedger : {
1261+ schemaVersion : 'ObligationLedgerV1' ,
1262+ items : [ ] ,
1263+ selectedBusinessSkillId : null ,
1264+ requiredStageIds : [ ] ,
1265+ satisfiedStageIds : [ ]
1266+ }
1267+ } ,
1268+ updatedAt : replacementNow . toISOString ( ) ,
1269+ expiresAt : new Date ( replacementNow . getTime ( ) + ( options . turnTtlMs || TURN_TTL_MS ) ) . toISOString ( )
1270+ }
1271+ assertProjectedCapacity ( paths , replacement , fsImpl )
1272+ atomicWriteJson ( paths . envelope , replacement , fsImpl )
1273+ const readBack = readJson ( paths . envelope , fsImpl )
1274+ if ( ! readBack || readBack . state ?. bootstrap ?. bootstrapDigest !== bootstrap . bootstrapDigest ) {
1275+ const error = new Error ( 'TURN_ENVELOPE_READBACK_FAILED' )
1276+ error . code = 'TURN_ENVELOPE_READBACK_FAILED'
1277+ throw error
1278+ }
1279+ return {
1280+ bootstrap,
1281+ envelope : readBack ,
1282+ reused : false ,
1283+ replaced : true ,
1284+ paths,
1285+ retention
1286+ }
11741287 }
11751288 return {
11761289 bootstrap,
@@ -1633,6 +1746,7 @@ module.exports = {
16331746 atomicWriteJson,
16341747 parseExplicitSkillId,
16351748 bootstrapSkillRoute,
1749+ canReplaceUnconsumedBootstrapEnvelope,
16361750 bindExplicitSkillRequest,
16371751 loadEnvelope,
16381752 transactEnvelope,
0 commit comments