orizu 0.5.13 → 0.5.15

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.
@@ -19,7 +19,7 @@ import { existsSync, mkdirSync } from 'fs'
19
19
  import { join } from 'path'
20
20
 
21
21
  import { authedFetch } from './http.js'
22
- import { assertWorkspaceDirUsable } from './workspace.js'
22
+ import { assertWorkspaceDirUsable, workspaceExists } from './workspace.js'
23
23
 
24
24
  export type SetupFetcher = (path: string, init?: RequestInit) => Promise<Response>
25
25
 
@@ -56,7 +56,37 @@ export interface GithubLinkResult {
56
56
  }
57
57
 
58
58
  const DEFAULT_POLL_INTERVAL_MS = 2000
59
- const DEFAULT_POLL_TIMEOUT_MS = 180000
59
+ // ALI-1141: long enough for a slow org-picker + repo-selection walk, but the
60
+ // wait MUST be finite — when the App is already installed in the target org,
61
+ // GitHub only offers "Configure" and never redirects to our setup callback, so
62
+ // an unbounded poll would spin forever. (Still under the server's 15-minute
63
+ // nonce TTL, so the poll times out before the server would report `expired`.)
64
+ const DEFAULT_POLL_TIMEOUT_MS = 600000
65
+ // ALI-1141: after this long with no callback, the likeliest cause is the
66
+ // already-installed org above — surface it instead of heartbeating silently.
67
+ const ALREADY_INSTALLED_HINT_MS = 90000
68
+
69
+ /**
70
+ * Why the poll can never succeed when the App is already installed in the org
71
+ * (ALI-1141): GitHub's `installations/new?state=` page then only offers
72
+ * "Configure", and completing configuration does NOT redirect to the App's
73
+ * Setup URL with our state nonce — the callback that flips the row out of
74
+ * `pending` never fires. Printed as a mid-poll hint and folded into the
75
+ * timeout error so the user is never left with an unexplained hang.
76
+ */
77
+ function alreadyInstalledHintLines(teamSlug: string): string[] {
78
+ return [
79
+ ' ⚠ GitHub has not called back yet. The most likely cause: the Orizu GitHub',
80
+ ' App is ALREADY INSTALLED in that org — GitHub then only offers',
81
+ ' "Configure", and finishing configuration never notifies Orizu, so this',
82
+ ' wait cannot succeed. Remedies:',
83
+ ' • Install the App into a DIFFERENT GitHub org for this team (each org',
84
+ ' installation can be linked to exactly one Orizu team).',
85
+ ' • If the org is not linked to any Orizu team, uninstall the Orizu App',
86
+ ' from the org (GitHub → org Settings → GitHub Apps) and re-run',
87
+ ` \`orizu github link --team ${teamSlug}\` to do a fresh install.`,
88
+ ]
89
+ }
60
90
 
