orizu 0.5.26 → 0.5.28

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.
Files changed (164) hide show
  1. package/README.md +19 -0
  2. package/dist/artifacts-git-runtime.d.ts +34 -0
  3. package/dist/artifacts-git-runtime.d.ts.map +1 -0
  4. package/dist/artifacts-git-runtime.js +312 -0
  5. package/dist/cloudflare-artifacts-git-remote.d.ts +5 -0
  6. package/dist/cloudflare-artifacts-git-remote.d.ts.map +1 -0
  7. package/dist/cloudflare-artifacts-git-remote.js +21 -0
  8. package/dist/dataset-transfer-download.d.ts +2 -0
  9. package/dist/dataset-transfer-download.d.ts.map +1 -0
  10. package/dist/dataset-transfer-download.js +66 -0
  11. package/dist/dataset-transfer-policy.d.ts +2 -0
  12. package/dist/dataset-transfer-policy.d.ts.map +1 -0
  13. package/dist/dataset-transfer-policy.js +8 -0
  14. package/dist/dataset-transfer-upload.d.ts +42 -0
  15. package/dist/dataset-transfer-upload.d.ts.map +1 -0
  16. package/dist/dataset-transfer-upload.js +110 -0
  17. package/dist/git-credential.d.ts.map +1 -1
  18. package/dist/git-credential.js +1 -1
  19. package/dist/git-recovery-environment.d.ts +1 -0
  20. package/dist/git-recovery-environment.d.ts.map +1 -1
  21. package/dist/git-recovery-environment.js +1 -1
  22. package/dist/help.d.ts.map +1 -1
  23. package/dist/help.js +44 -11
  24. package/dist/hosted-boot.d.ts +75 -2
  25. package/dist/hosted-boot.d.ts.map +1 -1
  26. package/dist/hosted-boot.js +585 -102
  27. package/dist/hosted-bootstrap.d.ts +2 -0
  28. package/dist/hosted-bootstrap.d.ts.map +1 -1
  29. package/dist/hosted-bootstrap.js +12 -3
  30. package/dist/hosted-harness-opencode.d.ts +8 -9
  31. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  32. package/dist/hosted-harness-opencode.js +126 -24
  33. package/dist/hosted-harness.d.ts +3 -0
  34. package/dist/hosted-harness.d.ts.map +1 -1
  35. package/dist/hosted-harness.js +4 -1
  36. package/dist/hosted-harvest.d.ts +30 -9
  37. package/dist/hosted-harvest.d.ts.map +1 -1
  38. package/dist/hosted-harvest.js +222 -28
  39. package/dist/hosted-loop-lifecycle.d.ts +15 -3
  40. package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
  41. package/dist/hosted-loop-lifecycle.js +6 -4
  42. package/dist/hosted-loop.d.ts +47 -7
  43. package/dist/hosted-loop.d.ts.map +1 -1
  44. package/dist/hosted-loop.js +310 -64
  45. package/dist/hosted-question-limits.d.ts +7 -0
  46. package/dist/hosted-question-limits.d.ts.map +1 -0
  47. package/dist/hosted-question-limits.js +6 -0
  48. package/dist/hosted-question.d.ts +27 -0
  49. package/dist/hosted-question.d.ts.map +1 -0
  50. package/dist/hosted-question.js +118 -0
  51. package/dist/hosted-run-event-sink.d.ts +10 -5
  52. package/dist/hosted-run-event-sink.d.ts.map +1 -1
  53. package/dist/hosted-run-event-sink.js +12 -8
  54. package/dist/hosted-runtime-assets.d.ts +16 -7
  55. package/dist/hosted-runtime-assets.d.ts.map +1 -1
  56. package/dist/hosted-runtime-assets.js +40 -12
  57. package/dist/hosted-session-cli.d.ts.map +1 -1
  58. package/dist/hosted-session-cli.js +10 -19
  59. package/dist/http.d.ts +1 -1
  60. package/dist/http.d.ts.map +1 -1
  61. package/dist/http.js +10 -4
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +113 -92
  64. package/dist/instruction-set-gepa-launch.d.ts +1 -1
  65. package/dist/instruction-set-gepa-launch.d.ts.map +1 -1
  66. package/dist/instruction-set-gepa-launch.js +5 -3
  67. package/dist/instruction-set-loader.d.ts.map +1 -1
  68. package/dist/instruction-set-loader.js +63 -2
  69. package/dist/instruction-set-lock/index.d.ts +51 -0
  70. package/dist/instruction-set-lock/index.d.ts.map +1 -0
  71. package/dist/instruction-set-lock/index.js +459 -0
  72. package/dist/instruction-set-lock/schema.v1.d.ts +136 -0
  73. package/dist/instruction-set-lock/schema.v1.d.ts.map +1 -0
  74. package/dist/instruction-set-lock/schema.v1.js +155 -0
  75. package/dist/instruction-set-provenance.d.ts +26 -0
  76. package/dist/instruction-set-provenance.d.ts.map +1 -0
  77. package/dist/instruction-set-provenance.js +68 -0
  78. package/dist/instruction-set-sync/helpers.d.ts +22 -0
  79. package/dist/instruction-set-sync/helpers.d.ts.map +1 -0
  80. package/dist/instruction-set-sync/helpers.js +677 -0
  81. package/dist/instruction-set-sync/index.d.ts +60 -0
  82. package/dist/instruction-set-sync/index.d.ts.map +1 -0
  83. package/dist/instruction-set-sync/index.js +689 -0
  84. package/dist/instruction-set-update/index.d.ts +42 -0
  85. package/dist/instruction-set-update/index.d.ts.map +1 -0
  86. package/dist/instruction-set-update/index.js +445 -0
  87. package/dist/instruction-set-verify/index.d.ts +27 -0
  88. package/dist/instruction-set-verify/index.d.ts.map +1 -0
  89. package/dist/instruction-set-verify/index.js +865 -0
  90. package/dist/instruction-sets-cli.d.ts +1 -4
  91. package/dist/instruction-sets-cli.d.ts.map +1 -1
  92. package/dist/instruction-sets-cli.js +220 -109
  93. package/dist/model-config-fields.d.ts +35 -0
  94. package/dist/model-config-fields.d.ts.map +1 -0
  95. package/dist/model-config-fields.js +28 -0
  96. package/dist/optimizations-list-cli.d.ts.map +1 -1
  97. package/dist/optimizations-list-cli.js +7 -4
  98. package/dist/score-format.d.ts +2 -0
  99. package/dist/score-format.d.ts.map +1 -0
  100. package/dist/score-format.js +18 -0
  101. package/dist/workbench-repo-contract.d.ts +65 -0
  102. package/dist/workbench-repo-contract.d.ts.map +1 -0
  103. package/dist/workbench-repo-contract.js +250 -0
  104. package/dist/workspace-bootstrap-job.d.ts +45 -0
  105. package/dist/workspace-bootstrap-job.d.ts.map +1 -0
  106. package/dist/workspace-bootstrap-job.js +431 -0
  107. package/dist/workspace-bootstrap-seed.d.ts +74 -0
  108. package/dist/workspace-bootstrap-seed.d.ts.map +1 -0
  109. package/dist/workspace-bootstrap-seed.js +209 -0
  110. package/package.json +4 -3
  111. package/src/artifacts-git-runtime.ts +360 -0
  112. package/src/cloudflare-artifacts-git-remote.ts +24 -0
  113. package/src/dataset-transfer-download.ts +61 -0
  114. package/src/dataset-transfer-policy.ts +8 -0
  115. package/src/dataset-transfer-upload.ts +145 -0
  116. package/src/git-credential.ts +1 -2
  117. package/src/git-recovery-environment.ts +1 -1
  118. package/src/help.ts +44 -11
  119. package/src/hosted-boot.ts +644 -109
  120. package/src/hosted-bootstrap.ts +14 -3
  121. package/src/hosted-harness-opencode.ts +130 -28
  122. package/src/hosted-harness.ts +4 -1
  123. package/src/hosted-harvest.ts +258 -33
  124. package/src/hosted-loop-lifecycle.ts +19 -5
  125. package/src/hosted-loop.ts +340 -68
  126. package/src/hosted-question-limits.ts +6 -0
  127. package/src/hosted-question.ts +145 -0
  128. package/src/hosted-run-event-sink.ts +24 -12
  129. package/src/hosted-runtime-assets.ts +51 -11
  130. package/src/hosted-session-cli.ts +10 -24
  131. package/src/http.ts +10 -4
  132. package/src/index.ts +108 -134
  133. package/src/instruction-set-gepa-launch.ts +4 -4
  134. package/src/instruction-set-loader.ts +53 -2
  135. package/src/instruction-set-lock/index.ts +561 -0
  136. package/src/instruction-set-lock/schema.v1.json +141 -0
  137. package/src/instruction-set-lock/schema.v1.ts +155 -0
  138. package/src/instruction-set-provenance.ts +89 -0
  139. package/src/instruction-set-sync/helpers.ts +707 -0
  140. package/src/instruction-set-sync/index.ts +791 -0
  141. package/src/instruction-set-update/index.ts +488 -0
  142. package/src/instruction-set-verify/index.ts +1067 -0
  143. package/src/instruction-sets-cli.ts +226 -114
  144. package/src/model-config-fields.ts +31 -0
  145. package/src/optimizations-list-cli.ts +7 -4
  146. package/src/score-format.ts +16 -0
  147. package/src/workbench-repo-contract.ts +314 -0
  148. package/src/workspace-bootstrap-job.ts +611 -0
  149. package/src/workspace-bootstrap-seed.ts +350 -0
  150. package/vendor/orizu-gepa/src/orizu_gepa_connector/instruction_set_loader.py +46 -2
  151. package/vendor/skills/orizu/SKILL.md +4 -2
  152. package/vendor/skills/orizu/flows/promote.md +4 -0
  153. package/vendor/skills/orizu/references/assess-and-plan.md +1 -1
  154. package/vendor/skills/orizu/references/authority-map.md +1 -1
  155. package/vendor/skills/orizu/references/cli-reference.md +151 -35
  156. package/vendor/skills/orizu/references/instructions-after-prompts.md +3 -3
  157. package/vendor/skills/orizu/references/migrate-existing-gepa-setup.md +5 -6
  158. package/vendor/skills/orizu/references/migrate-hand-rolled-instruction-layout.md +252 -0
  159. package/vendor/skills/orizu/references/optimization-reports.md +5 -4
  160. package/vendor/skills/orizu/references/optimization-with-gepa.md +1 -1
  161. package/vendor/skills/orizu/references/orizu-in-your-codebase.md +195 -0
  162. package/vendor/skills/orizu/references/primer.md +4 -2
  163. package/vendor/skills/orizu/references/prompt-control-plane.md +33 -29
  164. package/vendor/skills/orizu/references/vocabulary.md +1 -1
