最近在服务器上使用 codex-cli 时遇到一个很容易让人误判的问题:终端意外关闭后,我以为原来的 Codex 会话丢了,于是在新终端里执行 codex resume,结果直接恢复失败。
原始报错如下:
■ Failed to resume session from /home/ubuntu/.codex/sessions/2026/08/10/rollout-2026-
08-10T01-12-14-019fe93a-37ae-7952-8b4e-4fdc38a7289a.jsonl: thread/resume failed during
TUI bootstrap: thread/resume failed: thread 019fe93a-37ae-7952-8b4e-4fdc38a7289a
already has an active writer (code -32600)
第一反应很容易是:会话文件坏了、历史丢了、或者 resume 本身出问题了。但最后查下来,真正的问题不是会话损坏,而是旧的 Codex 进程还活着,并且仍然持有这个线程的写者锁。
这篇文章记录的是
codex-cli 0.147.0环境下的一次实测排障。相关机制来自 openai/codex PR #34986 引入的单写者约束:同一个 paginated thread 同一时间只允许一个活着的 writer 写入。
现象:新终端无法恢复旧会话
出问题的场景是:
- 我在 SSH 终端里开着 Codex TUI;
- 终端窗口意外关闭或断开;
- 我重新连上服务器;
- 执行
codex resume; - Codex 报
already has an active writer (code -32600)。
这个报错里的关键不是 Failed to resume session,而是:
thread 019fe93a-37ae-7952-8b4e-4fdc38a7289a already has an active writer
也就是说,Codex 认为这个 thread 仍然有一个“活着的写者”。为了避免两个进程同时向同一个会话线程写入,它拒绝了新的恢复请求。
根因:旧进程还握着 thread-writer-lock
codex-cli 0.147.0 包含 2026-07 合入的 openai/codex PR #34986,标题是:
Enforce single-writer ownership for paginated threads
这个变更的核心逻辑是:
- 创建或恢复 paginated thread 时,会获取一个 per-thread filesystem lock;
- 这个锁会在 live recorder 生命周期内一直保留;
- 如果另一个进程竞争同一个
thread/resume,会返回 JSON-RPC 错误-32600; - writer 被 discard、delete 或 shutdown 后,才释放 ownership;
- stale lock files 可以清理,但不能影响 active writers。
本机实测时,报错里的线程:
019fe93a-37ae-7952-8b4e-4fdc38a7289a
对应锁文件是:
/home/ubuntu/.codex/thread-writer-locks/019fe93a-37ae-7952-8b4e-4fdc38a7289a.lock
用 lsof 看,锁文件正被某个旧 PID 持有,并且是带写锁打开:
49uW
同时 who 还能看到旧的 pts/0 仍然处于登录状态。也就是说,从客户端看终端像是关了,但服务端并没有真正结束那次 SSH 会话,旧的 Codex 进程也没有退出。
因此,新终端里的 codex resume 不是恢复不了历史,而是被旧进程有意挡住了。
先确认:会话文件通常没坏
这次报错里提到的 session 文件是:
/home/ubuntu/.codex/sessions/2026/08/10/rollout-2026-08-10T01-12-14-019fe93a-37ae-7952-8b4e-4fdc38a7289a.jsonl
排查时这个文件仍然存在,大小约 2.8MB,最后一条也是完整结束的助手回复。换句话说,历史并没有丢,只是当前 thread 的写入权仍然被旧进程占用。
这里不要急着删 session 文件,也不要手工改 .jsonl。这类问题优先处理“谁还持有锁”。
最简单的解决方式
如果还能找到原来的终端,比如 who 显示 pts/0 仍然在线,那最安全的做法是:
- 回到那个终端里的 Codex TUI 继续使用;或
- 在旧 Codex TUI 里正常退出,例如
Ctrl+C; - 然后在新终端里重新执行
codex resume。
正常退出后,旧 writer 生命周期结束,锁会释放,新终端就能恢复。
旧终端不可达怎么办
如果旧终端已经不可达,就要结束残留进程。关键原则是:
- 不要直接删除所有 lock 文件;
- 不要误杀当前正在运行的 Codex;
- 先找出持有
~/.codex/thread-writer-locks/*.lock的进程; - 结束残留进程后,让内核释放文件锁;
- 只清理无人持有、且为空的孤儿锁文件。
为了以后遇到同类问题不用重复手动排查,我写了一个小脚本:codex-unlock。
使用方法
codex-unlock # 交互式:列出状态 -> 确认 -> 清理 -> 验证
codex-unlock --list # 只查看当前锁状态(只读)
codex-unlock --clean # 只清理无人持有的孤儿锁
codex-unlock --kill PID # 指定结束某些残留进程
我建议第一次先执行:
codex-unlock --list
看清楚当前到底有哪些进程持有锁,再决定是否清理。
如果确认某个旧 PID 是残留 Codex 进程,可以指定结束:
codex-unlock --kill 1737986
也可以直接运行交互模式:
codex-unlock
它会先列出状态,再让你确认是否结束残留持有者。
脚本内容
#!/usr/bin/env bash
#
# codex-unlock - 排查并释放 Codex 线程"写者锁"(thread-writer-locks)
#
# 何时用:终端意外关闭/断开后,旧 codex 进程没退出,导致新终端恢复会话报错:
# thread/resume failed: thread xxx already has an active writer (code -32600)
#
# 用法:
# codex-unlock 交互式:列出状态 -> 确认 -> 结束残留进程 -> 清理孤儿锁 -> 验证
# codex-unlock --list 只查看当前锁状态(只读,不改任何东西)
# codex-unlock --kill PID1 PID2 结束指定残留进程(自动跳过当前会话自身)
# codex-unlock --clean 只清理"无人持有"的孤儿锁文件
# codex-unlock -h 查看帮助
#
# 安全设计:
# 1. 自动识别当前 Codex 会话的进程祖先链并跳过,绝不会误杀自己;
# 2. --clean 只删除 0 字节且当前无任何进程持有的锁文件,绝不动 .coordination.lock;
# 3. 结束进程先 SIGTERM,3 秒不退再 SIGKILL。
set -u
LOCKS_DIR="${CODEX_LOCKS_DIR:-$HOME/.codex/thread-writer-locks}"
SELF_PID="$$"
die() { printf '错误: %s\n' "$*" >&2; exit 1; }
[ -d /proc ] || die "仅支持 Linux(依赖 /proc 扫描文件锁)"
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
}
# 当前会话的祖先链 PID(含自身),这些进程永远不杀
collect_protected() {
local pid="$SELF_PID" ppid
while [ "$pid" -gt 1 ] 2>/dev/null; do
printf '%s\n' "$pid"
ppid=$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null) || break
[ -z "$ppid" ] || [ "$ppid" = "$pid" ] && break
pid="$ppid"
done
}
# 扫描所有进程,输出每行 "PID<TAB>锁文件路径"(排序去重)
scan_holders() {
local d pid fd tgt
for d in /proc/[0-9]*; do
[ -d "$d/fd" ] || continue
pid="${d#/proc/}"
for fd in "$d"/fd/*; do
[ -e "$fd" ] || continue
tgt=$(readlink "$fd" 2>/dev/null) || continue
case "$tgt" in
"$LOCKS_DIR"/*.lock) printf '%s\t%s\n' "$pid" "$tgt" ;;
esac
done
done | sort -u
}
# 只保留非当前会话的持有者 PID(去重)
stale_pids() {
local protected pid
protected=$(collect_protected)
scan_holders | cut -f1 | sort -u | while read -r pid; do
grep -qw "$pid" <<<"$protected" || printf '%s\n' "$pid"
done
}
cmd_of() {
tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null | cut -c1-140
}
list_holders() {
if [ ! -d "$LOCKS_DIR" ]; then
echo "目录不存在: $LOCKS_DIR"
echo "说明当前还没有任何 Codex 线程锁,直接 codex resume 即可。"
return
fi
echo "== 当前持有线程写者锁的进程 =="
local line pid lock
local any=0
while IFS=$'\t' read -r pid lock; do
[ -n "$pid" ] || continue
any=1
printf 'PID %-8s 运行时间/终端: %s\n' "$pid" "$(ps -o etime=,tty= -p "$pid" 2>/dev/null | tr -s ' ')"
printf ' 锁: %s\n' "${lock##*/}"
printf ' 命令: %s\n' "$(cmd_of "$pid")"
done <<< "$(scan_holders)"
[ "$any" = 0 ] && echo " 无"
echo
echo "== 无人持有的孤儿锁文件(可安全清理) =="
local held orphan f
held=$(scan_holders | cut -f2)
orphan=0
for f in "$LOCKS_DIR"/*.lock; do
[ -f "$f" ] || continue
case "$f" in */.coordination.lock) continue ;; esac
if ! grep -qxF "$f" <<<"$held"; then
printf ' %s (mtime %s, %s 字节)\n' "${f##*/}" "$(stat -c %y "$f" 2>/dev/null | cut -d. -f1)" "$(stat -c %s "$f" 2>/dev/null)"
orphan=1
fi
done
[ "$orphan" = 0 ] && echo " 无"
}
# 结束残留进程;want="all" 或逗号分隔 PID 列表
kill_stale() {
local want="$1" protected line pid lock ppid pcmd killed=0 i
protected=$(collect_protected)
while IFS=$'\t' read -r pid lock; do
[ -n "$pid" ] || continue
grep -qw "$pid" <<<"$protected" && continue # 当前会话,跳过
if [ "$want" != "all" ]; then
case ",$want," in *",$pid,"*) ;; *) continue ;; esac
fi
ppid=$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null || true)
printf '结束残留进程 PID %s(持有 %s)...\n' "$pid" "${lock##*/}"
kill "$pid" 2>/dev/null
for i in 1 2 3; do
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
if kill -0 "$pid" 2>/dev/null; then
echo ' SIGTERM 未退出,升级 SIGKILL'
kill -9 "$pid" 2>/dev/null
sleep 1
fi
if kill -0 "$pid" 2>/dev/null; then
printf ' 警告: PID %s 仍在运行\n' "$pid"
else
killed=1
printf ' PID %s 已退出,写者锁由内核自动释放\n' "$pid"
# 顺带结束它的 node 包装进程(仅当不是当前会话祖先)
if [ -n "$ppid" ] && ! grep -qw "$ppid" <<<"$protected"; then
pcmd=$(cmd_of "$ppid")
case "$pcmd" in *codex*) kill "$ppid" 2>/dev/null || true; printf ' 顺带结束包装进程 PID %s\n' "$ppid" ;; esac
fi
fi
done <<< "$(scan_holders)"
[ "$killed" = 0 ] && echo '没有需要结束的残留进程'
}
clean_orphans() {
local held f removed=0
held=$(scan_holders | cut -f2)
for f in "$LOCKS_DIR"/*.lock; do
[ -f "$f" ] || continue
case "$f" in */.coordination.lock) continue ;; esac
if ! grep -qxF "$f" <<<"$held"; then
if [ "$(stat -c %s "$f" 2>/dev/null || echo 1)" != "0" ]; then
printf '跳过非空锁文件(异常,需人工检查): %s\n' "$f"
continue
fi
printf '清理孤儿锁: %s\n' "${f##*/}"
rm -f "$f"
removed=1
fi
done
[ "$removed" = 0 ] && echo '没有孤儿锁需要清理'
}
final_check() {
local protected line pid lock n=0
protected=$(collect_protected)
echo '== 最终状态 =='
while IFS=$'\t' read -r pid lock; do
[ -n "$pid" ] || continue
grep -qw "$pid" <<<"$protected" && continue
n=$((n + 1))
printf ' 仍有 PID %s 持有 %s\n' "$pid" "${lock##*/}"
done <<< "$(scan_holders)"
[ "$n" = 0 ] && echo ' 无残留持有者 ✔'
echo
echo '现在可以执行: codex resume(或 codex resume --last)'
}
interactive() {
local stale ans
list_holders
echo
stale=$(stale_pids)
if [ -z "$stale" ]; then
echo '没有残留持有者,直接 codex resume 即可;若仍失败可运行 codex-unlock --clean 清理孤儿锁。'
return 0
fi
printf '发现 %s 个残留持有者(PID: %s)。结束它们?[y/N] ' \
"$(printf '%s\n' "$stale" | wc -l | tr -d ' ')" \
"$(printf '%s\n' "$stale" | tr '\n' ' ')"
read -r ans
case "$ans" in
y|Y|yes|YES)
kill_stale all
clean_orphans
final_check
;;
*) echo '已取消,未做任何改动。' ;;
esac
}
case "${1:-}" in
--list) list_holders ;;
--clean) clean_orphans ;;
--kill)
shift
[ $# -gt 0 ] || die '--kill 需要指定 PID(多个用空格分隔),例如: codex-unlock --kill 1234 5678'
kill_stale "$(printf ',%s' "$@" | cut -c2-)"
final_check
;;
-h|--help) usage ;;
*) interactive ;;
esac
安装到 PATH
例如保存到 ~/bin/codex-unlock:
mkdir -p ~/bin
nano ~/bin/codex-unlock
chmod +x ~/bin/codex-unlock
如果 ~/bin 还不在 PATH 中,可以加到 shell 配置里:
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
然后先只读检查:
codex-unlock --list
我保留的安全边界
这个脚本没有做“暴力清空锁目录”,而是保留了几个边界:
- 依赖
/proc,所以只支持 Linux; - 自动收集当前脚本进程的祖先链,避免误杀当前会话;
--list完全只读;--clean只删除无人持有、大小为0的.lock文件;- 永远跳过
.coordination.lock; --kill先发SIGTERM,3 秒不退出才升级到SIGKILL;- 结束残留 writer 后再做最终状态验证。
这几个限制看起来啰嗦,但比一条 rm -rf ~/.codex/thread-writer-locks/* 安全得多。锁文件只是表象,真正要处理的是还活着的 writer 进程。
总结
这次问题的关键结论是:
already has an active writer (code -32600)不等于会话丢失;- 大概率是旧 Codex 进程仍然持有 thread writer lock;
- 能回到旧终端就正常退出,这是最安全的;
- 旧终端不可达时,结束残留进程比手工删除 session 更合理;
- 清理锁文件前一定要确认没有进程仍在持有它。
以后再遇到这种情况,我的排查顺序会是:
codex-unlock --list
codex-unlock
codex resume
先确认,再清理,最后恢复。不要一上来就删会话文件。