61
91
  function defaultSleep(ms: number): Promise<void> {
62
92
  return new Promise(resolve => setTimeout(resolve, ms))
@@ -118,10 +148,12 @@ export async function runGithubLink(teamSlug: string, io: GithubLinkIo): Promise
118
148
  const sleep = io.sleep ?? defaultSleep
119
149
  const interval = io.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
120
150
  const timeout = io.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS
121
- const deadline = now() + timeout
151
+ const started = now()
152
+ const deadline = started + timeout
122
153
  // Periodic heartbeat so a long install does not look hung; never echoes state.
123
154
  const HEARTBEAT_MS = 15000
124
- let lastHeartbeat = now()
155
+ let lastHeartbeat = started
156
+ let printedAlreadyInstalledHint = false
125
157
 
126
158
  for (;;) {
127
159
  const poll = await fetcher(`/api/cli/github/link?state=${encodeURIComponent(state)}`)
@@ -149,11 +181,23 @@ export async function runGithubLink(teamSlug: string, io: GithubLinkIo): Promise
149
181
  // status 'pending' → keep waiting.
150
182
  }
151
183
  if (now() >= deadline) {
184
+ // Same actionable message as the mid-poll hint: an already-installed org
185
+ // is the one known way to reach this timeout with nothing else wrong.
152
186
  throw new Error(
153
- 'Timed out waiting for the GitHub App install to complete. ' +
187
+ `Timed out waiting for the GitHub App install to complete after ${Math.round(timeout / 60000)} minutes. ` +
188
+ 'If the Orizu GitHub App is already installed in that org, GitHub never notifies Orizu when you finish the "Configure" flow — ' +
189
+ 'install the App into a DIFFERENT GitHub org for this team (or uninstall it from the org first if no other Orizu team uses it). ' +
154
190
  `Resume with \`orizu github link --team ${teamSlug}\` after finishing the install.`
155
191
  )
156
192
  }
193
+ if (!printedAlreadyInstalledHint && now() - started >= ALREADY_INSTALLED_HINT_MS) {
194
+ for (const line of alreadyInstalledHintLines(teamSlug)) io.print(line)
195
+ io.print(
196
+ ` Still waiting (Ctrl+C to abort; resume later with \`orizu github link --team ${teamSlug}\`)…`
197
+ )
198
+ printedAlreadyInstalledHint = true
199
+ lastHeartbeat = now()
200
+ }
157
201
  if (now() - lastHeartbeat >= HEARTBEAT_MS) {
158
202
  io.print(' …still waiting for the GitHub install to complete.')
159
203
  lastHeartbeat = now()
@@ -338,8 +382,7 @@ export async function runHostedAttach(
338
382
  io.print(` Cloning ${repoFullName}...`)
339
383
  // Clone with the helper inline + the workspace id in the env so the token is
340
384
  // brokered on demand and never materializes in the URL or on disk.
341
- gitOrThrow(
342
- io.git,
385
+ const clone = io.git(
343
386
  [
344
387
  '-c',
345
388
  `credential.helper=${CREDENTIAL_HELPER}`,
@@ -351,6 +394,22 @@ export async function runHostedAttach(
351
394
  ],
352
395
  { env: { ORIZU_WORKSPACE_ID: options.workspaceId } }
353
396
  )
397
+ if (clone.status !== 0) {
398
+ const detail = clone.stderr || `exit ${clone.status}`
399
+ // A local-only workspace (`orizu setup --local`) has orizu.team.json but no
400
+ // `.git`, so it passes `assertWorkspaceDirUsable` yet fails the clone —
401
+ // `git clone` refuses the non-empty dir up front and writes nothing, so
402
+ // every byte in there is the user's workspace, not an incomplete clone.
403
+ // Never advise deleting it.
404
+ if (workspaceExists(options.targetDir)) {
405
+ throw new Error(
406
+ `git clone failed: ${detail} ${options.targetDir} already holds a local Orizu workspace (orizu.team.json) without a hosted clone, so the workbench repo can't be cloned into it. Don't delete it — move it aside or re-run \`orizu setup\` with \`--workspace <empty-path>\` (or keep it local with \`--local\`).`
407
+ )
408
+ }
409
+ throw new Error(
410
+ `git clone failed: ${detail} Remove or empty the incomplete clone directory ${options.targetDir}, then re-run \`orizu setup\`.`
411
+ )
412
+ }
354
413
  // Persist the helper repo-locally for future pull/push (cwd-resolved).
355
414
  gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir })
356
415
  gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir })
package/src/help.ts CHANGED
@@ -772,16 +772,28 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
772
772
  },
773
773
  {
774
774
  path: ['session', 'start'],
775
- usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json]',
775
+ usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json] | orizu session start --hosted (--task <prompt> | --task-file <path>) [--duration <min>] [--project <team/project>] [--tail]',
776
776
  summary:
777
- 'Start a durable workspace session and print the session id. When run inside the workbench clone (or with --workspace), the remote session branch is fetched and checked out locally, and the exact next git commands are printed.',
777
+ 'Start a durable workspace session, or add --hosted with a task prompt to start a coordinator-managed hosted agent. Plain sessions check out their remote session branch locally when run inside the workbench clone (or with --workspace).',
778
778
  group: 'Sessions',
779
779
  options: [
780
780
  { name: '--project <team/project>', help: 'Optionally scope the session to a project in the attached workspace.' },
781
781
  { name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
782
+ { name: '--hosted', help: 'Start a coordinator-managed hosted agent session instead of a plain durable session.' },
783
+ { name: '--task <prompt>', help: 'Hosted agent task prompt. Mutually exclusive with --task-file.' },
784
+ { name: '--task-file <path>', help: 'Read the hosted agent task prompt from a UTF-8 file. Mutually exclusive with --task.' },
785
+ { name: '--duration <min>', help: 'Hosted sandbox duration in minutes (default 60, maximum 1440).' },
786
+ { name: '--model <provider/model>', help: 'Optional hosted model override.' },
787
+ { name: '--reasoning-effort <level>', help: 'Optional hosted model reasoning-effort override.' },
788
+ { name: '--title <title>', help: 'Optional hosted run title.' },
789
+ { name: '--tail', help: 'Follow the hosted run until it reaches a terminal state.' },
782
790
  { name: '--json', help: 'Emit the machine-readable session payload.' },
783
791
  ],
784
- examples: ['orizu session start', 'orizu session start --project highlight/hip --json'],
792
+ examples: [
793
+ 'orizu session start',
794
+ 'orizu session start --project highlight/hip --json',
795
+ 'orizu session start --hosted --task-file ./task.md --project highlight/hip --duration 90',
796
+ ],
785
797
  },
