Skip to content

Commit 9d6fc05

Browse files
committed
Merge PR openai#497: task runner liveness + workspace pin + cancellation safety
# Conflicts: # plugins/codex/commands/rescue.md # plugins/codex/scripts/codex-companion.mjs # tests/runtime.test.mjs
2 parents a4792d9 + 048f433 commit 9d6fc05

12 files changed

Lines changed: 734 additions & 57 deletions

File tree

plugins/codex/agents/codex-rescue.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Forwarding rules:
2222
- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...`.
2323
- If the user did not explicitly choose `--background` or `--wait`, prefer foreground for a small, clearly bounded rescue request.
2424
- 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.
25+
- For any task expected to exceed a few minutes, add `--background` to the `task` invocation by default so a caller timeout cannot terminate it.
26+
- Treat `--cwd <dir>` and `-C <dir>` as workspace routing controls. Pass `--cwd <dir>` explicitly on every `task` invocation, using the intended workspace root forwarded by the caller.
2527
- You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better Codex prompt before forwarding it.
2628
- 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.
2729
- 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.

plugins/codex/commands/rescue.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent
3-
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <model|spark|sol|terra|luna>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [what Codex should investigate, solve, or continue]"
3+
argument-hint: "[--background|--wait] [--cwd <dir>|-C <dir>] [--resume|--fresh] [--model <model|spark|sol|terra|luna>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [what Codex should investigate, solve, or continue]"
44
allowed-tools: Bash(node:*), AskUserQuestion, Agent
55
---
66

@@ -18,6 +18,7 @@ Execution mode:
1818
- If neither flag is present, default to foreground.
1919
- `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text.
2020
- `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text.
21+
- `--cwd <dir>` and `-C <dir>` are workspace-routing flags. Preserve the directory for the resume preflight and the forwarded `task` call, but do not treat either form as part of the natural-language task text.
2122
- If the request includes `--resume`, do not ask whether to continue. The user already chose.
2223
- If the request includes `--fresh`, do not ask whether to continue. The user already chose.
2324
- Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running:
@@ -26,6 +27,12 @@ Execution mode:
2627
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate --json
2728
```
2829

30+
- If the request includes `--cwd <dir>` or `-C <dir>`, pass the same directory to that helper as `--cwd <dir>`:
31+
32+
```bash
33+
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate --json --cwd "<dir>"
34+
```
35+
2936
- If that helper reports `available: true`, use `AskUserQuestion` exactly once to ask whether to continue the current Codex thread or start a new one.
3037
- The two choices must be:
3138
- `Continue current Codex thread`
@@ -39,6 +46,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate -
3946
Operating rules:
4047

