闫志恒 / Codex resume 报 already has an active writer,我写了个 codex-unlock

Created Mon, 10 Aug 2026 10:30:00 +0800 Modified Mon, 10 Aug 2026 04:27:06 +0000

最近在服务器上使用 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 写入。

现象:新终端无法恢复旧会话

出问题的场景是:

  1. 我在 SSH 终端里开着 Codex TUI;
  2. 终端窗口意外关闭或断开;
  3. 我重新连上服务器;
  4. 执行 codex resume
  5. 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 仍然在线,那最安全的做法是:

  1. 回到那个终端里的 Codex TUI 继续使用;或
  2. 在旧 Codex TUI 里正常退出,例如 Ctrl+C
  3. 然后在新终端里重新执行 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

先确认,再清理,最后恢复。不要一上来就删会话文件。