@@ -30,10 +30,12 @@
30
30
  * non-secret ANTHROPIC_API_KEY dummy placeholder (firewall brokers the real key).
31
31
  */
32
32
  import { spawnSync } from 'child_process';
33
- import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from 'fs';
33
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeSync } from 'fs';
34
+ import { isValidCloudflareArtifactsGitRemote } from './cloudflare-artifacts-git-remote.js';
34
35
  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
- import { INJECTED_ENV_VARS_ENV, runHostedLoop, } from './hosted-loop.js';
36
+ import { INJECTED_ENV_VARS_ENV, closeHostedLoopSession, runHostedLoop, runHostedLoopTurn, startHostedLoopSession, } from './hosted-loop.js';
36
37
  import { DEFAULT_EGRESS_CANARY_HOST, DEFAULT_HOSTED_MODEL } from './hosted-loop-lifecycle.js';
38
+ import { composeHostedAnswerPrompt } from './hosted-question.js';
37
39
  import { resumeRunEventSink } from './hosted-run-event-sink.js';
38
40
  import { stageOrizuSkill } from './hosted-skill-staging.js';
39
41
  /** Non-secret placeholder model key (firewall brokers the real org key on
@@ -42,8 +44,6 @@ import { stageOrizuSkill } from './hosted-skill-staging.js';
42
44
  * DO-path `workers/session-coordinator/src/bootstrap.ts`); kept in sync by
43
45
  * grep. Inlined (not imported) to avoid a hosted-session-cli import cycle. */
44
46
  const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder';
45
- const CLOUDFLARE_ARTIFACTS_HOST = /^[0-9a-f]{32}\.artifacts\.cloudflare\.net$/;
46
- const CLOUDFLARE_ARTIFACTS_GIT_PATH = /^\/git\/[A-Za-z0-9][A-Za-z0-9._-]{0,255}\/[A-Za-z0-9][A-Za-z0-9._-]{0,255}\.git$/;
47
47
  // -- Frozen env contract ------------------------------------------------------
48
48
  export const REQUIRED_BOOT_ENV_VARS = [
49
49
  'ORIZU_BOOT_SECRET',
@@ -100,6 +100,17 @@ export function deriveBootStatusUrl(agentTokenUrl) {
100
100
  * is never a place for a secret; this bounds it anyway, and we scrub the boot
101
101
  * secret defensively before sending. */
102
102
  const MAX_BOOT_REASON_CHARS = 800;
103
+ const TURN_STATUS_ATTEMPT_TIMEOUT_MS = 10_000;
104
+ function resolveTestPositiveInteger(processEnv, name, maximum = Number.MAX_SAFE_INTEGER) {
105
+ const configured = processEnv.NODE_ENV === 'test' ? processEnv[name] : undefined;
106
+ if (!configured || !/^\d+$/u.test(configured))
107
+ return undefined;
108
+ const value = Number(configured);
109
+ return value > 0 && value <= maximum ? value : undefined;
110
+ }
111
+ function resolveTurnStatusAttemptTimeoutMs(processEnv) {
112
+ return resolveTestPositiveInteger(processEnv, 'ORIZU_HOSTED_TEST_TURN_STATUS_TIMEOUT_MS');
113
+ }
103
114
  /** Best-effort scrub + truncate for a reported failure reason: never leak the
104
115
  * boot secret (the one credential the boot always holds), and keep it short. */
105
116
  export function redactBootReason(reason, bootSecret) {
@@ -132,6 +143,55 @@ export async function postBootStatus(opts) {
132
143
  opts.log?.(`boot-status ${opts.status} report failed: ${error instanceof Error ? error.message : String(error)}`);
133
144
  }
134
145
  }
146
+ async function postRequiredIdleReady(opts) {
147
+ let detail = 'not attempted';
148
+ for (let attempt = 0; attempt < 5; attempt += 1) {
149
+ try {
150
+ const response = await opts.fetchImpl(opts.bootStatusUrl, {
151
+ method: 'POST',
152
+ headers: { Authorization: `Bearer ${opts.bootSecret}`, 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({ status: 'ready' }),
154
+ signal: AbortSignal.timeout(opts.attemptTimeoutMs ?? TURN_STATUS_ATTEMPT_TIMEOUT_MS),
155
+ });
156
+ if (response.ok)
157
+ return;
158
+ detail = `status ${response.status}`;
159
+ }
160
+ catch (error) {
161
+ detail = error instanceof Error ? error.message : String(error);
162
+ }
163
+ if (attempt < 4)
164
+ await opts.sleep(250 * 2 ** attempt);
165
+ }
166
+ throw new Error(`ready acknowledgement failed: ${detail}`);
167
+ }
168
+ async function postTurnStatus(opts) {
169
+ const sleep = opts.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
170
+ let detail = 'not attempted';
171
+ for (let attempt = 0; attempt < 5; attempt += 1) {
172
+ try {
173
+ const response = await opts.fetchImpl(opts.bootStatusUrl, {
174
+ method: 'POST',
175
+ headers: { Authorization: `Bearer ${opts.bootSecret}`, 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({
177
+ status: opts.status,
178
+ runId: opts.runId,
179
+ ...(opts.reason ? { reason: opts.reason } : {}),
180
+ }),
181
+ signal: AbortSignal.timeout(opts.attemptTimeoutMs ?? TURN_STATUS_ATTEMPT_TIMEOUT_MS),
182
+ });
183
+ if (response.ok)
184
+ return;
185
+ detail = `status ${response.status}`;
186
+ }
187
+ catch (error) {
188
+ detail = error instanceof Error ? error.message : String(error);
189
+ }
190
+ if (attempt < 4)
191
+ await sleep(250 * 2 ** attempt);
192
+ }
193
+ throw new Error(`${opts.status} acknowledgement failed: ${detail}`);
194
+ }
135
195
  /**
136
196
  * Exchange the boot secret for a fresh Orizu agent bearer at the coordinator's
137
197
  * agent-token route (`GET`, `Authorization: Bearer <boot secret>` → 200
@@ -209,6 +269,9 @@ export async function fetchEnvBundle(opts) {
209
269
  * values from run events. Returns the merged redaction-var list.
210
270
  */
211
271
  export function applyEnvBundle(bundle, env) {
272
+ if (bundle.connectors.some(connector => connector.envVar.startsWith('ORIZU_'))) {
273
+ throw new Error('reserved_connector_env: connector env vars must not use ORIZU_*');
274
+ }
212
275
  for (const connector of bundle.connectors) {
213
276
  env[connector.envVar] = connector.value;
214
277
  }
@@ -225,6 +288,11 @@ export function applyEnvBundle(bundle, env) {
225
288
  env[INJECTED_ENV_VARS_ENV] = merged.join(',');
226
289
  return merged;
227
290
  }
291
+ function promptForPendingHostedTurn(turn) {
292
+ return turn.answerToQuestion
293
+ ? composeHostedAnswerPrompt(turn.answerToQuestion, turn.body)
294
+ : turn.body;
295
+ }
228
296
  async function bearerJson(fetchImpl, url, bearer, init = {}) {
229
297
  const res = await fetchImpl(url, {
230
298
  ...init,
@@ -244,7 +312,7 @@ function asPositiveNumber(value) {
244
312
  /** GET the session (agent-capable, RLS-scoped) to learn its workspace, branch,
245
313
  * task, and any existing run — the DO start body carries none of these. */
246
314
  export async function resolveSession(opts) {
247
- const body = await bearerJson(opts.fetchImpl, `${opts.baseUrl}/api/cli/sessions/${encodeURIComponent(opts.sessionId)}`, opts.bearer);
315
+ const body = await bearerJson(opts.fetchImpl, `${opts.baseUrl}/api/cli/sessions/${encodeURIComponent(opts.sessionId)}`, opts.bearer, opts.signal ? { signal: opts.signal } : {});
248
316
  const session = (body.session ?? {});
249
317
  const workspaceId = asString(session.workspaceId);
250
318
  if (!workspaceId)
@@ -253,21 +321,124 @@ export async function resolveSession(opts) {
253
321
  if (!repoBranch)
254
322
  throw new Error('session response carried no repoBranch (branch not provisioned)');
255
323
  const clientInfo = (session.clientInfo ?? {});
256
- const task = asString(clientInfo.task);
257
- if (!task)
324
+ const initialTask = asString(clientInfo.task);
325
+ if (!initialTask)
258
326
  throw new Error('session client_info carried no task prompt');
259
327
  const runs = Array.isArray(session.runs) ? session.runs : [];
260
- const runId = runs.length > 0 ? asString(runs[0].id) : null;
328
+ const pending = session.pendingTurn && typeof session.pendingTurn === 'object'
329
+ ? session.pendingTurn : null;
330
+ const answer = pending?.answerToQuestion && typeof pending.answerToQuestion === 'object'
331
+ ? pending.answerToQuestion
332
+ : null;
333
+ const answerToQuestion = answer && asString(answer.questionId) && asString(answer.question)
334
+ ? { questionId: answer.questionId, question: answer.question }
335
+ : null;
336
+ const hasValidTurnId = pending?.turnId === null || asString(pending?.turnId) !== null;
337
+ const pendingTurn = pending && hasValidTurnId &&
338
+ Number.isSafeInteger(pending.ordinal) && pending.ordinal > 0 &&
339
+ asString(pending.runId) && asString(pending.body) && asString(pending.clientMessageId) &&
340
+ (pending.answerToQuestion === null || answerToQuestion)
341
+ ? {
342
+ turnId: pending.turnId === null ? null : pending.turnId,
343
+ ordinal: pending.ordinal,
344
+ runId: pending.runId,
345
+ body: pending.body,
346
+ clientMessageId: pending.clientMessageId,
347
+ answerToQuestion,
348
+ }
349
+ : null;
350
+ if (pending && !pendingTurn)
351
+ throw new Error('hosted_pending_turn_invalid');
352
+ const unfinishedRun = pendingTurn
353
+ ? runs.find(run => asString(run.id) === pendingTurn.runId &&
354
+ (run.status === 'pending' || run.status === 'running'))
355
+ : null;
356
+ // The route orders runs newest-first. Resume the service-selected lowest
357
+ // unfinished turn; only when none exists do we fall back to the initial
358
+ // (oldest) run and its original task.
359
+ const selectedRun = unfinishedRun ?? runs.at(-1);
360
+ const runId = asString(selectedRun?.id);
261
361
  return {
262
362
  workspaceId,
263
363
  repoBranch,
264
- task,
364
+ task: unfinishedRun ? promptForPendingHostedTurn(pendingTurn) : initialTask,
265
365
  model: asString(clientInfo.model),
266
366
  reasoningEffort: asString(clientInfo.reasoningEffort),
367
+ surface: asString(clientInfo.surface),
267
368
  durationMinutes: asPositiveNumber(clientInfo.durationMinutes),
268
369
  runId,
370
+ runStatus: asString(selectedRun?.status),
371
+ status: asString(session.status),
372
+ pendingTurn: unfinishedRun ? pendingTurn : null,
373
+ interruptRequestedRunId: asString(session.interruptRequestedRunId),
374
+ agentSessionId: asString(session.agentSessionId),
375
+ projectId: asString(session.projectId),
269
376
  };
270
377
  }
378
+ /** Ride the ALI-1757 session/pending-turn poll while one prompt is active.
379
+ * This uses the same additive session GET contract, but deliberately shares
380
+ * none of the between-turn credential cleanup or mint-on-401 behavior: the
381
+ * session-stable bearer file is live ORIZU_TOKEN_FILE for the prompt. */
382
+ export async function pollHostedSessionDuringTurn(opts) {
383
+ const diagnose = (message) => {
384
+ try {
385
+ opts.onDiagnostic?.(message);
386
+ }
387
+ catch { /* diagnostics are best-effort */ }
388
+ };
389
+ const readFile = opts.readFile ?? ((path) => readFileSync(path, 'utf8'));
390
+ const sleep = opts.sleep ?? ((ms) => new Promise(resolve => {
391
+ if (opts.signal.aborted) {
392
+ resolve();
393
+ return;
394
+ }
395
+ const timer = setTimeout(finish, ms);
396
+ const handleAbort = () => finish();
397
+ function finish() {
398
+ clearTimeout(timer);
399
+ opts.signal.removeEventListener('abort', handleAbort);
400
+ resolve();
401
+ }
402
+ opts.signal.addEventListener('abort', handleAbort, { once: true });
403
+ }));
404
+ while (!opts.signal.aborted) {
405
+ try {
406
+ const latest = await resolveSession({
407
+ baseUrl: opts.baseUrl,
408
+ sessionId: opts.sessionId,
409
+ bearer: readFile(opts.bearerFileAbs).trim(),
410
+ signal: AbortSignal.any([
411
+ opts.signal,
412
+ AbortSignal.timeout(10_000),
413
+ ]),
414
+ fetchImpl: (url, init) => {
415
+ const headers = new Headers(init?.headers);
416
+ // Additive, authority-free discriminator: existing session GET
417
+ // remains the one endpoint, while fixtures/observability can separate
418
+ // prompt-time health reads from between-turn queue consumption.
419
+ headers.set('x-orizu-hosted-poll', 'during-turn');
420
+ return opts.fetchImpl(url, { ...init, headers });
421
+ },
422
+ });
423
+ if (latest.interruptRequestedRunId === opts.runId) {
424
+ try {
425
+ opts.onInterrupt();
426
+ }
427
+ catch {
428
+ diagnose('hosted interrupt callback failed; request will not be redelivered');
429
+ }
430
+ return;
431
+ }
432
+ }
433
+ catch {
434
+ // Fixed, value-free diagnostic: bearer values and response bodies never
435
+ // enter logs. A transient read failure cannot fail the running turn.
436
+ diagnose('hosted interrupt poll failed; retrying');
437
+ }
438
+ if (!opts.signal.aborted)
439
+ await sleep(5_000);
440
+ }
441
+ }
271
442
  /** Reuse an existing run or start one with the AGENT bearer (actor_type='agent'),
272
443
  * exactly like the operator path's run-start step. */
273
444
  export async function ensureRun(opts) {
@@ -287,6 +458,29 @@ export async function ensureRun(opts) {
287
458
  throw new Error('workbench-run start returned no run id');
288
459
  return runId;
289
460
  }
461
+ /** Emit the RunAPI-reserved run_started transition for a pre-created turn.
462
+ * Idempotent for the initial run, which may already be running. */
463
+ export async function beginRunExecution(opts) {
464
+ const sleep = opts.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
465
+ let detail = 'not attempted';
466
+ for (let attempt = 0; attempt < 5; attempt += 1) {
467
+ try {
468
+ await bearerJson(opts.fetchImpl, `${opts.baseUrl}/api/cli/workbench-runs/${encodeURIComponent(opts.runId)}`, opts.bearer, {
469
+ method: 'POST',
470
+ headers: { 'Content-Type': 'application/json' },
471
+ body: '{}',
472
+ signal: AbortSignal.timeout(opts.attemptTimeoutMs ?? TURN_STATUS_ATTEMPT_TIMEOUT_MS),
473
+ });
474
+ return;
475
+ }
476
+ catch (error) {
477
+ detail = error instanceof Error ? error.message : String(error);
478
+ }
479
+ if (attempt < 4)
480
+ await sleep(250 * 2 ** attempt);
481
+ }
482
+ throw new Error(`run_started transition failed: ${detail}`);
483
+ }
290
484
  /** Mint a session_read repo token to learn the repo full name, then build the
291
485
  * GitHub clone URL (mirrors the operator path's `defaultResolveRepo`). */
292
486
  export async function resolveRepo(opts) {
@@ -302,22 +496,7 @@ export async function resolveRepo(opts) {
302
496
  if (!artifactsRemote) {
303
497
  throw new Error('repo-token response carried no Artifacts remote');
304
498
  }
305
- let parsed;
306
- try {
307
- parsed = new URL(artifactsRemote);
308
- }
309
- catch {
310
- throw new Error('repo-token response carried an invalid Artifacts remote');
311
- }
312
- if (parsed.protocol !== 'https:' ||
313
- parsed.username ||
314
- parsed.password ||
315
- parsed.port ||
316
- parsed.search ||
317
- parsed.hash ||
318
- !CLOUDFLARE_ARTIFACTS_HOST.test(parsed.hostname) ||
319
- !CLOUDFLARE_ARTIFACTS_GIT_PATH.test(parsed.pathname) ||
320
- parsed.toString() !== artifactsRemote) {
499
+ if (!isValidCloudflareArtifactsGitRemote(artifactsRemote)) {
321
500
  throw new Error('repo-token response carried an invalid Artifacts remote');
322
501
  }
323
502
  return {
@@ -371,6 +550,8 @@ export function assertSafeRunId(runId) {
371
550
  function writeSecretFile(path, contents, write) {
372
551
  write(path, contents.endsWith('\n') ? contents : `${contents}\n`);
373
552
  }
553
+ class HostedTurnFailure extends Error {
554
+ }
374
555
  /**
375
556
  * The full in-sandbox boot: validate → pull bearer → pull+apply env bundle →
376
557
  * resolve session/run/repo → write the pull-mode credential assets → clone the
@@ -403,6 +584,16 @@ export async function runHostedBoot(opts) {
403
584
  const readFile = opts.readFile ?? ((path) => readFileSync(path, 'utf8'));
404
585
  const log = opts.log ?? (() => { });
405
586
  const now = opts.now ?? (() => Date.now());
587
+ const sleep = opts.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
588
+ const testRotationMinimum = processEnv.NODE_ENV === 'test'
589
+ ? processEnv.ORIZU_HOSTED_TEST_BEARER_ROTATION_MIN_DELAY_MS
590
+ : undefined;
591
+ const bearerRotationMinimumDelayMs = testRotationMinimum && /^\d+$/u.test(testRotationMinimum)
592
+ ? Number(testRotationMinimum)
593
+ : undefined;
594
+ const turnStatusAttemptTimeoutMs = resolveTurnStatusAttemptTimeoutMs(processEnv);
595
+ const idlePollMs = resolveTestPositiveInteger(processEnv, 'ORIZU_HOSTED_TEST_IDLE_POLL_MS') ?? 5_000;
596
+ const idleReadBackoffCapMs = resolveTestPositiveInteger(processEnv, 'ORIZU_HOSTED_TEST_IDLE_READ_BACKOFF_CAP_MS') ?? 45_000;
406
597
  // 0 — ENV HYGIENE (ALI-1062): the boot secret is the DO path's only durable
407
598
  // credential (it mints agent bearers at the internet-reachable agent-token
408
599
  // route for up to 24h), and `env.bootSecret` is already captured — so scrub
@@ -413,8 +604,10 @@ export async function runHostedBoot(opts) {
413
604
  // boot-status closures hold `env.bootSecret`, and the git credential helper
414
605
  // reads the 0600 run-dir boot-secret FILE (written in step 4).
415
606
  delete processEnv.ORIZU_BOOT_SECRET;
607
+ if (env.runId)
608
+ assertSafeRunId(env.runId);
416
609
  // 1 — Pull the agent bearer (retry/backoff — the DO may still be arming).
417
- const bearer = await pullAgentBearer({
610
+ let bearer = await pullAgentBearer({
418
611
  agentTokenUrl: env.agentTokenUrl,
419
612
  bootSecret: env.bootSecret,
420
613
  fetchImpl,
@@ -429,14 +622,87 @@ export async function runHostedBoot(opts) {
429
622
  const redacted = applyEnvBundle(bundle, processEnv);
430
623
  log(`env bundle applied (${bundle.connectors.length} connectors, ${redacted.length} redacted vars)`);
431
624
  // 3 — Resolve the session (workspace/branch/task) + run + repo from the plane.
432
- const session = await resolveSession({ baseUrl: env.baseUrl, sessionId: env.sessionId, bearer: bearer.token, fetchImpl });
625
+ let session = await resolveSession({ baseUrl: env.baseUrl, sessionId: env.sessionId, bearer: bearer.token, fetchImpl });
626
+ let currentNonTerminalRunId = session.runStatus === 'pending' || session.runStatus === 'running'
627
+ ? session.runId
628
+ : null;
629
+ // Production restarts with no live run are between turns. Prove this boot is
630
+ // live before waiting so the coordinator's readiness timeout cannot reap an
631
+ // intentionally idle sandbox before the longer hosted idle policy applies.
632
+ if (!opts.runLoop && !currentNonTerminalRunId) {
633
+ await postRequiredIdleReady({
634
+ bootStatusUrl: env.bootStatusUrl,
635
+ bootSecret: env.bootSecret,
636
+ fetchImpl,
637
+ sleep,
638
+ attemptTimeoutMs: turnStatusAttemptTimeoutMs,
639
+ });
640
+ }
641
+ let consecutiveIdleSessionReadFailures = 0;
642
+ const recordIdleSessionReadFailure = (detail) => {
643
+ consecutiveIdleSessionReadFailures += 1;
644
+ if (consecutiveIdleSessionReadFailures >= 10) {
645
+ throw new Error(`idle session read failed after 10 attempts: ${detail}`);
646
+ }
647
+ log(`idle session resolution failed; retrying (${detail})`);
648
+ };
649
+ while (!opts.runLoop && !currentNonTerminalRunId) {
650
+ await sleep(Math.min(idlePollMs * 2 ** consecutiveIdleSessionReadFailures, idleReadBackoffCapMs));
651
+ try {
652
+ if (bearer.expiresAtMs !== null && bearer.expiresAtMs <= now() + 60_000) {
653
+ bearer = await pullAgentBearer({
654
+ agentTokenUrl: env.agentTokenUrl, bootSecret: env.bootSecret,
655
+ fetchImpl, attempts: 3, backoffMs: 250, sleep, log,
656
+ });
657
+ }
658
+ session = await resolveSession({
659
+ baseUrl: env.baseUrl, sessionId: env.sessionId, bearer: bearer.token, fetchImpl,
660
+ });
661
+ }
662
+ catch (error) {
663
+ const detail = error instanceof Error ? error.message : String(error);
664
+ if (detail.startsWith('agent-token pull failed after')) {
665
+ throw new Error(`idle agent-token refresh failed: ${detail}`);
666
+ }
667
+ if (!detail.includes('(401)')) {
668
+ recordIdleSessionReadFailure(detail);
669
+ continue;
670
+ }
671
+ try {
672
+ bearer = await pullAgentBearer({
673
+ agentTokenUrl: env.agentTokenUrl, bootSecret: env.bootSecret,
674
+ fetchImpl, attempts: 3, backoffMs: 250, sleep, log,
675
+ });
676
+ session = await resolveSession({
677
+ baseUrl: env.baseUrl, sessionId: env.sessionId, bearer: bearer.token, fetchImpl,
678
+ });
679
+ }
680
+ catch (retryError) {
681
+ const detail = retryError instanceof Error ? retryError.message : String(retryError);
682
+ if (detail.startsWith('agent-token pull failed after')) {
683
+ throw new Error(`idle agent-token refresh failed: ${detail}`);
684
+ }
685
+ recordIdleSessionReadFailure(detail);
686
+ continue;
687
+ }
688
+ }
689
+ consecutiveIdleSessionReadFailures = 0;
690
+ if (session.status && session.status !== 'active') {
691
+ return { ok: true, runId: null, loopStatus: null, error: null };
692
+ }
693
+ currentNonTerminalRunId = session.runStatus === 'pending' || session.runStatus === 'running'
694
+ ? session.runId
695
+ : null;
696
+ }
433
697
  const workspaceId = env.workspaceId ?? session.workspaceId;
434
- const runId = await ensureRun({
698
+ // A non-null production run came from current session authority. Only the
699
+ // injected one-shot test seam may create a missing run.
700
+ const runId = currentNonTerminalRunId ?? await ensureRun({
435
701
  baseUrl: env.baseUrl,
436
702
  sessionId: env.sessionId,
437
703
  bearer: bearer.token,
438
704
  fetchImpl,
439
- existingRunId: env.runId ?? session.runId,
705
+ existingRunId: null,
440
706
  });
441
707
  // Path-safety BEFORE the run id reaches any `.orizu-run/${runId}` path
442
708
  // (ALI-1060): a caller-supplied ORIZU_RUN_ID must not escape the run dir.
@@ -452,20 +718,25 @@ export async function runHostedBoot(opts) {
452
718
  // loop's event sink; the boot secret feeds the credential helper's per-op pull.
453
719
  const runDirRel = `.orizu-run/${runId}`;
454
720
  const runDirAbs = `${root}/${runDirRel}`;
721
+ const sessionDirAbs = `${root}/.orizu-session/${env.sessionId}`;
455
722
  const workspaceDir = `${root}/repo`;
456
723
  assertSafeGitValue('sessionBranch', session.repoBranch);
457
724
  assertSafeGitValue('cloneUrl', repo.cloneUrl);
458
725
  mkdirp(runDirAbs);
459
- const bootSecretFileAbs = `${runDirAbs}/boot-secret`;
460
- const bearerFileAbs = `${runDirAbs}/${BEARER_BASENAME}`;
461
- const helperScriptAbs = `${runDirAbs}/${HELPER_SCRIPT_BASENAME}`;
462
- const bootContextAbs = `${runDirAbs}/${BOOT_CONTEXT_BASENAME}`;
463
- const cacheFileAbs = `${runDirAbs}/${REPO_CRED_CACHE_BASENAME}`;
726
+ mkdirp(sessionDirAbs);
727
+ const bootSecretFileAbs = `${sessionDirAbs}/boot-secret`;
728
+ const bearerFileAbs = `${sessionDirAbs}/${BEARER_BASENAME}`;
729
+ const helperScriptAbs = `${sessionDirAbs}/${HELPER_SCRIPT_BASENAME}`;
730
+ const bootContextAbs = `${sessionDirAbs}/${BOOT_CONTEXT_BASENAME}`;
731
+ const cacheFileAbs = `${sessionDirAbs}/${REPO_CRED_CACHE_BASENAME}`;
464
732
  const taskFileAbs = `${runDirAbs}/task.txt`;
465
733
  writeSecretFile(bootSecretFileAbs, env.bootSecret, writeFile);
466
734
  writeSecretFile(bearerFileAbs, bearer.token, writeFile);
467
735
  writeFile(taskFileAbs, session.task);
468
736
  writeFile(helperScriptAbs, renderCredentialHelperScript());
737
+ // Keep the session-stable cache inode available across turns without ever
738
+ // persisting an Artifacts credential (the helper deliberately leaves it empty).
739
+ writeSecretFile(cacheFileAbs, '', writeFile);
469
740
  const bootContext = {
470
741
  apiBaseUrl: env.baseUrl,
471
742
  workspaceId,
@@ -545,15 +816,26 @@ export async function runHostedBoot(opts) {
545
816
  // sandbox always runs the pre-baked image under an enforced egress policy, so
546
817
  // the loop's startup egress canary + deferred setup hook are both armed.
547
818
  const model = session.model ?? DEFAULT_HOSTED_MODEL;
819
+ const configuredOpenCodePort = resolveTestPositiveInteger(processEnv, 'ORIZU_HOSTED_TEST_OPENCODE_PORT', 65_535) ?? (processEnv.ORIZU_OPENCODE_PORT
820
+ ? Number(processEnv.ORIZU_OPENCODE_PORT)
821
+ : undefined);
822
+ if (configuredOpenCodePort !== undefined && (!Number.isInteger(configuredOpenCodePort) || configuredOpenCodePort < 1 || configuredOpenCodePort > 65_535)) {
823
+ throw new Error('ORIZU_OPENCODE_PORT must be a valid TCP port');
824
+ }
548
825
  const loopContext = {
549
826
  apiBaseUrl: env.baseUrl,
550
827
  runId,
551
828
  bearerFile: bearerFileAbs,
552
829
  taskFile: taskFileAbs,
553
830
  workspaceDir,
831
+ sessionBranch: session.repoBranch,
832
+ repositoryRemote: repo.cloneUrl,
833
+ repositoryCredentialHelper: helperValue,
554
834
  model,
555
835
  reasoningEffort: session.reasoningEffort ?? undefined,
836
+ sessionOrigin: session.surface === 'hosted-web' ? 'hosted-web' : 'cli',
556
837
  messageId: `${runId}:task`,
838
+ resumeAgentSessionId: session.agentSessionId ?? undefined,
557
839
  author: AGENT_GIT_IDENTITY,
558
840
  anthropicDummyKey: env.anthropicDummyKey ?? ANTHROPIC_DUMMY_KEY,
559
841
  // Derive the per-prompt max-duration cap from the session duration so a long
@@ -563,66 +845,227 @@ export async function runHostedBoot(opts) {
563
845
  prebaked: true,
564
846
  egressCanaryHost: DEFAULT_EGRESS_CANARY_HOST,
565
847
  runSetupHook: true,
848
+ opencodePort: configuredOpenCodePort,
566
849
  };
567
- const rotation = startBearerRotation({
568
- agentTokenUrl: env.agentTokenUrl,
569
- bootSecret: env.bootSecret,
570
- bearerFileAbs,
571
- fetchImpl,
572
- writeFile,
573
- initialExpiresAtMs: bearer.expiresAtMs,
574
- now,
575
- log,
576
- });
577
- try {
578
- const runLoop = opts.runLoop ??
579
- ((input) => runHostedLoop({
580
- context: input.context,
581
- taskPrompt: input.taskPrompt,
582
- bearerProvider: input.bearerProvider,
583
- redactSecretsList: input.redactSecretsList,
584
- }));
585
- const result = await runLoop({
586
- context: loopContext,
587
- taskPrompt: session.task,
588
- // ALI-1062 exact-value redaction: the boot secret is 64 bare hex chars —
589
- // no TOKEN_SHAPE_PATTERNS rule can catch it, so it MUST ride the loop's
590
- // verbatim redaction list or an echoed env dump would land it in
591
- // workbench_run_events.
592
- redactSecretsList: [env.bootSecret],
593
- // Read the bearer FILE fresh per request (review F1): `startBearerRotation`
594
- // rewrites `bearerFileAbs` before the current bearer expires, so a run
595
- // exceeding the ~60-min TTL (DO cap is 24h) keeps a valid bearer. A frozen
596
- // closure over the boot-time token would silently stop recording events at
597
- // ~TTL. The initial token was written to this file at boot (writeSecretFile
598
- // above); the write is atomic (F2), so this never reads a torn file.
599
- bearerProvider: () => readFile(bearerFileAbs).trim(),
600
- });
601
- log(`hosted-loop finished: ${result.status}`);
602
- // ALI-1064: the loop is TERMINAL — the auto-harvest (ALI-1036) and the
603
- // run's terminal seal already ran INSIDE runHostedLoop, so it is safe to
604
- // hand the session back. Report `complete` so the DO ends the workspace
605
- // session server-side and stops/destroys the sandbox NOW instead of
606
- // extending it until the 24h cap (live 2026-07-15: zombie boxes burned
607
- // 2×CPU/4GiB for hours after their loops finished). This fires for a
608
- // failed loop too — the run is terminal either way, and the loop already
609
- // owns its own run-level terminal state (never a `failed` boot-status
610
- // here; that would misreport a run failure as a bootstrap failure).
611
- // Best-effort: the DO's duration/24h caps are the backstop.
612
- await postBootStatus({
613
- bootStatusUrl: env.bootStatusUrl,
614
- bootSecret: env.bootSecret,
615
- status: 'complete',
616
- runId,
617
- reason: result.error ? redactBootReason(result.error, env.bootSecret) : null,
618
- fetchImpl,
619
- log,
850
+ const runLoop = opts.runLoop ??
851
+ ((input) => runHostedLoop({
852
+ context: input.context,
853
+ taskPrompt: input.taskPrompt,
854
+ bearerProvider: input.bearerProvider,
855
+ redactSecretsList: input.redactSecretsList,
856
+ onDiagnostic: log,
857
+ }));
858
+ // Preserve the injected one-shot seam for existing unit callers. Production
859
+ // uses the durable multi-turn branch below (and therefore has no injected
860
+ // loop or harness boundary).
861
+ if (opts.runLoop) {
862
+ const rotation = startBearerRotation({
863
+ agentTokenUrl: env.agentTokenUrl, bootSecret: env.bootSecret, bearerFileAbs,
864
+ fetchImpl, writeFile, initialExpiresAtMs: bearer.expiresAtMs, now, log,
865
+ minimumDelayMs: bearerRotationMinimumDelayMs,
620
866
  });
621
- return { ok: !result.error, runId, loopStatus: result.status, error: result.error };
867
+ try {
868
+ const result = await runLoop({
869
+ context: loopContext, taskPrompt: session.task,
870
+ redactSecretsList: [env.bootSecret],
871
+ bearerProvider: () => readFile(bearerFileAbs).trim(),
872
+ });
873
+ // The one-shot compatibility path predates turn callbacks, but its
874
+ // terminal reason has the same custody rule: scrub the bare boot secret
875
+ // before it crosses the boot-status boundary.
876
+ await postBootStatus({
877
+ bootStatusUrl: env.bootStatusUrl, bootSecret: env.bootSecret,
878
+ status: 'complete', runId,
879
+ reason: result.error ? redactBootReason(result.error, env.bootSecret) : null,
880
+ fetchImpl, log,
881
+ });
882
+ return { ok: !result.error, runId, loopStatus: result.status, error: result.error };
883
+ }
884
+ finally {
885
+ await rotation.stop();
886
+ }
887
+ }
888
+ const { runId: _runId, bearerFile: _bearerFile, taskFile: _taskFile, messageId: _messageId, ...sessionLoopBase } = loopContext;
889
+ const sessionContext = {
890
+ ...sessionLoopBase,
891
+ sessionId: env.sessionId,
892
+ projectId: session.projectId,
893
+ sessionDir: sessionDirAbs,
894
+ };
895
+ const hostedLoopSession = await startHostedLoopSession(sessionContext);
896
+ let current = session.pendingTurn?.runId === runId
897
+ ? { runId, ordinal: session.pendingTurn.ordinal, body: promptForPendingHostedTurn(session.pendingTurn) }
898
+ : { runId, ordinal: 1, body: session.task };
899
+ let currentBearer = bearer;
900
+ let lastResult = { status: 'succeeded', agentSessionId: session.agentSessionId, installOk: true, error: null };
901
+ let hostedTurnFailure = null;
902
+ try {
903
+ for (;;) {
904
+ assertSafeRunId(current.runId);
905
+ const currentRunDir = `${root}/.orizu-run/${current.runId}`;
906
+ const currentTaskFile = `${currentRunDir}/task.txt`;
907
+ mkdirp(currentRunDir);
908
+ writeFile(currentTaskFile, current.body);
909
+ writeSecretFile(bearerFileAbs, currentBearer.token, writeFile);
910
+ // Keep the failure reporter pinned to the turn that is about to execute;
911
+ // the boot-time run id becomes stale as soon as a follow-up is admitted.
912
+ opts.onBootContext?.({ runId: current.runId, bearer: currentBearer.token });
913
+ await postTurnStatus({
914
+ bootStatusUrl: env.bootStatusUrl, bootSecret: env.bootSecret,
915
+ status: 'turn_started', runId: current.runId, fetchImpl, sleep,
916
+ attemptTimeoutMs: turnStatusAttemptTimeoutMs,
917
+ });
918
+ await beginRunExecution({
919
+ baseUrl: env.baseUrl,
920
+ runId: current.runId,
921
+ bearer: currentBearer.token,
922
+ fetchImpl,
923
+ sleep,
924
+ attemptTimeoutMs: turnStatusAttemptTimeoutMs,
925
+ });
926
+ const rotation = startBearerRotation({
927
+ agentTokenUrl: env.agentTokenUrl, bootSecret: env.bootSecret, bearerFileAbs,
928
+ fetchImpl, writeFile, initialExpiresAtMs: currentBearer.expiresAtMs, now, log,
929
+ minimumDelayMs: bearerRotationMinimumDelayMs,
930
+ });
931
+ const promptInterruptController = new AbortController();
932
+ const interruptPollController = new AbortController();
933
+ const interruptPoll = pollHostedSessionDuringTurn({
934
+ baseUrl: env.baseUrl,
935
+ sessionId: env.sessionId,
936
+ runId: current.runId,
937
+ bearerFileAbs,
938
+ fetchImpl,
939
+ signal: interruptPollController.signal,
940
+ onInterrupt: () => promptInterruptController.abort(),
941
+ onDiagnostic: log,
942
+ readFile,
943
+ });
944
+ try {
945
+ lastResult = await runHostedLoopTurn(hostedLoopSession, {
946
+ runId: current.runId,
947
+ ordinal: current.ordinal,
948
+ taskFile: currentTaskFile,
949
+ messageId: `${current.runId}:task`,
950
+ bearerFile: bearerFileAbs,
951
+ }, {
952
+ taskPrompt: current.body,
953
+ redactSecretsList: [env.bootSecret],
954
+ bearerProvider: () => readFile(bearerFileAbs).trim(),
955
+ interruptSignal: promptInterruptController.signal,
956
+ onDiagnostic: log,
957
+ });
958
+ }
959
+ finally {
960
+ // Stop and join the during-prompt reader BEFORE removing the live
961
+ // ORIZU_TOKEN_FILE. Only the existing between-turn path unlinks it.
962
+ interruptPollController.abort();
963
+ await interruptPoll;
964
+ // Remove the active-turn credential before waiting for an in-flight
965
+ // refresh. The post-await stopped check then prevents its recreation.
966
+ try {
967
+ unlinkSync(bearerFileAbs);
968
+ }
969
+ catch { /* already absent */ }
970
+ await rotation.stop();
971
+ }
972
+ log(`hosted-loop turn ${current.ordinal} finished: ${lastResult.status}`);
973
+ await postTurnStatus({
974
+ bootStatusUrl: env.bootStatusUrl, bootSecret: env.bootSecret,
975
+ status: 'turn_completed', runId: current.runId, fetchImpl, sleep,
976
+ attemptTimeoutMs: turnStatusAttemptTimeoutMs,
977
+ });
978
+ if (lastResult.error) {
979
+ throw new Error(`fatal hosted turn exit: ${lastResult.error}`);
980
+ }
981
+ // One boot writer drains the lowest pending ordinal. Idle status reads
982
+ // reuse the current bearer, staying well below the coordinator's mint
983
+ // budget. Refresh proactively near expiry and once on a 401. Exhausting
984
+ // the bounded agent-token pull is fatal and reported for the current run;
985
+ // other transient status-read failures retain the session and back off.
986
+ for (;;) {
987
+ await sleep(5_000);
988
+ let pendingTurn = null;
989
+ try {
990
+ if (currentBearer.expiresAtMs !== null && currentBearer.expiresAtMs <= now() + 60_000) {
991
+ currentBearer = await pullAgentBearer({
992
+ agentTokenUrl: env.agentTokenUrl, bootSecret: env.bootSecret,
993
+ fetchImpl, attempts: 3, backoffMs: 250, sleep, log,
994
+ });
995
+ }
996
+ let latest;
997
+ try {
998
+ latest = await resolveSession({
999
+ baseUrl: env.baseUrl, sessionId: env.sessionId,
1000
+ bearer: currentBearer.token, fetchImpl,
1001
+ });
1002
+ }
1003
+ catch (error) {
1004
+ if (!(error instanceof Error) || !error.message.includes('(401)'))
1005
+ throw error;
1006
+ currentBearer = await pullAgentBearer({
1007
+ agentTokenUrl: env.agentTokenUrl, bootSecret: env.bootSecret,
1008
+ fetchImpl, attempts: 3, backoffMs: 250, sleep, log,
1009
+ });
1010
+ latest = await resolveSession({
1011
+ baseUrl: env.baseUrl, sessionId: env.sessionId,
1012
+ bearer: currentBearer.token, fetchImpl,
1013
+ });
1014
+ }
1015
+ if (latest.status && latest.status !== 'active') {
1016
+ return { ok: true, runId: current.runId, loopStatus: lastResult.status, error: null };
1017
+ }
1018
+ pendingTurn = latest.pendingTurn;
1019
+ }
1020
+ catch (error) {
1021
+ const detail = error instanceof Error ? error.message : String(error);
1022
+ if (detail.startsWith('agent-token pull failed after')) {
1023
+ throw new Error(`idle agent-token refresh failed: ${detail}`);
1024
+ }
1025
+ log(`turn poll failed; retrying (${detail})`);
1026
+ }
1027
+ finally {
1028
+ try {
1029
+ unlinkSync(bearerFileAbs);
1030
+ }
1031
+ catch { /* idle: no readable bearer */ }
1032
+ }
1033
+ if (pendingTurn) {
1034
+ current = {
1035
+ runId: pendingTurn.runId,
1036
+ ordinal: pendingTurn.ordinal,
1037
+ body: promptForPendingHostedTurn(pendingTurn),
1038
+ };
1039
+ break;
1040
+ }
1041
+ }
1042
+ }
1043
+ }
1044
+ catch (error) {
1045
+ const detail = error instanceof Error ? error.message : String(error);
1046
+ hostedTurnFailure = new HostedTurnFailure(detail);
622
1047
  }
623
1048
  finally {
624
- rotation.stop();
1049
+ try {
1050
+ await closeHostedLoopSession(hostedLoopSession);
1051
+ }
1052
+ catch (error) {
1053
+ log(`hosted-loop cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1054
+ }
1055
+ try {
1056
+ unlinkSync(bearerFileAbs);
1057
+ }
1058
+ catch { /* already absent */ }
1059
+ try {
1060
+ rmSync(sessionDirAbs, { recursive: true, force: true });
1061
+ }
1062
+ catch (error) {
1063
+ log(`hosted-loop session-dir cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1064
+ }
625
1065
  }
1066
+ if (hostedTurnFailure)
1067
+ throw hostedTurnFailure;
1068
+ throw new HostedTurnFailure('hosted turn drain exited without a terminal result');
626
1069
  }
627
1070
  /**
628
1071
  * Background refresher: before the current bearer expires, pull a fresh one from
@@ -633,7 +1076,7 @@ export async function runHostedBoot(opts) {
633
1076
  * reads this file fresh per request (see runHostedBoot), so a rewritten file is
634
1077
  * picked up on the next event/request. No-ops when there is no expiry signal.
635
1078
  */
636
- function startBearerRotation(opts) {
1079
+ export function startBearerRotation(opts) {
637
1080
  // Refresh ~5 min before expiry; if the server gives no expiry, fall back to a
638
1081
  // conservative fixed cadence just under a typical 60-min TTL.
639
1082
  const REFRESH_BUFFER_MS = 5 * 60 * 1000;
@@ -643,15 +1086,7 @@ function startBearerRotation(opts) {
643
1086
  const MAX_SCHEDULE_MS = 6 * 60 * 60 * 1000;
644
1087
  let stopped = false;
645
1088
  let timer = null;
646
- const scheduleFrom = (expiresAtMs) => {
647
- if (stopped)
648
- return;
649
- const raw = expiresAtMs ? expiresAtMs - opts.now() - REFRESH_BUFFER_MS : FALLBACK_INTERVAL_MS;
650
- const delay = Math.min(MAX_SCHEDULE_MS, Math.max(30_000, raw));
651
- timer = setTimeout(refresh, delay);
652
- if (typeof timer.unref === 'function')
653
- timer.unref();
654
- };
1089
+ let refreshInFlight = null;
655
1090
  const refresh = async () => {
656
1091
  if (stopped)
657
1092
  return;
@@ -662,22 +1097,46 @@ function startBearerRotation(opts) {
662
1097
  fetchImpl: opts.fetchImpl,
663
1098
  attempts: 3,
664
1099
  });
1100
+ // stop() may have won while the network pull was in flight. Never
1101
+ // recreate the bearer after the turn has entered its credential-free idle.
1102
+ if (stopped)
1103
+ return;
665
1104
  const contents = fresh.token.endsWith('\n') ? fresh.token : `${fresh.token}\n`;
666
1105
  opts.writeFile(opts.bearerFileAbs, contents);
667
1106
  opts.log('rotated agent bearer (0600 file rewritten)');
668
1107
  scheduleFrom(fresh.expiresAtMs);
669
1108
  }
670
1109
  catch (error) {
1110
+ if (stopped)
1111
+ return;
671
1112
  opts.log(`agent bearer rotation failed: ${error instanceof Error ? error.message : String(error)}`);
672
1113
  scheduleFrom(opts.now() + REFRESH_BUFFER_MS); // retry soon
673
1114
  }
674
1115
  };
1116
+ const startRefresh = () => {
1117
+ const operation = refresh();
1118
+ refreshInFlight = operation;
1119
+ void operation.finally(() => {
1120
+ if (refreshInFlight === operation)
1121
+ refreshInFlight = null;
1122
+ });
1123
+ };
1124
+ const scheduleFrom = (expiresAtMs) => {
1125
+ if (stopped)
1126
+ return;
1127
+ const raw = expiresAtMs ? expiresAtMs - opts.now() - REFRESH_BUFFER_MS : FALLBACK_INTERVAL_MS;
1128
+ const delay = Math.min(MAX_SCHEDULE_MS, Math.max(opts.minimumDelayMs ?? 30_000, raw));
1129
+ timer = (opts.schedule ?? setTimeout)(startRefresh, delay);
1130
+ if (typeof timer.unref === 'function')
1131
+ timer.unref();
1132
+ };
675
1133
  scheduleFrom(opts.initialExpiresAtMs);
676
1134
  return {
677
- stop: () => {
1135
+ stop: async () => {
678
1136
  stopped = true;
679
1137
  if (timer)
680
1138
  clearTimeout(timer);
1139
+ await refreshInFlight;
681
1140
  },
682
1141
  };
683
1142
  }
@@ -718,7 +1177,11 @@ export async function hostedBootCommand(io) {
718
1177
  // Report it so the DO stops + destroys the sandbox NOW instead of waiting
719
1178
  // for the readiness timeout, and mark the run failed so it never stays
720
1179
  // 'running' forever (the DO also marks it — both are idempotent).
721
- await reportBootFailure({ env, ctx: bootCtx, reason: message, log });
1180
+ await reportBootFailure({
1181
+ env, ctx: bootCtx, reason: message, log,
1182
+ status: error instanceof HostedTurnFailure ? 'turn_failed' : 'failed',
1183
+ attemptTimeoutMs: resolveTurnStatusAttemptTimeoutMs(process.env),
1184
+ });
722
1185
  return 1;
723
1186
  }
724
1187
  }
@@ -773,10 +1236,30 @@ export async function reportBootFailure(opts) {
773
1236
  opts.log(`run-failed mark failed: ${error instanceof Error ? error.message : String(error)}`);
774
1237
  }
775
1238
  }
1239
+ const status = opts.status ?? 'failed';
1240
+ if (status === 'turn_failed' && runId) {
1241
+ try {
1242
+ await postTurnStatus({
1243
+ bootStatusUrl: opts.env.bootStatusUrl,
1244
+ bootSecret: opts.env.bootSecret,
1245
+ status,
1246
+ runId,
1247
+ reason,
1248
+ fetchImpl,
1249
+ sleep: opts.sleep,
1250
+ attemptTimeoutMs: opts.attemptTimeoutMs,
1251
+ });
1252
+ opts.log('turn_failed acknowledged');
1253
+ }
1254
+ catch (error) {
1255
+ opts.log(`turn_failed acknowledgement exhausted: ${error instanceof Error ? error.message : String(error)}`);
1256
+ }
1257
+ return;
1258
+ }
776
1259
  await postBootStatus({
777
1260
  bootStatusUrl: opts.env.bootStatusUrl,
778
1261
  bootSecret: opts.env.bootSecret,
779
- status: 'failed',
1262
+ status,
780
1263
  runId,
781
1264
  reason,
782
1265
  fetchImpl,