diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 310f66e..b561485 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.5.3", + "version": "1.5.4", "description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, tracked tasks, and transcript transfers from inside Codex.", "author": { "name": "CBEPX", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3acd1db..392a8cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: - run: npm run typecheck - run: npm run typecheck:tests - run: npm run test:cross-platform + - run: npm run test:lifecycle-contract macos-full: name: Full CI (macos-latest) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79beafa..846bf4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## [Unreleased] +## v1.5.4 + +### Added + +- Add a read-only `setup --check` preflight with runtime source, plugin version, config path, workspace diagnostics, and explicit pending hook-trust reporting. +- Add the task-specific `--wait-timeout-ms` flag while retaining `--timeout-ms` as a deprecated compatibility alias. + +### Changed + +- Make review and rescue forwarding poll a yielded shell session to command exit and treat only exit code 0 as success; exit 124 remains retrievable work and other failures keep their original diagnostics. + +### Fixed + +- Track the detached worker separately from its Claude child so a leader PID exit cannot auto-reap a live worker, publish parent worker metadata through a queued-only CAS, preserve unresolved child handles as `cancel_failed`, and prevent late progress writers from overwriting terminal state. +- Announce failed, cancel-failed, and unknown background outcomes instead of silently surfacing completed jobs only, while marking rendered foreground outcomes viewed through a terminal-only CAS. +- Resolve resume candidates and execution through the same owner-scoped policy, accept completed or failed tasks, ignore foreign active tasks, and pass only a real Claude session ID to `claude --resume`; direct CLI callers must provide the owner session ID, while the rescue skill supplies its routed marker-derived owner. +- Classify Fable model-credit limits and authentication expiry with actionable output instead of generic failures. +- Write setup changes to `config.toml` through the existing atomic, symlink-aware writer. + ## v1.5.3 ### Fixed diff --git a/README.md b/README.md index b36cc32..69684a5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex Install the fork release from the CBEPX marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.3 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.4 codex plugin add cc@cbepx ``` @@ -59,8 +59,8 @@ The optional `npx` helper can install this fork release and enable the required ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.3 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.3/cc-plugin-codex-1.5.3.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.4 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.4/cc-plugin-codex-1.5.4.tgz install ``` On Windows, prefer the marketplace path or the `npx` helper. The shell-script helper below is POSIX-only. @@ -200,7 +200,8 @@ $cc:rescue --model sonnet --effort medium investigate the flaky test | `--model ` | Claude model (`opus`, `sonnet`, `haiku`, `fable`, or full ID; defaults to `opus`). Aliases are resolved by Claude Code; a full ID pins a version. | | `--effort ` | Reasoning effort: `low`, `medium`, `high`, `xhigh`, `max` (default: `xhigh` for opus, `high` for sonnet, unset for haiku and fable) | | `--prompt-file ` | Read task description from a file | -| `--timeout-ms ` | Foreground observer timeout before returning a retrievable job | +| `--wait-timeout-ms ` | Foreground observer timeout before returning a retrievable job | +| `--timeout-ms ` | Deprecated alias for `--wait-timeout-ms` | **Resume behavior:** If you don't pass `--resume` or `--fresh`, rescue checks for a resumable Claude session and asks once whether to continue or start fresh. Your phrasing guides the recommendation — "continue the last run" → resume, "start over" → fresh. @@ -257,11 +258,12 @@ $cc:cancel task-abc123 # cancel a running job ```text $cc:setup # verify everything +$cc:setup --check # read-only readiness check $cc:setup --enable-review-gate # turn on stop-time review gate $cc:setup --disable-review-gate # turn it off ``` -Setup checks Claude Code availability, native plugin hook feature gates, and review-gate state. If Claude Code isn't installed, it offers to install it. +Setup checks Claude Code availability, native plugin hook feature gates, and review-gate state. `--check` reports required repairs without changing config or hook trust. If Claude Code isn't installed, setup offers to install it. This is also the repair path for marketplace-installed copies of the plugin: `$cc:setup` confirms `[features].hooks = true` and `[features].plugin_hooks = true`, then trusts this plugin's current native hook hashes so Codex loads the bundled hooks from the active plugin cache. ## Background Jobs @@ -334,7 +336,7 @@ The review gate is an **optional** stop-time hook. When enabled, pressing Ctrl+C Install from the fork's marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.3 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.4 codex plugin add cc@cbepx ``` @@ -355,8 +357,8 @@ This fork does not install from the upstream Sendbird marketplace. Use the CBEPX ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.3 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.3/cc-plugin-codex-1.5.3.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.4 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.4/cc-plugin-codex-1.5.4.tgz install ``` After install, run: @@ -386,7 +388,7 @@ $cc:setup Re-run the fork marketplace install flow, pinned to the release you want: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.3 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.4 codex plugin add cc@cbepx ``` diff --git a/hooks/unread-result-hook.mjs b/hooks/unread-result-hook.mjs index f5c83f1..953bb67 100644 --- a/hooks/unread-result-hook.mjs +++ b/hooks/unread-result-hook.mjs @@ -11,7 +11,13 @@ import { fileURLToPath } from "node:url"; import { readHookInput } from "./lib/hook-input.mjs"; import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs"; -import { getConfig, listJobs, patchJob, writeTurnBaseline } from "../scripts/lib/state.mjs"; +import { + getConfig, + listJobs, + patchJob, + TERMINAL_JOB_STATUSES, + writeTurnBaseline, +} from "../scripts/lib/state.mjs"; import { getWorkingTreeFingerprint } from "../scripts/lib/git.mjs"; import { nowIso, SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs"; @@ -26,7 +32,7 @@ function isExplicitClaudeStatusRequest(prompt) { } function summarizeJob(job) { - const parts = [job.id]; + const parts = [job.id, job.status]; if (job.kindLabel) parts.push(job.kindLabel); if (job.summary) parts.push(job.summary); return parts.join(" | "); @@ -37,33 +43,34 @@ function buildAdditionalContext(jobs) { const remaining = jobs.length - listed.length; const intro = jobs.length === 1 - ? "A Claude Code background job from this session has finished and has not been surfaced yet." - : `${jobs.length} Claude Code background jobs from this session have finished and have not been surfaced yet.`; + ? "A Claude Code background job from this session reached a terminal state and has not been surfaced yet." + : `${jobs.length} Claude Code background jobs from this session reached a terminal state and have not been surfaced yet.`; const guidance = jobs.length === 1 - ? `Before handling the new request, briefly mention that ${jobs[0].id} finished and ask whether the user wants to inspect its result first or continue with the new request. If they want the result, direct them to \`$cc:result ${jobs[0].id}\`. If the user is clearly asking about this finished work already, answer that directly instead of asking again. Do not bring this completion up again automatically after this turn.` - : "Before handling the new request, briefly mention that these Claude Code jobs finished and ask whether the user wants to inspect them first or continue with the new request. If they want to inspect them, direct them to `$cc:status` first, then `$cc:result ` for a specific finished job. If the user is clearly asking about this finished work already, answer that directly instead of asking again. Do not bring these completions up again automatically after this turn."; + ? `Before handling the new request, briefly mention that ${jobs[0].id} reached ${jobs[0].status} and ask whether the user wants to inspect its result first or continue with the new request. If they want the result, direct them to \`$cc:result ${jobs[0].id}\`. If the user is clearly asking about this work already, answer that directly instead of asking again. Do not bring this outcome up again automatically after this turn.` + : "Before handling the new request, briefly mention that these Claude Code jobs reached terminal states and ask whether the user wants to inspect them first or continue with the new request. If they want to inspect them, direct them to `$cc:status` first, then `$cc:result ` for a specific job. If the user is clearly asking about this work already, answer that directly instead of asking again. Do not bring these outcomes up again automatically after this turn."; return [ intro, "", - "Finished jobs:", + "Terminal jobs:", ...listed, - ...(remaining > 0 ? [`- and ${remaining} more finished Claude Code job(s)`] : []), + ...(remaining > 0 ? [`- and ${remaining} more terminal Claude Code job(s)`] : []), "", guidance, ].join("\n"); } -function selectUnreadCompletedJobs(workspaceRoot, sessionId) { +function selectUnreadTerminalJobs(workspaceRoot, sessionId) { if (!sessionId) { return []; } return listJobs(workspaceRoot) .filter((job) => job.sessionId === sessionId) - .filter((job) => job.status === "completed") + .filter((job) => TERMINAL_JOB_STATUSES.has(job.status)) + .filter((job) => job.status !== "cancelled") .filter((job) => !job.resultViewedAt) .filter((job) => !job.notifiedAt) .sort((left, right) => @@ -125,7 +132,7 @@ async function main() { return; } - const jobs = selectUnreadCompletedJobs(workspaceRoot, sessionId); + const jobs = selectUnreadTerminalJobs(workspaceRoot, sessionId); if (jobs.length === 0) { return; } diff --git a/internal-skills/cli-runtime/runtime.md b/internal-skills/cli-runtime/runtime.md index 70d2721..5e25bd5 100644 --- a/internal-skills/cli-runtime/runtime.md +++ b/internal-skills/cli-runtime/runtime.md @@ -46,9 +46,11 @@ Task defaults: - Use a structured file-write path to create that prompt file when possible. Do not solve shell quoting by wrapping the same long task inside another brittle inline shell command. - If the tool output includes stderr progress chatter and a final stdout-style result, ignore the progress chatter and preserve only the final stdout-equivalent result text. - Return the stdout of the `task` command exactly as-is. -- Run the companion command as one blocking foreground shell-tool call, not as a background terminal/session. -- Do not request a shell session id, poll a shell session later, or return before the companion command exits. -- If the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call. +- Run the companion command in the foreground; do not add shell backgrounding such as `&`, `nohup`, or detached `spawn`. +- If the shell tool returns a session id, keep polling that same session until the companion command exits. +- Exit code 0 is the only successful completion. +- Exit code 124 means the job is still running; return the companion output without claiming it finished. +- For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification. - If the parent supplied a non-empty parent thread id for background completion, allow at most one success-only `send_input` notification before finishing. - Mention the tool name `send_input` literally when describing that notification path. - Use the exact tool shape `send_input({ target: , message: })`. diff --git a/internal-skills/review-runtime/runtime.md b/internal-skills/review-runtime/runtime.md index 4063429..6dfc3f1 100644 --- a/internal-skills/review-runtime/runtime.md +++ b/internal-skills/review-runtime/runtime.md @@ -36,9 +36,11 @@ Background contract: - return stdout only - ignore stderr progress chatter such as `[cc] ...` - do not inspect the repo or perform the review itself - - run the companion command as one blocking foreground shell-tool call, not as a background terminal/session - - do not request a shell session id, poll a shell session later, or return before the companion command exits - - if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call + - run the companion command in the foreground; do not add shell backgrounding such as `&`, `nohup`, or detached `spawn` + - If the shell tool returns a session id, keep polling that same session until the companion command exits. + - Exit code 0 is the only successful completion. + - Exit code 124 means the job is still running; return the companion output without claiming it finished. + - For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification. - use at most one `send_input` completion notification on success - mention the tool name `send_input` literally in the child instructions - use the exact tool shape `send_input({ target: , message: })` diff --git a/package-lock.json b/package-lock.json index 2c90d6d..3e86de8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.5.3", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.5.3", + "version": "1.5.4", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" diff --git a/package.json b/package.json index 1a45db9..b0f5d9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.5.3", + "version": "1.5.4", "description": "Claude Code Plugin for Codex (CBEPX fork)", "type": "module", "author": { @@ -61,6 +61,7 @@ "test": "node --import ./tests/test-env.mjs --test tests/*.test.mjs", "test:coverage": "c8 --all --include='scripts/**/*.mjs' --include='hooks/**/*.mjs' --reporter=text --reporter=json-summary --reporter=lcov --reports-dir=reports/coverage --check-coverage --lines=89 --statements=89 --branches=79 --functions=96 node --import ./tests/test-env.mjs --test tests/*.test.mjs tests/integration/*.test.mjs tests/e2e/*.test.mjs", "test:cross-platform": "node --import ./tests/test-env.mjs --test tests/args.test.mjs tests/cancel-command.test.mjs tests/changelog.test.mjs tests/claude-cli.test.mjs tests/fs.test.mjs tests/install-hooks.test.mjs tests/mutation-config.test.mjs tests/plugin-install-guard.test.mjs tests/process.test.mjs tests/prompts.test.mjs tests/render.test.mjs tests/sandbox-modes.test.mjs tests/skills-contracts.test.mjs tests/structured-output.test.mjs tests/version-sync.test.mjs", + "test:lifecycle-contract": "node --import ./tests/test-env.mjs --test --test-name-pattern=\"keeps a running job while its owning worker is alive|falls back to the identity-checked Claude PID|terminates a live Claude child|bounds Windows Claude child cleanup|clears a recycled Claude child PID|clears an identity-unavailable Claude child|reports cancel_failed when a live Claude child|tracks the worker separately|logs when worker identity is unavailable|does not bypass a terminal writer|ignores progress after its job file disappears\" tests/state.test.mjs tests/tracked-jobs.test.mjs", "test:integration": "node --import ./tests/test-env.mjs --test tests/integration/*.test.mjs", "test:mutation": "npm run test:mutation:pr", "test:mutation:pr": "npm run test:mutation:critical && npm run test:mutation:shard:managed && npm run test:mutation:shard:installer", diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index c6afd22..4b87acb 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -56,6 +56,7 @@ import { pruneStaleReviewWorktrees, } from "./lib/review-worktree.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; +import { writeTextAtomic } from "./lib/managed-global-integration.mjs"; import { collectReviewContext, ensureGitRepository, @@ -78,11 +79,13 @@ import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { parseStructuredOutput } from "./lib/structured-output.mjs"; import { ACTIVE_JOB_STATUSES, + TERMINAL_JOB_STATUSES, generateJobId, getConfig, getCurrentSession, listJobs, patchJob, + readJobFile, JOB_RESERVATION_SUFFIX, resolveJobsDir, resolveJobLogFile, @@ -141,10 +144,10 @@ function printUsage() { console.log( [ "Usage:", - " node scripts/claude-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", + " node scripts/claude-companion.mjs setup [--check] [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/claude-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--user-mcp-tool ...] [--allow-project-mcp-servers]", " node scripts/claude-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--user-mcp-tool ...] [--allow-project-mcp-servers] [focus text]", - " node scripts/claude-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--timeout-ms ] [prompt]", + " node scripts/claude-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--wait-timeout-ms ] [prompt]", " node scripts/claude-companion.mjs transfer [--source ] [--json]", " node scripts/claude-companion.mjs status [job-id] [--all] [--json]", " node scripts/claude-companion.mjs result [job-id] [--json]", @@ -358,6 +361,9 @@ function firstMeaningfulLine(text, fallback) { } function formatClaudeFailureSummary(failure, fallback) { + if (failure?.kind === "claude_auth") { + return "Claude Code authentication failed; run `claude auth login`."; + } if (failure?.kind !== "claude_rate_limit") { return fallback; } @@ -458,8 +464,7 @@ function readCodexConfig() { } function writeCodexConfig(content) { - fs.mkdirSync(path.dirname(CODEX_CONFIG_TOML), { recursive: true }); - fs.writeFileSync(CODEX_CONFIG_TOML, content, "utf8"); + writeTextAtomic(CODEX_CONFIG_TOML, content); } function configureNativePluginHooks() { @@ -539,7 +544,8 @@ function hookNeedsTrust(hook) { return trustStatus === "untrusted" || trustStatus === "modified"; } -async function repairNativePluginHookTrust(cwd) { +async function repairNativePluginHookTrust(cwd, options = {}) { + const repair = options.repair !== false; const pluginInfo = currentPluginCacheInstallInfo(); if (!shouldRepairPluginHookTrust()) { return { @@ -592,6 +598,17 @@ async function repairNativePluginHookTrust(cwd) { }; } + if (!repair) { + return { + attempted: true, + ready: false, + found: pluginHooks.length, + trusted: 0, + pendingTrust: untrustedHooks.length, + detail: `${untrustedHooks.length} native plugin hook(s) require trust`, + }; + } + const value = Object.fromEntries( untrustedHooks.map((hook) => [ hook.key, @@ -672,7 +689,24 @@ function ensureClaudeReady(cwd) { } } -function buildSetupReport(cwd, actionsTaken = [], hookTrust = null) { +function buildSetupDiagnostics(cwd) { + const pluginInfo = currentPluginCacheInstallInfo(); + let packageVersion = null; + try { + packageVersion = JSON.parse( + fs.readFileSync(path.join(ROOT_DIR, "package.json"), "utf8") + ).version ?? null; + } catch {} + return { + runtimeSource: pluginInfo ? "installed-cache" : "source-checkout", + pluginVersion: pluginInfo?.version ?? packageVersion, + pluginRoot: CANONICAL_ROOT_DIR, + configPath: CODEX_CONFIG_TOML, + workspaceRoot: resolveWorkspaceRoot(cwd), + }; +} + +function buildSetupReport(cwd, actionsTaken = [], hookTrust = null, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const nodeStatus = binaryAvailable("node", ["--version"], { cwd }); const claudeStatus = getClaudeAvailability(cwd); @@ -688,10 +722,18 @@ function buildSetupReport(cwd, actionsTaken = [], hookTrust = null) { nextSteps.push("Run `claude auth login`."); } if (!hooksStatus.installed) { - nextSteps.push("Run `$cc:setup` again after enabling native Codex plugin hooks."); + nextSteps.push( + options.checkOnly + ? "Run `$cc:setup` to enable native Codex plugin hooks." + : "Run `$cc:setup` again after enabling native Codex plugin hooks." + ); } if (hookTrust?.ready === false) { - nextSteps.push("Open `/hooks` and trust this plugin's hooks manually, then rerun `$cc:setup`."); + nextSteps.push( + options.checkOnly + ? "Run `$cc:setup` to trust this plugin's native hooks." + : "Open `/hooks` and trust this plugin's hooks manually, then rerun `$cc:setup`." + ); } if (!config.stopReviewGate) { nextSteps.push( @@ -711,6 +753,8 @@ function buildSetupReport(cwd, actionsTaken = [], hookTrust = null) { auth: authStatus, hooks: hooksStatus, hookTrust, + checkOnly: Boolean(options.checkOnly), + diagnostics: buildSetupDiagnostics(cwd), reviewGateEnabled: Boolean(config.stopReviewGate), actionsTaken, nextSteps @@ -724,38 +768,48 @@ function buildSetupReport(cwd, actionsTaken = [], hookTrust = null) { async function handleSetup(argv) { const { options } = parseCommandInput(argv, { valueOptions: ["cwd"], - booleanOptions: ["json", "enable-review-gate", "disable-review-gate"] + booleanOptions: ["json", "check", "enable-review-gate", "disable-review-gate"] }); if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate."); } + if ( + options.check && + (options["enable-review-gate"] || options["disable-review-gate"]) + ) { + throw new Error("--check cannot be combined with review-gate changes."); + } const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); const actionsTaken = []; - if (configureNativePluginHooks()) { + if (!options.check && configureNativePluginHooks()) { actionsTaken.push( "Enabled native Codex plugin hooks via [features].hooks and [features].plugin_hooks." ); actionsTaken.push("Restart Codex if this session started before the feature change."); } - const hookTrust = await repairNativePluginHookTrust(cwd); + const hookTrust = await repairNativePluginHookTrust(cwd, { + repair: !options.check, + }); if (hookTrust.trusted > 0) { actionsTaken.push(`Trusted ${hookTrust.trusted} native Codex plugin hooks.`); } - if (options["enable-review-gate"]) { + if (!options.check && options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); actionsTaken.push(`Enabled the stop-time review gate for ${workspaceRoot}.`); - } else if (options["disable-review-gate"]) { + } else if (!options.check && options["disable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", false); actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); } - const finalReport = buildSetupReport(cwd, actionsTaken, hookTrust); + const finalReport = buildSetupReport(cwd, actionsTaken, hookTrust, { + checkOnly: Boolean(options.check), + }); outputResult( options.json ? finalReport : renderSetupReport(finalReport), options.json @@ -822,6 +876,15 @@ function parsePositiveMilliseconds(value, optionName) { return parsed; } +function parseWaitTimeoutMilliseconds(options) { + if (options["wait-timeout-ms"] != null && options["timeout-ms"] != null) { + throw new Error("Choose only one of --wait-timeout-ms or --timeout-ms."); + } + const optionName = + options["wait-timeout-ms"] != null ? "wait-timeout-ms" : "timeout-ms"; + return parsePositiveMilliseconds(options[optionName], `--${optionName}`); +} + function readJsonConfig(filePath) { try { return JSON.parse(fs.readFileSync(filePath, "utf8")); @@ -1543,6 +1606,25 @@ function spawnDetachedReviewWorker(cwd, jobId, workspaceRoot, logFile = null) { return child; } +function recordQueuedWorker(workspaceRoot, jobId, pid) { + let pidIdentity = null; + try { + pidIdentity = getSpawnedProcessIdentity(pid); + } catch {} + try { + transitionJob(workspaceRoot, jobId, ["queued"], "queued", { + pid, + pidIdentity, + workerPid: pid, + workerPidIdentity: pidIdentity, + }); + } catch (error) { + if (error?.code !== "ELOCKBUSY") { + throw error; + } + } +} + function enqueueBackgroundReview(cwd, job, request) { const { logFile } = createTrackedProgress(job); appendLogLine(logFile, "Queued for background execution."); @@ -1559,14 +1641,7 @@ function enqueueBackgroundReview(cwd, job, request) { const child = spawnDetachedReviewWorker(cwd, job.id, job.workspaceRoot, logFile); if (child.pid != null) { - let pidIdentity = null; - try { - pidIdentity = getSpawnedProcessIdentity(child.pid); - } catch {} - patchJob(job.workspaceRoot, job.id, { - pid: child.pid, - pidIdentity, - }); + recordQueuedWorker(job.workspaceRoot, job.id, child.pid); } return { @@ -1597,7 +1672,7 @@ function buildTaskJob( summary: taskMetadata.summary, write, sessionId: ownerSessionId, - explicitJobId + explicitJobId, }); } @@ -1722,13 +1797,32 @@ function markViewedViaStatusAccess(workspaceRoot, jobs) { if (!job?.id || job.resultViewedAt || !statusPayloadSurfacesStoredResult(job)) { continue; } - patchJob(workspaceRoot, job.id, { resultViewedAt: viewedAt }); - changed = true; + const storedJob = markTerminalJobViewed(workspaceRoot, job.id, viewedAt); + changed ||= Boolean(storedJob?.resultViewedAt); } return changed; } +function markTerminalJobViewed(workspaceRoot, jobId, viewedAt = nowIso()) { + const storedJob = readJobFile(workspaceRoot, jobId); + if (!storedJob || !TERMINAL_JOB_STATUSES.has(storedJob.status)) { + return storedJob; + } + try { + transitionJob( + workspaceRoot, + jobId, + [storedJob.status], + storedJob.status, + { resultViewedAt: viewedAt } + ); + return readJobFile(workspaceRoot, jobId) ?? storedJob; + } catch { + return storedJob; + } +} + // --------------------------------------------------------------------------- // Foreground execution wrapper // --------------------------------------------------------------------------- @@ -1738,24 +1832,25 @@ async function runForegroundCommand(job, runner, options = {}) { logFile: options.logFile, stderr: !options.json && !options.quietProgress }); - const execution = await runTrackedJob( - job, - (onSpawn) => runner(progress, onSpawn), - { logFile } - ); - if (execution.exitStatus === 0 && options.markViewedOnSuccess) { - patchJob(job.workspaceRoot, job.id, { - resultViewedAt: nowIso(), - }); - } - outputResult( - options.json ? execution.payload : execution.rendered, - options.json - ); - if (execution.exitStatus !== 0) { - process.exitCode = execution.exitStatus; + try { + const execution = await runTrackedJob( + job, + (onSpawn) => runner(progress, onSpawn), + { logFile } + ); + outputResult( + options.json ? execution.payload : execution.rendered, + options.json + ); + if (execution.exitStatus !== 0) { + process.exitCode = execution.exitStatus; + } + return execution; + } finally { + if (options.markViewedOnSuccess) { + markTerminalJobViewed(job.workspaceRoot, job.id); + } } - return execution; } // --------------------------------------------------------------------------- @@ -1818,14 +1913,7 @@ function enqueueDetachedTask(cwd, job, request, options = {}) { const child = spawnDetachedTaskWorker(cwd, job.id, job.workspaceRoot, logFile); if (child.pid != null) { - let pidIdentity = null; - try { - pidIdentity = getSpawnedProcessIdentity(child.pid); - } catch {} - patchJob(job.workspaceRoot, job.id, { - pid: child.pid, - pidIdentity, - }); + recordQueuedWorker(job.workspaceRoot, job.id, child.pid); } return { @@ -1978,10 +2066,8 @@ async function runForegroundDetachedTask(cwd, job, request, options = {}) { }; } - if (storedJob.status === "completed" && options.markViewedOnSuccess) { - storedJob = patchJob(job.workspaceRoot, job.id, { - resultViewedAt: nowIso(), - }) ?? storedJob; + if (options.markViewedOnSuccess) { + storedJob = markTerminalJobViewed(job.workspaceRoot, job.id) ?? storedJob; } const resultPayload = buildStoredTaskPayload(storedJob); @@ -2051,34 +2137,54 @@ async function waitForStoredJob(workspaceRoot, jobId, options = {}) { // Resume support // --------------------------------------------------------------------------- -async function resolveLatestResumableSession(cwd, options = {}) { - const workspaceRoot = resolveWorkspaceRoot(cwd); - const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter( - (job) => job.id !== options.excludeJobId - ); +const RESUMABLE_TASK_STATUSES = new Set(["completed", "failed"]); - // Check for active tasks first - const activeTask = jobs.find( - (job) => job.jobClass === "task" && isActiveJobStatus(job.status) - ); - if (activeTask) { - throw new Error( - `Task ${activeTask.id} is still running. Use $cc:status before continuing it.` - ); +function getClaudeSessionId(job) { + const sessionId = job?.result?.sessionId ?? job?.threadId ?? null; + return typeof sessionId === "string" && sessionId.trim() + ? sessionId.trim() + : null; +} + +function resolveTaskResumeState(cwd, ownerSessionId, options = {}) { + if (!ownerSessionId) { + return { candidate: null, activeTask: null, reason: "missing_owner_session" }; } - // Find most recent completed task with a session ID - const trackedTask = jobs.find( + const workspaceRoot = resolveWorkspaceRoot(cwd); + const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter( (job) => + job.id !== options.excludeJobId && job.jobClass === "task" && - job.status === "completed" && - (job.threadId || job.sessionId) + job.sessionId === ownerSessionId ); - if (trackedTask) { - return trackedTask.threadId || trackedTask.sessionId; + const activeTask = jobs.find((job) => isActiveJobStatus(job.status)) ?? null; + if (activeTask) { + return { candidate: null, activeTask, reason: "active_task" }; } - return null; + const job = jobs.find( + (candidate) => + RESUMABLE_TASK_STATUSES.has(candidate.status) && + getClaudeSessionId(candidate) + ); + return job + ? { + candidate: { job, claudeSessionId: getClaudeSessionId(job) }, + activeTask: null, + reason: "available", + } + : { candidate: null, activeTask: null, reason: "not_found" }; +} + +async function resolveLatestResumableSession(cwd, options = {}) { + const state = resolveTaskResumeState(cwd, options.ownerSessionId, options); + if (state.activeTask) { + throw new Error( + `Task ${state.activeTask.id} is still running. Use $cc:status before continuing it.` + ); + } + return state.candidate?.claudeSessionId ?? null; } // --------------------------------------------------------------------------- @@ -2240,6 +2346,7 @@ async function handleTask(argv) { "view-state", "owner-session-id", "job-id", + "wait-timeout-ms", "timeout-ms", "poll-interval-ms", ], @@ -2265,10 +2372,7 @@ async function handleTask(argv) { const resolvedEffort = resolveDefaultEffort(model, options.effort); const effort = resolvedEffort ? resolveEffort(resolvedEffort) : null; const prompt = readTaskPrompt(cwd, options, positionals); - const foregroundTimeoutMs = parsePositiveMilliseconds( - options["timeout-ms"], - "--timeout-ms" - ); + const foregroundTimeoutMs = parseWaitTimeoutMilliseconds(options); const markViewedOnSuccess = resolveMarkViewedOnSuccess( options["view-state"], Boolean(options.background) @@ -2279,6 +2383,14 @@ async function handleTask(argv) { if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } + const ownerSessionId = resolveOwnerSessionId( + options["owner-session-id"] ?? process.env[SESSION_ID_ENV] + ); + if (resumeLast && !ownerSessionId) { + throw new Error( + "Cannot resume without an owning Codex session. Run from the original session or use --fresh." + ); + } // Validate before arming: ensure we have a prompt or resume target if (!prompt && !resumeLast) { @@ -2287,7 +2399,6 @@ async function handleTask(argv) { ensureClaudeReady(cwd); const write = Boolean(options.write); - const ownerSessionId = resolveOwnerSessionId(options["owner-session-id"]); const explicitJobId = resolveExplicitJobId(options["job-id"], workspaceRoot); await withReleasedReservation(workspaceRoot, explicitJobId, async () => { const taskMetadata = buildTaskRunMetadata({ @@ -2299,7 +2410,9 @@ async function handleTask(argv) { // Resolve resume session inside the reservation guard so failures do not leak markers. let resumeSessionId = null; if (resumeLast) { - resumeSessionId = await resolveLatestResumableSession(workspaceRoot); + resumeSessionId = await resolveLatestResumableSession(workspaceRoot, { + ownerSessionId, + }); if (!resumeSessionId) { throw new Error( "No previous Claude Code task session was found for this repository." @@ -2477,16 +2590,17 @@ async function handleReviewWorker(argv) { async function handleStatus(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"], + valueOptions: ["cwd", "wait-timeout-ms", "timeout-ms", "poll-interval-ms"], booleanOptions: ["json", "all", "wait"] }); const cwd = resolveCommandCwd(options); + const waitTimeoutMs = parseWaitTimeoutMilliseconds(options); const reference = positionals[0] ?? ""; if (reference) { let snapshot = options.wait ? await waitForSingleJobSnapshot(cwd, reference, { - timeoutMs: options["timeout-ms"], + timeoutMs: waitTimeoutMs, pollIntervalMs: options["poll-interval-ms"] }) : buildSingleJobSnapshot(cwd, reference); @@ -2538,9 +2652,7 @@ function handleResult(argv) { const { workspaceRoot, job, state } = resolveResultJob(cwd, reference); let storedJob = readStoredJob(workspaceRoot, job.id); if (state !== "active") { - storedJob = patchJob(workspaceRoot, job.id, { - resultViewedAt: nowIso(), - }) ?? storedJob; + storedJob = markTerminalJobViewed(workspaceRoot, job.id) ?? storedJob; } const payload = { job, @@ -2559,30 +2671,24 @@ function handleResult(argv) { function handleTaskResumeCandidate(argv) { const { options } = parseCommandInput(argv, { - valueOptions: ["cwd"], + valueOptions: ["cwd", "owner-session-id"], booleanOptions: ["json"] }); const cwd = resolveCommandCwd(options); - const routing = buildSessionRoutingContext(cwd); - const workspaceRoot = routing.workspaceRoot; - const sessionId = routing.ownerSessionId; - const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); - const candidate = - sessionId == null - ? null - : jobs.find( - (job) => - job.jobClass === "task" && - (job.threadId || job.sessionId) && - job.status !== "queued" && - job.status !== "running" && - job.sessionId === sessionId - ) ?? null; + const workspaceRoot = resolveWorkspaceRoot(cwd); + const sessionId = resolveOwnerSessionId( + options["owner-session-id"] ?? process.env[SESSION_ID_ENV] ?? null + ); + const state = resolveTaskResumeState(workspaceRoot, sessionId); + const candidate = state.candidate?.job ?? null; const payload = { available: Boolean(candidate), sessionId, + ownerSessionId: sessionId, + reason: state.reason, + activeJobId: state.activeTask?.id ?? null, candidate: candidate == null ? null @@ -2593,14 +2699,17 @@ function handleTaskResumeCandidate(argv) { summary: candidate.summary ?? null, threadId: candidate.threadId ?? null, sessionId: candidate.sessionId ?? null, + claudeSessionId: state.candidate.claudeSessionId, completedAt: candidate.completedAt ?? null, updatedAt: candidate.updatedAt ?? null } }; - const rendered = candidate - ? `Resumable task found: ${candidate.id} (${candidate.status}).\n` - : "No resumable task found for this session.\n"; + const rendered = state.activeTask + ? `Task ${state.activeTask.id} is still running in this session.\n` + : candidate + ? `Resumable task found: ${candidate.id} (${candidate.status}).\n` + : "No resumable task found for this session.\n"; outputCommandResult(payload, rendered, options.json); } diff --git a/scripts/lib/claude-cli.mjs b/scripts/lib/claude-cli.mjs index 67ea9d9..adfb079 100644 --- a/scripts/lib/claude-cli.mjs +++ b/scripts/lib/claude-cli.mjs @@ -305,15 +305,17 @@ function extractClaudeLimitResetText(text) { } const CLAUDE_FINAL_MESSAGE_LIMIT_RE = - /(?:you(?:'|’)?ve|you have)\s+hit\s+your\s+.*limit|\b(?:session|usage)\s+limit\b.{0,120}\bresets(?:\s+at)?\b|\b(?:session|usage)\s+limit\s+reached\b/i; + /(?:you(?:'|’)?ve|you have)\s+(?:hit|reached)\s+your\s+.*limit|\b(?:session|usage)\s+limit\b.{0,120}\bresets(?:\s+at)?\b|\b(?:session|usage)\s+limit\s+reached\b/i; const CLAUDE_ERROR_LIMIT_RE = - /(?:you(?:'|’)?ve|you have)\s+hit\s+your\s+.*limit|\b(?:session|usage)\s+limit\b|rate[_ -]?limit|\b429\b/i; + /(?:you(?:'|’)?ve|you have)\s+(?:hit|reached)\s+your\s+.*limit|\b(?:session|usage)\s+limit\b|rate[_ -]?limit|\b429\b/i; +const CLAUDE_AUTH_ERROR_RE = + /\bnot (?:logged|signed) in\b|\bnot authenticated\b|\bauthentication required\b|\binvalid api key\b|\b(?:oauth|access) token\b.{0,80}\bexpired\b|\bclaude auth login\b/i; const CLAUDE_USAGE_LIMIT_EPOCH_RE = /\b(?:claude\s+ai\s+)?(?:session|usage)\s+limit\s+reached\|(\d{10}|\d{13})\b/i; const CLAUDE_USAGE_LIMIT_EPOCH_GLOBAL_RE = /\b(?:claude\s+ai\s+)?(?:session|usage)\s+limit\s+reached\|(\d{10}|\d{13})\b/gi; const CLAUDE_LIMIT_RESET_TEXT_RE = - /(?:(?:you(?:'|’)?ve|you have)\s+hit\s+your\s+[^\r\n.]*?limit|\b(?:session|usage)\s+limit(?:\s+reached)?\b)[^\r\n.]*?\bresets(?:\s+at)?\s+([^\r\n.]+)/gi; + /(?:(?:you(?:'|’)?ve|you have)\s+(?:hit|reached)\s+your\s+[^\r\n.]*?limit|\b(?:session|usage)\s+limit(?:\s+reached)?\b)[^\r\n.]*?\bresets(?:\s+at)?\s+([^\r\n.]+)/gi; const CLAUDE_ERROR_RESET_TEXT_RE = /(?:rate[_ -]?limit|\b429\b)[^\r\n.]{0,120}?\bresets\s+at\s+([^\r\n.]+)/gi; @@ -365,18 +367,31 @@ export function classifyClaudeFailure(value = {}) { CLAUDE_FINAL_MESSAGE_LIMIT_RE.test(finalMessage) ); const stderrLimit = Boolean(stderr && CLAUDE_ERROR_LIMIT_RE.test(stderr)); - if (!finalMessageLimit && !stderrLimit) { - return null; + if (finalMessageLimit || stderrLimit) { + const limitSource = finalMessageLimit ? finalMessage : stderr; + const resetText = finalMessageLimit + ? extractClaudeLimitResetText(limitSource) ?? extractClaudeLimitResetTextFromError(stderr) + : extractClaudeLimitResetTextFromError(stderr); + return { + kind: "claude_rate_limit", + message, + resetText, + }; } - const limitSource = finalMessageLimit ? finalMessage : stderr; - const resetText = finalMessageLimit - ? extractClaudeLimitResetText(limitSource) ?? extractClaudeLimitResetTextFromError(stderr) - : extractClaudeLimitResetTextFromError(stderr); - return { - kind: "claude_rate_limit", - message, - resetText, - }; + const finalMessageAuth = Boolean( + value.finalMessageHasAuthSignal && + finalMessage && + CLAUDE_AUTH_ERROR_RE.test(finalMessage) + ); + const stderrAuth = Boolean(stderr && CLAUDE_AUTH_ERROR_RE.test(stderr)); + if (finalMessageAuth || stderrAuth) { + return { + kind: "claude_auth", + message, + resetText: null, + }; + } + return null; } function collectStringValues(value, strings = []) { @@ -400,6 +415,12 @@ function hasClaudeLimitText(value) { ); } +function hasClaudeAuthText(value) { + return collectStringValues(value).some((text) => + CLAUDE_AUTH_ERROR_RE.test(text) + ); +} + function compactModelEvent(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -570,6 +591,7 @@ export class StreamParser { finalModel: null, contextWindow: null, hasTerminalLimitSignal: false, + hasTerminalAuthSignal: false, }; } @@ -629,6 +651,9 @@ export class StreamParser { if (hasSyntheticModelSignal(event) && hasClaudeLimitText(event)) { this.state.hasTerminalLimitSignal = true; } + if (hasSyntheticModelSignal(event) && hasClaudeAuthText(event)) { + this.state.hasTerminalAuthSignal = true; + } switch (event.type) { case "stream_event": return this._handleStreamEvent(event); @@ -1364,6 +1389,7 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { ? classifyClaudeFailure({ finalMessage: parser.state.finalMessage, finalMessageHasLimitSignal: parser.state.hasTerminalLimitSignal, + finalMessageHasAuthSignal: parser.state.hasTerminalAuthSignal, stderr, }) : null; diff --git a/scripts/lib/job-control.mjs b/scripts/lib/job-control.mjs index 725357f..1c3f1e9 100644 --- a/scripts/lib/job-control.mjs +++ b/scripts/lib/job-control.mjs @@ -18,6 +18,7 @@ import { readJobFile, resolveJobFile, resolveJobLogFile, + TERMINAL_JOB_STATUSES, } from "./state.mjs"; import { SESSION_ID_ENV } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -87,7 +88,6 @@ function formatElapsedDuration(startValue, endValue = null) { } const ACTIVE_STATUSES = new Set(["running", "cancelling"]); -const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "cancel_failed", "unknown"]); function inferJobPhase(job, progressPreview = []) { switch (job.status) { @@ -125,9 +125,9 @@ export function enrichJob(job, options = {}) { logFile: managedLogFile, elapsed: formatElapsedDuration( job.startedAt ?? job.createdAt, - TERMINAL_STATUSES.has(job.status) ? (job.completedAt ?? null) : null + TERMINAL_JOB_STATUSES.has(job.status) ? (job.completedAt ?? null) : null ), - duration: TERMINAL_STATUSES.has(job.status) + duration: TERMINAL_JOB_STATUSES.has(job.status) ? formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? job.updatedAt) : null, }; @@ -170,7 +170,7 @@ export function buildStatusSnapshot(cwd, options = {}) { .filter((job) => ACTIVE_STATUSES.has(job.status)) .map((job) => enrichJob(job, { maxProgressLines })); - const finishedJobs = jobs.filter((job) => TERMINAL_STATUSES.has(job.status)); + const finishedJobs = jobs.filter((job) => TERMINAL_JOB_STATUSES.has(job.status)); const latestFinishedRaw = finishedJobs[0] ?? null; const latestFinished = latestFinishedRaw ? enrichJob(latestFinishedRaw, { maxProgressLines }) @@ -212,7 +212,7 @@ export function resolveResultJob(cwd, reference) { if (reference) { const selected = matchJobReference(jobs, reference); const enriched = enrichJob(selected); - if (TERMINAL_STATUSES.has(enriched.status)) { + if (TERMINAL_JOB_STATUSES.has(enriched.status)) { return { workspaceRoot, job: enriched, state: "terminal" }; } if (enriched.status === "queued" || ACTIVE_STATUSES.has(enriched.status)) { @@ -224,7 +224,7 @@ export function resolveResultJob(cwd, reference) { } const selected = matchJobReference(jobs, reference, (job) => - TERMINAL_STATUSES.has(job.status) + TERMINAL_JOB_STATUSES.has(job.status) ); if (selected) { return { workspaceRoot, job: enrichJob(selected), state: "terminal" }; diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index 3857e24..91d25bc 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -340,6 +340,7 @@ export function renderSetupReport(report) { "# Claude Code Setup", "", `Status: ${report.ready ? "ready" : "needs attention"}`, + ...(report.checkOnly ? ["Mode: read-only check"] : []), "", "Checks:", `- node: ${report.node.detail}`, @@ -348,6 +349,13 @@ export function renderSetupReport(report) { `- hooks: ${report.hooks.detail}`, ...(report.hookTrust ? [`- hook trust: ${report.hookTrust.detail}`] : []), `- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`, + ...(report.diagnostics + ? [ + `- plugin: ${report.diagnostics.pluginVersion ?? "unknown"} (${report.diagnostics.runtimeSource})`, + `- config: ${report.diagnostics.configPath}`, + `- workspace: ${report.diagnostics.workspaceRoot}`, + ] + : []), "", ]; if (report.actionsTaken.length > 0) { @@ -405,6 +413,14 @@ export function renderReviewResult(parsedResult, meta) { } export function renderTaskResult(parsedResult) { + if (parsedResult?.failure?.kind === "claude_auth") { + const original = String(parsedResult.failure.message ?? "").trim(); + return [ + "Claude Code authentication failed. Run `claude auth login` and retry.", + original ? `\nOriginal Claude message:\n${original}` : "", + "", + ].join("\n"); + } if (parsedResult?.failure?.kind === "claude_rate_limit") { const reset = parsedResult.failure.resetText ? ` Retry after ${parsedResult.failure.resetText}.` diff --git a/scripts/lib/state.mjs b/scripts/lib/state.mjs index 84dbc3c..bea55e3 100644 --- a/scripts/lib/state.mjs +++ b/scripts/lib/state.mjs @@ -29,6 +29,7 @@ import { resolveWorkspaceRoot } from "./workspace.mjs"; import { getProcessIdentity, isProcessAlive, + terminateProcessTreeIfIdentityMatches, } from "./process.mjs"; const STATE_VERSION = 1; @@ -65,6 +66,13 @@ const HARD_LINK_UNSUPPORTED_CODES = new Set([ let hardLinksUnsupported = false; export const JOB_RESERVATION_SUFFIX = ".reserve"; export const ACTIVE_JOB_STATUSES = new Set(["queued", "running", "cancelling"]); +export const TERMINAL_JOB_STATUSES = new Set([ + "completed", + "failed", + "cancelled", + "cancel_failed", + "unknown", +]); const NO_SESSION_RETENTION_BUCKET = "__no-session__"; export function nowIso() { @@ -541,16 +549,35 @@ export function reapStaleJobs(cwd, jobs, options = {}) { const isProcessAliveImpl = options.isProcessAliveImpl ?? isProcessAlive; const getProcessIdentityImpl = options.getProcessIdentityImpl ?? getProcessIdentity; + const terminateProcessTreeIfIdentityMatchesImpl = + options.terminateProcessTreeIfIdentityMatchesImpl ?? + terminateProcessTreeIfIdentityMatches; return jobs.map((job) => { if (isWithinReapGracePeriod(job)) return job; if (!REAPABLE_STATUSES.has(job.status)) return job; - if (!job.pid) { + const workerPid = Number.isInteger(job.workerPid) && job.workerPid > 0 + ? job.workerPid + : null; + const workerPidIdentity = + typeof job.workerPidIdentity === "string" && job.workerPidIdentity + ? job.workerPidIdentity + : null; + const trackWorker = Boolean( + workerPid && + (workerPidIdentity || !job.pid || job.pid === workerPid) + ); + const trackedPid = trackWorker ? workerPid : job.pid; + const trackedPidIdentity = trackWorker + ? workerPidIdentity ?? job.pidIdentity + : job.pidIdentity; + if (!trackedPid) { if (job.status !== "queued") return job; const current = readJobFile(cwd, job.id) ?? job; if ( current.status !== "queued" || current.pid || + current.workerPid || isWithinReapGracePeriod(current, Date.now(), QUEUED_WITHOUT_PID_REAP_GRACE_MS) ) { return current; @@ -561,6 +588,8 @@ export function reapStaleJobs(cwd, jobs, options = {}) { completedAt: nowIso(), pid: null, pidIdentity: null, + workerPid: null, + workerPidIdentity: null, phase: "failed", }); return readJobFile(cwd, job.id) ?? job; @@ -570,7 +599,7 @@ export function reapStaleJobs(cwd, jobs, options = {}) { } const now = Date.now(); - const processExists = isProcessAliveImpl(job.pid); + const processExists = isProcessAliveImpl(trackedPid); let withinWindowsIdentityLease = false; let windowsIdentityCheckFile = null; let windowsIdentityProbeFile = null; @@ -612,7 +641,7 @@ export function reapStaleJobs(cwd, jobs, options = {}) { } const needsIdentityCheck = processExists && - job.pidIdentity && + trackedPidIdentity && (platform !== "win32" || !withinWindowsIdentityLease); let identityMatches = true; let identityUnavailable = false; @@ -620,11 +649,11 @@ export function reapStaleJobs(cwd, jobs, options = {}) { try { identityMatches = getProcessIdentityImpl( - job.pid, + trackedPid, platform === "win32" ? { timeout: WINDOWS_REAPER_IDENTITY_TIMEOUT_MS } : undefined - ) === job.pidIdentity; + ) === trackedPidIdentity; } catch { identityMatches = false; identityUnavailable = true; @@ -719,28 +748,76 @@ export function reapStaleJobs(cwd, jobs, options = {}) { // Process is dead — transition via CAS try { - const nextStatus = identityUnavailableTooLong - ? (job.status === "cancelling" ? "cancel_failed" : "failed") - : (job.status === "cancelling" ? "cancelled" : "failed"); + const hasDistinctClaudeChild = Boolean( + workerPid && job.pid && job.pid !== workerPid + ); + let childCleanup = null; + if (hasDistinctClaudeChild) { + try { + childCleanup = terminateProcessTreeIfIdentityMatchesImpl( + job.pid, + job.pidIdentity, + { + platform, + getProcessIdentityImpl, + isProcessAliveImpl, + ...(platform === "win32" + ? { timeout: WINDOWS_REAPER_IDENTITY_TIMEOUT_MS } + : {}), + } + ); + } catch (error) { + childCleanup = { + attempted: true, + delivered: false, + reason: error instanceof Error ? error.message : String(error), + }; + } + } + const childResolved = Boolean( + childCleanup?.delivered || + childCleanup?.reason === "process-missing" || + childCleanup?.reason === "identity-mismatch" || + (childCleanup?.reason === "identity-unavailable" && + !isProcessAliveImpl(job.pid)) + ); + const unresolvedClaudeChild = hasDistinctClaudeChild && !childResolved; + const nextStatus = job.status === "cancelling" + ? (identityUnavailableTooLong || unresolvedClaudeChild + ? "cancel_failed" + : "cancelled") + : "failed"; const terminalData = identityUnavailableTooLong ? { errorMessage: - `Process ${job.pid} identity remained unverifiable beyond the bounded Windows recheck window. Manual cleanup may be required.`, + `Process ${trackedPid} identity remained unverifiable beyond the bounded Windows recheck window. Manual cleanup may be required.`, completedAt: nowIso(), phase: nextStatus, reapedUnverifiable: true, ...(nextStatus === "cancel_failed" - ? { pgid: job.pgid ?? job.pid } + ? { pgid: job.pgid ?? job.pid ?? trackedPid } : {}), } : { - errorMessage: job.status === "cancelling" + errorMessage: nextStatus === "cancel_failed" + ? `Cancellation could not verify cleanup of Claude child ${job.pid}; manual cleanup is required.` + : job.status === "cancelling" ? "Cancelled by user. Auto-reaped after process exit." - : `Process ${job.pid} died without completing. Auto-reaped.`, + : `${trackWorker ? "Worker" : "Process"} ${trackedPid} died without completing. Auto-reaped.${ + unresolvedClaudeChild + ? ` Claude child ${job.pid} may still be running; manual cleanup is required.` + : "" + }`, completedAt: nowIso(), - pid: null, - pidIdentity: null, - phase: nextStatus === "cancelled" ? "cancelled" : "failed", + ...(unresolvedClaudeChild + ? {} + : { pid: null, pidIdentity: null }), + workerPid: null, + workerPidIdentity: null, + ...(nextStatus === "cancel_failed" + ? { pgid: job.pgid ?? job.pid ?? trackedPid } + : {}), + phase: nextStatus, }; const transitioned = transitionJob( cwd, diff --git a/scripts/lib/tracked-jobs.mjs b/scripts/lib/tracked-jobs.mjs index 221fbeb..2a070c8 100644 --- a/scripts/lib/tracked-jobs.mjs +++ b/scripts/lib/tracked-jobs.mjs @@ -12,8 +12,8 @@ import fs from "node:fs"; import process from "node:process"; -import { terminateProcessTree } from "./process.mjs"; -import { nowIso, ensureStateDir, getCurrentSession, patchJob, readJobFile, resolveJobLogFile, writeJobFile, cleanupOldJobs, transitionJob } from "./state.mjs"; +import { getProcessIdentity, terminateProcessTree } from "./process.mjs"; +import { nowIso, ensureStateDir, getCurrentSession, readJobFile, resolveJobLogFile, writeJobFile, cleanupOldJobs, transitionJob } from "./state.mjs"; export { nowIso }; @@ -325,14 +325,17 @@ export function createJobProgressUpdater(workspaceRoot, jobId) { return; } - // Late display events (e.g. trailing subagent output) must not resurrect - // phase/thread fields on a job that already reached a terminal status. - const current = readJobFile(workspaceRoot, jobId); - if (current && current.status !== "running") { - return; + try { + transitionTrackedJob( + workspaceRoot, + jobId, + ["running"], + "running", + patch + ); + } catch { + // Progress is best-effort; terminal writers and disappearing jobs win. } - - patchJob(workspaceRoot, jobId, patch); }; } @@ -354,6 +357,24 @@ export function createProgressReporter({ stderr = false, logFile = null, onEvent } export async function runTrackedJob(job, runner, options = {}) { + const workerPid = process.pid; + const getProcessIdentityImpl = + options.getProcessIdentityImpl ?? getProcessIdentity; + const storedJob = readJobFile(job.workspaceRoot, job.id); + let workerPidIdentity = + [job, storedJob].find( + (candidate) => + candidate?.workerPid === workerPid && + typeof candidate.workerPidIdentity === "string" && + candidate.workerPidIdentity + )?.workerPidIdentity ?? null; + if (!workerPidIdentity) { + try { + workerPidIdentity = getProcessIdentityImpl(workerPid); + } catch {} + } + // ponytail: without a stable worker identity, fall back to the child's + // identity; add an alternate worker identity source only if this becomes common. const runningRecord = { ...job, status: "running", @@ -361,9 +382,16 @@ export async function runTrackedJob(job, runner, options = {}) { phase: "starting", pid: job.pid ?? null, // Preserve queued worker PID until onSpawn replaces it pidIdentity: job.pidIdentity ?? null, + workerPid, + workerPidIdentity, logFile: options.logFile ?? job.logFile ?? null }; - const storedJob = readJobFile(job.workspaceRoot, job.id); + if (!workerPidIdentity) { + appendLogLine( + runningRecord.logFile, + "Worker identity unavailable; stale-job detection will fall back to the Claude child identity." + ); + } if (storedJob) { const started = transitionTrackedJob( job.workspaceRoot, @@ -374,6 +402,8 @@ export async function runTrackedJob(job, runner, options = {}) { startedAt: runningRecord.startedAt, phase: runningRecord.phase, logFile: runningRecord.logFile, + workerPid, + workerPidIdentity, } ); if (!started.transitioned) { @@ -423,6 +453,8 @@ export async function runTrackedJob(job, runner, options = {}) { turnId: execution.turnId ?? null, pid: null, pidIdentity: null, + workerPid: null, + workerPidIdentity: null, phase: completionStatus === "completed" ? "done" : "failed", completedAt, summary: execution.summary, @@ -470,6 +502,8 @@ export async function runTrackedJob(job, runner, options = {}) { errorMessage, pid: null, pidIdentity: null, + workerPid: null, + workerPidIdentity: null, phase: "failed", completedAt, logFile: options.logFile ?? job.logFile ?? null diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index 117e348..2f7056b 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -92,9 +92,11 @@ Background flow: - run exactly one shell command - execute: `node "/scripts/claude-companion.mjs" adversarial-review --cwd "" --view-state defer ` - - run that command as one blocking foreground shell-tool call, not as a background terminal/session - - do not request a shell session id, poll a shell session later, or return before the companion command exits - - if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call + - run that command in the foreground; do not add shell backgrounding such as `&`, `nohup`, or detached `spawn` + - If the shell tool returns a session id, keep polling that same session until the companion command exits. + - Exit code 0 is the only successful completion. + - Exit code 124 means the job is still running; return the companion output without claiming it finished. + - For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification. - include `--owner-session-id ` only when the parent resolved a non-empty owner session id - include `--job-id ` when the parent reserved one - preserve the helper's exact non-empty `workspaceRoot` in `--cwd` so the reservation and job use the same workspace state diff --git a/skills/rescue/SKILL.md b/skills/rescue/SKILL.md index fd8fe06..5cfe21f 100644 --- a/skills/rescue/SKILL.md +++ b/skills/rescue/SKILL.md @@ -1,6 +1,6 @@ --- name: rescue -description: 'Delegate a substantial diagnosis, implementation, or follow-up task to Claude Code through the tracked-job runtime. Args: --background, --wait, --resume, --resume-last, --fresh, --write, --model , --effort , --timeout-ms , --prompt-file , [task text]. Defaults to opus + xhigh effort. Use when Claude should investigate or change things, not when the user only wants review findings.' +description: 'Delegate a substantial diagnosis, implementation, or follow-up task to Claude Code through the tracked-job runtime. Args: --background, --wait, --resume, --resume-last, --fresh, --write, --model , --effort , --wait-timeout-ms , --prompt-file , [task text]. Defaults to opus + xhigh effort. Use when Claude should investigate or change things, not when the user only wants review findings.' --- # Claude Code Rescue @@ -21,7 +21,7 @@ Resolve `` as two directories above this `SKILL.md` file. Keep the Raw slash-command arguments: `$ARGUMENTS` -Supported arguments: `--background`, `--wait`, `--resume`, `--resume-last`, `--fresh`, `--write`, `--model `, `--effort `, `--timeout-ms `, `--prompt-file `, plus free-text task text +Supported arguments: `--background`, `--wait`, `--resume`, `--resume-last`, `--fresh`, `--write`, `--model `, `--effort `, `--wait-timeout-ms `, deprecated alias `--timeout-ms `, `--prompt-file `, plus free-text task text Main-thread routing rules: - If the user explicitly invoked `$cc:rescue` or `Claude Code Rescue`, do not keep the work in the main Codex thread. Delegate it. @@ -29,7 +29,7 @@ Main-thread routing rules: - Treat `--background` and `--wait` as execution controls, not task text. - `--background` and `--wait` are Codex-side execution controls only. Never forward either flag to `claude-companion.mjs task`. - The main Codex thread owns that execution-mode choice. It decides whether to wait for the subagent. The child subagent must never reinterpret those flags as companion flags. -- Treat `--model`, `--effort`, `--timeout-ms`, `--resume`, `--resume-last`, `--fresh`, and `--prompt-file` as runtime or routing controls, not task text. +- Treat `--model`, `--effort`, `--wait-timeout-ms`, deprecated alias `--timeout-ms`, `--resume`, `--resume-last`, `--fresh`, and `--prompt-file` as runtime or routing controls, not task text. - If the user task text itself begins with a slash command such as `/simplify`, `/fix`, or `/review`, treat that slash command as literal Claude Code task text to be forwarded unchanged. Do not execute or reinterpret it in the parent Codex thread. - `--model` selects the Claude model for the companion `task` command only. It does not select the Codex subagent model. - If the user explicitly passed `--background`, run the rescue subagent in the background. @@ -41,7 +41,10 @@ Main-thread routing rules: - Default to `--write` unless the user explicitly wants read-only behavior or only review, diagnosis, or research without edits. - If `--resume` or `--resume-last` is present, continue the latest tracked Claude Code task. If `--fresh` is present, start a new task. - If none of `--resume`, `--resume-last`, or `--fresh` is present, first run: - `node "/scripts/claude-companion.mjs" task-resume-candidate --json` + `node "/scripts/claude-companion.mjs" session-routing-context --json` +- If that helper returns a non-empty `ownerSessionId`, pass it into the resume probe: + `node "/scripts/claude-companion.mjs" task-resume-candidate --owner-session-id --json` +- If it returns an empty `ownerSessionId`, skip the resume probe and delegate normally. - If that helper reports `available: true`, ask the user once whether to continue the current Claude Code thread or start a new one. - Use exactly these two choices: - `Continue current Claude Code thread` @@ -118,9 +121,11 @@ Subagent launch: - The built-in rescue path must use a compact strict forwarding message. It must: - identify the child as a transient forwarding worker for Claude Code rescue - include exactly one shell command to run - - run that command as one blocking foreground shell-tool call, not as a background terminal/session - - do not request a shell session id, poll a shell session later, or return before the companion command exits - - if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call + - run that command in the foreground; do not add shell backgrounding such as `&`, `nohup`, or detached `spawn` + - If the shell tool returns a session id, keep polling that same session until the companion command exits. + - Exit code 0 is the only successful completion. + - Exit code 124 means the job is still running; return the companion output without claiming it finished. + - For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification. - for foreground rescue only, tell the child to return that command's stdout text exactly, with no preamble, summary, code fence, trimming, normalization, or punctuation changes - tell the child to ignore stderr progress chatter such as `[cc] ...` lines and preserve only the stdout-equivalent final result text - if a parent thread id is provided for experimental background notification, allow one extra `send_input` call after a successful shell result and before finishing diff --git a/skills/rescue/agents/openai.yaml b/skills/rescue/agents/openai.yaml index 6a30ba2..08f21b8 100644 --- a/skills/rescue/agents/openai.yaml +++ b/skills/rescue/agents/openai.yaml @@ -1,3 +1,3 @@ interface: display_name: "Claude Code Rescue" - short_description: "Args: --background, --wait, --resume, --resume-last, --fresh, --write, --model , --effort , --timeout-ms , --prompt-file , [task text]. Delegate substantial work through the built-in rescue subagent path." + short_description: "Args: --background, --wait, --resume, --resume-last, --fresh, --write, --model , --effort , --wait-timeout-ms , --prompt-file , [task text]. Delegate substantial work through the built-in rescue subagent path." diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index a9cd0f2..bdd46d5 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -90,9 +90,11 @@ Background flow: - run exactly one shell command - execute: `node "/scripts/claude-companion.mjs" review --cwd "" --view-state defer ` - - run that command as one blocking foreground shell-tool call, not as a background terminal/session - - do not request a shell session id, poll a shell session later, or return before the companion command exits - - if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call + - run that command in the foreground; do not add shell backgrounding such as `&`, `nohup`, or detached `spawn` + - If the shell tool returns a session id, keep polling that same session until the companion command exits. + - Exit code 0 is the only successful completion. + - Exit code 124 means the job is still running; return the companion output without claiming it finished. + - For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification. - include `--owner-session-id ` only when the parent resolved a non-empty owner session id - include `--job-id ` when the parent reserved one - preserve the helper's exact non-empty `workspaceRoot` in `--cwd` so the reservation and job use the same workspace state diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 4a60731..ace2f12 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -1,6 +1,6 @@ --- name: setup -description: 'Check whether Claude Code CLI is ready in this environment and optionally toggle the stop-time review gate. Args: --enable-review-gate, --disable-review-gate. Use for installation, authentication, or review-gate setup requests.' +description: 'Check whether Claude Code CLI is ready in this environment and optionally repair setup or toggle the stop-time review gate. Args: --check, --enable-review-gate, --disable-review-gate. Use for installation, authentication, or review-gate setup requests.' --- # Claude Code Setup @@ -10,16 +10,18 @@ Use this skill when the user wants to verify Claude Code readiness or toggle the Resolve `` as two directories above this `SKILL.md` file. Keep the shell tool in the active Codex user workspace; never set its working directory to `` or the directory used to read this skill. The companion uses that shell's current directory as the workspace. Supported arguments: +- `--check` (read-only; do not combine with review-gate changes) - `--enable-review-gate` - `--disable-review-gate` Workflow: -- First run the machine-readable probe: - `node "/scripts/claude-companion.mjs" setup --json $ARGUMENTS` +- First run the machine-readable read-only probe: + `node "/scripts/claude-companion.mjs" setup --check --json` +- `--check` is read-only: it reports config and hook-trust repairs without applying them. - If it reports that Claude Code is unavailable and `npm` is available, ask whether to install Claude Code now. - If the user agrees, run `npm install -g @anthropic-ai/claude-code` and rerun setup. - If Claude Code is already installed or `npm` is unavailable, do not ask about installation. -- If setup reports missing native plugin hook features or hook trust, rerun setup once. The companion repairs `[features].hooks`, `[features].plugin_hooks`, and this plugin's native hook trust hashes itself. +- Unless the user explicitly requested `--check`, if the check reports missing native plugin hook features or hook trust, run setup once without `--check`. The companion repairs `[features].hooks`, `[features].plugin_hooks`, and this plugin's native hook trust hashes itself. - After the decision flow is complete, run the final user-facing command without `--json`: `node "/scripts/claude-companion.mjs" setup $ARGUMENTS` diff --git a/skills/status/SKILL.md b/skills/status/SKILL.md index 387e2fd..a867581 100644 --- a/skills/status/SKILL.md +++ b/skills/status/SKILL.md @@ -1,6 +1,6 @@ --- name: status -description: 'Show active or recent Claude Code jobs in this repository, or detailed status for a specific job id. Args: [job-id], --wait, --timeout-ms , --poll-interval-ms , --all. Use for tracked-job inspection, not setup or result retrieval.' +description: 'Show active or recent Claude Code jobs in this repository, or detailed status for a specific job id. Args: [job-id], --wait, --wait-timeout-ms , --poll-interval-ms , --all. Use for tracked-job inspection, not setup or result retrieval.' --- # Claude Code Status @@ -10,7 +10,7 @@ Use this skill when the user wants the current state of Claude Code jobs in this Resolve `` as two directories above this `SKILL.md` file. Keep the shell tool in the active Codex user workspace; never set its working directory to `` or the directory used to read this skill. Always run: `node "/scripts/claude-companion.mjs" status $ARGUMENTS` -Supported arguments: `[job-id]`, `--wait`, `--timeout-ms `, `--poll-interval-ms `, `--all` +Supported arguments: `[job-id]`, `--wait`, `--wait-timeout-ms `, deprecated alias `--timeout-ms `, `--poll-interval-ms `, `--all` Output: - Present the companion stdout exactly as returned. diff --git a/skills/status/agents/openai.yaml b/skills/status/agents/openai.yaml index e19d217..5da1b4b 100644 --- a/skills/status/agents/openai.yaml +++ b/skills/status/agents/openai.yaml @@ -1,5 +1,5 @@ interface: display_name: "Claude Code Status" - short_description: "Args: [job-id], --wait, --timeout-ms , --poll-interval-ms , --all. Inspect active or recent Claude Code jobs." + short_description: "Args: [job-id], --wait, --wait-timeout-ms , --poll-interval-ms , --all. Inspect active or recent Claude Code jobs." policy: allow_implicit_invocation: false diff --git a/stryker.shard.config.mjs b/stryker.shard.config.mjs index b3db7a5..b57cc96 100644 --- a/stryker.shard.config.mjs +++ b/stryker.shard.config.mjs @@ -21,15 +21,16 @@ const shards = { command: "npm run test:mutation:state:unit", mutate: [ // Persistence lifecycle, session lookup, and terminal job transitions. - "scripts/lib/state.mjs:180-220", - "scripts/lib/state.mjs:289-363", - "scripts/lib/state.mjs:395-443", - "scripts/lib/state.mjs:520-761", - "scripts/lib/state.mjs:810-982", - "scripts/lib/state.mjs:1048-1105", - "scripts/lib/state.mjs:1111-1157", + "scripts/lib/state.mjs:188-228", + "scripts/lib/state.mjs:297-371", + "scripts/lib/state.mjs:403-451", + "scripts/lib/state.mjs:528-838", + "scripts/lib/state.mjs:887-1059", + "scripts/lib/state.mjs:1125-1182", + "scripts/lib/state.mjs:1188-1234", "scripts/lib/tracked-jobs.mjs:26-39", - "scripts/lib/tracked-jobs.mjs:356-482", + "scripts/lib/tracked-jobs.mjs:282-340", + "scripts/lib/tracked-jobs.mjs:359-516", ], }, "job-control": { diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index 93df101..f390ef7 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -142,6 +142,19 @@ describe("StreamParser", () => { assert.equal(regularParser.state.hasTerminalLimitSignal, false); }); + it("marks synthetic authentication failures as trusted terminal signals", () => { + const parser = new StreamParser(); + parser.feed( + JSON.stringify({ + type: "result", + result: "Invalid API key. Please run /login.", + model: "", + }) + "\n" + ); + + assert.equal(parser.state.hasTerminalAuthSignal, true); + }); + it("does not guess context telemetry from synthetic limit payloads", () => { const parser = new StreamParser(); const resultEvent = JSON.stringify({ @@ -1079,6 +1092,54 @@ describe("StreamParser", () => { }); describe("classifyClaudeFailure", () => { + it("classifies Fable model-credit limits from terminal output", () => { + const failure = classifyClaudeFailure({ + finalMessage: "You've reached your Fable 5 limit", + finalMessageHasLimitSignal: true, + }); + + assert.equal(failure.kind, "claude_rate_limit"); + assert.match(failure.message, /Fable 5 limit/); + assert.equal(failure.resetText, null); + }); + + it("classifies actionable Claude authentication failures", () => { + const failure = classifyClaudeFailure({ + stderr: "Not logged in. Run claude auth login to continue.", + }); + + assert.equal(failure.kind, "claude_auth"); + assert.match(failure.message, /auth login/); + }); + + it("classifies authentication failures from terminal output", () => { + const failure = classifyClaudeFailure({ + finalMessage: "Invalid API key. Please run /login.", + finalMessageHasAuthSignal: true, + }); + + assert.equal(failure.kind, "claude_auth"); + }); + + it("ignores authentication prose from ordinary final output", () => { + assert.equal( + classifyClaudeFailure({ + finalMessage: "Documented the invalid API key and claude auth login errors.", + }), + null + ); + }); + + it("prefers a terminal quota signal over secondary auth stderr", () => { + const failure = classifyClaudeFailure({ + finalMessage: "You've reached your Fable 5 limit", + finalMessageHasLimitSignal: true, + stderr: "Not logged in. Run claude auth login.", + }); + + assert.equal(failure.kind, "claude_rate_limit"); + }); + it("classifies Claude 429 stderr and extracts reset text", () => { const failure = classifyClaudeFailure({ stderr: "APIErrorStatus: 429. You've hit your session limit; resets at 4:50pm (Europe/Moscow).", diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index ace9f14..940fd23 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -11,6 +11,8 @@ import path from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { SESSION_ID_ENV } from "../../scripts/lib/tracked-jobs.mjs"; + const PROJECT_ROOT = path.resolve( fileURLToPath(new URL("../../", import.meta.url)) ); @@ -896,9 +898,11 @@ function startMockProvider({ spawnMessage ?? "You are a transient forwarding worker for Claude Code rescue.\n" + "Run exactly one shell command.\n" + - "Run that command as one blocking foreground shell-tool call, not as a background terminal or session.\n" + - "Do not request a shell session id, poll a shell session later, or return before the command exits.\n" + - "If the shell tool is exec_command, call it once in non-interactive mode and wait for exit in that same call.\n" + + "Run that command in the foreground without shell backgrounding.\n" + + "If the shell tool returns a session id, keep polling that same session until the command exits.\n" + + "Exit code 0 is the only successful completion.\n" + + "Exit code 124 means the job is still running; return the command output without claiming it finished.\n" + + "For any other non-zero exit code or shell-tool error, return the raw command output or diagnostic without a success notification.\n" + "Return only that command's stdout text exactly.\n" + "Ignore stderr progress chatter such as [cc] lines.\n" + "If the tool output includes both stderr progress and a final stdout-style result, preserve only the final stdout-equivalent result text.\n" + @@ -932,16 +936,18 @@ function startMockProvider({ "spawned child turn should receive the forwarding contract" ); assert.ok( - bodyText.includes("blocking foreground shell-tool call, not as a background terminal or session"), - "built-in child should be told not to launch a background terminal/session" + bodyText.includes("Run that command in the foreground without shell backgrounding."), + "built-in child should be told not to launch shell backgrounding" ); assert.ok( - bodyText.includes("Do not request a shell session id, poll a shell session later, or return before the command exits."), - "built-in child should be told not to return a shell session before the command exits" + bodyText.includes("If the shell tool returns a session id, keep polling that same session until the command exits."), + "built-in child should be told to poll a yielded shell session" ); assert.ok( - bodyText.includes("If the shell tool is exec_command, call it once in non-interactive mode and wait for exit in that same call."), - "built-in child should be told how to use exec_command without backgrounding" + bodyText.includes("Exit code 0 is the only successful completion.") && + bodyText.includes("Exit code 124 means the job is still running") && + bodyText.includes("For any other non-zero exit code or shell-tool error"), + "built-in child should receive outcome-aware exit handling" ); assert.ok( bodyText.includes("transient forwarding worker for Claude Code rescue"), @@ -1342,9 +1348,10 @@ describe("Codex rescue-skill E2E", () => { expectedParentNeedles: [ "background-routing-context --kind task --json", "helper's non-empty `workspaceRoot` as the canonical workspace", - "blocking foreground shell-tool call, not as a background terminal/session", - "do not request a shell session id, poll a shell session later, or return before the companion command exits", - "if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call", + "If the shell tool returns a session id, keep polling that same session until the companion command exits.", + "Exit code 0 is the only successful completion.", + "Exit code 124 means the job is still running", + "For any other non-zero exit code or shell-tool error", ], notificationMessage, }); @@ -1481,6 +1488,7 @@ describe("Codex rescue-skill E2E", () => { } const testEnv = createEnvironment(); + testEnv.env[SESSION_ID_ENV] = "parent-resume-session"; const initialTaskPrompt = "codex-rescue-e2e builtin-agent initial delay=10"; const initialRequest = "$cc:rescue --builtin-agent --wait say hello from codex e2e"; let provider = startMockProvider({ @@ -1749,6 +1757,15 @@ describe("Codex direct-skill E2E", () => { const setupReport = JSON.parse(setupResult.stdout); assert.equal(setupReport.hookTrust.ready, true); assert.ok(setupReport.hookTrust.found > 0); + assert.equal(setupReport.diagnostics.runtimeSource, "installed-cache"); + assert.equal( + setupReport.diagnostics.pluginVersion, + JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8")).version + ); + assert.equal( + fs.realpathSync.native(setupReport.diagnostics.pluginRoot), + fs.realpathSync.native(pluginRoot) + ); } finally { await provider.close(); cleanupEnvironment(testEnv); @@ -1835,9 +1852,10 @@ describe("Codex direct-skill E2E", () => { "helper's non-empty `workspaceRoot` as the canonical workspace", "--owner-session-id ", "Never satisfy background review by running the companion command itself with shell backgrounding", - "blocking foreground shell-tool call, not as a background terminal/session", - "do not request a shell session id, poll a shell session later, or return before the companion command exits", - "if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call", + "If the shell tool returns a session id, keep polling that same session until the companion command exits.", + "Exit code 0 is the only successful completion.", + "Exit code 124 means the job is still running", + "For any other non-zero exit code or shell-tool error", "allow one extra `send_input` call after a successful shell result", "must target the provided parent thread id", "do not silently drop the completion notification path from the child prompt", @@ -1862,9 +1880,11 @@ describe("Codex direct-skill E2E", () => { "You are a pure forwarder for a background Claude Code review job.\n" + "Do not inspect the repo, do not review anything yourself, and do not add commentary.\n" + "Run exactly one shell command and capture only the stdout-equivalent final result text from that command, ignoring stderr progress chatter like [cc] lines.\n" + - "Run that command as one blocking foreground shell-tool call, not as a background terminal or session.\n" + - "Do not request a shell session id, poll a shell session later, or return before the command exits.\n" + - "If the shell tool is exec_command, call it once in non-interactive mode and wait for exit in that same call.\n" + + "Run that command in the foreground without shell backgrounding.\n" + + "If the shell tool returns a session id, keep polling that same session until the command exits.\n" + + "Exit code 0 is the only successful completion.\n" + + "Exit code 124 means the job is still running; return the command output without claiming it finished.\n" + + "For any other non-zero exit code or shell-tool error, return the raw command output or diagnostic without a success notification.\n" + "If the command succeeds and a parent thread id is available, send exactly this notification to the parent thread before finishing: " + JSON.stringify(notificationMessage) + "\n" + "Use that same sentence as your own final assistant message.\n" + @@ -2050,9 +2070,10 @@ describe("Codex direct-skill E2E", () => { "helper's non-empty `workspaceRoot` as the canonical workspace", "--owner-session-id ", "Never satisfy background adversarial review by running the companion command itself with shell backgrounding", - "blocking foreground shell-tool call, not as a background terminal/session", - "do not request a shell session id, poll a shell session later, or return before the companion command exits", - "if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call", + "If the shell tool returns a session id, keep polling that same session until the companion command exits.", + "Exit code 0 is the only successful completion.", + "Exit code 124 means the job is still running", + "For any other non-zero exit code or shell-tool error", "allow one extra `send_input` call after a successful shell result", "must target the provided parent thread id", "do not silently drop the completion notification path from the child prompt", @@ -2078,9 +2099,11 @@ describe("Codex direct-skill E2E", () => { "You are a pure forwarder for a background Claude Code adversarial review job.\n" + "Do not inspect the repo, do not review anything yourself, and do not add commentary.\n" + "Run exactly one shell command and capture only the stdout-equivalent final result text from that command, ignoring stderr progress chatter like [cc] lines.\n" + - "Run that command as one blocking foreground shell-tool call, not as a background terminal or session.\n" + - "Do not request a shell session id, poll a shell session later, or return before the command exits.\n" + - "If the shell tool is exec_command, call it once in non-interactive mode and wait for exit in that same call.\n" + + "Run that command in the foreground without shell backgrounding.\n" + + "If the shell tool returns a session id, keep polling that same session until the command exits.\n" + + "Exit code 0 is the only successful completion.\n" + + "Exit code 124 means the job is still running; return the command output without claiming it finished.\n" + + "For any other non-zero exit code or shell-tool error, return the raw command output or diagnostic without a success notification.\n" + "If the command succeeds and a parent thread id is available, send exactly this notification to the parent thread before finishing: " + JSON.stringify(notificationMessage) + "\n" + "Use that same sentence as your own final assistant message.\n" + @@ -2134,7 +2157,7 @@ describe("Codex direct-skill E2E", () => { userRequest, expectedNeedles: ["Claude Code Setup"], shellCommands: [ - `node ${JSON.stringify(COMPANION_SCRIPT)} setup --json --enable-review-gate`, + `node ${JSON.stringify(COMPANION_SCRIPT)} setup --check --json`, `node ${JSON.stringify(COMPANION_SCRIPT)} setup --enable-review-gate`, ], }); @@ -2173,7 +2196,7 @@ describe("Codex direct-skill E2E", () => { userRequest, expectedNeedles: ["Claude Code Setup"], shellCommands: [ - `node ${JSON.stringify(COMPANION_SCRIPT)} setup --json`, + `node ${JSON.stringify(COMPANION_SCRIPT)} setup --check --json`, `node ${JSON.stringify(COMPANION_SCRIPT)} setup`, ], }); @@ -2214,7 +2237,7 @@ describe("Codex direct-skill E2E", () => { userRequest, expectedNeedles: ["Claude Code Setup"], shellCommands: [ - `node ${JSON.stringify(COMPANION_SCRIPT)} setup --json --enable-review-gate`, + `node ${JSON.stringify(COMPANION_SCRIPT)} setup --check --json`, `node ${JSON.stringify(COMPANION_SCRIPT)} setup --enable-review-gate`, ], }); diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 6f154ce..205e770 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -50,6 +50,11 @@ async function main() { } if (args[0] === "auth" && args[1] === "status") { + if (process.env.CLAUDE_FAKE_AUTHENTICATED === "0") { + process.stderr.write("not authenticated\\n"); + process.exitCode = 1; + return; + } process.stdout.write("authenticated\\n"); return; } @@ -82,6 +87,8 @@ async function main() { const emitUnknownNoTerminal = /\\bunknown-no-terminal\\b/.test(prompt); const emitMalformedLine = /\\bmalformed-line\\b/.test(prompt); const emitSessionLimit = /\\bsession-limit\\b/.test(prompt); + const emitFableLimit = /\\bfable-limit\\b/.test(prompt); + const emitAuthFailure = /\\bauth-failure\\b/.test(prompt); const failAfterResult = /\\bfail-after-result\\b/.test(prompt); const terminalModelFallback = /\\bterminal-model-fallback\\b/.test(prompt); const terminalModel = process.env.CLAUDE_FAKE_TERMINAL_MODEL; @@ -156,6 +163,25 @@ async function main() { return; } + if (emitFableLimit) { + process.stdout.write( + JSON.stringify({ + type: "result", + session_id: sessionId, + result: "You've reached your Fable 5 limit", + model: "", + }) + "\\n" + ); + process.exitCode = 1; + return; + } + + if (emitAuthFailure) { + process.stderr.write("Not logged in. Run claude auth login to continue.\\n"); + process.exitCode = 1; + return; + } + if (emitMalformedLine) { process.stdout.write("{not-json\\n"); } @@ -631,6 +657,32 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function createSlowParentPsShim(testEnv, delayMs) { + const shimDir = path.join(testEnv.rootDir, "slow-parent-ps"); + const shimPath = path.join(shimDir, "ps"); + fs.mkdirSync(shimDir, { recursive: true }); + fs.writeFileSync( + shimPath, + [ + "#!/usr/bin/env node", + 'const { spawnSync } = require("node:child_process");', + "const args = process.argv.slice(2);", + 'const parent = spawnSync("/bin/ps", ["-o", "command=", "-p", String(process.ppid)], { encoding: "utf8" });', + 'if (!/\\b(?:task|review)-worker\\b/.test(parent.stdout || "")) {', + ` Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${delayMs});`, + "}", + 'const result = spawnSync("/bin/ps", args, { encoding: "utf8" });', + 'process.stdout.write(result.stdout || "");', + 'process.stderr.write(result.stderr || "");', + "process.exitCode = result.status ?? 1;", + "", + ].join("\n"), + "utf8" + ); + fs.chmodSync(shimPath, 0o755); + return shimDir; +} + function runGit(cwd, args) { const result = spawnSync("git", args, { cwd, @@ -807,6 +859,75 @@ describe("claude-companion integration", () => { } }); + it("setup --check reports required repairs without changing config or hook trust", () => { + const testEnv = createTestEnvironment(); + const codexDir = path.join(testEnv.homeDir, ".codex"); + const configFile = path.join(codexDir, "config.toml"); + const originalConfig = "[features]\nhooks = false\nplugin_hooks = false\n"; + fs.mkdirSync(codexDir, { recursive: true }); + fs.writeFileSync(configFile, originalConfig, "utf8"); + const fakeCodex = createFakeCodexAppServer(testEnv, [ + { + key: "cc@sendbird:hooks/hooks.json:session_start:0:0", + sourcePath: path.join(PROJECT_ROOT, "hooks", "hooks.json"), + source: "plugin", + pluginId: "cc@sendbird", + currentHash: "sha256:session", + trustStatus: "untrusted", + }, + ]); + + try { + const report = runCompanionJson( + ["setup", "--cwd", testEnv.workspaceDir, "--check", "--json"], + { + env: { + ...testEnv.env, + CC_PLUGIN_CODEX_EXECUTABLE: process.execPath, + CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON: JSON.stringify([fakeCodex.serverPath]), + CC_PLUGIN_CODEX_FORCE_HOOK_TRUST: "1", + }, + } + ); + + assert.equal(report.checkOnly, true); + assert.equal(report.ready, false); + assert.equal(report.hooks.installed, false); + assert.equal(report.hookTrust.ready, false); + assert.equal(report.hookTrust.pendingTrust, 1); + assert.deepEqual(report.actionsTaken, []); + assert.equal(fs.readFileSync(configFile, "utf8"), originalConfig); + assert.equal(report.diagnostics.configPath, configFile); + assert.equal(report.diagnostics.workspaceRoot, testEnv.workspaceDir); + assert.match(report.diagnostics.pluginVersion, /^\d+\.\d+\.\d+/); + + const requests = readJsonLines(fakeCodex.logPath); + assert.ok(requests.some((request) => request.method === "hooks/list")); + assert.ok(!requests.some((request) => request.method === "config/batchWrite")); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("rejects mutating review-gate flags in setup --check mode", () => { + const testEnv = createTestEnvironment(); + try { + const result = runCompanionExpectFailure( + [ + "setup", + "--cwd", + testEnv.workspaceDir, + "--check", + "--enable-review-gate", + ], + { env: testEnv.env } + ); + assert.match(result.stderr, /--check cannot be combined with review-gate changes/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("setup trusts current native plugin hooks through Codex hooks/list", () => { const testEnv = createTestEnvironment(); const projectAlias = path.join(testEnv.rootDir, "plugin-root-link"); @@ -1599,6 +1720,42 @@ describe("claude-companion integration", () => { } }); + it("classifies Fable quota and authentication failures with actionable output", () => { + const testEnv = createTestEnvironment(); + + try { + const fableResult = runCompanionExpectFailure( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--json", + "--quiet-progress", + "fable-limit delay=20", + ], + { env: testEnv.env } + ); + const fablePayload = JSON.parse(fableResult.stdout); + assert.equal(fablePayload.failure?.kind, "claude_rate_limit"); + assert.match(fablePayload.failure?.message ?? "", /Fable 5 limit/); + + const authResult = runCompanionExpectFailure( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--quiet-progress", + "auth-failure delay=20", + ], + { env: testEnv.env } + ); + assert.match(authResult.stdout, /claude auth login/); + assert.match(authResult.stdout, /Not logged in/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("uses --resume to continue the latest session and keeps --fresh from injecting a resume id", async () => { const testEnv = createTestEnvironment(); const sessionEnv = { @@ -2317,7 +2474,7 @@ describe("claude-companion integration", () => { testEnv.workspaceDir, "--json", "--wait", - "--timeout-ms", + "--wait-timeout-ms", "5000", "--poll-interval-ms", "10", @@ -2334,6 +2491,29 @@ describe("claude-companion integration", () => { } }); + it("rejects both status wait-timeout spellings together", () => { + const testEnv = createTestEnvironment(); + try { + const result = runCompanionExpectFailure( + [ + "status", + "--cwd", + testEnv.workspaceDir, + "--wait", + "--wait-timeout-ms", + "100", + "--timeout-ms", + "200", + "missing-job", + ], + { env: testEnv.env } + ); + assert.match(result.stderr, /Choose only one of --wait-timeout-ms or --timeout-ms/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("filters status overview to the current session marker when env is unavailable", () => { const testEnv = createTestEnvironment(); @@ -2445,6 +2625,10 @@ describe("claude-companion integration", () => { ...testEnv.env, [SESSION_ID_ENV]: "child-session", }; + const parentSessionEnv = { + ...testEnv.env, + [SESSION_ID_ENV]: "parent-session", + }; try { writeCurrentSessionMarker(testEnv, "parent-session"); @@ -2481,7 +2665,7 @@ describe("claude-companion integration", () => { const resumeCandidate = runCompanionJson( ["task-resume-candidate", "--cwd", testEnv.workspaceDir, "--json"], - { env: testEnv.env } + { env: parentSessionEnv } ); assert.equal(resumeCandidate.available, true); assert.equal(resumeCandidate.sessionId, "parent-session"); @@ -2779,7 +2963,7 @@ describe("claude-companion integration", () => { } }); - it("keeps completed resume candidates session-scoped and ignores active tasks", async () => { + it("keeps resume candidates session-scoped and blocks only on active tasks from that session", async () => { const testEnv = createTestEnvironment(); const sessionAEnv = { ...testEnv.env, @@ -2837,10 +3021,11 @@ describe("claude-companion integration", () => { ["task-resume-candidate", "--cwd", testEnv.workspaceDir, "--json"], { env: sessionAEnv } ); - assert.equal(candidateA.available, true); + assert.equal(candidateA.available, false); assert.equal(candidateA.sessionId, "session-a"); - assert.equal(candidateA.candidate.id, completedA.jobId); - assert.notEqual(candidateA.candidate.id, activeA.jobId); + assert.equal(candidateA.reason, "active_task"); + assert.equal(candidateA.activeJobId, activeA.jobId); + assert.equal(candidateA.candidate, null); const candidateB = runCompanionJson( ["task-resume-candidate", "--cwd", testEnv.workspaceDir, "--json"], @@ -2850,6 +3035,21 @@ describe("claude-companion integration", () => { assert.equal(candidateB.sessionId, "session-b"); assert.equal(candidateB.candidate.id, completedB.jobId); + const explicitCandidateB = runCompanionJson( + [ + "task-resume-candidate", + "--cwd", + testEnv.workspaceDir, + "--owner-session-id", + "session-b", + "--json", + ], + { env: testEnv.env } + ); + assert.equal(explicitCandidateB.available, true); + assert.equal(explicitCandidateB.ownerSessionId, "session-b"); + assert.equal(explicitCandidateB.candidate.id, completedB.jobId); + const candidateC = runCompanionJson( ["task-resume-candidate", "--cwd", testEnv.workspaceDir, "--json"], { env: sessionCEnv } @@ -2870,9 +3070,9 @@ describe("claude-companion integration", () => { ["task-resume-candidate", "--cwd", testEnv.workspaceDir, "--json"], { env: testEnv.env } ); - assert.equal(markerCandidateA.available, true); - assert.equal(markerCandidateA.sessionId, "session-a"); - assert.equal(markerCandidateA.candidate.id, completedA.jobId); + assert.equal(markerCandidateA.available, false); + assert.equal(markerCandidateA.sessionId, null); + assert.equal(markerCandidateA.candidate, null); writeCurrentSessionMarker(testEnv, "session-c"); const markerCandidateC = runCompanionJson( @@ -2880,10 +3080,165 @@ describe("claude-companion integration", () => { { env: testEnv.env } ); assert.equal(markerCandidateC.available, false); - assert.equal(markerCandidateC.sessionId, "session-c"); + assert.equal(markerCandidateC.sessionId, null); assert.equal(markerCandidateC.candidate, null); await waitForTerminalResult(testEnv, activeA.jobId, sessionAEnv); + + const completedCandidateA = runCompanionJson( + ["task-resume-candidate", "--cwd", testEnv.workspaceDir, "--json"], + { env: sessionAEnv } + ); + assert.equal(completedCandidateA.available, true); + assert.equal(completedCandidateA.candidate.id, activeA.jobId); + assert.match(completedCandidateA.candidate.claudeSessionId, /^stub-/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("resumes a failed task from the same owner despite a foreign active task", () => { + const testEnv = createTestEnvironment(); + const sessionAEnv = { + ...testEnv.env, + [SESSION_ID_ENV]: "session-a", + }; + + try { + writeSessionScopedJob(testEnv, "task-a-failed", { + id: "task-a-failed", + jobClass: "task", + sessionId: "session-a", + status: "failed", + threadId: "claude-session-a", + result: { sessionId: "claude-session-a" }, + createdAt: "2026-04-03T10:00:00Z", + completedAt: "2026-04-03T10:01:00Z", + }); + writeSessionScopedJob(testEnv, "task-b-running", { + id: "task-b-running", + jobClass: "task", + sessionId: "session-b", + status: "running", + createdAt: "2026-04-03T11:00:00Z", + }); + + const resumeArgsFile = path.join(testEnv.rootDir, "owner-resume-args.json"); + runCompanion( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--owner-session-id", + "session-a", + "--resume", + "--quiet-progress", + "owner-resume delay=20", + ], + { + env: { + ...sessionAEnv, + CLAUDE_ARGS_FILE: resumeArgsFile, + }, + } + ); + + const resumeArgs = JSON.parse(fs.readFileSync(resumeArgsFile, "utf8")); + assert.equal( + resumeArgs[resumeArgs.indexOf("--resume") + 1], + "claude-session-a" + ); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("keeps an ownerless task visible without using the marker for resume", async () => { + const testEnv = createTestEnvironment(); + try { + writeCurrentSessionMarker(testEnv, "stale-marker-session"); + const launch = await runCompanionAsyncJson( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--background", + "--json", + "ownerless-task delay=20", + ], + { env: testEnv.env } + ); + await waitForTerminalResult(testEnv, launch.jobId, testEnv.env); + assert.equal( + readStoredJobById(testEnv, launch.jobId).sessionId, + "stale-marker-session" + ); + + const resume = runCompanionExpectFailure( + ["task", "--cwd", testEnv.workspaceDir, "--resume", "follow-up"], + { env: testEnv.env } + ); + assert.match(resume.stderr, /Cannot resume without an owning Codex session/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("marks a rendered foreground failure as viewed", () => { + const testEnv = createTestEnvironment(); + const sessionEnv = { + ...testEnv.env, + [SESSION_ID_ENV]: "session-foreground-failure", + }; + + try { + const result = runCompanionExpectFailure( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--quiet-progress", + "auth-failure delay=20", + ], + { env: sessionEnv } + ); + assert.match(result.stdout, /claude auth login/); + + const failedJob = listStoredJobs(testEnv).find( + (job) => + job.sessionId === "session-foreground-failure" && + job.status === "failed" + ); + assert.ok(failedJob, "expected one failed foreground job"); + assert.match(failedJob.resultViewedAt ?? "", /\d{4}-\d{2}-\d{2}T/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("marks a throwing foreground failure as viewed", () => { + const testEnv = createTestEnvironment(); + const sessionEnv = { + ...testEnv.env, + [SESSION_ID_ENV]: "session-foreground-throw", + CLAUDE_FAKE_AUTHENTICATED: "0", + }; + + try { + setupGitWorkspace(testEnv.workspaceDir); + const result = runCompanionExpectFailure( + ["review", "--cwd", testEnv.workspaceDir, "--scope", "working-tree"], + { env: sessionEnv } + ); + assert.match(result.stderr, /claude auth login/); + + const failedJob = listStoredJobs(testEnv).find( + (job) => + job.sessionId === "session-foreground-throw" && + job.status === "failed" + ); + assert.ok(failedJob, "expected one failed foreground job"); + assert.match(failedJob.resultViewedAt ?? "", /\d{4}-\d{2}-\d{2}T/); } finally { cleanupTestEnvironment(testEnv); } @@ -3106,7 +3461,7 @@ describe("claude-companion integration", () => { testEnv.workspaceDir, "--json", "--quiet-progress", - "--timeout-ms", + "--wait-timeout-ms", "1", "--poll-interval-ms", "1", @@ -3143,19 +3498,42 @@ describe("claude-companion integration", () => { "task", "--cwd", testEnv.workspaceDir, - "--timeout-ms", + "--wait-timeout-ms", value, "invalid-timeout", ], { env: testEnv.env } ); - assert.match(result.stderr, /--timeout-ms must be a positive number of milliseconds/); + assert.match(result.stderr, /--wait-timeout-ms must be a positive number of milliseconds/); } } finally { cleanupTestEnvironment(testEnv); } }); + it("rejects both foreground task timeout spellings together", () => { + const testEnv = createTestEnvironment(); + + try { + const result = runCompanionExpectFailure( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--wait-timeout-ms", + "100", + "--timeout-ms", + "200", + "ambiguous-timeout", + ], + { env: testEnv.env } + ); + assert.match(result.stderr, /Choose only one of --wait-timeout-ms or --timeout-ms/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("preserves cancelled job status in synthetic foreground task payloads", async () => { const testEnv = createTestEnvironment(); const sessionEnv = { @@ -4094,6 +4472,47 @@ describe("claude-companion integration", () => { } }); + it("does not overwrite a running Claude child when parent worker identity resolves late", async (t) => { + if (process.platform !== "darwin") { + t.skip("Darwin ps shim reproduces the post-spawn identity race"); + return; + } + const testEnv = createTestEnvironment(); + const shimDir = createSlowParentPsShim(testEnv, 1_000); + const sessionEnv = { + ...testEnv.env, + [SESSION_ID_ENV]: "session-late-parent-worker-identity", + PATH: `${shimDir}${path.delimiter}${testEnv.env.PATH}`, + }; + + try { + const launch = await runCompanionAsyncJson( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--background", + "--json", + "late-parent-worker-identity delay=2500", + ], + { env: sessionEnv } + ); + const runningJob = readStoredJobById(testEnv, launch.jobId); + assert.equal(runningJob.status, "running"); + assert.notEqual(runningJob.pid, runningJob.workerPid); + + const result = await waitForTerminalResult( + testEnv, + launch.jobId, + sessionEnv, + { timeoutMs: 6_000 } + ); + assert.equal(result.job.status, "completed"); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("cancels one background task cleanly while sibling tasks continue to completion", async () => { const testEnv = createTestEnvironment(); const prompts = [ diff --git a/tests/mutation-config.test.mjs b/tests/mutation-config.test.mjs index 421d9cc..9c8fbaa 100644 --- a/tests/mutation-config.test.mjs +++ b/tests/mutation-config.test.mjs @@ -18,15 +18,16 @@ const expectations = [ ["scripts/lib/process.mjs:108-179", ["terminateProcessTree"]], ["scripts/lib/process.mjs:185-367", ["terminateProcessTreeIfIdentityMatches"]], ["scripts/lib/process.mjs:390-504", ["getProcessIdentity", "getSpawnedProcessIdentity", "validateProcessIdentity", "isProcessAlive", "isProcessGroupAlive"]], - ["scripts/lib/state.mjs:180-220", ["ensurePluginDataLayout", "resolveWorkspaceHash", "ensureStateDir"]], - ["scripts/lib/state.mjs:289-363", ["setCurrentSession", "getCurrentSession", "clearCurrentSession", "markSessionCleanupPending", "listPendingSessionCleanups", "clearSessionCleanupPending"]], - ["scripts/lib/state.mjs:395-443", ["writeJobFile", "normalizeStoredJob"]], - ["scripts/lib/state.mjs:520-761", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]], - ["scripts/lib/state.mjs:810-982", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], - ["scripts/lib/state.mjs:1048-1105", ["casJobStatus", "transitionJob", "writeAtomic"]], - ["scripts/lib/state.mjs:1111-1157", ["cleanupOldJobs"]], + ["scripts/lib/state.mjs:188-228", ["ensurePluginDataLayout", "resolveWorkspaceHash", "ensureStateDir"]], + ["scripts/lib/state.mjs:297-371", ["setCurrentSession", "getCurrentSession", "clearCurrentSession", "markSessionCleanupPending", "listPendingSessionCleanups", "clearSessionCleanupPending"]], + ["scripts/lib/state.mjs:403-451", ["writeJobFile", "normalizeStoredJob"]], + ["scripts/lib/state.mjs:528-838", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]], + ["scripts/lib/state.mjs:887-1059", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], + ["scripts/lib/state.mjs:1125-1182", ["casJobStatus", "transitionJob", "writeAtomic"]], + ["scripts/lib/state.mjs:1188-1234", ["cleanupOldJobs"]], ["scripts/lib/tracked-jobs.mjs:26-39", ["transitionTrackedJob"]], - ["scripts/lib/tracked-jobs.mjs:356-482", ["runTrackedJob"]], + ["scripts/lib/tracked-jobs.mjs:282-340", ["createJobProgressUpdater"]], + ["scripts/lib/tracked-jobs.mjs:359-516", ["runTrackedJob"]], ["scripts/lib/job-control.mjs:144-247", ["matchJobReference", "buildStatusSnapshot", "resolveCancelableJob"]], ["scripts/installer-cli.mjs:96-234", ["readPersonalMarketplace", "prepareLegacyLocalCleanup", "isPluginAlreadyAbsent", "isPluginUninstallRefused"]], ["scripts/installer-cli.mjs:275-371", ["installOrUpdate", "uninstall"]], @@ -62,3 +63,27 @@ test("mutation line ranges still contain their intended complete functions", () ); } }); + +test("Windows lifecycle gate patterns stay aligned with their tests", () => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8") + ); + const lifecycleScript = packageJson.scripts["test:lifecycle-contract"]; + const expected = [ + ["tests/state.test.mjs", "keeps a running job while its owning worker is alive"], + ["tests/state.test.mjs", "falls back to the identity-checked Claude PID"], + ["tests/state.test.mjs", "terminates a live Claude child"], + ["tests/state.test.mjs", "bounds Windows Claude child cleanup"], + ["tests/state.test.mjs", "clears a recycled Claude child PID"], + ["tests/state.test.mjs", "clears an identity-unavailable Claude child"], + ["tests/state.test.mjs", "reports cancel_failed when a live Claude child"], + ["tests/tracked-jobs.test.mjs", "tracks the worker separately"], + ["tests/tracked-jobs.test.mjs", "logs when worker identity is unavailable"], + ["tests/tracked-jobs.test.mjs", "does not bypass a terminal writer"], + ["tests/tracked-jobs.test.mjs", "ignores progress after its job file disappears"], + ]; + for (const [file, name] of expected) { + assert.match(lifecycleScript, new RegExp(name)); + assert.match(fs.readFileSync(path.join(PROJECT_ROOT, file), "utf8"), new RegExp(name)); + } +}); diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 6fdf8ef..3e8ea61 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -170,6 +170,24 @@ describe("renderSetupReport", () => { assert.ok(output.includes("- hooks: Codex hooks installed")); }); + it("labels read-only checks and includes runtime diagnostics", () => { + const output = renderSetupReport({ + ...baseReport, + checkOnly: true, + diagnostics: { + runtimeSource: "source-checkout", + pluginVersion: "1.5.4", + configPath: "/tmp/codex/config.toml", + workspaceRoot: "/tmp/workspace", + }, + }); + + assert.match(output, /Mode: read-only check/); + assert.match(output, /plugin: 1\.5\.4 \(source-checkout\)/); + assert.match(output, /config: \/tmp\/codex\/config\.toml/); + assert.match(output, /workspace: \/tmp\/workspace/); + }); + it("includes hook trust details when present", () => { const output = renderSetupReport({ ...baseReport, @@ -404,6 +422,18 @@ describe("renderTaskResult", () => { assert.match(output, /You've hit your session limit/); }); + it("renders Claude authentication failures with login guidance", () => { + const output = renderTaskResult({ + failure: { + kind: "claude_auth", + message: "Not logged in.", + }, + }); + + assert.match(output, /claude auth login/); + assert.match(output, /Not logged in/); + }); + it("returns default message when nothing provided", () => { const output = renderTaskResult({}); assert.ok(output.includes("did not return a final message")); diff --git a/tests/skills-contracts.test.mjs b/tests/skills-contracts.test.mjs index be720c1..738f7d9 100644 --- a/tests/skills-contracts.test.mjs +++ b/tests/skills-contracts.test.mjs @@ -134,8 +134,10 @@ test("review skills preserve foreground/background routing contracts", () => { "`fork_context: false`", '`reasoning_effort: "medium"`', "Omit `model` so the forwarding child inherits the current Codex runtime model.", - "run that command as one blocking foreground shell-tool call, not as a background terminal/session", - "do not request a shell session id, poll a shell session later, or return before the companion command exits", + "If the shell tool returns a session id, keep polling that same session until the companion command exits.", + "Exit code 0 is the only successful completion.", + "Exit code 124 means the job is still running; return the companion output without claiming it finished.", + "For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification.", "never leave an empty routing placeholder such as `--owner-session-id --job-id`", "send_input({ target: , message: })", notification, @@ -208,7 +210,8 @@ test("rescue keeps host execution controls out of the companion task", () => { assertIncludesAll( rescue, [ - "task-resume-candidate --json", + "session-routing-context --json", + "task-resume-candidate --owner-session-id --json", "background-routing-context --kind task --json", '--cwd ""', "--view-state on-success", @@ -218,11 +221,20 @@ test("rescue keeps host execution controls out of the companion task", () => { "--prompt-file", "send_input({ target: , message: })", "Background Claude Code rescue finished. Open it with $cc:result .", + "If the shell tool returns a session id, keep polling that same session until the companion command exits.", + "Exit code 0 is the only successful completion.", + "Exit code 124 means the job is still running; return the companion output without claiming it finished.", + "For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification.", "../../internal-skills/cli-runtime/runtime.md", "../../internal-skills/task-prompt-shaping/prompt-shaping.md", ], "rescue" ); + assert.ok( + rescue.indexOf("session-routing-context --json") < + rescue.indexOf("task-resume-candidate --owner-session-id --json"), + "rescue must resolve owner routing before probing for a resume candidate" + ); assert.match(rescue, /Never forward either flag to `claude-companion\.mjs task`/i); assert.doesNotMatch(rescue, /claude-companion\.mjs" task --(?:background|wait)/i); assert.doesNotMatch(rescue, /gpt-5\.\d+/i); @@ -249,8 +261,10 @@ test("internal runtime references preserve executable routing invariants", () => "Never emit an empty routing placeholder such as `--owner-session-id --job-id`", 'review --cwd "" --view-state defer', 'adversarial-review --cwd "" --view-state defer', - "run the companion command as one blocking foreground shell-tool call, not as a background terminal/session", - "do not request a shell session id, poll a shell session later, or return before the companion command exits", + "If the shell tool returns a session id, keep polling that same session until the companion command exits.", + "Exit code 0 is the only successful completion.", + "Exit code 124 means the job is still running; return the companion output without claiming it finished.", + "For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification.", "Omit `model` so the child inherits the current Codex runtime model.", "Do not add a fixed-version model fallback.", "send_input({ target: , message: })", @@ -267,8 +281,10 @@ test("internal runtime references preserve executable routing invariants", () => 'node "/scripts/claude-companion.mjs" task --cwd ""', "Never derive the workspace from the plugin root", "Never emit an empty routing placeholder such as `--owner-session-id --job-id`", - "Run the companion command as one blocking foreground shell-tool call, not as a background terminal/session.", - "Do not request a shell session id, poll a shell session later, or return before the companion command exits.", + "If the shell tool returns a session id, keep polling that same session until the companion command exits.", + "Exit code 0 is the only successful completion.", + "Exit code 124 means the job is still running; return the companion output without claiming it finished.", + "For any other non-zero exit code or shell-tool error, return the raw companion output or diagnostic without a success notification.", "Never call `task --background` or invent `task --wait`.", "--owner-session-id ", "--job-id", @@ -286,7 +302,8 @@ test("setup keeps native hook repair in the companion flow", () => { assertIncludesAll( setup, [ - 'claude-companion.mjs" setup --json', + 'claude-companion.mjs" setup --check --json', + "`--check` is read-only", "[features].hooks", "[features].plugin_hooks", "native hook trust hashes", diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 5d849c8..c03f017 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -1629,6 +1629,245 @@ describe("reapStaleJobs", () => { assert.ok(result[0].completedAt); }); + it("keeps a running job while its owning worker is alive after the Claude PID exits", () => { + const id = "test-reap-live-worker"; + const claudePid = 11111; + const workerPid = 22222; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: claudePid, + pidIdentity: "claude-identity", + workerPid, + workerPidIdentity: "worker-identity", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + const checkedPids = []; + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: (pid) => { + checkedPids.push(pid); + return pid === workerPid; + }, + getProcessIdentityImpl: (pid) => + pid === workerPid ? "worker-identity" : "claude-identity", + } + ); + + assert.deepEqual(checkedPids, [workerPid]); + assert.equal(result[0].status, "running"); + assert.equal(result[0].pid, claudePid); + assert.equal(result[0].workerPid, workerPid); + }); + + it("falls back to the identity-checked Claude PID when worker identity is missing", () => { + const id = "test-reap-worker-no-identity"; + const claudePid = 11113; + const workerPid = 22224; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: claudePid, + pidIdentity: "claude-identity", + workerPid, + workerPidIdentity: null, + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + const checkedPids = []; + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: (pid) => { + checkedPids.push(pid); + return false; + }, + getProcessIdentityImpl: () => "claude-identity", + } + ); + + assert.deepEqual(checkedPids, [claudePid]); + assert.equal(result[0].status, "failed"); + }); + + it("terminates a live Claude child when its owning worker dies", () => { + const id = "test-reap-dead-worker-live-child"; + const claudePid = 11114; + const workerPid = 22225; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: claudePid, + pidIdentity: "claude-identity", + workerPid, + workerPidIdentity: "worker-identity", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + const terminated = []; + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: (pid) => pid === claudePid, + getProcessIdentityImpl: () => "worker-identity", + terminateProcessTreeIfIdentityMatchesImpl: (pid, identity) => { + terminated.push([pid, identity]); + return { attempted: true, delivered: true }; + }, + } + ); + + assert.deepEqual(terminated, [[claudePid, "claude-identity"]]); + assert.equal(result[0].status, "failed"); + assert.equal(result[0].pid, null); + assert.equal(result[0].pidIdentity, null); + }); + + it("bounds Windows Claude child cleanup to the reaper identity timeout", () => { + const id = "test-reap-dead-worker-windows-child-timeout"; + const claudePid = 11116; + const workerPid = 22227; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: claudePid, + pidIdentity: "123456789", + workerPid, + workerPidIdentity: "987654321", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + /** @type {{ timeout?: number } | null} */ + let cleanupOptions = null; + + reapStaleJobs(PROJECT_CWD, [readJobFile(PROJECT_CWD, id)], { + platform: "win32", + isProcessAliveImpl: (pid) => pid === claudePid, + getProcessIdentityImpl: () => "987654321", + terminateProcessTreeIfIdentityMatchesImpl: (_pid, _identity, options) => { + cleanupOptions = options; + return { attempted: true, delivered: true }; + }, + }); + + assert.equal(cleanupOptions?.timeout, 2_000); + }); + + it("clears a recycled Claude child PID when its owning worker dies", () => { + const id = "test-reap-dead-worker-recycled-child"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: 11115, + pidIdentity: "old-claude-identity", + workerPid: 22226, + workerPidIdentity: "worker-identity", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: () => false, + getProcessIdentityImpl: () => "worker-identity", + terminateProcessTreeIfIdentityMatchesImpl: () => ({ + attempted: false, + delivered: false, + reason: "identity-mismatch", + }), + } + ); + + assert.equal(result[0].status, "failed"); + assert.equal(result[0].pid, null); + assert.equal(result[0].pidIdentity, null); + assert.doesNotMatch(result[0].errorMessage, /manual cleanup/i); + }); + + it("clears an identity-unavailable Claude child after confirming it is dead", () => { + const id = "test-reap-dead-worker-dead-unidentified-child"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: 11117, + pidIdentity: null, + workerPid: 22228, + workerPidIdentity: "worker-identity", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: () => false, + getProcessIdentityImpl: () => "worker-identity", + terminateProcessTreeIfIdentityMatchesImpl: () => ({ + attempted: false, + delivered: false, + reason: "identity-unavailable", + }), + } + ); + + assert.equal(result[0].status, "failed"); + assert.equal(result[0].pid, null); + assert.equal(result[0].pidIdentity, null); + assert.doesNotMatch(result[0].errorMessage, /manual cleanup/i); + }); + + it("reports cancel_failed when a live Claude child cannot be cleaned up", () => { + const id = "test-reap-cancelling-dead-worker-live-child"; + const claudePid = 11118; + const workerPid = 22229; + writeJobFile(PROJECT_CWD, id, { + id, + status: "cancelling", + pid: claudePid, + pidIdentity: null, + workerPid, + workerPidIdentity: "worker-identity", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: (pid) => pid === claudePid, + getProcessIdentityImpl: () => "worker-identity", + terminateProcessTreeIfIdentityMatchesImpl: () => ({ + attempted: false, + delivered: false, + reason: "identity-unavailable", + }), + } + ); + + assert.equal(result[0].status, "cancel_failed"); + assert.equal(result[0].pid, claudePid); + assert.equal(result[0].phase, "cancel_failed"); + assert.match(result[0].errorMessage, /manual cleanup/i); + }); + it("transitions queued job with dead worker PID to failed", () => { const id = "test-reap-queued-dead"; writeJobFile(PROJECT_CWD, id, { diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index 02cf18f..337a4fc 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -373,6 +373,52 @@ describe("createJobProgressUpdater", () => { fs.rmSync(repoDir, { recursive: true, force: true }); } }); + + it("does not bypass a terminal writer that already owns the job lock", () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-progress-terminal-lock", + workspaceRoot: repoDir, + status: "running", + phase: "running", + title: "terminal writer lock", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + writeJobFile(repoDir, job.id, job); + const lockFile = `${resolveJobFile(repoDir, job.id)}.lock`; + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: process.pid, + identity: null, + timestamp: Date.now(), + token: "terminal-writer", + }) + ); + + try { + const updateProgress = createJobProgressUpdater(repoDir, job.id); + assert.doesNotThrow(() => + updateProgress({ phase: "subagent", message: "late delta" }) + ); + assert.equal(readJobFile(repoDir, job.id).phase, "running"); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it("ignores progress after its job file disappears", () => { + const repoDir = createTempGitRepo(); + try { + const updateProgress = createJobProgressUpdater(repoDir, "missing-progress-job"); + assert.doesNotThrow(() => + updateProgress({ phase: "subagent", message: "late delta" }) + ); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); }); // --------------------------------------------------------------------------- @@ -380,6 +426,90 @@ describe("createJobProgressUpdater", () => { // --------------------------------------------------------------------------- describe("runTrackedJob", () => { + it("tracks the worker separately from the cancellable Claude process", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-worker-owner-job", + workspaceRoot: repoDir, + status: "queued", + title: "worker ownership", + workerPid: process.pid, + workerPidIdentity: "parent-captured-worker-identity", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + writeJobFile(repoDir, job.id, job); + + try { + await runTrackedJob(job, async (onSpawn) => { + const starting = readJobFile(repoDir, job.id); + assert.equal(starting.workerPid, process.pid); + assert.equal( + starting.workerPidIdentity, + "parent-captured-worker-identity" + ); + + onSpawn({ pid: 43210, pidIdentity: "claude-identity" }); + const running = readJobFile(repoDir, job.id); + assert.equal(running.workerPid, process.pid); + assert.equal(running.pid, 43210); + assert.equal(running.pidIdentity, "claude-identity"); + + return { + exitStatus: 0, + threadId: "thread-worker-owner", + turnId: null, + payload: { answer: 42 }, + rendered: "finished", + summary: "finished", + }; + }); + + const terminal = readJobFile(repoDir, job.id); + assert.equal(terminal.status, "completed"); + assert.equal(terminal.pid, null); + assert.equal(terminal.pidIdentity, null); + assert.equal(terminal.workerPid, null); + assert.equal(terminal.workerPidIdentity, null); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it("logs when worker identity is unavailable and reaping must degrade", async () => { + const repoDir = createTempGitRepo(); + const logFile = path.join(repoDir, "worker-identity.log"); + const job = { + id: "tracked-worker-identity-unavailable", + workspaceRoot: repoDir, + status: "queued", + title: "worker identity warning", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + writeJobFile(repoDir, job.id, job); + + try { + await runTrackedJob( + job, + async () => ({ exitStatus: 0, rendered: "finished" }), + { + logFile, + getProcessIdentityImpl: () => { + throw new Error("ps unavailable"); + }, + } + ); + + assert.match( + fs.readFileSync(logFile, "utf8"), + /worker identity unavailable.*Claude child identity/i + ); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + it("does not revive a queued job after cancellation wins before startup", async () => { const repoDir = createTempGitRepo(); const job = { diff --git a/tests/unread-result-hook.test.mjs b/tests/unread-result-hook.test.mjs index 9c8f8bc..46f219a 100644 --- a/tests/unread-result-hook.test.mjs +++ b/tests/unread-result-hook.test.mjs @@ -172,6 +172,61 @@ test("injects one-shot context for same-session completed unread jobs and marks } }); +test("announces failed terminal jobs with their outcome", () => { + const testEnv = createEnv(); + try { + writeJob(testEnv, { + id: "task-failed", + sessionId: "session-a", + status: "failed", + kindLabel: "rescue", + summary: "fix failed", + createdAt: "2026-04-03T10:00:00Z", + updatedAt: "2026-04-03T10:01:00Z", + completedAt: "2026-04-03T10:01:00Z", + }); + + const output = runHook(testEnv, { + hook_event_name: "UserPromptSubmit", + cwd: testEnv.workspaceDir, + session_id: "session-a", + prompt: "continue working", + }); + + assert.match(output, /task-failed/); + assert.match(output, /failed/); + assert.match(readJob(testEnv, "task-failed").notifiedAt, /\d{4}-\d{2}-\d{2}T/); + } finally { + cleanupEnv(testEnv); + } +}); + +test("does not announce an expected user cancellation", () => { + const testEnv = createEnv(); + try { + writeJob(testEnv, { + id: "task-cancelled", + sessionId: "session-a", + status: "cancelled", + createdAt: "2026-04-03T10:00:00Z", + updatedAt: "2026-04-03T10:01:00Z", + completedAt: "2026-04-03T10:01:00Z", + }); + + const output = runHook(testEnv, { + hook_event_name: "UserPromptSubmit", + cwd: testEnv.workspaceDir, + session_id: "session-a", + prompt: "continue working", + }); + + assert.equal(output, ""); + assert.equal(readJob(testEnv, "task-cancelled").notifiedAt, undefined); + } finally { + cleanupEnv(testEnv); + } +}); + test("records a turn baseline for the current session on UserPromptSubmit", () => { const testEnv = createEnv(); try {