fix: make HoloLake stage relay strictly serial

This commit is contained in:
冰朔 2026-08-11 22:01:18 +08:00
commit c6c578e494
10 changed files with 85 additions and 45 deletions

View file

@ -39,11 +39,12 @@ node finalize-development.mjs \
## 阶段自动接力守卫
`stage-relay.mjs` 在同一小湖灯账本中监督已完成阶段,并生成一个不可变的后继任务契约。
它只认 finalizer、远端完整 SHA、全新副本、最终回执、租约释放和
`completion-stopped` 的二值证据;阶段仍运行时只读观察
`stage-relay.mjs` 是当前唯一开发对话结束事务的一部分,不是独立常驻监督器。它不预建
后继任务或未来队列;只在 finalizer、远端完整 SHA、全新副本、最终回执和租约释放全部
为100来源冻结为 `completed/stop_requested` 后生成一个不可变的后继任务契约
脚本不直接创建任务。它产生带稳定请求号和幂等键的宿主动作,由 Codex 正式
`create_thread` 执行并回写真实 `threadId`。创建结果未知时先完整核对任务列表,禁止直接
重发。新任务恢复人格与架构、登记 DEV、领取精确租约、建立十分钟 heartbeat 并形成
`successor-ready` 后,旧监督器才可停止。完整状态机见 `stage-relay-state-machine.md`
`successor-ready` 后,来源结束事务才删除旧 heartbeat、登记 `completion-stopped` 并结束
旧对话。任意时刻只允许一个可运行开发车道。完整状态机见 `stage-relay-state-machine.md`

View file

