Skip to content

Commit cacaecf

Browse files
committed
fix: persist ContextRead source receipts
1 parent 3bb7d9c commit cacaecf

8 files changed

Lines changed: 347 additions & 15 deletions

File tree

changelogs/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
> **当前**: v1.16.1 已归档到 `changelogs/releases/v1.16.1.md`;本文件仅保留历史锚点索引与镜像供 validate 探针消费。发版后新实现再写入下方「当前未发布」。
55
66
## 当前未发布实现候选
7+
- **ContextRead / SkillRoute receipt 持久化修复(PF-256 / PI-216)**:MCP Profile/memory 正文观察新增 128-slot、单 slot 512KiB 的 `ContextSourceObservationLedgerV1`,以 locked read-modify-write 合并分片/并发 writer;SkillRoute 在 commit/rebind/load_stage/status/Stop 前按 contextEpoch + planId + planContentId + activeRoot + project 精确重放,抵御 Hook/lifecycle 旧快照把 receipt 覆盖回 baseline。重放保留 MCP 证据显式空 hostSessionId,防止同 observationId 被误判语义冲突;source metadata 漂移、`stale/blocked`、lock timeout 和错绑仍 fail closed,未放宽任何 MCP/ContextBinding 参数校验。
78
- **README 常见任务与 CLI 帮助防误写**:公共 README 新增可直接复制的分析、需求确认、Bug 诊断、`@rocky` 自动修复、深度审查、任务续接和发布请求教程;`devcodex help <command>``<command> --help` 现统一进入只读帮助,不再误执行 `init` 或进入子命令解析。`PublicReadmeContractV1` 和 CLI registry 负向探针防止教程或零写入语义回归。
89
- **v1.16.1 已归档**:长会话提示有界化、会话治理状态隔离、项目绑定稳定性与唯一短名解析修复已进入 `changelogs/releases/v1.16.1.md`
910
- **v1.16.0 已归档**:首次初始化、项目 Profile、用户语言与 workspace runtime-state 一致性已进入 `changelogs/releases/v1.16.0.md`

