orizu 0.5.3 → 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.
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Runner/optimizer artifact pull (symmetric with `runners push` /
3
- * `optimizers push`): resolve the artifact by id or name, download the
4
- * content-addressed zip from storage, and extract it into --out. Version
5
- * metadata is written to .orizu/pull.json — .orizu/ is excluded from artifact
6
- * zips (see artifact-archive.ts), so a pulled directory re-pushes with an
7
- * identical content hash.
2
+ * Runner/optimizer artifact commands: list (discover names, version ids, and
3
+ * labels) and pull (symmetric with `runners push` / `optimizers push` —
4
+ * resolve by id or name, download the content-addressed zip from storage,
5
+ * and extract it into --out). Pull writes version metadata to
6
+ * .orizu/pull.json — .orizu/ is excluded from artifact zips (see
7
+ * artifact-archive.ts), so a pulled directory re-pushes with an identical
8
+ * content hash.
8
9
  *
9
10
  * Lives outside index.ts per the CLI index line ratchet (ALI-976): this module
10
11
  * owns its own argument parsing and printing via injected io.
@@ -49,7 +50,50 @@ async function parseJsonPayload(response, context) {
49
50
  `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
50
51
  }
51
52
  }
52
- export async function pullArtifactCommand(args, io) {
53
+ export async function runnerOptimizerCommand(args, io) {
54
+ if (args[1] === 'list') {
55
+ await listArtifactsCommand(args, io);
56
+ return;
57
+ }
58
+ await pullArtifactCommand(args, io);
59
+ }
60
+ async function listArtifactsCommand(args, io) {
61
+ const kind = args[0] === 'runners' ? 'runner' : 'optimizer';
62
+ const plural = kind === 'runner' ? 'runners' : 'optimizers';
63
+ const fetcher = io.fetcher || authedFetch;
64
+ const project = argValue(args, '--project') || await io.resolveProjectSlug(null);
65
+ const response = await fetcher(`/api/cli/${plural}?project=${encodeURIComponent(project)}`);
66
+ if (!response.ok) {
67
+ throw new Error(`Failed to fetch ${plural}: ${await response.text()}`);
68
+ }
69
+ const data = await parseJsonPayload(response, `${plural} list`);
70
+ if (io.json) {
71
+ io.print(JSON.stringify(data));
72
+ return;
73
+ }
74
+ const items = data[plural] || [];
75
+ if (items.length === 0) {
76
+ io.print(`No ${plural} found.`);
77
+ return;
78
+ }
79
+ const rows = items.map(item => ({
80
+ id: sanitizeTerminalText(item.id),
81
+ name: sanitizeTerminalText(item.name),
82
+ versions: String(item.versionCount ?? 0),
83
+ latest: sanitizeTerminalText(item.latestVersionId || '-'),
84
+ labels: sanitizeTerminalText((item.labels || []).map(entry => entry.label).join(', ') || '-'),
85
+ }));
86
+ const idWidth = Math.max('ID'.length, ...rows.map(row => row.id.length));
87
+ const nameWidth = Math.max('NAME'.length, ...rows.map(row => row.name.length));
88
+ const versionsWidth = Math.max('VERSIONS'.length, ...rows.map(row => row.versions.length));
89
+ const latestWidth = Math.max('LATEST VERSION'.length, ...rows.map(row => row.latest.length));
90
+ io.print(`${'ID'.padEnd(idWidth)} ${'NAME'.padEnd(nameWidth)} ${'VERSIONS'.padEnd(versionsWidth)} ${'LATEST VERSION'.padEnd(latestWidth)} LABELS`);
91
+ io.print(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionsWidth)} ${'-'.repeat(latestWidth)} ${'-'.repeat('LABELS'.length)}`);
92
+ rows.forEach(row => {
93
+ io.print(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.versions.padEnd(versionsWidth)} ${row.latest.padEnd(latestWidth)} ${row.labels}`);
94
+ });
95
+ }
96
+ async function pullArtifactCommand(args, io) {
53
97
  const kind = args[0] === 'runners' ? 'runner' : 'optimizer';
54
98
  const fetcher = io.fetcher || authedFetch;
55
99
  const positionalRef = args[2];
package/dist/help.js CHANGED
@@ -344,6 +344,12 @@ export const COMMAND_DOCS = [
344
344
  summary: 'Push a local runner artifact directory.',
345
345
  group: 'Scorers and runners',
346
346
  },
347
+ {
348
+ path: ['runners', 'list'],
349
+ usage: 'orizu runners list [--project <team/project>] [--json]',
350
+ summary: 'List runner artifacts with version counts, latest version ids, and labels.',
351
+ group: 'Scorers and runners',
352
+ },
347
353
  {
348
354
  path: ['runners', 'pull'],
349
355
  usage: 'orizu runners pull <runner-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
@@ -362,6 +368,12 @@ export const COMMAND_DOCS = [
362
368
  summary: 'Push a local optimizer artifact directory.',
363
369
  group: 'Optimizations',
364
370
  },
371
+ {
372
+ path: ['optimizers', 'list'],
373
+ usage: 'orizu optimizers list [--project <team/project>] [--json]',
374
+ summary: 'List optimizer artifacts with version counts, latest version ids, and labels.',
375
+ group: 'Optimizations',
376
+ },
365
377
  {
366
378
  path: ['optimizers', 'pull'],
367
379
  usage: 'orizu optimizers pull <optimizer-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
@@ -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/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import { formatTaskCreateError } from './task-create-error.js';
27
27
  import { renderAgentSetupPrompt } from './setup-prompt.js';
28
28
  import { getWorkspaceRoot, initOrizuWorkspace, workspaceExists, } from './workspace.js';
29
29
  import { connectorsCommand } from './connectors-cli.js';
30
- import { ARTIFACT_MAX_BYTES, pullArtifactCommand } from './artifact-pull.js';
30
+ import { ARTIFACT_MAX_BYTES, runnerOptimizerCommand } from './artifact-pull.js';
31
31
  import { killSwitchCommand } from './kill-switch-cli.js';
32
32
  import { egressAllowlistCommand } from './egress-allowlist-cli.js';
33
33
  import { manifestsCommand } from './manifests-cli.js';
@@ -5001,8 +5001,8 @@ export async function main(rawArgs = process.argv.slice(2)) {
5001
5001
  await pushRunnerArtifact(command === 'runners' ? 'runner' : 'optimizer');
5002
5002
  return;
5003
5003
  }
5004
- if ((command === 'runners' || command === 'optimizers') && subcommand === 'pull') {
5005
- await pullArtifactCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug });
5004
+ if ((command === 'runners' || command === 'optimizers') && (subcommand === 'pull' || subcommand === 'list')) {
5005
+ await runnerOptimizerCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug });
5006
5006
  return;
5007
5007
  }
5008
5008
  if (command === 'runners' && subcommand === 'exec') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Runner/optimizer artifact pull (symmetric with `runners push` /
3
- * `optimizers push`): resolve the artifact by id or name, download the
4
- * content-addressed zip from storage, and extract it into --out. Version
5
- * metadata is written to .orizu/pull.json — .orizu/ is excluded from artifact
6
- * zips (see artifact-archive.ts), so a pulled directory re-pushes with an
7
- * identical content hash.
2
+ * Runner/optimizer artifact commands: list (discover names, version ids, and
3
+ * labels) and pull (symmetric with `runners push` / `optimizers push` —
4
+ * resolve by id or name, download the content-addressed zip from storage,
5
+ * and extract it into --out). Pull writes version metadata to
6
+ * .orizu/pull.json — .orizu/ is excluded from artifact zips (see
7
+ * artifact-archive.ts), so a pulled directory re-pushes with an identical
8
+ * content hash.
8
9
  *
9
10
  * Lives outside index.ts per the CLI index line ratchet (ALI-976): this module
10
11
  * owns its own argument parsing and printing via injected io.
@@ -84,7 +85,67 @@ interface PullArtifactResponse {
84
85
  labels?: Array<{ label: string; runnerVersionId?: string; optimizerVersionId?: string }>
85
86
  }
86
87
 
87
- export async function pullArtifactCommand(args: string[], io: PullArtifactIo) {
88
+ interface ArtifactListEntry {
89
+ id: string
90
+ name: string
91
+ description?: string | null
92
+ optimizerFamily?: string | null
93
+ latestVersionId?: string | null
94
+ versionCount?: number
95
+ labels?: Array<{ label: string; runnerVersionId?: string; optimizerVersionId?: string }>
96
+ }
97
+
98
+ export async function runnerOptimizerCommand(args: string[], io: PullArtifactIo) {
99
+ if (args[1] === 'list') {
100
+ await listArtifactsCommand(args, io)
101
+ return
102
+ }
103
+ await pullArtifactCommand(args, io)
104
+ }
105
+
106
+ async function listArtifactsCommand(args: string[], io: PullArtifactIo) {
107
+ const kind: PullableArtifactKind = args[0] === 'runners' ? 'runner' : 'optimizer'
108
+ const plural = kind === 'runner' ? 'runners' : 'optimizers'
109
+ const fetcher = io.fetcher || authedFetch
110
+ const project = argValue(args, '--project') || await io.resolveProjectSlug(null)
111
+
112
+ const response = await fetcher(`/api/cli/${plural}?project=${encodeURIComponent(project)}`)
113
+ if (!response.ok) {
114
+ throw new Error(`Failed to fetch ${plural}: ${await response.text()}`)
115
+ }
116
+
117
+ const data = await parseJsonPayload<Record<string, ArtifactListEntry[]>>(response, `${plural} list`)
118
+ if (io.json) {
119
+ io.print(JSON.stringify(data))
120
+ return
121
+ }
122
+
123
+ const items = data[plural] || []
124
+ if (items.length === 0) {
125
+ io.print(`No ${plural} found.`)
126
+ return
127
+ }
128
+
129
+ const rows = items.map(item => ({
130
+ id: sanitizeTerminalText(item.id),
131
+ name: sanitizeTerminalText(item.name),
132
+ versions: String(item.versionCount ?? 0),
133
+ latest: sanitizeTerminalText(item.latestVersionId || '-'),
134
+ labels: sanitizeTerminalText((item.labels || []).map(entry => entry.label).join(', ') || '-'),
135
+ }))
136
+ const idWidth = Math.max('ID'.length, ...rows.map(row => row.id.length))
137
+ const nameWidth = Math.max('NAME'.length, ...rows.map(row => row.name.length))
138
+ const versionsWidth = Math.max('VERSIONS'.length, ...rows.map(row => row.versions.length))
139
+ const latestWidth = Math.max('LATEST VERSION'.length, ...rows.map(row => row.latest.length))
140
+
141
+ io.print(`${'ID'.padEnd(idWidth)} ${'NAME'.padEnd(nameWidth)} ${'VERSIONS'.padEnd(versionsWidth)} ${'LATEST VERSION'.padEnd(latestWidth)} LABELS`)
142
+ io.print(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionsWidth)} ${'-'.repeat(latestWidth)} ${'-'.repeat('LABELS'.length)}`)
143
+ rows.forEach(row => {
144
+ io.print(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.versions.padEnd(versionsWidth)} ${row.latest.padEnd(latestWidth)} ${row.labels}`)
145
+ })
146
+ }
147
+
148
+ async function pullArtifactCommand(args: string[], io: PullArtifactIo) {
88
149
  const kind: PullableArtifactKind = args[0] === 'runners' ? 'runner' : 'optimizer'
89
150
  const fetcher = io.fetcher || authedFetch
90
151
  const positionalRef = args[2]
package/src/help.ts CHANGED
@@ -369,6 +369,12 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
369
369
  summary: 'Push a local runner artifact directory.',
370
370
  group: 'Scorers and runners',
371
371
  },
372
+ {
373
+ path: ['runners', 'list'],
374
+ usage: 'orizu runners list [--project <team/project>] [--json]',
375
+ summary: 'List runner artifacts with version counts, latest version ids, and labels.',
376
+ group: 'Scorers and runners',
377
+ },
372
378
  {
373
379
  path: ['runners', 'pull'],
374
380
  usage: 'orizu runners pull <runner-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
@@ -387,6 +393,12 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
387
393
  summary: 'Push a local optimizer artifact directory.',
388
394
  group: 'Optimizations',
389
395
  },
396
+ {
397
+ path: ['optimizers', 'list'],
398
+ usage: 'orizu optimizers list [--project <team/project>] [--json]',
399
+ summary: 'List optimizer artifacts with version counts, latest version ids, and labels.',
400
+ group: 'Optimizations',
401
+ },
390
402
  {
391
403
  path: ['optimizers', 'pull'],
392
404
  usage: 'orizu optimizers pull <optimizer-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
@@ -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
package/src/index.ts CHANGED
@@ -59,7 +59,7 @@ import {
59
59
  workspaceExists,
60
60
  } from './workspace.js'
61
61
  import { connectorsCommand } from './connectors-cli.js'
62
- import { ARTIFACT_MAX_BYTES, pullArtifactCommand } from './artifact-pull.js'
62
+ import { ARTIFACT_MAX_BYTES, runnerOptimizerCommand } from './artifact-pull.js'
63
63
  import { killSwitchCommand } from './kill-switch-cli.js'
64
64
  import { egressAllowlistCommand } from './egress-allowlist-cli.js'
65
65
  import { manifestsCommand } from './manifests-cli.js'
@@ -6467,8 +6467,8 @@ export async function main(rawArgs = process.argv.slice(2)) {
6467
6467
  return
6468
6468
  }
6469
6469
 
6470
- if ((command === 'runners' || command === 'optimizers') && subcommand === 'pull') {
6471
- await pullArtifactCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug }); return
6470
+ if ((command === 'runners' || command === 'optimizers') && (subcommand === 'pull' || subcommand === 'list')) {
6471
+ await runnerOptimizerCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug }); return
6472
6472
  }
6473
6473
 
6474
6474
  if (command === 'runners' && subcommand === 'exec') {