diff --git a/devlog/_plan/261111_link_child_updates/000_plan.md b/devlog/_plan/261111_link_child_updates/000_plan.md index 077d7091b48..eda16095159 100644 --- a/devlog/_plan/261111_link_child_updates/000_plan.md +++ b/devlog/_plan/261111_link_child_updates/000_plan.md @@ -263,6 +263,7 @@ file or symbol; later phases MODIFY it. | file | `child-ops-types.ts`: `ChildJob`, `ChildJobItem`, `ChildItemState`, `ChildOpsEnv`, `ItemRunner`, `ItemOutcome` | `src/link/child-ops-types.ts` | 030 (moves the job types out of 020's engine) | | file | `child-ops-engine.ts`: `createChildOpsEngine`, `sharedChildOpsEngine`, `resetSharedChildOpsEngineForTests` (030); methods `submit(request, env)` (030; 020 shipped `submit(request)`), `job` (`ChildJob \| undefined`), `activeJob`, `whenDone` (050), `settled` | `src/link/child-ops-engine.ts` | 020 (030, 050 extend) | | file | `child-ops-runners.ts`: `runSyncItem`, `runRestartCodexItem`, `runUpdateItem`, `pinnedSync` | `src/link/child-ops-runners.ts` | 030 | +| file | `sync-then-restart.ts`: `syncThenRestart`, `pinnedSyncRanNothing`, `RESTART_RESULT_UNKNOWN` (moved from `child-ops-runners.ts`, which re-exports the constant); the one "sync then guarded restart" sequence for the runners and `ocx sync --restart-children` | `src/link/sync-then-restart.ts` | 040 (extracted from 030's `syncWithRestart`) | | file | `child-ops-scheduler.ts`: `createChildOpsScheduler`, `createLinkBackoff` (shared by 050 and 070) | `src/link/child-ops-scheduler.ts` | 050 | | file | `child-policy.ts`, `child-ops-host.ts` (`startChildOpsHost`) | `src/link/` | 050 | | file | `child-update-sweep.ts` | `src/link/child-update-sweep.ts` | 070 | diff --git a/devlog/_plan/261111_link_child_updates/040_sync_loosening.md b/devlog/_plan/261111_link_child_updates/040_sync_loosening.md index b3289e4f8fa..b5fb7271c38 100644 --- a/devlog/_plan/261111_link_child_updates/040_sync_loosening.md +++ b/devlog/_plan/261111_link_child_updates/040_sync_loosening.md @@ -11,9 +11,16 @@ > the restart goes out as a second pinned sync after a fresh activity read, as in 030). > Also escapes the regex pipe in the Verifiers table (`Codex\|Fan-out`), which split that row. -- **Work phase:** wp4. **Depends on:** wp1 (`remotePinnedSyncArgv(linkId, platform, opts)`, the classifier in - `src/link/sync-fanout-classify.ts`), wp2 (`GET /api/link/status?children=1` with `child.activity` and - `child.activeJob`, and `validateStatus` accepting `child`). **Class:** C3, promoted to C4 for the +> **Revision 5 (wp4 P re-verification on 4e4d8d4206).** Re-anchors every citation to the merged wp1-wp3 code, makes the +> CLI fan-out reuse wp3's "sync then guarded restart" sequence (decision 7, new `src/link/sync-then-restart.ts`), +> adds the two outcomes wp3 already distinguishes (`restart-not-run`, `restart-result-unknown`) and records the +> wire's activity collapse (decision 8). Details in "wp4 P re-verification" at the end. + +- **Work phase:** wp4. **Depends on:** wp1 (`remotePinnedSyncArgv(linkId, platform, opts)`, `ssh-argv.ts:482-491`; + `classifySyncResult(result, { expectSelectedKind })`, `sync-fanout-classify.ts:63`), wp2 + (`GET /api/link/status?children=1` with `child.activity` and `child.activeJob`, validated by `validateChildOps`, + `src/cli/link-status-child.ts:30-66`, reached from `validateStatus`, `src/cli/link.ts:189`), wp3 + (`syncWithRestart`, `child-ops-runners.ts:116-130`, whose sequence this phase extracts and reuses). **Class:** C3, promoted to C4 for the `sync.home-fanout/1` document change and because the Home can now end running turns on another machine. - **Outcome:** on a Home, `ocx sync --child ` (repeatable) fans out only to the named links, and `ocx sync --restart-children[=app-server|codex]` asks each selected Child to restart its Codex @@ -22,30 +29,37 @@ `restart-skipped:link-active`. Fan-out also skips a Child that a Child operation (wp2/wp3 job) holds. Without the new flags the behaviour and the JSON document are exactly today's. - **IN:** `src/cli/sync-flags.ts`, `src/cli/sync-command.ts` (`runChild`), `src/cli/sync-fanout.ts` (filter, - restart decision, exit rule), `src/cli/sync-link-status.ts` (`children=1`), wording in - `capabilities-base.ts:786`, `registry.ts:186-190`, `help.ts:52`, `lifecycle.md:592-612` + 7 locales, - `structure/remote-link.md:9`, `skills/ocx`, tests. + busy skip, restart note to reason and hint, exit rule), `src/cli/sync-link-status.ts` (`children=1`), + `src/link/sync-then-restart.ts` (NEW, the sequence moved out of wp3's `syncWithRestart`), + `src/link/child-ops-runners.ts` (`syncWithRestart` delegates; behaviour unchanged), wording in + `capabilities-base.ts:786`, `registry.ts:191-195`, `help.ts:52`, `lifecycle.md:592-612` + 7 locales, + `structure/remote-link.md:9` and `:75`, `skills/ocx`, tests. - **OUT:** any Child-side change (the Child already honours both restart tokens since 2.54/2.93, F2); a restart outcome marker (wp8); Windows Child restarts (refused, D20); `ocx link restart-codex` (wp3). ## Decisions and deviations from 004 1. **How a skipped restart is reported (A2).** The Child's own `--json` output must stay the 4-key - `ocx.sync.fanout/1` document, because older Homes classify any other key set as `child-output-invalid` - (`src/cli/sync-fanout.ts:28-40`). The restart flags do not change it: under `--json` the Child routes + `ocx.sync.fanout/1` document, because Homes classify any other key set as `child-output-invalid` + (`validDocument`, `src/link/sync-fanout-classify.ts:45-57`). The restart flags do not change it: under `--json` the Child routes restart text to stderr (`sync-command.ts:21`) and the restart happens after the catalog write - (`sync-command.ts:237-243`). The Home reports its own decision in the Home's document, on the existing - `ChildResult` (`sync-fanout.ts:85`) without a new key: a Child whose sync succeeded but whose restart + (`handleConnectedSyncCatalogWrite`, `sync-command.ts:239-245`). The Home reports its own decision in the Home's document, on the existing + `ChildResult` (`sync-fanout.ts:11`) without a new key: a Child whose sync succeeded but whose restart was withheld is `{ state: "synced", reason: "restart-skipped:link-active", hint }`. Today `reason` is always `null` for `synced`; a consumer that branches on `state` keeps working. The same carrier holds - `restart-skipped:activity-unknown` (the Home runtime answered without `child`), `restart-skipped:windows` - and `restart-unsupported:ocx-child` (`ocx-child` accepts and ignores the flags, - `native/ocx-child/src/cli.rs:1211-1215`). + `restart-skipped:activity-unknown` (the Home runtime answered without `child`, or the re-read failed), + `restart-skipped:windows`, `restart-unsupported:ocx-child` (`ocx-child` accepts and ignores the flags, + `native/ocx-child/src/cli.rs:1211-1215`), and two outcomes wp3 already tells apart (decision 7): + `restart-not-run` (the restarting sync certainly ran nothing, F2) and `restart-result-unknown` (it was sent and its + result is unknown; same string as wp3's `RESTART_RESULT_UNKNOWN`, `child-ops-runners.ts:27`). The wp1 rule that + `ChildVerdict.executable` never reaches `ocx.sync.fanout/1` already holds twice on the merged base: + `runChild` picks `{ state, reason }` (`sync-command.ts:220-222`) and `runHomeFanout` builds each row from the + schema's own fields (`sync-fanout.ts:59-61`). This phase keeps both. 2. **Exit 6 for a withheld restart (refinement of D16).** The operator asked for a restart that did not happen, so `fanoutExitCode` returns 6 for a `synced` row whose `reason` starts with `restart-`, exactly as - it does for a hinted skip (`sync-fanout.ts:153-156`). `not-selected` and `child-busy` rows carry no hint and + it does for a hinted skip (`sync-fanout.ts:70-73`). This matches `ocx link sync` (030 R2). `not-selected` and `child-busy` rows carry no hint and keep exit 0, as D16 and D8 specify. -3. **Activity is re-read per Child, not once.** `runHomeFanout` reads status once (`sync-fanout.ts:119`). +3. **Activity is re-read per Child, not once.** `runHomeFanout` reads status once (`sync-fanout.ts:34`). With `--restart-children` it reads it again for each Child after that Child's plain sync and immediately before a second pinned sync that carries the restart (Revision 4), so the window in which a new turn can start shrinks from the whole fan-out (up to 180 s per Child) to one loopback request plus one SSH round trip of a no-op sync. A @@ -62,27 +76,55 @@ owner asked; it is still withheld per Child while a relayed request is in flight. `ocx sync` does not reject unknown flags (no `rejectArgs` in `src/cli/sync-command.ts`), and the Home's own restart reads exact tokens (`restart-scope.ts:35-37`), so `--yes` changes nothing else. +7. **One implementation of "sync then guarded restart" (re-verification).** wp3's `syncWithRestart` + (`child-ops-runners.ts:116-130`) already encodes the sequence this phase needs: plain sync; `unsupported` on a + non-POSIX platform or `childKind: "ephemeral"`; `not-run` when the plain sync did not succeed; activity read after + it, unknown counting as in flight; a second pinned sync carrying the restart; `unsupported` when the second sync + reports `executable: "ocx-child"`; `not-run` when the second sync certainly ran nothing (F2, + `child-ops-runners.ts:97-100`); otherwise the restart was sent and, if that sync did not succeed, its result is + unknown. The CLI fan-out cannot call it as is: it is module-private, it takes the runtime's `ChildOpsEnv` (the + runtime's `SshRunner`, `catalogEtag`, `writeObservation` and a synchronous `activity`, + `child-ops-types.ts:59-79`), and the CLI runs in its own process with `runFanoutProcess` (process-tree + termination, `FanoutProcessError`) and reads activity over `GET /api/link/status?children=1`. Copying the sequence + would let the two drift, so wp4 moves the sequence (and the F2 "ran nothing" rule) into a pure module, + `src/link/sync-then-restart.ts`, parameterised by a `runPinned(restart)` callback and an async + `readActivity()`. `syncWithRestart` becomes a wrapper over it with identical results (its tests in + `tests/clients/link-child-ops-runners.test.ts` stay unchanged and green, which is the proof). The CLI's `runChild` + calls the same function with a `runFanoutProcess` callback and a `readActivity` that `runHomeFanout` passes in + (the per-Child re-read of decision 3), then maps the result to a `reason`. The module imports only types, so it + adds nothing to the CLI's import graph beyond itself. Lab boundary: none of the three core files reaches it. +8. **The wire collapses unknown activity into `inFlight: 0` (re-verification).** `attachLinkChildOps` writes + `{ idle: false, inFlight: 0, lastDataEndAt: null }` when the listener has no activity snapshot + (`src/link/status-projection.ts:149-150`), so a CLI reading the wire cannot tell "no activity book" from "nothing + in flight". On a Home the snapshot is never null: the link listener always creates the activity book and serves + `linkActivity` (`src/server/index/link-listener.ts:93`, `:237`; read at `link-child-ops-routes.ts:138`). The + gate stays `inFlight > 0`, the same test wp3 applies (`child-ops-runners.ts:51-52`), and the residual is recorded + under "Enforcement bypass record". Gating on `idle` instead would also withhold the restart for ten minutes after + every finished turn (`DEFAULT_IDLE_AFTER_MS`, `child-activity.ts:4`), which A2 does not ask for. ## File change map | Path | Kind | Purpose | | --- | --- | --- | | src/cli/sync-flags.ts | MODIFY (:1-10) | parse `--child`, `--restart-children`; reject conflicts | -| src/cli/sync-command.ts | MODIFY (:18-19, :207-226) | unknown-id check before the Home sync; pass selection and restart; `runChild` options | -| src/cli/sync-fanout.ts | MODIFY (`runHomeFanout`, `fanoutExitCode`, `childSkipHint`) | filter, busy skip, restart decision, exit rule | +| src/cli/sync-command.ts | MODIFY (:18-19, :207-228) | unknown-id check before the Home sync; pass selection and restart; `runChild` calls the shared sequence | +| src/cli/sync-fanout.ts | MODIFY (`runHomeFanout`, `fanoutExitCode`) | filter, busy skip, per-Child `readActivity`, restart hint, exit rule | | src/cli/sync-link-status.ts | MODIFY (:29) | `?children=1` | -| src/cli/capabilities-base.ts | MODIFY (:786, usage line, flags) | wording and the two flags | -| src/cli/registry.ts | MODIFY (:186, :190) | usage and wording | +| src/link/sync-then-restart.ts | NEW | `syncThenRestart`, `pinnedSyncRanNothing`, `RESTART_RESULT_UNKNOWN` (decision 7) | +| src/link/child-ops-runners.ts | MODIFY (:27, :97-100, :116-130) | `syncWithRestart` and `runPinnedSync` delegate; constant re-exported | +| src/cli/capabilities-base.ts | MODIFY (:786, :790, `flags` at :772) | wording and the two flags | +| src/cli/registry.ts | MODIFY (:191, :195) | usage and wording | | src/cli/help.ts | MODIFY (:52) | one-line usage | | docs-site/src/content/docs/reference/cli/lifecycle.md | MODIFY (:592, :609-612) | heading usage, restart paragraph | | docs-site/src/content/docs/{fr,ja,ko,ru,tr,zh-cn,zh-tw}/reference/cli/lifecycle.md | MODIFY | fr :166/:170, ja :173/:177, ko :261/:265, ru :262/:266, tr :259/:263, zh-cn :169/:173, zh-tw :161/:165 | -| structure/remote-link.md | MODIFY (:9) | "without a restart flag" → opt-in restart | +| structure/remote-link.md | MODIFY (:9, :75) | "without a restart flag" → opt-in restart; name the shared sequence | | skills/ocx/references/05_remote_hub.md | MODIFY (:325) | hand page wording | | skills/ocx/references/01_surface_lifecycle.md | REGENERATE | `bun run skill:surface` (line 110 comes from `capabilities-base.ts:786`) | | tests/cli/sync-fanout-select-restart.test.ts | NEW | pure fan-out cases | -| tests/cli/sync-command-child-flags.test.ts | NEW | flags and `runChild` argv | +| tests/cli/sync-command-child-flags.test.ts | NEW | flags, unknown id, `runChild` argv and restart mapping | -Sizes: `sync-command.ts` 243 → about 262, `sync-fanout.ts` about 85 after wp1 → about 125, `sync-flags.ts` 10 → about 45. +Sizes on 4e4d8d4206: `sync-command.ts` 245 → about 265, `sync-fanout.ts` 78 → about 100, `sync-flags.ts` 10 → about 45, +`child-ops-runners.ts` 312 → about 300, new module about 60. None of them is in `tests/fixtures/file-size-baseline.json`. ## Changes @@ -153,9 +195,93 @@ nor `--restart-children=codex` equals `--restart-codex` or `--restart-app-server itself because of the new flag. `parseSyncExpectation` ignores the new flags (`sync-expectation.ts:21-36`). A `--child` value consumed here is not an `--expect-link` value: the two parsers match different flag names. +`ChildRestartRequest` is exported by `src/link/ssh-argv.ts:478` (`"app-server" | "codex"`), so the import above +resolves on the merged base; `sync-flags.ts` is still the 10-line file shown under Before. + +### src/link/sync-then-restart.ts (NEW, decision 7) + +The body is wp3's `syncWithRestart` (`child-ops-runners.ts:116-130`) and its F2 rule (`:97-100`), moved +verbatim and parameterised. Type-only imports. + +```ts +import type { ChildRestart, ChildRestartOutcome } from "./child-ops-types"; +import type { LinkRecord } from "./store"; + +/** Revision 6 F2 (030): a restarting sync that may have run but whose result is unknown. */ +export const RESTART_RESULT_UNKNOWN = "restart-result-unknown"; + +type Verdict = { state: string; reason: string | null; executable?: "ocx-child" }; +/** One pinned sync: its verdict and whether it certainly ran nothing on the Child (F2). */ +export interface PinnedRun { sync: S; notRun: boolean } +/** Why no restart went out. In process only; never serialized into a job or ocx.sync.fanout/1. */ +export type RestartWithheld = "platform" | "ocx-child" | "sync-failed" | "activity-unknown" | "link-active" | "second-not-run"; + +export interface SyncThenRestart { + /** The plain sync's verdict, or the restarting sync's when restartUnknown. */ + sync: S; + restart?: { requested: "app-server" | "codex"; outcome: ChildRestartOutcome }; + restartUnknown?: true; + withheld?: RestartWithheld; + /** The restarting sync's verdict whenever one ran. */ + second?: S; +} + +/** F2: the gate refused before selecting an ocx, or the Child's sync declined. Moved from child-ops-runners.ts:97-100. */ +export function pinnedSyncRanNothing(verdict: Verdict, result: { code: number; stderr: string }): boolean { + const selected = /^ocx-fanout:selected:/m.test(result.stderr); + return verdict.state === "skipped" || (verdict.state === "failed" && !selected && [121, 125, 126, 127].includes(result.code)); +} + +export async function syncThenRestart(input: { + requested: ChildRestart; platform: "posix" | "windows"; childKind: LinkRecord["childKind"]; + runPinned: (restart: ChildRestart) => Promise>; + /** Read after the plain sync, immediately before the restart. null is unknown and counts as in flight (D5). */ + readActivity: () => Promise<{ inFlight: number } | null>; +}): Promise> { + const { requested } = input; + const first = (await input.runPinned("none")).sync; + if (requested === "none") return { sync: first }; + if (input.platform !== "posix") return { sync: first, restart: { requested, outcome: "unsupported" }, withheld: "platform" }; + if (input.childKind === "ephemeral") return { sync: first, restart: { requested, outcome: "unsupported" }, withheld: "ocx-child" }; + if (first.state !== "synced") return { sync: first, restart: { requested, outcome: "not-run" }, withheld: "sync-failed" }; + const activity = await input.readActivity(); + if (activity === null) return { sync: first, restart: { requested, outcome: "skipped" }, withheld: "activity-unknown" }; + if (activity.inFlight > 0) return { sync: first, restart: { requested, outcome: "skipped" }, withheld: "link-active" }; + const second = await input.runPinned(requested); + if (second.sync.executable === "ocx-child") return { sync: first, restart: { requested, outcome: "unsupported" }, withheld: "ocx-child", second: second.sync }; + if (second.sync.state === "synced") return { sync: first, restart: { requested, outcome: "requested" }, second: second.sync }; + if (second.notRun) return { sync: first, restart: { requested, outcome: "not-run" }, withheld: "second-not-run", second: second.sync }; + return { sync: second.sync, restart: { requested, outcome: "requested" }, restartUnknown: true, second: second.sync }; +} +``` + +wp3's order is kept exactly: the unsupported checks precede the "plain sync failed" check, as at +`child-ops-runners.ts:120-121`. The callback never sees a Windows restart, so `remotePinnedSyncArgv`'s +refusal (`ssh-argv.ts:486`, `LinkSshArgumentError("unsupported:windows-restart")`) stays a backstop. + +### src/link/child-ops-runners.ts + +- `:27` becomes `export { RESTART_RESULT_UNKNOWN } from "./sync-then-restart";` (its three uses at :169, :197, :306 and any + importer keep the name). +- `runPinnedSync` `:97-100`: `const notRun = pinnedSyncRanNothing(sync, result);`. +- `syncWithRestart` `:116-130` keeps its signature and its result type (`withheld`/`second` are dropped): + +```ts +async function syncWithRestart(record: LinkRecord, platform: Platform, requested: ChildRestart, + env: ChildOpsEnv): Promise<{ sync: SyncResult; restart?: RestartReport; restartUnknown?: true }> { + const { sync, restart, restartUnknown } = await syncThenRestart({ requested, platform, childKind: record.childKind, + runPinned: restart => runPinnedSync(record, platform, restart, env), + // linkBusy's rule: unknown is in flight. + readActivity: async () => env.activity(record.id) }); + return { sync, ...(restart ? { restart } : {}), ...(restartUnknown ? { restartUnknown } : {}) }; +} +``` + +`linkBusy` (`:51-53`) stays for `runRestartCodexItem`'s pre-check (`:194`). + ### src/cli/sync-command.ts -Before (`:18-19` and `:207-226`, abridged): +Before (`:18-19` and `:207-228`, abridged): ```ts const flags = parseSyncFanoutFlags(deps.args.slice(1)); @@ -165,9 +291,15 @@ Before (`:18-19` and `:207-226`, abridged): home: { state, exit: code }, catalogCurrent, localOnly: flags.localOnly, applicable, readStore: () => readLinkStore(linkStorePath()), readLinkStatus: () => readHomeLinkStatus(), runChild: async (link, deadline) => { - ... + ...platform discovery (:211-217)... + try { const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), argv: remotePinnedSyncArgv(link.id, platform) }), { timeoutMs: Math.max(0, deadline - Date.now()) }); - return { ...classifySyncResult(result), exit: result.code }; + // Pick the public fields: a verdict key that ocx.sync.fanout/1 does not define never reaches the document. + const { state, reason } = classifySyncResult(result); + return { state, reason, exit: result.code }; + } catch (error) { ...FanoutProcessError → timed-out / failed... } + }, + }); ``` After: @@ -189,71 +321,103 @@ After: selected: flags.children === null ? null : new Set(flags.children), restartChildren: flags.restartChildren, readStore: () => readLinkStore(linkStorePath()), readLinkStatus: () => readHomeLinkStatus(), runChild: async (link, deadline, options) => { - ...platform discovery unchanged... - const windowsSkip = options.restart !== undefined && platform === "windows"; - const restart = windowsSkip ? {} : options.restart === undefined ? {} : { restart: options.restart }; - try { - const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), - argv: remotePinnedSyncArgv(link.id, platform, restart) }), { timeoutMs: Math.max(0, deadline - Date.now()) }); - return { ...classifySyncResult(result), exit: result.code, ...(windowsSkip ? { restartNote: "restart-skipped:windows" as const } : {}) }; - } ...unchanged catch... + ...platform discovery unchanged (:211-217), once per Child... + type Run = { state: ChildResult["state"]; reason: string | null; exit: number | null; executable?: "ocx-child" }; + const runPinned = async (restart: ChildRestart): Promise> => { + try { + const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), + argv: remotePinnedSyncArgv(link.id, platform, restart === "none" ? {} : { restart }) }), { timeoutMs: Math.max(0, deadline - Date.now()) }); + const verdict = classifySyncResult(result, { expectSelectedKind: restart !== "none" }); + return { sync: { ...verdict, exit: result.code }, notRun: pinnedSyncRanNothing(verdict, result) }; + } catch (error) { + if (!(error instanceof FanoutProcessError)) throw error; + const timedOut = error.reason === "deadline-exceeded" || error.reason === "termination-unconfirmed"; + return { sync: { state: timedOut ? "timed-out" : "failed", reason: error.reason, exit: null }, notRun: error.reason === "ssh-spawn-failed" }; + } + }; + const done = await syncThenRestart({ requested: options.restart ?? "none", platform, childKind: link.childKind, + runPinned, readActivity: options.readActivity }); + // Pick the public fields: executable, withheld and second never reach ocx.sync.fanout/1. + const note = restartNote(done, link); + return note === null ? { state: done.sync.state, reason: done.sync.reason, exit: done.sync.exit } + : { state: "synced", reason: note, exit: done.sync.exit }; }, }); ``` +`restartNote` lives in `sync-fanout.ts` (pure, tested there) and returns `null` unless the plain sync succeeded: + +| `syncThenRestart` result | row `reason` (state `synced`) | +| --- | --- | +| no restart requested, or `outcome: "requested"` without `restartUnknown` | `null` (row as today) | +| `withheld: "platform"` | `restart-skipped:windows` | +| `withheld: "ocx-child"` (childKind or selected-kind marker) | `restart-unsupported:ocx-child` | +| `withheld: "sync-failed"` | `null`: the plain sync's failure row stands | +| `withheld: "activity-unknown"` | `restart-skipped:activity-unknown` | +| `withheld: "link-active"` | `restart-skipped:link-active` | +| `withheld: "second-not-run"` | `restart-not-run` | +| `restartUnknown` | `restart-result-unknown` (the plain sync succeeded; `done.sync` is the second run, so `exit` is that run's) | + +The per-Child budget is unchanged: both pinned syncs share `deadline` and run inside `runHomeFanout`'s existing race +(`sync-fanout.ts:53-58`), so a slow second sync ends as `timed-out/termination-unconfirmed` like any Child. + ### src/cli/sync-fanout.ts ```ts -export type RestartNote = "restart-skipped:link-active" | "restart-skipped:activity-unknown" | "restart-skipped:windows" | "restart-unsupported:ocx-child"; -type ChildRun = Omit & { restartNote?: RestartNote }; +export type RestartNote = "restart-skipped:link-active" | "restart-skipped:activity-unknown" | "restart-skipped:windows" + | "restart-unsupported:ocx-child" | "restart-not-run" | "restart-result-unknown"; +export type ChildRunOptions = { restart?: ChildRestartRequest; readActivity: () => Promise<{ inFlight: number } | null> }; export async function runHomeFanout(deps: { - ...existing fields... + ...existing fields (:15-18)... selected?: ReadonlySet | null; restartChildren?: ChildRestartRequest | null; - runChild: (link: LinkRecord, deadline: number, options: { restart?: ChildRestartRequest }) => Promise; + runChild: (link: LinkRecord, deadline: number, options: ChildRunOptions) => Promise>; }): Promise; ``` -Worker loop (`sync-fanout.ts:127-147`), after `const link` and the client-initiated `continue`: +Worker loop (`sync-fanout.ts:42-64`), after `const link` and the client-initiated `continue` (:46): ```ts if (deps.selected && !deps.selected.has(link.id)) { result.children[index] = classify(link, "skipped", "not-selected"); continue; } - let wire = status.links.find(row => row.id === link.id); - if (!wire) { ...unchanged link-not-in-runtime... } - if (wire.state !== "connected") { ...unchanged tunnel-... } + const wire = status.links.find(row => row.id === link.id); + ...unchanged link-not-in-runtime (:48) and tunnel- (:49)... if (wire.child?.activeJob) { result.children[index] = classify(link, "skipped", "child-busy"); continue; } - let options: { restart?: ChildRestartRequest } = {}, note: RestartNote | undefined; - if (deps.restartChildren && link.childKind === "ephemeral") note = "restart-unsupported:ocx-child"; - ...race unchanged, calling deps.runChild(link, deadline, {}) (the plain sync, never a restart)... - // Revision 4 (4529521916): activity is read after the sync, then the restart goes out as a second pinned sync. - if (deps.restartChildren && !note && run.state === "synced") { - let fresh: typeof wire | undefined; - // 4529522035: a failed re-read means activity is unknown; the first read is not reused. - try { fresh = (await deps.readLinkStatus()).links.find(row => row.id === link.id); } catch { fresh = undefined; } - const activity = fresh?.child?.activity; - if (!activity) note = "restart-skipped:activity-unknown"; - else if (activity.inFlight > 0) note = "restart-skipped:link-active"; - else { - // ...race again within the same deadline: - const second = await deps.runChild(link, deadline, { restart: deps.restartChildren }); - // 4529522026: ocx-child ignores the flag; 010's selected-kind marker tells us even without childKind. - if (second.executable === "ocx-child") note = "restart-unsupported:ocx-child"; - else if (second.state !== "synced") run = { ...second, restartNote: second.restartNote }; - } - } - const { restartNote, ...child } = run; - const finalNote = note ?? restartNote; - result.children[index] = child.state === "synced" && finalNote - ? { linkId: link.id, alias: link.alias, ...child, reason: finalNote, hint: childRestartHint(finalNote, link.alias) } - : { linkId: link.id, alias: link.alias, ...child, ...(child.state === "skipped" ? { hint: childSkipHint(child.reason, link.alias) } : {}) }; + // Decision 3 / 4529522035: a fresh read per Child; a failed read or a row without child is unknown. + const readActivity = async () => { + try { return (await deps.readLinkStatus()).links.find(row => row.id === link.id)?.child?.activity ?? null; } + catch { return null; } + }; + const options: ChildRunOptions = deps.restartChildren ? { restart: deps.restartChildren, readActivity } : { readActivity }; + ...race unchanged (:50-58), calling deps.runChild(link, deadline, options)... + // :60 grows one branch; :61 is unchanged, so only schema fields reach the row. + const hint = child.state === "skipped" ? childSkipHint(child.reason, link.alias) + : child.state === "synced" && child.reason?.startsWith("restart-") ? childRestartHint(child.reason as RestartNote, link.alias) : child.hint; ``` ```ts +export function restartNote(done: SyncThenRestart<{ state: string; reason: string | null }>, link: LinkRecord): RestartNote | null { + if (!done.restart) return null; + // Reached only after a successful plain sync; done.sync is then the restarting sync's verdict. + if (done.restartUnknown) return "restart-result-unknown"; + // Any failed plain sync keeps its own row, including on Windows or ocx-child (wp3 checks those first). + if (done.sync.state !== "synced") return null; + switch (done.withheld) { + case undefined: case "sync-failed": return null; + case "platform": return "restart-skipped:windows"; + case "ocx-child": return "restart-unsupported:ocx-child"; + case "activity-unknown": return "restart-skipped:activity-unknown"; + case "link-active": return "restart-skipped:link-active"; + case "second-not-run": return "restart-not-run"; + } +} + function childRestartHint(note: RestartNote, alias: string): string { if (note === "restart-skipped:link-active") return `A model request was running on ${alias}; its Codex was not restarted. Retry when it is idle.`; if (note === "restart-skipped:activity-unknown") return "Restart this Home's OpenCodex runtime so it reports Child activity, then retry."; if (note === "restart-skipped:windows") return "Codex restarts over SSH are not supported on Windows Children; restart Codex on that machine."; + if (note === "restart-not-run") return `The restart was not sent to ${alias}; check its sync status and retry.`; + if (note === "restart-result-unknown") return `The restart was sent to ${alias} but its result is unknown; check Codex on that machine.`; return "ocx-child does not restart Codex; restart the Codex app or its app-servers on that machine."; } @@ -265,13 +429,20 @@ export function fanoutExitCode(document: FanoutDocument): number { } ``` -`FanoutDocument`'s type and key set are unchanged; only the value domain of `reason` on a `synced` row grows. +`restartNote` takes `link` only for symmetry with the hint and may drop it. Without `--restart-children` no +`restart` is requested, `readActivity` is never called, `runChild` makes exactly one `runFanoutProcess` call +with `remotePinnedSyncArgv(link.id, platform, {})` and the row is today's. `FanoutDocument`'s type and key set are unchanged; +only the value domain of `reason` on a `synced` row grows. `sync-fanout.ts` takes `SyncThenRestart` as a type import from +`src/link/sync-then-restart.ts`, as it already imports `childSkipHint` from `src/link` (:4). ### src/cli/sync-link-status.ts `:29` `fetch(`${base}/api/link/status`, ...)` → `fetch(`${base}/api/link/status?children=1`, ...)`. `validateStatus` -already accepts `child` after wp2 (`020_signals_visibility.md`). An older Home ignores the query; rows then +accepts `child` since wp2 (`src/cli/link.ts:168` allows the key, `:189` calls `validateChildOps`), and `ocx link +status` already sends the query (`link.ts:321`). An older Home ignores the query; rows then lack `child`, which only matters with `--restart-children` (decision 4) or for the busy skip (no `activeJob` ⇒ no skip). +The `?children=1` read costs more than the plain one (`attachLinkChildOpsForState` flushes catalog pulls and reads +`child-observations.json`, `link-child-ops-routes.ts:127-142`); it stays inside `readHomeLinkStatus`'s 5 s deadline. ### Wording (source of truth first, then derived copies) @@ -282,17 +453,20 @@ lack `child`, which only matters with `--restart-children` (decision 4) or for t Child unless --restart-children[=app-server|codex] is given; the Home then skips the restart, never the sync, for a Child with a model request in flight (restart-skipped:link-active), on Windows Children, and on ocx-child, and exits 6. Applying changed Home settings may restart the Child's own OpenCodex client runtime." The same entry's - `usage` (the `ocx sync ...` string at `:790`) and `flags` gain `--child` (string, repeatable) and - `--restart-children` (optional value `app-server|codex`). -- `src/cli/registry.ts:186` usage gains `[--child ]... [--restart-children[=app-server|codex]]`; `:190` is - replaced by the sentence above minus the first clause. + `usage` (the `ocx sync ...` string at `:790`) and `flags` (the list opening at `:772`) gain `--child` (string, + repeatable) and `--restart-children` (optional value `app-server|codex`). The exit-6 detail at `:787` names the + `restart-*` reasons beside the skipped Children. +- `src/cli/registry.ts:191` usage gains `[--child ]... [--restart-children[=app-server|codex]]`; `:195` is + replaced by the sentence above minus the first clause (`:194` already says what `--local-only` and `--json` do). - `src/cli/help.ts:52`: `ocx sync [--local-only] [--json] [--restart-codex] [--child ] [--restart-children]`. - `docs-site/src/content/docs/reference/cli/lifecycle.md`: the `:592` heading usage gains both flags (the explicit `` anchor stays, so links keep working); `:609-612` becomes: "Fan-out requests no Codex restart on a Child unless you pass `--restart-children` (app-servers) or `--restart-children=codex --yes` (the Codex desktop app, which ends live conversations and needs a signed-in desktop session on that machine). The Home skips the restart, never the sync, for a Child that is relaying a model request at that moment (`restart-skipped:link-active`), for - Windows Children and for `ocx-child`, and then exits 6. `--child ` (repeatable) fans out only to those + Windows Children and for `ocx-child`, and then exits 6. If the restart could not be sent, or was sent but its result + is unknown, the Child's row reads `restart-not-run` or `restart-result-unknown` and the exit is also 6. + `--child ` (repeatable) fans out only to those links; the others are reported `not-selected`. A Child busy with a Child operation is skipped as `child-busy`. The Child's pinned sync may still restart its own OpenCodex client runtime after applying changed Home settings. If the Home times out and stops its local SSH process, a remote sync already running may continue on the Child." @@ -302,8 +476,10 @@ lack `child`, which only matters with `--restart-children` (decision 4) or for t - `structure/remote-link.md:9`: "...before executing `sync --expect-link` without a restart flag." → "...before executing `sync --expect-link`; the Home adds `--restart-app-server-only` or `--restart-codex` only for `ocx sync --restart-children`, never on Windows or `ocx-child`, and withholds it for a link whose relayed - model traffic is in flight (`src/cli/sync-fanout.ts`)." Owned by `structure/cli-management.md` for `src/cli/` - too: add one sentence there naming the two flags. + model traffic is in flight (`src/cli/sync-fanout.ts`, `src/link/sync-then-restart.ts`)." `:75`, which names + `child-ops-runners.ts` as the runners' home, gains: "The sync-then-restart sequence both the runners and `ocx sync + --restart-children` use is `src/link/sync-then-restart.ts`." `structure/cli-management.md:9` already describes + the fan-out files for `src/cli/`: add one sentence there naming the two flags. - `skills/ocx/references/05_remote_hub.md:325`: same rule in one sentence; `01_surface_lifecycle.md` regenerates. ## Field chains @@ -314,32 +490,35 @@ lack `child`, which only matters with `--restart-children` (decision 4) or for t | `flags.restartChildren` | `parseSyncFanoutFlags` | Child argv `--restart-app-server-only` / `--restart-codex` (wp1 builder) | Child `readRestartScope` | Child `handleRestartScopeAfterWrite` | | `reason: "not-selected"`, `"child-busy"` | `runHomeFanout` | `ocx.sync.fanout/1` `children[].reason` | GOAT and scripts parsing the document; `renderFanoutHuman` | exit rule (no hint ⇒ no 6) | | `reason: restart-*` + `hint` on `synced` | `runHomeFanout` | same document | same | `fanoutExitCode` → 6; human line | -| `child.activity.inFlight`, `child.activeJob` | wp2 projection | `/api/link/status?children=1` | `validateStatus` (wp2) via `readHomeLinkStatus` | restart decision, busy skip | -| `restartNote` | `runChild` (Windows) | N/A (stripped before output) | N/A | merged into `reason` | +| `child.activity.inFlight`, `child.activeJob` | wp2 projection (`status-projection.ts:149-151`) | `/api/link/status?children=1` | `validateChildOps` (`link-status-child.ts:62-66`) via `validateStatus` and `readHomeLinkStatus` | `readActivity` → `syncThenRestart`; busy skip | +| `SyncThenRestart.withheld`, `.second`, verdict `executable` | `syncThenRestart`, `classifySyncResult` | N/A (in process; `runChild` picks schema fields) | N/A | `restartNote` → row `reason` | ## Tests +Revision 5 moves the sequence cases (activity read after the plain sync, failed re-read, selected-kind `ocx-child`, +not-run, result unknown) to the layer that owns them: `syncThenRestart` is covered through wp3's +`tests/clients/link-child-ops-runners.test.ts` (unchanged; it must stay green after the extraction, which proves the +wrapper changed nothing) and through `runChild` in the command test below. `runHomeFanout` cases check selection, +the busy skip, that `readActivity` reads status afresh, hints and the exit rule. + - `tests/cli/sync-fanout-select-restart.test.ts` (new, cli; fails before implementation because `runHomeFanout` ignores `selected`/`restartChildren` and the 3-argument `runChild` does not exist). Each case - drives `runHomeFanout` with an in-memory store, a scripted `readLinkStatus` and a recording `runChild`: + drives `runHomeFanout` with an in-memory store, a scripted `readLinkStatus` and a recording `runChild` that + returns a scripted row and may call `options.readActivity`: - `--child runs only the selected link`: two connected links, `selected = {A}`; B is `skipped/not-selected` with no hint; `runChild` called once; exit 0. - `busy link is skipped`: row with `child.activeJob`; `skipped/child-busy`, no hint, no SSH. - - `idle link gets the restart`: `child.activity.inFlight: 0`; `runChild` receives `{}` and then - `{restart: "app-server"}` (Revision 4: sync first, restart second); row `synced`, `reason null`, exit 0. - - `active link syncs without restart` (A2): the second status read returns `inFlight: 1`; `runChild` receives - `{}`; row `synced`/`restart-skipped:link-active` with hint; exit 6. - - `a turn that starts after the first read is seen`: first read idle, second read `inFlight: 1` → withheld - (proves the per-Child re-read). - - `missing child block withholds`: rows without `child` → `restart-skipped:activity-unknown`. - - `failed re-read withholds` (Revision 4, 4529522035): first read idle, the re-read throws → - `restart-skipped:activity-unknown`, `runChild` called once with `{}`, exit 6. - - `ocx-child without childKind` (Revision 4, 4529522026): no `childKind`; the second `runChild` returns - `executable: "ocx-child"` → `synced`/`restart-unsupported:ocx-child` with hint, exit 6. - - `ocx-child withholds`: `childKind: "ephemeral"` → `restart-unsupported:ocx-child`, no status re-read. - - `Windows note is merged`: `runChild` returns `restartNote: "restart-skipped:windows"` → reason set, exit 6. - - `failed sync keeps its own reason`: `runChild` fails with `child-sync-failed` while a note exists → the failure wins. - - `document keys unchanged`: every child row's key set is a subset of `linkId, alias, state, reason, exit, hint`. + - `restart request reaches runChild`: with `restartChildren: "codex"`, `options.restart` is `"codex"`; without + it `options.restart` is absent and `readActivity` is never called (no second status read). + - `readActivity reads afresh` (decision 3): the first status read is idle, the second returns `inFlight: 1` for + that link; `options.readActivity()` resolves `{ inFlight: 1, ... }`, so the first read is never reused. + - `readActivity is unknown`: a row without `child` and a throwing re-read (4529522035) both resolve `null`. + - `restart notes get hints and exit 6`: `runChild` returns `synced` with each `RestartNote`; each row carries + `childRestartHint` and the exit is 6; a `synced` row with `reason: null` exits 0. + - `restartNote table`: every row of the mapping table under `sync-command.ts`, including a failed plain sync on + Windows and on `childKind: "ephemeral"` → `null` (the failure row stands). + - `document keys unchanged`: a `runChild` result carrying `executable` still yields rows whose key set is a subset + of `linkId, alias, state, reason, exit, hint`. - `tests/cli/sync-command-child-flags.test.ts` (new, cli; fails before implementation): - `parse table`: `--child id`, `--child=id`, duplicate id (deduplicated), bad id, missing value, `--restart-children` bare/`=codex --yes`/`=desktop` (exit 2)/twice (exit 2), with `--local-only` (exit 2); @@ -349,14 +528,20 @@ lack `child`, which only matters with `--restart-children` (decision 4) or for t - `unknown --child exits 2 before the Home sync`: `runSyncCommand` with a temp `OPENCODEX_HOME` holding one link and `--child lnk_ffffffffffffffff`; exit 2 and the injected Home sync spy never runs (pattern of `tests/cli/sync-command-fanout.test.ts`). - - `runChild argv carries the token`: capture the `buildExecArgv` argv for POSIX (last args - `--json --restart-app-server-only`) and Windows (`windowsScriptData` args) and none for a Windows restart. + - `runChild runs the shared sequence`: `spyOn(processApi, "runFanoutProcess")` and `fanoutSshRunner` as in + `tests/cli/sync-command-fanout.test.ts:218-219`, and a scripted local status (`readHomeLinkStatus` spied the same + way). POSIX idle: two process calls, the first argv ending `--json`, the second `--json --restart-app-server-only` + (`--restart-codex` for `=codex --yes`), row `synced`/`null`, exit 0. In flight: one call, + `restart-skipped:link-active`, exit 6. Windows (`remotePlatform: "windows"`): one call with no restart token, + `restart-skipped:windows`. Second call stderr with `ocx-fanout:selected-kind:ocx-child`: + `restart-unsupported:ocx-child`. Second call exit 121 without the selection marker: `restart-not-run`. Second + call exit 255: `synced`/`restart-result-unknown`, `exit` 255. No case's JSON row has an `executable` key. - Status query: extend nothing in place; one case in the new command test asserts the fetched URL ends with `/api/link/status?children=1` (`readHomeLinkStatus` takes `fetchImpl`, `sync-link-status.ts:12`). Registration (000 "Test registration rule"): both files as `"cli"`, one line each before the closing `}` of -`scripts/test-layout/layout-clients.json` and of `tests/fixtures/test-layout-expected.json`; nothing goes into -`layout.json` (1998 lines, the ratchet fails at 2000). Existing files that must stay green unchanged: `tests/cli/sync-fanout.test.ts`, +`scripts/test-layout/layout-clients.json` (196 lines on 4e4d8d4206) and of `tests/fixtures/test-layout-expected.json` +(1669 lines); nothing goes into `layout.json` (1999 lines on 4e4d8d4206, one below the 2000 ratchet). Existing files that must stay green unchanged: `tests/clients/link-child-ops-runners.test.ts` (the extraction's proof), `tests/cli/sync-fanout.test.ts`, `sync-command-fanout.test.ts`, `sync-fanout-process.test.ts`, `sync-link-status.test.ts`, `cli-sync-expect-link.test.ts`, `cli-json-contract.test.ts`, `tests/update/update-sync-mutex.test.ts`, `tests/clients/link-pinned-sync-argv.test.ts`, `tests/cli/cli-capabilities.test.ts`, `tests/ci-workflows/skill-ocx.test.ts`. @@ -365,7 +550,9 @@ Registration (000 "Test registration rule"): both files as `"cli"`, one line eac | Guard | Tier | Executing surface | Known bypass | Residual risk / wording | | --- | --- | --- | --- | --- | -| skip restart while a relayed turn runs | runtime, Home | `runHomeFanout` after each Child's plain sync (Revision 4) | a turn that starts between the per-Child status read and the Child's restart (one loopback request plus one SSH round trip of a no-op sync; the catalog sync itself now runs before the read); traffic the Home does not relay (a Child user composing locally) | docs say "skips the restart for a Child that is relaying a model request at that moment", never "never interrupts a turn" | +| skip restart while a relayed turn runs | runtime, Home | `syncThenRestart` (called from `runChild`) after each Child's plain sync, with `runHomeFanout`'s per-Child `readActivity` (Revision 4, 5) | a turn that starts between the per-Child status read and the Child's restart (one loopback request plus one SSH round trip of a no-op sync; the catalog sync itself now runs before the read); traffic the Home does not relay (a Child user composing locally) | docs say "skips the restart for a Child that is relaying a model request at that moment", never "never interrupts a turn" | +| unknown activity is never idle | runtime, Home | `readActivity` → `null` for a missing row, a missing `child` block or a failed read | a Home listener without an activity book projects `inFlight: 0` on the wire (`status-projection.ts:149-150`), which reads as idle; the Home's link listener always has one (`link-listener.ts:93`, `:237`) | decision 8; wp3's in-process path still sees `null` and withholds | +| an unknown restart result is not reported as success | runtime, Home | `restartNote` → `restart-result-unknown`, exit 6 | none | the hint asks the operator to check Codex on that machine | | no restart on Windows / `ocx-child` | runtime, Home | `runChild`, `runHomeFanout` | none: the flag is never sent | — | | Home's own scope unaffected | parse rule | `readRestartScope` exact tokens | none | — | | unknown `--child` refused | runtime, Home | `runSyncCommand` | an id removed between the check and fan-out reads `link-not-in-runtime` or is absent from the store | harmless | @@ -374,13 +561,13 @@ Registration (000 "Test registration rule"): both files as `"cli"`, one line eac | Command | Today | Reads this phase's target | | --- | --- | --- | -| `bun test tests/cli/sync-command-fanout.test.ts tests/cli/sync-link-status.test.ts tests/cli/sync-fanout-process.test.ts tests/cli/cli-json-contract.test.ts tests/cli/sync-fanout.test.ts` | ran 2026-11-11: 38 pass, 1 skip, 0 fail, exit 0 | yes: `sync-command.ts`, `sync-fanout.ts`, `sync-link-status.ts`, the JSON contract | +| `bun test tests/cli/sync-command-fanout.test.ts tests/cli/sync-link-status.test.ts tests/cli/sync-fanout-process.test.ts tests/cli/cli-json-contract.test.ts tests/cli/sync-fanout.test.ts tests/clients/link-child-ops-runners.test.ts tests/clients/link-pinned-sync-argv.test.ts` | ran on 4e4d8d4206 (revision 5): 117 pass, 2 skip, 0 fail, 119 tests across 7 files, exit 0 | yes: `sync-command.ts`, `sync-fanout.ts`, `sync-link-status.ts`, the JSON contract, wp3's sequence, the restart argv | | `bun test tests/cli/cli-sync-expect-link.test.ts tests/update/update-sync-mutex.test.ts` | ran (with link-ssh-argv): 83 pass, 1 skip, 0 fail, exit 0 | yes: Child-side sync and mutex the restart rides on | | `bun test tests/cli/sync-fanout-select-restart.test.ts tests/cli/sync-command-child-flags.test.ts` | new; fail before implementation | new behaviour | | `bun run skill:surface:check` | ran: exit 0 (current) | `capabilities-base.ts:786` → `01_surface_lifecycle.md` | | `bun test tests/cli/cli-capabilities.test.ts tests/ci-workflows/skill-ocx.test.ts` | ran in the 020 batch: 0 fail | flag/route parity, hand pages | -| `rg -n -i "never requests a Codex\|Fan-out never" src skills structure docs-site` | ran: 5 hits (lifecycle.md:609, 05_remote_hub.md:325, 01_surface_lifecycle.md:110, capabilities-base.ts:786, registry.ts:190); must be 0 English hits after the change, and each locale's paragraph is rewritten | wording sync | -| `bun run typecheck` | ran: exit 0 | all `src/` | +| `rg -n -i "never requests a Codex\|Fan-out never\|without a restart flag" src skills structure docs-site` | ran on 4e4d8d4206: 6 hits (lifecycle.md:609, 05_remote_hub.md:325, 01_surface_lifecycle.md:110, capabilities-base.ts:786, registry.ts:195, structure/remote-link.md:9); must be 0 English hits after the change, and each locale's paragraph is rewritten | wording sync | +| `bun run typecheck` | ran on 4e4d8d4206: exit 0 (2,402 files) | all `src/` | | `bun run structure:check` | revision 1: exit 1 on 00680193cb (an unrelated 603-line doc); audit r1 on 61151e3457: exit 0 | `structure/remote-link.md`, `cli-management.md` | ## Acceptance criteria @@ -395,6 +582,10 @@ Registration (000 "Test registration rule"): both files as `"cli"`, one line eac 6. A Child held by a Child operation is `skipped/child-busy` with no SSH and no exit 6. 7. Windows and `ocx-child` Children never receive a restart flag and report their note. 8. No English source, generated skill page or locale page still says fan-out never requests a restart. +9. After the extraction, `tests/clients/link-child-ops-runners.test.ts` passes unchanged, and `src/link/child-ops-runners.ts` + no longer contains the sequence or the F2 code list (one implementation, decision 7). +10. A restarting sync that certainly ran nothing reports `restart-not-run`; one whose result is unknown reports + `synced`/`restart-result-unknown` with the second run's `exit`; both exit 6 and neither row carries `executable`. ## Security notes, risks and Windows @@ -408,3 +599,83 @@ requested (004 assumption 2). Windows: sync and `--child` are supported; restart is unproven there). NOT RUN: full `bun run test` (shared host), `bun run lint:i18n` (no `gui/` change). + +## wp4 P re-verification (base dev 4e4d8d4206) + +Checked on `codex/link-child-updates-wp4` at 4e4d8d42061765fe05d1d4a3db8eb52e9e9d1233 (= `origin/dev`, with wp1 #8505, +wp2 #8508 and wp3 #8513 merged). Revision 5 above carries the fixes; this section records what was checked. + +**Stale anchors fixed.** `ChildResult` `sync-fanout.ts:85` → `:11`; status read `:119` → `:34`; exit rule +`:153-156` → `:70-73`; worker loop `:127-147` → `:42-64`; strict 4-key check `sync-fanout.ts:28-40` → moved by wp1 to +`validDocument`, `src/link/sync-fanout-classify.ts:45-57`; Child restart after write `sync-command.ts:237-243` → +`:239-245`; fan-out call `:207-226` → `:207-228`; `registry.ts:186/:190` → `:191/:195`; `layout.json` 1998 → 1999 +lines; `layout-clients.json` 196 and `test-layout-expected.json` 1669 lines; sizes (`sync-command.ts` 245, +`sync-fanout.ts` 78, not 85). Still accurate: `sync-command.ts:18-19`, `:21`, `:213`; `restart-scope.ts:35-37`; +`sync-link-status.ts:12`, `:29`; `capabilities-base.ts:786`, `:790`; `help.ts:52`; `lifecycle.md:592`, `:609-612` +and every locale line pair; `structure/remote-link.md:9`; `05_remote_hub.md:325`; `01_surface_lifecycle.md:110`; +`native/ocx-child/src/cli.rs:1211-1215`; `sync-expectation.ts:21`. + +**Names and types confirmed.** `remotePinnedSyncArgv(linkId, platform = "posix", opts: PinnedSyncOptions = {})` with +`opts.restart?: ChildRestartRequest` and the Windows refusal `LinkSshArgumentError("unsupported:windows-restart")` +(`ssh-argv.ts:478-490`); `classifySyncResult(result, { expectSelectedKind })` returns `ChildVerdict` with optional +`executable: "ocx-child"` only when asked (`sync-fanout-classify.ts:14`, `:63-83`); `child.activity` is +`{ idle, inFlight, lastDataEndAt }` and `child.activeJob` is `{ jobId, op, state } | null` +(`src/cli/link-status-child.ts:15-18`, `:62-66`). Revision 4's draft read `second.executable` from `runChild`, which +wp1's fix now strips at `sync-command.ts:220-222`; the sequence therefore moves below that pick (decision 7). + +**wp1 note folded: already done.** `ChildVerdict.executable` cannot reach `ocx.sync.fanout/1` on the merged base: +`runChild` returns only `{ state, reason, exit }` (`sync-command.ts:220-222`) and `runHomeFanout` rebuilds each row +from schema fields (`sync-fanout.ts:59-61`). wp4 keeps both picks and adds a test with an `executable`-carrying result. + +**Reuse decision.** Decision 7: one implementation, `src/link/sync-then-restart.ts`, extracted from wp3's +`syncWithRestart` and F2 rule and called by both the runners and the CLI `runChild`. It also brings the CLI to wp3's +outcome set, so two outcomes the revision 4 draft folded into a failed row become `restart-not-run` and +`restart-result-unknown` on a `synced` row (the catalog did sync). Decision 8 records the wire's `inFlight: 0` collapse. + +**Earlier decisions kept.** Repeatable `--child`; unknown id exits 2 before the Home sync; unselected rows +`skipped/not-selected` without a hint or exit 6; `--restart-children[=app-server|codex]`, bare = `app-server`, `codex` +needs `--yes`; A2 withholding per link; no new key in the Child's strict 4-key document or in `ocx.sync.fanout/1`; +Windows and `ocx-child` never receive a restart flag; an old Home without `child` withholds; the wording targets. + +**Commands run (this re-verification).** + +- `bun test tests/cli/sync-command-fanout.test.ts tests/cli/sync-link-status.test.ts tests/cli/sync-fanout-process.test.ts tests/cli/cli-json-contract.test.ts tests/cli/sync-fanout.test.ts tests/clients/link-child-ops-runners.test.ts tests/clients/link-pinned-sync-argv.test.ts`: + 117 pass, 2 skip, 0 fail, 466 expect() calls, 119 tests across 7 files, exit 0. +- `bun run typecheck`: "No type errors in 2,402 files", exit 0. +- `rg` wording hits: 6, listed in the Verifiers table. + +NOT RUN: full `bun run test`, `bun run structure:check` and `bun run skill:surface:check` (no source, structure or +capability change in this pass; they run in the wp4 build). + + +## Revision 6 (wp4 plan audit, base 4e4d8d4206) + +Audit verdict GO-WITH-FIXES (blockers 4, no High/Critical). All findings accepted; binding on the implementation: + +1. **Blocker.** Before the second (restarting) `runPinned`, if the shared deadline is spent (`deadline - now <= 0`), the + sequence returns `notRun: true` (restart reason `restart-not-run`), because nothing reached the Child. Test: a first sync + that consumes the deadline yields `restart-not-run`, never `restart-result-unknown`. +2. **Blocker. Default-path change stated honestly.** Every Home fan-out (with or without flags) now reads + `GET /api/link/status?children=1` and skips a Child that holds an active job as `child-busy` (no hint, not exit 6). The + Outcome line and acceptance criterion 1 say so. A `child` block that fails validation is treated as absent for that row + (no busy skip, restarts withheld with `restart-skipped:activity-unknown`), never as a failure of the whole status read; + the other rows are unaffected. Tests: no-flags fan-out skips a busy Child as `child-busy`; a malformed `child` block on + one row leaves the other rows' results unchanged. +3. **Blocker.** `lifecycle.md` (`:600-602`) and `capabilities-base.ts:787` (and the 7 locale copies of lifecycle.md) list + `not-selected` and `child-busy` as intentional skips that do not cause exit 6. +4. **Blocker.** The sync capability gains a `--yes` flag entry; `--child` and `--restart-children` are declared with + `value: "string"`, their summaries stating "repeatable" and "bare means app-server"; registry usage, help line and the + regenerated skill page show `--yes` for `--restart-children=codex`. +5. Low. `child-ops-runners.ts` imports `RESTART_RESULT_UNKNOWN` from `sync-then-restart.ts` (and re-exports only if an + external importer exists; none today). +6. Low. Wording: a link whose `childKind` is not recorded can receive the restart flag on the second sync, which is how the + selected-kind marker is learned; `ocx-child` ignores it (`native/ocx-child/src/cli.rs:1211-1215`). Acceptance criterion 7, + the enforcement table and `structure/remote-link.md:9` say exactly this. +7. Low. Selection is applied first in every branch of `runHomeFanout` (Home sync failed, status unavailable or invalid): + unselected links always report `not-selected`. +8. Low. The `restart-skipped:activity-unknown` hint says the Home could not read the Child's activity (status read failed, + timed out, or the Home predates activity reporting) and to retry `ocx sync --child --restart-children`; it only + suggests updating the Home when the `child` block is absent. +9. Low. `guides/remote-link.md` (Home-side restart paragraph) and its 7 translations mention `ocx sync --restart-children` + as the fan-out form with the same activity guard. + diff --git a/devlog/_plan/261111_link_child_updates/060_dashboard.md b/devlog/_plan/261111_link_child_updates/060_dashboard.md index f0b1b97bccd..3b4a1818af9 100644 --- a/devlog/_plan/261111_link_child_updates/060_dashboard.md +++ b/devlog/_plan/261111_link_child_updates/060_dashboard.md @@ -398,3 +398,9 @@ that it shows the `desktop-app` owner action. GUI parity test's regex is widened for 030's `refusal()` switch, which uses `fail(code, …)`. 3. **Restart scope** is chosen per click in a dialog; the policy panel's `childCodexRestart` only governs the automatic restart (050), so there is no "remember scope" setting. + +## Carried from wp3 review (#8513) + +- Remote Link guide (English + 7 translations): the restart protection paragraph must say to wait for active Codex + turns to finish before a restart, not only pause new requests, because a turn running a tool has no relayed request in + flight (CodeRabbit 4533270262). diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index 74d4430d3c1..5481d18efba 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -98,7 +98,7 @@ Sur le Home, `ocx link check (--link-id | --all)` lit pour chaque enfant la ### Opérer les enfants depuis le Home -`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien ; `restart-codex` et `--restart-codex` exigent `--yes`, tandis que `ocx link sync --link-id --restart-app-server-only` redémarre les app-servers sans confirmation. Le redémarrage est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise après une application réussie, échouée ou annulée ; une application au résultat inconnu n’est ni relancée ni resynchronisée, vérifiez-la avec `ocx link check`. La mise à jour interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sur tout autre Home, ces opérations ne sont pas disponibles, et le tableau de bord ne les propose pas. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). Cette protection est au mieux : une requête qui démarre entre la vérification et le redémarrage peut encore être interrompue ; suspendez les nouvelles requêtes sur cet enfant si ses tours doivent être préservés. +`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien ; `restart-codex` et `--restart-codex` exigent `--yes`, tandis que `ocx link sync --link-id --restart-app-server-only` redémarre les app-servers sans confirmation. Le redémarrage est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise après une application réussie, échouée ou annulée ; une application au résultat inconnu n’est ni relancée ni resynchronisée, vérifiez-la avec `ocx link check`. La mise à jour interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sur tout autre Home, ces opérations ne sont pas disponibles, et le tableau de bord ne les propose pas. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). Cette protection est au mieux : une requête qui démarre entre la vérification et le redémarrage peut encore être interrompue ; suspendez les nouvelles requêtes sur cet enfant si ses tours doivent être préservés. `ocx sync --restart-children` (ou `--restart-children=codex --yes`) applique la même règle lors de la propagation d’une synchronisation du Home, à tous les enfants ou à ceux désignés par `--child ` : chaque enfant est synchronisé, et seul son redémarrage est sauté (`restart-skipped:link-active`) tant qu’une requête relayée est en cours. Ne liez pas une machine qui ne vous appartient pas : le Home y exécute des commandes avec votre clé SSH. diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 60b9764d1b9..e9087143b72 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -163,11 +163,11 @@ La section **OAuth reliability** indique si le stockage des identifiants est acc ## Synchronisation du catalogue -### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` -Sur un Home Remote Link, après la validation et l’enregistrement d’un catalogue à jour, `ocx sync` synchronise aussi les Enfants liés admissibles. `--local-only` désactive cette propagation. `--json` produit un seul document `ocx.sync.fanout/1` contenant le résultat du Home et l’état de chaque Enfant ; un Enfant ignoré indique la raison. Le code de sortie non nul du Home est renvoyé sans modification. Si le Home réussit, un Enfant connecté ignoré pendant la propagation, en échec ou en délai dépassé, ou un registre de liens illisible, entraîne le code de sortie 6. Les exclusions intentionnelles, comme les liens initiés par le client et `--local-only`, conservent leur comportement ; les Enfants connectés ignorés reçoivent une indication `hint`. En mode texte, le registre illisible produit un message sans chemin. Le code 7 est réservé à une mise à jour en cours. Si la synchronisation du Home lève une erreur en mode JSON, stdout conserve un seul document et stderr fournit un diagnostic assaini. +Sur un Home Remote Link, après la validation et l’enregistrement d’un catalogue à jour, `ocx sync` synchronise aussi les Enfants liés admissibles. `--local-only` désactive cette propagation. `--json` produit un seul document `ocx.sync.fanout/1` contenant le résultat du Home et l’état de chaque Enfant ; un Enfant ignoré indique la raison. Le code de sortie non nul du Home est renvoyé sans modification. Si le Home réussit, un Enfant connecté ignoré pendant la propagation, en échec ou en délai dépassé, ou un registre de liens illisible, entraîne le code de sortie 6. Les exclusions intentionnelles, comme les liens initiés par le client et `--local-only`, conservent leur comportement ; les Enfants connectés ignorés reçoivent une indication `hint`. En mode texte, le registre illisible produit un message sans chemin. Le code 7 est réservé à une mise à jour en cours. Si la synchronisation du Home lève une erreur en mode JSON, stdout conserve un seul document et stderr fournit un diagnostic assaini. Les liens exclus par `--child` (`not-selected`) et les Enfants occupés par une opération d’Enfant (`child-busy`) sont aussi des exclusions intentionnelles sans code 6 ; un redémarrage d’Enfant demandé qui n’a pas eu lieu avec certitude produit le code 6. -La propagation ne demande jamais le redémarrage de l’application Codex ni d’un app-server sur un Enfant. Pour cela, utilisez GOAT, exécutez `ocx sync --restart-codex` sur un Enfant `ocx` complet, ou relancez séparément Codex Desktop et les processus `app-server` sur un hôte léger `ocx-child`. La synchronisation épinglée de l’Enfant peut néanmoins redémarrer son propre runtime client OpenCodex après l’application de réglages Home modifiés. Si le Home interrompt son processus SSH local après un délai dépassé, une synchronisation distante déjà lancée peut continuer sur l’Enfant. +La propagation ne demande aucun redémarrage de Codex sur un Enfant, sauf si vous passez `--restart-children` (les app-servers) ou `--restart-children=codex --yes` (l’application Codex Desktop, ce qui met fin aux conversations en cours et nécessite une session de bureau ouverte sur cette machine). Le Home synchronise d’abord chaque Enfant sélectionné, relit l’activité de cet Enfant, puis envoie le redémarrage dans une seconde synchronisation épinglée. Il saute le redémarrage, jamais la synchronisation, pour un Enfant qui relaie une requête de modèle à ce moment (`restart-skipped:link-active`) ou dont il ne peut pas lire l’activité (`restart-skipped:activity-unknown`), pour les Enfants Windows (`restart-skipped:windows`) et pour `ocx-child` (`restart-unsupported:ocx-child`), puis quitte avec le code 6. Un lien dont le type d’Enfant n’est pas enregistré reçoit tout de même l’option de redémarrage lors de cette seconde synchronisation ; c’est ainsi que le Home apprend qu’il exécute `ocx-child`, qui ignore l’option. Si le redémarrage n’a pas pu être envoyé, ou a été envoyé sans résultat connu, la ligne de l’Enfant indique `restart-not-run` ou `restart-result-unknown` et le code de sortie est aussi 6. Comme pour `ocx link restart-codex`, cette protection est au mieux : une requête qui démarre entre cette lecture et le redémarrage peut encore être interrompue. `--child ` (répétable) limite la propagation à ces liens ; les autres sont signalés `not-selected`. Un Enfant occupé par une opération d’Enfant, comme `ocx link update`, est ignoré avec `child-busy`. La synchronisation épinglée de l’Enfant peut néanmoins redémarrer son propre runtime client OpenCodex après l’application de réglages Home modifiés. Si le Home interrompt son processus SSH local après un délai dépassé, une synchronisation distante déjà lancée peut continuer sur l’Enfant. > **Enfant Remote Link :** sur un enfant `ocx` complet, `ocx sync` applique aussi les réglages Codex du Home et redémarre le runtime client s'il a démarré avant cette application ; si ce redémarrage échoue, l'ancien runtime continue de servir et la commande quitte avec un code non nul. Sur un hôte léger `ocx-child`, `ocx-child sync` démarre `ocx-child serve` s'il est absent, le relance lorsque `runtimeStartedAt < lastApplyAt` et publie la parité au mieux hors mode `stale` (un échec de relance arrête l'ancien relais et quitte avec un code non nul sans redémarrer Codex Desktop ni `app-server`, qu'il faut relancer séparément). Les champs de `ocx status --json` (ou `ocx-child status --json`) sur l'enfant et `ocx link status --json` sur le Home sont décrits dans [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (en anglais). Sur le Home, `ocx link check (--link-id | --all)` lit la version installée et la voie de mise à jour de chaque enfant lancé par le Home via `ocx update --plan` par SSH (60 secondes par enfant, sans rien installer), et `ocx link status --json` ajoute un bloc `child` à chaque ligne ; voir [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (en anglais). `ocx link sync`, `ocx link restart-codex --yes` et `ocx link update --yes` lancent sur ces enfants des tâches de synchronisation, de redémarrage de Codex et de mise à jour, et affichent le document `ocx.link.child-job/1` ; voir [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index c2ed4503850..79a367d50ca 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -205,7 +205,7 @@ From the Home you can also sync a Home-initiated Child, restart Codex on it, and - `ocx link restart-codex --link-id --yes` syncs first and then restarts the Child's Codex app-servers, so a running Codex loads the current catalog. `--scope codex` also quits and relaunches the Codex desktop app, which needs a signed-in desktop session on the Child. `ocx link sync --link-id ` with `--restart-app-server-only` or `--restart-codex --yes` does the same. A restart ends the Codex turns running on that Child, so it names exactly one link. It needs 2.93.0 or later. - `ocx link update (--link-id | --all) --yes` brings the Child's OpenCodex to this Home's version, never to another one, through the Child's own updater. The Home first confirms that the Child still belongs to this link, then asks the Child for an update plan, applies it with a deadline counted on the Child's own clock, and syncs again after a successful, failed or rolled-back apply. An apply whose result is unknown is neither retried nor resynced; check that Child with `ocx link check`. npm and pnpm installs are updated through their Node launcher and Bun installs through Bun; `ocx link check` on an npm or pnpm Child therefore also runs `node --version` there, read-only. Updates need 2.97.0 or later on the Child; an older Child receives no update command and reports `owner-action` (`legacy-ocx`). Updates run one Child at a time and restart the Child's OpenCodex, which ends the requests the Home is relaying for it, so `--yes` is required. -The Home does not restart Codex on a Child while it is relaying a model request for that Child. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). This protection is best-effort: a request that starts between that check and the restart can still be ended, so pause new requests on that Child when its turns must survive. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, a new operation that names only that link is refused with the same 409, and a job that names several links reports that link's item as `busy` (`link-operation-in-progress`) while its other links still run. +The Home does not restart Codex on a Child while it is relaying a model request for that Child. `ocx sync --restart-children` (or `--restart-children=codex --yes`) applies the same rule when a Home sync fans out, to every Child or to those named with `--child `: each Child is synced, and only its restart is skipped (`restart-skipped:link-active`) while a relayed request is in flight. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). This protection is best-effort: a request that starts between that check and the restart can still be ended, so pause new requests on that Child when its turns must survive. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, a new operation that names only that link is refused with the same 409, and a job that names several links reports that link's item as `busy` (`link-operation-in-progress`) while its other links still run. The commands exit 0 when every Child succeeded or was already up to date (with `--no-wait`, 0 only means the job was accepted; it may still be running. The printed job carries a `jobId`; read the result with `GET /api/link/children/jobs/` on the Home, using the admin token on loopback. Finished jobs stay readable for one hour, at most 32 of them, and are lost when the Home restarts, so read the result promptly), 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which. The CLI needs a Home bound to loopback; on any other Home these commands are refused (exit 1), and the dashboard has no controls for them. diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 5d4e7f75901..aaebf53f9f3 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -98,7 +98,7 @@ Home で `ocx link check (--link-id | --all)` を実行すると、Home か ### Home から Child を操作する -`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定します。`restart-codex` と `--restart-codex` には `--yes` が必要ですが、`ocx link sync --link-id --restart-app-server-only` は確認なしで app-server を再起動します。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理し、適用が成功、失敗、ロールバックした後に再同期します。結果が不明な適用は再実行も再同期もしないため、`ocx link check` で確認してください。その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではこれらの操作は使えず、ダッシュボードにもその操作はありません。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 この保護はベストエフォートです。確認から再起動までの間に始まったリクエストは終了することがあるため、ターンを残す必要がある場合はその Child で新しいリクエストを止めてください。 +`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定します。`restart-codex` と `--restart-codex` には `--yes` が必要ですが、`ocx link sync --link-id --restart-app-server-only` は確認なしで app-server を再起動します。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理し、適用が成功、失敗、ロールバックした後に再同期します。結果が不明な適用は再実行も再同期もしないため、`ocx link check` で確認してください。その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではこれらの操作は使えず、ダッシュボードにもその操作はありません。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 この保護はベストエフォートです。確認から再起動までの間に始まったリクエストは終了することがあるため、ターンを残す必要がある場合はその Child で新しいリクエストを止めてください。 `ocx sync --restart-children`(または `--restart-children=codex --yes`)は、Home の同期を伝播するときに、すべての Child または `--child ` で指定した Child に同じ規則を適用します。各 Child は同期され、中継中のリクエストがある間は再起動だけがスキップされます(`restart-skipped:link-active`)。 自分が所有していないマシンをリンクしないでください。Home はあなたの SSH 鍵でそのマシン上のコマンドを実行します。 diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index f6d03a00734..b607be527f2 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -170,11 +170,11 @@ ocx status --json ## カタログの同期 -### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` -Remote Link の Home では、最新のカタログが正常にコミットされた後、`ocx sync` は対象となるリンク済み Child にも同期を伝播します。`--local-only` はこの伝播を無効にします。`--json` は Home の結果と各 Child の状態を含む単一の `ocx.sync.fanout/1` 文書を出力し、スキップされた Child の理由も示します。Home 自体の終了コードが 0 以外の場合は、そのコードを変更せずに返します。Home が成功した場合、接続された Child の同期スキップ、失敗、タイムアウト、またはリンクストアの読み取り不能は終了コード 6 となります。クライアント主導リンクや `--local-only` などの意図的なスキップは従来どおりです。スキップされた接続済み Child には対処方法を示す `hint` が付きます。リンクストアを読み取れない場合、テキスト表示ではパスを含まないエラーを出します。コード 7 は更新処理中のために予約されています。JSON モードで Home の同期が例外を送出した場合も、stdout は単一の結果文書を保ち、stderr に無害化された診断を出力します。 +Remote Link の Home では、最新のカタログが正常にコミットされた後、`ocx sync` は対象となるリンク済み Child にも同期を伝播します。`--local-only` はこの伝播を無効にします。`--json` は Home の結果と各 Child の状態を含む単一の `ocx.sync.fanout/1` 文書を出力し、スキップされた Child の理由も示します。Home 自体の終了コードが 0 以外の場合は、そのコードを変更せずに返します。Home が成功した場合、接続された Child の同期スキップ、失敗、タイムアウト、またはリンクストアの読み取り不能は終了コード 6 となります。クライアント主導リンクや `--local-only` などの意図的なスキップは従来どおりです。スキップされた接続済み Child には対処方法を示す `hint` が付きます。リンクストアを読み取れない場合、テキスト表示ではパスを含まないエラーを出します。コード 7 は更新処理中のために予約されています。JSON モードで Home の同期が例外を送出した場合も、stdout は単一の結果文書を保ち、stderr に無害化された診断を出力します。 `--child` で除外したリンク(`not-selected`)と Child 操作中の Child(`child-busy`)も意図的なスキップで、終了コード 6 にはなりません。要求した Child の再起動が確実に行われなかった場合は終了コード 6 になります。 -伝播は Child の Codex アプリや app-server の再起動を要求しません。必要な場合は GOAT を使うか、フル `ocx` Child では `ocx sync --restart-codex` を実行してください(軽量 `ocx-child` Child では Codex デスクトップと `app-server` を別途再起動してください)。ただし、Child の固定先同期は、変更された Home 設定を適用した後、自身の OpenCodex クライアントランタイムを再起動することがあります。Home がタイムアウトしてローカルの SSH プロセスを停止しても、すでに開始されたリモート同期は Child 上で続くことがあります。 +伝播は、`--restart-children`(app-server)または `--restart-children=codex --yes`(Codex デスクトップアプリ。進行中の会話が終了し、そのマシンでサインイン済みのデスクトップセッションが必要です)を指定しない限り、Child の Codex 再起動を要求しません。Home は選択した各 Child をまず同期し、その Child のアクティビティを読み直してから、2 回目の固定先同期で再起動を送ります。その時点でモデルリクエストを中継している Child(`restart-skipped:link-active`)、アクティビティを読み取れない Child(`restart-skipped:activity-unknown`)、Windows Child(`restart-skipped:windows`)、`ocx-child`(`restart-unsupported:ocx-child`)では、同期は行い再起動だけをスキップし、終了コード 6 で終了します。Child の種類が記録されていないリンクにも 2 回目の同期で再起動フラグが送られ、Home はそれによって `ocx-child` であることを知ります。`ocx-child` はこのフラグを無視します。再起動を送れなかった場合や、送ったものの結果が不明な場合、その Child の行は `restart-not-run` または `restart-result-unknown` となり、終了コードは同じく 6 です。`ocx link restart-codex` と同様にこの保護はベストエフォートで、読み取りから再起動までの間に始まったリクエストは中断されることがあります。`--child `(複数指定可)を指定すると、そのリンクにだけ伝播し、他は `not-selected` と報告されます。`ocx link update` などの Child 操作中の Child は `child-busy` としてスキップされます。ただし、Child の固定先同期は、変更された Home 設定を適用した後、自身の OpenCodex クライアントランタイムを再起動することがあります。Home がタイムアウトしてローカルの SSH プロセスを停止しても、すでに開始されたリモート同期は Child 上で続くことがあります。 > **Remote Link Child:** フル `ocx` Child では `ocx sync` が Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は再起動フラグなしでも再起動します。再起動に失敗しても以前のランタイムは動作し続け、コマンドは 0 以外で終了します。軽量 `ocx-child` ホストでは `ocx-child sync` が未起動時に `ocx-child serve` を起動し、`runtimeStartedAt < lastApplyAt` のときにローテーションして非 `stale` 同期時にベストエフォートで parity を報告します(ローテーション失敗時は旧リレー停止後に 0 以外で終了するため再実行で復旧し、Codex デスクトップや `app-server` の再起動は別途行ってください)。Child の `ocx status --json`(または `ocx-child status --json`)と Home の `ocx link status --json` の関連フィールドは [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英語)を参照してください。 Home では `ocx link check (--link-id | --all)` が SSH 経由の `ocx update --plan`(Child ごとに 60 秒、インストールは変更しません)で Home から追加した各 Child のインストール済みバージョンと更新経路を読み取り、`ocx link status --json` は各行に `child` ブロックを追加します。詳しくは [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英語)を参照してください。`ocx link sync`、`ocx link restart-codex --yes`、`ocx link update --yes` はこれらの Child で同期、Codex 再起動、更新のジョブを実行し、`ocx.link.child-job/1` ドキュメントを出力します。詳しくは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index f0ef7dceb59..8cf136a3918 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -98,7 +98,7 @@ Home에서 `ocx link check (--link-id | --all)`를 실행하면 Home이 추 ### Home에서 Child 운영하기 -`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정합니다. `restart-codex`와 `--restart-codex`에는 `--yes`가 필요하지만 `ocx link sync --link-id --restart-app-server-only`는 확인 없이 app-server를 재시작합니다. Home이 그 Child의 모델 요청을 중계하는 동안에는 재시작을 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리하며, 적용이 성공, 실패, 롤백된 뒤에는 다시 동기화합니다. 결과를 알 수 없는 적용은 다시 실행하거나 동기화하지 않으므로 `ocx link check`로 확인하세요. 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요합니다. 그렇지 않은 Home에서는 이 작업을 실행할 수 없으며, 대시보드에도 이 작업은 없습니다. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. 이 보호는 최선의 노력일 뿐입니다. 확인과 재시작 사이에 시작된 요청은 끝날 수 있으므로, 턴을 보존해야 하면 그 Child에서 새 요청을 멈추세요. +`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정합니다. `restart-codex`와 `--restart-codex`에는 `--yes`가 필요하지만 `ocx link sync --link-id --restart-app-server-only`는 확인 없이 app-server를 재시작합니다. Home이 그 Child의 모델 요청을 중계하는 동안에는 재시작을 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리하며, 적용이 성공, 실패, 롤백된 뒤에는 다시 동기화합니다. 결과를 알 수 없는 적용은 다시 실행하거나 동기화하지 않으므로 `ocx link check`로 확인하세요. 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요합니다. 그렇지 않은 Home에서는 이 작업을 실행할 수 없으며, 대시보드에도 이 작업은 없습니다. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. 이 보호는 최선의 노력일 뿐입니다. 확인과 재시작 사이에 시작된 요청은 끝날 수 있으므로, 턴을 보존해야 하면 그 Child에서 새 요청을 멈추세요. `ocx sync --restart-children`(또는 `--restart-children=codex --yes`)는 Home 동기화를 전파할 때 모든 Child 또는 `--child `로 지정한 Child에 같은 규칙을 적용합니다. 각 Child는 동기화되고, 중계 중인 요청이 있는 동안에는 재시작만 건너뜁니다(`restart-skipped:link-active`). 소유하지 않은 컴퓨터는 링크하지 마세요. Home은 그 컴퓨터에서 사용자의 SSH 키로 명령을 실행합니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 383a0a8ee2a..a79f5447739 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -258,11 +258,11 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또 ## 카탈로그 동기화 -### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` -Remote Link Home에서는 최신 카탈로그가 성공적으로 커밋된 뒤 `ocx sync`가 조건을 충족하는 연결된 Child에도 동기화를 전파합니다. `--local-only`는 이 전파를 끕니다. `--json`은 Home 결과와 각 Child의 상태를 담은 단일 `ocx.sync.fanout/1` 문서를 출력하며, 건너뛴 연결된 Child에는 이유와 조치 방법(`hint`)을 표시합니다. Home 자체의 종료 코드가 0이 아니면 그 코드를 그대로 반환합니다. Home이 성공한 경우 연결된 Child의 동기화를 건너뛰거나 Child 실패, 시간 초과 또는 링크 저장소를 읽을 수 없는 경우 종료 코드 6을 반환합니다. 클라이언트 주도 링크와 `--local-only` 등 의도적으로 건너뛰는 경우는 기존 종료 동작을 유지합니다. 링크 저장소를 읽을 수 없으면 텍스트 모드에서 경로가 없는 오류를 출력합니다. 코드 7은 업데이트 진행 중을 위해 예약되어 있습니다. JSON 모드에서 Home 동기화가 예외를 던져도 stdout에는 결과 문서 하나만 출력하고 stderr에는 안전하게 변환된 진단을 출력합니다. +Remote Link Home에서는 최신 카탈로그가 성공적으로 커밋된 뒤 `ocx sync`가 조건을 충족하는 연결된 Child에도 동기화를 전파합니다. `--local-only`는 이 전파를 끕니다. `--json`은 Home 결과와 각 Child의 상태를 담은 단일 `ocx.sync.fanout/1` 문서를 출력하며, 건너뛴 연결된 Child에는 이유와 조치 방법(`hint`)을 표시합니다. Home 자체의 종료 코드가 0이 아니면 그 코드를 그대로 반환합니다. Home이 성공한 경우 연결된 Child의 동기화를 건너뛰거나 Child 실패, 시간 초과 또는 링크 저장소를 읽을 수 없는 경우 종료 코드 6을 반환합니다. 클라이언트 주도 링크와 `--local-only` 등 의도적으로 건너뛰는 경우는 기존 종료 동작을 유지합니다. 링크 저장소를 읽을 수 없으면 텍스트 모드에서 경로가 없는 오류를 출력합니다. 코드 7은 업데이트 진행 중을 위해 예약되어 있습니다. JSON 모드에서 Home 동기화가 예외를 던져도 stdout에는 결과 문서 하나만 출력하고 stderr에는 안전하게 변환된 진단을 출력합니다. `--child`로 제외한 링크(`not-selected`)와 Child 작업이 진행 중인 Child(`child-busy`)도 의도적으로 건너뛴 경우이므로 종료 코드 6이 되지 않습니다. 요청한 Child 재시작이 확실히 이루어지지 않으면 종료 코드 6을 반환합니다. -전파는 Child의 Codex 앱이나 app-server 재시작을 요청하지 않습니다. 재시작이 필요하면 GOAT를 사용하거나, 전체 `ocx` Child에서는 `ocx sync --restart-codex`를 실행하고 경량 `ocx-child` 호스트에서는 Codex 데스크톱 앱과 `app-server`를 별도로 재시작하세요. 다만 Child의 고정 대상 동기화는 변경된 Home 설정을 적용한 뒤 자체 OpenCodex 클라이언트 런타임을 재시작할 수 있습니다. Home이 시간 초과로 로컬 SSH 프로세스를 중지하더라도 이미 시작된 원격 동기화는 Child에서 계속될 수 있습니다. +전파는 `--restart-children`(app-server) 또는 `--restart-children=codex --yes`(Codex 데스크톱 앱. 진행 중인 대화가 끝나며 해당 컴퓨터에 로그인된 데스크톱 세션이 필요합니다)를 지정하지 않는 한 Child의 Codex 재시작을 요청하지 않습니다. Home은 선택한 각 Child를 먼저 동기화하고, 그 Child의 활동을 다시 읽은 뒤에야 두 번째 고정 대상 동기화로 재시작을 보냅니다. 그 순간 모델 요청을 중계 중인 Child(`restart-skipped:link-active`), 활동을 읽을 수 없는 Child(`restart-skipped:activity-unknown`), Windows Child(`restart-skipped:windows`), `ocx-child`(`restart-unsupported:ocx-child`)에서는 동기화는 하되 재시작만 건너뛰고 종료 코드 6으로 끝납니다. Child 종류가 기록되지 않은 링크도 두 번째 동기화에서 재시작 플래그를 받으며, Home은 이를 통해 그 Child가 `ocx-child`를 실행한다는 것을 알게 됩니다. `ocx-child`는 이 플래그를 무시합니다. 재시작을 보내지 못했거나 보냈지만 결과를 알 수 없으면 해당 Child 행은 `restart-not-run` 또는 `restart-result-unknown`이 되고 종료 코드도 6입니다. `ocx link restart-codex`와 마찬가지로 이 보호는 최선의 노력이며, 활동을 읽은 뒤 재시작 전에 시작된 요청은 끊길 수 있습니다. `--child `(반복 가능)를 지정하면 그 링크에만 전파하고 나머지는 `not-selected`로 보고합니다. `ocx link update` 같은 Child 작업이 진행 중인 Child는 `child-busy`로 건너뜁니다. 다만 Child의 고정 대상 동기화는 변경된 Home 설정을 적용한 뒤 자체 OpenCodex 클라이언트 런타임을 재시작할 수 있습니다. Home이 시간 초과로 로컬 SSH 프로세스를 중지하더라도 이미 시작된 원격 동기화는 Child에서 계속될 수 있습니다. > **Remote Link Child:** 전체 `ocx` Child에서는 `ocx sync`가 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 재시작하고 실패하더라도 이전 런타임이 계속 서비스한 채 0이 아닌 코드로 끝납니다. 경량 `ocx-child` 호스트에서는 `ocx-child sync`가 릴레이가 없을 때 `ocx-child serve`를 시작하고 `runtimeStartedAt < lastApplyAt`이면 교체하며 `stale`이 아닌 동기화에서 최선을 다해 parity를 보고합니다(교체 실패 시 이전 릴레이가 멈춘 상태로 0이 아닌 코드로 끝나며, Codex 데스크톱 앱과 장기 실행 `app-server` 프로세스는 별도로 재시작해야 합니다). Child의 `ocx status --json`(또는 `ocx-child status --json`)과 Home의 `ocx link status --json` 필드는 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(영문)를 참고하세요. Home에서는 `ocx link check (--link-id | --all)`가 SSH로 `ocx update --plan`을 실행해(Child마다 60초, 설치는 바꾸지 않음) Home이 추가한 각 Child의 설치 버전과 업데이트 경로를 읽고, `ocx link status --json`은 각 행에 `child` 블록을 추가합니다. 자세한 내용은 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(영문)를 참고하세요. `ocx link sync`, `ocx link restart-codex --yes`, `ocx link update --yes`는 이 Child들에서 동기화, Codex 재시작, 업데이트 작업을 실행하고 `ocx.link.child-job/1` 문서를 출력합니다. 자세한 내용은 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e340e88e78c..e275899089b 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -589,7 +589,7 @@ See [v2 outcomes](/reference/cli/agents/#explicit-v2-target-and-outcome) and for target, scope and spending boundaries. A reset-grant status read can contact upstream status but never consumes or resumes a grant. -### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` On a Remote Link Home, a successfully committed current catalog is also synced to eligible linked Children. `--local-only` disables this fan-out. `--json` prints one `ocx.sync.fanout/1` result with @@ -599,17 +599,32 @@ and an actionable `hint` in JSON and human output. Update OpenCodex on an older use **Check for Updates** in the signed-in Desktop tray. A link mismatch may require re-linking the Child, while an updating Child can be retried after the update. The Home's own nonzero exit code is returned unchanged. When the Home exits 0, a connected Child -skipped during fan-out, a failed or timed-out Child, or an unreadable link store produces exit 6. -Intentional skips such as client-initiated links and `--local-only` keep their existing exit behavior. +skipped during fan-out, a failed or timed-out Child, a requested Child restart that did not certainly +happen, or an unreadable link store produces exit 6. Intentional skips such as client-initiated links, +`--local-only`, links left out by `--child` (`not-selected`) and Children held by a Child operation +(`child-busy`) keep their existing exit behavior. An unreadable link store produces a path-free error in human output. Exit 7 is reserved for an update in flight. If the Home sync throws in JSON mode, stdout still contains one result document and stderr gives a sanitized failure message. -Fan-out never requests a Codex app or app-server restart on a Child. Use GOAT, run -`ocx sync --restart-codex` on a full `ocx` Child, or restart the Codex desktop app and long-lived `app-server` processes separately on a lightweight `ocx-child` Child if a Codex restart is needed. The Child's pinned sync may -still restart its own OpenCodex client runtime after applying changed Home settings. If the Home -times out and stops its local SSH process, a remote sync already running may continue on the Child. +Fan-out requests no Codex restart on a Child unless you pass `--restart-children` (app-servers) or +`--restart-children=codex --yes` (the Codex desktop app, which ends live conversations and needs a +signed-in desktop session on that machine). The Home syncs each selected Child first, reads that +Child's activity again, and only then sends the restart as a second pinned sync. It skips the +restart, never the sync, for a Child that is relaying a model request at that moment +(`restart-skipped:link-active`) or whose activity it cannot read (`restart-skipped:activity-unknown`), +for Windows Children (`restart-skipped:windows`) and for `ocx-child` (`restart-unsupported:ocx-child`), +and then exits 6. A link whose Child kind is not recorded still receives the restart flag on that +second sync, which is how the Home learns that it runs `ocx-child`; `ocx-child` ignores the flag. If +the restart could not be sent, or was sent but its result is unknown, the Child's row reads +`restart-not-run` or `restart-result-unknown` and the exit is also 6. As with +`ocx link restart-codex`, this protection is best-effort: a request that starts between that read and +the restart can still be ended. `--child ` (repeatable) fans out only to those links; the +others are reported `not-selected`. A Child busy with a Child operation such as `ocx link update` is +skipped as `child-busy`. The Child's pinned sync may still restart its own OpenCodex client runtime +after applying changed Home settings. If the Home times out and stops its local SSH process, a remote +sync already running may continue on the Child. If an update is in flight or the shared update/sync mutex cannot be determined, sync changes nothing, reports `update-in-flight`, and exits 7. If another sync holds the mutex, it reports `sync-in-progress` and exits 1; a Home counts that Child as failed and exits 6 when its own sync succeeded. With `--json`, either refusal prints one result document with the Home skipped and the refusal reason in `fanout.reason`. diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index fdba38ccb63..505e41443fb 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### Управление Child с Home -`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку; `restart-codex` и `--restart-codex` требуют `--yes`, а `ocx link sync --link-id --restart-app-server-only` перезапускает app-server без подтверждения. Перезапуск пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, и снова синхронизирует после успешного, неудачного или откатанного применения; применение с неизвестным результатом не повторяется и не синхронизируется, проверьте его через `ocx link check`. Запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; на любом другом Home эти операции недоступны, и в панели управления их нет. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). Эта защита работает по возможности: запрос, начавшийся между проверкой и перезапуском, всё равно может быть прерван, поэтому приостановите новые запросы на этом Child, если его ходы нужно сохранить. +`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку; `restart-codex` и `--restart-codex` требуют `--yes`, а `ocx link sync --link-id --restart-app-server-only` перезапускает app-server без подтверждения. Перезапуск пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, и снова синхронизирует после успешного, неудачного или откатанного применения; применение с неизвестным результатом не повторяется и не синхронизируется, проверьте его через `ocx link check`. Запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; на любом другом Home эти операции недоступны, и в панели управления их нет. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). Эта защита работает по возможности: запрос, начавшийся между проверкой и перезапуском, всё равно может быть прерван, поэтому приостановите новые запросы на этом Child, если его ходы нужно сохранить. `ocx sync --restart-children` (или `--restart-children=codex --yes`) применяет то же правило при рассылке синхронизации Home — ко всем Child или к указанным через `--child `: каждый Child синхронизируется, а пропускается только его перезапуск (`restart-skipped:link-active`), пока выполняется ретранслируемый запрос. Не связывайте машину, которая вам не принадлежит: Home выполняет на ней команды с вашим SSH-ключом. diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index bcfa0a55365..8fca34f7f9d 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -259,11 +259,11 @@ credential'ы и не выполняет repair. ## Синхронизация каталога -### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` -На Home Remote Link после успешной фиксации актуального каталога `ocx sync` также синхронизирует подходящие связанные дочерние машины. `--local-only` отключает эту рассылку. `--json` выводит один документ `ocx.sync.fanout/1` с результатом Home и состоянием каждой дочерней машины; для пропущенной машины указана причина. Ненулевой код выхода самого Home возвращается без изменений. Если Home завершился успешно, пропуск синхронизации подключённой дочерней машины, её сбой или тайм-аут либо недоступное хранилище связей дают код выхода 6. Преднамеренные пропуски, включая связи, созданные клиентом, и `--local-only`, сохраняют прежнее поведение; пропущенная подключённая машина получает подсказку `hint`. В текстовом режиме сообщение о недоступном хранилище не содержит путь. Код 7 зарезервирован для выполняющегося обновления. Если синхронизация Home выбрасывает исключение в режиме JSON, stdout по-прежнему содержит один документ результата, а stderr — безопасно отформатированную диагностику. +На Home Remote Link после успешной фиксации актуального каталога `ocx sync` также синхронизирует подходящие связанные дочерние машины. `--local-only` отключает эту рассылку. `--json` выводит один документ `ocx.sync.fanout/1` с результатом Home и состоянием каждой дочерней машины; для пропущенной машины указана причина. Ненулевой код выхода самого Home возвращается без изменений. Если Home завершился успешно, пропуск синхронизации подключённой дочерней машины, её сбой или тайм-аут либо недоступное хранилище связей дают код выхода 6. Преднамеренные пропуски, включая связи, созданные клиентом, и `--local-only`, сохраняют прежнее поведение; пропущенная подключённая машина получает подсказку `hint`. В текстовом режиме сообщение о недоступном хранилище не содержит путь. Код 7 зарезервирован для выполняющегося обновления. Если синхронизация Home выбрасывает исключение в режиме JSON, stdout по-прежнему содержит один документ результата, а stderr — безопасно отформатированную диагностику. Связи, исключённые через `--child` (`not-selected`), и дочерние машины, занятые операцией Child (`child-busy`), тоже считаются преднамеренными пропусками и не дают кода 6. Запрошенный перезапуск дочерней машины, который достоверно не произошёл, даёт код выхода 6. -Рассылка никогда не запрашивает перезапуск приложения Codex или app-server на дочерней машине. Для этого используйте GOAT, выполните `ocx sync --restart-codex` на полном Child с `ocx` или перезапустите приложение Codex Desktop и процессы `app-server` отдельно на легковесном хосте `ocx-child`. При этом её синхронизация с закреплённым Home может перезапустить собственный клиентский рантайм OpenCodex после применения изменённых настроек Home. Если Home завершит локальный процесс SSH по тайм-ауту, уже начатая удалённая синхронизация может продолжиться на дочерней машине. +Рассылка не запрашивает перезапуск Codex на дочерней машине, если не передан `--restart-children` (процессы app-server) или `--restart-children=codex --yes` (приложение Codex Desktop: это завершает живые разговоры и требует сеанса рабочего стола с выполненным входом на той машине). Home сначала синхронизирует каждую выбранную дочернюю машину, заново считывает её активность и только затем отправляет перезапуск второй закреплённой синхронизацией. Он пропускает перезапуск, но не синхронизацию, для дочерней машины, которая в этот момент ретранслирует запрос к модели (`restart-skipped:link-active`) или чью активность не удалось прочитать (`restart-skipped:activity-unknown`), для дочерних машин Windows (`restart-skipped:windows`) и для `ocx-child` (`restart-unsupported:ocx-child`), после чего завершается с кодом 6. Связь, тип дочерней машины которой не записан, всё же получает флаг перезапуска во второй синхронизации: так Home узнаёт, что там работает `ocx-child`, который этот флаг игнорирует. Если перезапуск не удалось отправить или он отправлен, но его результат неизвестен, строка дочерней машины содержит `restart-not-run` или `restart-result-unknown`, и код выхода также 6. Как и для `ocx link restart-codex`, эта защита работает по возможности: запрос, начавшийся между чтением активности и перезапуском, всё ещё может быть прерван. `--child ` (можно повторять) ограничивает рассылку этими связями; остальные получают `not-selected`. Дочерняя машина, занятая операцией Child, например `ocx link update`, пропускается как `child-busy`. Закреплённая синхронизация дочерней машины всё же может перезапустить её собственный клиентский рантайм OpenCodex после применения изменённых настроек Home. Если Home завершит локальный процесс SSH по тайм-ауту, уже начатая удалённая синхронизация может продолжиться на дочерней машине. > **Дочерняя машина Remote Link:** на полном Child с `ocx` команда `ocx sync` также применяет настройки Codex с Home и перезапускает клиентский рантайм, если он запущен раньше применения; при сбое перезапуска прежний рантайм продолжает работать, а команда завершается с ненулевым кодом. На легковесном хосте `ocx-child` команда `ocx-child sync` запускает `ocx-child serve`, если ретранслятор отсутствует, перезапускает его при `runtimeStartedAt < lastApplyAt` и отправляет отчёт о паритете по принципу best-effort вне режима `stale` (при сбое смены прежний ретранслятор останавливается и команда завершается с ненулевым кодом, а приложение Codex Desktop и долгоживущие процессы `app-server` при необходимости перезапускаются отдельно). Поля `ocx status --json` (или `ocx-child status --json`) на Child и `ocx link status --json` на Home описаны в разделе [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (на английском). На Home команда `ocx link check (--link-id | --all)` считывает установленную версию и путь обновления каждого Child, добавленного с Home, через `ocx update --plan` по SSH (60 секунд на Child, установка не меняется), а `ocx link status --json` добавляет к каждой строке блок `child`; см. [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (на английском). `ocx link sync`, `ocx link restart-codex --yes` и `ocx link update --yes` запускают на этих Child задания синхронизации, перезапуска Codex и обновления и выводят документ `ocx.link.child-job/1`; см. [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index 1a11f5a7766..94f6340756d 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -98,7 +98,7 @@ Home üzerinde `ocx link check (--link-id | --all)`, Home'un eklediği her ### Child'ları Home'dan yönetmek -`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler; `restart-codex` ve `--restart-codex` için `--yes` gerekir, `ocx link sync --link-id --restart-app-server-only` ise app-server süreçlerini onay istemeden yeniden başlatır. Home o Child için bir model isteği aktarırken yeniden başlatma atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir; başarılı, başarısız veya geri alınmış bir uygulamadan sonra yeniden eşitlenir. Sonucu bilinmeyen bir uygulama tekrarlanmaz ve eşitlenmez; `ocx link check` ile denetleyin. O Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; başka bir Home'da bu işlemler kullanılamaz ve panoda da bu işlemler yoktur. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). Bu koruma en iyi çaba esaslıdır: kontrol ile yeniden başlatma arasında başlayan bir istek yine de sonlandırılabilir; turların korunması gerekiyorsa o Child'da yeni istekleri duraklatın. +`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler; `restart-codex` ve `--restart-codex` için `--yes` gerekir, `ocx link sync --link-id --restart-app-server-only` ise app-server süreçlerini onay istemeden yeniden başlatır. Home o Child için bir model isteği aktarırken yeniden başlatma atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir; başarılı, başarısız veya geri alınmış bir uygulamadan sonra yeniden eşitlenir. Sonucu bilinmeyen bir uygulama tekrarlanmaz ve eşitlenmez; `ocx link check` ile denetleyin. O Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; başka bir Home'da bu işlemler kullanılamaz ve panoda da bu işlemler yoktur. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). Bu koruma en iyi çaba esaslıdır: kontrol ile yeniden başlatma arasında başlayan bir istek yine de sonlandırılabilir; turların korunması gerekiyorsa o Child'da yeni istekleri duraklatın. `ocx sync --restart-children` (veya `--restart-children=codex --yes`), bir Home eşitlemesi dağıtılırken aynı kuralı tüm Child'lara ya da `--child ` ile belirtilenlere uygular: her Child eşitlenir ve aktarılan bir istek sürerken yalnızca yeniden başlatması atlanır (`restart-skipped:link-active`). Size ait olmayan bir makineyi bağlamayın: Home orada sizin SSH anahtarınızla komut çalıştırır. diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index c6d6e2a3587..cc207058c17 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -256,11 +256,11 @@ Doctor asla kimlik bilgilerini değiştirmez veya onarımlar uygulamaz. ## Katalog senkronizasyonu -### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` -Remote Link Home üzerinde güncel katalog başarıyla kaydedildikten sonra `ocx sync`, uygun bağlı Child makineleri de eşitler. `--local-only` bu dağıtımı kapatır. `--json`, Home sonucunu ve her Child durumunu içeren tek bir `ocx.sync.fanout/1` belgesi yazdırır; atlanan Child için neden de belirtilir. Home'un kendi sıfırdan farklı çıkış kodu değiştirilmeden döndürülür. Home başarılı olduğunda dağıtım sırasında bağlı bir Child atlanırsa, hata verir veya zaman aşımına uğrarsa ya da bağlantı deposu okunamazsa çıkış kodu 6 üretilir. İstemci tarafından başlatılan bağlantılar ve `--local-only` gibi kasıtlı atlamalar önceki davranışı korur; atlanan bağlı Child için `hint` alanında bir öneri verilir. Metin kipindeki okunamayan depo hatası dosya yolu içermez. Kod 7, sürmekte olan bir güncelleme için ayrılmıştır. JSON kipinde Home eşitlemesi hata fırlatırsa stdout yine tek sonuç belgesi içerir ve stderr güvenli biçimde gösterilen bir tanı verir. +Remote Link Home üzerinde güncel katalog başarıyla kaydedildikten sonra `ocx sync`, uygun bağlı Child makineleri de eşitler. `--local-only` bu dağıtımı kapatır. `--json`, Home sonucunu ve her Child durumunu içeren tek bir `ocx.sync.fanout/1` belgesi yazdırır; atlanan Child için neden de belirtilir. Home'un kendi sıfırdan farklı çıkış kodu değiştirilmeden döndürülür. Home başarılı olduğunda dağıtım sırasında bağlı bir Child atlanırsa, hata verir veya zaman aşımına uğrarsa ya da bağlantı deposu okunamazsa çıkış kodu 6 üretilir. İstemci tarafından başlatılan bağlantılar ve `--local-only` gibi kasıtlı atlamalar önceki davranışı korur; atlanan bağlı Child için `hint` alanında bir öneri verilir. Metin kipindeki okunamayan depo hatası dosya yolu içermez. Kod 7, sürmekte olan bir güncelleme için ayrılmıştır. JSON kipinde Home eşitlemesi hata fırlatırsa stdout yine tek sonuç belgesi içerir ve stderr güvenli biçimde gösterilen bir tanı verir. `--child` ile dışarıda bırakılan bağlantılar (`not-selected`) ve bir Child işlemi tarafından tutulan Child'lar (`child-busy`) da kasıtlı atlamalardır ve çıkış kodu 6 üretmez. İstenen bir Child yeniden başlatması kesin olarak gerçekleşmediğinde çıkış kodu 6 üretilir. -Dağıtım, bir Child üzerinde Codex uygulamasının veya app-server'ın yeniden başlatılmasını asla istemez. Bunun için GOAT kullanın, tam `ocx` Child üzerinde `ocx sync --restart-codex` çalıştırın veya hafif `ocx-child` makinelerinde Codex masaüstü uygulamasını ve `app-server` süreçlerini ayrıca yeniden başlatın. Bununla birlikte Child'ın sabitlenmiş eşitlemesi, değişen Home ayarlarını uyguladıktan sonra kendi OpenCodex istemci çalışma zamanını yeniden başlatabilir. Home zaman aşımında yerel SSH işlemini durdursa bile başlamış bir uzak eşitleme Child üzerinde devam edebilir. +Dağıtım, `--restart-children` (app-server süreçleri) veya `--restart-children=codex --yes` (Codex masaüstü uygulaması; canlı konuşmaları sonlandırır ve o makinede oturum açılmış bir masaüstü oturumu gerektirir) verilmedikçe bir Child üzerinde Codex yeniden başlatması istemez. Home seçilen her Child'ı önce eşitler, o Child'ın etkinliğini yeniden okur ve ancak ondan sonra yeniden başlatmayı ikinci bir sabitlenmiş eşitlemeyle gönderir. O anda bir model isteği aktaran Child (`restart-skipped:link-active`), etkinliği okunamayan Child (`restart-skipped:activity-unknown`), Windows Child'lar (`restart-skipped:windows`) ve `ocx-child` (`restart-unsupported:ocx-child`) için eşitlemeyi değil yalnızca yeniden başlatmayı atlar ve çıkış kodu 6 ile çıkar. Child türü kaydedilmemiş bir bağlantı ikinci eşitlemede yeniden başlatma bayrağını yine alır; Home onun `ocx-child` çalıştırdığını bu şekilde öğrenir ve `ocx-child` bayrağı yok sayar. Yeniden başlatma gönderilemediyse veya gönderildi ama sonucu bilinmiyorsa Child satırı `restart-not-run` ya da `restart-result-unknown` olur ve çıkış kodu yine 6'dır. `ocx link restart-codex` için olduğu gibi bu koruma en iyi çaba esaslıdır: etkinlik okuması ile yeniden başlatma arasında başlayan bir istek yine de sonlandırılabilir. `--child ` (tekrarlanabilir) dağıtımı yalnızca bu bağlantılarla sınırlar; diğerleri `not-selected` olarak bildirilir. `ocx link update` gibi bir Child işlemiyle meşgul olan Child, `child-busy` olarak atlanır. Bununla birlikte Child'ın sabitlenmiş eşitlemesi, değişen Home ayarlarını uyguladıktan sonra kendi OpenCodex istemci çalışma zamanını yeniden başlatabilir. Home zaman aşımında yerel SSH işlemini durdursa bile başlamış bir uzak eşitleme Child üzerinde devam edebilir. > **Remote Link Child:** Tam `ocx` Child makinelerinde `ocx sync` Home'un Codex ayarlarını da uygular ve istemci çalışma zamanı bu uygulamadan önce başlamışsa yeniden başlatır; yeniden başlatma başarısız olursa önceki çalışma zamanı hizmet vermeye devam eder ve komut sıfırdan farklı bir kodla çıkar. Hafif `ocx-child` makinelerinde `ocx-child sync`, röle çalışmıyorsa `ocx-child serve` sürecini başlatır, `runtimeStartedAt < lastApplyAt` olduğunda yeniler ve `stale` olmayan eşitlemelerde en iyi çabayla parite bildirir (yenileme başarısız olursa önceki röle durdurulmuş olarak sıfırdan farklı kodla çıkar; Codex masaüstü uygulaması ve uzun ömürlü `app-server` süreçleri gerekirse ayrıca yeniden başlatılmalıdır). Child üzerindeki `ocx status --json` (veya `ocx-child status --json`) ile Home üzerindeki `ocx link status --json` alanları için [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (İngilizce) bölümüne bakın. Home üzerinde `ocx link check (--link-id | --all)`, Home'un eklediği her Child'ın kurulu sürümünü ve güncelleme yolunu SSH üzerinden `ocx update --plan` ile okur (Child başına 60 saniye, kurulumu değiştirmez); `ocx link status --json` her satıra bir `child` bloğu ekler. Bkz. [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (İngilizce). `ocx link sync`, `ocx link restart-codex --yes` ve `ocx link update --yes` bu Child'larda eşitleme, Codex yeniden başlatma ve güncelleme işleri çalıştırır ve `ocx.link.child-job/1` belgesini yazdırır; bkz. [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index a47079e4350..2c3c241172d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### 从 Home 操作子机 -`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 无需确认即可重启 app-server。当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,应用成功、失败或回滚后会再次同步;结果未知的应用不会重试也不会同步,请用 `ocx link check` 检查。正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;在其他 Home 上无法执行这些操作,仪表盘也不提供这些操作。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保护为尽力而为:在检查与重启之间开始的请求仍可能被结束,因此需要保留轮次时,请先暂停该子机上的新请求。 +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 无需确认即可重启 app-server。当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,应用成功、失败或回滚后会再次同步;结果未知的应用不会重试也不会同步,请用 `ocx link check` 检查。正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;在其他 Home 上无法执行这些操作,仪表盘也不提供这些操作。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保护为尽力而为:在检查与重启之间开始的请求仍可能被结束,因此需要保留轮次时,请先暂停该子机上的新请求。 `ocx sync --restart-children`(或 `--restart-children=codex --yes`)在 Home 同步向外传播时,对所有子机或用 `--child ` 指定的子机应用同一规则:每台子机都会同步,只有在有中继请求进行时才跳过其重启(`restart-skipped:link-active`)。 不要链接不属于你的机器:Home 会用你的 SSH 密钥在那里运行命令。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 8a49ae4e191..f58099b4f2b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -166,11 +166,11 @@ ocx status --json ## 目录同步 -### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` -在 Remote Link Home 上,成功提交最新目录后,`ocx sync` 也会向符合条件的已连接 Child 同步。`--local-only` 可关闭此传播。`--json` 输出一个 `ocx.sync.fanout/1` 文档,包含 Home 的结果和每个 Child 的状态;被跳过的 Child 会注明原因。Home 自身的非零退出码会原样返回。Home 成功时,若传播时跳过已连接的 Child,或 Child 失败、超时或链接存储不可读,命令以退出码 6 结束。客户端发起的链接和 `--local-only` 等有意跳过的情况保留原有行为;被跳过的已连接 Child 会附带操作建议 `hint`。文本模式下,链接存储不可读的错误不包含路径。退出码 7 保留给正在进行的更新。JSON 模式下如果 Home 同步抛出异常,stdout 仍只包含一个结果文档,stderr 则输出经过安全转义的诊断信息。 +在 Remote Link Home 上,成功提交最新目录后,`ocx sync` 也会向符合条件的已连接 Child 同步。`--local-only` 可关闭此传播。`--json` 输出一个 `ocx.sync.fanout/1` 文档,包含 Home 的结果和每个 Child 的状态;被跳过的 Child 会注明原因。Home 自身的非零退出码会原样返回。Home 成功时,若传播时跳过已连接的 Child,或 Child 失败、超时或链接存储不可读,命令以退出码 6 结束。客户端发起的链接和 `--local-only` 等有意跳过的情况保留原有行为;被跳过的已连接 Child 会附带操作建议 `hint`。文本模式下,链接存储不可读的错误不包含路径。退出码 7 保留给正在进行的更新。JSON 模式下如果 Home 同步抛出异常,stdout 仍只包含一个结果文档,stderr 则输出经过安全转义的诊断信息。因 `--child` 未选中的链接(`not-selected`)和正被 Child 操作占用的 Child(`child-busy`)同样属于有意跳过,不会产生退出码 6;请求的 Child 重启未能确定完成时,命令以退出码 6 结束。 -传播不会请求重启 Child 上的 Codex 应用或 app-server。如需重启,请使用 GOAT,在完整 `ocx` Child 上运行 `ocx sync --restart-codex`,或在轻量级 `ocx-child` 主机上单独重启 Codex 桌面应用与 `app-server` 进程。不过,Child 的固定目标同步在应用已更改的 Home 设置后,仍可能重启它自己的 OpenCodex 客户端运行时。Home 超时并停止本地 SSH 进程后,已开始的远程同步仍可能在 Child 上继续。 +除非传入 `--restart-children`(重启 app-server)或 `--restart-children=codex --yes`(重启 Codex 桌面应用,会结束进行中的对话,并需要该机器上已登录的桌面会话),传播不会请求重启 Child 上的 Codex。Home 先同步每个选中的 Child,再重新读取该 Child 的活动状态,然后才通过第二次固定目标同步发送重启。对于此刻正在中继模型请求的 Child(`restart-skipped:link-active`)、无法读取活动状态的 Child(`restart-skipped:activity-unknown`)、Windows Child(`restart-skipped:windows`)以及 `ocx-child`(`restart-unsupported:ocx-child`),Home 照常同步、只跳过重启,并以退出码 6 结束。未记录 Child 类型的链接仍会在第二次同步中收到重启标志,Home 由此得知它运行的是 `ocx-child`;`ocx-child` 会忽略该标志。如果重启未能发送,或已发送但结果未知,该 Child 的行显示 `restart-not-run` 或 `restart-result-unknown`,退出码同样为 6。与 `ocx link restart-codex` 一样,这项保护是尽力而为的:在读取活动状态与重启之间开始的请求仍可能被中断。`--child `(可重复)只向这些链接传播,其余链接报告为 `not-selected`。正被 `ocx link update` 等 Child 操作占用的 Child 会以 `child-busy` 跳过。不过,Child 的固定目标同步在应用已更改的 Home 设置后,仍可能重启它自己的 OpenCodex 客户端运行时。Home 超时并停止本地 SSH 进程后,已开始的远程同步仍可能在 Child 上继续。 > **Remote Link Child:** 在完整 `ocx` Child 上,`ocx sync` 还会应用 Home 的 Codex 设置并在客户端运行时早于该次应用启动时重启它;重启失败时原有运行时继续提供服务并以非零状态退出。在轻量级 `ocx-child` 主机上,`ocx-child sync` 会在中继未运行时启动 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 时轮换进程,并在非 `stale` 同步时尽力上报 parity(若轮换失败,旧中继已停止且命令以非零状态退出;Codex 桌面应用与长期运行的 `app-server` 进程如需刷新请单独重启)。Child 上的 `ocx status --json`(或 `ocx-child status --json`)与 Home 上的 `ocx link status --json` 相关字段见 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 在 Home 上,`ocx link check (--link-id | --all)` 通过 SSH 运行 `ocx update --plan`(每台子机 60 秒,不修改安装)读取每个由 Home 添加的子机的已安装版本和更新途径,`ocx link status --json` 会为每一行添加 `child` 块;详见 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英文)。`ocx link sync`、`ocx link restart-codex --yes` 和 `ocx link update --yes` 会在这些子机上运行同步、Codex 重启和更新任务,并输出 `ocx.link.child-job/1` 文档;详见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index 93f97d3db37..376184340e7 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### 從 Home 操作 Child -`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 不需確認即可重新啟動 app-server。當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,套用成功、失敗或回復後會再次同步;結果未知的套用不會重試也不會同步,請用 `ocx link check` 檢查。正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;在其他 Home 上無法執行這些操作,儀表板也不提供這些操作。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保護僅為盡力而為:在檢查與重新啟動之間開始的請求仍可能被結束,因此需要保留回合時,請先暫停該 Child 上的新請求。 +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 不需確認即可重新啟動 app-server。當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,套用成功、失敗或回復後會再次同步;結果未知的套用不會重試也不會同步,請用 `ocx link check` 檢查。正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;在其他 Home 上無法執行這些操作,儀表板也不提供這些操作。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保護僅為盡力而為:在檢查與重新啟動之間開始的請求仍可能被結束,因此需要保留回合時,請先暫停該 Child 上的新請求。 `ocx sync --restart-children`(或 `--restart-children=codex --yes`)在 Home 同步向外傳播時,對所有 Child 或以 `--child ` 指定的 Child 套用同一規則:每台 Child 都會同步,只有在有中繼請求進行時才略過其重新啟動(`restart-skipped:link-active`)。 不要連結不屬於你的機器:Home 會用你的 SSH 金鑰在那裡執行指令。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index e7629d78ed9..5764f7adf24 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -158,11 +158,11 @@ ocx status --json ## 目錄同步 -### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +### `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` -在 Remote Link Home 上成功提交最新目錄後,`ocx sync` 也會向符合條件的已連結 Child 同步。`--local-only` 可停用這個傳播。`--json` 會輸出單一 `ocx.sync.fanout/1` 文件,包含 Home 的結果與各 Child 的狀態;遭略過的 Child 也會顯示原因。Home 自身的非零結束碼會原樣回傳。Home 成功時,若傳播時略過已連結的 Child,或 Child 失敗、逾時或連結儲存區無法讀取,指令以結束碼 6 結束。用戶端發起的連結和 `--local-only` 等刻意略過的情況保留原有行為;遭略過的已連結 Child 會附帶操作建議 `hint`。文字模式下,連結儲存區無法讀取的錯誤不包含路徑。結束碼 7 保留給進行中的更新。JSON 模式下若 Home 同步擲出例外,stdout 仍只包含一份結果文件,stderr 則輸出經安全轉義的診斷訊息。 +在 Remote Link Home 上成功提交最新目錄後,`ocx sync` 也會向符合條件的已連結 Child 同步。`--local-only` 可停用這個傳播。`--json` 會輸出單一 `ocx.sync.fanout/1` 文件,包含 Home 的結果與各 Child 的狀態;遭略過的 Child 也會顯示原因。Home 自身的非零結束碼會原樣回傳。Home 成功時,若傳播時略過已連結的 Child,或 Child 失敗、逾時或連結儲存區無法讀取,指令以結束碼 6 結束。用戶端發起的連結和 `--local-only` 等刻意略過的情況保留原有行為;遭略過的已連結 Child 會附帶操作建議 `hint`。文字模式下,連結儲存區無法讀取的錯誤不包含路徑。結束碼 7 保留給進行中的更新。JSON 模式下若 Home 同步擲出例外,stdout 仍只包含一份結果文件,stderr 則輸出經安全轉義的診斷訊息。因 `--child` 未選取的連結(`not-selected`)以及正被 Child 操作占用的 Child(`child-busy`)同樣屬於刻意略過,不會產生結束碼 6;要求的 Child 重新啟動未能確定完成時,指令以結束碼 6 結束。 -傳播不會要求重新啟動 Child 上的 Codex 應用程式或 app-server。若需要重新啟動,請使用 GOAT,在完整 `ocx` Child 執行 `ocx sync --restart-codex`,或在輕量級 `ocx-child` 主機上單獨重新啟動 Codex 桌面應用程式與 `app-server` 程序。不過,Child 的固定目標同步在套用變更後的 Home 設定時,仍可能重新啟動它自己的 OpenCodex 用戶端執行階段。Home 逾時並停止本機 SSH 程序後,已開始的遠端同步仍可能在 Child 上繼續。 +除非傳入 `--restart-children`(重新啟動 app-server)或 `--restart-children=codex --yes`(重新啟動 Codex 桌面應用程式,會結束進行中的對話,並需要該機器上已登入的桌面工作階段),傳播不會要求重新啟動 Child 上的 Codex。Home 會先同步每個選取的 Child,再重新讀取該 Child 的活動狀態,然後才透過第二次固定目標同步送出重新啟動。對於此刻正在中繼模型請求的 Child(`restart-skipped:link-active`)、無法讀取活動狀態的 Child(`restart-skipped:activity-unknown`)、Windows Child(`restart-skipped:windows`)以及 `ocx-child`(`restart-unsupported:ocx-child`),Home 照常同步、只略過重新啟動,並以結束碼 6 結束。未記錄 Child 類型的連結仍會在第二次同步中收到重新啟動旗標,Home 藉此得知它執行的是 `ocx-child`;`ocx-child` 會忽略該旗標。如果重新啟動未能送出,或已送出但結果未知,該 Child 的列會顯示 `restart-not-run` 或 `restart-result-unknown`,結束碼同樣為 6。與 `ocx link restart-codex` 相同,這項保護是盡力而為:在讀取活動狀態與重新啟動之間開始的請求仍可能被中斷。`--child `(可重複)只向這些連結傳播,其餘連結回報為 `not-selected`。正被 `ocx link update` 等 Child 操作占用的 Child 會以 `child-busy` 略過。不過,Child 的固定目標同步在套用變更後的 Home 設定時,仍可能重新啟動它自己的 OpenCodex 用戶端執行階段。Home 逾時並停止本機 SSH 程序後,已開始的遠端同步仍可能在 Child 上繼續。 > **Remote Link Child:** 在完整 `ocx` Child 上,`ocx sync` 也會套用 Home 的 Codex 設定並在用戶端執行階段早於該次套用啟動時重新啟動它;重新啟動失敗時原有執行階段會繼續提供服務並以非零狀態結束。在輕量級 `ocx-child` 主機上,`ocx-child sync` 會在中繼未執行時啟動 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 時輪替程序,並在非 `stale` 同步時盡力回報 parity(若輪替失敗,舊中繼已停止且指令以非零狀態結束;Codex 桌面應用程式與長期執行的 `app-server` 程序如需重新載入請單獨重新啟動)。Child 上的 `ocx status --json`(或 `ocx-child status --json`)與 Home 上的 `ocx link status --json` 相關欄位請見 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 在 Home 上,`ocx link check (--link-id | --all)` 透過 SSH 執行 `ocx update --plan`(每台 Child 60 秒,不修改安裝)讀取每個由 Home 新增的 Child 的已安裝版本和更新途徑,`ocx link status --json` 會為每一列加上 `child` 區塊;詳見 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英文)。`ocx link sync`、`ocx link restart-codex --yes` 和 `ocx link update --yes` 會在這些 Child 上執行同步、Codex 重新啟動和更新工作,並輸出 `ocx.link.child-job/1` 文件;詳見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 08c03509129..c392db24cae 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -192,5 +192,7 @@ "link-child-ops-engine.test.ts": "clients", "link-child-ops-runners.test.ts": "clients", "link-child-update-launcher.test.ts": "clients", - "cli-link-child-ops.test.ts": "cli" + "cli-link-child-ops.test.ts": "cli", + "sync-fanout-select-restart.test.ts": "cli", + "sync-command-child-flags.test.ts": "cli" } diff --git a/scripts/test-layout/seeds.json b/scripts/test-layout/seeds.json index 1c356fc21d7..34ba3108f1f 100644 --- a/scripts/test-layout/seeds.json +++ b/scripts/test-layout/seeds.json @@ -116,7 +116,7 @@ "^cline-(?:client|writer)(?:-|[.])", "^opencode-kilo-", "^(?:desktop|omp|pi|prime|remote)-", - "^sync-(?!fanout(?:-process)?\\.test\\.ts$|link-status\\.test\\.ts$|command-fanout\\.test\\.ts$)", + "^sync-(?!fanout(?:-process|-select-restart)?\\.test\\.ts$|link-status\\.test\\.ts$|command-(?:fanout|child-flags)\\.test\\.ts$)", "^command-code-client(?:-|[.])" ] }, diff --git a/skills/ocx/references/01_surface_lifecycle.md b/skills/ocx/references/01_surface_lifecycle.md index ce7372e8b50..a108e285f26 100644 --- a/skills/ocx/references/01_surface_lifecycle.md +++ b/skills/ocx/references/01_surface_lifecycle.md @@ -85,7 +85,7 @@ Drives no management route. ### `ocx sync` -Usage: `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +Usage: `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]` Synchronize client catalogs, including Aside profiles through the running server's mutation owner. @@ -104,11 +104,14 @@ State-changing: yes. JSON mode: `payload`. | `--restart-desktop-app` | boolean | Deprecated alias of --restart-codex. | | `--expect-link` | string | Sync only when this machine is a connected Remote Link Child of this link id; otherwise exit 5 with nothing changed. | | `--expect-hub-key` | string | Sync only when this machine is a hub client connected with this API key id; otherwise exit 5 with nothing changed. | +| `--child` | string | Fan out only to this link id; repeatable. Other links report not-selected. | +| `--restart-children` | string | After each selected Child's sync, restart its Codex: app-server or codex (codex needs --yes); bare means app-server. Withheld per Child while a relayed model request is in flight. | +| `--yes` | boolean | Confirms --restart-children=codex, which quits and relaunches the Codex app and ends live conversations on each selected Child. | - The Aside refresh uses the live server; other catalog synchronization also performs local work. - An update in flight or an unavailable update/sync mutex refuses without changes: update-in-flight, exit 7. Another sync reports sync-in-progress, exit 1. -- A current Home catalog fans out to linked Children. Fan-out never requests a Codex app or app-server restart on a Child; use GOAT or ocx sync --restart-codex there. Applying changed Home settings may restart the Child's own OpenCodex client runtime. -- After a successful Home sync, a connected Child skipped during fan-out, a failed or timed-out Child, or an unreadable link store yields partial exit 6. Skipped connected Children include an actionable hint; update an outdated ocx through its owning installer, with the Desktop tray as an option for Desktop installs. Intentional skips, including client-initiated links and --local-only, keep their existing exit behavior. +- A current Home catalog fans out to linked Children; --child (repeatable) limits it to those links. Fan-out requests no Codex restart on a Child unless --restart-children[=app-server|codex] is given (codex also needs --yes); the Home then skips the restart, never the sync, for a Child with a model request in flight (restart-skipped:link-active), on Windows Children, and on ocx-child, and exits 6. Applying changed Home settings may restart the Child's own OpenCodex client runtime. +- After a successful Home sync, a connected Child skipped during fan-out, a failed or timed-out Child, a synced Child whose requested restart did not certainly happen (restart-skipped:*, restart-unsupported:ocx-child, restart-not-run, restart-result-unknown), or an unreadable link store yields partial exit 6. Skipped connected Children and withheld restarts include an actionable hint; update an outdated ocx through its owning installer, with the Desktop tray as an option for Desktop installs. Intentional skips, including client-initiated links, --local-only, not-selected (outside --child) and child-busy (a Child operation holds the Child), keep their existing exit behavior. - --expect-link/--expect-hub-key are checked under the lock that selects the sync path. A mismatch writes nothing, never falls back to local providers, exits 5, and ends stderr with {"phase":"child_link_mismatch"|"hub_client_mismatch","reason":...}; reasons are disconnected, invalid_state, disconnect_pending, pending_operation, hub_client, link_client, other_link and other_key. Usage errors exit 2. ### `ocx stop` diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 3a133f28280..9b9cd51450a 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -322,9 +322,12 @@ relinked in between, the sync changes nothing and exits 5 with a JSON `phase` li ([04_failure_semantics.md](04_failure_semantics.md#a-pinned-sync-that-refuses-exit-5)). On the Home, `ocx sync` fans out only after it commits a current catalog. Use `--local-only` -to suppress propagation and `--json` to inspect Home and Child results. Fan-out never asks a Child -to restart a Codex app or app-server; use GOAT or `ocx sync --restart-codex` on that Child. Its -pinned sync can still restart its own OpenCodex client runtime after applying changed Home settings. +to suppress propagation, `--json` to inspect Home and Child results, and `--child ` +(repeatable) to fan out only to those links (the others read `not-selected`). Fan-out asks a Child to +restart Codex only with `--restart-children` (app-servers) or `--restart-children=codex --yes`: each +Child is synced first, and its restart, never its sync, is skipped while it relays a model request +(`restart-skipped:link-active`), on Windows and on `ocx-child`, with exit 6. A Child held by a Child +operation is skipped as `child-busy`. Its pinned sync can still restart its own OpenCodex client runtime after applying changed Home settings. A Child sync already running can continue after the Home times out its local SSH process. ### Checking Children from the Home (`ocx link check`) diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index a8b0806f1b5..a57ce86d493 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -777,17 +777,20 @@ export const CAPABILITIES: readonly Capability[] = [ { name: "--restart-desktop-app", value: "boolean", summary: "Deprecated alias of --restart-codex." }, { name: "--expect-link", value: "string", summary: "Sync only when this machine is a connected Remote Link Child of this link id; otherwise exit 5 with nothing changed." }, { name: "--expect-hub-key", value: "string", summary: "Sync only when this machine is a hub client connected with this API key id; otherwise exit 5 with nothing changed." }, + { name: "--child", value: "string", summary: "Fan out only to this link id; repeatable. Other links report not-selected." }, + { name: "--restart-children", value: "string", summary: "After each selected Child's sync, restart its Codex: app-server or codex (codex needs --yes); bare means app-server. Withheld per Child while a relayed model request is in flight." }, + { name: "--yes", value: "boolean", summary: "Confirms --restart-children=codex, which quits and relaunches the Codex app and ends live conversations on each selected Child." }, ], mutates: true, json: "payload", details: [ "The Aside refresh uses the live server; other catalog synchronization also performs local work.", "An update in flight or an unavailable update/sync mutex refuses without changes: update-in-flight, exit 7. Another sync reports sync-in-progress, exit 1.", - "A current Home catalog fans out to linked Children. Fan-out never requests a Codex app or app-server restart on a Child; use GOAT or ocx sync --restart-codex there. Applying changed Home settings may restart the Child's own OpenCodex client runtime.", - "After a successful Home sync, a connected Child skipped during fan-out, a failed or timed-out Child, or an unreadable link store yields partial exit 6. Skipped connected Children include an actionable hint; update an outdated ocx through its owning installer, with the Desktop tray as an option for Desktop installs. Intentional skips, including client-initiated links and --local-only, keep their existing exit behavior.", + "A current Home catalog fans out to linked Children; --child (repeatable) limits it to those links. Fan-out requests no Codex restart on a Child unless --restart-children[=app-server|codex] is given (codex also needs --yes); the Home then skips the restart, never the sync, for a Child with a model request in flight (restart-skipped:link-active), on Windows Children, and on ocx-child, and exits 6. Applying changed Home settings may restart the Child's own OpenCodex client runtime.", + "After a successful Home sync, a connected Child skipped during fan-out, a failed or timed-out Child, a synced Child whose requested restart did not certainly happen (restart-skipped:*, restart-unsupported:ocx-child, restart-not-run, restart-result-unknown), or an unreadable link store yields partial exit 6. Skipped connected Children and withheld restarts include an actionable hint; update an outdated ocx through its owning installer, with the Desktop tray as an option for Desktop installs. Intentional skips, including client-initiated links, --local-only, not-selected (outside --child) and child-busy (a Child operation holds the Child), keep their existing exit behavior.", "--expect-link/--expect-hub-key are checked under the lock that selects the sync path. A mismatch writes nothing, never falls back to local providers, exits 5, and ends stderr with {\"phase\":\"child_link_mismatch\"|\"hub_client_mismatch\",\"reason\":...}; reasons are disconnected, invalid_state, disconnect_pending, pending_operation, hub_client, link_client, other_link and other_key. Usage errors exit 2.", ], - usage: "ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]", + usage: "ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]", }, { command: ["agent","subagents","force"], diff --git a/src/cli/help.ts b/src/cli/help.ts index 712b3279eab..164ccfa30cc 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -51,6 +51,7 @@ Usage: ocx disconnect Restore local state and clear the hub connection ocx sync [--local-only] [--json] [--restart-codex] Sync models and linked Children --expect-link | --expect-hub-key : only from that Home/hub key (exit 5 otherwise) + --child : only those Children; --restart-children[=app-server | =codex --yes]: restart their Codex ocx sync-cache [--restart-codex] Refresh Codex's model cache from the active catalog ocx catalog pull Install a validated remote catalog and refresh the Codex cache diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 3819e1d7965..ed47542b1f4 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -188,11 +188,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "sync", - usage: "ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]", + usage: "ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only] [--child ]... [--restart-children[=app-server] | --restart-children=codex --yes]", summary: "Fetch provider models and inject them into Codex config.", details: [ "A current Home catalog fans out to linked Children; --local-only disables fan-out and --json emits the result. Partial fan-out exits 6.", - "Fan-out never requests a Codex app or app-server restart on a Child; use GOAT or ocx sync --restart-codex on the Child. The Child's pinned sync may restart its OpenCodex client runtime after applying changed Home settings.", + "--child (repeatable) limits fan-out to those links; the others report not-selected. A Child held by a Child operation is skipped as child-busy. Fan-out requests no Codex restart on a Child unless --restart-children[=app-server] or --restart-children=codex --yes is given; the Home then skips the restart, never the sync, for a Child with a model request in flight (restart-skipped:link-active), on Windows Children, and on ocx-child, and exits 6. The Child's pinned sync may restart its OpenCodex client runtime after applying changed Home settings.", "After writing the catalog, warns if long-lived Codex app-server processes are still running.", "An update in flight or an unavailable update/sync mutex refuses without changes: update-in-flight, exit 7. Another sync reports sync-in-progress, exit 1.", "--restart-codex restarts the app-servers AND fully quits and relaunches the Codex desktop app on macOS, Linux and Windows, after catalog/cache writes or a committed configuration change. It ends live conversations.", diff --git a/src/cli/sync-command.ts b/src/cli/sync-command.ts index 4a5dca76927..3c35ff4b4ce 100644 --- a/src/cli/sync-command.ts +++ b/src/cli/sync-command.ts @@ -10,13 +10,23 @@ import { readLinkStore } from "../link/store"; import { linkKnownHostsPath, linkStorePath } from "../link/paths"; import { buildExecArgv, remotePinnedSyncArgv } from "../link/ssh-argv"; import { requireRemotePlatformFramed } from "../link/remote-platform"; +import { pinnedSyncRanNothing, syncThenRestart, type PinnedRun } from "../link/sync-then-restart"; +import type { ChildRestart } from "../link/child-ops-types"; import { FanoutProcessError, fanoutSshRunner, runFanoutProcess } from "./sync-fanout-process"; import { readHomeLinkStatus } from "./sync-link-status"; -import { classifySyncResult, fanoutExitCode, renderFanoutHuman, renderFanoutJson, runHomeFanout, REMOTE_LINK_SYNC_MESSAGE, STALE_REMOTE_CATALOG_MESSAGE, type HomeResult } from "./sync-fanout"; +import { classifySyncResult, fanoutExitCode, renderFanoutHuman, renderFanoutJson, restartNote, runHomeFanout, REMOTE_LINK_SYNC_MESSAGE, STALE_REMOTE_CATALOG_MESSAGE, type ChildResult, type HomeResult } from "./sync-fanout"; export async function runSyncCommand(deps: CliDispatchDeps): Promise { const flags = parseSyncFanoutFlags(deps.args.slice(1)); if (!flags.ok) { console.error(flags.message); return 2; } + if (flags.children) { + // D16: an unknown id is a usage error before the Home sync changes anything. + let known: Set; + try { known = new Set(readLinkStore(linkStorePath()).links.map(link => link.id)); } + catch { console.error("--child cannot be checked: the Remote Link store could not be read."); return 2; } + const unknown = flags.children.find(id => !known.has(id)); + if (unknown) { console.error(`--child ${unknown} is not a link on this Home (see ocx link status).`); return 2; } + } const originalLog = console.log; if (flags.json) console.log = (...args: unknown[]) => console.error(...args); let applicable = false, catalogCurrent = false; @@ -206,8 +216,9 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { if (code !== 0) state = "failed"; const document = await runHomeFanout({ home: { state, exit: code }, catalogCurrent, localOnly: flags.localOnly, applicable, + selected: flags.children === null ? null : new Set(flags.children), restartChildren: flags.restartChildren, readStore: () => readLinkStore(linkStorePath()), readLinkStatus: () => readHomeLinkStatus(), - runChild: async (link, deadline) => { + runChild: async (link, deadline, options) => { const runner = fanoutSshRunner(deadline); let platform: "posix" | "windows"; try { platform = link.remotePlatform ?? await requireRemotePlatformFramed(runner, link.alias, linkKnownHostsPath()); } @@ -215,15 +226,27 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { if (error instanceof FanoutProcessError && (error.reason === "deadline-exceeded" || error.reason === "termination-unconfirmed")) return { state: "timed-out", reason: error.reason, exit: null }; return { state: "failed", reason: "platform-probe-failed", exit: null }; } - try { - const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), argv: remotePinnedSyncArgv(link.id, platform) }), { timeoutMs: Math.max(0, deadline - Date.now()) }); - // Pick the public fields: a verdict key that ocx.sync.fanout/1 does not define never reaches the document. - const { state, reason } = classifySyncResult(result); - return { state, reason, exit: result.code }; - } catch (error) { - if (error instanceof FanoutProcessError) return { state: error.reason === "deadline-exceeded" || error.reason === "termination-unconfirmed" ? "timed-out" : "failed", reason: error.reason, exit: null }; - throw error; - } + type Run = { state: ChildResult["state"]; reason: string | null; exit: number | null; executable?: "ocx-child" }; + const runPinned = async (restart: ChildRestart): Promise> => { + // Revision 6: with the shared deadline spent before the restarting sync, nothing is sent to the Child. + if (restart !== "none" && deadline - Date.now() <= 0) return { sync: { state: "timed-out", reason: "deadline-exceeded", exit: null }, notRun: true }; + try { + const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), + argv: remotePinnedSyncArgv(link.id, platform, restart === "none" ? {} : { restart }) }), { timeoutMs: Math.max(0, deadline - Date.now()) }); + const verdict = classifySyncResult(result, { expectSelectedKind: restart !== "none" }); + return { sync: { ...verdict, exit: result.code }, notRun: pinnedSyncRanNothing(verdict, result) }; + } catch (error) { + if (!(error instanceof FanoutProcessError)) throw error; + const timedOut = error.reason === "deadline-exceeded" || error.reason === "termination-unconfirmed"; + return { sync: { state: timedOut ? "timed-out" : "failed", reason: error.reason, exit: null }, notRun: error.reason === "ssh-spawn-failed" }; + } + }; + const done = await syncThenRestart({ requested: options.restart ?? "none", platform, childKind: link.childKind, + runPinned, readActivity: options.readActivity }); + // Pick the public fields: executable, withheld and second never reach ocx.sync.fanout/1. + const note = restartNote(done); + return note === null ? { state: done.sync.state, reason: done.sync.reason, exit: done.sync.exit } + : { state: "synced", reason: note, exit: done.sync.exit }; }, }); if (flags.json) originalLog(renderFanoutJson(document)); diff --git a/src/cli/sync-fanout.ts b/src/cli/sync-fanout.ts index 9e7ccfd2796..73cfc7e19ab 100644 --- a/src/cli/sync-fanout.ts +++ b/src/cli/sync-fanout.ts @@ -1,4 +1,6 @@ import type { LinkRecord, LinkStore } from "../link/store"; +import type { ChildRestartRequest } from "../link/ssh-argv"; +import type { SyncThenRestart } from "../link/sync-then-restart"; import type { LinkStatusPayload } from "./link"; import { LinkStatusError } from "./sync-link-status"; import { childSkipHint } from "../link/sync-fanout-classify"; @@ -10,11 +12,19 @@ export const SYNC_FANOUT_PARTIAL_EXIT = 6; export type HomeResult = { state: "synced" | "stale" | "skipped" | "failed"; exit: number }; export type ChildResult = { linkId: string; alias: string; state: "synced" | "skipped" | "failed" | "timed-out"; reason: string | null; exit: number | null; hint?: string }; export type FanoutDocument = { schema: "ocx.sync.fanout/1"; home: HomeResult; fanout: { state: "ran" | "disabled" | "not-applicable" | "failed"; reason: string | null }; children: ChildResult[] }; +/** The reason on a synced row whose requested restart did not certainly happen (040 decision 1). Each one exits 6. */ +export type RestartNote = "restart-skipped:link-active" | "restart-skipped:activity-unknown" | "restart-skipped:windows" + | "restart-unsupported:ocx-child" | "restart-not-run" | "restart-result-unknown"; +/** readActivity: a fresh status read for this link; null when unknown (D5: unknown is never idle). */ +export type ChildRunOptions = { restart?: ChildRestartRequest; readActivity: () => Promise<{ inFlight: number } | null> }; export async function runHomeFanout(deps: { home: HomeResult; catalogCurrent: boolean; localOnly?: boolean; applicable?: boolean; + /** null or absent: every link. Otherwise only these ids run; the rest read skipped/not-selected. */ + selected?: ReadonlySet | null; + restartChildren?: ChildRestartRequest | null; readStore: () => LinkStore; readLinkStatus: () => Promise; - runChild: (link: LinkRecord, deadline: number) => Promise>; + runChild: (link: LinkRecord, deadline: number, options: ChildRunOptions) => Promise>; now?: () => number; deadlineMs?: number; concurrency?: number; terminationGraceMs?: number; }): Promise { const result: FanoutDocument = { schema: "ocx.sync.fanout/1", home: deps.home, fanout: { state: deps.localOnly ? "disabled" : "not-applicable", reason: null }, children: [] }; @@ -24,17 +34,21 @@ export async function runHomeFanout(deps: { if (!store.links.length) return result; result.fanout.state = "ran"; const classify = (link: LinkRecord, state: ChildResult["state"], reason: string | null, exit: number | null = null): ChildResult => ({ linkId: link.id, alias: link.alias, state, reason, exit }); - result.children = store.links.map(link => classify(link, "skipped", link.direction === "client-initiated" ? "client-initiated-link" : "home-catalog-not-current")); + // Revision 6: selection is applied first in every branch, so an unselected link always reads not-selected. + const unselected = (link: LinkRecord): boolean => deps.selected != null && !deps.selected.has(link.id); + const rowFor = (link: LinkRecord, state: ChildResult["state"], reason: string | null): ChildResult => + unselected(link) ? classify(link, "skipped", "not-selected") : classify(link, state, reason); + result.children = store.links.map(link => rowFor(link, "skipped", link.direction === "client-initiated" ? "client-initiated-link" : "home-catalog-not-current")); const skipReason = deps.home.state === "failed" || deps.home.exit !== 0 ? "home-sync-failed" : deps.home.state === "skipped" ? "home-sync-skipped" : !deps.catalogCurrent ? "home-catalog-not-current" : null; if (skipReason) { - result.children = store.links.map(link => classify(link, "skipped", link.direction === "client-initiated" ? "client-initiated-link" : skipReason)); + result.children = store.links.map(link => rowFor(link, "skipped", link.direction === "client-initiated" ? "client-initiated-link" : skipReason)); return result; } let status: LinkStatusPayload; try { status = await deps.readLinkStatus(); } catch (error) { const reason = error instanceof LinkStatusError ? error.reason : "home-link-status-unavailable"; - result.children = store.links.map(link => link.direction === "client-initiated" ? classify(link, "skipped", "client-initiated-link") : classify(link, reason === "home-runtime-not-running" ? "skipped" : "failed", reason)); + result.children = store.links.map(link => link.direction === "client-initiated" ? rowFor(link, "skipped", "client-initiated-link") : rowFor(link, reason === "home-runtime-not-running" ? "skipped" : "failed", reason)); return result; } let cursor = 0; @@ -43,21 +57,35 @@ export async function runHomeFanout(deps: { const index = cursor++; const link = store.links[index]; if (!link) return; - if (link.direction === "client-initiated") continue; + if (unselected(link) || link.direction === "client-initiated") continue; const wire = status.links.find(row => row.id === link.id); if (!wire) { result.children[index] = classify(link, "skipped", "link-not-in-runtime"); continue; } if (wire.state !== "connected") { result.children[index] = classify(link, "skipped", `tunnel-${wire.state}`); continue; } + // A Child operation (wp2/wp3 job) holds this Child: an intentional skip, no hint, no exit 6. + if (wire.child?.activeJob) { result.children[index] = classify(link, "skipped", "child-busy"); continue; } + // Decision 3 / 4529522035: a fresh read per Child, never the first one; a failed read or a row without child is unknown. + let unknownCause: ActivityUnknownCause = "read-failed"; + const readActivity = async (): Promise<{ inFlight: number } | null> => { + let fresh: LinkStatusPayload; + try { fresh = await deps.readLinkStatus(); } catch { unknownCause = "read-failed"; return null; } + const row = fresh.links.find(candidate => candidate.id === link.id); + if (!row) { unknownCause = "read-failed"; return null; } + if (!row.child) { unknownCause = "absent"; return null; } + return row.child.activity; + }; + const options: ChildRunOptions = deps.restartChildren ? { restart: deps.restartChildren, readActivity } : { readActivity }; let timer: ReturnType | undefined; try { const budget = deps.deadlineMs ?? 180_000; const child = await Promise.race([ - deps.runChild(link, (deps.now ?? Date.now)() + budget), + deps.runChild(link, (deps.now ?? Date.now)() + budget, options), new Promise>(resolve => { timer = setTimeout(() => resolve({ state: "timed-out", reason: "termination-unconfirmed", exit: null }), budget + (deps.terminationGraceMs ?? 3000)); }), ]); // Build the row from the schema's own fields, so an extra key on a runner's result never reaches ocx.sync.fanout/1. - const hint = child.state === "skipped" ? childSkipHint(child.reason, link.alias) : child.hint; + const hint = child.state === "skipped" ? childSkipHint(child.reason, link.alias) + : child.state === "synced" && child.reason?.startsWith("restart-") ? childRestartHint(child.reason, link, deps.restartChildren ?? "app-server", unknownCause) : child.hint; result.children[index] = { linkId: link.id, alias: link.alias, state: child.state, reason: child.reason, exit: child.exit, ...(hint === undefined ? {} : { hint }) }; } catch { result.children[index] = classify(link, "failed", "internal-error"); } finally { if (timer !== undefined) clearTimeout(timer); } @@ -67,9 +95,46 @@ export async function runHomeFanout(deps: { return result; } +/** Maps the shared sequence's result to a synced row's reason; null keeps the row as the sync reported it. */ +export function restartNote(done: SyncThenRestart<{ state: string; reason: string | null }>): RestartNote | null { + if (!done.restart) return null; + // Reached only after a successful plain sync; done.sync is then the restarting sync's verdict. + if (done.restartUnknown) return "restart-result-unknown"; + // Any failed plain sync keeps its own row, including on Windows or ocx-child (those are checked first). + if (done.sync.state !== "synced") return null; + switch (done.withheld) { + case undefined: case "sync-failed": return null; + case "platform": return "restart-skipped:windows"; + case "ocx-child": return "restart-unsupported:ocx-child"; + case "activity-unknown": return "restart-skipped:activity-unknown"; + case "link-active": return "restart-skipped:link-active"; + case "second-not-run": return "restart-not-run"; + } +} + +type ActivityUnknownCause = "absent" | "read-failed"; + +function childRestartHint(reason: string, link: LinkRecord, restart: ChildRestartRequest, cause: ActivityUnknownCause): string | undefined { + const retry = `ocx sync --child ${link.id} --restart-children${restart === "codex" ? "=codex --yes" : ""}`; + switch (reason as RestartNote) { + case "restart-skipped:link-active": return `A model request was running on ${link.alias}; its Codex was not restarted. Retry when it is idle: ${retry}.`; + case "restart-skipped:activity-unknown": return cause === "absent" + ? `This Home's OpenCodex runtime did not report ${link.alias}'s activity; restart or update it, then retry ${retry}.` + : `The Home could not read ${link.alias}'s activity (the status read failed or timed out); retry ${retry}.`; + case "restart-skipped:windows": return "Codex restarts over SSH are not supported on Windows Children; restart Codex on that machine."; + case "restart-unsupported:ocx-child": return "ocx-child does not restart Codex; restart the Codex app or its app-servers on that machine."; + case "restart-not-run": return `The restart was not sent to ${link.alias}; check its sync status and retry ${retry}.`; + case "restart-result-unknown": return `The restart was sent to ${link.alias} but its result is unknown; check Codex on that machine.`; + default: return undefined; + } +} + export function fanoutExitCode(document: FanoutDocument): number { if (document.home.exit !== 0) return document.home.exit; - return document.fanout.state === "failed" || document.children.some(child => child.state === "failed" || child.state === "timed-out" || (child.state === "skipped" && child.hint !== undefined)) ? SYNC_FANOUT_PARTIAL_EXIT : 0; + return document.fanout.state === "failed" || document.children.some(child => child.state === "failed" || child.state === "timed-out" + || (child.state === "skipped" && child.hint !== undefined) + // A requested restart that did not certainly happen (040 decision 2). + || (child.state === "synced" && child.reason?.startsWith("restart-") === true)) ? SYNC_FANOUT_PARTIAL_EXIT : 0; } export function renderFanoutJson(document: FanoutDocument): string { return JSON.stringify(document); } diff --git a/src/cli/sync-flags.ts b/src/cli/sync-flags.ts index 845b5e8068d..df3bdab9d4f 100644 --- a/src/cli/sync-flags.ts +++ b/src/cli/sync-flags.ts @@ -1,4 +1,13 @@ -export type SyncFanoutFlags = { ok: true; localOnly: boolean; json: boolean } | { ok: false; message: string }; +import type { ChildRestartRequest } from "../link/ssh-argv"; + +export type SyncFanoutFlags = { + ok: true; localOnly: boolean; json: boolean; + /** null: every link (today's behaviour). Otherwise the deduplicated ids given with --child. */ + children: readonly string[] | null; + restartChildren: ChildRestartRequest | null; +} | { ok: false; message: string }; + +const LINK_ID = /^lnk_[0-9a-f]{16}$/; export function parseSyncFanoutFlags(args: readonly string[]): SyncFanoutFlags { for (const flag of ["--local-only", "--json"]) { @@ -6,5 +15,26 @@ export function parseSyncFanoutFlags(args: readonly string[]): SyncFanoutFlags { return { ok: false, message: `ocx sync accepts ${flag} once without a value.` }; } } - return { ok: true, localOnly: args.includes("--local-only"), json: args.includes("--json") }; + const children: string[] = []; + let restart: ChildRestartRequest | null = null, restartSeen = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--child" || arg.startsWith("--child=")) { + const value = arg === "--child" ? args[++i] : arg.slice("--child=".length); + if (value === undefined || !LINK_ID.test(value)) return { ok: false, message: "--child needs a link id of the form lnk_ followed by 16 hex digits (see ocx link status)." }; + if (!children.includes(value)) children.push(value); + } else if (arg === "--restart-children" || arg.startsWith("--restart-children=")) { + if (restartSeen) return { ok: false, message: "ocx sync accepts --restart-children once." }; + restartSeen = true; + const value = arg === "--restart-children" ? "app-server" : arg.slice("--restart-children=".length); + if (value !== "app-server" && value !== "codex") return { ok: false, message: "--restart-children accepts app-server (default) or codex." }; + restart = value; + } + } + const localOnly = args.includes("--local-only"); + if (localOnly && (children.length > 0 || restartSeen)) return { ok: false, message: "--local-only disables fan-out; it cannot be combined with --child or --restart-children." }; + if (restart === "codex" && !args.includes("--yes")) { + return { ok: false, message: "--restart-children=codex quits and relaunches the Codex app on each selected Child and ends its conversations; pass --yes." }; + } + return { ok: true, localOnly, json: args.includes("--json"), children: children.length ? children : null, restartChildren: restart }; } diff --git a/src/cli/sync-link-status.ts b/src/cli/sync-link-status.ts index 6f0888ee986..e3dfd2fc613 100644 --- a/src/cli/sync-link-status.ts +++ b/src/cli/sync-link-status.ts @@ -1,6 +1,7 @@ import { runningProxyUpdateHeaders } from "../oauth/login-cli"; import { runtimeBaseUrl, RuntimeApiError } from "./runtime-api"; import { validateStatus, type LinkStatusPayload } from "./link"; +import { validateChildOps } from "./link-status-child"; export type LinkStatusFailure = "home-runtime-not-running" | "home-link-status-unauthorized" | "home-link-status-timeout" | "home-link-status-unavailable" | "home-link-status-invalid"; export class LinkStatusError extends Error { @@ -26,14 +27,29 @@ export async function readHomeLinkStatus(deadlineMs = 5000, deps: { if (controller.signal.aborted) throw new LinkStatusError("home-link-status-timeout"); if (!base) throw new LinkStatusError("home-runtime-not-running"); let response: Response; - try { response = await (deps.fetchImpl ?? fetch)(`${base}/api/link/status`, { headers: (deps.headers ?? runningProxyUpdateHeaders)(), signal: controller.signal }); } + // wp2: children=1 adds each row's child block (activity, active job); an older Home ignores the query. + try { response = await (deps.fetchImpl ?? fetch)(`${base}/api/link/status?children=1`, { headers: (deps.headers ?? runningProxyUpdateHeaders)(), signal: controller.signal }); } catch (cause) { throw new LinkStatusError("home-link-status-unavailable", { cause }); } if (response.status === 401 || response.status === 403) throw new LinkStatusError("home-link-status-unauthorized"); if (!response.ok) throw new LinkStatusError("home-link-status-unavailable"); let text: string; try { text = await response.text(); } catch (cause) { throw new LinkStatusError("home-link-status-unavailable", { cause }); } - try { return validateStatus(JSON.parse(text)); } catch (cause) { throw new LinkStatusError("home-link-status-invalid", { cause }); } + try { return validateStatus(withoutInvalidChildBlocks(JSON.parse(text))); } catch (cause) { throw new LinkStatusError("home-link-status-invalid", { cause }); } }; try { return await Promise.race([work(), timeout]); } finally { if (timer !== undefined) clearTimeout(timer); controller.abort(); } } + +/** + * 040 Revision 6: a row whose child block fails validation reads as a row without one (no busy skip, restarts withheld + * as activity-unknown); it never fails the whole status read, so the other rows keep their results. + */ +function withoutInvalidChildBlocks(value: unknown): unknown { + if (!value || typeof value !== "object" || !Array.isArray((value as { links?: unknown }).links)) return value; + const links = (value as { links: unknown[] }).links.map((row, index) => { + if (!row || typeof row !== "object" || Array.isArray(row) || !Object.hasOwn(row, "child")) return row; + try { validateChildOps((row as { child: unknown }).child, index); return row; } + catch { const { child: _dropped, ...rest } = row as Record; return rest; } + }); + return { ...value, links }; +} diff --git a/src/link/child-ops-runners.ts b/src/link/child-ops-runners.ts index 8c148124e4a..20c21b2b4b5 100644 --- a/src/link/child-ops-runners.ts +++ b/src/link/child-ops-runners.ts @@ -8,6 +8,7 @@ import type { LinkRecord } from "./store"; import { RESTART_SKIPPED, type ChildJobItem, type ChildOpsEnv, type ChildRestart, type ItemOutcome, type ItemRunner } from "./child-ops-types"; import { withoutCarriedFields } from "./child-observations"; +import { pinnedSyncRanNothing, RESTART_RESULT_UNKNOWN, syncThenRestart } from "./sync-then-restart"; export const SYNC_TIMEOUT_MS = 180_000; /** The --not-after window, counted on the Child's clock from the plan probe (M9). */ @@ -23,8 +24,6 @@ export const APPLY_MUTATION_BOUND_MAX_MS = 900_000; export const POST_UPDATE_SYNC_RETRY_MS = 30_000; /** Revision 4: how long an uncertain update stays recorded for recovery (070 imports it from here). */ export const UNCERTAIN_RECOVERY_WINDOW_MS = 3_600_000; -/** Revision 6 F2: a restarting sync that may have run but whose result is unknown. */ -export const RESTART_RESULT_UNKNOWN = "restart-result-unknown"; const iso = (ms: number): string => new Date(ms).toISOString(); type Platform = "posix" | "windows"; type SyncResult = NonNullable; @@ -94,11 +93,7 @@ async function runPinnedSync(record: LinkRecord, platform: Platform, restart: Ch const sync: SyncResult = classifySyncResult(result, { expectSelectedKind: restart !== "none" }); // Only a synced Home-run sync proves the Child applied the catalog; a GET of /v1/catalog proves only a fetch. if (sync.state === "synced" && etag !== null) env.writeObservation(record.id, { catalogSyncedEtag: etag }); - // F2: the gate refused before selecting an ocx (121, 125, 126, 127 without the selection marker), or the Child's sync - // declined (skipped). A dropped channel (255), a failure after selection or a timeout may have restarted Codex. - const selected = /^ocx-fanout:selected:/m.test(result.stderr); - const notRun = sync.state === "skipped" || (sync.state === "failed" && !selected && [121, 125, 126, 127].includes(result.code)); - return { sync, notRun }; + return { sync, notRun: pinnedSyncRanNothing(sync, result) }; } /** The pinned sync --expect-link of one Child, with an optional restart flag (D13). */ @@ -107,26 +102,16 @@ export async function pinnedSync(record: LinkRecord, platform: Platform, restart } /** - * The pinned sync with an optional restart that obeys A2: unsupported where it cannot run, withheld while busy. - * Revision 4 (4529521916): the catalog sync runs first without a restart; activity is read after it; the restart then - * goes out as a second pinned sync, which is close to a no-op sync. The gap between the activity read and the restart is - * one SSH round trip instead of a whole sync (up to SYNC_TIMEOUT_MS). It cannot be closed from the Home: the restart - * executes on the Child, and holding a relayed request at the Home would not save a turn whose app-server is restarting. + * The pinned sync with an optional restart that obeys A2: unsupported where it cannot run, withheld while busy. The + * sequence lives in sync-then-restart.ts, shared with ocx sync --restart-children. */ async function syncWithRestart(record: LinkRecord, platform: Platform, requested: ChildRestart, env: ChildOpsEnv): Promise<{ sync: SyncResult; restart?: RestartReport; restartUnknown?: true }> { - if (requested === "none") return { sync: await pinnedSync(record, platform, "none", env) }; - const sync = await pinnedSync(record, platform, "none", env); - if (platform !== "posix" || record.childKind === "ephemeral") return { sync, restart: { requested, outcome: "unsupported" } }; - if (sync.state !== "synced") return { sync, restart: { requested, outcome: "not-run" } }; - if (linkBusy(record, env)) return { sync, restart: { requested, outcome: "skipped" } }; - const second = await runPinnedSync(record, platform, requested, env); - // 4529521993: a record without childKind can still select ocx-child, which ignores the flag (010 selected-kind marker). - if (second.sync.executable === "ocx-child") return { sync, restart: { requested, outcome: "unsupported" } }; - if (second.sync.state === "synced") return { sync, restart: { requested, outcome: "requested" } }; - if (second.notRun) return { sync, restart: { requested, outcome: "not-run" } }; - // F2: the restart was sent and its result is unknown; the item reports the second sync beside it. - return { sync: second.sync, restart: { requested, outcome: "requested" }, restartUnknown: true }; + const { sync, restart, restartUnknown } = await syncThenRestart({ requested, platform, childKind: record.childKind, + runPinned: next => runPinnedSync(record, platform, next, env), + // linkBusy's rule: unknown (null) counts as in flight. + readActivity: async () => env.activity(record.id) }); + return { sync, ...(restart ? { restart } : {}), ...(restartUnknown ? { restartUnknown } : {}) }; } export function syncOutcome(sync: SyncResult): ItemOutcome { diff --git a/src/link/sync-then-restart.ts b/src/link/sync-then-restart.ts new file mode 100644 index 00000000000..dc0ccb13c2f --- /dev/null +++ b/src/link/sync-then-restart.ts @@ -0,0 +1,65 @@ +import type { ChildRestart, ChildRestartOutcome } from "./child-ops-types"; +import type { LinkRecord } from "./store"; + +/** + * "Sync then guarded restart" (A2, 040 decision 7): the one implementation used by the Child operation runners + * (child-ops-runners.ts) and by ocx sync --restart-children (src/cli/sync-command.ts). Type-only imports, so the CLI + * pulls nothing else in. The caller supplies the pinned sync and the activity read. + */ + +/** Revision 6 F2 (030): a restarting sync that may have run but whose result is unknown. */ +export const RESTART_RESULT_UNKNOWN = "restart-result-unknown"; + +type Verdict = { state: string; reason: string | null; executable?: "ocx-child" }; +/** One pinned sync: its verdict and whether it certainly ran nothing on the Child (F2). */ +export interface PinnedRun { sync: S; notRun: boolean } +/** Why no restart went out. In process only; never serialized into a job or ocx.sync.fanout/1. */ +export type RestartWithheld = "platform" | "ocx-child" | "sync-failed" | "activity-unknown" | "link-active" | "second-not-run"; + +export interface SyncThenRestart { + /** The plain sync's verdict, or the restarting sync's when restartUnknown. */ + sync: S; + restart?: { requested: "app-server" | "codex"; outcome: ChildRestartOutcome }; + restartUnknown?: true; + withheld?: RestartWithheld; + /** The restarting sync's verdict whenever one ran. */ + second?: S; +} + +/** + * F2: the gate refused before selecting an ocx (121, 125, 126, 127 without the selection marker), or the Child's sync + * declined (skipped). A dropped channel (255), a failure after selection or a timeout may have restarted Codex. + */ +export function pinnedSyncRanNothing(verdict: Verdict, result: { code: number; stderr: string }): boolean { + const selected = /^ocx-fanout:selected:/m.test(result.stderr); + return verdict.state === "skipped" || (verdict.state === "failed" && !selected && [121, 125, 126, 127].includes(result.code)); +} + +/** + * Revision 4 (4529521916): the catalog sync runs first without a restart; activity is read after it; the restart then + * goes out as a second pinned sync, which is close to a no-op sync. The gap between the activity read and the restart is + * one SSH round trip instead of a whole sync. It cannot be closed from the Home: the restart executes on the Child. + */ +export async function syncThenRestart(input: { + requested: ChildRestart; platform: "posix" | "windows"; childKind: LinkRecord["childKind"]; + runPinned: (restart: ChildRestart) => Promise>; + /** Read after the plain sync, immediately before the restart. null is unknown and counts as in flight (D5). */ + readActivity: () => Promise<{ inFlight: number } | null>; +}): Promise> { + const { requested } = input; + const first = (await input.runPinned("none")).sync; + if (requested === "none") return { sync: first }; + if (input.platform !== "posix") return { sync: first, restart: { requested, outcome: "unsupported" }, withheld: "platform" }; + if (input.childKind === "ephemeral") return { sync: first, restart: { requested, outcome: "unsupported" }, withheld: "ocx-child" }; + if (first.state !== "synced") return { sync: first, restart: { requested, outcome: "not-run" }, withheld: "sync-failed" }; + const activity = await input.readActivity(); + if (activity === null) return { sync: first, restart: { requested, outcome: "skipped" }, withheld: "activity-unknown" }; + if (activity.inFlight > 0) return { sync: first, restart: { requested, outcome: "skipped" }, withheld: "link-active" }; + const second = await input.runPinned(requested); + // 4529521993: a record without childKind can still select ocx-child, which ignores the flag (010 selected-kind marker). + if (second.sync.executable === "ocx-child") return { sync: first, restart: { requested, outcome: "unsupported" }, withheld: "ocx-child", second: second.sync }; + if (second.sync.state === "synced") return { sync: first, restart: { requested, outcome: "requested" }, second: second.sync }; + if (second.notRun) return { sync: first, restart: { requested, outcome: "not-run" }, withheld: "second-not-run", second: second.sync }; + // F2: the restart was sent and its result is unknown; the caller reports the second sync beside it. + return { sync: second.sync, restart: { requested, outcome: "requested" }, restartUnknown: true, second: second.sync }; +} diff --git a/structure/cli-management.md b/structure/cli-management.md index a9813656443..e9b43c39a2a 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -6,7 +6,7 @@ ## CLI readiness diagnostics -`src/cli/sync-command.ts` runs the Home sync before calling the fan-out primitives in `src/cli/sync-fanout.ts`, `src/cli/sync-fanout-process.ts` and `src/cli/sync-link-status.ts`. Fan-out requires a committed converged catalog, limits concurrency to three, and reports partial failure as exit 6, including when a connected Child's sync is skipped. Such Child rows add an actionable `hint` to the existing JSON schema and human output; intentional skips retain their exit behavior. An unreadable link store produces a fixed path-free stderr message in human mode. A thrown Home error in JSON mode produces a sanitized stderr diagnostic. The subprocess helper bounds output, treats grace-cancelled pipes as incomplete, and escalates termination; the status helper shares one deadline across discovery and response body. Pinned and connected syncs do not fan out. The per-Child classifier (`classifySyncResult`, `classifyVersionProbe`, `parseOcxVersion`) lives in `src/link/sync-fanout-classify.ts` so the Link engine can use it without importing `src/cli`; `src/cli/sync-fanout.ts` re-exports it. +`src/cli/sync-command.ts` runs the Home sync before calling the fan-out primitives in `src/cli/sync-fanout.ts`, `src/cli/sync-fanout-process.ts` and `src/cli/sync-link-status.ts`. Fan-out requires a committed converged catalog, limits concurrency to three, and reports partial failure as exit 6, including when a connected Child's sync is skipped. Such Child rows add an actionable `hint` to the existing JSON schema and human output; intentional skips retain their exit behavior. An unreadable link store produces a fixed path-free stderr message in human mode. A thrown Home error in JSON mode produces a sanitized stderr diagnostic. The subprocess helper bounds output, treats grace-cancelled pipes as incomplete, and escalates termination; the status helper shares one deadline across discovery and response body. Pinned and connected syncs do not fan out. `--child ` limits fan-out to the named links (`not-selected` for the rest), the status helper reads `/api/link/status?children=1` so a Child held by a Child operation is skipped as `child-busy`, and `--restart-children[=app-server|codex]` adds a guarded Codex restart after each Child's sync through `src/link/sync-then-restart.ts`. The per-Child classifier (`classifySyncResult`, `classifyVersionProbe`, `parseOcxVersion`) lives in `src/link/sync-fanout-classify.ts` so the Link engine can use it without importing `src/cli`; `src/cli/sync-fanout.ts` re-exports it. `src/cli/status.ts` retains connection transport in the status JSON. Link clients identify Remote Link Home in the banner and connection line. They read supported Hub state through the identity-checked local Child without a CLI credential, retain owner-bound cache fallback on transport errors, and report standalone Home role absence without obsolete Hub cache. Hub clients retain their existing remote provider and login report. diff --git a/structure/remote-link.md b/structure/remote-link.md index dd08b6d96b8..bfd7574e84e 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -6,7 +6,7 @@ Signal fencing uses the configuration mutation lock (`C`), yielding 50 ms betwee Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. Only `catalogFingerprint` and `catalogSyncedAt`, updated by normal catalog sync, are excluded from this restore-specific comparison; `priorCatalog` remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start alone does not reapply routing; the operator steps live in the public CLI lifecycle reference. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. -`src/link/ssh-argv.ts` and `src/link/remote-windows.ts` build every gated Child command through `remoteGatedOcxArgv` and `windowsGatedOcxArgv`: the script selects one executable, reads its version inside the remote script, refuses below the command's minimum with a named `ocx-fanout:` marker and exit 121, and only then runs the arguments. Every gated script exports `OCX_NO_DESKTOP_HANDOFF=1` before the version probe and the run, so a package launcher answers for itself and a Desktop handoff cannot pass the gate on behalf of an older launcher. The pinned sync is the 2.93.0 instance and executes `sync --expect-link` without a restart flag; its builder accepts one optional restart flag for later callers and refuses it for a Windows Child (`unsupported:windows-restart`), and no caller passes it yet. The update builders in `src/link/child-gated-ocx.ts` require 2.97.0 inside the script, so the Home never sends `update` to an ocx that reports an older version; they refuse a selected `ocx-child` by file name (`ocx-fanout:child-ocx-ephemeral`) and on POSIX report `ocx-fanout:entry:pair|binary` (Windows reads no census, so its script prints no entry marker and the Home infers `binary`); the plan probe also reports the Child's clock, and the shipped builders refuse Windows apply. A Windows command that would exceed the encoded command-line budget throws `windows-command-too-long`; the plan probe fits a Home version of at least 40 characters. `src/link/child-update-contract.ts` is the Home's own strict copy of the Child's `update.orchestrated/1` plan and result documents and maps every apply exit to a closed verdict. `src/link/child-observations.ts` keeps a tolerant per-link cache in `/link/child-observations.json` beside the strict `links.json`: it drops unknown keys, prunes removed links on write and reads damage as empty. The per-Child sync classifier lives in `src/link/sync-fanout-classify.ts`. The plan probe is wired to the observe operation (`src/link/child-observe.ts`, below), which always passes the resolved platform to `classifyUpdatePlanProbe`; the apply builder and the sync restart flag still have no caller and nothing runs on a timer. The relay, not `onAuthenticatedCatalog` (which fires on admission and keeps driving the supervisor and the enrollment wait), records each Child's catalog pulls. Coverage: `tests/clients/link-gated-ocx-argv.test.ts`, `tests/clients/link-child-update-contract.test.ts`, `tests/clients/link-child-observations.test.ts`. Fan-out platform discovery in `src/link/remote-platform.ts` accepts a Windows marker on any stdout line or one framed uname line, so login-shell output cannot decide the platform. The Home requires one valid selection marker on stderr and one stdout delimiter, and classifies only the text after that delimiter; login-shell output before them is ignored. Oversized version probes are skipped. POSIX version capture and watchdog readiness use a private temporary directory, removed before execution or on refusal. The Home's `src/cli/sync-command.ts` fans out only after its catalog is current; the Child may still restart its own OpenCodex client runtime after applying changed Home settings. +`src/link/ssh-argv.ts` and `src/link/remote-windows.ts` build every gated Child command through `remoteGatedOcxArgv` and `windowsGatedOcxArgv`: the script selects one executable, reads its version inside the remote script, refuses below the command's minimum with a named `ocx-fanout:` marker and exit 121, and only then runs the arguments. Every gated script exports `OCX_NO_DESKTOP_HANDOFF=1` before the version probe and the run, so a package launcher answers for itself and a Desktop handoff cannot pass the gate on behalf of an older launcher. The pinned sync is the 2.93.0 instance and executes `sync --expect-link`; the Home adds `--restart-app-server-only` or `--restart-codex` only for a restarting sync (the Child operation runners below and `ocx sync --restart-children`), never on Windows (the builder refuses it, `unsupported:windows-restart`) or for a link recorded as `ocx-child`, and withholds it for a link whose relayed model traffic is in flight (`src/cli/sync-fanout.ts`, `src/link/sync-then-restart.ts`). A link whose `childKind` is not recorded can receive the flag on that second sync, which is how the selected-kind marker reports `ocx-child`; `ocx-child` ignores it. The update builders in `src/link/child-gated-ocx.ts` require 2.97.0 inside the script, so the Home never sends `update` to an ocx that reports an older version; they refuse a selected `ocx-child` by file name (`ocx-fanout:child-ocx-ephemeral`) and on POSIX report `ocx-fanout:entry:pair|binary` (Windows reads no census, so its script prints no entry marker and the Home infers `binary`); the plan probe also reports the Child's clock, and the shipped builders refuse Windows apply. A Windows command that would exceed the encoded command-line budget throws `windows-command-too-long`; the plan probe fits a Home version of at least 40 characters. `src/link/child-update-contract.ts` is the Home's own strict copy of the Child's `update.orchestrated/1` plan and result documents and maps every apply exit to a closed verdict. `src/link/child-observations.ts` keeps a tolerant per-link cache in `/link/child-observations.json` beside the strict `links.json`: it drops unknown keys, prunes removed links on write and reads damage as empty. The per-Child sync classifier lives in `src/link/sync-fanout-classify.ts`. The plan probe is wired to the observe operation (`src/link/child-observe.ts`, below), which always passes the resolved platform to `classifyUpdatePlanProbe`; the apply builder and the sync restart flag are called from the Child operation runners (below) and `ocx sync --restart-children`, and nothing runs on a timer. The relay, not `onAuthenticatedCatalog` (which fires on admission and keeps driving the supervisor and the enrollment wait), records each Child's catalog pulls. Coverage: `tests/clients/link-gated-ocx-argv.test.ts`, `tests/clients/link-child-update-contract.test.ts`, `tests/clients/link-child-observations.test.ts`. Fan-out platform discovery in `src/link/remote-platform.ts` accepts a Windows marker on any stdout line or one framed uname line, so login-shell output cannot decide the platform. The Home requires one valid selection marker on stderr and one stdout delimiter, and classifies only the text after that delimiter; login-shell output before them is ignored. Oversized version probes are skipped. POSIX version capture and watchdog readiness use a private temporary directory, removed before execution or on refusal. The Home's `src/cli/sync-command.ts` fans out only after its catalog is current; the Child may still restart its own OpenCodex client runtime after applying changed Home settings. `src/link/` owns the building blocks for linking OpenCodex machines over SSH. The pure modules start no process, open no socket and schedule no timer on import. @@ -72,7 +72,7 @@ Applying a link probes the host key into a temporary file, waits for the operato Child operations run as jobs. `src/server/management/link-child-ops-routes.ts` serves `POST /api/link/children/operations` (body exactly `{op, linkIds, restart?}` with `op` one of `observe`, `sync`, `restart-codex`, `update`; `linkIds` is `"all"` or 1 to 64 distinct link ids; `restart` is `app-server` or `codex`, refused on `observe`, defaulting to `app-server` for `restart-codex`, and any request that carries a restart must name its links, so `"all"` with a restart is `400 invalid_body`; `202 {job}`, `400 invalid_body` for any other op or key including `trigger`, `404 link_not_found`, `409 link_not_home_initiated` for a single Child-initiated link outside `observe`, `409 link_operation_in_progress`, `409 no_eligible_links` only when links.json has no link, `409 runtime_role_client` on a Child) and `GET /api/link/children/jobs/{jobId}` (`ljob_` plus 16 hex; `400 invalid_job_id`, `404 job_not_found`). The route always submits `trigger: "manual"`; only the in-process scheduler can submit `auto`. `handleLinkRoutes` delegates every `/api/link/children/` path before its reconnect and DELETE matchers, so `DELETE /api/link/children/x` answers 404 and never removes a link, and `DELETE /api/link/{id}` answers `409 link_operation_in_progress` while a job holds that link; otherwise removal reserves the link in the engine (`reserve`) before its first teardown await and releases it when it ends, so a job submitted during the removal gets 409 for that link alone or a `busy` item, and no job reaches a Child the removal is disconnecting. Both routes take the same principals as probe and apply; the local-control socket still admits only `GET /api/link/status`, so on a non-loopback Home the CLI verbs exit 1 because these operations are not available there (the dashboard has no controls for them). `src/link/child-ops-engine.ts` keeps the `ocx.link.child-job/1` jobs (types in `src/link/child-ops-types.ts`) in one engine per Home process (`sharedChildOpsEngine`; the routes cache it on `LinkRouteState.childOps` and status reads its `activeJob`): one lock per link (a held link makes a single-link request 409 and a multi-link item `busy`, which leaves that link's `lastOperation` to its holder), three items at a time across every job of which at most one is an update (a waiting update does not hold up the items behind it), Child-initiated items of `sync`, `restart-codex` and `update` ended `unsupported`/`child-initiated` at admission, the record re-read before SSH (`refused`/`link-removed` when it vanished), finished jobs kept one hour and at most 32 on each job's own clock, no timer. Each submit carries a `ChildOpsEnv` (store, SSH runner, wire state, activity, Home version, observe, a cache writer that never throws, the Home catalog etag). Items carry closed reason tokens and Home-written hints, never remote text. -`src/link/child-ops-runners.ts` holds the runners. `observe` is `src/link/child-observe.ts`: client-initiated and `ocx-child` links answer without SSH; otherwise one gated `update --plan` (60 s, 64 KiB) classifies the Child, and on an npm or pnpm Child served through its census pair the script also reports whether a Node answers `--version` within 10 s (`ocx-fanout:launcher:available|unavailable`). `sync` sends the pinned `sync --expect-link` (gate 2.93.0, 180 s) and, when the sync reports synced, records the catalog etag read before it as `catalogSyncedEtag`; with a restart it reads the link's activity after that sync and sends the restart as a second pinned sync carrying `--restart-app-server-only` or `--restart-codex`, withheld (`restart.outcome: "skipped"`, reason `restart-skipped:link-active`) while a relayed request is in flight or the activity is unknown. A second sync refused before an ocx was selected, or skipped by the Child, reports `not-run`; one that may have run but failed or timed out ends `uncertain`/`restart-result-unknown`. `restart-codex` is that path with the restart as its purpose: a busy link sends nothing (`busy`), Windows is refused without SSH (`windows-restart`), `ocx-child` reports `unsupported` (the record's `childKind` or the `selected-kind` marker). `update` sends an identity pinned sync first, then a fresh observe (Windows Children too, because `desktop-request` is decided before the `windows-apply` refusal), then the gated apply `update --yes --json --version --expect-plan --not-after ` (gate 2.97.0, bounded by `min(mutationBoundMs, 900 s) + 120 s`, an SSH spawn failure ending `failed`/`ssh-spawn-failed` with nothing to recover, not sent with under 15 s of the window left), through ` /bin/ocx.mjs` with `OCX_UPDATE_ORCHESTRATED_CHILD` unset only when the plan's installer is npm or pnpm (a missing Node exits 121 `child-node-unavailable`, owner action `launcher-node-missing`), and a pinned sync after every outcome that may have stopped the Child's proxy (success, failure, roll-back); the target is always the Home's version, exit 11 records `suppressedTarget` and a manual update clears it, an uncertain apply records `pendingUpdateRecovery` for an hour and is never re-sent. The Windows gated builder builds only `update --plan`. A legacy key admission, which is how a Child older than 2.69 reaches the Home, counts as activity only from its response headers, so a non-streaming request from such a Child is invisible to the restart guard while it runs. An item that ran writes its observation fields and then `lastOperation` to `child-observations.json`. `src/cli/link-child-ops.ts` (`runLinkCheck`, `runLinkChildOp`, the tolerant `validateChildJob`) polls every 2 s with a per-op item budget (observe 120 s, sync and restart-codex 420 s, update 2 100 s) that does not run while an item is queued, under a 3-hour cap, and exits 0, 6 for a partial result (including a success with a reason or a restart that did not go out), 1 for a failure, an uncertain item or an unknown item state, 4 for an unknown link or (with its own message) a Home without the route or, for a non-observe op, one that answers `400 invalid_body`, 5 for a conflict. `ocx link update` needs `--yes`, as does any restart of scope `codex` and `restart-codex` itself. Coverage: `tests/server/link-child-ops-routes.test.ts`, `tests/clients/link-child-observe.test.ts`, `tests/clients/link-child-ops-engine.test.ts`, `tests/clients/link-child-ops-runners.test.ts`, `tests/clients/link-child-update-launcher.test.ts`, `tests/cli/cli-link-check.test.ts`, `tests/cli/cli-link-child-ops.test.ts`. +`src/link/child-ops-runners.ts` holds the runners. The sync-then-restart sequence both the runners and `ocx sync --restart-children` use is `src/link/sync-then-restart.ts`. `observe` is `src/link/child-observe.ts`: client-initiated and `ocx-child` links answer without SSH; otherwise one gated `update --plan` (60 s, 64 KiB) classifies the Child, and on an npm or pnpm Child served through its census pair the script also reports whether a Node answers `--version` within 10 s (`ocx-fanout:launcher:available|unavailable`). `sync` sends the pinned `sync --expect-link` (gate 2.93.0, 180 s) and, when the sync reports synced, records the catalog etag read before it as `catalogSyncedEtag`; with a restart it reads the link's activity after that sync and sends the restart as a second pinned sync carrying `--restart-app-server-only` or `--restart-codex`, withheld (`restart.outcome: "skipped"`, reason `restart-skipped:link-active`) while a relayed request is in flight or the activity is unknown. A second sync refused before an ocx was selected, or skipped by the Child, reports `not-run`; one that may have run but failed or timed out ends `uncertain`/`restart-result-unknown`. `restart-codex` is that path with the restart as its purpose: a busy link sends nothing (`busy`), Windows is refused without SSH (`windows-restart`), `ocx-child` reports `unsupported` (the record's `childKind` or the `selected-kind` marker). `update` sends an identity pinned sync first, then a fresh observe (Windows Children too, because `desktop-request` is decided before the `windows-apply` refusal), then the gated apply `update --yes --json --version --expect-plan --not-after ` (gate 2.97.0, bounded by `min(mutationBoundMs, 900 s) + 120 s`, an SSH spawn failure ending `failed`/`ssh-spawn-failed` with nothing to recover, not sent with under 15 s of the window left), through ` /bin/ocx.mjs` with `OCX_UPDATE_ORCHESTRATED_CHILD` unset only when the plan's installer is npm or pnpm (a missing Node exits 121 `child-node-unavailable`, owner action `launcher-node-missing`), and a pinned sync after every outcome that may have stopped the Child's proxy (success, failure, roll-back); the target is always the Home's version, exit 11 records `suppressedTarget` and a manual update clears it, an uncertain apply records `pendingUpdateRecovery` for an hour and is never re-sent. The Windows gated builder builds only `update --plan`. A legacy key admission, which is how a Child older than 2.69 reaches the Home, counts as activity only from its response headers, so a non-streaming request from such a Child is invisible to the restart guard while it runs. An item that ran writes its observation fields and then `lastOperation` to `child-observations.json`. `src/cli/link-child-ops.ts` (`runLinkCheck`, `runLinkChildOp`, the tolerant `validateChildJob`) polls every 2 s with a per-op item budget (observe 120 s, sync and restart-codex 420 s, update 2 100 s) that does not run while an item is queued, under a 3-hour cap, and exits 0, 6 for a partial result (including a success with a reason or a restart that did not go out), 1 for a failure, an uncertain item or an unknown item state, 4 for an unknown link or (with its own message) a Home without the route or, for a non-observe op, one that answers `400 invalid_body`, 5 for a conflict. `ocx link update` needs `--yes`, as does any restart of scope `codex` and `restart-codex` itself. Coverage: `tests/server/link-child-ops-routes.test.ts`, `tests/clients/link-child-observe.test.ts`, `tests/clients/link-child-ops-engine.test.ts`, `tests/clients/link-child-ops-runners.test.ts`, `tests/clients/link-child-update-launcher.test.ts`, `tests/cli/cli-link-check.test.ts`, `tests/cli/cli-link-child-ops.test.ts`. ## Client link transport diff --git a/tests/cli/sync-command-child-flags.test.ts b/tests/cli/sync-command-child-flags.test.ts new file mode 100644 index 00000000000..9e30169e636 --- /dev/null +++ b/tests/cli/sync-command-child-flags.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parseSyncFanoutFlags } from "../../src/cli/sync-flags"; +import { readRestartScope } from "../../src/cli/restart-scope"; +import { runSyncCommand } from "../../src/cli/sync-command"; +import type { CliDispatchDeps } from "../../src/cli/dispatch"; +import * as codexSync from "../../src/codex/sync"; +import * as shim from "../../src/cli/codex-shim-autorestore"; +import * as statusApi from "../../src/cli/sync-link-status"; +import * as processApi from "../../src/cli/sync-fanout-process"; + +const A = "lnk_0123456789abcdef"; +const OK = { code: 0, stdout: "ocx-fanout:stdout-begin\nRemote Link Home catalog synchronized.", stderr: "ocx-fanout:selected:2.93.0\n" }; + +describe("parseSyncFanoutFlags", () => { + test("--child and --restart-children", () => { + expect(parseSyncFanoutFlags(["--child", A])).toEqual({ ok: true, localOnly: false, json: false, children: [A], restartChildren: null }); + expect(parseSyncFanoutFlags(["--child=" + A, "--child", A])).toMatchObject({ ok: true, children: [A] }); + expect(parseSyncFanoutFlags([])).toMatchObject({ ok: true, children: null, restartChildren: null }); + expect(parseSyncFanoutFlags(["--restart-children"])).toMatchObject({ ok: true, restartChildren: "app-server" }); + expect(parseSyncFanoutFlags(["--restart-children=app-server"])).toMatchObject({ ok: true, restartChildren: "app-server" }); + expect(parseSyncFanoutFlags(["--restart-children=codex", "--yes"])).toMatchObject({ ok: true, restartChildren: "codex" }); + for (const bad of [["--child", "lnk_123"], ["--child"], ["--child="], ["--restart-children=desktop"], + ["--restart-children", "--restart-children"], ["--local-only", "--child", A], ["--local-only", "--restart-children"]]) { + expect(parseSyncFanoutFlags(bad).ok).toBe(false); + } + const codex = parseSyncFanoutFlags(["--restart-children=codex"]); + expect(codex.ok).toBe(false); + if (!codex.ok) expect(codex.message).toContain("--yes"); + }); + + test("the Home's own restart scope ignores --restart-children", () => { + const log = { error: () => {} }; + expect(readRestartScope(["--restart-children=codex", "--yes"], log)).toEqual({ appServers: false, desktopApp: false }); + expect(readRestartScope(["--restart-children"], log)).toEqual({ appServers: false, desktopApp: false }); + }); +}); + +const temps: string[] = []; +const restores: Array<() => void> = []; +afterEach(() => { + for (const restore of restores.splice(0).reverse()) restore(); + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function home(links: Array>) { + const temp = mkdtempSync(join(tmpdir(), "ocx-sync-child-flags-")); temps.push(temp); + const previous = { home: process.env.OPENCODEX_HOME, codex: process.env.CODEX_HOME, exit: process.exitCode }; + process.env.OPENCODEX_HOME = join(temp, "ocx"); process.env.CODEX_HOME = join(temp, "codex"); process.exitCode = 0; + restores.push(() => { + if (previous.home === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous.home; + if (previous.codex === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previous.codex; + process.exitCode = previous.exit ?? 0; + }); + mkdirSync(join(temp, "ocx", "link"), { recursive: true }); + writeFileSync(join(temp, "ocx", "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai" })); + const records = links.map(extra => ({ id: A, alias: "fake", direction: "hub-initiated", hostKeyFingerprint: "SHA256:AAAAAAAAAAAAAAAA", tunnelPort: 27001, apiKeyId: "test", createdAt: "2026-01-01T00:00:00Z", ...extra })); + writeFileSync(join(temp, "ocx", "link", "links.json"), JSON.stringify({ version: 1, listenerPort: 27000, links: records })); + const base = { status: "catalog-only" as const, ok: true, added: 0, catalogPath: "fixture", catalogExists: true, catalogWritten: false, cacheSynced: false, message: "done", refreshOutcome: "committed" as const }; + const sync = spyOn(codexSync, "syncModelsToCodex").mockResolvedValue(base); + const restore = spyOn(shim, "runCodexShimAutoRestoreAsync").mockResolvedValue(undefined as never); + const output: string[] = [], errors: string[] = []; + const log = spyOn(console, "log").mockImplementation((...parts) => { output.push(parts.map(String).join(" ")); }); + const error = spyOn(console, "error").mockImplementation((...parts) => { errors.push(parts.map(String).join(" ")); }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + restores.push(() => { sync.mockRestore(); restore.mockRestore(); log.mockRestore(); error.mockRestore(); warn.mockRestore(); }); + return { sync, output, errors, records }; +} + +const activity = (inFlight: number, activeJob: unknown = null) => ({ + homeVersion: "2.99.0", runningVersion: null, installedVersion: null, installKind: null, versionSkew: "unknown", + update: { route: null, reason: null, observedAt: null }, catalog: { state: "unknown", pulledAt: null, homeChangedAt: null }, + codex: null, activity: { idle: inFlight === 0, inFlight, lastDataEndAt: null }, activeJob, lastOperation: null, +}); + +async function fanout(args: string[], opts: { link?: Record; reads: Array; runs: Array; afterFirstRun?: () => void }) { + const h = home([{ remotePlatform: "posix", ...opts.link }]); + let reads = 0; + const statusSpy = spyOn(statusApi, "readHomeLinkStatus").mockImplementation(async () => { + const child = opts.reads[Math.min(reads++, opts.reads.length - 1)]; + if (child instanceof Error) throw child; + const record = h.records[0]!; + return { role: "home", listener: { state: "listening", port: 27000 }, child: null, + links: [{ ...record, state: "connected", since: record.createdAt, reason: null, lastApplyAt: null, runtimeStartedAt: null, appliedCodexSettingsDigest: null, ...(child ? { child } : {}) }] } as never; + }); + const commands: string[] = []; + const processSpy = spyOn(processApi, "runFanoutProcess").mockImplementation(async argv => { + commands.push(argv.at(-1)!); + const next = opts.runs.shift(); + if (!next) throw new Error("unexpected Child process"); + if (commands.length === 1) opts.afterFirstRun?.(); + return next; + }); + restores.push(() => { statusSpy.mockRestore(); processSpy.mockRestore(); }); + const code = await runSyncCommand({ args: ["sync", "--json", ...args], findLiveProxy: async () => null } as unknown as CliDispatchDeps); + const document = JSON.parse(h.output.at(-1)!) as { children: Array> }; + return { code, row: document.children[0]!, commands, reads, sync: h.sync }; +} + +describe("ocx sync runChild with --child and --restart-children", () => { + test("an unknown --child exits 2 before the Home sync", async () => { + const h = home([{}]); + const code = await runSyncCommand({ args: ["sync", "--child", "lnk_ffffffffffffffff"], findLiveProxy: async () => null } as unknown as CliDispatchDeps); + expect(code).toBe(2); + expect(h.sync).not.toHaveBeenCalled(); + expect(h.errors.join("\n")).toContain("lnk_ffffffffffffffff"); + }); + + test("--restart-children=codex without --yes exits 2 before the Home sync", async () => { + const h = home([{}]); + const code = await runSyncCommand({ args: ["sync", "--restart-children=codex"], findLiveProxy: async () => null } as unknown as CliDispatchDeps); + expect(code).toBe(2); + expect(h.sync).not.toHaveBeenCalled(); + expect(h.errors.join("\n")).toContain("--yes"); + }); + + test("without the new flags: one plain pinned sync and one status read", async () => { + const run = await fanout([], { reads: [activity(0)], runs: [OK] }); + expect(run).toMatchObject({ code: 0, row: { state: "synced", reason: null, exit: 0 }, reads: 1 }); + expect(run.commands).toHaveLength(1); + expect(run.commands[0]).not.toContain("--restart-"); + }); + + test("a busy Child is skipped without SSH even without flags", async () => { + const run = await fanout([], { reads: [activity(0, { jobId: "job_1", op: "update", state: "running" })], runs: [] }); + expect(run).toMatchObject({ code: 0, row: { state: "skipped", reason: "child-busy", exit: null } }); + expect(run.commands).toEqual([]); + }); + + test("idle POSIX Child: plain sync, then a restarting sync", async () => { + const appServer = await fanout(["--child", A, "--restart-children"], { reads: [activity(0)], runs: [OK, OK] }); + expect(appServer).toMatchObject({ code: 0, row: { state: "synced", reason: null, exit: 0 }, reads: 2 }); + expect(appServer.commands[0]).not.toContain("--restart-"); + expect(appServer.commands[1]).toContain("--restart-app-server-only"); + expect(appServer.commands[1]).not.toContain("--restart-codex"); + const codex = await fanout(["--restart-children=codex", "--yes"], { reads: [activity(0)], runs: [OK, OK] }); + expect(codex.code).toBe(0); + expect(codex.commands[1]).toContain("--restart-codex"); + expect(codex.commands[1]).not.toContain("--restart-app-server-only"); + }); + + test("a relayed request in flight withholds the restart, never the sync", async () => { + const run = await fanout(["--restart-children"], { reads: [activity(0), activity(1)], runs: [OK] }); + expect(run).toMatchObject({ code: 6, row: { state: "synced", reason: "restart-skipped:link-active", exit: 0 } }); + expect(run.commands).toHaveLength(1); + }); + + test("a failed activity re-read withholds the restart", async () => { + const run = await fanout(["--restart-children"], { reads: [activity(0), new statusApi.LinkStatusError("home-link-status-timeout")], runs: [OK] }); + expect(run).toMatchObject({ code: 6, row: { state: "synced", reason: "restart-skipped:activity-unknown" } }); + expect(run.commands).toHaveLength(1); + }); + + test("a Windows Child gets the plain sync only", async () => { + const run = await fanout(["--restart-children"], { link: { remotePlatform: "windows" }, reads: [activity(0)], runs: [OK] }); + expect(run).toMatchObject({ code: 6, row: { state: "synced", reason: "restart-skipped:windows" } }); + expect(run.commands).toHaveLength(1); + expect(run.commands[0]).not.toContain("--restart-"); + }); + + test("ocx-child selected by the restarting sync is unsupported", async () => { + const run = await fanout(["--restart-children"], { reads: [activity(0)], runs: [OK, { ...OK, stderr: "ocx-fanout:selected:2.93.0\nocx-fanout:selected-kind:ocx-child\n" }] }); + expect(run).toMatchObject({ code: 6, row: { state: "synced", reason: "restart-unsupported:ocx-child", exit: 0 } }); + expect(run.row).not.toHaveProperty("executable"); + }); + + test("a restarting sync that certainly ran nothing reports restart-not-run", async () => { + const run = await fanout(["--restart-children"], { reads: [activity(0)], runs: [OK, { code: 121, stdout: "", stderr: "warning\n" }] }); + expect(run).toMatchObject({ code: 6, row: { state: "synced", reason: "restart-not-run", exit: 0 } }); + }); + + test("a restarting sync with an unknown result reports restart-result-unknown with its exit", async () => { + const run = await fanout(["--restart-children"], { reads: [activity(0)], runs: [OK, { code: 255, stdout: "", stderr: "" }] }); + expect(run).toMatchObject({ code: 6, row: { state: "synced", reason: "restart-result-unknown", exit: 255 } }); + expect(run.row).not.toHaveProperty("executable"); + }); + + test("a spent deadline before the restarting sync sends nothing and reports restart-not-run", async () => { + let now: ReturnType | undefined; + const real = Date.now; + const run = await fanout(["--restart-children"], { reads: [activity(0)], runs: [OK, OK], afterFirstRun: () => { + const later = real() + 3_600_000; + now = spyOn(Date, "now").mockImplementation(() => later); + } }).finally(() => now?.mockRestore()); + expect(run).toMatchObject({ code: 6, row: { state: "synced", reason: "restart-not-run", exit: 0 } }); + expect(run.commands).toHaveLength(1); + }); +}); + +describe("readHomeLinkStatus", () => { + const wireRow = (id: string, child: unknown) => ({ id, alias: id, direction: "hub-initiated", state: "connected", since: "2026-01-01T00:00:00Z", reason: null, tunnelPort: 27001, child }); + test("asks for Child activity and drops only a malformed child block", async () => { + const urls: string[] = []; + const body = { role: "home", listener: { state: "listening", port: 27000 }, child: null, + links: [wireRow("lnk_aaaaaaaaaaaaaaaa", activity(1)), wireRow("lnk_bbbbbbbbbbbbbbbb", { ...activity(0), activity: { idle: "no" } })] }; + const payload = await statusApi.readHomeLinkStatus(5000, { baseUrl: async () => "http://127.0.0.1:1", headers: () => new Headers(), + fetchImpl: (async (url: string) => { urls.push(String(url)); return new Response(JSON.stringify(body)); }) as unknown as typeof fetch }); + expect(urls).toEqual(["http://127.0.0.1:1/api/link/status?children=1"]); + expect(payload.links[0]!.child?.activity.inFlight).toBe(1); + expect(payload.links[1]).not.toHaveProperty("child"); + expect(payload.links[1]!.id).toBe("lnk_bbbbbbbbbbbbbbbb"); + }); +}); diff --git a/tests/cli/sync-fanout-select-restart.test.ts b/tests/cli/sync-fanout-select-restart.test.ts new file mode 100644 index 00000000000..9746c61b9f7 --- /dev/null +++ b/tests/cli/sync-fanout-select-restart.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { fanoutExitCode, restartNote, runHomeFanout, type ChildRunOptions, type RestartNote } from "../../src/cli/sync-fanout"; +import { LinkStatusError } from "../../src/cli/sync-link-status"; +import type { LinkStatusPayload } from "../../src/cli/link"; +import type { LinkRecord, LinkStore } from "../../src/link/store"; +import type { SyncThenRestart } from "../../src/link/sync-then-restart"; + +const A = "lnk_aaaaaaaaaaaaaaaa", B = "lnk_bbbbbbbbbbbbbbbb", C = "lnk_cccccccccccccccc"; +const link = (id: string, alias: string, extra: Partial = {}): LinkRecord => ({ id, alias, direction: "hub-initiated", + hostKeyFingerprint: "SHA256:AAAAAAAAAAAAAAAA", tunnelPort: 27001, apiKeyId: "k", createdAt: "2026-01-01T00:00:00Z", ...extra }) as LinkRecord; +const childBlock = (inFlight: number, activeJob: { jobId: string; op: string; state: "queued" | "running" } | null = null) => ({ + homeVersion: "2.99.0", runningVersion: null, installedVersion: null, installKind: null, versionSkew: "unknown", + update: { route: null, reason: null, observedAt: null }, catalog: { state: "unknown", pulledAt: null, homeChangedAt: null }, + codex: null, activity: { idle: inFlight === 0, inFlight, lastDataEndAt: null }, activeJob, lastOperation: null, +}); +const row = (record: LinkRecord, child?: ReturnType) => ({ id: record.id, alias: record.alias, direction: record.direction, + state: "connected", since: record.createdAt, reason: null, tunnelPort: 27001, lastApplyAt: null, runtimeStartedAt: null, + appliedCodexSettingsDigest: null, ...(child ? { child } : {}) }); +const status = (rows: ReturnType[]): LinkStatusPayload => ({ role: "home", listener: { state: "listening", port: 27000 }, child: null, links: rows }) as unknown as LinkStatusPayload; +const store = (links: LinkRecord[]): LinkStore => ({ version: 1, listenerPort: 27000, links }) as LinkStore; +type Row = Awaited[0]["runChild"]>>; + +function harness(links: LinkRecord[], reads: Array, run: (record: LinkRecord, options: ChildRunOptions) => Promise = async () => ({ state: "synced", reason: null, exit: 0 })) { + const calls: Array<{ id: string; options: ChildRunOptions }> = []; + let statusReads = 0; + const deps = { + home: { state: "synced" as const, exit: 0 }, catalogCurrent: true, readStore: () => store(links), + readLinkStatus: async () => { const next = reads[Math.min(statusReads++, reads.length - 1)]!; if (next instanceof Error) throw next; return next; }, + runChild: async (record: LinkRecord, _deadline: number, options: ChildRunOptions) => { calls.push({ id: record.id, options }); return run(record, options); }, + }; + return { deps, calls, reads: () => statusReads }; +} + +describe("ocx sync --child selection", () => { + test("--child runs only the selected link", async () => { + const a = link(A, "alpha"), b = link(B, "beta"); + const h = harness([a, b], [status([row(a), row(b)])]); + const document = await runHomeFanout({ ...h.deps, selected: new Set([A]) }); + expect(h.calls.map(call => call.id)).toEqual([A]); + expect(document.children[1]).toEqual({ linkId: B, alias: "beta", state: "skipped", reason: "not-selected", exit: null }); + expect(fanoutExitCode(document)).toBe(0); + }); + + test("selection wins in every branch, including client-initiated links and a failed Home or status read", async () => { + const a = link(A, "alpha"), b = link(B, "beta"), c = link(C, "gamma", { direction: "client-initiated" }); + const failedHome = harness([a, b, c], [status([row(a), row(b)])]); + const home = await runHomeFanout({ ...failedHome.deps, home: { state: "failed", exit: 1 }, selected: new Set([A]) }); + expect(home.children.map(child => child.reason)).toEqual(["home-sync-failed", "not-selected", "not-selected"]); + const unreadable = harness([a, b, c], [new LinkStatusError("home-link-status-invalid")]); + const invalid = await runHomeFanout({ ...unreadable.deps, selected: new Set([A]) }); + expect(invalid.children.map(child => [child.state, child.reason])).toEqual([["failed", "home-link-status-invalid"], ["skipped", "not-selected"], ["skipped", "not-selected"]]); + const notCurrent = await runHomeFanout({ ...harness([a, b], [status([])]).deps, catalogCurrent: false, selected: new Set([B]) }); + expect(notCurrent.children.map(child => child.reason)).toEqual(["not-selected", "home-catalog-not-current"]); + expect(fanoutExitCode(invalid)).toBe(6); + }); + + test("a Child held by a Child operation is skipped as child-busy without flags, SSH or exit 6", async () => { + const a = link(A, "alpha"), b = link(B, "beta"); + const h = harness([a, b], [status([row(a, childBlock(0, { jobId: "job_1", op: "update", state: "running" })), row(b, childBlock(0))])]); + const document = await runHomeFanout(h.deps); + expect(document.children[0]).toEqual({ linkId: A, alias: "alpha", state: "skipped", reason: "child-busy", exit: null }); + expect(h.calls.map(call => call.id)).toEqual([B]); + expect(fanoutExitCode(document)).toBe(0); + }); +}); + +describe("ocx sync --restart-children fan-out", () => { + test("the restart request reaches runChild; without it there is no restart and no second status read", async () => { + const a = link(A, "alpha"); + const asked = harness([a], [status([row(a, childBlock(0))])]); + await runHomeFanout({ ...asked.deps, restartChildren: "codex" }); + expect(asked.calls[0]!.options.restart).toBe("codex"); + const plain = harness([a], [status([row(a, childBlock(0))])]); + await runHomeFanout(plain.deps); + expect(plain.calls[0]!.options.restart).toBeUndefined(); + expect(plain.reads()).toBe(1); + }); + + test("readActivity reads status afresh for that link", async () => { + const a = link(A, "alpha"); + let seen: unknown; + const h = harness([a], [status([row(a, childBlock(0))]), status([row(a, childBlock(1))])], async (_record, options) => { + seen = await options.readActivity(); return { state: "synced", reason: null, exit: 0 }; + }); + await runHomeFanout({ ...h.deps, restartChildren: "app-server" }); + expect(seen).toMatchObject({ inFlight: 1 }); + expect(h.reads()).toBe(2); + }); + + test("a row without child and a failed re-read are both unknown, with different hints", async () => { + const a = link(A, "alpha"); + const results: unknown[] = []; + const unknownRow = async (_record: LinkRecord, options: ChildRunOptions): Promise => { + results.push(await options.readActivity()); return { state: "synced", reason: "restart-skipped:activity-unknown", exit: 0 }; + }; + const absent = await runHomeFanout({ ...harness([a], [status([row(a, childBlock(0))]), status([row(a)])], unknownRow).deps, restartChildren: "app-server" }); + const failed = await runHomeFanout({ ...harness([a], [status([row(a, childBlock(0))]), new LinkStatusError("home-link-status-timeout")], unknownRow).deps, restartChildren: "codex" }); + expect(results).toEqual([null, null]); + expect(absent.children[0]!.hint).toContain("did not report"); + expect(absent.children[0]!.hint).toContain("ocx sync --child " + A + " --restart-children"); + expect(failed.children[0]!.hint).toContain("could not read"); + expect(failed.children[0]!.hint).toContain("ocx sync --child " + A + " --restart-children=codex --yes"); + expect(failed.children[0]!.hint).not.toContain("update"); + expect(fanoutExitCode(absent)).toBe(6); + }); + + const notes: RestartNote[] = ["restart-skipped:link-active", "restart-skipped:activity-unknown", "restart-skipped:windows", + "restart-unsupported:ocx-child", "restart-not-run", "restart-result-unknown"]; + test.each(notes)("%s on a synced row carries a hint and exits 6", async note => { + const a = link(A, "alpha"); + const h = harness([a], [status([row(a, childBlock(0))])], async () => ({ state: "synced", reason: note, exit: 0 })); + const document = await runHomeFanout({ ...h.deps, restartChildren: "app-server" }); + expect(document.children[0]).toMatchObject({ state: "synced", reason: note }); + expect(typeof document.children[0]!.hint).toBe("string"); + expect(fanoutExitCode(document)).toBe(6); + }); + + test("a synced row without a restart note exits 0 and runner-only keys never reach the document", async () => { + const a = link(A, "alpha"); + const h = harness([a], [status([row(a, childBlock(0))])], async () => ({ state: "synced", reason: null, exit: 0, executable: "ocx-child" }) as Row); + const document = await runHomeFanout({ ...h.deps, restartChildren: "app-server" }); + expect(fanoutExitCode(document)).toBe(0); + for (const child of document.children) { + expect(Object.keys(child).every(key => ["linkId", "alias", "state", "reason", "exit", "hint"].includes(key))).toBe(true); + } + }); +}); + +describe("restartNote", () => { + type Done = SyncThenRestart<{ state: string; reason: string | null }>; + const synced = { state: "synced", reason: null }; + const failed = { state: "failed", reason: "child-sync-failed" }; + const r = (outcome: "requested" | "unsupported" | "not-run" | "skipped") => ({ requested: "app-server" as const, outcome }); + test.each<[string, Done, RestartNote | null]>([ + ["no restart requested", { sync: synced }, null], + ["restart requested", { sync: synced, restart: r("requested"), second: synced }, null], + ["windows", { sync: synced, restart: r("unsupported"), withheld: "platform" }, "restart-skipped:windows"], + ["ocx-child", { sync: synced, restart: r("unsupported"), withheld: "ocx-child" }, "restart-unsupported:ocx-child"], + ["plain sync failed", { sync: failed, restart: r("not-run"), withheld: "sync-failed" }, null], + ["failed plain sync on windows", { sync: failed, restart: r("unsupported"), withheld: "platform" }, null], + ["failed plain sync on ephemeral", { sync: failed, restart: r("unsupported"), withheld: "ocx-child" }, null], + ["activity unknown", { sync: synced, restart: r("skipped"), withheld: "activity-unknown" }, "restart-skipped:activity-unknown"], + ["link active", { sync: synced, restart: r("skipped"), withheld: "link-active" }, "restart-skipped:link-active"], + ["second ran nothing", { sync: synced, restart: r("not-run"), withheld: "second-not-run", second: failed }, "restart-not-run"], + ["result unknown", { sync: failed, restart: r("requested"), restartUnknown: true, second: failed }, "restart-result-unknown"], + ])("%s", (_name, done, expected) => { + expect(restartNote(done)).toBe(expected); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 88c69f93cdb..7e5eae03bd1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1665,5 +1665,7 @@ "link-child-ops-engine.test.ts": "clients", "link-child-ops-runners.test.ts": "clients", "link-child-update-launcher.test.ts": "clients", - "cli-link-child-ops.test.ts": "cli" + "cli-link-child-ops.test.ts": "cli", + "sync-fanout-select-restart.test.ts": "cli", + "sync-command-child-flags.test.ts": "cli" }