Skip to content

Commit d1acd53

Browse files
authored
fix(sdk): reserved-leaf path for runtime-key primitives so prefix keys don't collide (#86)
with_lock/once/cache_* wrote their backing file at the key path, so prefix-related keys (a/b/c and a/b/c/d) collided with ENOTDIR — permanently dead-lettering consensus convergence (found via dogfood #614). Backing files now live at <base>/<key>/=lock|=mark|=value (= is forbidden in keys). with_lock also reserves the cache namespace. verify.sh green. ⟦AI:FKST⟧
1 parent d7ff783 commit d1acd53

12 files changed

Lines changed: 197 additions & 37 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,11 @@ Lua 单元测试由 `fkst-framework test` 执行。runner 只发现 package root
141141

142142
`fkst.test.mock_command(pattern, result)` 劫持 test mode 中的 `exec_sync`、codex SDK 与 git SDK 外部命令调用;渲染命令行按前缀或子串匹配,mock 按注册顺序一次性消费。`result``{ stdout = "", stderr = "", exit_code = 0 }` 形状,`stderr``exit_code` 可省略。未 mock 的外部命令 fail closed 且不启动真实进程。`fkst.test.command_calls()` 返回已记录调用,包含渲染命令、program、args、stdin、stdout、stderr 与 exit_code。`setup_worktree` 在 test mode 也通过 git mock runner,但 mock 不合成 worktree 副作用。
143143

144-
production Lua SDK 包含 `once(key, fn) -> boolean`。它是 best-effort per-key de-bounce scratch marker,不是 durable state。`key` 必须是非空相对 filesystem path,`/` 表示目录;每个 segment 非空、匹配 `[A-Za-z0-9._-]+`,且不是 `.``..`;禁止 leading / trailing `/``//`、反斜杠、NUL 与绝对路径。framework 直接使用校验后的 key,在 `<RT>/locks/once/<key>` 上获取 exclusive flock,再检查 `<RT>/marks/<key>``locks/once/` 是 once 内部锁的保留子目录,不属于 `with_lock` 用户锁命名空间。marker 已存在时返回 `false` 且不调用 `fn`;marker 不存在时调用 `fn`,成功后写入 marker 并返回 `true``fn` 失败时错误原样传播且不写 marker,后续调用会重试。
144+
production Lua SDK 包含 `once(key, fn) -> boolean`。它是 best-effort per-key de-bounce scratch marker,不是 durable state。`key` 必须是非空相对 filesystem path,`/` 表示目录;每个 segment 非空、匹配 `[A-Za-z0-9._-]+`,且不是 `.``..`;禁止 leading / trailing `/``//`、反斜杠、NUL 与绝对路径。framework 直接使用校验后的 key,在 `<RT>/locks/once/<key>/=lock` 上获取 exclusive flock,再检查 `<RT>/marks/<key>/=mark``locks/once/` 是 once 内部锁的保留子目录,不属于 `with_lock` 用户锁命名空间。marker 已存在时返回 `false` 且不调用 `fn`;marker 不存在时调用 `fn`,成功后写入 marker 并返回 `true``fn` 失败时错误原样传播且不写 marker,后续调用会重试。
145145

146-
`once` 的可观察性来自 engine log 和 runtime scratch:skip / run 决策会写入可 grep 的 `once decision=... key=...` 结构化日志;marker 内容只提供 `key``marked_at` 的人工可读提示,不参与判重;LIVE lock holder 可用 `lsof <RT>/locks/once/<key>` 查看。
146+
`once` 的可观察性来自 engine log 和 runtime scratch:skip / run 决策会写入可 grep 的 `once decision=... key=...` 结构化日志;marker 内容只提供 `key``marked_at` 的人工可读提示,不参与判重;LIVE lock holder 可用 `lsof <RT>/locks/once/<key>/=lock` 查看。
147147

148-
production Lua SDK 还包含 `cache_set(key, value[, ttl_seconds])``cache_get(key) -> string | nil``cache_expire(key)`。它们是 best-effort scratch KV primitive,不是 durable state。`key` 使用同一 runtime key 合约,framework 直接读写 `<RT>/cache/<key>`,所以 `<RT>/cache` 是人工可浏览的目录树;`cache_set` 原子覆盖写入带 expiry metadata 的 string value,`ttl_seconds` 缺省或 nil 表示不过期,正数表示按 wall-clock deadline 过期;`cache_get` 命中时返回 string,缺失、过期、malformed 或 unreadable 时返回 nil,过期文件会 best-effort lazy evict;`cache_expire` 显式删除 key,缺失视为成功。`<RT>` 被清空或换 host 后,`cache_get` 返回 nil,调用者必须从 durable source 重新推导;需要 read-compare-write 原子性时由调用者外层使用 `with_lock`
148+
production Lua SDK 还包含 `cache_set(key, value[, ttl_seconds])``cache_get(key) -> string | nil``cache_expire(key)`。它们是 best-effort scratch KV primitive,不是 durable state。`key` 使用同一 runtime key 合约,framework 读写 `<RT>/cache/<key>/=value`,所以 `<RT>/cache` 是人工可浏览的目录树;reserved leaf `=value` 不会与有效 key 冲突,因为 `=` 不在合法 key segment 字符集内。`cache_set` 原子覆盖写入带 expiry metadata 的 string value,`ttl_seconds` 缺省或 nil 表示不过期,正数表示按 wall-clock deadline 过期;`cache_get` 命中时返回 string,缺失、过期、malformed 或 unreadable 时返回 nil,过期文件会 best-effort lazy evict;`cache_expire` 显式删除 key,缺失视为成功。`<RT>` 被清空或换 host 后,`cache_get` 返回 nil,调用者必须从 durable source 重新推导;需要 read-compare-write 原子性时由调用者外层使用 `with_lock`
149149

150150
production Lua SDK 包含 `graph_json() -> string`。它是显式授权的只读 composed graph introspection:只有当前 Department 的 `M.spec.graph_json = true` 时可调用。调用时按当前 fixed package roots input set 与 host root 重新扫描并验证 graph,返回稳定排序的 `fkst.graph.v1` JSON string,供 package/host 渲染 topology view。输出包含 raiser / queue / department nodes、消息流 edges、department `consumes` / `produces` / `ephemeral` / `stall_window` / materialized `retry` metadata;node `id` 与 edge endpoint 使用 `kind:canonical_name` 形态;不包含 runtime state、queue capacity、`lua` path 或 `owner_root`
151151

SPEC.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@
7373
- 固定 Lua SDK surface 锚点是 `fixed-lua-sdk-surface`;允许 surface 是 `pipeline``source``raise``spawn_codex_sync``spawn_codex``fkst.codex_runs``exec_sync``await_all``with_lock``once``cache_set``cache_get``cache_expire``graph_json``t``git_log_count``git_log_grep``count_worktrees``list_orphan_worktrees``setup_worktree``file``json.decode``log.info``log.warn``log.error``now`
7474
- `t(key[, vars])` is the fixed key-catalog localization primitive. It reads owner-root `locales/<locale>.lua` and `locales/en.lua`, resolves `<locale>` from `FKST_OUTPUT_LANG`, falls back to `en` for missing locale or key with a structured warning, and interpolates `{name}` placeholders from scalar vars. Catalog files are flat Lua tables with stable string keys and literal UTF-8 string values. `en` is the reference locale; conformance requires every non-`en` catalog to cover all `en` keys, rejects decode-helper-hidden literals in `locales/`, and rejects machine protocol tokens in catalog keys or values. Locale catalogs are the sanctioned exception to the source-files-English rule for prose literals; machine tokens remain code and must not be catalog content.
7575
- `graph_json() -> string` 是显式授权的只读 introspection surface;只有当前 Department 的 `M.spec.graph_json = true` 时可调用。它按当前 fixed package roots input set 与 host root 重新扫描并验证 composed graph,返回稳定 JSON 字符串,schema 为 `fkst.graph.v1`。输出只包含 topology fact:raiser / queue / department nodes、raiser→queue / queue→department / department→queue edges,以及 department 的 `consumes``produces``ephemeral``stall_window` 和 materialized `retry` metadata;node `id` 与 edge endpoint 使用 `kind:canonical_name` 形态以区分同名 raiser / queue / department;不输出 `lua` path、`owner_root`、queue capacity 或 runtime state。排序必须确定性。
76-
- `with_lock(name, fn)``once(key, fn)``cache_set(key, value[, ttl_seconds])``cache_get(key)``cache_expire(key)` 共用 runtime key 合约:key / name 必须是非空相对 filesystem path,`/` 表示目录;每个 segment 非空、最长 255 bytes、匹配 `[A-Za-z0-9._-]+`,且不是全点 segment(如 `.` / `..` / `...`);禁止 leading / trailing `/``//`、反斜杠、NUL 与绝对路径。校验后的 key 可直接 join 到 `<RT>/{locks,marks,cache}/<key>`,形成可人工浏览的目录树,不再做 byte hex 编码
77-
- `once(key, fn)``<RT>/locks/once/<key>` 上持有 exclusive flock 后检查 `<RT>/marks/<key>``locks/once/` 是 once 内部锁的保留子目录,不属于 `with_lock` 用户锁命名空间。marker 已存在时返回 `false` 且不调用 `fn`,不存在时调用 `fn`,成功后写入 marker 并返回 `true`,失败时传播错误且不写 marker。
78-
- `cache_set(key, value[, ttl_seconds])``cache_get(key)``cache_expire(key)` 读写 `<RT>/cache/<key>``cache_set` 原子覆盖写入 byte-exact string value 与可选 expiry metadata;`ttl_seconds` 缺省或 nil 表示不过期,正数表示按 wall-clock deadline 过期。`cache_get` 命中时返回 string,缺失、过期、malformed 或 unreadable 时返回 nil,过期文件会 best-effort lazy evict;`cache_expire` 显式删除 key,缺失视为成功。cache 是 host-local best-effort scratch,不是 durable state;调用者需要 read-compare-write 原子性时必须外层使用 `with_lock`
76+
- `with_lock(name, fn)``once(key, fn)``cache_set(key, value[, ttl_seconds])``cache_get(key)``cache_expire(key)` 共用 runtime key 合约:key / name 必须是非空相对 filesystem path,`/` 表示目录;每个 segment 非空、最长 255 bytes、匹配 `[A-Za-z0-9._-]+`,且不是全点 segment(如 `.` / `..` / `...`);禁止 leading / trailing `/``//`、反斜杠、NUL 与绝对路径。校验后的 key 保持为 `<RT>/{locks,marks,cache}/<key>/` 目录路径,engine 在该目录下写 reserved leaf file(`=lock` / `=mark` / `=value`);`=` 不在合法 key segment 字符集内,因此不会与有效 key 冲突
77+
- `with_lock(name, fn)``<RT>/locks/<name>/=lock` 上持有 exclusive flock;`once(key, fn)``<RT>/locks/once/<key>/=lock` 上持有 exclusive flock 后检查 `<RT>/marks/<key>/=mark``locks/once/` 是 once 内部锁的保留子目录,不属于 `with_lock` 用户锁命名空间。marker 已存在时返回 `false` 且不调用 `fn`,不存在时调用 `fn`,成功后写入 marker 并返回 `true`,失败时传播错误且不写 marker。
78+
- `cache_set(key, value[, ttl_seconds])``cache_get(key)``cache_expire(key)` 读写 `<RT>/cache/<key>/=value``cache_set` 原子覆盖写入 byte-exact string value 与可选 expiry metadata;`ttl_seconds` 缺省或 nil 表示不过期,正数表示按 wall-clock deadline 过期。`cache_get` 命中时返回 string,缺失、过期、malformed 或 unreadable 时返回 nil,过期文件会 best-effort lazy evict;`cache_expire` 显式删除 key,缺失视为成功。cache 是 host-local best-effort scratch,不是 durable state;调用者需要 read-compare-write 原子性时必须外层使用 `with_lock`
7979
- Department 默认以可靠方式消费队列;`M.spec.ephemeral = {"queue"}` 可将本 Department 对指定 consumed queue 的订阅降级为非可靠。`M.spec.retry = false` 只表示失败不重试;`M.spec.retry = { ... }` 可覆盖 `max_attempts`、`base`、`cap` 的任意子集,缺失字段从全局默认补齐。`M.spec.stall_window` 是可靠投递 lease 与续租窗口,不是 framework child 无输出 kill deadline。可靠订阅启动必须有 `FKST_DURABLE_ROOT`,缺失 fail-closed。可靠 source event 必须带 `SourceRef{kind,reference}`;cron 由 raiser 名派生,file_watch 由绝对路径派生,Department `RAISED` 进入可靠 queue 时继承上游 source_ref,缺失则 publish fail-closed 且上游 delivery 不 ack。可靠 consumer 由 Fanout wake + 定时 tick 调用 redb store `lease`,spawn framework 后仅在 exit 0 且 RAISED publish 成功时 `ack`;非零退出、codex timeout、spawn error 或 RAISED publish 失败调用 `retry`,到 max attempts 写 redb dead 表并 best-effort publish `dead_letter`。当前 delivery 来自 `dead_letter` 时抑制再次发送 `dead_letter`。该机制不是新 source kind,不提供 exactly-once;语义是 at-least-once-until-ack,`Fanout::send` 在可靠路径只作进程内唤醒。
8080
- `spawn_codex_sync``spawn_codex` 接受 `timeout` opt,默认 3600 秒,作为 codex 子进程整体 wall-clock cap;stdout/stderr 输出只被捕获,不延长 timeout。`spawn_codex` handle 只能由 `await_all` join;单 handle 等待使用 `await_all({handle})`;first-result fanout 与 sleep timer 不是固定 Lua SDK surface。
8181
- `fkst.codex_runs()` is a read-only bounded observability surface over engine codex run records. It returns running and recent codex runs with `role`, `started_at`, `status` (`running` / `done` / `failed`), bounded `output_tail`, and optional `exit_code`; it does not expose runtime paths or unbounded stdout/stderr.

crates/fkst-common/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@ pub use config::Config;
1111
pub use durable_layout::{DurableLayout, DURABLE_ROOT_ENV};
1212
pub use error::FkstError;
1313
pub use event::Event;
14-
pub use runtime_layout::{RuntimeKind, RuntimeLayout};
14+
pub use runtime_layout::{
15+
runtime_key_file, RuntimeKind, RuntimeLayout, RUNTIME_LOCK_LEAF, RUNTIME_MARK_LEAF,
16+
RUNTIME_VALUE_LEAF,
17+
};
1518
pub use validation::validate_runtime_key;

crates/fkst-common/src/runtime_layout.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ use anyhow::{anyhow, Result};
44
use std::path::{Component, Path, PathBuf};
55

66
pub const RUNTIME_ROOT_ENV: &str = "FKST_RUNTIME_ROOT";
7+
pub const RUNTIME_LOCK_LEAF: &str = "=lock";
8+
pub const RUNTIME_MARK_LEAF: &str = "=mark";
9+
pub const RUNTIME_VALUE_LEAF: &str = "=value";
710

811
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
912
// runtime path categories are explicit and bounded before path construction.
@@ -62,6 +65,10 @@ impl RuntimeLayout {
6265
}
6366
}
6467

