Skip to content

Commit 3f97543

Browse files
committed
fix: preserve canonical targets across recovery
1 parent 35bbdb2 commit 3f97543

7 files changed

Lines changed: 108 additions & 33 deletions

‎changelogs/releases/v1.20.0.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ v1.20.0 是一次面向长任务可靠性的控制面升级。它让正式任务
2121
- 用户可见响应、报告和进度以人类结论为主;大体量机器证据保留在索引和证据层,不再倾倒到主阅读面。
2222
- 历史 audit-state 按 current、legacy、non-audit、unsupported 和 invalid 分型;历史字节保持不变,当前无效状态仍严格失败。
2323
- Profile 分节读取按同一 source digest 累计全部必需标题,单节命中不再错误标记完整。
24-
- Windows 上任务操作记录复用产物决策层的 canonical 目标集,短路径、真实路径或大小写别名不再造成目标摘要误漂移并阻断合法写入与恢复。
24+
- Windows 上产物决策、任务操作校验与恢复统一使用同一目标身份算法;压缩恢复通过路径表保存封存后的 canonical 目标集,8.3 短路径、真实路径、大小写或词法别名不再造成目标摘要误漂移并阻断合法写入与恢复。
2525
- 验证修复期新增 `RepairConvergenceStateV1`:先冻结完整问题集、批量修复,再统一 affected;独立失败一次收齐,相同绿色候选零执行复用,避免每修一个问题就重跑近全量。
2626
- 验证执行支持有界并发、候选证据缓存、关键路径 ETA 与项目 `.tmp` 临时数据治理;发布、安全、恢复、真实宿主与共享状态节点继续现场验证。
2727

