-
Notifications
You must be signed in to change notification settings - Fork 2.3k
fix: persist background jobs before worker spawn #733
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ce6563b
f8452a1
2f35b69
5e7cfb3
d7a435d
0d8cb00
d5fda68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ import { | |
| generateJobId, | ||
| getConfig, | ||
| listJobs, | ||
| markJobCancellationRequested, | ||
| setConfig, | ||
| upsertJob, | ||
| writeJobFile | ||
|
|
@@ -48,6 +49,7 @@ import { | |
| createJobProgressUpdater, | ||
| createJobRecord, | ||
| createProgressReporter, | ||
| failTrackedJobLaunch, | ||
| nowIso, | ||
| runTrackedJob, | ||
| SESSION_ID_ENV | ||
|
|
@@ -668,7 +670,7 @@ async function runForegroundCommand(job, runner, options = {}) { | |
| return execution; | ||
| } | ||
|
|
||
| function spawnDetachedTaskWorker(cwd, jobId) { | ||
| function spawnDetachedTaskWorker(cwd, jobId, onError) { | ||
| const scriptPath = path.join(ROOT_DIR, "scripts", "codex-companion.mjs"); | ||
| const child = spawn(process.execPath, [scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId], { | ||
| cwd, | ||
|
|
@@ -677,25 +679,32 @@ function spawnDetachedTaskWorker(cwd, jobId) { | |
| stdio: "ignore", | ||
| windowsHide: true | ||
| }); | ||
| child.unref(); | ||
| child.once("error", onError); | ||
| child.once("spawn", () => child.unref()); | ||
| return child; | ||
| } | ||
|
|
||
| function enqueueBackgroundTask(cwd, job, request) { | ||
| const { logFile } = createTrackedProgress(job); | ||
| appendLogLine(logFile, "Queued for background execution."); | ||
|
|
||
| const child = spawnDetachedTaskWorker(cwd, job.id); | ||
| const queuedRecord = { | ||
| ...job, | ||
| status: "queued", | ||
| phase: "queued", | ||
| pid: child.pid ?? null, | ||
| pid: null, | ||
|
fscfede-beep marked this conversation as resolved.
|
||
| logFile, | ||
| request | ||
| }; | ||
| writeJobFile(job.workspaceRoot, job.id, queuedRecord); | ||
| upsertJob(job.workspaceRoot, queuedRecord); | ||
| const failSpawn = (error) => failTrackedJobLaunch(queuedRecord, error); | ||
| try { | ||
| spawnDetachedTaskWorker(cwd, job.id, failSpawn); | ||
| } catch (error) { | ||
| failSpawn(error); | ||
| throw error; | ||
| } | ||
|
|
||
| return { | ||
| payload: { | ||
|
|
@@ -850,6 +859,10 @@ async function handleTaskWorker(argv) { | |
| if (!storedJob) { | ||
| throw new Error(`No stored job found for ${options["job-id"]}.`); | ||
| } | ||
| if (storedJob.status !== "queued") { | ||
|
fscfede-beep marked this conversation as resolved.
|
||
| appendLogLine(storedJob.logFile ?? null, `Background worker skipped ${storedJob.status} job.`); | ||
| return; | ||
| } | ||
|
|
||
| const request = storedJob.request; | ||
| if (!request || typeof request !== "object") { | ||
|
|
@@ -969,9 +982,11 @@ async function handleCancel(argv) { | |
| const cwd = resolveCommandCwd(options); | ||
| const reference = positionals[0] ?? ""; | ||
| const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env }); | ||
| markJobCancellationRequested(workspaceRoot, job.id); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| const existing = readStoredJob(workspaceRoot, job.id) ?? {}; | ||
| const threadId = existing.threadId ?? job.threadId ?? null; | ||
| const turnId = existing.turnId ?? job.turnId ?? null; | ||
| const latestJob = { ...job, ...existing }; | ||
| const threadId = latestJob.threadId ?? null; | ||
| const turnId = latestJob.turnId ?? null; | ||
|
|
||
| const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId }); | ||
| if (interrupt.attempted) { | ||
|
|
@@ -983,8 +998,8 @@ async function handleCancel(argv) { | |
| ); | ||
| } | ||
|
|
||
| terminateProcessTree(job.pid ?? Number.NaN); | ||
| appendLogLine(job.logFile, "Cancelled by user."); | ||
| terminateProcessTree(latestJob.pid ?? Number.NaN); | ||
| appendLogLine(latestJob.logFile ?? job.logFile, "Cancelled by user."); | ||
|
|
||
| const completedAt = nowIso(); | ||
| const nextJob = { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| import fs from "node:fs"; | ||
| import process from "node:process"; | ||
|
|
||
| import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; | ||
| import { isJobCancellationRequested, isJobRemovalRequested, readJobFile, removeJobFromState, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; | ||
|
|
||
| export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; | ||
|
|
||
|
|
@@ -73,6 +73,9 @@ export function createJobProgressUpdater(workspaceRoot, jobId) { | |
| let lastTurnId = null; | ||
|
|
||
| return (event) => { | ||
| if (isJobRemovalRequested(workspaceRoot, jobId)) { | ||
| return; | ||
| } | ||
| const normalized = normalizeProgressEvent(event); | ||
| const patch = { id: jobId }; | ||
| let changed = false; | ||
|
|
@@ -139,7 +142,62 @@ function readStoredJobOrNull(workspaceRoot, jobId) { | |
| return readJobFile(jobFile); | ||
| } | ||
|
|
||
| function removedExecution(job) { | ||
| return { | ||
| exitStatus: 0, | ||
| payload: { jobId: job.id, status: "removed" }, | ||
| rendered: "", | ||
| summary: "Session ended.", | ||
| threadId: null, | ||
| turnId: null | ||
| }; | ||
| } | ||
|
|
||
| export function failTrackedJobLaunch(job, error) { | ||
| if (isJobRemovalRequested(job.workspaceRoot, job.id)) { | ||
| return { ...job, status: "removed", phase: "removed", pid: null }; | ||
| } | ||
| const currentJob = readStoredJobOrNull(job.workspaceRoot, job.id) ?? job; | ||
| if (isJobCancellationRequested(job.workspaceRoot, job.id) || currentJob.status !== "queued") { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an emitted spawn error overlaps Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| return currentJob; | ||
| } | ||
| const completedAt = nowIso(); | ||
| const errorMessage = `Background worker failed to start: ${error instanceof Error ? error.message : String(error)}`; | ||
| const failedRecord = { ...currentJob, status: "failed", phase: "failed", pid: null, completedAt, errorMessage }; | ||
| writeJobFile(job.workspaceRoot, job.id, failedRecord); | ||
| upsertJob(job.workspaceRoot, failedRecord); | ||
|
|
||
| if (isJobRemovalRequested(job.workspaceRoot, job.id)) { | ||
| const jobFile = resolveJobFile(job.workspaceRoot, job.id); | ||
| if (fs.existsSync(jobFile)) fs.unlinkSync(jobFile); | ||
| removeJobFromState(job.workspaceRoot, job.id); | ||
| return { ...currentJob, status: "removed", phase: "removed", pid: null }; | ||
| } | ||
|
|
||
| if (isJobCancellationRequested(job.workspaceRoot, job.id)) { | ||
| const cancelledAt = nowIso(); | ||
| const cancelledRecord = { | ||
| ...currentJob, | ||
| status: "cancelled", | ||
| phase: "cancelled", | ||
| pid: null, | ||
| completedAt: cancelledAt, | ||
| cancelledAt, | ||
| errorMessage: "Cancelled by user." | ||
| }; | ||
| writeJobFile(job.workspaceRoot, job.id, cancelledRecord); | ||
| upsertJob(job.workspaceRoot, cancelledRecord); | ||
| return cancelledRecord; | ||
| } | ||
|
|
||
| appendLogLine(currentJob.logFile ?? null, errorMessage); | ||
| return failedRecord; | ||
| } | ||
|
|
||
| export async function runTrackedJob(job, runner, options = {}) { | ||
| if (isJobRemovalRequested(job.workspaceRoot, job.id)) { | ||
| return removedExecution(job); | ||
| } | ||
| const runningRecord = { | ||
| ...job, | ||
| status: "running", | ||
|
|
@@ -148,11 +206,60 @@ export async function runTrackedJob(job, runner, options = {}) { | |
| pid: process.pid, | ||
| logFile: options.logFile ?? job.logFile ?? null | ||
| }; | ||
| writeJobFile(job.workspaceRoot, job.id, runningRecord); | ||
| upsertJob(job.workspaceRoot, runningRecord); | ||
|
|
||
| try { | ||
| writeJobFile(job.workspaceRoot, job.id, runningRecord); | ||
| if (isJobRemovalRequested(job.workspaceRoot, job.id)) { | ||
| const jobFile = resolveJobFile(job.workspaceRoot, job.id); | ||
| if (fs.existsSync(jobFile)) fs.unlinkSync(jobFile); | ||
| return removedExecution(job); | ||
| } | ||
| upsertJob(job.workspaceRoot, runningRecord); | ||
| if (isJobRemovalRequested(job.workspaceRoot, job.id)) { | ||
| removeJobFromState(job.workspaceRoot, job.id); | ||
| return removedExecution(job); | ||
| } | ||
| if (isJobCancellationRequested(job.workspaceRoot, job.id)) { | ||
| const completedAt = nowIso(); | ||
| const cancelledRecord = { | ||
| ...runningRecord, | ||
| status: "cancelled", | ||
| phase: "cancelled", | ||
| pid: null, | ||
| completedAt, | ||
| cancelledAt: completedAt, | ||
| errorMessage: "Cancelled by user." | ||
| }; | ||
| writeJobFile(job.workspaceRoot, job.id, cancelledRecord); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| upsertJob(job.workspaceRoot, { | ||
| id: job.id, | ||
| status: "cancelled", | ||
| phase: "cancelled", | ||
| pid: null, | ||
| completedAt, | ||
| cancelledAt: completedAt, | ||
| errorMessage: "Cancelled by user." | ||
| }); | ||
| if (isJobRemovalRequested(job.workspaceRoot, job.id)) { | ||
| const jobFile = resolveJobFile(job.workspaceRoot, job.id); | ||
| if (fs.existsSync(jobFile)) fs.unlinkSync(jobFile); | ||
| removeJobFromState(job.workspaceRoot, job.id); | ||
| return removedExecution(job); | ||
| } | ||
| appendLogLine(options.logFile ?? job.logFile ?? null, "Cancelled before task execution."); | ||
| return { | ||
| exitStatus: 0, | ||
| payload: { jobId: job.id, status: "cancelled" }, | ||
| rendered: "Cancelled by user.\n", | ||
| summary: "Cancelled by user.", | ||
| threadId: null, | ||
| turnId: null | ||
| }; | ||
| } | ||
| const execution = await runner(); | ||
| if (isJobRemovalRequested(job.workspaceRoot, job.id)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| removeJobFromState(job.workspaceRoot, job.id); | ||
| return removedExecution(job); | ||
| } | ||
| const completionStatus = execution.exitStatus === 0 ? "completed" : "failed"; | ||
| const completedAt = nowIso(); | ||
| writeJobFile(job.workspaceRoot, job.id, { | ||
|
|
@@ -179,6 +286,10 @@ export async function runTrackedJob(job, runner, options = {}) { | |
| appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); | ||
| return execution; | ||
| } catch (error) { | ||
| if (isJobRemovalRequested(job.workspaceRoot, job.id)) { | ||
| removeJobFromState(job.workspaceRoot, job.id); | ||
| return removedExecution(job); | ||
| } | ||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||
| const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord; | ||
| const completedAt = nowIso(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.