4148
- The subagent is a thin forwarder only. It should use one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...` and return that command's stdout as-is.
49+
- Pass `--cwd <dir>` explicitly for the intended workspace root. For work expected to exceed a few minutes, have the subagent add the task command's own `--background` flag so a caller timeout cannot terminate it.
4250
- Return the Codex companion stdout verbatim to the user.
4351
- Do not paraphrase, summarize, rewrite, or add commentary before or after it.
4452
- Do not ask the subagent to inspect files, monitor progress, poll `/codex:status`, fetch `/codex:result`, call `/codex:cancel`, summarize output, or do follow-up work of its own.

plugins/codex/scripts/codex-companion.mjs

Lines changed: 90 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
readStoredJob,
3939
resolveCancelableJob,
4040
resolveResultJob,
41+
settleCancellationAfterTermination,
4142
sortJobsNewestFirst
4243
} from "./lib/job-control.mjs";
4344
import {
@@ -66,6 +67,7 @@ const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
6667
const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json");
6768
const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000;
6869
const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
70+
const TASK_WORKER_RECORD_WAIT_TIMEOUT_MS = 1000;
6971
const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
7072
const VALID_REASONING_EFFORTS = new Set(REASONING_EFFORTS);
7173
const MODEL_ALIASES = new Map([
@@ -83,7 +85,7 @@ function printUsage() {
8385
" node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]",
8486
" node scripts/codex-companion.mjs review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [--model <model|spark|sol|terra|luna>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>]",
8587
" node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [--model <model|spark|sol|terra|luna>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [focus text]",
86-
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark|sol|terra|luna>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [prompt]",
88+
" node scripts/codex-companion.mjs task [--background] [--write] [--cwd <dir>] [--resume-last|--resume|--fresh] [--model <model|spark|sol|terra|luna>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [prompt]",
8789
" node scripts/codex-companion.mjs transfer [--source <claude-jsonl>] [--json]",
8890
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
8991
" node scripts/codex-companion.mjs result [job-id] [--json]",
@@ -160,10 +162,41 @@ function resolveCommandWorkspace(options = {}) {
160162
return resolveWorkspaceRoot(resolveCommandCwd(options));
161163
}
162164

165+
function resolveTaskCwd(options = {}) {
166+
const cwd = resolveCommandCwd(options);
167+
if (!options.cwd) {
168+
return cwd;
169+
}
170+
171+
let stats;
172+
try {
173+
stats = fs.statSync(cwd);
174+
} catch (error) {
175+
if (error?.code === "ENOENT") {
176+
throw new Error(`Task workspace directory does not exist: ${cwd}`);
177+
}
178+
throw error;
179+
}
180+
if (!stats.isDirectory()) {
181+
throw new Error(`Task workspace path is not a directory: ${cwd}`);
182+
}
183+
return cwd;
184+
}
185+
163186
function sleep(ms) {
164187
return new Promise((resolve) => setTimeout(resolve, ms));
165188
}
166189

190+
async function waitForStoredJob(workspaceRoot, jobId) {
191+
const deadline = Date.now() + TASK_WORKER_RECORD_WAIT_TIMEOUT_MS;
192+
let storedJob = readStoredJob(workspaceRoot, jobId);
193+
while (!storedJob && Date.now() < deadline) {
194+
await sleep(25);
195+
storedJob = readStoredJob(workspaceRoot, jobId);
196+
}
197+
return storedJob;
198+
}
199+
167200
function shorten(text, limit = 96) {
168201
const normalized = String(text ?? "").trim().replace(/\s+/g, " ");
169202
if (!normalized) {
@@ -780,7 +813,7 @@ async function handleTask(argv) {
780813
}
781814
});
782815

783-
const cwd = resolveCommandCwd(options);
816+
const cwd = resolveTaskCwd(options);
784817
const workspaceRoot = resolveCommandWorkspace(options);
785818
const model = normalizeRequestedModel(options.model);
786819
const effort = normalizeReasoningEffort(options.effort);
@@ -858,9 +891,13 @@ async function handleTaskWorker(argv) {
858891

859892
const cwd = resolveCommandCwd(options);
860893
const workspaceRoot = resolveCommandWorkspace(options);
861-
const storedJob = readStoredJob(workspaceRoot, options["job-id"]);
894+
const storedJob = await waitForStoredJob(workspaceRoot, options["job-id"]);
862895
if (!storedJob) {
863-
throw new Error(`No stored job found for ${options["job-id"]}.`);
896+
return;
897+
}
898+
if (storedJob.status === "cancelled") {
899+
appendLogLine(storedJob.logFile, "Skipped cancelled background job.");
900+
return;
864901
}
865902

866903
const request = storedJob.request;
@@ -943,8 +980,8 @@ function handleTaskResumeCandidate(argv) {
943980
booleanOptions: ["json"]
944981
});
945982

946-
const cwd = resolveCommandCwd(options);
947-
const workspaceRoot = resolveCommandWorkspace(options);
983+
const cwd = resolveTaskCwd(options);
984+
const workspaceRoot = resolveWorkspaceRoot(cwd);
948985
const sessionId = getCurrentClaudeSessionId();
949986
const jobs = filterJobsForCurrentClaudeSession(sortJobsNewestFirst(listJobs(workspaceRoot)));
950987
const candidate = findLatestResumableTaskJob(jobs);
@@ -984,6 +1021,34 @@ async function handleCancel(argv) {
9841021
const existing = readStoredJob(workspaceRoot, job.id) ?? {};
9851022
const threadId = existing.threadId ?? job.threadId ?? null;
9861023
const turnId = existing.turnId ?? job.turnId ?? null;
1024+
const completedAt = nowIso();
1025+
const cancellingJob = {
1026+
...job,
1027+
status: "cancelled",
1028+
phase: "cancelled",
1029+
completedAt,
1030+
errorMessage: "Cancelled by user."
1031+
};
1032+
const persistCancellation = (pid) => {
1033+
const cancelledJob = { ...cancellingJob, pid };
1034+
writeJobFile(workspaceRoot, job.id, {
1035+
...existing,
1036+
...cancelledJob,
1037+
cancelledAt: completedAt
1038+
});
1039+
upsertJob(workspaceRoot, {
1040+
id: job.id,
1041+
status: "cancelled",
1042+
phase: "cancelled",
1043+
pid,
1044+
errorMessage: "Cancelled by user.",
1045+
completedAt
1046+
});
1047+
return cancelledJob;
1048+
};
1049+
1050+
persistCancellation(job.pid ?? null);
1051+
appendLogLine(job.logFile, "Cancelled by user.");
9871052

9881053
const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId });
9891054
if (interrupt.attempted) {
@@ -995,32 +1060,27 @@ async function handleCancel(argv) {
9951060
);
9961061
}
9971062

998-
terminateProcessTree(job.pid ?? Number.NaN);
999-
appendLogLine(job.logFile, "Cancelled by user.");
1063+
let termination = null;
1064+
let terminationError = null;
1065+
try {
1066+
termination = terminateProcessTree(job.pid ?? Number.NaN);
1067+
} catch (error) {
1068+
terminationError = error;
1069+
}
10001070

1001-
const completedAt = nowIso();
1002-
const nextJob = {
1003-
...job,
1004-
status: "cancelled",
1005-
phase: "cancelled",
1006-
pid: null,
1007-
completedAt,
1008-
errorMessage: "Cancelled by user."
1009-
};
1071+
const outcome = settleCancellationAfterTermination(
1072+
workspaceRoot,
1073+
job,
1074+
existing,
1075+
termination,
1076+
terminationError
1077+
);
1078+
if (!outcome.processStopped) {
1079+
appendLogLine(job.logFile, outcome.job.errorMessage);
1080+
throw outcome.error;
1081+
}
10101082

1011-
writeJobFile(workspaceRoot, job.id, {
1012-
...existing,
1013-
...nextJob,
1014-
cancelledAt: completedAt
1015-
});
1016-
upsertJob(workspaceRoot, {
1017-
id: job.id,
1018-
status: "cancelled",
1019-
phase: "cancelled",
1020-
pid: null,
1021-
errorMessage: "Cancelled by user.",
1022-
completedAt
1023-
});
1083+
const nextJob = persistCancellation(null);
10241084

10251085
const payload = {
10261086
jobId: job.id,

plugins/codex/scripts/lib/args.mjs

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,21 +73,29 @@ export function parseArgs(argv, config = {}) {
7373
return { options, positionals };
7474
}
7575

76-
export function splitRawArgumentString(raw) {
76+
function parseRawArgumentString(raw, literalClosingQuoteBackslash) {
7777
const tokens = [];
7878
let current = "";
7979
let quote = null;
80-
let escaping = false;
8180

82-
for (const character of raw) {
83-
if (escaping) {
84-
current += character;
85-
escaping = false;
86-
continue;
87-
}
81+
for (let index = 0; index < raw.length; index += 1) {
82+
const character = raw[index];
8883

8984
if (character === "\\") {
90-
escaping = true;
85+
const nextCharacter = raw[index + 1];
86+
if (literalClosingQuoteBackslash && quote !== null && nextCharacter === quote) {
87+
current += character;
88+
continue;
89+
}
90+
if (
91+
nextCharacter !== undefined &&
92+
(nextCharacter === "'" || nextCharacter === "\"" || nextCharacter === "\\" || /\s/.test(nextCharacter))
93+
) {
94+
current += nextCharacter;
95+
index += 1;
96+
} else {
97+
current += character;
98+
}
9199
continue;
92100
}
93101

@@ -116,13 +124,18 @@ export function splitRawArgumentString(raw) {
116124
current += character;
117125
}
118126

119-
if (escaping) {
120-
current += "\\";
121-
}
122-
123127
if (current) {
124128
tokens.push(current);
125129
}
126130

127-
return tokens;
131+
return { tokens, openQuote: quote };
132+
}
133+
134+
export function splitRawArgumentString(raw) {
135+
const escapePreferred = parseRawArgumentString(raw, false);
136+
if (escapePreferred.openQuote === null) {
137+
return escapePreferred.tokens;
138+
}
139+
140+
return parseRawArgumentString(raw, true).tokens;
128141
}

plugins/codex/scripts/lib/job-control.mjs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import fs from "node:fs";
22

33
import { getSessionRuntimeStatus } from "./codex.mjs";
4-
import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs";
4+
import { isProcessAlive } from "./process.mjs";
5+
import { getConfig, listJobs, readJobFile, resolveJobFile, upsertJob, writeJobFile } from "./state.mjs";
56
import { SESSION_ID_ENV } from "./tracked-jobs.mjs";
67
import { resolveWorkspaceRoot } from "./workspace.mjs";
78

89
export const DEFAULT_MAX_STATUS_JOBS = 8;
910
export const DEFAULT_MAX_PROGRESS_LINES = 4;
11+
export const CANCELLATION_TERMINATION_FAILED_MESSAGE =
12+
"Cancellation requested but process termination failed; retry /codex:cancel.";
1013

1114
export function sortJobsNewestFirst(jobs) {
1215
return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
@@ -188,6 +191,48 @@ export function readStoredJob(workspaceRoot, jobId) {
188191
return readJobFile(jobFile);
189192
}
190193

194+
export function settleCancellationAfterTermination(
195+
workspaceRoot,
196+
job,
197+
existing,
198+
termination,
199+
terminationError = null,
200+
options = {}
201+
) {
202+
const terminationFailed = Boolean(terminationError) || termination?.delivered !== true;
203+
const pid = job.pid ?? null;
204+
const isProcessAliveImpl = options.isProcessAliveImpl ?? isProcessAlive;
205+
206+
if (!terminationFailed || !Number.isInteger(pid) || pid <= 0 || !isProcessAliveImpl(pid)) {
207+
return { processStopped: true, job: null, error: null };
208+
}
209+
210+
const restoredJob = {
211+
...existing,
212+
...job,
213+
status: job.status,
214+
phase: job.phase ?? existing.phase ?? job.status,
215+
pid,
216+
errorMessage: CANCELLATION_TERMINATION_FAILED_MESSAGE
217+
};
218+
writeJobFile(workspaceRoot, job.id, restoredJob);
219+
upsertJob(workspaceRoot, {
220+
...job,
221+
status: job.status,
222+
phase: restoredJob.phase,
223+
pid,
224+
completedAt: job.completedAt ?? null,
225+
cancelledAt: job.cancelledAt ?? null,
226+
errorMessage: CANCELLATION_TERMINATION_FAILED_MESSAGE
227+
});
228+
229+
return {
230+
processStopped: false,
231+
job: restoredJob,
232+
error: terminationError ?? new Error(CANCELLATION_TERMINATION_FAILED_MESSAGE)
233+
};
234+
}
235+
191236
function matchJobReference(jobs, reference, predicate = () => true) {
192237
const filtered = jobs.filter(predicate);
193238
if (!reference) {

0 commit comments

Comments
 (0)