Client or integration
OpenCodex dashboard
Area
Service lifecycle
Summary
During a GUI npm update with restart enabled, a non-elevated Windows Task Scheduler service refresh can fail with the expected WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED marker and correctly fall back to a direct proxy start.
In the reproduced run, the fallback needed slightly more than the fixed 15-second readiness deadline. The update job ended at 09:55:28.828 with proxy restart never became healthy, while the target-version proxy became healthy around 09:55:29.
The package update had succeeded and the replacement proxy was running, so this was a false terminal failure. The first 15-second miss also entered the redundant explicit-restart path, including two 30-second busy-port waits and another service-install attempt.
Expected: accept a target-version/new-PID replacement that becomes healthy within a realistic bounded Windows cold-start window, while still warning that the scheduler registration was not refreshed.
Reproduction
- Install OpenCodex through npm and install the Windows Task Scheduler service.
- Run the proxy non-elevated.
- From the dashboard, update
2.7.42 to 2.7.43 with restart enabled.
- Let
schtasks /create /tn opencodex-proxy /xml ... /f return access denied.
- Observe the intended direct fallback start.
- On a sufficiently slow cold start, observe the update job fail at the 15-second readiness deadline immediately before
/healthz becomes healthy.
This is timing-dependent. In the reproduced run, the fallback process was created around 09:55:14, the job failed at 09:55:28.828, and the listener became healthy around 09:55:29.
The current arrival deadline is 15 seconds followed by a 15-second stability window. A bounded regression could keep the replacement unhealthy until virtual time 15.25s, then expose a changed PID and the target version, and assert success without entering the explicit-restart path. A final identity probe at the deadline would also close the narrow boundary race.
Version
2.7.42 → 2.7.43 (v2.7.43, commit d1f544bbc22d25b9b2bd3c8e776fb8e3242b4ed5)
Operating system
Windows 11 Pro 25H2, build 26200.8894, x64
Provider and model
Not provider-specific
Logs or error output
Reinstalling the background service with the updated files...
WindowsSchtasksError: Windows access denied while running Task Scheduler.
OCX_ERROR_CODE=WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED
opencodex: service refresh failed — starting the proxy directly instead.
Proxy starting on port 10100.
Update installed. Restarting proxy...
npm self-update did not leave a healthy proxy; performing explicit restart...
Port 10100 still busy after 30s; refusing to hop — reinstall may fail until the port is free.
Service reinstall failed (exit 1); falling back to a direct proxy start.
Port 10100 still busy after 30s (reclaim could not free the socket); not starting on another port.
error: proxy restart never became healthy on 127.0.0.1:10100
Screenshots and supporting files
This appears to be a narrow follow-up to #515. It is distinct from #591/#698, whose locale-independent access-denied marker worked correctly in this reproduction.
Relevant readiness/restart code:
|
const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; |
|
const UPDATE_JOB_FILENAME = "update-job.json"; |
|
const UPDATE_TIMEOUT_MS = 180_000; |
|
const RESTART_TIMEOUT_MS = 60_000; |
|
const RESTART_HEALTH_TIMEOUT_MS = 15_000; |
|
const RESTART_STABILITY_WINDOW_MS = 15_000; |
|
/** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */ |
|
export const UPDATE_JOB_LEGACY_STALE_MS = 10 * 60_000; |
|
/** How long update restart waits for the captured port to become bindable after stop. */ |
|
export const RESTART_PORT_RECLAIM_MS = 30_000; |
|
async function awaitRestartedProxyHealthy( |
|
job: UpdateJobState, |
|
captured: { port: number; hostname: string }, |
|
io: RestartIo = {}, |
|
): Promise<AwaitHealthyResult> { |
|
const probe = io.probeProxy ?? (async (port: number, hostname?: string) => ( |
|
!!(await proxyIdentityAt(port, { hostname })) |
|
)); |
|
const sleep = io.sleepMs ?? (async (ms: number) => { |
|
await new Promise(resolve => setTimeout(resolve, ms)); |
|
}); |
|
const now = io.now ?? (() => Date.now()); |
|
const port = captured.port; |
|
const hostname = captured.hostname; |
|
const startDeadline = now() + RESTART_HEALTH_TIMEOUT_MS; |
|
|
|
while (now() < startDeadline) { |
|
if (await probe(port, hostname)) { |
|
updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`); |
|
const stableUntil = now() + RESTART_STABILITY_WINDOW_MS; |
|
while (now() < stableUntil) { |
|
if (!(await probe(port, hostname))) { |
|
updateJob(job, {}, `Proxy became unhealthy on ${hostname}:${port} during the stability window.`); |
|
return { ok: false, reason: "flapped" }; |
|
} |
|
await sleep(500); |
|
} |
|
updateJob(job, {}, `Proxy stayed healthy for ${Math.trunc(RESTART_STABILITY_WINDOW_MS / 1000)}s after restart.`); |
|
return { ok: true }; |
|
} |
|
await sleep(250); |
|
} |
|
|
|
return { ok: false, reason: "timeout" }; |
|
export async function finishGuiUpdateRestart( |
|
job: UpdateJobState, |
|
captured: { port: number; hostname: string; oldPid?: number }, |
|
installer: Installer, |
|
io: RestartIo = {}, |
|
): Promise<boolean> { |
|
if (installer === "npm") { |
|
const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)(); |
|
if (serviceInstalled) { |
|
const already = await awaitRestartedProxyHealthy(job, captured, io); |
|
if (already.ok) { |
|
const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)( |
|
captured.port, |
|
captured.hostname, |
|
); |
|
const evidence = npmSelfUpdateRestartEvidence(job, captured, identity); |
|
if (evidence.ok) { |
|
updateJob( |
|
job, |
|
{}, |
|
`Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`, |
|
); |
|
return true; |
|
} |
|
updateJob( |
|
job, |
|
{}, |
|
`npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`, |
|
); |
|
} else { |
|
updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart..."); |
|
} |
|
} |
|
} |
|
const restartFn = io.restartAfterUpdateFn ?? restartAfterUpdate; |
|
await restartFn(job, captured, io); |
|
if (installer !== "npm") { |
|
// Bun/source: health alone remains enough unless a richer identity probe is supplied. |
|
if (!io.probeProxyIdentity) return confirmRestartedProxy(job, captured, io); |
|
} |
|
return confirmNpmExplicitRestart(job, captured, io); |
|
} |
|
|
|
/** |
|
* After an explicit npm (or identity-aware) restart, require update-correlated |
|
* evidence — not merely a healthy OpenCodex listener. A no-op restart or a |
|
* failed port reclaim can leave the pre-update process on the captured port; |
|
* `confirmRestartedProxy` alone would treat that as success. |
|
*/ |
|
async function confirmNpmExplicitRestart( |
|
job: UpdateJobState, |
|
captured: { port: number; hostname: string; oldPid?: number }, |
|
io: RestartIo = {}, |
|
): Promise<boolean> { |
|
const healthy = await awaitRestartedProxyHealthy(job, captured, io); |
|
if (!healthy.ok) { |
|
const port = captured.port; |
|
const hostname = captured.hostname; |
|
const error = healthy.reason === "flapped" |
|
? `proxy restart became unhealthy on ${hostname}:${port}` |
|
: `proxy restart never became healthy on ${hostname}:${port}`; |
|
updateJob(job, { |
|
status: "failed", |
|
restarted: false, |
|
error, |
|
}, restartFailureHint(port)); |
|
return false; |
|
} |
|
|
|
const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)( |
|
captured.port, |
|
captured.hostname, |
|
); |
|
const evidence = npmSelfUpdateRestartEvidence(job, captured, identity); |
|
if (!evidence.ok) { |
|
updateJob(job, { |
|
status: "failed", |
|
restarted: false, |
|
error: `proxy restart did not show update-correlated identity (${evidence.reason})`, |
|
}, restartFailureHint(captured.port)); |
|
return false; |
|
} |
|
|
|
updateJob( |
|
job, |
|
{}, |
|
`Proxy restart confirmed on ${captured.hostname}:${captured.port} (${evidence.detail}).`, |
|
); |
|
return true; |
Redacted configuration
Not configuration-dependent. The reproduction uses an npm installation and the Windows Task Scheduler service backend.
Checks
Client or integration
OpenCodex dashboard
Area
Service lifecycle
Summary
During a GUI npm update with restart enabled, a non-elevated Windows Task Scheduler service refresh can fail with the expected
WINDOWS_SCHTASKS_CREATE_ACCESS_DENIEDmarker and correctly fall back to a direct proxy start.In the reproduced run, the fallback needed slightly more than the fixed 15-second readiness deadline. The update job ended at
09:55:28.828withproxy restart never became healthy, while the target-version proxy became healthy around09:55:29.The package update had succeeded and the replacement proxy was running, so this was a false terminal failure. The first 15-second miss also entered the redundant explicit-restart path, including two 30-second busy-port waits and another service-install attempt.
Expected: accept a target-version/new-PID replacement that becomes healthy within a realistic bounded Windows cold-start window, while still warning that the scheduler registration was not refreshed.
Reproduction
2.7.42to2.7.43with restart enabled.schtasks /create /tn opencodex-proxy /xml ... /freturn access denied./healthzbecomes healthy.This is timing-dependent. In the reproduced run, the fallback process was created around
09:55:14, the job failed at09:55:28.828, and the listener became healthy around09:55:29.The current arrival deadline is 15 seconds followed by a 15-second stability window. A bounded regression could keep the replacement unhealthy until virtual time
15.25s, then expose a changed PID and the target version, and assert success without entering the explicit-restart path. A final identity probe at the deadline would also close the narrow boundary race.Version
2.7.42 → 2.7.43 (
v2.7.43, commitd1f544bbc22d25b9b2bd3c8e776fb8e3242b4ed5)Operating system
Windows 11 Pro 25H2, build 26200.8894, x64
Provider and model
Not provider-specific
Logs or error output
Screenshots and supporting files
This appears to be a narrow follow-up to #515. It is distinct from #591/#698, whose locale-independent access-denied marker worked correctly in this reproduction.
Relevant readiness/restart code:
opencodex/src/update/job.ts
Lines 25 to 34 in d1f544b
opencodex/src/update/job.ts
Lines 520 to 553 in d1f544b
opencodex/src/update/job.ts
Lines 679 to 767 in d1f544b
Redacted configuration
Not configuration-dependent. The reproduction uses an npm installation and the Windows Task Scheduler service backend.
Checks