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.
- package/dist/github-setup.js +60 -6
- package/dist/help.js +15 -3
- package/dist/hosted-boot.js +22 -4
- package/dist/hosted-harness-opencode.js +144 -1
- package/dist/hosted-loop-lifecycle.js +20 -0
- package/dist/hosted-loop.js +96 -6
- package/dist/hosted-run-event-sink.js +91 -18
- package/dist/hosted-session-cli.js +133 -11
- package/dist/index.js +20 -7
- package/dist/merge-job.js +26 -3
- package/dist/option-validation.js +54 -0
- package/dist/workbench-cli.js +31 -5
- package/package.json +1 -1
- package/src/github-setup.ts +66 -7
- package/src/help.ts +15 -3
- package/src/hosted-boot.ts +26 -4
- package/src/hosted-harness-opencode.ts +204 -1
- package/src/hosted-loop-lifecycle.ts +21 -0
- package/src/hosted-loop.ts +116 -3
- package/src/hosted-run-event-sink.ts +89 -22
- package/src/hosted-session-cli.ts +144 -11
- package/src/index.ts +22 -6
- package/src/merge-job.ts +25 -6
- package/src/option-validation.ts +63 -0
- package/src/workbench-cli.ts +34 -5
- package/vendor/skills/orizu-cli/SKILL.md +2 -2
package/dist/github-setup.js
CHANGED
|
@@ -17,7 +17,7 @@ import { spawnSync } from 'child_process';
|
|
|
17
17
|
import { existsSync, mkdirSync } from 'fs';
|
|
18
18
|
import { join } from 'path';
|
|
19
19
|
import { authedFetch } from './http.js';
|
|
20
|
-
import { assertWorkspaceDirUsable } from './workspace.js';
|
|
20
|
+
import { assertWorkspaceDirUsable, workspaceExists } from './workspace.js';
|
|
21
21
|
/**
|
|
22
22
|
* Strip terminal control characters from server-supplied strings before they
|
|
23
23
|
* are printed (mirrors index.ts `sanitizeTerminalText`), so a hostile or
|
|
@@ -28,7 +28,36 @@ function sanitizeServerText(value) {
|
|
|
28
28
|
return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
|
|
29
29
|
}
|
|
30
30
|
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
31
|
-
|
|
31
|
+
// ALI-1141: long enough for a slow org-picker + repo-selection walk, but the
|
|
32
|
+
// wait MUST be finite — when the App is already installed in the target org,
|
|
33
|
+
// GitHub only offers "Configure" and never redirects to our setup callback, so
|
|
34
|
+
// an unbounded poll would spin forever. (Still under the server's 15-minute
|
|
35
|
+
// nonce TTL, so the poll times out before the server would report `expired`.)
|
|
36
|
+
const DEFAULT_POLL_TIMEOUT_MS = 600000;
|
|
37
|
+
// ALI-1141: after this long with no callback, the likeliest cause is the
|
|
38
|
+
// already-installed org above — surface it instead of heartbeating silently.
|
|
39
|
+
const ALREADY_INSTALLED_HINT_MS = 90000;
|
|
40
|
+
/**
|
|
41
|
+
* Why the poll can never succeed when the App is already installed in the org
|
|
42
|
+
* (ALI-1141): GitHub's `installations/new?state=` page then only offers
|
|
43
|
+
* "Configure", and completing configuration does NOT redirect to the App's
|
|
44
|
+
* Setup URL with our state nonce — the callback that flips the row out of
|
|
45
|
+
* `pending` never fires. Printed as a mid-poll hint and folded into the
|
|
46
|
+
* timeout error so the user is never left with an unexplained hang.
|
|
47
|
+
*/
|
|
48
|
+
function alreadyInstalledHintLines(teamSlug) {
|
|
49
|
+
return [
|
|
50
|
+
' ⚠ GitHub has not called back yet. The most likely cause: the Orizu GitHub',
|
|
51
|
+
' App is ALREADY INSTALLED in that org — GitHub then only offers',
|
|
52
|
+
' "Configure", and finishing configuration never notifies Orizu, so this',
|
|
53
|
+
' wait cannot succeed. Remedies:',
|
|
54
|
+
' • Install the App into a DIFFERENT GitHub org for this team (each org',
|
|
55
|
+
' installation can be linked to exactly one Orizu team).',
|
|
56
|
+
' • If the org is not linked to any Orizu team, uninstall the Orizu App',
|
|
57
|
+
' from the org (GitHub → org Settings → GitHub Apps) and re-run',
|
|
58
|
+
` \`orizu github link --team ${teamSlug}\` to do a fresh install.`,
|
|
59
|
+
];
|
|
60
|
+
}
|
|
32
61
|
function defaultSleep(ms) {
|
|
33
62
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
34
63
|
}
|
|
@@ -83,10 +112,12 @@ export async function runGithubLink(teamSlug, io) {
|
|
|
83
112
|
const sleep = io.sleep ?? defaultSleep;
|
|
84
113
|
const interval = io.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
85
114
|
const timeout = io.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
|
|
86
|
-
const
|
|
115
|
+
const started = now();
|
|
116
|
+
const deadline = started + timeout;
|
|
87
117
|
// Periodic heartbeat so a long install does not look hung; never echoes state.
|
|
88
118
|
const HEARTBEAT_MS = 15000;
|
|
89
|
-
let lastHeartbeat =
|
|
119
|
+
let lastHeartbeat = started;
|
|
120
|
+
let printedAlreadyInstalledHint = false;
|
|
90
121
|
for (;;) {
|
|
91
122
|
const poll = await fetcher(`/api/cli/github/link?state=${encodeURIComponent(state)}`);
|
|
92
123
|
if (poll.ok) {
|
|
@@ -107,9 +138,20 @@ export async function runGithubLink(teamSlug, io) {
|
|
|
107
138
|
// status 'pending' → keep waiting.
|
|
108
139
|
}
|
|
109
140
|
if (now() >= deadline) {
|
|
110
|
-
|
|
141
|
+
// Same actionable message as the mid-poll hint: an already-installed org
|
|
142
|
+
// is the one known way to reach this timeout with nothing else wrong.
|
|
143
|
+
throw new Error(`Timed out waiting for the GitHub App install to complete after ${Math.round(timeout / 60000)} minutes. ` +
|
|
144
|
+
'If the Orizu GitHub App is already installed in that org, GitHub never notifies Orizu when you finish the "Configure" flow — ' +
|
|
145
|
+
'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). ' +
|
|
111
146
|
`Resume with \`orizu github link --team ${teamSlug}\` after finishing the install.`);
|
|
112
147
|
}
|
|
148
|
+
if (!printedAlreadyInstalledHint && now() - started >= ALREADY_INSTALLED_HINT_MS) {
|
|
149
|
+
for (const line of alreadyInstalledHintLines(teamSlug))
|
|
150
|
+
io.print(line);
|
|
151
|
+
io.print(` Still waiting (Ctrl+C to abort; resume later with \`orizu github link --team ${teamSlug}\`)…`);
|
|
152
|
+
printedAlreadyInstalledHint = true;
|
|
153
|
+
lastHeartbeat = now();
|
|
154
|
+
}
|
|
113
155
|
if (now() - lastHeartbeat >= HEARTBEAT_MS) {
|
|
114
156
|
io.print(' …still waiting for the GitHub install to complete.');
|
|
115
157
|
lastHeartbeat = now();
|
|
@@ -216,7 +258,7 @@ export async function runHostedAttach(options, io) {
|
|
|
216
258
|
io.print(` Cloning ${repoFullName}...`);
|
|
217
259
|
// Clone with the helper inline + the workspace id in the env so the token is
|
|
218
260
|
// brokered on demand and never materializes in the URL or on disk.
|
|
219
|
-
|
|
261
|
+
const clone = io.git([
|
|
220
262
|
'-c',
|
|
221
263
|
`credential.helper=${CREDENTIAL_HELPER}`,
|
|
222
264
|
'-c',
|
|
@@ -225,6 +267,18 @@ export async function runHostedAttach(options, io) {
|
|
|
225
267
|
cleanUrl,
|
|
226
268
|
options.targetDir,
|
|
227
269
|
], { env: { ORIZU_WORKSPACE_ID: options.workspaceId } });
|
|
270
|
+
if (clone.status !== 0) {
|
|
271
|
+
const detail = clone.stderr || `exit ${clone.status}`;
|
|
272
|
+
// A local-only workspace (`orizu setup --local`) has orizu.team.json but no
|
|
273
|
+
// `.git`, so it passes `assertWorkspaceDirUsable` yet fails the clone —
|
|
274
|
+
// `git clone` refuses the non-empty dir up front and writes nothing, so
|
|
275
|
+
// every byte in there is the user's workspace, not an incomplete clone.
|
|
276
|
+
// Never advise deleting it.
|
|
277
|
+
if (workspaceExists(options.targetDir)) {
|
|
278
|
+
throw new Error(`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\`).`);
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`git clone failed: ${detail} Remove or empty the incomplete clone directory ${options.targetDir}, then re-run \`orizu setup\`.`);
|
|
281
|
+
}
|
|
228
282
|
// Persist the helper repo-locally for future pull/push (cwd-resolved).
|
|
229
283
|
gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir });
|
|
230
284
|
gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir });
|
package/dist/help.js
CHANGED
|
@@ -747,15 +747,27 @@ export const COMMAND_DOCS = [
|
|
|
747
747
|
},
|
|
748
748
|
{
|
|
749
749
|
path: ['session', 'start'],
|
|
750
|
-
usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json]',
|
|
751
|
-
summary: 'Start a durable workspace session
|
|
750
|
+
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]',
|
|
751
|
+
summary: '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).',
|
|
752
752
|
group: 'Sessions',
|
|
753
753
|
options: [
|
|
754
754
|
{ name: '--project <team/project>', help: 'Optionally scope the session to a project in the attached workspace.' },
|
|
755
755
|
{ name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
|
|
756
|
+
{ name: '--hosted', help: 'Start a coordinator-managed hosted agent session instead of a plain durable session.' },
|
|
757
|
+
{ name: '--task <prompt>', help: 'Hosted agent task prompt. Mutually exclusive with --task-file.' },
|
|
758
|
+
{ name: '--task-file <path>', help: 'Read the hosted agent task prompt from a UTF-8 file. Mutually exclusive with --task.' },
|
|
759
|
+
{ name: '--duration <min>', help: 'Hosted sandbox duration in minutes (default 60, maximum 1440).' },
|
|
760
|
+
{ name: '--model <provider/model>', help: 'Optional hosted model override.' },
|
|
761
|
+
{ name: '--reasoning-effort <level>', help: 'Optional hosted model reasoning-effort override.' },
|
|
762
|
+
{ name: '--title <title>', help: 'Optional hosted run title.' },
|
|
763
|
+
{ name: '--tail', help: 'Follow the hosted run until it reaches a terminal state.' },
|
|
756
764
|
{ name: '--json', help: 'Emit the machine-readable session payload.' },
|
|
757
765
|
],
|
|
758
|
-
examples: [
|
|
766
|
+
examples: [
|
|
767
|
+
'orizu session start',
|
|
768
|
+
'orizu session start --project highlight/hip --json',
|
|
769
|
+
'orizu session start --hosted --task-file ./task.md --project highlight/hip --duration 90',
|
|
770
|
+
],
|
|
759
771
|
},
|
|
760
772
|
{
|
|
761
773
|
path: ['session', 'status'],
|
package/dist/hosted-boot.js
CHANGED
|
@@ -33,7 +33,7 @@ import { spawnSync } from 'child_process';
|
|
|
33
33
|
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from 'fs';
|
|
34
34
|
import { AGENT_GIT_IDENTITY, BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, REPO_CRED_CACHE_BASENAME, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
|
|
35
35
|
import { INJECTED_ENV_VARS_ENV, runHostedLoop, } from './hosted-loop.js';
|
|
36
|
-
import { DEFAULT_EGRESS_CANARY_HOST } from './hosted-loop-lifecycle.js';
|
|
36
|
+
import { DEFAULT_EGRESS_CANARY_HOST, DEFAULT_HOSTED_MODEL } from './hosted-loop-lifecycle.js';
|
|
37
37
|
import { resumeRunEventSink } from './hosted-run-event-sink.js';
|
|
38
38
|
import { stageOrizuSkill } from './hosted-skill-staging.js';
|
|
39
39
|
/** Non-secret placeholder model key (firewall brokers the real org key on
|
|
@@ -42,8 +42,6 @@ import { stageOrizuSkill } from './hosted-skill-staging.js';
|
|
|
42
42
|
* DO-path `workers/session-coordinator/src/bootstrap.ts`); kept in sync by
|
|
43
43
|
* grep. Inlined (not imported) to avoid a hosted-session-cli import cycle. */
|
|
44
44
|
const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder';
|
|
45
|
-
/** Default hosted model — mirrors hosted-session-cli.ts `DEFAULT_HOSTED_MODEL`. */
|
|
46
|
-
const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8';
|
|
47
45
|
// -- Frozen env contract ------------------------------------------------------
|
|
48
46
|
export const REQUIRED_BOOT_ENV_VARS = [
|
|
49
47
|
'ORIZU_BOOT_SECRET',
|
|
@@ -371,6 +369,16 @@ export async function runHostedBoot(opts) {
|
|
|
371
369
|
const readFile = opts.readFile ?? ((path) => readFileSync(path, 'utf8'));
|
|
372
370
|
const log = opts.log ?? (() => { });
|
|
373
371
|
const now = opts.now ?? (() => Date.now());
|
|
372
|
+
// 0 — ENV HYGIENE (ALI-1062): the boot secret is the DO path's only durable
|
|
373
|
+
// credential (it mints agent bearers at the internet-reachable agent-token
|
|
374
|
+
// route for up to 24h), and `env.bootSecret` is already captured — so scrub
|
|
375
|
+
// the raw value from the process env NOW, before anything downstream can
|
|
376
|
+
// inherit it: the opencode/agent process is spawned with `{...process.env}`
|
|
377
|
+
// (nodeChildSpawner) and the customer `.orizu/setup.sh` hook inherits the
|
|
378
|
+
// env too. Nothing reads it from the env after this point — the rotation /
|
|
379
|
+
// boot-status closures hold `env.bootSecret`, and the git credential helper
|
|
380
|
+
// reads the 0600 run-dir boot-secret FILE (written in step 4).
|
|
381
|
+
delete processEnv.ORIZU_BOOT_SECRET;
|
|
374
382
|
// 1 — Pull the agent bearer (retry/backoff — the DO may still be arming).
|
|
375
383
|
const bearer = await pullAgentBearer({
|
|
376
384
|
agentTokenUrl: env.agentTokenUrl,
|
|
@@ -533,10 +541,20 @@ export async function runHostedBoot(opts) {
|
|
|
533
541
|
});
|
|
534
542
|
try {
|
|
535
543
|
const runLoop = opts.runLoop ??
|
|
536
|
-
((input) => runHostedLoop({
|
|
544
|
+
((input) => runHostedLoop({
|
|
545
|
+
context: input.context,
|
|
546
|
+
taskPrompt: input.taskPrompt,
|
|
547
|
+
bearerProvider: input.bearerProvider,
|
|
548
|
+
redactSecretsList: input.redactSecretsList,
|
|
549
|
+
}));
|
|
537
550
|
const result = await runLoop({
|
|
538
551
|
context: loopContext,
|
|
539
552
|
taskPrompt: session.task,
|
|
553
|
+
// ALI-1062 exact-value redaction: the boot secret is 64 bare hex chars —
|
|
554
|
+
// no TOKEN_SHAPE_PATTERNS rule can catch it, so it MUST ride the loop's
|
|
555
|
+
// verbatim redaction list or an echoed env dump would land it in
|
|
556
|
+
// workbench_run_events.
|
|
557
|
+
redactSecretsList: [env.bootSecret],
|
|
540
558
|
// Read the bearer FILE fresh per request (review F1): `startBearerRotation`
|
|
541
559
|
// rewrites `bearerFileAbs` before the current bearer expires, so a run
|
|
542
560
|
// exceeding the ~60-min TTL (DO cap is 24h) keeps a valid bearer. A frozen
|
|
@@ -120,6 +120,22 @@ const ANTHROPIC_ADAPTIVE_THINKING_MODELS = new Set([
|
|
|
120
120
|
'claude-sonnet-4-6',
|
|
121
121
|
]);
|
|
122
122
|
const ANTHROPIC_ADAPTIVE_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
123
|
+
/**
|
|
124
|
+
* Split a possibly provider-qualified model string into provider + model id.
|
|
125
|
+
* SINGLE-SLASH ids ("anthropic/claude-opus-4-8") are the assumed form; a bare
|
|
126
|
+
* id defaults to provider "anthropic". This is THE shared parse — consumed by
|
|
127
|
+
* BOTH the prompt path (`buildPromptRequestBody`) and the pre-prompt
|
|
128
|
+
* validation (`awaitOpenCodeModelResolvable`), so the two can never drift:
|
|
129
|
+
* parity is exactly what makes the validation meaningful (ALI-1086).
|
|
130
|
+
* KNOWN LIMIT: `split('/', 2)` drops everything after the second slash, so a
|
|
131
|
+
* 3-part id ("openrouter/anthropic/claude-x") misparses to provider
|
|
132
|
+
* "openrouter" + model "anthropic" — identically in both places (today's
|
|
133
|
+
* pre-existing prompt-path behavior, now shared rather than duplicated).
|
|
134
|
+
*/
|
|
135
|
+
export function qualifyHostedModel(model) {
|
|
136
|
+
const [providerId, modelId] = model.includes('/') ? model.split('/', 2) : ['anthropic', model];
|
|
137
|
+
return { providerId, modelId };
|
|
138
|
+
}
|
|
123
139
|
export function buildPromptRequestBody(content, model, opencodeMessageId, reasoningEffort) {
|
|
124
140
|
const body = {
|
|
125
141
|
parts: [{ type: 'text', text: content }],
|
|
@@ -127,7 +143,9 @@ export function buildPromptRequestBody(content, model, opencodeMessageId, reason
|
|
|
127
143
|
};
|
|
128
144
|
if (!model)
|
|
129
145
|
return body;
|
|
130
|
-
|
|
146
|
+
// Shared parse (see qualifyHostedModel): keeps the prompt path and the
|
|
147
|
+
// ALI-1086 pre-prompt validation in lockstep by construction.
|
|
148
|
+
const { providerId, modelId } = qualifyHostedModel(model);
|
|
131
149
|
const modelSpec = { providerID: providerId, modelID: modelId };
|
|
132
150
|
if (reasoningEffort) {
|
|
133
151
|
if (providerId === 'anthropic') {
|
|
@@ -688,6 +706,131 @@ function errorEvent(messageId, error) {
|
|
|
688
706
|
const message = error instanceof Error ? error.message : String(error);
|
|
689
707
|
return { kind: 'error', messageId, critical: true, payload: { error: message } };
|
|
690
708
|
}
|
|
709
|
+
const MODEL_CATALOG_REQUEST_TIMEOUT_MS = 10_000;
|
|
710
|
+
/**
|
|
711
|
+
* Read the resolvable catalog from a running `opencode serve` via
|
|
712
|
+
* `GET /config/providers` (shape: `{ providers: [{ id, models: { <id>: … } }] }`,
|
|
713
|
+
* verified against opencode-ai@1.14.41). Returns null on ANY transport/shape
|
|
714
|
+
* failure — the caller treats that as "cannot validate", never as "invalid".
|
|
715
|
+
*/
|
|
716
|
+
export async function fetchOpenCodeModelCatalog(baseUrl, fetchImpl = globalThis.fetch, timeoutMs = MODEL_CATALOG_REQUEST_TIMEOUT_MS) {
|
|
717
|
+
const controller = new AbortController();
|
|
718
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
719
|
+
try {
|
|
720
|
+
const res = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/config/providers`, {
|
|
721
|
+
signal: controller.signal,
|
|
722
|
+
});
|
|
723
|
+
if (!res.ok)
|
|
724
|
+
return null;
|
|
725
|
+
const data = asRecord(await res.json());
|
|
726
|
+
if (!Array.isArray(data.providers))
|
|
727
|
+
return null;
|
|
728
|
+
const catalog = new Map();
|
|
729
|
+
for (const entry of data.providers) {
|
|
730
|
+
const provider = asRecord(entry);
|
|
731
|
+
const id = typeof provider.id === 'string' ? provider.id : null;
|
|
732
|
+
if (!id)
|
|
733
|
+
continue;
|
|
734
|
+
catalog.set(id, Object.keys(asRecord(provider.models)));
|
|
735
|
+
}
|
|
736
|
+
return catalog;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
clearTimeout(timer);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function sharedPrefixLength(a, b) {
|
|
746
|
+
const max = Math.min(a.length, b.length);
|
|
747
|
+
let i = 0;
|
|
748
|
+
while (i < max && a[i] === b[i])
|
|
749
|
+
i += 1;
|
|
750
|
+
return i;
|
|
751
|
+
}
|
|
752
|
+
/** Rank candidate model ids by similarity to the requested id (longest shared
|
|
753
|
+
* prefix first, ties broken lexicographically DESCENDING so the newest of a
|
|
754
|
+
* versioned family leads) and cap the list — the fail-fast error should lead
|
|
755
|
+
* with the closest/newest substitutes, not a full catalog dump. */
|
|
756
|
+
export function rankModelAlternatives(requested, candidates, limit = 8) {
|
|
757
|
+
return [...new Set(candidates)]
|
|
758
|
+
.sort((a, b) => {
|
|
759
|
+
const byPrefix = sharedPrefixLength(requested, b) - sharedPrefixLength(requested, a);
|
|
760
|
+
return byPrefix !== 0 ? byPrefix : b.localeCompare(a);
|
|
761
|
+
})
|
|
762
|
+
.slice(0, limit);
|
|
763
|
+
}
|
|
764
|
+
const MODEL_RESOLVE_TIMEOUT_MS = 30_000;
|
|
765
|
+
const MODEL_RESOLVE_POLL_MS = 2_000;
|
|
766
|
+
/**
|
|
767
|
+
* Decide whether the requested model is resolvable by the RUNNING opencode,
|
|
768
|
+
* polling briefly so the boot-time models.dev refresh has time to land.
|
|
769
|
+
* Fail-open on validator infrastructure, with a DELIBERATE asymmetry on null
|
|
770
|
+
* catalog reads:
|
|
771
|
+
* - null BEFORE any successful read → `skipped` immediately (dead/absent
|
|
772
|
+
* endpoint; opencode's own `session.error` remains the backstop, and the
|
|
773
|
+
* early exit keeps absent-endpoint runs — and tests — cheap);
|
|
774
|
+
* - null AFTER at least one successful read → a transient boot-window blip
|
|
775
|
+
* (5xx/drop): sleep and keep polling within the budget; a budget that
|
|
776
|
+
* ends in that state returns `skipped`, NEVER a false `unresolvable` off
|
|
777
|
+
* a flaky read.
|
|
778
|
+
* Only a HEALTHY catalog that never lists the model within the budget yields
|
|
779
|
+
* `unresolvable`, with ranked, provider-qualified alternatives.
|
|
780
|
+
*/
|
|
781
|
+
export async function awaitOpenCodeModelResolvable(opts) {
|
|
782
|
+
const timeoutMs = opts.timeoutMs ?? MODEL_RESOLVE_TIMEOUT_MS;
|
|
783
|
+
const pollMs = opts.pollMs ?? MODEL_RESOLVE_POLL_MS;
|
|
784
|
+
const sleep = opts.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
|
|
785
|
+
const now = opts.now ?? (() => Date.now());
|
|
786
|
+
const { providerId, modelId } = qualifyHostedModel(opts.model);
|
|
787
|
+
const requested = `${providerId}/${modelId}`;
|
|
788
|
+
const startedAt = now();
|
|
789
|
+
let lastGoodCatalog = null;
|
|
790
|
+
let sawTransientFailure = false;
|
|
791
|
+
for (;;) {
|
|
792
|
+
const catalog = await fetchOpenCodeModelCatalog(opts.baseUrl, opts.fetchImpl);
|
|
793
|
+
if (catalog === null) {
|
|
794
|
+
if (lastGoodCatalog === null) {
|
|
795
|
+
// Never answered — the endpoint is dead/absent, not blipping. Skip
|
|
796
|
+
// immediately rather than invent a failure mode (or a slow wait) the
|
|
797
|
+
// run would not otherwise have.
|
|
798
|
+
return { kind: 'skipped', reason: 'opencode /config/providers unavailable' };
|
|
799
|
+
}
|
|
800
|
+
// Transient failure after a successful read (boot-window 5xx/drop) —
|
|
801
|
+
// keep polling within the budget.
|
|
802
|
+
sawTransientFailure = true;
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
lastGoodCatalog = catalog;
|
|
806
|
+
sawTransientFailure = false;
|
|
807
|
+
if ((catalog.get(providerId) ?? []).includes(modelId)) {
|
|
808
|
+
return { kind: 'resolvable', waitedMs: now() - startedAt };
|
|
809
|
+
}
|
|
810
|
+
// Deliberately NOT failing fast when the provider itself is unknown:
|
|
811
|
+
// the models.dev refresh can add whole providers too, so the poll runs
|
|
812
|
+
// the full budget either way.
|
|
813
|
+
}
|
|
814
|
+
if (now() - startedAt >= timeoutMs)
|
|
815
|
+
break;
|
|
816
|
+
await sleep(pollMs);
|
|
817
|
+
}
|
|
818
|
+
if (sawTransientFailure || lastGoodCatalog === null) {
|
|
819
|
+
// The budget ended on a flaky read — never declare `unresolvable` off it.
|
|
820
|
+
return { kind: 'skipped', reason: 'opencode /config/providers became unavailable mid-poll' };
|
|
821
|
+
}
|
|
822
|
+
const providerKnown = lastGoodCatalog.has(providerId);
|
|
823
|
+
const candidates = providerKnown
|
|
824
|
+
? (lastGoodCatalog.get(providerId) ?? []).map(id => `${providerId}/${id}`)
|
|
825
|
+
: [...lastGoodCatalog.entries()].flatMap(([pid, ids]) => ids.map(id => `${pid}/${id}`));
|
|
826
|
+
return {
|
|
827
|
+
kind: 'unresolvable',
|
|
828
|
+
requested,
|
|
829
|
+
alternatives: rankModelAlternatives(requested, candidates),
|
|
830
|
+
providerKnown,
|
|
831
|
+
waitedMs: now() - startedAt,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
691
834
|
/**
|
|
692
835
|
* Tools DENIED in a hosted headless run (ALI-1037). OpenCode exposes a `question`
|
|
693
836
|
* permission key (docs/permissions) that gates the interactive "ask the user a
|
|
@@ -21,6 +21,26 @@
|
|
|
21
21
|
* `hosted-loop.ts` re-exports everything here, so the CLI's import surface
|
|
22
22
|
* (`@/packages/cli/src/hosted-loop`) is unchanged.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* Default provider-qualified hosted model — the SINGLE source of truth
|
|
26
|
+
* (ALI-1086). Imported by both the operator-path session CLI
|
|
27
|
+
* (`hosted-session-cli.ts`) and the DO-path boot (`hosted-boot.ts`); never
|
|
28
|
+
* re-inline it.
|
|
29
|
+
*
|
|
30
|
+
* PIN CONSTRAINT: the hosted OpenCode runtime is HARD-PINNED to
|
|
31
|
+
* `opencode-ai@1.14.41` (`OPENCODE_PINNED_VERSION`, SSE-fragile — do not bump)
|
|
32
|
+
* whose BUNDLED model catalog predates this model (newest bundled anthropic
|
|
33
|
+
* opus: claude-opus-4-7). Verified empirically on ALI-1086: 1.14.41 DOES fetch
|
|
34
|
+
* `https://models.dev/api.json` at boot when reachable (cached to
|
|
35
|
+
* `~/.cache/opencode/models.json`, refreshed when stale, disabled only by
|
|
36
|
+
* `OPENCODE_DISABLE_MODELS_FETCH`), so with models.dev on the sandbox egress
|
|
37
|
+
* allowlist (#1392, `ORIZU_HOSTED_EGRESS_ALLOWLIST`) this id resolves in new
|
|
38
|
+
* sandboxes. If the catalog fetch is blocked/stale anyway, the loop's
|
|
39
|
+
* pre-prompt validation (`awaitOpenCodeModelResolvable`) fails the run fast,
|
|
40
|
+
* naming the resolvable alternatives. The durable fix for the stale bundled
|
|
41
|
+
* catalog is the ALI-929 harness swap.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8';
|
|
24
44
|
/**
|
|
25
45
|
* Environment injected into the spawned OpenCode server so the AGENT's bash sees a
|
|
26
46
|
* PRE-AUTHENTICATED `orizu` CLI (ALI-1044). ORIZU_TOKEN_FILE points at the 0600
|
package/dist/hosted-loop.js
CHANGED
|
@@ -39,14 +39,14 @@ import { join } from 'path';
|
|
|
39
39
|
import { HOSTED_TASK_PREAMBLE, PREBAKED_MARKER_PATH, SETUP_HOOK_RELATIVE_PATH, composeHostedTaskPrompt, parsePrebakedMarker, } from './hosted-runtime-assets.js';
|
|
40
40
|
import { harvestWorkspace } from './hosted-harvest.js';
|
|
41
41
|
import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.js';
|
|
42
|
-
import { DEFAULT_PROMPT_MAX_DURATION_MS, OPENCODE_PINNED_VERSION, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
|
|
42
|
+
import { DEFAULT_PROMPT_MAX_DURATION_MS, OPENCODE_PINNED_VERSION, awaitOpenCodeModelResolvable, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
|
|
43
43
|
import { createClaudeAgentHarness } from './hosted-harness-claude.js';
|
|
44
|
-
import { drainHarnessToSink, resumeRunEventSink, } from './hosted-run-event-sink.js';
|
|
44
|
+
import { drainHarnessToSink, resumeRunEventSink, RunTerminalError, TerminalDeliveryError, } from './hosted-run-event-sink.js';
|
|
45
45
|
import { buildOpenCodeSpawnEnv, defaultProbeEgress, egressCanaryAllowedHost, runEgressCanary, } from './hosted-loop-lifecycle.js';
|
|
46
46
|
// The lifecycle/orchestration seam moved to `hosted-loop-lifecycle.ts`
|
|
47
47
|
// (ALI-1015 Phase 1) so `lib/hosted-runtime/` can share it with a server-side
|
|
48
48
|
// coordinator. Re-exported here so this module's public surface is unchanged.
|
|
49
|
-
export { DEFAULT_EGRESS_CANARY_HOST, buildOpenCodeSpawnEnv, egressCanaryAllowedHost, runEgressCanary, } from './hosted-loop-lifecycle.js';
|
|
49
|
+
export { DEFAULT_EGRESS_CANARY_HOST, DEFAULT_HOSTED_MODEL, buildOpenCodeSpawnEnv, egressCanaryAllowedHost, runEgressCanary, } from './hosted-loop-lifecycle.js';
|
|
50
50
|
// Time reserved BEFORE the sandbox self-terminates so a duration-capped prompt
|
|
51
51
|
// abort still has room to harvest partial work (commit/push) + write the terminal
|
|
52
52
|
// event: `promptMaxDurationMs = sandboxBudgetMs − this` (ALI-1061). 5 min is
|
|
@@ -97,6 +97,15 @@ function resolveIdleTimeoutMs(override) {
|
|
|
97
97
|
}
|
|
98
98
|
return DEFAULT_AGENT_IDLE_TIMEOUT_MS;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Loop-level retry schedule for an undeliverable TERMINAL transition (ALI-1065
|
|
102
|
+
* finding 2). Each entry is a pause before one more full sink delivery cycle
|
|
103
|
+
* (which itself retries ~4 times over ~350ms), so a transport blip of a few
|
|
104
|
+
* seconds recovers with the ORIGINAL status instead of being re-recorded as
|
|
105
|
+
* 'failed'. Bounded: a persistently unreachable control plane fails the
|
|
106
|
+
* DELIVERY visibly (result.error) without recording a wrong outcome.
|
|
107
|
+
*/
|
|
108
|
+
export const TERMINAL_DELIVERY_RETRY_DELAYS_MS = [2_000, 8_000];
|
|
100
109
|
/** Model-key env vars whose value must be scrubbed from every run event. */
|
|
101
110
|
const MODEL_KEY_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
|
|
102
111
|
/**
|
|
@@ -399,6 +408,40 @@ export async function runHostedLoop(opts) {
|
|
|
399
408
|
payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
|
|
400
409
|
});
|
|
401
410
|
}
|
|
411
|
+
// Fail-fast model validation (ALI-1086): ask the RUNNING opencode whether
|
|
412
|
+
// the requested model is resolvable BEFORE the first prompt. The pinned
|
|
413
|
+
// opencode's bundled catalog is stale (predates claude-opus-4-8) and only
|
|
414
|
+
// a boot-time models.dev fetch (#1392 allowlists it) refreshes it — when
|
|
415
|
+
// that fetch is blocked, the first prompt used to die inside the SSE
|
|
416
|
+
// stream with an opaque `run_failed: Model not found`. Now the run fails
|
|
417
|
+
// immediately with a structured artifact + an error naming the requested
|
|
418
|
+
// id AND the resolvable alternatives. Fail-open on validator
|
|
419
|
+
// infrastructure: an unreachable catalog endpoint (`skipped`) proceeds
|
|
420
|
+
// silently — opencode's own session.error remains the backstop. Healthy
|
|
421
|
+
// and skipped runs append NO event, so the ALI-929 cross-harness
|
|
422
|
+
// stream-identity invariant is untouched (validation exists only on the
|
|
423
|
+
// opencode path; the Claude-SDK driver resolves models itself).
|
|
424
|
+
const validateModel = opts.validateModel ?? awaitOpenCodeModelResolvable;
|
|
425
|
+
const validation = await validateModel({ baseUrl: spawned.baseUrl, model: context.model });
|
|
426
|
+
if (validation.kind === 'unresolvable') {
|
|
427
|
+
await sink.append({
|
|
428
|
+
kind: 'artifact',
|
|
429
|
+
payload: {
|
|
430
|
+
step: 'model_validation',
|
|
431
|
+
ok: false,
|
|
432
|
+
requestedModel: validation.requested,
|
|
433
|
+
resolvableAlternatives: validation.alternatives,
|
|
434
|
+
providerKnown: validation.providerKnown,
|
|
435
|
+
opencodeVersion: pinnedVersion,
|
|
436
|
+
waitedMs: validation.waitedMs,
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
throw new Error(`requested model ${validation.requested} is not resolvable by the pinned ` +
|
|
440
|
+
`opencode runtime (opencode-ai@${pinnedVersion}); resolvable alternatives: ` +
|
|
441
|
+
`${validation.alternatives.join(', ') || '(none)'} — retry with --model ` +
|
|
442
|
+
`set to one of these (stale bundled catalog + blocked models.dev refresh; ` +
|
|
443
|
+
`see ALI-1086, durable fix ALI-929)`);
|
|
444
|
+
}
|
|
402
445
|
// Derive the per-prompt max-duration cap from the sandbox budget so a long
|
|
403
446
|
// `--duration` run is not killed at the hard-coded 90-min cap while its
|
|
404
447
|
// sandbox is still alive; floored at the harness default for short/undated
|
|
@@ -472,10 +515,57 @@ export async function runHostedLoop(opts) {
|
|
|
472
515
|
}
|
|
473
516
|
catch (error) {
|
|
474
517
|
const message = error instanceof Error ? error.message : String(error);
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
// is valuable, incl. an idle-watchdog abort) — before the terminal seals.
|
|
518
|
+
// Harvest FIRST on every abnormal path (partial work is valuable, incl. an
|
|
519
|
+
// idle-watchdog abort) — before any terminal write seals the sink.
|
|
478
520
|
await runAutoHarvest();
|
|
521
|
+
// ALI-1065 finding 2: an undeliverable TERMINAL transition must NEVER be
|
|
522
|
+
// re-recorded as 'failed' — the run's outcome was already decided; only its
|
|
523
|
+
// DELIVERY failed. Retry the delivery with the ORIGINAL status (spaced past
|
|
524
|
+
// the sink's own ~350ms retry window, so a ~1s transport blip recovers),
|
|
525
|
+
// and if it still cannot land, fail the DELIVERY visibly: return the
|
|
526
|
+
// intended status with a non-null error, which the boot reports via its
|
|
527
|
+
// boot-status callback, and the coordinator's session-end finalizer sweeps
|
|
528
|
+
// the still-'running' row server-side with an explicit marker.
|
|
529
|
+
if (error instanceof TerminalDeliveryError) {
|
|
530
|
+
const sleep = opts.sleepImpl ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
531
|
+
for (const delayMs of TERMINAL_DELIVERY_RETRY_DELAYS_MS) {
|
|
532
|
+
if (sink.sealed)
|
|
533
|
+
break;
|
|
534
|
+
await sleep(delayMs);
|
|
535
|
+
try {
|
|
536
|
+
await sink.finish(error.intendedStatus, error.finishOptions);
|
|
537
|
+
return { status: error.intendedStatus, agentSessionId, installOk, error: null };
|
|
538
|
+
}
|
|
539
|
+
catch (retryError) {
|
|
540
|
+
// The run went terminal server-side while we were retrying: accept
|
|
541
|
+
// the server record (Orizu records win) instead of retrying further.
|
|
542
|
+
if (retryError instanceof RunTerminalError) {
|
|
543
|
+
return { status: 'cancelled', agentSessionId, installOk, error: null };
|
|
544
|
+
}
|
|
545
|
+
// Otherwise keep retrying within the bounded schedule; never change
|
|
546
|
+
// the status.
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (sink.sealed) {
|
|
550
|
+
// A concurrent/preceding delivery landed after all — the record is
|
|
551
|
+
// terminal server-side with our intended status semantics preserved.
|
|
552
|
+
return { status: error.intendedStatus, agentSessionId, installOk, error: null };
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
status: error.intendedStatus,
|
|
556
|
+
agentSessionId,
|
|
557
|
+
installOk,
|
|
558
|
+
error: `terminal delivery failed (intended status preserved): ${message}`,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
// The run went terminal SERVER-side (cancel/finish out from under this
|
|
562
|
+
// writer): the sink sealed itself; accept the server record (Orizu records
|
|
563
|
+
// win) instead of fabricating a local failure.
|
|
564
|
+
if (error instanceof RunTerminalError) {
|
|
565
|
+
return { status: 'cancelled', agentSessionId, installOk, error: null };
|
|
566
|
+
}
|
|
567
|
+
// Fail closed: never leave the run non-terminal. finish() is idempotent and
|
|
568
|
+
// no-ops when the drain already sealed the sink.
|
|
479
569
|
if (!sink.sealed) {
|
|
480
570
|
try {
|
|
481
571
|
await sink.finish('failed', { summary: { error: message } });
|