orizu 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,6 +48,7 @@
48
48
  * `OPENCODE_PINNED_VERSION`; a bump that switches OpenCode to CRLF framing
49
49
  * would break both ports and must be re-verified.
50
50
  */
51
+ import { readFileSync } from 'fs';
51
52
  // OpenCode is HARD-PINNED to this version everywhere it is installed/launched.
52
53
  // AUDIT RISK #1 (SSE fragility): the entire event model rides OpenCode's
53
54
  // undocumented `/event` SSE part schema (`message.part.updated`, `session.idle`,
@@ -696,15 +697,34 @@ export function buildOpenCodeConfigContent(opts) {
696
697
  permission: opts.permission ?? { '*': { '*': 'allow' } },
697
698
  });
698
699
  }
700
+ const OPENCODE_READY_TIMEOUT_MS = 180_000;
701
+ const OPENCODE_READY_POLL_MS = 500;
702
+ const OPENCODE_READY_PROBE_TIMEOUT_MS = 2_000;
703
+ const OPENCODE_LOG_TAIL_BYTES = 2_000;
704
+ function readLogTail(logPath) {
705
+ if (!logPath)
706
+ return null;
707
+ try {
708
+ const content = readFileSync(logPath, 'utf8');
709
+ if (!content.trim())
710
+ return null;
711
+ return content.slice(-OPENCODE_LOG_TAIL_BYTES).trim();
712
+ }
713
+ catch {
714
+ return null;
715
+ }
716
+ }
699
717
  /**
700
- * Launch `opencode serve` locally. Process supervision is intentionally minimal
701
- * for this slice — P3.5 runs this INSIDE the Daytona sandbox via the CLI. The
702
- * pinned version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must
703
- * install (see the SSE-fragility note on that constant). Returns the localhost
704
- * base URL the driver connects to; callers still `start()`/`runPrompt()` the
705
- * harness against it.
718
+ * Launch `opencode serve` locally and WAIT until it answers HTTP. OpenCode's
719
+ * first boot performs a one-time sqlite migration before it listens; returning
720
+ * the baseUrl without waiting raced the harness's first `POST /session` into a
721
+ * connection-refused "fetch failed" (ALI-1034, found live in QA-3). Any HTTP
722
+ * status counts as ready — we only need the socket to be accepting. Process
723
+ * supervision is otherwise intentionally minimal for this slice; the pinned
724
+ * version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must install
725
+ * (see the SSE-fragility note on that constant).
706
726
  */
707
- export function spawnOpenCode(opts) {
727
+ export async function spawnOpenCode(opts) {
708
728
  const port = opts.port ?? DEFAULT_OPENCODE_PORT;
709
729
  const env = {
710
730
  OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
@@ -724,6 +744,45 @@ export function spawnOpenCode(opts) {
724
744
  stderr: 'ignore',
725
745
  });
726
746
  });
727
- const child = spawnImpl(cmd, { cwd: opts.cwd, env });
728
- return { baseUrl: `http://localhost:${port}`, port, stop: () => child.kill() };
747
+ const child = spawnImpl(cmd, { cwd: opts.cwd, env, logPath: opts.logPath });
748
+ const baseUrl = `http://localhost:${port}`;
749
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
750
+ const timeoutMs = opts.readyTimeoutMs ?? OPENCODE_READY_TIMEOUT_MS;
751
+ const pollMs = opts.readyPollMs ?? OPENCODE_READY_POLL_MS;
752
+ const startedAt = Date.now();
753
+ for (;;) {
754
+ if (opts.signal?.aborted) {
755
+ child.kill();
756
+ throw new Error('opencode serve readiness wait aborted');
757
+ }
758
+ try {
759
+ const probe = new AbortController();
760
+ const timer = setTimeout(() => probe.abort(), OPENCODE_READY_PROBE_TIMEOUT_MS);
761
+ // Link the run signal so an abort tears down an in-flight probe
762
+ // immediately instead of waiting out the per-probe timeout.
763
+ const probeSignal = opts.signal ? AbortSignal.any([opts.signal, probe.signal]) : probe.signal;
764
+ try {
765
+ // Any HTTP response (even 404) proves the server is accepting.
766
+ const res = await fetchImpl(`${baseUrl}/session`, { signal: probeSignal });
767
+ await cancelBody(res.body);
768
+ break;
769
+ }
770
+ finally {
771
+ clearTimeout(timer);
772
+ }
773
+ }
774
+ catch {
775
+ // not listening yet — fall through to the timeout check + sleep
776
+ }
777
+ // Soft budget: checked between probes, so the total wait can overrun by up
778
+ // to one probe timeout + poll interval. Negligible against the default.
779
+ if (Date.now() - startedAt >= timeoutMs) {
780
+ child.kill();
781
+ const tail = readLogTail(opts.logPath);
782
+ throw new Error(`opencode serve did not become ready on ${baseUrl} within ${timeoutMs}ms` +
783
+ (tail ? ` — log tail: ${tail}` : ' (no log output captured)'));
784
+ }
785
+ await new Promise(resolve => setTimeout(resolve, pollMs));
786
+ }
787
+ return { baseUrl, port, stop: () => child.kill(), readyAfterMs: Date.now() - startedAt };
729
788
  }
