Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/desktop/build/bin/kanban
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RESOURCES_DIR="$(dirname "$SCRIPT_DIR")"
CLI_ENTRY="$RESOURCES_DIR/app.asar.unpacked/cli/cli.js"

# Runtime-update override. The desktop orchestrator pre-validates the
# path; we fail loudly on a missing file rather than silently falling
# back, so the parent's rollback bookkeeping stays in sync with what
# actually ran.
if [ -n "$KANBAN_CLI_OVERRIDE" ]; then
if [ ! -f "$KANBAN_CLI_OVERRIDE" ]; then
echo "error: KANBAN_CLI_OVERRIDE points to missing file: $KANBAN_CLI_OVERRIDE" >&2
exit 1
fi
CLI_ENTRY="$KANBAN_CLI_OVERRIDE"
fi

if [ ! -f "$CLI_ENTRY" ]; then
echo "error: Kanban CLI not found at $CLI_ENTRY" >&2
exit 1
Expand Down
11 changes: 11 additions & 0 deletions packages/desktop/build/bin/kanban.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ set "SCRIPT_DIR=%~dp0"
set "RESOURCES_DIR=%SCRIPT_DIR%.."
set "CLI_ENTRY=%RESOURCES_DIR%\app.asar.unpacked\cli\cli.js"

REM Runtime-update override (see POSIX shim for rationale): fail loud
REM on missing file rather than silently falling back to bundled.
if defined KANBAN_CLI_OVERRIDE (
if not exist "%KANBAN_CLI_OVERRIDE%" (
echo error: KANBAN_CLI_OVERRIDE points to missing file: %KANBAN_CLI_OVERRIDE% >&2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The echo line expands %KANBAN_CLI_OVERRIDE% without quoting. On Windows, if the value contains shell metacharacters (&, |, >, <), cmd.exe splits the line at those characters and executes the trailing token as a separate command. The POSIX shim double-quotes $KANBAN_CLI_OVERRIDE at every expansion site; the same caution applies here.

Suggested change
echo error: KANBAN_CLI_OVERRIDE points to missing file: %KANBAN_CLI_OVERRIDE% >&2
echo error: KANBAN_CLI_OVERRIDE points to missing file: "%KANBAN_CLI_OVERRIDE%" >&2
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/desktop/build/bin/kanban.cmd
Line: 18

Comment:
The `echo` line expands `%KANBAN_CLI_OVERRIDE%` without quoting. On Windows, if the value contains shell metacharacters (`&`, `|`, `>`, `<`), cmd.exe splits the line at those characters and executes the trailing token as a separate command. The POSIX shim double-quotes `$KANBAN_CLI_OVERRIDE` at every expansion site; the same caution applies here.

```suggestion
    echo error: KANBAN_CLI_OVERRIDE points to missing file: "%KANBAN_CLI_OVERRIDE%" >&2
```

How can I resolve this? If you propose a fix, please make it concise.

endlocal
exit /b 1
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The endlocal call here is inconsistent with every other early-exit path in this script. The parallel error paths (e.g., "Kanban CLI not found") do not call endlocal before exit /b 1, and neither does the POSIX shim have an equivalent. Removing it keeps all error exits uniform.

Suggested change
endlocal
exit /b 1
exit /b 1
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/desktop/build/bin/kanban.cmd
Line: 19-20

Comment:
The `endlocal` call here is inconsistent with every other early-exit path in this script. The parallel error paths (e.g., "Kanban CLI not found") do not call `endlocal` before `exit /b 1`, and neither does the POSIX shim have an equivalent. Removing it keeps all error exits uniform.

```suggestion
    exit /b 1
```

How can I resolve this? If you propose a fix, please make it concise.

)
set "CLI_ENTRY=%KANBAN_CLI_OVERRIDE%"
)

REM Windows packaged layout:
REM Kanban\resources\bin\kanban.cmd (this file)
REM RESOURCES_DIR = Kanban\resources
Expand Down
8 changes: 8 additions & 0 deletions packages/desktop/src/runtime-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export interface RuntimeChildManagerOptions {
* Node process, so generous headroom matters for multi-agent workloads.
*/
maxOldSpaceMb?: number;
/** Absolute path to a cli.js the shim should run instead of the bundled
* one. Forwarded via `KANBAN_CLI_OVERRIDE`. */
cliEntryOverride?: string;
spawnFn?: typeof spawn;
}

Expand Down Expand Up @@ -147,6 +150,7 @@ export class RuntimeChildManager extends EventEmitter<RuntimeChildManagerEvents>
pollIntervalMs: number;
startupTimeoutMs: number;
maxOldSpaceMb: number;
cliEntryOverride: string | undefined;
spawnFn: typeof spawn;
};