‎hooks/_runtime/artifact-slot-decision.cjs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ const {
88
extractMutationFootprint,
99
validateMutationFootprint
1010
} = require('./mutation-footprint.cjs')
11+
const {
12+
taskOperationTargetSetDigest
13+
} = require('./lifecycle-turn-liveness.cjs')
1114
const {
1215
createArtifactTemplateBinding,
1316
findArtifactTemplateQualification,
@@ -519,7 +522,7 @@ function decideArtifactMutation(input = {}, options = {}) {
519522
operation: footprint.operation,
520523
sourceTargets: footprint.sourceTargets,
521524
targetTargets: footprint.targetTargets,
522-
targetSetDigest: digest(footprint.normalizedTargets),
525+
targetSetDigest: taskOperationTargetSetDigest(footprint.normalizedTargets),
523526
footprintDigest: footprint.footprintDigest,
524527
adapterDigest: footprint.adapterDigest || null,
525528
plannedSetDigest: footprint.plannedSetDigest || null,
@@ -672,7 +675,7 @@ function decideArtifactMutation(input = {}, options = {}) {
672675
operation: footprint.operation,
673676
sourceTargets: sourceCanonical,
674677
targetTargets: targetCanonical,
675-
targetSetDigest: digest([...sourceCanonical, ...targetCanonical].map(comparable).sort()),
678+
targetSetDigest: taskOperationTargetSetDigest([...sourceCanonical, ...targetCanonical]),
676679
footprintDigest: footprint.footprintDigest,
677680
adapterDigest: footprint.adapterDigest,
678681
plannedSetDigest: footprint.plannedSetDigest,
@@ -727,7 +730,7 @@ function validateArtifactSlotDecision(value, binding = null, options = {}) {
727730
}
728731
}
729732
if (Array.isArray(value?.sourceTargets) && Array.isArray(value?.targetTargets) &&
730-
digest([...value.sourceTargets, ...value.targetTargets].map(comparable).sort()) !== value.targetSetDigest) {
733+
taskOperationTargetSetDigest([...value.sourceTargets, ...value.targetTargets]) !== value.targetSetDigest) {
731734
errors.push('artifact-decision-target-set-digest-mismatch')
732735
}
733736
const { decisionDigest, ...semantic } = value || {}
@@ -759,7 +762,7 @@ function reconcileArtifactSlotDecision(decision, input = {}, options = {}) {
759762
: target
760763
const observedSourceTargets = footprint.sourceTargets.map(canonicalObserved)
761764
const observedTargetTargets = footprint.targetTargets.map(canonicalObserved)
762-
const observedTargets = [...observedSourceTargets, ...observedTargetTargets].map(comparable).sort()
765+
const observedTargets = [...observedSourceTargets, ...observedTargetTargets]
763766
const errors = []
764767
if (decision.projectionKind === 'digest-only') {
765768
if (decision.status !== 'active') errors.push(`artifact-decision-not-active:${decision.status}`)
@@ -770,7 +773,7 @@ function reconcileArtifactSlotDecision(decision, input = {}, options = {}) {
770773
if (!validation.valid) errors.push(...validation.errors)
771774
}
772775
if (input.success === false) errors.push('artifact-tool-reported-failure')
773-
if (digest(observedTargets) !== decision.targetSetDigest) errors.push('artifact-post-target-set-drift')
776+
if (taskOperationTargetSetDigest(observedTargets) !== decision.targetSetDigest) errors.push('artifact-post-target-set-drift')
774777
const sourceTargets = Array.isArray(decision.sourceTargets) ? decision.sourceTargets : observedSourceTargets
775778
const targetTargets = Array.isArray(decision.targetTargets) ? decision.targetTargets : observedTargetTargets
776779
const exists = target => {

‎hooks/_runtime/evidence/codex-skill-route-pass.v1.json‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,24 @@
55
"hostVariant": "codex-cli/exec-user-global-local-stdio",
66
"testedVersion": "codex-cli 0.145.0",
77
"protocol": "MCP 2024-11-05",
8-
"runtimeContractDigest": "97e5638c9b666255fab6ca28cff86359ac0ab8ca0872a8735f12657c13601e5e",
8+
"runtimeContractDigest": "071668e4e49b6388d70dbeca26062bc0f6b32edf07cbc83f8ef1b47027071578",
99
"hostAdapterDigest": "8e13dda7dd8f6e38235228bc620829cfac653b7bd0ad4f37f96bf16609e559fc",
10-
"sourceEvidenceDigest": "82683379a269fced1b8eb1b435fc1b7f94257185fd55af96965646dd84dd28f8",
10+
"sourceEvidenceDigest": "c0b3cc6a4a3532042fda26633be3ba3a3bc97c2f219f16521d469a4e8736abfc",
1111
"sourceProbe": {
1212
"schemaVersion": "SkillRouteS15EvidenceV1",
13-
"probeRunId": "s15-codex-probe-698fbe27-4c2a-4e8e-8f37-0fb24d1038ff",
13+
"probeRunId": "s15-codex-probe-123c8110-db0d-46c7-ba09-5c871f0229c1",
1414
"authorizationSource": "isolated-probe-authority",
1515
"contextSource": "host-hooks",
1616
"observationMode": "hook-post-history",
1717
"receiptStatus": "relevant-complete",
18-
"completedAt": "2026-09-06T17:13:58.595Z"
18+
"completedAt": "2026-09-06T18:07:40.605Z"
1919
},
2020
"runtimeBinding": {
2121
"source": "isolated-source-candidate",
22-
"expectedDigest": "97e5638c9b666255fab6ca28cff86359ac0ab8ca0872a8735f12657c13601e5e",
23-
"generationDigest": "97e5638c9b666255fab6ca28cff86359ac0ab8ca0872a8735f12657c13601e5e",
24-
"modeReceiptDigest": "97e5638c9b666255fab6ca28cff86359ac0ab8ca0872a8735f12657c13601e5e",
25-
"routeEnvelopeDigest": "97e5638c9b666255fab6ca28cff86359ac0ab8ca0872a8735f12657c13601e5e"
22+
"expectedDigest": "071668e4e49b6388d70dbeca26062bc0f6b32edf07cbc83f8ef1b47027071578",
23+
"generationDigest": "071668e4e49b6388d70dbeca26062bc0f6b32edf07cbc83f8ef1b47027071578",
24+
"modeReceiptDigest": "071668e4e49b6388d70dbeca26062bc0f6b32edf07cbc83f8ef1b47027071578",
25+
"routeEnvelopeDigest": "071668e4e49b6388d70dbeca26062bc0f6b32edf07cbc83f8ef1b47027071578"
2626
},
2727
"probe": {
2828
"schemaVersion": "SkillRouteProbeSummaryV1",

‎hooks/_runtime/host-skill-route-capabilities.v1.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
"status": "PASS",
1414
"testedVersion": "codex-cli 0.145.0",
1515
"protocol": "MCP 2024-11-05",
16-
"runtimeContractDigest": "97e5638c9b666255fab6ca28cff86359ac0ab8ca0872a8735f12657c13601e5e",
16+
"runtimeContractDigest": "071668e4e49b6388d70dbeca26062bc0f6b32edf07cbc83f8ef1b47027071578",
1717
"hostAdapterDigest": "8e13dda7dd8f6e38235228bc620829cfac653b7bd0ad4f37f96bf16609e559fc",
1818
"evidenceRef": "hooks/_runtime/evidence/codex-skill-route-pass.v1.json",
19-
"evidenceDigest": "c6afb6446d68e02abb9c9569c13f915279d7c4a21bb654b3213884779c6c0db9",
19+
"evidenceDigest": "42d97ad0ef5f71f7e53207f766dcfe47b234877a5ad80c5adcd757fd5ca31c82",
2020
"entrySurface": "codex exec --ephemeral",
2121
"bootstrapDelivery": "stable user-global Hook launcher UserPromptSubmit or profile_context_plan fallback",
2222
"defaultEligible": true

‎hooks/_runtime/task-recovery-store-v5.cjs‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2850,6 +2850,14 @@ function buildMutationRecoveryPreflightV2(
28502850
ownerAuthority = null,
28512851
taskOperationSet = null
28522852
) {
2853+
const sealedOperationTargets = taskOperationSet?.unresolved?.exactTargets || []
2854+
const decisionTargets = [
2855+
...(decision.sourceTargets || []),
2856+
...(decision.targetTargets || [])
2857+
]
2858+
const operationExactTargets = sealedOperationTargets.length
2859+
? sealedOperationTargets
2860+
: (decisionTargets.length ? decisionTargets : footprint.normalizedTargets)
28532861
const roots = [
28542862
boundedRecoveryString(decision.activeRootIdentity?.canonicalPath, 1024),
28552863
boundedRecoveryString(decision.projectRootIdentity?.canonicalPath, 1024)
@@ -2953,15 +2961,21 @@ function buildMutationRecoveryPreflightV2(
29532961
preObservation.observedAt,
29542962
compactRecoveryDigest(preObservation.receiptDigest)
29552963
],
2956-
o: buildTaskOperationRecoveryV1(taskOperationSet, {
2957-
currentOperationId: lease.operationId,
2958-
priorOperationIds: ownerAuthority?.usage?.[4] || [],
2959-
kind: footprint.operation,
2960-
exactTargets: footprint.normalizedTargets,
2961-
targetSetDigest: decision.targetSetDigest,
2962-
beforeDigest: preObservation.snapshotDigest,
2963-
dispatchedAt: lease.issuedAt
2964-
}),
2964+
o: {
2965+
...buildTaskOperationRecoveryV1(taskOperationSet, {
2966+
currentOperationId: lease.operationId,
2967+
priorOperationIds: ownerAuthority?.usage?.[4] || [],
2968+
kind: footprint.operation,
2969+
// TaskOperationRecordV1 seals the canonical target spellings before the
2970+
// compact decision projection drops its path arrays. The footprint may
2971+
// retain an equivalent Windows short-path, case alias, or lexical alias.
2972+
exactTargets: operationExactTargets,
2973+
targetSetDigest: decision.targetSetDigest,
2974+
beforeDigest: preObservation.snapshotDigest,
2975+
dispatchedAt: lease.issuedAt
2976+
}),
2977+
x: encodePaths(operationExactTargets)
2978+
},
29652979
...(ownerAuthority ? { a: ownerAuthority } : {})
29662980
}
29672981
return record
@@ -3113,11 +3127,17 @@ function materializeMutationRecoveryPreflightV2(record) {
31133127
receiptDigest: materializeRecoveryDigest(preValue.receiptDigest)
31143128
}
31153129
const ownerAuthority = compactV2 ? record.a : record.ownerAuthority
3130+
const hasOperationTargetRefs = compactV2 && Object.prototype.hasOwnProperty.call(record.o || {}, 'x')
3131+
const operationExactTargets = hasOperationTargetRefs
3132+
? decodePaths(record.o.x)
3133+
: footprint.normalizedTargets
3134+
if (hasOperationTargetRefs &&
3135+
(!Array.isArray(record.o.x) || operationExactTargets.length !== record.o.x.length)) return null
31163136
const taskOperation = materializeTaskOperationRecoveryV1(compactV2 ? record.o : record.taskOperation, {
31173137
currentOperationId: lease.operationId,
31183138
priorOperationIds: ownerAuthority?.usage?.[4] || [],
31193139
kind: footprint.operation,
3120-
exactTargets: footprint.normalizedTargets,
3140+
exactTargets: operationExactTargets,
31213141
targetSetDigest: decision.targetSetDigest,
31223142
beforeDigest: preObservation.snapshotDigest,
31233143
dispatchedAt: lease.issuedAt

‎scripts/test-artifact-mutation-authority.js‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const {
4545
} = require('../hooks/_runtime/artifact-mutation-reconciliation.cjs')
4646
const { compactLifecycleStateV5 } = require('../hooks/_runtime/lifecycle-state-projection-v5.cjs')
4747
const { sha256, stableStringify } = require('../hooks/_runtime/content-identity.cjs')
48+
const { taskOperationTargetSetDigest } = require('../hooks/_runtime/lifecycle-turn-liveness.cjs')
4849
const {
4950
createArtifactTemplateBinding,
5051
projectArtifactTemplateBinding,
@@ -541,6 +542,18 @@ try {
541542
assert.strictEqual(readOnlyFootprint.coverage, 'not-applicable')
542543
assert.deepStrictEqual(readOnlyFootprint.normalizedTargets, [])
543544

545+
const nonFormalTarget = path.join(path.dirname(tempRoot), `Case-Sensitive-Target-${process.pid}.js`)
546+
const nonFormal = decisionFor({
547+
tool_name: 'Write',
548+
tool_input: { file_path: nonFormalTarget, content: 'module.exports = true\n' }
549+
})
550+
assert.strictEqual(nonFormal.decision.decisionStatus, 'not-applicable')
551+
assert.strictEqual(
552+
nonFormal.decision.targetSetDigest,
553+
taskOperationTargetSetDigest(nonFormal.decision.targetTargets),
554+
'non-formal target digest must use the same Windows path identity as task-operation recovery'
555+
)
556+
544557
for (const fixture of [
545558
{ command: 'npm install', commandClass: 'package-manager' },
546559
{ command: 'openapi-generator generate', commandClass: 'codegen' },
@@ -599,6 +612,13 @@ try {
599612
assert.strictEqual(allowed.decision.slotId, 'bug-cp2')
600613
assert.strictEqual(allowed.decision.slotIds.includes('bug-cp2'), true)
601614
assert.strictEqual(allowed.decision.mergedRegistryDigest, layered.mergedRegistryDigest)
615+
assert.strictEqual(
616+
allowed.decision.targetSetDigest,
617+
taskOperationTargetSetDigest([
618+
...allowed.decision.sourceTargets,
619+
...allowed.decision.targetTargets
620+
])
621+
)
602622
assert.strictEqual(validateArtifactSlotDecision(allowed.decision).valid, true)
603623
const suppliedDecision = decisionFor(
604624
{ tool_name: 'Edit', tool_input: { file_path: cp2 } },

‎scripts/test-task-recovery-store-v5.js‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -295,21 +295,26 @@ function tasklessIngressState() {
295295
return value
296296
}
297297

298-
function mutationStateV2Ephemeral(operationIdOverride = 'ephemeral-mutation-v2', targetCount = 1) {
298+
function mutationStateV2Ephemeral(operationIdOverride = 'ephemeral-mutation-v2', targetCount = 1, fixtureOptions = {}) {
299299
const value = state('00000000-0000-4000-8000-000000000099', 'implementation')
300300
value.taskRecoveryBinding = null
301301
const operationId = operationIdOverride
302-
const targetPaths = targetCount === 1
303-
? [path.join(tempRoot, 'src', 'ephemeral-source.js')]
304-
: Array.from({ length: targetCount }, (_, index) =>
305-
path.join(tempRoot, 'src', `ephemeral-source-${String(index).padStart(3, '0')}.js`))
302+
const targetPaths = Array.isArray(fixtureOptions.targetPaths)
303+
? fixtureOptions.targetPaths
304+
: (targetCount === 1
305+
? [path.join(tempRoot, 'src', 'ephemeral-source.js')]
306+
: Array.from({ length: targetCount }, (_, index) =>
307+
path.join(tempRoot, 'src', `ephemeral-source-${String(index).padStart(3, '0')}.js`)))
308+
const decisionTargetPaths = Array.isArray(fixtureOptions.decisionTargetPaths)
309+
? fixtureOptions.decisionTargetPaths
310+
: targetPaths
306311
const targetPath = targetPaths[0]
307312
const decisionDigest = 'a'.repeat(64)
308313
const footprintDigest = 'b'.repeat(64)
309314
const plannedSetDigest = 'c'.repeat(64)
310315
const adapterDigest = 'd'.repeat(64)
311316
const registryDigest = 'e'.repeat(64)
312-
const operationTargetSetDigest = taskOperationTargetSetDigest(targetPaths)
317+
const operationTargetSetDigest = taskOperationTargetSetDigest(decisionTargetPaths)
313318
value.stickyProject = {
314319
schemaVersion: 'ProjectTargetLeaseV2',
315320
targetDigest: '0'.repeat(64),
@@ -421,6 +426,8 @@ function mutationStateV2Ephemeral(operationIdOverride = 'ephemeral-mutation-v2',
421426
taskRecoveryKey: null,
422427
contextEpoch: 'ctx-test',
423428
operation: 'create-or-update',
429+
sourceTargets: [],
430+
targetTargets: decisionTargetPaths,
424431
targetSetDigest: operationTargetSetDigest,
425432
footprintDigest,
426433
adapterDigest,
@@ -501,7 +508,7 @@ function mutationStateV2Ephemeral(operationIdOverride = 'ephemeral-mutation-v2',
501508
writerGeneration: 0,
502509
expectedStateSequence: 0,
503510
kind: 'create-or-update',
504-
exactTargets: targetPaths,
511+
exactTargets: decisionTargetPaths,
505512
targetSetDigest: value.turnLiveness.inFlightOperation.artifactDecision.targetSetDigest,
506513
beforeDigest: value.turnLiveness.inFlightOperation.mutationPreObservation.snapshotDigest
507514
}, baseOptions)
@@ -1586,6 +1593,31 @@ try {
15861593
taskOperationTargetSetDigest(recoveredExactTargets)
15871594
)
15881595

1596+
const lexicalAliasMeta = path.join(tempRoot, 'ephemeral-preflight-lexical-alias-hooks')
1597+
const lexicalAliasTarget = `${path.join(tempRoot, 'src', 'alias-segment')}${path.sep}..${path.sep}ephemeral-alias.js`
1598+
const canonicalAliasTarget = path.join(tempRoot, 'src', 'ephemeral-alias.js')
1599+
const lexicalAliasState = mutationStateV2Ephemeral(
1600+
'ephemeral-lexical-alias',
1601+
1,
1602+
{ targetPaths: [lexicalAliasTarget], decisionTargetPaths: [canonicalAliasTarget] }
1603+
)
1604+
const lexicalAliasPreflight = commitTaskRecoveryState({
1605+
metaDir: lexicalAliasMeta,
1606+
identity: { activeRoot, project: 'devcodex' },
1607+
sessionKey: 'ephemeral-lexical-alias-session',
1608+
state: lexicalAliasState
1609+
}, { ...baseOptions, reason: 'mutation-preflight', force: true })
1610+
assert.strictEqual(lexicalAliasPreflight.status, 'ephemeral-stub', JSON.stringify(lexicalAliasPreflight))
1611+
const lexicalAliasRead = readTaskRecoveryState({
1612+
metaDir: lexicalAliasMeta,
1613+
sessionKey: 'ephemeral-lexical-alias-session'
1614+
})
1615+
assert.deepStrictEqual(
1616+
lexicalAliasRead.state.turnLiveness.taskOperationSet.unresolved.exactTargets,
1617+
[canonicalAliasTarget],
1618+
'recovery must persist the canonical decision target instead of a lexical footprint alias'
1619+
)
1620+
15891621
const secondUseMeta = path.join(tempRoot, 'ephemeral-preflight-second-use-hooks')
15901622
const priorOperationId = `simple-prior-${'p'.repeat(80)}`
15911623
const currentOperationId = `simple-current-${'c'.repeat(80)}`

0 commit comments

Comments
 (0)