Skip to content

feat(agent-core-v2): add cross-process session occupancy lock - #4246

Draft
7Sageer wants to merge 16 commits into
mainfrom
session-lock
Draft

7Sageer wants to merge 16 commits into
mainfrom
session-lock

Conversation

@7Sageer

@7Sageer 7Sageer commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Requirement or Bug

多个客户端(TUI、kimi web / desktop、VS Code、ACP)同开一个会话时 wire 会分叉、元数据互相覆盖;改为同一时刻只允许一个进程写,写之前发现他端写过就先重新载入。

Bug Reproduction Steps

N/A

Root Cause

N/A

Code Changes

每个会话一把跨进程占用锁 {homeDir}/locks/sessions/{id}.lock,只在写会话时持有,打开、查看不拿锁。锁文件记 version / token / pid / hostname / startedAt / clientIdentity,先写临时文件再 link 发布,已存在即失败。判活规则:pid 存活且加锁晚于本次开机才算活锁;hostname 与本机不同一律按占用处理;拿锁时 IO 出错,告警后不加锁继续。

 发消息 / 手动压缩 / 撤销(loop.submit、compact、undo)
+  beginSessionRun(sessionId)
+    beginRun:本进程未持有则发布锁文件
+      撞上活锁 → session.occupied(holder 放进 details)
+    每次持锁(holdToken)只检查一次过期:
+      本进程已加载的任一 agent(主 agent 与子 agent)的 wire 磁盘长度
+      不在 [已知长度, 已知长度 + 本进程在途字节] 内 → 放锁 + session.stale
+    同一次持锁内再次开跑(steer、skill 投递、subagent、cron)直接通过,不再 stat
   …原有逻辑…
+ 删除 / fork / 归档 / 冷归档:持有显式 lease
+ 其他 wire 写入:AppendLogStore 落盘前持锁;拿不到先留内存,之后 flush 时重试;关闭时仍拿不到则告警丢弃
+ state.json:写前持锁,写入期间另持一个显式 lease,空闲放锁不会在写入中途删锁;每次新持锁后先重读,避免用缓存覆盖他端的修改;
+   发消息时是否用这条消息生成标题,也按重读后的结果判断
+ resume 时拿不到锁或文件已被改:不改写(迁移本次跳过,修复延后到首次写入)
+ 放锁:会话空闲(无 turn、后台任务、压缩)且落盘后再等 3 秒;close 时立即放;进程 'exit' 时同步删除

「已知长度」由 AppendLogStore 维护:读取时记下,本进程追加时累加,改写时重置。在途字节用来容忍自己还没落盘或分块写到一半的追加。

错误分流:

 session.occupied(40931)──┬─ TUI:占用对话框 Retry / Cancel,Cancel 时原文回到输入框
                            ├─ kap:SESSION_OCCUPIED,holder 在 details
                            ├─ print:开跑前(含创建 goal 前)预检锁;撞锁时 stderr 提示,退出码 4;stream-json 输出结构化错误
                            ├─ VS Code:按 preflight 错误处理,撤回消息、原文回到输入框,toast 显示同一文案
                            └─ ACP:显示 engine 文案
 session.stale(40930)────┬─ TUI:重新载入并回放历史,原文和已粘贴的附件回到输入框并提示,由用户再发;
                            │       重载期间保持请求中:新敲的字接在原文后面,回车的消息排队,重载完成后发给新会话
                            ├─ kap:重新加载会话并重挂 WS 订阅(推一次 transcript reset),再返回 SESSION_STALE
                            ├─ print:自动重新载入后继续;goal 创建幂等,重试不会重复创建 goal 或重复输出摘要
                            ├─ VS Code:按 preflight 错误处理,撤回消息、原文回到输入框,toast 提示重新打开会话
                            └─ ACP:提示重新打开会话

TUI 里所有发起 prompt 的路径(普通发送、goal steer、插件命令、inline skill、批量 steer)都走同一个冲突处理。

占用文案以 engine 为准,各端直接显示:This session is in use by another Kimi Code process. Stop the session in that process or exit it, then try again. VS Code 的 toast 优先取 ERROR_MESSAGES,那里放了一份相同的副本,由测试钉住两者逐字一致。

文件职责:

 packages/agent-core-v2/src/
+├── persistence/interface/sessionLock.ts                 # ISessionLockService、beginSessionRun、错误构造
+├── persistence/backends/node-fs/sessionLockService.ts   # 文件锁实现(运行持有 + 显式 lease 两层)
+├── persistence/backends/memory/sessionLockService.ts    # 测试用内存实现
+├── session/sessionActivity/sessionLockReleaseService.ts # 空闲放锁
 ├── persistence/backends/node-fs/appendLogStore.ts       # 已知长度、在途字节、externallyModified、落盘前持锁
 ├── agent/{loop,fullCompaction,undo}                     # 开跑入口接 beginSessionRun
 ├── wire/wireService.ts                                  # resume 拿不到锁或已被改时不改写;wire 写入绑定持锁
 ├── session/sessionMetadata/                             # state.json 写前持锁,新持锁先重读;prompt 标题在重读后判断
 └── workspace/sessionLifecycle/                          # delete / fork / archive / 冷归档持 lease
 packages/kap-server/src/
 ├── protocol/error-codes.ts                              # 40930 SESSION_STALE、40931 SESSION_OCCUPIED
 ├── routes/{prompts,sessions}.ts + sessionReload.ts      # 错误映射;stale 时先重载会话再回错
 └── transport/ws/v1/sessionEventBroadcaster.ts           # close 时 detach(清掉在途 turn 与子 agent 名单)、resume 时 reattach
 apps/kimi-code/src/
 ├── tui/components/dialogs/session-occupied-dialog.ts    # 占用对话框
 ├── tui/kimi-tui.ts + tui/commands/                      # 各发起 prompt 路径与 /compact、/undo 的冲突处理;过期重载期间的输入与排队
 ├── tui/utils/image-attachment-store.ts                  # 过期重载时保存并恢复附件
 └── cli/v2/run-v2-print.ts                               # 退出码 4、stale 自动重载重试
 packages/{acp-server,node-sdk}、apps/vscode              # 新错误码的提示与映射;VS Code 把两个码归为 preflight 错误