68+
pub fn runtime_key_file(base: &Path, key: &str, leaf: &str) -> PathBuf {
69+
base.join(key).join(leaf)
70+
}
71+
6572
fn reject_traversal(path: &Path) -> Result<()> {
6673
if path.as_os_str().is_empty() {
6774
return Err(anyhow!("runtime root must not be empty"));

crates/fkst-framework/src/sdk_cache.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! SDK: best-effort host-local scratch key-value cache.
22
3-
use fkst_common::{validate_runtime_key, RuntimeKind};
3+
use fkst_common::{
4+
runtime_key_file, validate_runtime_key, RuntimeKind, RUNTIME_LOCK_LEAF, RUNTIME_VALUE_LEAF,
5+
};
46
use mlua::{Lua, Result};
57
use nix::fcntl::{flock, FlockArg};
68
use std::fs::{File, OpenOptions};
@@ -71,7 +73,11 @@ fn cache_set(
7173
let _lock = acquire_cache_lock(host_root, key)?;
7274
let layout =
7375
runtime_context::layout_from_host_root(host_root).map_err(mlua::Error::external)?;
74-
let target = layout.runtime_dir(RuntimeKind::Cache).join(key);
76+
let target = runtime_key_file(
77+
&layout.runtime_dir(RuntimeKind::Cache),
78+
key,
79+
RUNTIME_VALUE_LEAF,
80+
);
7581
let parent = target.parent().ok_or_else(|| {
7682
mlua::Error::external(anyhow::anyhow!(
7783
"cache target '{}' has no parent",
@@ -104,7 +110,11 @@ fn cache_get(host_root: &Path, key: String, clock: &Clock) -> Result<Option<Vec<
104110
let _lock = acquire_cache_lock(host_root, key)?;
105111
let layout =
106112
runtime_context::layout_from_host_root(host_root).map_err(mlua::Error::external)?;
107-
let target = layout.runtime_dir(RuntimeKind::Cache).join(key);
113+
let target = runtime_key_file(
114+
&layout.runtime_dir(RuntimeKind::Cache),
115+
key,
116+
RUNTIME_VALUE_LEAF,
117+
);
108118
match std::fs::read(&target) {
109119
Ok(raw) => match decode_entry(&raw) {
110120
Some((Some(expires_at), _)) if clock() >= expires_at => {
@@ -123,17 +133,19 @@ fn cache_expire(host_root: &Path, key: String) -> Result<()> {
123133
let _lock = acquire_cache_lock(host_root, key)?;
124134
let layout =
125135
runtime_context::layout_from_host_root(host_root).map_err(mlua::Error::external)?;
126-
let target = layout.runtime_dir(RuntimeKind::Cache).join(key);
136+
let target = runtime_key_file(
137+
&layout.runtime_dir(RuntimeKind::Cache),
138+
key,
139+
RUNTIME_VALUE_LEAF,
140+
);
127141
remove_cache_file(&target)
128142
}
129143

130144
fn acquire_cache_lock(host_root: &Path, key: &str) -> Result<File> {
131145
let layout =
132146
runtime_context::layout_from_host_root(host_root).map_err(mlua::Error::external)?;
133-
let path = layout
134-
.runtime_dir(RuntimeKind::Locks)
135-
.join("cache")
136-
.join(key);
147+
let cache_locks = layout.runtime_dir(RuntimeKind::Locks).join("cache");
148+
let path = runtime_key_file(&cache_locks, key, RUNTIME_LOCK_LEAF);
137149
let parent = path.parent().ok_or_else(|| {
138150
mlua::Error::external(anyhow::anyhow!(
139151
"cache lock target '{}' has no parent",

crates/fkst-framework/src/sdk_git.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
//! - count_worktrees() -- count linked worktrees excluding main checkout
77
//! - list_orphan_worktrees(pfx) -- list <runtime>/worktrees/<pfx>* linked worktree paths
88
9-
use fkst_common::{validate_runtime_key, RuntimeKind, RuntimeLayout};
9+
use fkst_common::{
10+
runtime_key_file, validate_runtime_key, RuntimeKind, RuntimeLayout, RUNTIME_LOCK_LEAF,
11+
};
1012
use mlua::{Function, Lua, Result};
1113
use nix::fcntl::{flock, FlockArg};
1214
use std::os::fd::AsRawFd;
@@ -64,10 +66,15 @@ fn register_with_lock(lua: &Lua, host_root: PathBuf) -> Result<()> {
6466
"with_lock name '{name}' uses reserved lock namespace 'once'"
6567
)));
6668
}
69+
if name == "cache" || name.starts_with("cache/") {
70+
return Err(mlua::Error::external(anyhow::anyhow!(
71+
"with_lock name '{name}' uses reserved lock namespace 'cache'"
72+
)));
73+
}
6774
let layout = runtime_context::layout_from_host_root(&host_root)
6875
.map_err(mlua::Error::external)?;
6976
let locks = layout.runtime_dir(RuntimeKind::Locks);
70-
let path = locks.join(name);
77+
let path = runtime_key_file(&locks, name, RUNTIME_LOCK_LEAF);
7178
let parent = path.parent().ok_or_else(|| {
7279
mlua::Error::external(anyhow::anyhow!(
7380
"with_lock target '{}' has no parent",

crates/fkst-framework/src/sdk_mark.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! SDK: `once(key, fn) -> boolean` best-effort per-key debounce marker.
22
3-
use fkst_common::{validate_runtime_key, RuntimeKind};
3+
use fkst_common::{
4+
runtime_key_file, validate_runtime_key, RuntimeKind, RUNTIME_LOCK_LEAF, RUNTIME_MARK_LEAF,
5+
};
46
use mlua::{Function, Lua, Result};
57
use nix::fcntl::{flock, FlockArg};
68
use std::os::fd::AsRawFd;
@@ -21,10 +23,8 @@ fn once(host_root: &Path, key: String, f: Function) -> Result<bool> {
2123
let key = validate_runtime_key(&key).map_err(mlua::Error::external)?;
2224
let layout =
2325
runtime_context::layout_from_host_root(host_root).map_err(mlua::Error::external)?;
24-
let lock_path = layout
25-
.runtime_dir(RuntimeKind::Locks)
26-
.join("once")
27-
.join(key);
26+
let once_locks = layout.runtime_dir(RuntimeKind::Locks).join("once");
27+
let lock_path = runtime_key_file(&once_locks, key, RUNTIME_LOCK_LEAF);
2828
let lock_parent = lock_path.parent().ok_or_else(|| {
2929
mlua::Error::external(anyhow::anyhow!(
3030
"once lock target '{}' has no parent",
@@ -42,7 +42,11 @@ fn once(host_root: &Path, key: String, f: Function) -> Result<bool> {
4242

4343
flock(lock_file.as_raw_fd(), FlockArg::LockExclusive).map_err(mlua::Error::external)?;
4444

45-
let marker = layout.runtime_dir(RuntimeKind::Marks).join(key);
45+
let marker = runtime_key_file(
46+
&layout.runtime_dir(RuntimeKind::Marks),
47+
key,
48+
RUNTIME_MARK_LEAF,
49+
);
4650
if marker.exists() {
4751
sdk_log::info(&format!("once decision=skip-marked key={key}"));
4852
drop(lock_file);

0 commit comments

Comments
 (0)