Skip to content

Commit d2112e3

Browse files
committed
fix: harden workflow continuation and skill route stale recovery
1 parent 307f84e commit d2112e3

3 files changed

Lines changed: 201 additions & 4 deletions

File tree

‎hooks/_runtime/skill-route-state.cjs‎

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
743774
function 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,

‎scripts/test-skill-route-state.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2474,6 +2474,74 @@ try {
24742474
snapshotFixture.cleanup()
24752475
}
24762476

2477+
// Acceptance T01b: a stale bootstrap envelope that has never served catalog,
2478+
// committed a route, or loaded any stage may be replaced by the current
2479+
// runtime identity for the same turn. Once the turn has been consumed, the
2480+
// existing identity collision guard must remain strict.
2481+
const staleBootstrapReplacementFixture = createSkillRouteFixture({ project: 'stale-bootstrap-replacement' })
2482+
try {
2483+
const staleRuntimeDigest = '1'.repeat(64)
2484+
const replacementEpoch = 'ctx-stale-bootstrap-replacement'
2485+
const staleBoot = bootstrapSkillRoute({
2486+
project: staleBootstrapReplacementFixture.project,
2487+
activeRoot: staleBootstrapReplacementFixture.activeRoot,
2488+
contextEpoch: replacementEpoch,
2489+
prompt: 'Run stale bootstrap replacement probe',
2490+
mode: 'unified',
2491+
cwd: staleBootstrapReplacementFixture.projectRoot,
2492+
runtimeContractDigest: staleRuntimeDigest
2493+
}, staleBootstrapReplacementFixture.runtimeOptions)
2494+
assert.strictEqual(staleBoot.reused, false)
2495+
assert.strictEqual(staleBoot.bootstrap.runtimeContractDigest, staleRuntimeDigest)
2496+
2497+
const replacementBoot = bootstrapSkillRoute({
2498+
project: staleBootstrapReplacementFixture.project,
2499+
activeRoot: staleBootstrapReplacementFixture.activeRoot,
2500+
contextEpoch: replacementEpoch,
2501+
prompt: 'Run stale bootstrap replacement probe',
2502+
mode: 'unified',
2503+
cwd: staleBootstrapReplacementFixture.projectRoot
2504+
}, staleBootstrapReplacementFixture.runtimeOptions)
2505+
assert.strictEqual(replacementBoot.reused, false)
2506+
assert.strictEqual(replacementBoot.replaced, true)
2507+
assert.notStrictEqual(replacementBoot.bootstrap.runtimeContractDigest, staleRuntimeDigest)
2508+
assert.strictEqual(
2509+
replacementBoot.envelope.state.contributionLedger.items[0].outcome,
2510+
'replaced-unconsumed-stale-bootstrap'
2511+
)
2512+
requestCatalogAll(staleBootstrapReplacementFixture, replacementBoot.bootstrap)
2513+
} finally {
2514+
staleBootstrapReplacementFixture.cleanup()
2515+
}
2516+
2517+
const consumedBootstrapCollisionFixture = createSkillRouteFixture({ project: 'consumed-bootstrap-collision' })
2518+
try {
2519+
const consumedEpoch = 'ctx-consumed-bootstrap-collision'
2520+
const consumedBoot = bootstrapSkillRoute({
2521+
project: consumedBootstrapCollisionFixture.project,
2522+
activeRoot: consumedBootstrapCollisionFixture.activeRoot,
2523+
contextEpoch: consumedEpoch,
2524+
prompt: 'Run consumed bootstrap collision probe',
2525+
mode: 'unified',
2526+
cwd: consumedBootstrapCollisionFixture.projectRoot
2527+
}, consumedBootstrapCollisionFixture.runtimeOptions)
2528+
requestCatalogAll(consumedBootstrapCollisionFixture, consumedBoot.bootstrap)
2529+
assert.throws(
2530+
() => bootstrapSkillRoute({
2531+
project: consumedBootstrapCollisionFixture.project,
2532+
activeRoot: consumedBootstrapCollisionFixture.activeRoot,
2533+
contextEpoch: consumedEpoch,
2534+
prompt: 'Run consumed bootstrap collision probe',
2535+
mode: 'unified',
2536+
cwd: consumedBootstrapCollisionFixture.projectRoot,
2537+
runtimeContractDigest: '2'.repeat(64)
2538+
}, consumedBootstrapCollisionFixture.runtimeOptions),
2539+
error => error && error.code === 'BOOTSTRAP_IDENTITY_COLLISION'
2540+
)
2541+
} finally {
2542+
consumedBootstrapCollisionFixture.cleanup()
2543+
}
2544+
24772545
const replayBudgetFixture = createSkillRouteFixture({ project: 'replay-budget' })
24782546
try {
24792547
const replayBoot = bootstrapSkillRoute({

‎scripts/test-task-admission-authority.js‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ const TEST_RUNTIME = {
7474
runtimeDigest: 'a'.repeat(64)
7575
}
7676

77+
function removeReparseEntryNoFollow(entryPath) {
78+
let stat
79+
try {
80+
stat = fs.lstatSync(entryPath)
81+
} catch (error) {
82+
if (error?.code === 'ENOENT') return
83+
throw error
84+
}
85+
if (stat.isSymbolicLink()) {
86+
fs.unlinkSync(entryPath)
87+
return
88+
}
89+
fs.rmSync(entryPath, { recursive: true, force: true })
90+
}
91+
7792
function setupRoot(name) {
7893
const physicalRoot = path.join(TEMP_ROOT, name)
7994
const activeRoot = path.join(physicalRoot, '.devcodex')
@@ -2787,7 +2802,7 @@ try {
27872802
const reparseInput = admissionInput(reparseRoot, 'reparse')
27882803
assert.strictEqual(run(reparseInput).status, 'needs-reconcile')
27892804
assert.strictEqual(fs.readdirSync(outside).length, 0)
2790-
fs.rmSync(path.join(reparseRoot.activeRoot, 'bugs', '任务-reparse'), { recursive: true, force: true })
2805+
removeReparseEntryNoFollow(path.join(reparseRoot.activeRoot, 'bugs', '任务-reparse'))
27912806
const reparseRecovered = run(reparseInput)
27922807
assert.strictEqual(reparseRecovered.phase, 'cp-state-written', 'zero-effect precondition failure must recover after the unsafe path is removed')
27932808

0 commit comments

Comments
 (0)