Expand All @@ -163,6 +167,7 @@ export class RuntimeChildManager extends EventEmitter<RuntimeChildManagerEvents>
pollIntervalMs: options.pollIntervalMs ?? 200,
startupTimeoutMs: options.startupTimeoutMs ?? 30_000,
maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
cliEntryOverride: options.cliEntryOverride || undefined,
spawnFn: options.spawnFn ?? spawn,
};
}
Expand Down Expand Up @@ -214,6 +219,9 @@ export class RuntimeChildManager extends EventEmitter<RuntimeChildManagerEvents>

const env = buildFilteredEnv();
env.KANBAN_DESKTOP = "1";
if (this.opts.cliEntryOverride !== undefined) {
env.KANBAN_CLI_OVERRIDE = this.opts.cliEntryOverride;
}
// Merge our V8 heap limit with any existing NODE_OPTIONS from parent.
// Strip both hyphen and underscore variants to avoid duplicates.
const existingNodeOptions = env.NODE_OPTIONS?.trim() || "";
Expand Down
71 changes: 69 additions & 2 deletions packages/desktop/src/runtime-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ import { powerSaveBlocker } from "electron";

import { RuntimeChildManager } from "./runtime-child.js";


interface RuntimeOrchestratorOptions {

host: string;
port: number;
healthTimeoutMs: number;
resolveCliShimPath: () => string;
/** Re-evaluated on every spawn. `null` ⇒ use the shim's bundled cli. */
resolveCliEntryOverride?: () => string | null;
/** Called when a staged spawn fails its readiness probe. The
* orchestrator retries once with the bundled cli on this same launch;
* the callback should clear/roll back the version it just tried.
* `cliEntry` is the exact override path used for the failed spawn —
* capturing it at spawn time avoids racing with a concurrent
* background staging that may have moved the pointer to a *new*
* version (which we must not roll back). */
onCliEntryOverrideFailed?: (reason: string, cliEntry: string) => void;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Async callback silently discarded

onCliEntryOverrideFailed is typed as () => void, but TypeScript allows assigning an async function here. The retry call site does not await the return value, so any async work in the callback races with the immediately-following resolveCliEntryOverride() call. If the pointer is not yet cleared, the retry will pick up the same staged path again instead of falling back to the bundled CLI.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/desktop/src/runtime-orchestrator.ts
Line: 21

Comment:
**Async callback silently discarded**

`onCliEntryOverrideFailed` is typed as `() => void`, but TypeScript allows assigning an `async` function here. The retry call site does not `await` the return value, so any async work in the callback races with the immediately-following `resolveCliEntryOverride()` call. If the pointer is not yet cleared, the retry will pick up the same staged path again instead of falling back to the bundled CLI.

How can I resolve this? If you propose a fix, please make it concise.

fetchImpl?: typeof fetch;
attachedProbeIntervalMs?: number;
attachedProbeFailureThreshold?: number;
Expand Down Expand Up @@ -71,6 +79,15 @@ export class RuntimeOrchestrator extends EventEmitter<RuntimeOrchestratorEventMa
// at spawn time. Initial value `null` distinguishes "not yet looked
// up" from "looked up and resolved to a string".
private cachedShimPath: string | null = null;
// Captured at spawn time so the failure handler rolls back the
// version that actually ran — not whatever the pointer happens to
// say at failure time, which a concurrent background stage may have
// already replaced.
private currentSpawnOverridePath: string | null = null;
// Latched during the one same-launch fallback retry, so a callback
// that synchronously clears the pointer + a still-broken bundled
// runtime can't loop forever.
private overrideRetryInFlight = false;

// Latched once `shutdown()` / `dispose()` begin. Every `await` boundary
// in the lifecycle methods (`connect`, `restart`, `startOwnRuntime`)
Expand Down Expand Up @@ -353,6 +370,35 @@ export class RuntimeOrchestrator extends EventEmitter<RuntimeOrchestratorEventMa
this.manager.removeAllListeners("error");
this.manager = null;
}
// A staged runtime that failed its readiness probe is broken;
// notify the host (clears the pointer) and retry the same
// launch with the bundled cli. The latch prevents an infinite
// loop if the bundled runtime is also broken.
const reason = err instanceof Error ? err.message : String(err);
const failedOverride = this.currentSpawnOverridePath;
if (failedOverride && !this.overrideRetryInFlight) {
this.currentSpawnOverridePath = null;
this.overrideRetryInFlight = true;
try {
this.opts.onCliEntryOverrideFailed?.(reason, failedOverride);
} catch (cbErr) {
console.warn(
"[desktop] onCliEntryOverrideFailed threw:",
cbErr instanceof Error ? cbErr.message : cbErr,
);
}
console.warn(
`[desktop] Staged runtime failed (${reason}); falling back to bundled.`,
);
if (this.terminated) return;
try {
await this.startOwnRuntime();
} finally {
this.overrideRetryInFlight = false;
}
Comment on lines +393 to +398

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 overrideRetryInFlight not reset on early terminated return

The if (this.terminated) return at line 393 exits before the try/finally block, so overrideRetryInFlight remains true if shutdown() races in at that exact point. In practice this is benign — a terminated orchestrator spawns nothing further — but moving the check inside the try would make the reset unconditional and easier to reason about.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/desktop/src/runtime-orchestrator.ts
Line: 393-398

Comment:
**`overrideRetryInFlight` not reset on early `terminated` return**

The `if (this.terminated) return` at line 393 exits before the `try/finally` block, so `overrideRetryInFlight` remains `true` if `shutdown()` races in at that exact point. In practice this is benign — a terminated orchestrator spawns nothing further — but moving the check inside the `try` would make the reset unconditional and easier to reason about.

How can I resolve this? If you propose a fix, please make it concise.

return;
}
this.currentSpawnOverridePath = null;
// Suppress on terminated — caller (drain inside shutdown/dispose)
// already moved past the point where it cares about the spawn
// failure, and re-throwing would surface as an unhandled
Expand Down Expand Up @@ -389,10 +435,31 @@ export class RuntimeOrchestrator extends EventEmitter<RuntimeOrchestratorEventMa
return resolved;
}

private resolveCliEntryOverride(): string | undefined {
this.currentSpawnOverridePath = null;
const resolver = this.opts.resolveCliEntryOverride;
if (!resolver) return undefined;
let override: string | null;
try {
override = resolver();
} catch (err) {
console.warn(
"[desktop] resolveCliEntryOverride threw:",
err instanceof Error ? err.message : err,
);
return undefined;
}
if (!override) return undefined;
this.currentSpawnOverridePath = override;
console.log(`[desktop] Runtime override → ${override}`);
return override;
}

private createManager(): RuntimeChildManager {
const manager = new RuntimeChildManager({
cliPath: this.getValidatedShimPath(),
shutdownTimeoutMs: DEFAULT_CHILD_SHUTDOWN_TIMEOUT_MS,
cliEntryOverride: this.resolveCliEntryOverride(),
});


Expand Down
44 changes: 44 additions & 0 deletions packages/desktop/test/runtime-child-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,50 @@ describe("RuntimeChildManager", () => {
expect(options.env.NODE_OPTIONS).not.toContain("4096");
});

// `cliEntryOverride` is forwarded to the child env as
// `KANBAN_CLI_OVERRIDE` for the shim to pick up.
it("forwards cliEntryOverride to the child env as KANBAN_CLI_OVERRIDE", async () => {
const spawnSpy = createSpawnFn(mockChild);
manager = new RuntimeChildManager({
cliPath: CLI_PATH,
spawnFn: spawnSpy,
cliEntryOverride: "/some/abs/path/cli.js",
});
await manager.start(TEST_CONFIG);

const spawnCall = (spawnSpy as ReturnType<typeof vi.fn>).mock.calls[0];
const options = spawnCall[2] as { env: NodeJS.ProcessEnv };
expect(options.env.KANBAN_CLI_OVERRIDE).toBe("/some/abs/path/cli.js");
});

it("does not set KANBAN_CLI_OVERRIDE when override is omitted", async () => {
const spawnSpy = createSpawnFn(mockChild);
manager = new RuntimeChildManager({
cliPath: CLI_PATH,
spawnFn: spawnSpy,
});
await manager.start(TEST_CONFIG);

const spawnCall = (spawnSpy as ReturnType<typeof vi.fn>).mock.calls[0];
const options = spawnCall[2] as { env: NodeJS.ProcessEnv };
expect(options.env.KANBAN_CLI_OVERRIDE).toBeUndefined();
});

it("treats an empty string cliEntryOverride as 'no override'", async () => {
// Optional-chained callers can surface "" instead of undefined.
const spawnSpy = createSpawnFn(mockChild);
manager = new RuntimeChildManager({
cliPath: CLI_PATH,
spawnFn: spawnSpy,
cliEntryOverride: "",
});
await manager.start(TEST_CONFIG);

const spawnCall = (spawnSpy as ReturnType<typeof vi.fn>).mock.calls[0];
const options = spawnCall[2] as { env: NodeJS.ProcessEnv };
expect(options.env.KANBAN_CLI_OVERRIDE).toBeUndefined();
});

// Platform-aware spawn options — pinned because regressing either
// one breaks a specific failure mode:
// - POSIX `detached: true` : required so treeKill(-pid) walks PTYs
Expand Down
Loading