From 0ac71420c7a1bfa3a697f0469a2c289786a61187 Mon Sep 17 00:00:00 2001 From: xzjncu Date: Mon, 13 Jul 2026 17:20:37 +0800 Subject: [PATCH] fix: share Codex task state across worktrees Rescue tasks launched from isolated worktrees now remain visible to the parent session, and unspecified rescue runs stay foreground so callers receive the final Codex output instead of an untracked background job. --- plugins/codex/agents/codex-rescue.md | 4 +-- plugins/codex/scripts/lib/state.mjs | 31 +++++++++++++--- .../codex/skills/codex-cli-runtime/SKILL.md | 3 +- tests/commands.test.mjs | 8 ++--- tests/state.test.mjs | 36 ++++++++++++++++--- 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86..ff833bd1 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -20,8 +20,8 @@ Selection guidance: Forwarding rules: - Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...`. -- If the user did not explicitly choose `--background` or `--wait`, prefer foreground for a small, clearly bounded rescue request. -- If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution. +- If the user did not explicitly choose `--background` or `--wait`, run the task in the foreground so the caller receives its final stdout. Do not infer background execution from task complexity. +- Add `--background` only when the user explicitly supplies it; treat `--wait` as an explicit foreground request. - You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better Codex prompt before forwarding it. - Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text. - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498..bd29a80f 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; @@ -26,18 +27,38 @@ function defaultState() { }; } +function resolveRepositoryIdentity(workspaceRoot) { + try { + const commonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { + cwd: workspaceRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + const mainWorktree = execFileSync("git", ["--git-dir", commonDir, "worktree", "list", "--porcelain"], { + cwd: workspaceRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).match(/^worktree (.+)$/m)?.[1]; + return mainWorktree ?? commonDir; + } catch { + return null; + } +} + export function resolveStateDir(cwd) { const workspaceRoot = resolveWorkspaceRoot(cwd); - let canonicalWorkspaceRoot = workspaceRoot; + const repositoryIdentity = resolveRepositoryIdentity(workspaceRoot); + const stateIdentity = repositoryIdentity ?? workspaceRoot; + let canonicalStateIdentity = stateIdentity; try { - canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot); + canonicalStateIdentity = fs.realpathSync.native(stateIdentity); } catch { - canonicalWorkspaceRoot = workspaceRoot; + canonicalStateIdentity = stateIdentity; } - const slugSource = path.basename(workspaceRoot) || "workspace"; + const slugSource = path.basename(stateIdentity) || "workspace"; const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; - const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16); + const hash = createHash("sha256").update(canonicalStateIdentity).digest("hex").slice(0, 16); const pluginDataDir = process.env[PLUGIN_DATA_ENV]; const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; return path.join(stateRoot, `${slug}-${hash}`); diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb5..f989c690 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -25,7 +25,8 @@ Execution rules: Command selection: - Use exactly one `task` invocation per rescue handoff. -- If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. +- If the forwarded request omits `--background` and `--wait`, run `task` in the foreground. Do not infer background execution from task complexity; the caller expects the final stdout unchanged. +- If the forwarded request explicitly includes `--background`, strip it from the task text and add `--background` to `task`; if it includes `--wait`, strip it and keep `task` in the foreground. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b0605..cbd1fd78 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -127,8 +127,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /--resume/); assert.match(agent, /--fresh/); assert.match(agent, /thin forwarding wrapper/i); - assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i); - assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i); + assert.match(agent, /run the task in the foreground so the caller receives its final stdout/i); + assert.doesNotMatch(agent, /prefer background execution/i); assert.match(agent, /Use exactly one `Bash` call/i); assert.match(agent, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(agent, /Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`/i); @@ -148,8 +148,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /Leave `--effort` unset unless the user explicitly requests a specific effort/i); assert.match(runtimeSkill, /Leave model unset by default/i); assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i); - assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); - assert.match(runtimeSkill, /Strip it before calling `task`/i); + assert.match(runtimeSkill, /run `task` in the foreground/i); + assert.match(runtimeSkill, /If the forwarded request explicitly includes `--background`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57ce..9cf4237d 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -4,16 +4,27 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { makeTempDir } from "./helpers.mjs"; +import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + delete process.env.CLAUDE_PLUGIN_DATA; - assert.equal(stateDir.startsWith(os.tmpdir()), true); - assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); - assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + try { + const stateDir = resolveStateDir(workspace); + + assert.equal(stateDir.startsWith(os.tmpdir()), true); + assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); + assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } }); test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { @@ -40,6 +51,21 @@ test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { } }); +test("resolveStateDir shares state between a repository and its worktree", () => { + const repository = makeTempDir(); + const worktree = makeTempDir(); + initGitRepo(repository); + fs.writeFileSync(path.join(repository, "README.md"), "fixture\n", "utf8"); + run("git", ["add", "README.md"], { cwd: repository }); + run("git", ["commit", "-m", "fixture"], { cwd: repository }); + run("git", ["worktree", "add", "--detach", worktree, "HEAD"], { cwd: repository }); + + try { + assert.equal(resolveStateDir(worktree), resolveStateDir(repository)); + } finally { + run("git", ["worktree", "remove", "--force", worktree], { cwd: repository }); + } +}); test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => { const workspace = makeTempDir(); const stateFile = resolveStateFile(workspace);