content/duplication-inventory.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schemaVersion": "ControlContentDuplicationInventoryV1",
3-
"sourceBundleDigest": "97fc1e92cc289615b3fd163ca6911281566edf1431ac776e6af2a77ae1d67d30",
3+
"sourceBundleDigest": "88d55041aa3d8af562eb402a58d4b4d71f486fbd7e13e30137f20a225a61c7f2",
44
"thresholds": {
55
"minParagraphChars": 100,
66
"sectionThreshold": 0.94,

content/instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
- 目标唯一后调用 `profile_context_plan` 形成 `ContextReadPlanV2`(兼容读取 V1):baseline 只返回 README/index、effective non-local config 与顶层 metadata inventory;`01~09-*``config.local.json` 和记忆正文必须进入 selected / excluded / unclassified 决策,禁止 hidden full read。
103103
- `ContextReadPlanV2` 必须从已经读取的 effective config 生成身份绑定的 `ExecutionOptimizationPlanBindingV1``profile_load` / `profile_skill_plan` 消费该绑定,禁止为了判断优化模式再次隐式读取 `config.json`;绑定缺失、损坏或未知时必须 fail-closed 到 `full-only`,不得继续 section/bundle 优化。
104104
- 按计划使用 `profile_load(files)``memory_status``memory_session_query``memory_summary_query` 获取最小必要正文;只有与 plan / epoch / target / source 精确关联且被 `PostToolUse` 观察为成功的结果,才能形成 `ContextReadReceiptV2`(兼容 V1)。`PreToolUse` 只代表 attempted,不代表 loaded / verified / complete。
105+
- MCP 本地 stdio 直接交付的 Profile/memory 正文观察必须先进入有界、原子、单调合并的 `ContextSourceObservationLedgerV1`;共享 lifecycle receipt 只是可重建投影。SkillRoute 只能在 contextEpoch / planId / planContentId / activeRoot / project 全部相同且 source metadata 未漂移时重放 ledger;`stale/blocked`、source-digest 或 profile-drift 仍必须 fail closed。
105106
- 全量读取仅在用户 / 项目明确要求、audit / migration 确需全量、低置信无法安全裁剪、或必要真相源缺失且定向升级不足时允许,并记录 `fullReadReason``config.local.json` 仍只在用户或项目明确指定时读取。
106107
- Profile 缺失时 ENV_MODE 默认为 `prod`(保守降级);resume / compact / summary 恢复必须重建 seed 与计划并精确查询当前 handoff、sessions、报告或清单,摘要不能替代文件真相源,但也不构成整目录重读理由。
107108
- 旧 no-args 全量 MCP 工具保留兼容性,不得作为正常生产路径,也不得单独证明上下文完整。

content/skills/ai-agent-system-architecture/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ IntentSeedV1 → unique project/activeRoot → ContextReadPlanV2(V1 兼容)
6060
- `IntentSeedV1` 仅来自当前消息语义和已观察到的 continuity,不得先全文读取 Profile / memory 再“识别”意图;关键词不是 canonical intent。
6161
- `ContextReadPlanV2` 必须显式区分 baseline、selected、excluded、unclassified 与 `fullReadReason`,并把稳定 `planContentId` 与 invocation `planId` 分离;`ContextReadPlanV1` 仅保留读取兼容。默认读取最小充分来源;Profile 规划阶段不得 hidden full read,记忆使用 bounded status/session/summary query。
6262
- `ContextReadReceiptV2` 只接受 planId、planContentId、contextEpoch、activeRoot、source identity/query 和结果精确关联的 `PostToolUse` 成功证据。PreToolUse、计算 cache hit、旧全文工具返回或 fallback 文案都不能声明 complete;V1 receipt 不具备跨 epoch delivery reuse 资格。
63+
- MCP 本地 stdio 正文观察先写入有界 `ContextSourceObservationLedgerV1`,lifecycle receipt 只作可重建投影;SkillRoute 仅在 epoch/plan/root/project 全同且 source metadata 新鲜时重放。旧快照覆盖不得丢失已交付证据,`stale/blocked`、source-digest 或 profile-drift 不得被 ledger 绕过。
6364
- 用户/项目明确要求、audit/migration、低置信或必要来源缺失可升级全量;目标、scope/action/risk、source digest 或 compact/resume 发生实质漂移时重新规划。不得每个动作都重复加载。
6465
- 宿主缺少结构化工具时可走一次 path-observable / instruction fallback;证据不足保持 `partial/unverified`,后续安全、CP、治理和验证门禁不得因节流而降低。
6566

hooks/_runtime/context-source-observation.cjs

Lines changed: 173 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ const {
99
validateContextReadPlan
1010
} = require('./context-read-contract.cjs')
1111
const { readContextPlanObservation } = require('./context-plan-observation.cjs')
12+
const { createRuntimeStateStore } = require('./runtime-state-store.cjs')
13+
14+
const CONTEXT_SOURCE_LEDGER_SCHEMA = 'ContextSourceObservationLedgerV1'
15+
const CONTEXT_SOURCE_LEDGER_SLOT_COUNT = 128
16+
const CONTEXT_SOURCE_LEDGER_MAX_BYTES = 512 * 1024
17+
const CONTEXT_SOURCE_LEDGER_MAX_OBSERVATIONS = 128
1218

1319
const CONTEXT_BINDING_FIELDS = new Set([
1420
'schemaVersion',
@@ -41,6 +47,49 @@ function lifecycleStatePath({ activeRoot, project, workspaceNamespace }) {
4147
)
4248
}
4349

50+
function contextSourceLedgerRelativePath(contextEpoch) {
51+
const digest = stableDigest(String(contextEpoch || '').trim())
52+
const slot = Number.parseInt(digest.slice(0, 8), 16) % CONTEXT_SOURCE_LEDGER_SLOT_COUNT
53+
return path.join(
54+
'context-source-observations',
55+
'v1',
56+
`slot-${String(slot).padStart(3, '0')}.json`
57+
)
58+
}
59+
60+
function contextSourceLedgerStore(target, contextEpoch) {
61+
return createRuntimeStateStore({
62+
activeRoot: target.activeRoot,
63+
project: target.project,
64+
relativePath: contextSourceLedgerRelativePath(contextEpoch),
65+
maxBytes: CONTEXT_SOURCE_LEDGER_MAX_BYTES,
66+
lockWaitMs: 2000,
67+
maxWrites: 0
68+
})
69+
}
70+
71+
function ledgerIdentity(binding, target) {
72+
return {
73+
contextEpoch: binding.contextEpoch,
74+
planId: binding.planId,
75+
planContentId: binding.planContentId,
76+
activeRoot: portableRoot(target.activeRoot),
77+
project: target.project
78+
}
79+
}
80+
81+
function ledgerIdentityMatches(value, binding, target) {
82+
const identity = value?.identity
83+
return !!(
84+
identity &&
85+
identity.contextEpoch === binding.contextEpoch &&
86+
identity.planId === binding.planId &&
87+
identity.planContentId === binding.planContentId &&
88+
comparableRoot(identity.activeRoot) === comparableRoot(target.activeRoot) &&
89+
identity.project === target.project
90+
)
91+
}
92+
4493
function looksLikeWorkspaceNamespaceActiveRoot(activeRoot, project) {
4594
const resolved = path.resolve(String(activeRoot || ''))
4695
const projectName = String(project || '').trim()
@@ -202,7 +251,25 @@ function boundedNumber(value) {
202251
return Number.isFinite(number) && number >= 0 ? number : null
203252
}
204253

205-
function normalizeSourceResult(raw, plan, binding, target, hostSessionId) {
254+
function selectedSourceRefsStillMatch(selected, fsImpl = fs) {
255+
if (selected?.kind === 'memory') return true
256+
const refs = Array.isArray(selected?.sourceRefs) ? selected.sourceRefs : []
257+
if (!refs.length) return false
258+
return refs.every(ref => {
259+
let stat = null
260+
try { stat = fsImpl.statSync(ref.path) } catch { }
261+
const actual = {
262+
path: portableRoot(ref.path),
263+
layer: String(ref.layer || ''),
264+
exists: !!stat?.isFile(),
265+
size: stat?.isFile() ? stat.size : null,
266+
mtimeMs: stat?.isFile() ? stat.mtimeMs : null
267+
}
268+
return stableDigest(actual) === ref.metadataDigest
269+
})
270+
}
271+
272+
function normalizeSourceResult(raw, plan, binding, target, hostSessionId, fsImpl = fs) {
206273
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
207274
const sourceId = String(raw.sourceId || '').trim()
208275
const selected = plan.selectedSources.find(source => source.sourceId === sourceId)
@@ -217,8 +284,16 @@ function normalizeSourceResult(raw, plan, binding, target, hostSessionId) {
217284
bytes: raw.bytes ?? null,
218285
chars: raw.chars ?? null
219286
}))
287+
const effectiveHostSessionId = Object.prototype.hasOwnProperty.call(raw, 'hostSessionId')
288+
? String(raw.hostSessionId || '')
289+
: String(hostSessionId || '')
220290
return {
221-
observationId: String(raw.observationId || `mcp-${stableDigest({ sourceId, resultDigest, contextEpoch: binding.contextEpoch }).slice(0, 20)}`),
291+
observationId: String(raw.observationId || `mcp-${stableDigest({
292+
sourceId,
293+
resultDigest,
294+
contextEpoch: binding.contextEpoch,
295+
hostSessionId: effectiveHostSessionId
296+
}).slice(0, 20)}`),
222297
toolCallId: String(raw.toolCallId || 'mcp-direct'),
223298
sourceId,
224299
contextEpoch: binding.contextEpoch,
@@ -229,20 +304,101 @@ function normalizeSourceResult(raw, plan, binding, target, hostSessionId) {
229304
successful,
230305
observable: raw.observable !== false,
231306
transportSuccess: raw.transportSuccess !== false,
232-
sourceRefsMatch: raw.sourceRefsMatch === true,
307+
sourceRefsMatch: raw.sourceRefsMatch === true && selectedSourceRefsStillMatch(selected, fsImpl),
233308
schemaMatch: raw.schemaMatch !== false,
234309
targetMatch: raw.targetMatch !== false,
235310
resultDigest,
236311
contentIdentity: raw.contentIdentity || null,
237312
bodyObserved,
238-
hostSessionId: String(raw.hostSessionId || hostSessionId || ''),
313+
hostSessionId: effectiveHostSessionId,
239314
bytes: boundedNumber(raw.bytes),
240315
chars: boundedNumber(raw.chars),
241316
hostDeliveredBytes: boundedNumber(raw.hostDeliveredBytes),
242317
cache: raw.cache === true
243318
}
244319
}
245320

321+
function persistContextSourceLedger(target, binding, sourceResults, options = {}) {
322+
const store = contextSourceLedgerStore(target, binding.contextEpoch)
323+
const write = updateJsonLocked(store.filePath, current => {
324+
const reusable = current?.schemaVersion === CONTEXT_SOURCE_LEDGER_SCHEMA &&
325+
ledgerIdentityMatches(current, binding, target)
326+
const observations = reusable && Array.isArray(current.observations)
327+
? current.observations.filter(item => item && typeof item === 'object' && !Array.isArray(item))
328+
: []
329+
const byId = new Map(observations.map(item => [String(item.observationId || ''), item]))
330+
for (const result of sourceResults) {
331+
const observationId = String(result.observationId || '')
332+
const prior = observationId ? byId.get(observationId) : null
333+
if (prior && stableDigest(prior) === stableDigest(result)) continue
334+
if (observationId) byId.set(observationId, result)
335+
}
336+
return {
337+
schemaVersion: CONTEXT_SOURCE_LEDGER_SCHEMA,
338+
identity: ledgerIdentity(binding, target),
339+
observations: [...byId.values()].slice(-CONTEXT_SOURCE_LEDGER_MAX_OBSERVATIONS),
340+
updatedAt: new Date(options.nowMs || Date.now()).toISOString()
341+
}
342+
}, options)
343+
return { ...write, filePath: store.filePath }
344+
}
345+
346+
function readMcpContextSourceObservations(input = {}, options = {}) {
347+
const activeRoot = portableRoot(input.activeRoot)
348+
const project = String(input.project || '').trim()
349+
const target = { activeRoot, project }
350+
if (!activeRoot || !project) return { status: 'skipped', reasonCode: 'target-incomplete', sourceResults: [] }
351+
const binding = normalizeVerifiedBinding(input.contextBinding, target)
352+
if (!binding) return { status: 'skipped', reasonCode: 'binding-unverified', sourceResults: [] }
353+
const plan = input.plan
354+
const validation = validateContextReadPlan(plan)
355+
if (!validation.valid || plan.planId !== binding.planId || plan.planContentId !== binding.planContentId) {
356+
return { status: 'skipped', reasonCode: 'plan-binding-mismatch', sourceResults: [] }
357+
}
358+
const store = contextSourceLedgerStore(target, binding.contextEpoch)
359+
const read = store.read()
360+
if (read.status !== 'fresh') {
361+
return { status: read.status, reasonCode: read.errorCode || 'ledger-unavailable', sourceResults: [], filePath: store.filePath }
362+
}
363+
const ledger = read.value
364+
if (ledger?.schemaVersion !== CONTEXT_SOURCE_LEDGER_SCHEMA || !ledgerIdentityMatches(ledger, binding, target)) {
365+
return { status: 'stale', reasonCode: 'ledger-identity-mismatch', sourceResults: [], filePath: store.filePath }
366+
}
367+
const sourceResults = (Array.isArray(ledger.observations) ? ledger.observations : [])
368+
.map(item => normalizeSourceResult(item, plan, binding, target, input.hostSessionId, options.fs || fs))
369+
.filter(Boolean)
370+
return {
371+
status: 'fresh',
372+
sourceResults,
373+
filePath: store.filePath,
374+
observationCount: sourceResults.length
375+
}
376+
}
377+
378+
function replayMcpContextSourceObservations(receipt, plan, input = {}, options = {}) {
379+
if (!receipt || ['stale', 'blocked'].includes(receipt.status)) {
380+
return { status: 'skipped', reasonCode: `receipt-${receipt?.status || 'missing'}`, receipt }
381+
}
382+
const durable = readMcpContextSourceObservations({ ...input, plan }, options)
383+
if (durable.status !== 'fresh' || !durable.sourceResults.length) {
384+
return { ...durable, receipt }
385+
}
386+
let nextReceipt = receipt
387+
for (const result of durable.sourceResults) {
388+
nextReceipt = recordContextReadOutcome(nextReceipt, plan, result, {
389+
hostSessionId: input.hostSessionId,
390+
nowMs: options.nowMs
391+
})
392+
}
393+
return {
394+
status: 'replayed',
395+
receipt: nextReceipt,
396+
sourceResults: durable.sourceResults,
397+
filePath: durable.filePath,
398+
observationCount: durable.observationCount
399+
}
400+
}
401+
246402
function recordMcpContextSourceObservations(input = {}, options = {}) {
247403
const activeRoot = portableRoot(input.activeRoot)
248404
const project = String(input.project || '').trim()
@@ -273,10 +429,12 @@ function recordMcpContextSourceObservations(input = {}, options = {}) {
273429
return { status: 'skipped', reasonCode: 'plan-observation-invalid', errors: validation.errors }
274430
}
275431
const sourceResults = (Array.isArray(input.sourceResults) ? input.sourceResults : [])
276-
.map(item => normalizeSourceResult(item, observed.plan, binding, target, input.hostSessionId))
432+
.map(item => normalizeSourceResult(item, observed.plan, binding, target, input.hostSessionId, options.fs || fs))
277433
.filter(Boolean)
278434
if (!sourceResults.length) return { status: 'skipped', reasonCode: 'source-results-empty' }
279435

436+
const ledgerWrite = persistContextSourceLedger(target, binding, sourceResults, options)
437+
280438
const statePath = lifecycleStatePath(target)
281439
const write = updateJsonLocked(statePath, lifecycle => {
282440
const acquisition = installObservedPlan(lifecycle, observed.plan, binding, target, input.hostSessionId)
@@ -294,16 +452,25 @@ function recordMcpContextSourceObservations(input = {}, options = {}) {
294452

295453
const refreshed = readJson(statePath, options.fs || fs)
296454
const receipt = refreshed.contextAcquisition?.receipt || null
455+
const durable = ledgerWrite.status === 'persisted'
297456
return {
298-
status: 'persisted',
457+
status: durable ? 'persisted' : 'degraded',
458+
...(durable ? {} : { errorCode: ledgerWrite.errorCode || 'CONTEXT_SOURCE_LEDGER_NOT_PERSISTED' }),
299459
statePath,
460+
ledgerPath: ledgerWrite.filePath,
461+
ledgerStatus: ledgerWrite.status,
462+
lifecycleStatus: 'persisted',
300463
satisfiedSourceIds: Array.isArray(receipt?.satisfiedSourceIds) ? receipt.satisfiedSourceIds : [],
301464
missingSourceIds: Array.isArray(receipt?.missingSourceIds) ? receipt.missingSourceIds : [],
302465
receiptStatus: receipt?.status || 'unknown'
303466
}
304467
}
305468

306469
module.exports = {
470+
CONTEXT_SOURCE_LEDGER_SCHEMA,
471+
contextSourceLedgerRelativePath,
307472
lifecycleStatePath,
473+
readMcpContextSourceObservations,
474+
replayMcpContextSourceObservations,
308475
recordMcpContextSourceObservations
309476
}

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

Lines changed: 3 additions & 3 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": "86d0c12d9ccc65a127e95b4405b3ae1dacb8a5722333c8c46e756e0afe499911",
16+
"runtimeContractDigest": "2453a7ee9024ec046188edbe7e0bb2354f7dbc64af074a27f1fb7de0693884b7",
1717
"hostAdapterDigest": "39f18f77c0060b14b1b005d5a30b63ef33fe419cb27ed26790bfc60e61e43fe6",
18-
"evidenceDigest": "5757cc0f0789efd79abb8b80c7587a74f572e31b66849ce007bd88e9ff6afeb2",
19-
"evidenceRef": "E:/Worker/devcodex/.audit-state/skill-route-v161-codex-candidate-20260803.json",
18+
"evidenceDigest": "16906258d08fa8074f1a2a32e02ab3e562e72603bb59f53da2da379e4f499018",
19+
"evidenceRef": "E:/Worker/devcodex/.audit-state/skill-route-v162-codex-candidate-r2-20260803.json",
2020
"entrySurface": "codex exec --ephemeral",
2121
"bootstrapDelivery": "user-global hooks.json UserPromptSubmit",
2222
"defaultEligible": true

hooks/_runtime/skill-route-tool.cjs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const {
3737
getRuntimeContractDigest,
3838
resolveSkillRouteMode
3939
} = require('./skill-route-mode.cjs')
40+
const { replayMcpContextSourceObservations } = require('./context-source-observation.cjs')
4041
const {
4142
bootstrapSkillRoute,
4243
collectExpiredTurns,
@@ -184,8 +185,10 @@ function tryRebindLifecycleFromPlanObservation (binding, target, lifecycle, opti
184185
receipt.planId === binding.planId &&
185186
receipt.planContentId === binding.planContentId
186187
)
187-
if (identityMatches && ACCEPTED_CONTEXT_RECEIPT_STATUSES.has(receipt.status)) {
188-
return { rebound: false, lifecycle }
188+
if (identityMatches) {
189+
if (['stale', 'blocked'].includes(receipt.status) || ACCEPTED_CONTEXT_RECEIPT_STATUSES.has(receipt.status)) {
190+
return { rebound: false, lifecycle }
191+
}
189192
}
190193

191194
let readContextPlanObservation
@@ -340,7 +343,25 @@ function validateTrustedContextBinding (binding, target, options = {}) {
340343
lifecycle = rebound.lifecycle
341344
const acquisition = lifecycle?.contextAcquisition
342345
const plan = acquisition?.plan
343-
const receipt = acquisition?.receipt
346+
let receipt = acquisition?.receipt
347+
if (plan && receipt && !['stale', 'blocked'].includes(receipt.status)) {
348+
const replayed = replayMcpContextSourceObservations(receipt, plan, {
349+
activeRoot: target.activeRoot,
350+
project: target.project,
351+
contextBinding: binding,
352+
hostSessionId: acquisition.hostSessionId
353+
}, options)
354+
if (replayed.status === 'replayed') {
355+
receipt = replayed.receipt
356+
acquisition.receipt = receipt
357+
lifecycle.contextAcquisition = acquisition
358+
try {
359+
writeJsonAtomic(statePath, lifecycle, options.fs || fs)
360+
} catch {
361+
// The durable source ledger remains authoritative recovery evidence.
362+
}
363+
}
364+
}
344365
if (!plan || !receipt) {
345366
throw contextBindingErrorForSkillRoute(
346367
'CONTEXT_BINDING_PENDING',

0 commit comments

Comments
 (0)