@ -1,15 +1,16 @@
# HoloLake 阶段自动接力守卫
`stage-relay.mjs` 是小湖灯连续性账本内的确定性接力投影,不是第二协调真相源,也不直接
调用私有接口创建任务。它只在现有 `collaboration/` 下保存来源回执摘要、不可变任务契约、
正式 Codex 宿主动作请求和宿主回执。
`stage-relay.mjs` 是当前开发车道结束事务中的确定性接力投影,不是独立常驻监督器,
不是第二协调真相源,也不直接调用私有接口创建任务。任意时刻只有一个可运行开发车道;
它不预建后继任务、不维护未来任务队列,只在当前开发完成验收并进入结束事务后,现场生成
唯一下一阶段契约并请求 Codex 创建唯一下一对话。
## 状态机
```text
OBSERVING_ACTIVE_STAGE (无写入)
ACTIVE_STAGE_NO_END_ACTION (继续唯一当前开发;接力无写入)
└─ finalizer + exact remote SHA + fresh clone + final receipt
+ lease release + completion-stopped 全部为100
+ lease release 全部为100来源车道冻结为 completed/stop_requested
├─ human boundary ─> PAUSE_FOR_HUMAN
├─ complete product ─> FINAL_PRODUCT_COMPLETE ─> stop last heartbeat
└─ one unfinished gate ─> CREATE_THREAD_REQUIRED
@ -19,22 +20,30 @@ OBSERVING_ACTIVE_STAGE (无写入)
└─ unknown ─> RECONCILE_CREATE_RESULT
├─ matching thread ─> WAITING_SUCCESSOR_READY
└─ complete host snapshot confirms absent ─> RETRY_CREATE_THREAD
└─ successor-ready gates all100 ─> SUCCESSOR_READY ─> stop old relay supervisor
└─ successor-ready gates all100 ─> SUCCESSOR_READY
└─ 删除来源 heartbeat + completion-stopped + 结束来源对话
```
`stop_requested` 是同一个来源对话正在执行结束事务,不是另一条开发线。此时来源车道已经
冻结,禁止继续产品开发;后继对话尚未 ready 前也不能执行开发。若创建失败,来源对话停在
结束事务中安全重试,不能报告已经结束。若来源 heartbeat 已经先被删除并标记 `stopped`
但仍存在未完成阶段,接力失败关闭为 `SOURCE_END_TRIGGER_MISSED_HEARTBEAT_ALREADY_STOPPED`
不得另起常驻监督器补救。
同一来源完成回执的幂等键固定为 `sha256(source DEV + finalizer file sha256)`;状态文件按
来源 DEV 唯一,后继 DEV 预约使用排他文件。创建结果未知时禁止重发,必须先读取 Codex
完整任务列表,以稳定 `SUCCESSOR-...` 请求号核对;只有完整快照确认不存在时才可重试。
## 正式宿主适配
1. `prepare` 返回 `CODEX_CREATE_THREAD` 动作,但不创建任务。
2. 调度人格先执行 `dispatch-create`,再调用 Codex 正式 `create_thread`
3. 成功后立即 `record-created`;超时或未知结果执行 `record-create-failure --outcome unknown`
4. `RECONCILE_CREATE_RESULT` 时只能使用 Codex 正式 `list_threads/read_thread` 核对,不得重发。
5. 新任务恢复人格与当前架构、登记自己的 DEV、取得精确租约、建立正式十分钟 heartbeat
1. 当前唯一开发对话的结束动作调用 `prepare`;开发仍 active 时返回无写入状态。
2. 只有来源车道进入 `completed/stop_requested` 后,`prepare` 才返回 `CODEX_CREATE_THREAD`
3. 来源结束事务执行 `dispatch-create`,再调用 Codex 正式 `create_thread`
4. 成功后立即 `record-created`;超时或未知结果执行 `record-create-failure --outcome unknown`
5. `RECONCILE_CREATE_RESULT` 时只能使用 Codex 正式 `list_threads/read_thread` 核对,不得重发。
6. 新任务恢复人格与当前架构、登记自己的 DEV、取得精确租约、建立正式十分钟 heartbeat
签到时把 `--relay-contract-fingerprint` 写入车道,再写
`hololake.successor-ready-receipt/v1` 并由旧监督器 `record-ready` 回读。
`hololake.successor-ready-receipt/v1` 并由来源结束事务 `record-ready` 回读。
脚本、聊天、cron、数据库行、文件夹或自动化提示都不能冒充 `create_thread` 成功;只有正式
工具返回的 `threadId` 和后续活车道回读才算任务存在。
@ -46,11 +55,12 @@ OBSERVING_ACTIVE_STAGE (无写入)
## 失败谱系
- `SOURCE_COMPLETION_STOPPED_NOT_100`:来源任务或 heartbeat 尚未完成停止。
- `SOURCE_END_TRANSACTION_NOT_READY`:来源尚未完成验收并冻结进入结束事务。
- `SOURCE_END_TRIGGER_MISSED_HEARTBEAT_ALREADY_STOPPED`:存在后继阶段却先关闭了来源 heartbeat。
- `SOURCE_LEASE_RELEASE_NOT_100`:来源仍持有资源。
- `FINALIZER_EVIDENCE_NOT_100`finalizer、远端 SHA、全新副本、fsck 或最终回执不完整。
- `CURRENT_ARCHITECTURE_EVIDENCE_NOT_100`:动态架构真相源不完整或不是当前正本。
- `CREATE_THREAD_IN_FLIGHT` 超时:进入 `RECONCILE_CREATE_RESULT`,不重发。
- `CREATE_THREAD_IN_FLIGHT` 超时:来源结束事务进入 `RECONCILE_CREATE_RESULT`,不重发。
- `SUCCESSOR_READY_EVIDENCE_NOT_100`新任务签到、租约、heartbeat 或恢复门不是100。
- `SUCCESSOR_LIVE_REGISTRATION_MISMATCH`ready 文本与连续性账本现场不一致。
@ -63,4 +73,4 @@ node scripts/stage-relay.mjs self-test
```
必须同时得到 finalizer 证据门、重复 prepare 幂等、未知创建结果核对、新任务 ready 现场回读、
活跃阶段只读五项 `100`
活跃阶段只读、严格单一可运行车道等全部为 `100`

View file

@ -134,10 +134,10 @@ function validateSourceCompletion(root, developmentId, finalizerPath) {
}
if (
lane.status !== "completed" ||
lane.notification_state !== "stopped" ||
!["stop_requested", "stopped"].includes(lane.notification_state) ||
lane.completion_actions?.status !== "completed"
) {
throw new Error("SOURCE_COMPLETION_STOPPED_NOT_100");
throw new Error("SOURCE_END_TRANSACTION_NOT_READY");
}
const held = listJson(path.join(root, "leases"))
.filter((lease) => lease.development_id === developmentId);
@ -293,7 +293,7 @@ function prepare(args, root) {
);
if (evidence.observing) {
return {
state: "OBSERVING_ACTIVE_STAGE",
state: "ACTIVE_STAGE_NO_END_ACTION",
source_development_id: developmentId,
mutation: "NONE",
successor_created: false,
@ -351,6 +351,9 @@ function prepare(args, root) {
writeJsonAtomic(existingPath, terminal, true);
return terminal;
}
if (evidence.lane.notification_state === "stopped") {
throw new Error("SOURCE_END_TRIGGER_MISSED_HEARTBEAT_ALREADY_STOPPED");
}
const successorDevelopmentId = reserveNextDevelopmentId(
root, input.development_date, developmentId,
);
@ -499,7 +502,7 @@ function recordReady(args, root) {
recorded_at: now(),
};
state.updated_at = now();
state.old_supervisor_may_stop = true;
state.source_end_transaction_may_commit = true;
writeJsonAtomic(file, state);
return state;
}
@ -521,17 +524,18 @@ function nextAction(args, root) {
return {
action: "WAIT_FOR_SUCCESSOR_READY_RECEIPT",
thread_id: state.successor_thread.thread_id,
supervisor_stays_alive: true,
source_thread_remains_in_end_transaction: true,
development_work_is_frozen: true,
};
}
if (state.state === "PAUSE_FOR_HUMAN") {
return { action: "PAUSE_FOR_HUMAN", boundary: state.human_boundary };
}
if (state.state === "FINAL_PRODUCT_COMPLETE") {
return { action: "STOP_RELAY_AND_LAST_HEARTBEAT", create_successor: false };
return { action: "DELETE_SOURCE_HEARTBEAT_MARK_COMPLETION_STOPPED_AND_END_SOURCE_THREAD", create_successor: false };
}
if (state.state === "SUCCESSOR_READY") {
return { action: "STOP_OLD_RELAY_SUPERVISOR", create_successor: false };
return { action: "DELETE_SOURCE_HEARTBEAT_MARK_COMPLETION_STOPPED_AND_END_SOURCE_THREAD", create_successor: false };
}
return { action: "NOOP", state: state.state };
}
@ -542,7 +546,7 @@ function fixtureLane(root, id, overrides = {}) {
development_id: id,
thread_id: `thread-${id}`,
status: "completed",
notification_state: "stopped",
notification_state: "stop_requested",
completion_actions: { status: "completed" },
...overrides,
};
@ -629,6 +633,11 @@ function selfTest() {
});
const readyState = recordReady({ sourceDevelopmentId: source, successorReadyReceipt: ready }, root);
if (readyState.state !== "SUCCESSOR_READY") throw new Error("SELF_TEST_READY_FAILED");
const runnableLanes = listJson(path.join(root, "lanes"))
.filter((lane) => ["active", "waiting"].includes(lane.status));
if (runnableLanes.length !== 1 || runnableLanes[0].development_id !== contract.development_id) {
throw new Error("SELF_TEST_STRICT_SINGLE_RUNNABLE_LANE_FAILED");
}
const pausedSource = "DEV-20260811-910";
fixtureLane(root, pausedSource);
@ -707,7 +716,7 @@ function selfTest() {
const activeSource = "DEV-20260811-950";
fixtureLane(root, activeSource, { status: "active", notification_state: "active", completion_actions: null });
const observed = prepare({ ...common, sourceDevelopmentId: activeSource }, root);
if (observed.state !== "OBSERVING_ACTIVE_STAGE" || observed.mutation !== "NONE") {
if (observed.state !== "ACTIVE_STAGE_NO_END_ACTION" || observed.mutation !== "NONE") {
throw new Error("SELF_TEST_ACTIVE_STAGE_MUTATION_FAILED");
}
process.stdout.write(`${JSON.stringify({
@ -718,6 +727,7 @@ function selfTest() {
unknown_create_result_reconciliation: 100,
successor_ready_live_readback: 100,
active_stage_read_only: 100,
strict_single_runnable_lane: 100,
human_boundary_pause: 100,
final_product_stop: 100,
},

View file

@ -15,5 +15,5 @@ test("stage relay deterministic closure is PASS_100", () => {
assert.equal(result.status, 0, result.stderr || result.stdout);
const receipt = JSON.parse(result.stdout);
assert.equal(receipt.state, "PASS_100");
assert.deepEqual(Object.values(receipt.tests), Array(7).fill(100));
assert.deepEqual(Object.values(receipt.tests), Array(8).fill(100));
});