TUI 侧埋点:occupied_prompt_shown(holder 端 × 开跑端)、occupied_choice(retry / cancel)、session_stale_reload。

desktop / web 的专门提示在 code-app 另开 PR:MoonshotAI/kimi-code-app#899。web 产物(dist-web)等两边都合入后再同步。在那之前,web 走现有的通用发送失败路径:撤回乐观显示的消息,原文回到输入框;过期时 kap 已重载会话,历史会经 transcript reset 自动刷新。

Behavior Changes and Affected Users

行为 之前 之后 谁依赖旧行为 退路
两个客户端同时写同一会话 双方都能写,wire 分叉、元数据互相覆盖 后开跑的一方收到 session.occupied;打开、查看不受影响 无正当依赖,旧行为就是数据损坏 无;锁 IO 出错时自动降级为旧行为
他端写过之后本端再发消息 / compact / undo 在旧上下文上继续跑,分叉扩大 session.stale,本进程已加载的任一 agent wire 被他端写过即触发(含只写了子 agent 的 side chat):TUI 重载回放并保留输入;kap 重载会话后返回 40930;print 自动重载继续;VS Code / ACP 提示重新打开 无 无
他端写过 state.json 之后本端发消息 用本端缓存整份写回,他端改的标题等被覆盖 写前重读 state.json,是否用这条消息生成标题按重读结果判断,他端已命名就保留。本端过期时这条消息会被拒,但 lastPrompt 已先记成它,重发后与实际一致 无 无
他端运行期间(含等审批、后台任务、空闲后 3 秒内)本端的纯元数据写入,如改标题、PATCH custom 元数据 成功,但可能被运行方下一次整份写回覆盖 返回 SESSION_OCCUPIED,可重试 desktop / web 改标题,调 PATCH /sessions/{id} 的外部脚本 对方结束后重试
fork 他端正在运行的会话 照常复制,可能读到写到一半的尾部 session.occupied TUI、desktop / web 的 fork 用户 对方结束后重试
删除他端正在运行的会话 目录被删在运行中的进程底下 session.occupied;本端会话已关闭,kap 已清掉该会话的 WS 订阅和事件 journal,重新打开即可 无正当依赖 对方结束后重试
崩溃或被杀后遗留的锁文件 无锁 pid 已死或加锁早于本次开机 → 下次拿锁时自动回收;hostname 不同按占用处理 — 手动删除 {homeDir}/locks/sessions/<id>.lock(共享 home、改过 hostname 的机器)
进程退出,包括 TUI 关终端(SIGHUP)的紧急退出 无锁 exit 时同步删除本进程持有的锁文件 — —
锁目录不可写 / 锁 IO 出错 无锁 告警后不加锁继续,回到旧行为 权限受限的运行环境 天然退路
cron 到点时他端在跑,或本端已过期 照常 submit 本次跳过,下个 tick 重试;本端过期时一直跳过,直到重新载入 cron 用户 自动恢复
print 模式撞占用 继续跑 stderr 提示 + 退出码 4(避开 0 / 1 / 2 / 3 / 6 / 129 / 130 / 143);stream-json 输出 {"type":"error","code":"session.occupied","message":…,"details":…} 解析 print 输出或退出码的脚本 新增退出码,既有退出码不变
kap API 错误面 无这两个码 prompts、compact、undo 等新增 SESSION_OCCUPIED(40931)与 SESSION_STALE(40930);40903 是 9 月退役的 PROMPT_ALREADY_COMPLETED,不复用 desktop / web(code-app)、外部 API 脚本 未识别新码的客户端按普通错误处理
会话 close→reopen 期间的 WS 订阅 订阅绑在已销毁的 handle 上,事件静默丢失 detach / reattach:保留 targets 与事件 journal,清掉在途 turn 与子 agent 名单,重开后续播并推一次 transcript reset kap 的 WS 客户端(desktop / web) 无
运行之外的 wire 写入(后台任务通知等) 直接落盘 落盘前持锁;拿不到先留内存并告警一次,之后 flush 时重试;关闭时仍拿不到则告警丢弃 无 无
resume 时 wire 需要迁移或修复 直接改写 拿不到锁或文件已被改时不改写:迁移本次跳过,修复延后到首次写入 旧版本写的会话 之后能拿到锁时自动完成