786
798
  {
787
799
  path: ['session', 'status'],
@@ -50,7 +50,7 @@ import {
50
50
  type HostedLoopContext,
51
51
  type HostedLoopResult,
52
52
  } from './hosted-loop.js'
53
- import { DEFAULT_EGRESS_CANARY_HOST } from './hosted-loop-lifecycle.js'
53
+ import { DEFAULT_EGRESS_CANARY_HOST, DEFAULT_HOSTED_MODEL } from './hosted-loop-lifecycle.js'
54
54
  import { resumeRunEventSink } from './hosted-run-event-sink.js'
55
55
  import { stageOrizuSkill } from './hosted-skill-staging.js'
56
56
 
@@ -62,8 +62,6 @@ export type BootFetch = (url: string, init?: RequestInit) => Promise<Response>
62
62
  * DO-path `workers/session-coordinator/src/bootstrap.ts`); kept in sync by
63
63
  * grep. Inlined (not imported) to avoid a hosted-session-cli import cycle. */
64
64
  const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder'
65
- /** Default hosted model — mirrors hosted-session-cli.ts `DEFAULT_HOSTED_MODEL`. */
66
- const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8'
67
65
 
68
66
  // -- Frozen env contract ------------------------------------------------------
69
67
 
@@ -513,6 +511,9 @@ export interface RunHostedBootOptions {
513
511
  context: HostedLoopContext
514
512
  taskPrompt: string
515
513
  bearerProvider: () => string
514
+ /** Verbatim secrets the loop's event redaction must scrub (ALI-1062: the
515
+ * boot secret — bare hex, so no shape pattern would ever catch it). */
516
+ redactSecretsList: readonly string[]
516
517
  }) => Promise<HostedLoopResult>
517
518
  now?: () => number
518
519
  log?: (line: string) => void
@@ -565,6 +566,17 @@ export async function runHostedBoot(opts: RunHostedBootOptions): Promise<HostedB
565
566
  const log = opts.log ?? ((): void => {})
566
567
  const now = opts.now ?? ((): number => Date.now())
567
568
 
569
+ // 0 — ENV HYGIENE (ALI-1062): the boot secret is the DO path's only durable
570
+ // credential (it mints agent bearers at the internet-reachable agent-token
571
+ // route for up to 24h), and `env.bootSecret` is already captured — so scrub
572
+ // the raw value from the process env NOW, before anything downstream can
573
+ // inherit it: the opencode/agent process is spawned with `{...process.env}`
574
+ // (nodeChildSpawner) and the customer `.orizu/setup.sh` hook inherits the
575
+ // env too. Nothing reads it from the env after this point — the rotation /
576
+ // boot-status closures hold `env.bootSecret`, and the git credential helper
577
+ // reads the 0600 run-dir boot-secret FILE (written in step 4).
578
+ delete processEnv.ORIZU_BOOT_SECRET
579
+
568
580
  // 1 — Pull the agent bearer (retry/backoff — the DO may still be arming).