@@ -33,7 +33,8 @@
33
33
  * against a fake broker + fake OpenCode server with no real binary.
34
34
  */
35
35
  import { spawnSync, spawn as nodeSpawn } from 'child_process';
36
- import { existsSync, readFileSync } from 'fs';
36
+ import { closeSync, existsSync, openSync, readFileSync } from 'fs';
37
+ import { tmpdir } from 'os';
37
38
  import { join } from 'path';
38
39
  import { PREBAKED_MARKER_PATH, SETUP_HOOK_RELATIVE_PATH, parsePrebakedMarker, } from './hosted-runtime-assets.js';
39
40
  import { OPENCODE_PINNED_VERSION, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
@@ -218,13 +219,35 @@ function installOpenCodePinned(version) {
218
219
  }
219
220
  /** Node child_process spawner for `opencode serve` (the sandbox runtime is
220
221
  * node24 — Bun.spawn is not guaranteed present, so spawnOpenCode is given this
221
- * runtime-agnostic spawner). */
222
+ * runtime-agnostic spawner). When a logPath is provided, opencode's
223
+ * stdout/stderr land there so a readiness timeout can surface the real boot
224
+ * failure instead of a bare "fetch failed" (ALI-1034). */
222
225
  function nodeChildSpawner(cmd, opts) {
226
+ let stdio = 'ignore';
227
+ let fd = null;
228
+ if (opts.logPath) {
229
+ try {
230
+ fd = openSync(opts.logPath, 'a');
231
+ stdio = ['ignore', fd, fd];
232
+ }
233
+ catch {
234
+ // log capture is best-effort — never block the spawn on it
235
+ }
236
+ }
223
237
  const child = nodeSpawn(cmd[0], cmd.slice(1), {
224
238
  cwd: opts.cwd,
225
239
  env: { ...process.env, ...opts.env },
226
- stdio: 'ignore',
240
+ stdio,
227
241
  });
242
+ if (fd !== null) {
243
+ // The child holds its own copy of the descriptor.
244
+ try {
245
+ closeSync(fd);
246
+ }
247
+ catch {
248
+ // already closed
249
+ }
250
+ }
228
251
  return { kill: () => child.kill() };
229
252
  }
230
253
  export async function runHostedLoop(opts) {
@@ -386,13 +409,25 @@ export async function runHostedLoop(opts) {
386
409
  });
387
410
  }
388
411
  const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode;