受影响模块与测试覆盖(除注明外,均在 45e05a5 上本地运行):

  • agent-core-v2:全量 7215 通过,1 个失败(tool.test.ts 前台 subagent 超时),main 上同样失败
    • sessionLockService.test.ts:占用、死锁回收、hostname、lease 配对、IO 降级、退出时释放
    • beginSessionRun:fresh / stale / occupied / 自身追加 / 每次持锁只查一次 / 其他路径先拿锁也会检查 / 只有子 agent 的 wire 被外部追加也判 stale
    • appendLogStore.test.ts:已知长度、改写重置、落盘前持锁、自己的追加落盘前和分块写到一半都不算外部修改
    • sessionMetadata.test.ts:新持锁后重读 state.json;缓存未命名而磁盘上已有他端标题时保留该标题,事件里的标题与实际写入一致
  • kap-server:detach 后快照里没有旧的在途 turn 和子 agent;全量 1447 通过
  • apps/kimi-code:占用对话框、附件保存恢复、print goal 重试、过期重载期间输入合并与排队消息发给新会话等用例;全量 4202 通过
  • apps/vscode:两个码按 preflight 处理,occupied 文案与 engine 一致;全量 384 通过
  • lint、零注释检查,以及 agent-core-v2、kap-server、apps/kimi-code、apps/vscode 的 typecheck 均通过
  • 真实落盘与锁下的端到端验证(不入库):
    • 单客户端:模型在首轮调用 Skill、第二轮调用 Skill、调用 Agent,各 30 次均正常。这条路径在 b22b7c6 之前会被误判为 stale。
    • 同一 home 下两个客户端:对方跑完后发送得到 stale,重载后能发;对方运行中发送得到 occupied;最终 wire 里的 prompt 顺序正确,被拒的提交没有写入,没有分叉。

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue (external PRs: issue must have a maintainer's /approve).
  • I have added tests that prove my feature works.
  • The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

Multiple clients (TUI / kap-server / VS Code / ACP) running core in
separate processes could open the same session and corrupt each
other's wire.jsonl and metadata. Each session now holds a cross-process
occupancy lock ({homeDir}/locks/sessions/{id}.lock) taken only while
writing: runs acquire it on submit / compact / undo / cron / delete /
fork / cold-archive, the append log holds it before flushing, and a
stale check compares the main agent wire's on-disk length with the
length this process knows.

Collisions surface as session.occupied (holder in details); writes by
another client detected after acquire surface as session.stale and the
caller reloads before continuing. kap-server maps them to
SESSION_OCCUPIED (40903) / SESSION_STALE (40930), reloads the session
on stale, and re-attaches WS subscriptions; the TUI shows an occupancy
dialog (retry/cancel) or reloads and replays history keeping the input;
print exits with code 4 on occupied and reloads automatically on stale;
VS Code / ACP get minimal occupancy notices. Locks are released a few
seconds after the session goes idle, on close, and on process exit;
locking I/O failures degrade to unlocked operation with a warning.
Copilot AI balanced review requested due to automatic review settings October 15, 2026 11:57
@changeset-bot

changeset-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9c0d0b9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@7Hanrui

7Hanrui commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T13:35:00.453595Z 9c0d0b9 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d5c9e7401

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +214 to +218
if (!state.lockWarned) {
state.lockWarned = true;
console.warn(
`[append-log] ${scope}/${key}: session lock unavailable; keeping writes in memory`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject mutations when session lock acquisition fails

When another client owns the session, durable mutations that do not call beginSessionRun first—such as setPermission, setThinking, or setModel—still update the local fold and report success, while this path merely leaves their wire records pending. If the session closes while the owner remains active, retirement deletes that pending state; if a later flush succeeds, it appends the stale mutation without checking externallyModified. Thus settings can be silently lost or written over a newer history; ownership must be acquired before committing the mutation, and failure must propagate to the caller rather than buffering it.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Comment thread apps/kimi-code/src/tui/kimi-tui.ts Outdated
Comment on lines +2078 to +2079
onSelect: (action) => {
if (action !== 'retry') return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route the Cancel selection through cancellation cleanup

When a TUI user moves to the displayed Cancel option and presses Enter, onSelect('cancel') hits this return and performs none of the onCancel cleanup: the dialog remains mounted, the submitted text stays removed, and recalled attachments are not released. Esc works because it invokes onCancel directly, so the selected Cancel action should invoke that same path.

Useful? React with 👍 / 👎.

Comment on lines +113 to +116
private async ensureWriteOwnership(): Promise<void> {
if (this.sessionLocks.held(this.ctx.sessionId)) return;
this.sessionLocks.beginRun(this.ctx.sessionId);
const existing = await this.store.get<SessionMeta>(this.scope, META_KEY);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh metadata after acquiring a run lock elsewhere

When a prompt has already acquired the session run lock, this early return skips re-reading state.json, even though beginSessionRun only checked the main wire journal for external changes. If another client changed metadata while this client was idle—for example, setting a custom title or archiving the session—then this client's next prompt updates lastPrompt from its stale this.data snapshot and overwrites that newer metadata. Refresh or version-check metadata on the first metadata write of each newly acquired run, rather than treating possession of the wire lock as proof that the metadata snapshot is current.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Comment thread packages/acp-server/src/session.ts Outdated
Comment on lines +134 to +139
if (code === SESSION_OCCUPIED_CODE || code === SESSION_STALE_CODE) {
log.warn('acp: prompt rejected due to a cross-client session conflict', {
sessionId,
conflict: code,
});
return RequestError.invalidRequest({ code }, message);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reload ACP sessions after reporting a stale conflict

For an ACP client whose session was updated by another process, mapping session.stale to invalidRequest leaves the stale engine scope live, so every subsequent prompt fails with the same error. Even issuing ACP session/load or session/resume does not recover because resumeAcpSession explicitly passes an already-live scope through; the client must know to close the session first, despite the error only saying to reload. Handle the stale case by closing and re-materializing the engine session, as the CLI and kap-server paths do, before allowing the ACP client to retry.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

Comment on lines +213 to +217
private alive(holder: SessionLockHolder): boolean {
if (holder.hostname !== '' && holder.hostname !== this.hostname) return true;
if (!pidAlive(holder.pid)) return false;
const bootTime = Date.now() - os.uptime() * 1000;
return holder.startedAt >= bootTime;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect PID reuse before honoring a stale lock

After a crash or forced termination leaves a lock file behind, the OS can reuse its PID for an unrelated process during the same boot. In that case pidAlive succeeds and startedAt >= bootTime is necessarily true for any valid lock created this boot, so the abandoned lock is treated as live until the unrelated process exits and the session remains unusable. Persist and compare an actual process-start identity, or use another reclaimable liveness mechanism, rather than using the lock acquisition timestamp only as a reboot check.

Useful? React with 👍 / 👎.

Comment on lines +59 to +62
if (this.activity.state().busy) return;
await this.appendLogStore.flush().catch(() => undefined);
if (this.activity.state().busy) return;
this.sessionLocks.endRun(this.ctx.sessionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hold the lock through non-activity lifecycle operations

The release timer only consults ISessionActivityView.busy, which tracks turns, background tasks, and compaction but not session lifecycle work. A live archive acquires the run lock through setArchived(true) and then drains agents, waits for close listeners, and closes logs without an explicit lease; once activity becomes idle, this timer can release the lock after three seconds while that archive is still in progress. A second process can then start the same session while the first process is still tearing it down, so lifecycle operations such as archive need an explicit lease for their full duration or must participate in the busy state.

Useful? React with 👍 / 👎.

- metadata: re-read state.json on the first write of each lock-hold
  epoch (holdToken), so pure-metadata changes made by another client
  (title, archive) are not clobbered by turn-end lastPrompt writes
- lifecycle: hold an explicit lease across live archive(), matching
  delete(), so the idle release timer cannot drop the lock mid-teardown
- tui: route the occupied dialog's Cancel selection through the same
  cleanup as Esc (restore input, release recalled media)
- acp: tell stale-session users to close and reopen the session
- append-log: warn when retirement drops records kept in memory because
  the session lock was unavailable
@7Hanrui

7Hanrui commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0ebcf0a22

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if (known === undefined) return false;
const disk = this.diskLength(scope, key);
if (disk === undefined) return false;
return disk !== known;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect journal replacement beyond its byte length

When an SDK caller deletes and recreates an empty session under the same explicit ID while another process still has the old scope open, both journals contain a single metadata record whose timestamp changes but whose encoded byte length remains identical. This comparison therefore reports no external modification, allowing the old process to acquire the lock and append using its obsolete in-memory fold instead of raising session.stale, silently mixing the old and replacement sessions. Track a journal identity, generation, or content fingerprint rather than only its size.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

releaseQuiescence = await quiesceSessionAgents(reloaded.session, reloaded.agent);
};
flushWires = () => flushSessionWires(reloaded.session, reloaded.agent);
await runTurn(reloaded.session, reloaded.agent);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve occupied handling on the stale retry

If the first print-mode attempt reports session.stale and another client acquires the session after the reload but before this retry, the second runTurn throws session.occupied outside the surrounding catch. It therefore bypasses reportSessionOccupied, so headless callers lose the documented exit code 4 and stream-json callers do not receive the structured session.occupied error. Route the retry through the same conflict handler, or use a bounded retry loop that handles both errors consistently.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

Comment on lines +595 to +598
state.detached = undefined;
this.attachWorkView(session, state);
this.attachAgents(sessionId, session, state);
this.attachInteractions(sessionId, state);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh the event journal when reattaching a session

When the external writer is another kap-server process sharing the same home directory, it advances server/events/<session>.jsonl while this process is detached. The retained SessionEventJournal still caches its old sequence number, but this reattachment reuses it without reopening or rescanning the file, so the next local event appends a duplicate or out-of-order sequence and makes snapshot cursors and WebSocket resume results inconsistent for desktop and web clients. Reopen the journal from disk during reattachment while preserving the subscription targets separately.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

The prompt channels (AgentPromptChannel.submit/submitSteer, kap
/prompts, promptWithSkills) write prompt metadata before calling
loop.submit, and the metadata write acquires the session lock first.
beginSessionRun then short-circuited on 'already held' and skipped the
wire-length staleness check, so the main prompt path never detected
updates written by another client. Drop the already-held shortcut: the
check is a single stat and always passes while the lock is continuously
held, so every acquisition path now runs it regardless of order.
If another client acquires the session between the reload and the
retry, the retried turn now goes through the same conflict handler:
session.occupied reports to stderr / stream-json and exits with code 4
instead of bubbling out as exit code 1.
storage.append grows the file (writeFile) before fsync/close complete,
and knownLength only catches up afterwards; a beginSessionRun landing
in that window (steer, queued prompts, cron fires during a turn) was
misjudged as session.stale and deleted the lock file mid-turn. Track
in-flight bytes per log and compare disk against known + in-flight.
Cover the analogous rewrite window (post-rename syncDir) with an
expected-final-length marker.
@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 60abd1ecdb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +60 to +62
await this.appendLogStore.flush().catch(() => undefined);
if (this.activity.state().busy) return;
this.sessionLocks.endRun(this.ctx.sessionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Acquire a lease for metadata writes

When an idle metadata mutation such as setTitle takes longer than three seconds, or starts while this timer is awaiting a slow global flush, it only calls beginRun and does not increment an explicit lease; metadata writes also do not mark session activity as busy. This endRun can therefore unlink the lock while state.json is still being written, allowing another process to acquire the session and overwrite or race a mutation that already reported success. Although lifecycle operations now take explicit leases, the fresh evidence here is that SessionMetadata.applyUpdate still relies solely on the ambient run lock.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

};
flushWires = () => flushSessionWires(reloaded.session, reloaded.agent);
try {
await runTurn(reloaded.session, reloaded.agent);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid rerunning goal creation after a stale reload

For print-mode /goal requests against a stale resumed session, the first runTurn calls createGoal before runNativeTurn detects session.stale, and runNativeGoal also emits a goal summary from its finally block. Retrying the entire closure here invokes createGoal again after reload: a normal goal then fails with goal.already_exists once the first goal record is flushed during close, while /goal replace replaces the newly created goal and emits duplicate summaries to stream-json. Reload before the goal mutation or retry only the turn portion.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

@@ -2037,10 +2042,84 @@ export class KimiTUI {
return;
}
this.staging.trackDispatch(stagingLease, session.prompt(sdkInput, { promptId: submissionId }), (error) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply conflict recovery to every TUI prompt path

The new occupied/stale recovery is attached only to the normal session.prompt dispatch. Existing inline-skill and plugin-command submissions also reach AgentLoopService.submit and now receive these conflict errors, but their unchanged handlers only show a generic failure after the editor input has been consumed; inline-skill failures additionally leave the optimistic transcript entry in place. Thus these TUI users get neither the retry/cancel dialog nor stale reload and input restoration promised for sending from a second client. Route all prompt-launch variants through the same recovery path.

AGENTS.md reference: AGENTS.md:L102-L104

Useful? React with 👍 / 👎.

Comment thread apps/kimi-code/src/tui/kimi-tui.ts Outdated
...(options?.imageAttachmentIds ?? []),
...(options?.videoAttachmentIds ?? []),
]);
await reloadSessionAfterStale(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve staged attachments across stale reloads

When the rejected prompt contains a pasted image or video, releaseRecalled keeps its staged media usable only while the attachment store still owns the corresponding IDs. This reload calls switchToSession, whose transcript reset clears the attachment store and deletes those staged files, and only afterward does handleSessionStale restore the original placeholder text. The next submission treats the now-unknown placeholders as plain text, so clipboard media is silently and irrecoverably dropped instead of being returned with the user's message. Preserve or rehydrate the attachment snapshots across this reload.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

The equality check disk !== known + inFlight moved the false-positive
window to before the append lands (mkdir/open) and into chunked
writes, so beginSessionRun during a turn (steer, cron, queued prompts)
could misjudge, delete the lock file mid-turn, and later append behind
another client. Compare known <= disk <= known + inFlight instead, and
evaluate the check only on a fresh hold (holdToken epoch): re-entrant
runs pass through without a stat, matching the RFC's 'already held
passes through' while every acquisition path still triggers the check
exactly once per hold.

Also unify the occupancy prompt copy across clients: the engine error
message is now the single user-facing text ('该会话正在被其他 Kimi
Code 进程使用;请停止其他进程中的会话或退出该进程后重试'), and the
TUI dialog, /compact /undo errors, print output, and the VS Code
fallback all display it instead of maintaining their own wordings.
- metadata: hold an explicit lease across each state.json write so the
  idle release timer cannot unlink the lock mid-write
- print: preflight the session lock before goal creation; make goal
  creation idempotent and emit the goal summary only for attempts that
  did not fail with session.stale, so a stale reload + retry neither
  duplicates the goal nor the summary
- tui: route every prompt-launch path (plain, goal steer, plugin
  command, inline skill, batch steer) through one conflict handler
  with the occupied dialog and stale reload + input restore
- tui: snapshot and restore the attachment store across stale reloads
  so pasted media placeholders still resolve after history replay
@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 786853ce66

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".


SESSION_BUSY: 40901,
APPROVAL_ALREADY_RESOLVED: 40902,
SESSION_OCCUPIED: 40903,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allocate a distinct code for session occupancy

When a kap-server client encounters session occupancy, this emits 40903, but the published API already assigns 40903 to an already-completed prompt (docs/en/reference/server-api.md:1093,1121,1153). Desktop, web, or external clients that special-case that documented idempotency outcome can therefore interpret an occupied rejection as completion and silently discard the unsent prompt. Assign occupancy an unused protocol code and update both locale documents and API snapshots.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

Comment on lines +105 to +106
const disk = this.diskLength(scope, key);
if (disk !== undefined) state.knownLength = disk;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Snapshot only journal bytes consumed by the reader

When another process appends after this reader reaches EOF but before the finally block stats the file, knownLength includes records that were never decoded into the in-memory fold. After the writer releases its lock, externallyModified sees equal lengths and allows this stale client to append instead of raising session.stale, silently mixing histories. Record the byte count actually consumed, or capture a stable file snapshot before reading.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Comment on lines +2194 to +2197
restore: () => {
this.staging.releaseRecalled(rewrite.imageAttachmentIds);
this.restoreInputText(args);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the plugin command, not only its arguments

When an occupied plugin command is cancelled, or a stale session is reloaded, this restores only args; the original input was /${pluginId}:${commandName} ${args}. For example, cancelling /foo:bar x leaves only x in the editor, so pressing Enter sends an ordinary prompt rather than retrying the command, while an argument-free command restores an empty editor. Reconstruct and restore the complete command line.

AGENTS.md reference: AGENTS.md:L102-L104

Useful? React with 👍 / 👎.


submit(input: UserEntry, options?: LoopSubmitOptions): LoopSubmitResult {
if (this.disposing) throw abortError('Agent loop disposed');
beginSessionRun(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reload stale sessions before retrying cron deliveries

When a loaded session was changed by another process before a cron job becomes due, this new check throws session.stale, but deliverFire catches every submit exception and merely returns false (cronService.ts:195-205). The cursor remains due while the same stale scope is retried on every tick, so unattended recurring and one-shot cron jobs never fire again until some separate user action reloads the session. Reload or retire the session when cron encounters this error instead of repeatedly retrying the stale scope.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Comment on lines +416 to +417
if (this.tryHoldSessionLock() && !this.log.externallyModified(this.wireScope, AGENT_WIRE_RECORD_KEY)) {
await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist deferred wire migrations before accepting writes

When a client opens an older-format session while another process holds the lock, this condition skips the required journal rewrite but discards rewrittenRecords instead of remembering that migration is pending. Once the other process releases the lock without changing the file, beginSessionRun considers the journal current and appends modern records under the old protocol header; later readers then apply the old migration to those new records and can misread or corrupt the session. Preserve the deferred rewrite and complete it after acquiring ownership, before permitting any append.

AGENTS.md reference: AGENTS.md:L102-L104

Useful? React with 👍 / 👎.

…release locks on exit

SESSION_OCCUPIED reused 40903, which belonged to PROMPT_ALREADY_COMPLETED until #3747 retired it, and web clients still allowlist 40903 for that old meaning; reusing it conflated the two semantics. Move session occupancy to the never-used 40931 and update the server API reference in both locales.

The occupied error message was hardcoded in Chinese while every other engine error is English, so the TUI, print mode, VS Code, and ACP clients all rendered mixed-language output; replace it with an English equivalent.

The lock release hook ran on 'beforeExit', which never fires when the process leaves through process.exit (SIGHUP in the TUI goes through emergencyTerminalExit), leaving stale lock files that could keep a session looking occupied forever after a hostname change or pid reuse. releaseAll is fully synchronous, so hook 'exit' instead.
@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 610f8e36ab

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

this.sessionLocks,
this.appendLogStore,
this.sessionContext.sessionId,
this.sessionContext.scope(`agents/${MAIN_AGENT_ID}`),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate the active agent journal before accepting writes

When another client directly advances a subagent journal without changing agents/main/wire.jsonl, submitting to that subagent checks only the unchanged main journal here. Because beginSessionRun also caches the successful check by session token rather than by journal, the subsequent append proceeds from the stale subagent fold and can silently mix histories. Validate the current agent's journal, or all loaded journals, before accepting writes.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 45e05a5. On the first check of each lock hold, beginSessionRun now runs externallyModified over the wire of every agent loaded in this process (IAgentLifecycleService.list()), not only main. If any of them changed on disk, it releases the lock and throws session.stale. An agent that is not loaded is read from disk when it loads, so it does not need the check. All four call sites (loop submit, manual compaction, undo, print preflight) go through the same function.

this.setAppState({ streamingPhase: 'idle' });
this.resetLivePane();
const mediaSnapshot = this.imageStore.snapshot();
void (async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block editor input while stale reload is running

After a stale prompt, the UI is set back to idle and this reload is launched without being awaited or represented as an in-flight session replacement. A user can therefore type another message while reloadSessionAfterStale is pending; when it finishes, the unconditional restore() replaces that newly typed text with the rejected prompt, silently losing the user's intervening input. Keep the editor unavailable until reload and restoration complete, or merge with any text entered during the reload.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 45e05a5. The stale branch no longer drops to idle before the reload. The request stays in flight until the reload, the attachment restore and the input restore have finished, so Enter during the reload queues the message instead of sending it to the closing session. The queue is kept across switchToSession and drained into the reloaded session. When the rejected prompt is restored, text typed during the reload is appended after it instead of being overwritten.


submit(input: UserEntry, options?: LoopSubmitOptions): LoopSubmitResult {
if (this.disposing) throw abortError('Agent loop disposed');
beginSessionRun(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check staleness before persisting prompt metadata

For normal prompts and bundled-skill prompts, AgentPromptChannel calls applyPromptMetadataUpdate before reaching this new stale check. If another process changed the wire journal and released the lock, the rejected client first persists the unsent prompt as lastPrompt (and can generate a title/index update), then beginSessionRun throws session.stale; reloading does not undo that metadata mutation. Perform the stale preflight before prompt metadata is written so a rejected prompt cannot silently alter session metadata.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly addressed in c4c3e88. Whether to derive a title from the prompt is now decided inside the metadata service, after state.json has been re-read for the new lock hold. A stale cache can no longer overwrite a title another client wrote, and SessionMetaUpdated only carries a title that was actually written.

Writing lastPrompt before the stale check is accepted as is. The rejected prompt goes back to the input and is normally resent right after the reload, which records the same lastPrompt. If the user drops it instead, the session list shows that text as the last prompt until the next send. A title is derived from the rejected prompt only when the session is still untitled on disk, and that is the title the resend would produce anyway. Running the stale preflight first would change the call order at six call sites (prompt channel, plugin commands, two skill paths, two kap prompt routes) for a preview field.

Comment on lines +580 to +583
state.knownInteractions.clear();
state.deferredWork = undefined;
state.transcriptSeeded.clear();
state.deferredTranscriptSeeds.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear volatile snapshot trackers when detaching

When a stale kap-server session is detached while another process finishes its turn or subagents, this block clears subscriptions and transcript state but preserves state.tracker and state.roster. Reattachment only listens for future events, so REST snapshots and WebSocket resyncs can continue reporting an obsolete in-flight turn or running subagents until another local turn happens; clear or rebuild both trackers at the detach/reattach boundary.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 45e05a5. detachSession now clears the in-flight turn tracker and the subagent roster for the session, so after reattach the REST snapshot and the WS resync no longer report the turn or the subagents of the closed handle.

"session.init_failed": "Failed to initialize the session.",
"session.closed": "Session was closed.",
"session.fork_active_turn": "Wait for the current response before forking.",
"session.stale": "Session was updated by another client. Reopen the session to load the latest updates.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Surface session conflicts as preflight errors in VS Code

When a VS Code prompt is rejected with session.stale or session.occupied before TurnBegin, SessionRuntime emits a preflight error, whose store handler restores the input without rendering an inline error; the only visible notification is the toast gated by isPreflightError(code). Neither new code is in PREFLIGHT_CODES, and occupancy has no message entry at all, so sending appears to be silently ignored even though this stale message was added. Add both conflict codes to the preflight set and provide the occupancy message or recovery action.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 45e05a5. session.occupied and session.stale are now in PREFLIGHT_CODES, and ERROR_MESSAGES has an entry for session.occupied with the same copy the engine emits. The webview withdraws the message, puts the text back in the input, and shows the toast; the toast text comes from getUserMessage in the extension host.

…sion metadata

applyPromptMetadataUpdate decided whether to derive a title from the
prompt from the in-memory metadata cache, which is only refreshed when
the session lock is re-acquired. If another client named the session in
the meantime, the stale cache still looked untitled, so the next prompt
from this client overwrote the other client's title — both on the stale
path, where the prompt is rejected afterwards, and on the fresh path,
where the other client only changed the title on disk.

Move the untitled check into SessionMetadata.applyPromptMetadata, which
runs after ensureWriteOwnership() has re-read state.json, so the
decision is based on the latest persisted metadata. The published
SessionMetaUpdated event now carries the derived title only when it was
actually written.
- vscode: classify session.occupied and session.stale as preflight errors so the webview toasts them instead of silently swallowing the send, and give session.occupied the same English copy the engine emits (the toast text comes from getUserMessage in the extension host).
- engine: beginSessionRun now takes the session context and agent lifecycle service, and on the first check of each lock hold it runs externallyModified over every loaded agent's wire (scope agents/<agentId>, key wire.jsonl) instead of only the main agent's, so a fork that only touched a subagent journal is detected as stale. All four call sites (loop submit, manual compaction begin, undo, print preflight) pass the extra dependency.
- tui: keep the request in flight across a stale reload instead of dropping to idle, so input submitted during the reload queues rather than racing the closing session; park the queue around switchToSession (resetSessionRuntime would clear it) and drain it into the reloaded session afterwards; when restoring the rejected text, prepend it before content the user typed during the reload instead of overwriting it.
- kap-server: clear the in-flight turn tracker and subagent roster in detachSession so a reattached session no longer reports stale in-flight turns or running subagents in REST snapshots and WS resyncs.
@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 45e05a5d30

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +264 to +266
if (this.tryHoldForWrite()) {
await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Abort creation when initial metadata cannot acquire the lock

When two processes create the same explicit session ID before either has appended its index entry, the second process reaches this branch while the first holds the new session lock. tryHoldForWrite() returns false, but creation continues, mirrors the in-memory metadata, and reports success without writing state.json; subsequent operations run against a ghost scope for the other process's session and can later persist stale state. Propagate session.occupied and abort materialization instead of silently skipping the initial write.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Comment on lines +127 to +132
beginSessionRun(
this.sessionLocks,
this.appendLogStore,
this.session,
this.agentLifecycle,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hold an explicit lease for the entire undo operation

When undo restoration or participant reconciliation takes more than three seconds, beginSessionRun is the only hold acquired here, while ISessionActivityView.busy remains false because undo is not a turn, task, or compaction. The release timer can therefore unlink the lock after the branch switch but before the later restore/reconciliation records finish, allowing another process to mutate the same session concurrently. Acquire an explicit lease around the complete undo transaction.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Comment on lines +217 to +219
private alive(holder: SessionLockHolder): boolean {
if (holder.hostname !== '' && holder.hostname !== this.hostname) return true;
if (!pidAlive(holder.pid)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reclaim stale locks created on another host

When KIMI_CODE_HOME is on shared storage and the lock-owning host crashes or becomes unavailable, any client on a differently named host treats the holder as alive unconditionally. The PID and timestamp are never examined, so the session remains permanently occupied until someone manually deletes the lock file. Use a remotely reclaimable lease or an expiry/liveness mechanism instead of making every foreign-host lock immortal.

AGENTS.md reference: AGENTS.md:L102-L104

Useful? React with 👍 / 👎.

Detect external appends that race journal reads so a later run cannot miss session.stale. Keep deferred wire migrations pending and finish them before appending, while rejecting the append if migration still cannot complete. Restore the complete plugin command after an occupied cancellation, including commands without arguments.
@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 84e33ca296

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +317 to +320
await closeSessionById(app.accessor, resolved.session.id);
const reloaded = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
resolved = reloaded;
restorePermission = reloaded.restorePermission;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the permission mode across stale print reloads

When a resumed print-mode session hits session.stale, resolveNativeSession has already changed its permission mode to auto and saved a closure that restores the previous mode. This closes the old scope without invoking that closure, then replaces restorePermission with the reloaded scope's closure; because closing drains the old wire, the reload observes auto as its previous mode. A one-off headless run can therefore silently leave future interactive clients in auto mode. Restore the original override before closing, or perform stale preflight before applying the temporary permission change.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

);
}

const lastCheckedTokens = new Map<string, string>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear cached staleness tokens after lock holds end

In a long-lived process such as kap-server, the first run of every distinct session inserts its ID and random lock token into this process-global map, but ending the run, closing the session, deleting it, and disposing the lock service never remove the entry. Servers that encounter an unbounded stream of session IDs therefore retain all of those strings for the lifetime of the process. Keep the checked token in the held-lock state or remove it whenever the corresponding lock hold ends.

Useful? React with 👍 / 👎.

Replace the process-global session-id map with a WeakMap keyed by ISessionContext so closed and reloaded sessions do not retain cache entries for the lifetime of the process.
@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 325a97d8ea

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +167 to +169
const current = this.readHolder(reclaimPath);
if (current === undefined || !this.alive(current)) this.unlinkSilent(reclaimPath);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry after clearing a stale reclaim marker

When a process crashes after publishing the .reclaim file but before removing it, the next acquisition's first attempt only deletes that stale reclaim marker and returns; its second and final attempt then acquires the reclaim marker and deletes the original stale lock, but publish() exits the loop and throws session.occupied even though no lock remains. This makes the first prompt after such a crash fail spuriously and forces the user to retry; continue the acquisition loop after either stale marker is removed.

Useful? React with 👍 / 👎.

Comment on lines +1188 to +1191
case 'session.occupied':
reply.send(
errEnvelope(ErrorCode.SESSION_OCCUPIED, err.message, requestId, err.stack, err.details),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Declare occupancy on profile response schemas

When POST /sessions/{session_id}/profile updates a title while another process holds the session, metadata.setTitle() now throws session.occupied and the route reaches this new mapping, but updateProfileRoute declares only validation and not-found responses at lines 502-505. Its Fastify oneOf response serializer therefore cannot serialize the 40931 envelope and replaces the actionable occupancy response with a serialization error; add the conflict code to every route schema that can persist metadata, including title generation.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

Comment on lines +258 to +259
private lockPath(sessionId: string): string {
return join(this.lockDir, `${sessionId}.lock`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Encode session IDs before constructing lock filenames

For public SDK callers, CreateSessionOptions.id is an unconstrained string, so an existing ID such as tenant/session is persisted successfully under nested session directories. This path construction turns that ID into locks/sessions/tenant/session.lock, but ensureLockDir() creates only locks/sessions; the temporary lock write therefore fails with ENOENT, publish() fail-opens, and two processes both believe they hold the session while concurrently appending to it. Encode or hash the opaque session ID into one safe filename rather than treating it as a path.

Useful? React with 👍 / 👎.

Declare SESSION_OCCUPIED and SESSION_STALE in the response schemas for profile updates, title generation, and child creation. Cover the OpenAPI declarations and the existing HTTP 200 conflict envelopes with regression tests.
@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b9d5e945f6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +102 to +103
await this.ensureWriteOwnership();
const lease = this.sessionLocks.acquire(this.ctx.sessionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hold the metadata lease before refreshing state

When a first metadata update begins while SessionLockReleaseService.releaseWhenIdle() is already awaiting the global append-log flush, ensureWriteOwnership() can read state.json under the old run hold, then that callback can unlink the lock before this explicit lease is acquired. Another process can complete an update in that gap; this process then reacquires the lock and writes its pre-gap snapshot, silently overwriting newer title or custom metadata. The fresh evidence after the earlier lease finding is that the lease was added only after the refresh; acquire it before refreshing and retain it through the write.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

requireConfiguredModel(model);
const goalService = agent.accessor.get(IAgentGoalService);
const existing = goalService.getGoal().goal;
if (existing?.status === 'active' && existing.objective === goal.objective) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit goal deduplication to stale retries

For a resumed print session that already has an active goal X, this unconditional same-objective return changes both existing commands: /goal X used to fail with goal.already_exists, while /goal replace X used to clear the old goal and create a new goal ID with reset accounting; both now run another turn against the existing goal. Restrict this idempotence check to the post-stale retry that created that exact goal rather than applying it on the initial request.

AGENTS.md reference: AGENTS.md:L102-L104

Useful? React with 👍 / 👎.

Comment on lines +96 to +101
private tryHoldSessionLock(): boolean {
try {
this.sessionLocks.beginRun(this.sessionContext.sessionId);
return true;
} catch {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate occupancy from durable agent-state writes

When a user-facing durable mutation runs while another process holds the session—for example, POST /sessions/{id}/profile with agent_config.model calls profile.setModel()—the event is folded into memory and the setter returns before persistence. This callback catches session.occupied and converts it to false, so AppendLogStore merely retains the record in memory while the API reports success; because no local lock was acquired, no release timer is scheduled to retry, and closing the scope can drop the update or a later flush can append it after external changes. User-initiated agent-state mutations must acquire and validate ownership and surface the conflict rather than using the best-effort buffering path.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Comment on lines +312 to +317
beginSessionRun(
this.sessionLocks,
this.appendLogStore,
this.sessionContext,
this.agentLifecycle,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Map session conflicts on the skill activation route

When desktop or web invokes POST /sessions/{session_id}/skills/{name}:activate for an occupied or stale loaded session, IAgentSkillService.activate() now reaches this preflight and throws session.occupied or session.stale. The skill route declares neither response and its local mapper rethrows both, so the global handler reports INTERNAL_ERROR; it also never reloads a stale scope, causing every repeated skill activation to fail the same way until another endpoint reloads the session. Add both conflict responses and apply the same stale-session recovery used by the prompt route.

AGENTS.md reference: AGENTS.md:L114-L116

Useful? React with 👍 / 👎.

Move session error mapping and stale reloads into one shared route helper, reuse shared conflict response declarations, and cover skill activation conflicts. Remove the duplicate prompt, session, and skill error mappers while preserving their existing envelopes.
@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b806c3f223

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +184 to +189
if (isError2(error) && error.code === SessionErrors.codes.SESSION_OCCUPIED) {
this.log.warn(
'session metadata agent registration skipped; session is in use by another client',
{ sessionId: this.ctx.sessionId, agentId },
);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate occupancy when registering a new agent

When standalone agent creation such as TUI /btw, kap :btw, or SDK startBtw() runs while another process owns the session, AgentLifecycleService.create() awaits this method, but this branch swallows session.occupied and lets creation report success. The child remains in the in-memory roster without an entry in state.json; if its buffered journal is later persisted after the lock becomes free, it becomes orphaned and disappears on reload. Rethrow the conflict so agent creation rolls back instead of treating registration as best effort.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

let targetId: string | undefined;
let targetSessionDir: string | undefined;
const quiescenceHolds: IDisposable[] = [];
const sourceLease = this.sessionLocks.acquire(sourceId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh a stale source before constructing a fork

When this process has a loaded source session and another client updates and releases it before the fork, this lease acquisition succeeds without running the journal staleness check or refreshing metadata. The later ISessionMetadata.read() therefore returns the old cached title, custom metadata, and agent roster; although copySessionFiles() copies the current disk state, the fork then overwrites the target state.json from that stale cache and silently omits the other client's updates. Validate the loaded journals and reread source metadata under this lease, or reject with session.stale, before building the fork.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

@7Hanrui

7Hanrui commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c0d0b9915

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const tmp = `${lockPath}.tmp-${process.pid}-${++tmpSeq}`;
try {
fs.writeFileSync(tmp, body, { mode: 0o600 });
fs.linkSync(tmp, lockPath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid failing open when hard links are unavailable

When KIMI_CODE_HOME is placed on a filesystem without hard-link support (for example, some FAT, FUSE, or network-mounted volumes), fs.linkSync throws an error such as EPERM or ENOTSUP; publish() catches it and returns a token while creating no lock file. Each process then records the session as locally held and proceeds with writes, so concurrent clients can corrupt the journal despite this feature's occupancy guarantee. Use an atomic primitive supported by the target filesystem, or fail the write instead of continuing unlocked.

Useful? React with 👍 / 👎.


submit(input: UserEntry, options?: LoopSubmitOptions): LoopSubmitResult {
if (this.disposing) throw abortError('Agent loop disposed');
beginSessionRun(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize skill-activation conflicts for SDK callers

When a direct SDK caller invokes Session.activateSkill() on an occupied or stale session, AgentSkillService.activate() reaches this new preflight through loop.submit() and propagates the engine's Error2. Unlike prompt, promptWithSkills, and plugin commands, SDKRpcClientV2.activateSkill() does not pass that exception through restateEngineError, so the public call rejects with a non-KimiError and consumers using the exported isKimiError() cannot recognize or recover from session.occupied or session.stale. Wrap this entry point with the same normalization used by the other prompt-launch methods.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants