diff --git a/packages/persona-kit/src/interactive-spec.test.ts b/packages/persona-kit/src/interactive-spec.test.ts index e99123fc..40ec4ebd 100644 --- a/packages/persona-kit/src/interactive-spec.test.ts +++ b/packages/persona-kit/src/interactive-spec.test.ts @@ -324,7 +324,7 @@ test('opencode configFiles carries a well-formed opencode.json with the agent de }); }); -test('opencode non-interactive spec omits cwd/model flags and normalizes the agent model', () => { +test('opencode non-interactive spec execs through a shell, omits cwd/model flags, and normalizes the agent model', () => { const result = buildNonInteractiveSpec({ harness: 'opencode', personaId: 'daily-ship', @@ -335,8 +335,16 @@ test('opencode non-interactive spec omits cwd/model flags and normalizes the age workingDirectory: '/tmp/project' }); - assert.equal(result.bin, 'opencode'); + // workforce#330: opencode is spawned through `sh -c 'exec "$0" "$@"'`. + // Spawned directly by the runtime it fails in ~1.2s with an opaque + // `UnknownError: Unexpected server error`; with one shell in between the + // identical invocation runs normally. `exec` means the shell is replaced, so + // the caller still waits on the real opencode process. + // Windows has no /bin/sh, so the wrapper is POSIX-only (see the spec builder). + const expectShell = process.platform !== 'win32'; + assert.equal(result.bin, expectShell ? '/bin/sh' : 'opencode'); assert.deepEqual(result.args, [ + ...(expectShell ? ['-c', 'exec "$0" "$@"', 'opencode'] : []), 'run', '--agent', 'daily-ship', diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index 41c863d0..1e957913 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -738,9 +738,42 @@ export function buildNonInteractiveSpec( // separately, and `opencode run` does not support `--dir`. const args = ['run', ...interactive.args, '--format', 'default']; if (input.name) args.push('--title', input.name); + // Spawn opencode through `sh -c 'exec "$0" "$@"'` rather than exec'ing the + // binary directly (workforce#330). Spawned directly by the runtime, + // opencode fails in ~1.2s with an opaque + // Error: {"name":"UnknownError","data":{"message":"Unexpected server + // error. Check server logs for details.","ref":"..."}} + // and its own log file is created but stays 0 bytes. With one shell + // between Node and the binary — same binary, args, cwd, env, stdin, + // model and credential — it runs normally. Verified end to end on + // daily-ship: opaque failure without, real digests posted with. + // + // Ruled out by direct experiment in a live sandbox, so this is narrower + // than "wrap it and hope": PATH; prompt size and content (119KB synthetic + // and a real 72KB prompt both fine); provider flakiness (6/6 clean); + // NODE_OPTIONS; HOME / XDG_DATA_HOME; USER / LOGNAME; cwd; FD limits down + // to `ulimit -n 64`; a stdin write race (delayed stdin is read fine); + // inherited SIGPIPE disposition (deliberately re-ignoring it still + // succeeded); and the process group (`timeout`'s setpgid — bash exec with + // no timeout also succeeded). The shell alone is what matters. + // + // `exec` keeps the process count unchanged, so the caller still waits on + // the real opencode process and its exit code is reported verbatim. Only + // the opencode branch is routed this way; claude / codex / grok spawn + // directly and are unaffected. + // + // Windows has no `/bin/sh`, and `cmd.exe` has no exec-replacement + // semantics — wrapping there would ADD a process rather than replace one, + // breaking exit-code and signal passthrough. The failure this works around + // is observed in the Linux cloud sandboxes, so win32 keeps the direct + // spawn. Same platform split the sandbox exec path already uses + // (runtime/src/cloud-defaults.ts). + const execThroughShell = process.platform !== 'win32'; return { - bin: interactive.bin, - args, + bin: execThroughShell ? '/bin/sh' : interactive.bin, + args: execThroughShell + ? ['-c', 'exec "$0" "$@"', interactive.bin, ...args] + : args, prompt: { mode: 'stdin', contents: input.task }, configFiles: interactive.configFiles, warnings: interactive.warnings