569
581
  const bearer = await pullAgentBearer({
570
582
  agentTokenUrl: env.agentTokenUrl,
@@ -738,10 +750,20 @@ export async function runHostedBoot(opts: RunHostedBootOptions): Promise<HostedB
738
750
  const runLoop =
739
751
  opts.runLoop ??
740
752
  ((input): Promise<HostedLoopResult> =>
741
- runHostedLoop({ context: input.context, taskPrompt: input.taskPrompt, bearerProvider: input.bearerProvider }))
753
+ runHostedLoop({
754
+ context: input.context,
755
+ taskPrompt: input.taskPrompt,
756
+ bearerProvider: input.bearerProvider,
757
+ redactSecretsList: input.redactSecretsList,
758
+ }))
742
759
  const result = await runLoop({
743
760
  context: loopContext,
744
761
  taskPrompt: session.task,
762
+ // ALI-1062 exact-value redaction: the boot secret is 64 bare hex chars —
763
+ // no TOKEN_SHAPE_PATTERNS rule can catch it, so it MUST ride the loop's
764
+ // verbatim redaction list or an echoed env dump would land it in
765
+ // workbench_run_events.
766
+ redactSecretsList: [env.bootSecret],
745
767
  // Read the bearer FILE fresh per request (review F1): `startBearerRotation`
746
768
  // rewrites `bearerFileAbs` before the current bearer expires, so a run
747
769
  // exceeding the ~60-min TTL (DO cap is 24h) keeps a valid bearer. A frozen
@@ -155,6 +155,23 @@ const ANTHROPIC_ADAPTIVE_THINKING_MODELS = new Set([
155
155
  ])
156
156
  const ANTHROPIC_ADAPTIVE_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
157
157
 
158
+ /**
159
+ * Split a possibly provider-qualified model string into provider + model id.
160
+ * SINGLE-SLASH ids ("anthropic/claude-opus-4-8") are the assumed form; a bare
161
+ * id defaults to provider "anthropic". This is THE shared parse — consumed by
162
+ * BOTH the prompt path (`buildPromptRequestBody`) and the pre-prompt
163
+ * validation (`awaitOpenCodeModelResolvable`), so the two can never drift:
164
+ * parity is exactly what makes the validation meaningful (ALI-1086).
165
+ * KNOWN LIMIT: `split('/', 2)` drops everything after the second slash, so a
166
+ * 3-part id ("openrouter/anthropic/claude-x") misparses to provider
167
+ * "openrouter" + model "anthropic" — identically in both places (today's
168
+ * pre-existing prompt-path behavior, now shared rather than duplicated).
169
+ */
170
+ export function qualifyHostedModel(model: string): { providerId: string; modelId: string } {
171
+ const [providerId, modelId] = model.includes('/') ? model.split('/', 2) : ['anthropic', model]
172
+ return { providerId, modelId }
173
+ }
174
+
158
175
  export function buildPromptRequestBody(
159
176
  content: string,
160
177
  model: string | undefined,
@@ -167,7 +184,9 @@ export function buildPromptRequestBody(
167
184
  }
168
185
  if (!model) return body
169
186
 
170
- const [providerId, modelId] = model.includes('/') ? model.split('/', 2) : ['anthropic', model]
187
+ // Shared parse (see qualifyHostedModel): keeps the prompt path and the
188
+ // ALI-1086 pre-prompt validation in lockstep by construction.
189
+ const { providerId, modelId } = qualifyHostedModel(model)
171
190
  const modelSpec: Record<string, unknown> = { providerID: providerId, modelID: modelId }
172
191
 
173
192
  if (reasoningEffort) {
@@ -757,6 +776,190 @@ function errorEvent(messageId: string, error: unknown): HarnessEvent {
757
776
  return { kind: 'error', messageId, critical: true, payload: { error: message } }
758
777
  }
759
778
 
779
+ // -- Pre-prompt model validation (ALI-1086) -----------------------------------
780
+ //
781
+ // The pinned OpenCode (see OPENCODE_PINNED_VERSION) resolves model ids against
782
+ // a catalog that is its BUNDLED snapshot merged with a runtime fetch of
783
+ // https://models.dev/api.json (verified empirically on ALI-1086: fetched at
784
+ // boot when reachable, cached to <cache>/opencode/models.json, refreshed when
785
+ // stale). When models.dev is unreachable the bundled snapshot is stale
786
+ // (predates claude-opus-4-8) and the FIRST prompt dies inside the SSE stream
787
+ // with an opaque `run_failed: Model not found`. These helpers let the hosted
788
+ // loop ask the RUNNING `opencode serve` — `GET /config/providers` is the
789
+ // runtime's actual resolvable catalog — whether the requested model resolves,
790
+ // BEFORE the prompt is posted, and fail fast naming the alternatives.
791
+
792
+ /** provider id → model ids the RUNNING opencode instance can resolve. */
793
+ export type OpenCodeModelCatalog = ReadonlyMap<string, readonly string[]>
794
+
795
+ const MODEL_CATALOG_REQUEST_TIMEOUT_MS = 10_000
796
+
797
+ /**
798
+ * Read the resolvable catalog from a running `opencode serve` via
799
+ * `GET /config/providers` (shape: `{ providers: [{ id, models: { <id>: … } }] }`,
800
+ * verified against opencode-ai@1.14.41). Returns null on ANY transport/shape
801
+ * failure — the caller treats that as "cannot validate", never as "invalid".
802
+ */
803
+ export async function fetchOpenCodeModelCatalog(
804
+ baseUrl: string,
805
+ fetchImpl: HarnessFetch = globalThis.fetch as HarnessFetch,
806
+ timeoutMs: number = MODEL_CATALOG_REQUEST_TIMEOUT_MS
807
+ ): Promise<OpenCodeModelCatalog | null> {
808
+ const controller = new AbortController()
809
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
810
+ try {
811
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/config/providers`, {
812
+ signal: controller.signal,
813
+ })
814
+ if (!res.ok) return null
815
+ const data = asRecord(await res.json())
816
+ if (!Array.isArray(data.providers)) return null
817
+ const catalog = new Map<string, string[]>()
818
+ for (const entry of data.providers) {
819
+ const provider = asRecord(entry)
820
+ const id = typeof provider.id === 'string' ? provider.id : null
821
+ if (!id) continue
822
+ catalog.set(id, Object.keys(asRecord(provider.models)))
823
+ }
824
+ return catalog
825
+ } catch {
826
+ return null
827
+ } finally {
828
+ clearTimeout(timer)
829
+ }
830
+ }
831
+
832
+ function sharedPrefixLength(a: string, b: string): number {
833
+ const max = Math.min(a.length, b.length)
834
+ let i = 0
835
+ while (i < max && a[i] === b[i]) i += 1
836
+ return i
837
+ }
838
+
839
+ /** Rank candidate model ids by similarity to the requested id (longest shared
840
+ * prefix first, ties broken lexicographically DESCENDING so the newest of a
841
+ * versioned family leads) and cap the list — the fail-fast error should lead
842
+ * with the closest/newest substitutes, not a full catalog dump. */
843
+ export function rankModelAlternatives(
844
+ requested: string,
845
+ candidates: readonly string[],
846
+ limit = 8
847
+ ): string[] {
848
+ return [...new Set(candidates)]
849
+ .sort((a, b) => {
850
+ const byPrefix = sharedPrefixLength(requested, b) - sharedPrefixLength(requested, a)
851
+ return byPrefix !== 0 ? byPrefix : b.localeCompare(a)
852
+ })
853
+ .slice(0, limit)
854
+ }
855
+
856
+ export type ModelValidationOutcome =
857
+ | { kind: 'resolvable'; waitedMs: number }
858
+ | {
859
+ kind: 'unresolvable'
860
+ /** Provider-qualified requested model, e.g. "anthropic/claude-opus-4-8". */
861
+ requested: string
862
+ /** Provider-qualified resolvable substitutes, closest first. */
863
+ alternatives: string[]
864
+ /** False when the PROVIDER itself is unknown to the runtime. */
865
+ providerKnown: boolean
866
+ waitedMs: number
867
+ }
868
+ | { kind: 'skipped'; reason: string }
869
+
870
+ export interface AwaitModelResolvableOptions {
871
+ /** Base URL of the running `opencode serve`. */
872
+ baseUrl: string
873
+ /** Requested model, provider-qualified or bare (bare → "anthropic"). */
874
+ model: string
875
+ fetchImpl?: HarnessFetch
876
+ /**
877
+ * Total budget to wait for the model to become resolvable. OpenCode's
878
+ * models.dev refresh runs in the BACKGROUND at boot, so the catalog can be
879
+ * bundled-only for the first seconds after the server answers HTTP; polling
880
+ * bridges that window instead of failing a run the refresh would have fixed.
881
+ */
882
+ timeoutMs?: number
883
+ pollMs?: number
884
+ sleep?: (ms: number) => Promise<void>
885
+ now?: () => number
886
+ }
887
+
888
+ const MODEL_RESOLVE_TIMEOUT_MS = 30_000
889
+ const MODEL_RESOLVE_POLL_MS = 2_000
890
+
891
+ /**
892
+ * Decide whether the requested model is resolvable by the RUNNING opencode,
893
+ * polling briefly so the boot-time models.dev refresh has time to land.
894
+ * Fail-open on validator infrastructure, with a DELIBERATE asymmetry on null
895
+ * catalog reads:
896
+ * - null BEFORE any successful read → `skipped` immediately (dead/absent
897
+ * endpoint; opencode's own `session.error` remains the backstop, and the
898
+ * early exit keeps absent-endpoint runs — and tests — cheap);
899
+ * - null AFTER at least one successful read → a transient boot-window blip
900
+ * (5xx/drop): sleep and keep polling within the budget; a budget that
901
+ * ends in that state returns `skipped`, NEVER a false `unresolvable` off
902
+ * a flaky read.
903
+ * Only a HEALTHY catalog that never lists the model within the budget yields
904
+ * `unresolvable`, with ranked, provider-qualified alternatives.
905
+ */
906
+ export async function awaitOpenCodeModelResolvable(
907
+ opts: AwaitModelResolvableOptions
908
+ ): Promise<ModelValidationOutcome> {
909
+ const timeoutMs = opts.timeoutMs ?? MODEL_RESOLVE_TIMEOUT_MS
910
+ const pollMs = opts.pollMs ?? MODEL_RESOLVE_POLL_MS
911
+ const sleep = opts.sleep ?? ((ms: number): Promise<void> => new Promise(r => setTimeout(r, ms)))
912
+ const now = opts.now ?? ((): number => Date.now())
913
+ const { providerId, modelId } = qualifyHostedModel(opts.model)
914
+ const requested = `${providerId}/${modelId}`
915
+
916
+ const startedAt = now()
917
+ let lastGoodCatalog: OpenCodeModelCatalog | null = null
918
+ let sawTransientFailure = false
919
+ for (;;) {
920
+ const catalog = await fetchOpenCodeModelCatalog(opts.baseUrl, opts.fetchImpl)
921
+ if (catalog === null) {
922
+ if (lastGoodCatalog === null) {
923
+ // Never answered — the endpoint is dead/absent, not blipping. Skip
924
+ // immediately rather than invent a failure mode (or a slow wait) the
925
+ // run would not otherwise have.
926
+ return { kind: 'skipped', reason: 'opencode /config/providers unavailable' }
927
+ }
928
+ // Transient failure after a successful read (boot-window 5xx/drop) —
929
+ // keep polling within the budget.
930
+ sawTransientFailure = true
931
+ } else {
932
+ lastGoodCatalog = catalog
933
+ sawTransientFailure = false
934
+ if ((catalog.get(providerId) ?? []).includes(modelId)) {
935
+ return { kind: 'resolvable', waitedMs: now() - startedAt }
936
+ }
937
+ // Deliberately NOT failing fast when the provider itself is unknown:
938
+ // the models.dev refresh can add whole providers too, so the poll runs
939
+ // the full budget either way.
940
+ }
941
+ if (now() - startedAt >= timeoutMs) break
942
+ await sleep(pollMs)
943
+ }
944
+
945
+ if (sawTransientFailure || lastGoodCatalog === null) {
946
+ // The budget ended on a flaky read — never declare `unresolvable` off it.
947
+ return { kind: 'skipped', reason: 'opencode /config/providers became unavailable mid-poll' }
948
+ }
949
+
950
+ const providerKnown = lastGoodCatalog.has(providerId)
951
+ const candidates = providerKnown
952
+ ? (lastGoodCatalog.get(providerId) ?? []).map(id => `${providerId}/${id}`)
953
+ : [...lastGoodCatalog.entries()].flatMap(([pid, ids]) => ids.map(id => `${pid}/${id}`))
954
+ return {
955
+ kind: 'unresolvable',
956
+ requested,
957
+ alternatives: rankModelAlternatives(requested, candidates),
958
+ providerKnown,
959
+ waitedMs: now() - startedAt,
960
+ }
961
+ }
962
+
760
963
  // -- spawnOpenCode: the P3.5 in-sandbox launch helper ------------------------
761
964
 
762
965
  export interface OpenCodeConfigOptions {
@@ -24,6 +24,27 @@
24
24
 
25
25
  import type { HarnessEvent } from './hosted-harness.js'
26
26
 
27
+ /**
28
+ * Default provider-qualified hosted model — the SINGLE source of truth
29
+ * (ALI-1086). Imported by both the operator-path session CLI
30
+ * (`hosted-session-cli.ts`) and the DO-path boot (`hosted-boot.ts`); never
31
+ * re-inline it.
32
+ *
33
+ * PIN CONSTRAINT: the hosted OpenCode runtime is HARD-PINNED to
34
+ * `opencode-ai@1.14.41` (`OPENCODE_PINNED_VERSION`, SSE-fragile — do not bump)
35
+ * whose BUNDLED model catalog predates this model (newest bundled anthropic
36
+ * opus: claude-opus-4-7). Verified empirically on ALI-1086: 1.14.41 DOES fetch
37
+ * `https://models.dev/api.json` at boot when reachable (cached to
38
+ * `~/.cache/opencode/models.json`, refreshed when stale, disabled only by
39
+ * `OPENCODE_DISABLE_MODELS_FETCH`), so with models.dev on the sandbox egress
40
+ * allowlist (#1392, `ORIZU_HOSTED_EGRESS_ALLOWLIST`) this id resolves in new
41
+ * sandboxes. If the catalog fetch is blocked/stale anyway, the loop's
42
+ * pre-prompt validation (`awaitOpenCodeModelResolvable`) fails the run fast,
43
+ * naming the resolvable alternatives. The durable fix for the stale bundled
44
+ * catalog is the ALI-929 harness swap.
45
+ */
46
+ export const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8'
47
+
27
48
  /**
28
49
  * The run-scoped context the host orchestrator writes for the loop. Paths are
29
50
  * ABSOLUTE (or sandbox-root-relative) so the loop resolves them regardless of
@@ -51,8 +51,10 @@ import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.j
51
51
  import {
52
52
  DEFAULT_PROMPT_MAX_DURATION_MS,
53
53
  OPENCODE_PINNED_VERSION,
54
+ awaitOpenCodeModelResolvable,
54
55
  createOpenCodeHarness,
55
56
  spawnOpenCode,
57
+ type ModelValidationOutcome,
56
58
  type SpawnOpenCodeOptions,
57
59
  type SpawnedOpenCode,
58
60
  } from './hosted-harness-opencode.js'
@@ -60,6 +62,8 @@ import { createClaudeAgentHarness } from './hosted-harness-claude.js'
60
62
  import {
61
63
  drainHarnessToSink,
62
64
  resumeRunEventSink,
65
+ RunTerminalError,
66
+ TerminalDeliveryError,
63
67
  type HostedFetch,
64
68
  type TerminalStatus,
65
69
  } from './hosted-run-event-sink.js'
@@ -80,6 +84,7 @@ import {
80
84
  // coordinator. Re-exported here so this module's public surface is unchanged.
81
85
  export {
82
86
  DEFAULT_EGRESS_CANARY_HOST,
87
+ DEFAULT_HOSTED_MODEL,
83
88
  buildOpenCodeSpawnEnv,
84
89
  egressCanaryAllowedHost,
85
90
  runEgressCanary,
@@ -140,6 +145,16 @@ export interface RunHostedLoopOptions {
140
145
  createClaudeHarness?: () => AgentHarness
141
146
  spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode | Promise<SpawnedOpenCode>
142
147
  installOpenCode?: () => Promise<InstallResult>
148
+ /**
149
+ * Pre-prompt model validation against the RUNNING opencode's resolvable
150
+ * catalog (ALI-1086). Default: `awaitOpenCodeModelResolvable`, which polls
151
+ * `GET /config/providers` briefly so the boot-time models.dev refresh can
152
+ * land. An `unresolvable` outcome fails the run BEFORE the first prompt with
153
+ * an error naming the requested id and the resolvable alternatives; a
154
+ * `skipped` outcome (catalog endpoint unreachable) proceeds — opencode's own
155
+ * `session.error` remains the backstop. Injectable for tests.
156
+ */
157
+ validateModel?: (input: { baseUrl: string; model: string }) => Promise<ModelValidationOutcome>
143
158
  now?: () => number
144
159
  signal?: AbortSignal
145
160
  /** Extra verbatim secrets to redact (the bearer is added by the sink itself). */
@@ -170,6 +185,9 @@ export interface RunHostedLoopOptions {
170
185
  * `ORIZU_AGENT_IDLE_TIMEOUT_MS` or 25 minutes. <= 0 disables the watchdog.
171
186
  */
172
187
  idleTimeoutMs?: number
188
+ /** Injectable sleep for the terminal-delivery retry backoff (ALI-1065
189
+ * finding 2). Tests pass a no-op to avoid real delays. */
190
+ sleepImpl?: (ms: number) => Promise<void>
173
191
  }
174
192
 
175
193
  export interface HostedLoopResult {
@@ -211,6 +229,16 @@ function resolveIdleTimeoutMs(override: number | undefined): number {
211
229
  return DEFAULT_AGENT_IDLE_TIMEOUT_MS
212
230
  }
213
231
 
232
+ /**
233
+ * Loop-level retry schedule for an undeliverable TERMINAL transition (ALI-1065
234
+ * finding 2). Each entry is a pause before one more full sink delivery cycle
235
+ * (which itself retries ~4 times over ~350ms), so a transport blip of a few
236
+ * seconds recovers with the ORIGINAL status instead of being re-recorded as
237
+ * 'failed'. Bounded: a persistently unreachable control plane fails the
238
+ * DELIVERY visibly (result.error) without recording a wrong outcome.
239
+ */
240
+ export const TERMINAL_DELIVERY_RETRY_DELAYS_MS: readonly number[] = [2_000, 8_000]
241
+
214
242
  /** Model-key env vars whose value must be scrubbed from every run event. */
215
243
  const MODEL_KEY_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']
216
244
 
@@ -515,6 +543,43 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
515
543
  })
516
544
  }
517
545
 
546
+ // Fail-fast model validation (ALI-1086): ask the RUNNING opencode whether
547
+ // the requested model is resolvable BEFORE the first prompt. The pinned
548
+ // opencode's bundled catalog is stale (predates claude-opus-4-8) and only
549
+ // a boot-time models.dev fetch (#1392 allowlists it) refreshes it — when
550
+ // that fetch is blocked, the first prompt used to die inside the SSE
551
+ // stream with an opaque `run_failed: Model not found`. Now the run fails
552
+ // immediately with a structured artifact + an error naming the requested
553
+ // id AND the resolvable alternatives. Fail-open on validator
554
+ // infrastructure: an unreachable catalog endpoint (`skipped`) proceeds
555
+ // silently — opencode's own session.error remains the backstop. Healthy
556
+ // and skipped runs append NO event, so the ALI-929 cross-harness
557
+ // stream-identity invariant is untouched (validation exists only on the
558
+ // opencode path; the Claude-SDK driver resolves models itself).
559
+ const validateModel = opts.validateModel ?? awaitOpenCodeModelResolvable
560
+ const validation = await validateModel({ baseUrl: spawned.baseUrl, model: context.model })
561
+ if (validation.kind === 'unresolvable') {
562
+ await sink.append({
563
+ kind: 'artifact',
564
+ payload: {
565
+ step: 'model_validation',
566
+ ok: false,
567
+ requestedModel: validation.requested,
568
+ resolvableAlternatives: validation.alternatives,
569
+ providerKnown: validation.providerKnown,
570
+ opencodeVersion: pinnedVersion,
571
+ waitedMs: validation.waitedMs,
572
+ },
573
+ })
574
+ throw new Error(
575
+ `requested model ${validation.requested} is not resolvable by the pinned ` +
576
+ `opencode runtime (opencode-ai@${pinnedVersion}); resolvable alternatives: ` +
577
+ `${validation.alternatives.join(', ') || '(none)'} — retry with --model ` +
578
+ `set to one of these (stale bundled catalog + blocked models.dev refresh; ` +
579
+ `see ALI-1086, durable fix ALI-929)`
580
+ )
581
+ }
582
+
518
583
  // Derive the per-prompt max-duration cap from the sandbox budget so a long
519
584
  // `--duration` run is not killed at the hard-coded 90-min cap while its
520
585
  // sandbox is still alive; floored at the harness default for short/undated
@@ -591,10 +656,58 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
591
656
  return { status, agentSessionId, installOk, error: null }
592
657
  } catch (error) {
593
658
  const message = error instanceof Error ? error.message : String(error)
594
- // Fail closed: never leave the run non-terminal. finish() is idempotent and
595
- // no-ops when the drain already sealed the sink. Harvest FIRST (partial work
596
- // is valuable, incl. an idle-watchdog abort) — before the terminal seals.
659
+ // Harvest FIRST on every abnormal path (partial work is valuable, incl. an
660
+ // idle-watchdog abort) — before any terminal write seals the sink.
597
661
  await runAutoHarvest()
662
+
663
+ // ALI-1065 finding 2: an undeliverable TERMINAL transition must NEVER be
664
+ // re-recorded as 'failed' — the run's outcome was already decided; only its
665
+ // DELIVERY failed. Retry the delivery with the ORIGINAL status (spaced past
666
+ // the sink's own ~350ms retry window, so a ~1s transport blip recovers),
667
+ // and if it still cannot land, fail the DELIVERY visibly: return the
668
+ // intended status with a non-null error, which the boot reports via its
669
+ // boot-status callback, and the coordinator's session-end finalizer sweeps
670
+ // the still-'running' row server-side with an explicit marker.
671
+ if (error instanceof TerminalDeliveryError) {
672
+ const sleep = opts.sleepImpl ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)))
673
+ for (const delayMs of TERMINAL_DELIVERY_RETRY_DELAYS_MS) {
674
+ if (sink.sealed) break
675
+ await sleep(delayMs)
676
+ try {
677
+ await sink.finish(error.intendedStatus, error.finishOptions)
678
+ return { status: error.intendedStatus, agentSessionId, installOk, error: null }
679
+ } catch (retryError) {
680
+ // The run went terminal server-side while we were retrying: accept
681
+ // the server record (Orizu records win) instead of retrying further.
682
+ if (retryError instanceof RunTerminalError) {
683
+ return { status: 'cancelled', agentSessionId, installOk, error: null }
684
+ }
685
+ // Otherwise keep retrying within the bounded schedule; never change
686
+ // the status.
687
+ }
688
+ }
689
+ if (sink.sealed) {
690
+ // A concurrent/preceding delivery landed after all — the record is
691
+ // terminal server-side with our intended status semantics preserved.
692
+ return { status: error.intendedStatus, agentSessionId, installOk, error: null }
693
+ }
694
+ return {
695
+ status: error.intendedStatus,
696
+ agentSessionId,
697
+ installOk,
698
+ error: `terminal delivery failed (intended status preserved): ${message}`,
699
+ }
700
+ }
701
+
702
+ // The run went terminal SERVER-side (cancel/finish out from under this
703
+ // writer): the sink sealed itself; accept the server record (Orizu records
704
+ // win) instead of fabricating a local failure.
705
+ if (error instanceof RunTerminalError) {
706
+ return { status: 'cancelled', agentSessionId, installOk, error: null }
707
+ }
708
+
709
+ // Fail closed: never leave the run non-terminal. finish() is idempotent and
710
+ // no-ops when the drain already sealed the sink.
598
711
  if (!sink.sealed) {
599
712
  try {
600
713
  await sink.finish('failed', { summary: { error: message } })