@bitkyc08/opencodex 2.67.0 → 2.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +3 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/App-mpsibsFj.js +51 -0
- package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-kUE8tuAK.js +86 -0
- package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawning the detached `ocx start` that replaces this process: the parent side of a restart handoff.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the dashboard drain-and-restart (`src/server/management/system-restart.ts`, which is also
|
|
5
|
+
* how a join into a Child restarts) and the client runtime's standalone recycle
|
|
6
|
+
* (`src/client/runtime.ts`). Deliberately lean: the client runtime loads it only at recycle time and
|
|
7
|
+
* must not pull the server lifecycle graph in with it.
|
|
8
|
+
*
|
|
9
|
+
* Every replacement carries `OCX_RESTART_PARENT_PID`, so a replacement that still finds this process
|
|
10
|
+
* answering on the port waits for it instead of refusing it (`src/cli/restart-handoff.ts`).
|
|
11
|
+
*
|
|
12
|
+
* A handoff that waits for health (the drain completed) retries a replacement that exits before it
|
|
13
|
+
* answers, at most {@link REPLACEMENT_EARLY_EXIT_RETRIES} more times inside the one readiness
|
|
14
|
+
* budget: the port a restart hands over can still be draining, and one refused start used to end the
|
|
15
|
+
* whole handoff. A parent-exit handoff resolves once the child spawned and is never retried; the
|
|
16
|
+
* replacement's own parent wait and port reclaim cover it. A spawn error is not retried; it does not
|
|
17
|
+
* change between attempts.
|
|
18
|
+
*
|
|
19
|
+
* The replacement's stdout and stderr go to `<configDir>/restart-handoff.log` instead of nowhere, so
|
|
20
|
+
* a switch that failed can be diagnosed. The file is private (0600, created in the private config
|
|
21
|
+
* directory, never opened through a symlink) and bounded at {@link RESTART_HANDOFF_LOG_MAX_BYTES} on
|
|
22
|
+
* both sides: a handoff empties it before it opens it, and the replacement, which keeps writing to
|
|
23
|
+
* it for its whole life, checks it once a minute ({@link RESTART_HANDOFF_LOG_ENV},
|
|
24
|
+
* {@link armRestartHandoffLogCap}). The parent writes only timestamps, pids, ports, attempt counts,
|
|
25
|
+
* exit codes and errno codes, never an environment value; the replacement writes what an `ocx start`
|
|
26
|
+
* prints to a terminal. A log that cannot be opened never blocks the handoff: the output is
|
|
27
|
+
* discarded as before.
|
|
28
|
+
*/
|
|
29
|
+
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
30
|
+
import { closeSync, constants, fchmodSync, fstatSync, ftruncateSync, lstatSync, openSync, writeSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { getConfigDir } from "../config/paths";
|
|
33
|
+
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
|
|
34
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
35
|
+
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
36
|
+
import { REPLACEMENT_READY_TIMEOUT_MS, withRestartParentMarker } from "../lib/system-restart-contract";
|
|
37
|
+
import { findLiveProxy } from "./proxy-liveness";
|
|
38
|
+
|
|
39
|
+
export const RESTART_HANDOFF_LOG_FILENAME = "restart-handoff.log";
|
|
40
|
+
export const RESTART_HANDOFF_LOG_MAX_BYTES = 256 * 1024;
|
|
41
|
+
/**
|
|
42
|
+
* Set to `1` on a replacement whose stdout and stderr are the handoff log. `handleStart` consumes it
|
|
43
|
+
* and arms {@link armRestartHandoffLogCap}, so the file stays bounded after the parent that opened it
|
|
44
|
+
* is gone. A hand-set value only makes that start cap the private handoff log.
|
|
45
|
+
*/
|
|
46
|
+
export const RESTART_HANDOFF_LOG_ENV = "OCX_RESTART_HANDOFF_LOG";
|
|
47
|
+
/** How often a replacement writing into the handoff log checks its size: one lstat per tick. */
|
|
48
|
+
export const RESTART_HANDOFF_LOG_CHECK_MS = 60_000;
|
|
49
|
+
/** Respawns after an early exit, on top of the first attempt. */
|
|
50
|
+
export const REPLACEMENT_EARLY_EXIT_RETRIES = 2;
|
|
51
|
+
const REPLACEMENT_RETRY_DELAY_MS = 1_000;
|
|
52
|
+
const REPLACEMENT_READY_POLL_MS = 150;
|
|
53
|
+
|
|
54
|
+
export interface ReplacementReadinessIo {
|
|
55
|
+
findLive?: typeof findLiveProxy;
|
|
56
|
+
now?: () => number;
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
58
|
+
/** One absolute deadline for every attempt of a handoff; defaults to now + the readiness budget. */
|
|
59
|
+
deadlineAt?: number;
|
|
60
|
+
/** Stops polling once the attempt it serves has already settled (the replacement exited). */
|
|
61
|
+
cancelled?: () => boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function waitForReplacementReady(
|
|
65
|
+
expectedPid: number | undefined,
|
|
66
|
+
parentPid: number,
|
|
67
|
+
expectedPort: number | undefined,
|
|
68
|
+
io: ReplacementReadinessIo = {},
|
|
69
|
+
): Promise<boolean> {
|
|
70
|
+
const findLive = io.findLive ?? findLiveProxy;
|
|
71
|
+
const now = io.now ?? Date.now;
|
|
72
|
+
const sleep = io.sleep ?? Bun.sleep;
|
|
73
|
+
const deadline = io.deadlineAt ?? now() + REPLACEMENT_READY_TIMEOUT_MS;
|
|
74
|
+
while (now() < deadline) {
|
|
75
|
+
if (io.cancelled?.()) return false;
|
|
76
|
+
try {
|
|
77
|
+
const live = await findLive({
|
|
78
|
+
deadlineAt: deadline,
|
|
79
|
+
nowFn: now,
|
|
80
|
+
sleepFn: sleep,
|
|
81
|
+
});
|
|
82
|
+
// A probe that began within budget can still return after it. Never accept
|
|
83
|
+
// delayed health as proof once the shared absolute handoff budget expired.
|
|
84
|
+
if (now() >= deadline) return false;
|
|
85
|
+
if (
|
|
86
|
+
live
|
|
87
|
+
&& live.pid !== null
|
|
88
|
+
&& live.pid !== parentPid
|
|
89
|
+
&& (expectedPid === undefined || live.pid === expectedPid)
|
|
90
|
+
&& (expectedPort === undefined || live.port === expectedPort)
|
|
91
|
+
) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// A not-yet-bound replacement is indistinguishable from a transient
|
|
96
|
+
// liveness failure here; keep polling inside the one bounded window.
|
|
97
|
+
}
|
|
98
|
+
const remainingMs = deadline - now();
|
|
99
|
+
if (remainingMs <= 0) break;
|
|
100
|
+
await sleep(Math.min(REPLACEMENT_READY_POLL_MS, remainingMs));
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RestartHandoffLog {
|
|
106
|
+
readonly fd: number;
|
|
107
|
+
note(line: string): void;
|
|
108
|
+
close(): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function restartHandoffLogPath(configDir: string = getConfigDir()): string {
|
|
112
|
+
return join(configDir, RESTART_HANDOFF_LOG_FILENAME);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Empty the handoff log once it has reached the cap; below the cap this is one lstat. The truncation
|
|
117
|
+
* goes through a fresh non-append descriptor, never through a symlink, so it also works where an
|
|
118
|
+
* append handle cannot truncate (Windows). Every writer's append descriptor carries on at the new end.
|
|
119
|
+
* Returns true when it emptied the file.
|
|
120
|
+
*/
|
|
121
|
+
export function emptyRestartHandoffLogIfFull(path: string, now: () => number = Date.now): boolean {
|
|
122
|
+
let fd: number | undefined;
|
|
123
|
+
try {
|
|
124
|
+
const stat = lstatSync(path);
|
|
125
|
+
if (!stat.isFile() || stat.size < RESTART_HANDOFF_LOG_MAX_BYTES) return false;
|
|
126
|
+
fd = openSync(path, constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0));
|
|
127
|
+
if (!fstatSync(fd).isFile()) return false;
|
|
128
|
+
ftruncateSync(fd, 0);
|
|
129
|
+
writeSync(fd, `[${new Date(now()).toISOString()}] log emptied at the ${RESTART_HANDOFF_LOG_MAX_BYTES / 1024} KiB cap\n`);
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
} finally {
|
|
134
|
+
if (fd !== undefined) {
|
|
135
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Open the handoff log for appending: private, never through a symlink, and emptied first when it
|
|
142
|
+
* has reached the cap. Returns null when it cannot be opened safely.
|
|
143
|
+
*/
|
|
144
|
+
export function openRestartHandoffLog(path: string, now: () => number = Date.now): RestartHandoffLog | null {
|
|
145
|
+
emptyRestartHandoffLogIfFull(path, now);
|
|
146
|
+
let fd: number | undefined;
|
|
147
|
+
try {
|
|
148
|
+
// O_NOFOLLOW turns a planted symlink into an open error instead of a write somewhere else.
|
|
149
|
+
const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0);
|
|
150
|
+
fd = openSync(path, flags, 0o600);
|
|
151
|
+
const stat = fstatSync(fd);
|
|
152
|
+
if (!stat.isFile()) {
|
|
153
|
+
closeSync(fd);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (process.platform !== "win32") fchmodSync(fd, 0o600);
|
|
157
|
+
const handle = fd;
|
|
158
|
+
return {
|
|
159
|
+
fd: handle,
|
|
160
|
+
note(line: string) {
|
|
161
|
+
try { writeSync(handle, `[${new Date(now()).toISOString()}] ${line}\n`); } catch { /* diagnostics only */ }
|
|
162
|
+
},
|
|
163
|
+
close() {
|
|
164
|
+
try { closeSync(handle); } catch { /* already closed */ }
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
} catch {
|
|
168
|
+
if (fd !== undefined) {
|
|
169
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface RestartHandoffLogCapIo {
|
|
176
|
+
/** The log to bound; default: the config-dir handoff log. */
|
|
177
|
+
path?: string;
|
|
178
|
+
intervalMs?: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The replacement side of the log bound. A replacement whose parent sent its output to the handoff
|
|
183
|
+
* log keeps writing there for its whole life, long after that parent exited, so it checks the file
|
|
184
|
+
* itself from one low-frequency, unref'd timer and empties it at the cap. Consumes
|
|
185
|
+
* {@link RESTART_HANDOFF_LOG_ENV} so no later child inherits it; a start without the flag (every
|
|
186
|
+
* ordinary start) arms nothing. Returns a stop function, or null when nothing was armed.
|
|
187
|
+
*/
|
|
188
|
+
export function armRestartHandoffLogCap(
|
|
189
|
+
env: Record<string, string | undefined>,
|
|
190
|
+
io: RestartHandoffLogCapIo = {},
|
|
191
|
+
): (() => void) | null {
|
|
192
|
+
const flagged = env[RESTART_HANDOFF_LOG_ENV] === "1";
|
|
193
|
+
delete env[RESTART_HANDOFF_LOG_ENV];
|
|
194
|
+
if (!flagged) return null;
|
|
195
|
+
const path = io.path ?? restartHandoffLogPath();
|
|
196
|
+
const timer = setInterval(() => { emptyRestartHandoffLogIfFull(path); }, io.intervalMs ?? RESTART_HANDOFF_LOG_CHECK_MS);
|
|
197
|
+
timer.unref?.();
|
|
198
|
+
return () => { clearInterval(timer); };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function openDefaultRestartHandoffLog(now: () => number): RestartHandoffLog | null {
|
|
202
|
+
const configDir = getConfigDir();
|
|
203
|
+
const path = restartHandoffLogPath(configDir);
|
|
204
|
+
// Owned like crash.log, so uninstall removes it with the rest of the config directory.
|
|
205
|
+
try { recordOwnedConfigPath(configDir, path); } catch { /* the log is optional */ }
|
|
206
|
+
return openRestartHandoffLog(path, now);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export type ReplacementSpawn = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
|
210
|
+
|
|
211
|
+
export interface ReplacementStartIo extends ReplacementReadinessIo {
|
|
212
|
+
spawnChild?: ReplacementSpawn;
|
|
213
|
+
/** The handoff log file; `null` discards the replacement's output. Default: the config-dir log. */
|
|
214
|
+
logPath?: string | null;
|
|
215
|
+
parentPid?: number;
|
|
216
|
+
retryDelayMs?: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ReplacementStartRequest {
|
|
220
|
+
/** The port the replacement must bind; anything that is not a TCP port starts it unpinned. */
|
|
221
|
+
port?: number;
|
|
222
|
+
/**
|
|
223
|
+
* Wait until the replacement answers before resolving, retrying an early exit. Without it the
|
|
224
|
+
* promise resolves once the child spawned, so the parent can exit and release what the
|
|
225
|
+
* replacement is waiting for.
|
|
226
|
+
*/
|
|
227
|
+
waitForHealth: boolean;
|
|
228
|
+
/** The replacement's environment, already prepared by the caller; the parent marker is added here. */
|
|
229
|
+
env: NodeJS.ProcessEnv;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type AttemptOutcome =
|
|
233
|
+
| { kind: "spawned" | "ready" | "not-ready"; pid: number | undefined }
|
|
234
|
+
| { kind: "early-exit"; pid: number | undefined; code: number | null; signal: NodeJS.Signals | null };
|
|
235
|
+
|
|
236
|
+
function pinnedPort(port: number | undefined): number | undefined {
|
|
237
|
+
return typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535
|
|
238
|
+
? Math.trunc(port)
|
|
239
|
+
: undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function handoffError(code: string): NodeJS.ErrnoException {
|
|
243
|
+
const error = new Error(code) as NodeJS.ErrnoException;
|
|
244
|
+
error.code = code;
|
|
245
|
+
return error;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Stable, path-free label for a spawn failure (an errno message can carry the OS username). */
|
|
249
|
+
function failureLabel(err: unknown): string {
|
|
250
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
251
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
252
|
+
if (typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code)) return code;
|
|
253
|
+
}
|
|
254
|
+
return "spawn_failed";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function runReplacementAttempt(
|
|
258
|
+
launchArgs: string[],
|
|
259
|
+
env: NodeJS.ProcessEnv,
|
|
260
|
+
logFd: number | undefined,
|
|
261
|
+
waitForHealth: boolean,
|
|
262
|
+
expectedPort: number | undefined,
|
|
263
|
+
parentPid: number,
|
|
264
|
+
io: ReplacementStartIo,
|
|
265
|
+
): Promise<AttemptOutcome> {
|
|
266
|
+
return new Promise<AttemptOutcome>((resolve, reject) => {
|
|
267
|
+
let child: ChildProcess;
|
|
268
|
+
try {
|
|
269
|
+
const output = logFd ?? "ignore";
|
|
270
|
+
const options: SpawnOptions = {
|
|
271
|
+
detached: true,
|
|
272
|
+
stdio: ["ignore", output, output],
|
|
273
|
+
windowsHide: true,
|
|
274
|
+
env: withProcessRuntimeProvenance(env),
|
|
275
|
+
};
|
|
276
|
+
child = io.spawnChild
|
|
277
|
+
? io.spawnChild(process.execPath, launchArgs, options)
|
|
278
|
+
: spawn(process.execPath, launchArgs, options);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
reject(err);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
let settled = false;
|
|
284
|
+
const cleanup = () => {
|
|
285
|
+
child.off("error", onError);
|
|
286
|
+
child.off("exit", onExit);
|
|
287
|
+
child.off("spawn", onSpawn);
|
|
288
|
+
};
|
|
289
|
+
const settle = (outcome: AttemptOutcome | { error: unknown }) => {
|
|
290
|
+
if (settled) return;
|
|
291
|
+
settled = true;
|
|
292
|
+
cleanup();
|
|
293
|
+
if ("error" in outcome) {
|
|
294
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
295
|
+
try { child.kill(); } catch { /* best-effort failed-start cleanup */ }
|
|
296
|
+
}
|
|
297
|
+
try { child.unref(); } catch { /* best-effort */ }
|
|
298
|
+
reject(outcome.error);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
try { child.unref(); } catch { /* best-effort */ }
|
|
302
|
+
resolve(outcome);
|
|
303
|
+
};
|
|
304
|
+
const onError = (err: Error) => { settle({ error: err }); };
|
|
305
|
+
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
306
|
+
settle({ kind: "early-exit", pid: child.pid, code, signal });
|
|
307
|
+
};
|
|
308
|
+
const onSpawn = () => {
|
|
309
|
+
if (!waitForHealth) {
|
|
310
|
+
// The replacement may be waiting for resources only this parent's exit releases.
|
|
311
|
+
settle({ kind: "spawned", pid: child.pid });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
void waitForReplacementReady(child.pid, parentPid, expectedPort, { ...io, cancelled: () => settled }).then(
|
|
315
|
+
// Never kill a live ordinary start at its valid reclaim boundary. Parent exit is the
|
|
316
|
+
// final resource release the child may still be waiting for.
|
|
317
|
+
ready => { settle({ kind: ready ? "ready" : "not-ready", pid: child.pid }); },
|
|
318
|
+
err => { settle({ error: err }); },
|
|
319
|
+
);
|
|
320
|
+
};
|
|
321
|
+
child.once("error", onError);
|
|
322
|
+
child.once("exit", onExit);
|
|
323
|
+
child.once("spawn", onSpawn);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Spawn this process's replacement `ocx start` and, when asked, wait until it answers. Rejects with
|
|
329
|
+
* the spawn error, or with code `child_exit` once every attempt exited before it was ready.
|
|
330
|
+
*/
|
|
331
|
+
export async function spawnReplacementStart(
|
|
332
|
+
request: ReplacementStartRequest,
|
|
333
|
+
io: ReplacementStartIo = {},
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
const now = io.now ?? Date.now;
|
|
336
|
+
const sleep = io.sleep ?? Bun.sleep;
|
|
337
|
+
const parentPid = io.parentPid ?? process.pid;
|
|
338
|
+
const expectedPort = pinnedPort(request.port);
|
|
339
|
+
const launchArgs = selfLaunchArgv(expectedPort === undefined ? ["start"] : ["start", "--port", String(expectedPort)]);
|
|
340
|
+
const deadlineAt = io.deadlineAt ?? now() + REPLACEMENT_READY_TIMEOUT_MS;
|
|
341
|
+
const attempts = request.waitForHealth ? 1 + REPLACEMENT_EARLY_EXIT_RETRIES : 1;
|
|
342
|
+
const target = expectedPort === undefined ? "an unpinned port" : `port ${expectedPort}`;
|
|
343
|
+
const mode = request.waitForHealth ? "waiting for it to answer" : "exiting first";
|
|
344
|
+
const log = io.logPath === null
|
|
345
|
+
? null
|
|
346
|
+
: io.logPath === undefined ? openDefaultRestartHandoffLog(now) : openRestartHandoffLog(io.logPath, now);
|
|
347
|
+
const env: NodeJS.ProcessEnv = withRestartParentMarker(request.env, parentPid);
|
|
348
|
+
// Only a replacement that really writes into the log keeps it bounded after this parent is gone.
|
|
349
|
+
if (log) env[RESTART_HANDOFF_LOG_ENV] = "1";
|
|
350
|
+
else delete env[RESTART_HANDOFF_LOG_ENV];
|
|
351
|
+
try {
|
|
352
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
353
|
+
log?.note(`parent pid ${parentPid} starts a replacement on ${target} (attempt ${attempt}/${attempts}, ${mode})`);
|
|
354
|
+
let outcome: AttemptOutcome;
|
|
355
|
+
try {
|
|
356
|
+
outcome = await runReplacementAttempt(
|
|
357
|
+
launchArgs, env, log?.fd, request.waitForHealth, expectedPort, parentPid, { ...io, deadlineAt },
|
|
358
|
+
);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
log?.note(`replacement spawn failed (${failureLabel(error)})`);
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
const pid = outcome.pid ?? "unknown";
|
|
364
|
+
if (outcome.kind === "early-exit") {
|
|
365
|
+
log?.note(`replacement pid ${pid} exited before it answered (code ${outcome.code ?? "none"}, signal ${outcome.signal ?? "none"})`);
|
|
366
|
+
const retryDelayMs = io.retryDelayMs ?? REPLACEMENT_RETRY_DELAY_MS;
|
|
367
|
+
if (attempt >= attempts || now() + retryDelayMs >= deadlineAt) throw handoffError("child_exit");
|
|
368
|
+
await sleep(retryDelayMs);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (outcome.kind === "not-ready") {
|
|
372
|
+
console.warn("Restart replacement is still starting after the readiness window; handing off to it anyway");
|
|
373
|
+
log?.note(`replacement pid ${pid} is still starting after the readiness window; handing off anyway`);
|
|
374
|
+
} else {
|
|
375
|
+
log?.note(outcome.kind === "ready" ? `replacement pid ${pid} is serving ${target}` : `replacement pid ${pid} started`);
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
} finally {
|
|
380
|
+
log?.close();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -38,6 +38,14 @@ export function startupHealthProbeTimeoutMs(): number {
|
|
|
38
38
|
return PROBE_TIMEOUT_MS;
|
|
39
39
|
}
|
|
40
40
|
let cached: { timestamp: number; value: StartupHealth } | null = null;
|
|
41
|
+
/**
|
|
42
|
+
* Last completed reading, kept across invalidation. Invalidation only means "no longer
|
|
43
|
+
* fresh": a settings write clears `cached` so the next fresh read re-probes, but answering
|
|
44
|
+
* the snapshot with the synthetic not-installed fallback meanwhile turned a healthy service
|
|
45
|
+
* into `at-risk` on the dashboard until the probe finished (up to 15s on Windows). The
|
|
46
|
+
* fallback is kept for a process that has never completed a reading.
|
|
47
|
+
*/
|
|
48
|
+
let lastReading: StartupHealth | null = null;
|
|
41
49
|
let inflight: Promise<StartupHealth> | null = null;
|
|
42
50
|
let generation = 0;
|
|
43
51
|
|
|
@@ -64,7 +72,11 @@ export function getStartupHealthSnapshot(
|
|
|
64
72
|
const now = deps.now ?? Date.now;
|
|
65
73
|
if (cached && now() - cached.timestamp < CACHE_TTL_MS) return cached.value;
|
|
66
74
|
refreshInBackground(config, deps);
|
|
67
|
-
return
|
|
75
|
+
return staleOrFallback(config);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function staleOrFallback(config: Pick<OcxConfig, "codexAutoStart">): StartupHealth {
|
|
79
|
+
return lastReading ? markStartupHealthDiagnosticStale(lastReading) : conservativeFallback(config);
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupHealth {
|
|
@@ -140,7 +152,7 @@ function runProbe(config: Pick<OcxConfig, "codexAutoStart">): Promise<StartupHea
|
|
|
140
152
|
} catch { /* scan earlier output; config repair messages may precede JSON */ }
|
|
141
153
|
}
|
|
142
154
|
}
|
|
143
|
-
resolve(
|
|
155
|
+
resolve(staleOrFallback(config));
|
|
144
156
|
});
|
|
145
157
|
});
|
|
146
158
|
}
|
|
@@ -156,10 +168,11 @@ function refreshInBackground(
|
|
|
156
168
|
.then(value => {
|
|
157
169
|
if (startedGeneration === generation) {
|
|
158
170
|
cached = { timestamp: (deps.now ?? Date.now)(), value };
|
|
171
|
+
if (!value.diagnosticStale) lastReading = value;
|
|
159
172
|
}
|
|
160
173
|
return value;
|
|
161
174
|
})
|
|
162
|
-
.catch(() =>
|
|
175
|
+
.catch(() => staleOrFallback(config))
|
|
163
176
|
.finally(() => {
|
|
164
177
|
// An invalidated probe must never clear the newer generation's flight.
|
|
165
178
|
if (inflight === probe) inflight = null;
|
|
@@ -189,7 +202,7 @@ export async function getCachedStartupHealth(
|
|
|
189
202
|
]));
|
|
190
203
|
if (settled) return settled;
|
|
191
204
|
}
|
|
192
|
-
return
|
|
205
|
+
return staleOrFallback(config);
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
export function invalidateStartupHealthCache(): void {
|
|
@@ -197,3 +210,9 @@ export function invalidateStartupHealthCache(): void {
|
|
|
197
210
|
cached = null;
|
|
198
211
|
inflight = null;
|
|
199
212
|
}
|
|
213
|
+
|
|
214
|
+
/** Test-only: also forget the last reading, returning to the never-probed state. */
|
|
215
|
+
export function resetStartupHealthCacheForTests(): void {
|
|
216
|
+
invalidateStartupHealthCache();
|
|
217
|
+
lastReading = null;
|
|
218
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { CodexNativeRestoreResult } from "../codex/inject";
|
|
2
|
+
import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start";
|
|
2
3
|
import { deferralMatchesReceipt } from "../config/pending-teardown";
|
|
4
|
+
import { isDesktopSupervised } from "../lib/system-restart-contract";
|
|
5
|
+
import type { ManagementPrincipal } from "./management-auth";
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Shared-teardown decision and execution for `POST /api/stop` (#3008).
|
|
@@ -22,7 +25,8 @@ export type StopTeardownIo = {
|
|
|
22
25
|
export type StopTeardownBody = {
|
|
23
26
|
success: boolean;
|
|
24
27
|
message: string;
|
|
25
|
-
|
|
28
|
+
/** `not-owned`: a sibling instance, whose shared client routing belongs to the live owner. */
|
|
29
|
+
sharedTeardown: "deferred" | "performed" | "not-owned";
|
|
26
30
|
};
|
|
27
31
|
|
|
28
32
|
/**
|
|
@@ -43,8 +47,38 @@ export function deferralHonored(url: URL, ownsReceipt: (nonce: string | null) =>
|
|
|
43
47
|
return ownsReceipt(url.searchParams.get("teardownNonce"));
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
export type StopRefusalBody = { success: false; code: string; message: string };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The dashboard's Stop, refused while the desktop app supervises this process.
|
|
54
|
+
*
|
|
55
|
+
* The app starts its runtime again after any exit it did not ask for
|
|
56
|
+
* (`desktop/src-tauri/src/supervisor.rs`), and a dashboard Stop is not the app asking: the proxy
|
|
57
|
+
* would restore native Codex and exit, and the app would start it again seconds later and reload
|
|
58
|
+
* the dashboard. Like the service-manager refusals it is refused before anything changes, and it
|
|
59
|
+
* names what does stop the proxy: the app's tray Stop proxy, or Quit. Only a dashboard session is
|
|
60
|
+
* refused. `ocx stop`, which the tray's Stop, Quit and an update's drain all run, authenticates
|
|
61
|
+
* with the admin token and is unaffected. Read only on this route, never on the request path.
|
|
62
|
+
*/
|
|
63
|
+
export function desktopSupervisedStopRefusal(
|
|
64
|
+
principal: ManagementPrincipal | null | undefined,
|
|
65
|
+
supervised: () => boolean = isDesktopSupervised,
|
|
66
|
+
): StopRefusalBody | null {
|
|
67
|
+
if (principal !== "gui-session" || !supervised()) return null;
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
code: "desktop_supervised",
|
|
71
|
+
message: "The OpenCodex desktop app runs this proxy and starts it again after it exits, so the dashboard does not stop it. Use Stop proxy in the app's tray menu, or quit the app. Nothing was changed.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
/** Run (or skip) the shared teardown and describe the outcome truthfully. */
|
|
47
76
|
export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Promise<StopTeardownBody> {
|
|
77
|
+
// Before the deferral check: a sibling owns no shared teardown to perform OR to hand over.
|
|
78
|
+
// Restoring here would replay the live owner's journal and strip its Grok fence.
|
|
79
|
+
if (siblingOfLivePort() !== null) {
|
|
80
|
+
return { success: true, message: `Proxy stopping. ${siblingSkipMessage()}`, sharedTeardown: "not-owned" };
|
|
81
|
+
}
|
|
48
82
|
const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt;
|
|
49
83
|
if (deferralHonored(url, ownsReceipt)) {
|
|
50
84
|
// Not "native Codex restored": nothing was restored here, and claiming otherwise
|
package/src/server/system-env.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { localAdmissionToken, localInferenceDestination } from "../lib/local-des
|
|
|
11
11
|
import { probeHostname } from "./proxy-liveness";
|
|
12
12
|
import { providerContextCap } from "../providers/context-cap";
|
|
13
13
|
import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
14
|
+
import { siblingOfLivePort } from "../codex/sibling-start";
|
|
14
15
|
export { getShellEnvFilePath, installShellHook, uninstallShellHook, claudeCodeCliInstalled, reconcileShellHook } from "./system-env-shell";
|
|
15
16
|
export type { SystemEnvDeps } from "./system-env-shell";
|
|
16
17
|
import { systemEnvMarkerMode, writeShellEnvFile, removeShellEnvFile } from "./system-env-shell";
|
|
@@ -196,6 +197,8 @@ export async function injectSystemEnv(
|
|
|
196
197
|
config: OcxConfig,
|
|
197
198
|
deps: SystemEnvDeps = {},
|
|
198
199
|
): Promise<SystemEnvResult> {
|
|
200
|
+
// The launchd domain is machine-wide; a sibling instance leaves it to the live owner.
|
|
201
|
+
if (siblingOfLivePort() !== null) return { injected: false, reason: "sibling instance" };
|
|
199
202
|
if (process.platform !== "darwin") return { injected: false, reason: "not macOS" };
|
|
200
203
|
if (config.claudeCode?.enabled === false) return { injected: false, reason: "claude disabled" };
|
|
201
204
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./windows-wrapper-exit";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath } from "./state";
|
|
3
4
|
import { windowsWscript } from "./windows-scheduler";
|
|
@@ -64,11 +65,13 @@ export function buildWindowsServiceScript(
|
|
|
64
65
|
const path = process.env.PATH ?? "";
|
|
65
66
|
const lines = [
|
|
66
67
|
"@echo off",
|
|
67
|
-
"setlocal",
|
|
68
|
+
"setlocal EnableExtensions DisableDelayedExpansion",
|
|
69
|
+
'set "ERRORLEVEL="',
|
|
68
70
|
// The wrapper console is hidden by the wscript launcher (window style 0), so switching
|
|
69
71
|
// it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
|
|
70
72
|
"chcp 65001 >nul",
|
|
71
73
|
windowsBatchSet("OCX_SERVICE", "1"),
|
|
74
|
+
windowsBatchSet(WINDOWS_WRAPPER_PROTOCOL_ENV, "1"),
|
|
72
75
|
windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
|
|
73
76
|
windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
|
|
74
77
|
windowsBatchSet("PATH", path, "pathList"),
|
|
@@ -110,15 +113,16 @@ export function buildWindowsServiceScript(
|
|
|
110
113
|
cli ? " exit /b 3" : null,
|
|
111
114
|
cli ? ")" : null,
|
|
112
115
|
cli ? `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1` : `"%OCX_BUN%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
// Stop commands kill the wrapper; a zero child exit alone is not a stop request.
|
|
117
|
+
`if "%ERRORLEVEL%"=="${WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE}" goto stopped`,
|
|
118
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
|
|
115
119
|
// `timeout` needs console stdin and dies with "Input redirection is not supported"
|
|
116
120
|
// under Task Scheduler, turning the 5s cooldown into a hot restart loop; ping doesn't.
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
"
|
|
121
|
+
"ping -n 6 127.0.0.1 >nul",
|
|
122
|
+
"goto loop",
|
|
123
|
+
":stopped",
|
|
120
124
|
"endlocal",
|
|
121
|
-
"
|
|
125
|
+
"exit /b 0",
|
|
122
126
|
"",
|
|
123
127
|
// #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
|
|
124
128
|
// a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Only wrappers advertising this protocol understand a nonzero intentional exit.
|
|
2
|
+
export const WINDOWS_WRAPPER_PROTOCOL_ENV = "OCX_WINDOWS_WRAPPER_PROTOCOL";
|
|
3
|
+
export const WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE = 42;
|
|
4
|
+
|
|
5
|
+
export function serviceStayOutExitCode(env: NodeJS.ProcessEnv = process.env): number {
|
|
6
|
+
return env.OCX_SERVICE === "1" && env[WINDOWS_WRAPPER_PROTOCOL_ENV] === "1"
|
|
7
|
+
? WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE : 0;
|
|
8
|
+
}
|
package/src/stall-timeout.ts
CHANGED
|
@@ -1,20 +1,45 @@
|
|
|
1
|
+
export const DEFAULT_STALL_TIMEOUT_SEC = 300;
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Responses bridge emits `response.incomplete` / `upstream_stall_timeout`.
|
|
4
|
+
* How the configured stall budget should be resolved for a request.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* `localUpstream` marks a request whose upstream is operator-trusted local infrastructure
|
|
7
|
+
* (loopback / private / `.local` / `.lan` — see src/lib/local-upstream.ts). Such models are often
|
|
8
|
+
* CPU-bound, and their time-to-first-token or "thinking" phases can go silent for many minutes;
|
|
9
|
+
* a stall budget there would cut a healthy turn mid-thought. Callers compute the flag with
|
|
10
|
+
* `isLocalUpstream(destination)` and pass it in.
|
|
7
11
|
*/
|
|
8
|
-
export
|
|
12
|
+
export interface StallTimeoutOptions {
|
|
13
|
+
localUpstream?: boolean;
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
|
-
* Resolve the
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
17
|
+
* Resolve the per-request stall budget in seconds.
|
|
18
|
+
*
|
|
19
|
+
* An explicit value always wins: `0` (or any non-positive) disables the budget everywhere, and a
|
|
20
|
+
* positive value applies verbatim to local and public upstreams alike. Only when the operator
|
|
21
|
+
* left the budget unset does the destination matter: a local upstream resolves to disabled
|
|
22
|
+
* (`0`) while a public upstream keeps the {@link DEFAULT_STALL_TIMEOUT_SEC} safety clock.
|
|
23
|
+
*
|
|
24
|
+
* Disabling is expressed as `0`, never as a tiny budget: several consumers translate the
|
|
25
|
+
* seconds into a tick count or a pull deadline, where `0` would otherwise expire on the first
|
|
26
|
+
* beat and kill a healthy stream in ~2s. Consumers that kill on the budget must therefore gate
|
|
27
|
+
* on `resolveStallTimeoutSec(...) > 0` (see src/bridge/sse.ts, src/server/chat-native-sse.ts).
|
|
14
28
|
*/
|
|
15
|
-
export function resolveStallTimeoutSec(configuredSec: number | undefined): number {
|
|
29
|
+
export function resolveStallTimeoutSec(configuredSec: number | undefined, options: StallTimeoutOptions = {}): number {
|
|
30
|
+
const { localUpstream = false } = options;
|
|
16
31
|
if (typeof configuredSec === "number" && Number.isFinite(configuredSec)) {
|
|
32
|
+
if (configuredSec <= 0) return 0;
|
|
17
33
|
return Math.max(1, Math.ceil(configuredSec));
|
|
18
34
|
}
|
|
19
|
-
return DEFAULT_STALL_TIMEOUT_SEC;
|
|
35
|
+
return localUpstream ? 0 : DEFAULT_STALL_TIMEOUT_SEC;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The same resolution as {@link resolveStallTimeoutSec}, in milliseconds, for consumers that
|
|
40
|
+
* deadline against `performance.now()`. `0` is a stable "disabled" sentinel the inactivity
|
|
41
|
+
* guards honor (they arm no clock for a non-positive budget).
|
|
42
|
+
*/
|
|
43
|
+
export function resolveStallTimeoutMs(configuredSec: number | undefined, options: StallTimeoutOptions = {}): number {
|
|
44
|
+
return resolveStallTimeoutSec(configuredSec, options) * 1_000;
|
|
20
45
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { CleanupMode, CleanupResult } from "./cleanup";
|
|
9
9
|
import { spawnWorker } from "../lib/worker-embed";
|
|
10
10
|
import { resolveCodexHomeDir } from "../codex/home";
|
|
11
|
+
import { siblingOfLivePort } from "../codex/sibling-start";
|
|
11
12
|
import {
|
|
12
13
|
tryBeginStorageMutation,
|
|
13
14
|
} from "./storage-mutation-coordinator";
|
|
@@ -452,11 +453,14 @@ export function requestStorageCleanupPolicyRun(
|
|
|
452
453
|
/**
|
|
453
454
|
* Fire-and-forget entry for startup / schedule ticks.
|
|
454
455
|
* Skips the single-flight slot when disabled or not due so manual runs stay free.
|
|
456
|
+
* A sibling instance never runs one: the archived sessions belong to the shared CODEX_HOME the
|
|
457
|
+
* live owner serves (`src/codex/sibling-start.ts`), and the manual run route is refused there too.
|
|
455
458
|
*/
|
|
456
459
|
export function maybeRequestStorageCleanupPolicyRun(
|
|
457
460
|
reason: PolicyRunReason,
|
|
458
461
|
opts?: Omit<RequestPolicyRunOptions, "reason">,
|
|
459
462
|
): void {
|
|
463
|
+
if (siblingOfLivePort() !== null) return;
|
|
460
464
|
try {
|
|
461
465
|
const policy = readStorageCleanupPolicyFromConfig();
|
|
462
466
|
if (!policy.enabled) return;
|