389
- spawned = spawnImpl({
412
+ // The default spawnOpenCode WAITS for the server to answer HTTP before
413
+ // returning — OpenCode's first boot runs a one-time sqlite migration, and
414
+ // starting the harness before the server listens raced straight into a
415
+ // connection-refused "fetch failed" (ALI-1034).
416
+ spawned = await spawnImpl({
390
417
  model: context.model,
391
418
  cwd: context.workspaceDir,
392
419
  port: context.opencodePort,
393
420
  env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
394
421
  spawn: nodeChildSpawner,
422
+ logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
423
+ signal,
395
424
  });
425
+ if (typeof spawned.readyAfterMs === 'number') {
426
+ await sink.append({
427
+ kind: 'artifact',
428
+ payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
429
+ });
430
+ }
396
431
  harness = (opts.createHarness ?? ((baseUrl) => createOpenCodeHarness({ baseUrl })))(spawned.baseUrl);
397
432
  }
398
433
  const started = await harness.start({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,6 +49,8 @@
49
49
  * would break both ports and must be re-verified.
50
50
  */
51
51
 
52
+ import { readFileSync } from 'fs'
53
+
52
54
  import type {
53
55
  AgentHarness,
54
56
  HarnessEvent,
@@ -781,24 +783,62 @@ export interface SpawnOpenCodeOptions extends OpenCodeConfigOptions {
781
783
  /** Extra env merged over the config-content injection. */
782
784
  env?: Record<string, string>
783
785
  /** Injectable spawner (defaults to Bun.spawn) so this stays unit-testable. */
784
- spawn?: (cmd: string[], opts: { cwd?: string; env: Record<string, string> }) => { kill: () => void }
786
+ spawn?: (
787
+ cmd: string[],
788
+ opts: { cwd?: string; env: Record<string, string>; logPath?: string }
789
+ ) => { kill: () => void }
790
+ /** File the spawner should append opencode's stdout/stderr to. On readiness
791
+ * timeout its tail is included in the thrown error — the bare "fetch failed"
792
+ * this replaces was undiagnosable (ALI-1034). */
793
+ logPath?: string
794
+ /** Overall readiness budget. OpenCode's FIRST boot runs a one-time sqlite
795
+ * migration ("may take a few minutes") before it listens, so this must
796
+ * comfortably cover a cold snapshot. */
797
+ readyTimeoutMs?: number
798
+ /** Poll interval while waiting for the server to answer. */
799
+ readyPollMs?: number
800
+ /** Abort (e.g. run cancelled) while still waiting for readiness. */
801
+ signal?: AbortSignal
802
+ /** Injectable readiness probe (defaults to global fetch) for tests. */
803
+ fetchImpl?: typeof fetch
785
804
  }
786
805
 
787
806
  export interface SpawnedOpenCode {
788
807
  baseUrl: string
789
808
  port: number
790
809
  stop: () => void
810
+ /** How long the server took to answer its first request (set by the real
811
+ * spawnOpenCode; injected test spawners may omit it). */
812
+ readyAfterMs?: number
813
+ }
814
+
815
+ const OPENCODE_READY_TIMEOUT_MS = 180_000
816
+ const OPENCODE_READY_POLL_MS = 500
817
+ const OPENCODE_READY_PROBE_TIMEOUT_MS = 2_000
818
+ const OPENCODE_LOG_TAIL_BYTES = 2_000
819
+
820
+ function readLogTail(logPath: string | undefined): string | null {
821
+ if (!logPath) return null
822
+ try {
823
+ const content = readFileSync(logPath, 'utf8')
824
+ if (!content.trim()) return null
825
+ return content.slice(-OPENCODE_LOG_TAIL_BYTES).trim()
826
+ } catch {
827
+ return null
828
+ }
791
829
  }
792
830
 
793
831
  /**
794
- * Launch `opencode serve` locally. Process supervision is intentionally minimal
795
- * for this slice — P3.5 runs this INSIDE the Daytona sandbox via the CLI. The
796
- * pinned version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must
797
- * install (see the SSE-fragility note on that constant). Returns the localhost
798
- * base URL the driver connects to; callers still `start()`/`runPrompt()` the
799
- * harness against it.
832
+ * Launch `opencode serve` locally and WAIT until it answers HTTP. OpenCode's
833
+ * first boot performs a one-time sqlite migration before it listens; returning
834
+ * the baseUrl without waiting raced the harness's first `POST /session` into a
835
+ * connection-refused "fetch failed" (ALI-1034, found live in QA-3). Any HTTP
836
+ * status counts as ready — we only need the socket to be accepting. Process
837
+ * supervision is otherwise intentionally minimal for this slice; the pinned
838
+ * version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must install
839
+ * (see the SSE-fragility note on that constant).
800
840
  */
801
- export function spawnOpenCode(opts: SpawnOpenCodeOptions): SpawnedOpenCode {
841
+ export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<SpawnedOpenCode> {
802
842
  const port = opts.port ?? DEFAULT_OPENCODE_PORT
803
843
  const env: Record<string, string> = {
804
844
  OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
@@ -818,6 +858,47 @@ export function spawnOpenCode(opts: SpawnOpenCodeOptions): SpawnedOpenCode {
818
858
  stderr: 'ignore',
819
859
  })
820
860
  })
821
- const child = spawnImpl(cmd, { cwd: opts.cwd, env })
822
- return { baseUrl: `http://localhost:${port}`, port, stop: () => child.kill() }
861
+ const child = spawnImpl(cmd, { cwd: opts.cwd, env, logPath: opts.logPath })
862
+ const baseUrl = `http://localhost:${port}`
863
+
864
+ const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as typeof fetch)
865
+ const timeoutMs = opts.readyTimeoutMs ?? OPENCODE_READY_TIMEOUT_MS
866
+ const pollMs = opts.readyPollMs ?? OPENCODE_READY_POLL_MS
867
+ const startedAt = Date.now()
868
+ for (;;) {
869
+ if (opts.signal?.aborted) {
870
+ child.kill()
871
+ throw new Error('opencode serve readiness wait aborted')
872
+ }
873
+ try {
874
+ const probe = new AbortController()
875
+ const timer = setTimeout(() => probe.abort(), OPENCODE_READY_PROBE_TIMEOUT_MS)
876
+ // Link the run signal so an abort tears down an in-flight probe
877
+ // immediately instead of waiting out the per-probe timeout.
878
+ const probeSignal = opts.signal ? AbortSignal.any([opts.signal, probe.signal]) : probe.signal
879
+ try {
880
+ // Any HTTP response (even 404) proves the server is accepting.
881
+ const res = await fetchImpl(`${baseUrl}/session`, { signal: probeSignal })
882
+ await cancelBody(res.body)
883
+ break
884
+ } finally {
885
+ clearTimeout(timer)
886
+ }
887
+ } catch {
888
+ // not listening yet — fall through to the timeout check + sleep
889
+ }
890
+ // Soft budget: checked between probes, so the total wait can overrun by up
891
+ // to one probe timeout + poll interval. Negligible against the default.
892
+ if (Date.now() - startedAt >= timeoutMs) {
893
+ child.kill()
894
+ const tail = readLogTail(opts.logPath)
895
+ throw new Error(
896
+ `opencode serve did not become ready on ${baseUrl} within ${timeoutMs}ms` +
897
+ (tail ? ` — log tail: ${tail}` : ' (no log output captured)')
898
+ )
899
+ }
900
+ await new Promise(resolve => setTimeout(resolve, pollMs))
901
+ }
902
+
903
+ return { baseUrl, port, stop: () => child.kill(), readyAfterMs: Date.now() - startedAt }
823
904
  }
@@ -34,7 +34,8 @@
34
34
  */
35
35
 
36
36
  import { spawnSync, spawn as nodeSpawn } from 'child_process'
37
- import { existsSync, readFileSync } from 'fs'
37
+ import { closeSync, existsSync, openSync, readFileSync } from 'fs'
38
+ import { tmpdir } from 'os'
38
39
  import { join } from 'path'
39
40
 
40
41
  import type { AgentHarness, HarnessEvent, HarnessPrompt } from './hosted-harness.js'
@@ -156,7 +157,7 @@ export interface RunHostedLoopOptions {
156
157
  /** Build the in-process Claude-Agent-SDK harness (default: real SDK loader).
157
158
  * Injectable so the swap test drives a fake `query()` with no real SDK. */
158
159
  createClaudeHarness?: () => AgentHarness
159
- spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode
160
+ spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode | Promise<SpawnedOpenCode>
160
161
  installOpenCode?: () => Promise<InstallResult>
161
162
  now?: () => number
162
163
  signal?: AbortSignal
@@ -402,16 +403,36 @@ function installOpenCodePinned(version: string): InstallResult {
402
403
 
403
404
  /** Node child_process spawner for `opencode serve` (the sandbox runtime is
404
405
  * node24 — Bun.spawn is not guaranteed present, so spawnOpenCode is given this
405
- * runtime-agnostic spawner). */
406
+ * runtime-agnostic spawner). When a logPath is provided, opencode's
407
+ * stdout/stderr land there so a readiness timeout can surface the real boot
408
+ * failure instead of a bare "fetch failed" (ALI-1034). */
406
409
  function nodeChildSpawner(
407
410
  cmd: string[],
408
- opts: { cwd?: string; env: Record<string, string> }
411
+ opts: { cwd?: string; env: Record<string, string>; logPath?: string }
409
412
  ): { kill: () => void } {
413
+ let stdio: 'ignore' | Array<'ignore' | number> = 'ignore'
414
+ let fd: number | null = null
415
+ if (opts.logPath) {
416
+ try {
417
+ fd = openSync(opts.logPath, 'a')
418
+ stdio = ['ignore', fd, fd]
419
+ } catch {
420
+ // log capture is best-effort — never block the spawn on it
421
+ }
422
+ }
410
423
  const child = nodeSpawn(cmd[0], cmd.slice(1), {
411
424
  cwd: opts.cwd,
412
425
  env: { ...process.env, ...opts.env },
413
- stdio: 'ignore',
426
+ stdio,
414
427
  })
428
+ if (fd !== null) {
429
+ // The child holds its own copy of the descriptor.
430
+ try {
431
+ closeSync(fd)
432
+ } catch {
433
+ // already closed
434
+ }
435
+ }
415
436
  return { kill: () => child.kill() }
416
437
  }
417
438
 
@@ -575,13 +596,25 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
575
596
  }
576
597
 
577
598
  const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode
578
- spawned = spawnImpl({
599
+ // The default spawnOpenCode WAITS for the server to answer HTTP before
600
+ // returning — OpenCode's first boot runs a one-time sqlite migration, and
601
+ // starting the harness before the server listens raced straight into a
602
+ // connection-refused "fetch failed" (ALI-1034).
603
+ spawned = await spawnImpl({
579
604
  model: context.model,
580
605
  cwd: context.workspaceDir,
581
606
  port: context.opencodePort,
582
607
  env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
583
608
  spawn: nodeChildSpawner,
609
+ logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
610
+ signal,
584
611
  })
612
+ if (typeof spawned.readyAfterMs === 'number') {
613
+ await sink.append({
614
+ kind: 'artifact',
615
+ payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
616
+ })
617
+ }
585
618
 
586
619
  harness = (opts.createHarness ?? ((baseUrl: string) => createOpenCodeHarness({ baseUrl })))(
587
620
  spawned.baseUrl