orizu 0.5.7 → 0.5.9

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Auto-harvest (ALI-1036) — deterministic, loop-side durability.
3
+ *
4
+ * At the END of a hosted run (BOTH the success and failure paths — a failed
5
+ * run's partial work is still valuable), the in-sandbox loop calls
6
+ * `harvestWorkspace` to make sure nothing the agent produced is lost:
7
+ *
8
+ * 1. `git status --porcelain` (plumbing) decides if the clone is dirty. Ignored
9
+ * files never appear in porcelain output, so a checkpoint is skipped when the
10
+ * only changes are ignored files.
11
+ * 2. If dirty: `git add -A` then `git commit` attributed to the agent identity
12
+ * (AGENT_GIT_IDENTITY) with message `checkpoint: run <runId> auto-harvest`.
13
+ * 3. If there is anything unpushed (a fresh checkpoint OR pre-existing local
14
+ * commits the agent made but did not push), `git push` to the session branch
15
+ * (the repo-local credential helper configured by bootstrap authorizes it).
16
+ * 4. Return a typed outcome the loop records as a run event: `work_persisted`
17
+ * (sha + files), `work_none` (clean, nothing to push), or `work_persist_failed`
18
+ * (error). Harvest NEVER throws — a harvest failure must not change the run's
19
+ * terminal status; it is recorded and the run proceeds to terminal.
20
+ *
21
+ * The loop runs IN the sandbox, so git is driven with `child_process` (matching
22
+ * `defaultRunSetupHook` / `installOpenCodePinned` in `hosted-loop.ts`). `exec` is
23
+ * injectable so the whole thing is unit-testable with no real git.
24
+ */
25
+
26
+ import { spawnSync } from 'child_process'
27
+
28
+ export interface HarvestExecResult {
29
+ exitCode: number
30
+ stdout: string
31
+ stderr: string
32
+ }
33
+
34
+ /** Injectable git runner. Defaults to a `spawnSync` git in the workspace dir. */
35
+ export type HarvestExec = (args: readonly string[]) => HarvestExecResult
36
+
37
+ export interface HarvestOptions {
38
+ /** Directory of the cloned session-branch workspace (the loop's cwd for git). */
39
+ workspaceDir: string
40
+ runId: string
41
+ /** Commit attribution (AGENT_GIT_IDENTITY from hosted-runtime-assets). */
42
+ author: { name: string; email: string }
43
+ /** Injectable git runner (default: real `git` via child_process). */
44
+ exec?: HarvestExec
45
+ /**
46
+ * Safety gate (review finding #1): the default exec runs REAL `git add/commit/
47
+ * push` in `workspaceDir`. Real harvest must be affirmatively enabled — the loop
48
+ * only enables it inside a genuine hosted sandbox (prebaked marker present). When
49
+ * false AND no `exec` is injected, harvest is a no-op (`work_none`) so an errant
50
+ * workspaceDir (a test, a dev run) can never commit/push a host repo. An injected
51
+ * `exec` always wins (tests that DO drive harvest).
52
+ */
53
+ enabled?: boolean
54
+ }
55
+
56
+ export type HarvestOutcome =
57
+ | { kind: 'work_persisted'; sha: string; files: string[] }
58
+ | { kind: 'work_none' }
59
+ | { kind: 'work_persist_failed'; error: string }
60
+
61
+ function defaultHarvestExec(workspaceDir: string): HarvestExec {
62
+ return (args: readonly string[]): HarvestExecResult => {
63
+ const res = spawnSync('git', args as string[], { cwd: workspaceDir, encoding: 'utf8' })
64
+ if (res.error) {
65
+ return { exitCode: 1, stdout: '', stderr: res.error.message }
66
+ }
67
+ return { exitCode: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }
68
+ }
69
+ }
70
+
71
+ function detail(res: HarvestExecResult): string {
72
+ const text = (res.stderr && res.stderr.trim()) || (res.stdout && res.stdout.trim()) || `exit ${res.exitCode}`
73
+ return text
74
+ }
75
+
76
+ function splitLines(text: string): string[] {
77
+ return text
78
+ .split('\n')
79
+ .map(line => line.trim())
80
+ .filter(line => line.length > 0)
81
+ }
82
+
83
+ /**
84
+ * Count commits present locally but not on the upstream tracking branch. Returns
85
+ * 0 when there is no upstream (`@{u}` unresolvable) — with no upstream a push
86
+ * could not land anyway, so "nothing to push" is the safe reading.
87
+ */
88
+ function countUnpushed(exec: HarvestExec): number {
89
+ const res = exec(['rev-list', '--count', '@{u}..HEAD'])
90
+ if (res.exitCode !== 0) return 0
91
+ const n = Number.parseInt(res.stdout.trim(), 10)
92
+ return Number.isFinite(n) ? n : 0
93
+ }
94
+
95
+ /**
96
+ * Persist any uncommitted or unpushed work in the workspace. Deterministic and
97
+ * total: every path returns a `HarvestOutcome`, never throws.
98
+ */
99
+ export function harvestWorkspace(opts: HarvestOptions): HarvestOutcome {
100
+ // Safety gate: never touch a real repo unless explicitly enabled or given an
101
+ // injected exec. Absent both, report nothing-to-do rather than run host git.
102
+ if (!opts.exec && !opts.enabled) {
103
+ return { kind: 'work_none' }
104
+ }
105
+ const exec = opts.exec ?? defaultHarvestExec(opts.workspaceDir)
106
+ try {
107
+ // Never harvest bootstrap-injected runtime scaffolding (ALI-1051): the
108
+ // .claude/skills symlink is a sandbox-local pointer, not the agent's work.
109
+ // Bootstrap also excludes it via .git/info/exclude; this pathspec is the
110
+ // belt-and-braces for any sandbox where that didn't run.
111
+ const excludeScaffold = [':(exclude).claude/skills/**', ':(exclude).claude/skills']
112
+ const status = exec(['status', '--porcelain', '--', '.', ...excludeScaffold])
113
+ if (status.exitCode !== 0) {
114
+ return { kind: 'work_persist_failed', error: `git status failed: ${detail(status)}` }
115
+ }
116
+ const dirty = status.stdout.trim().length > 0
117
+
118
+ if (dirty) {
119
+ const add = exec(['add', '-A', '--', '.', ...excludeScaffold])
120
+ if (add.exitCode !== 0) {
121
+ return { kind: 'work_persist_failed', error: `git add failed: ${detail(add)}` }
122
+ }
123
+ const commit = exec([
124
+ '-c',
125
+ `user.name=${opts.author.name}`,
126
+ '-c',
127
+ `user.email=${opts.author.email}`,
128
+ 'commit',
129
+ '--author',
130
+ `${opts.author.name} <${opts.author.email}>`,
131
+ '-m',
132
+ `checkpoint: run ${opts.runId} auto-harvest`,
133
+ ])
134
+ if (commit.exitCode !== 0) {
135
+ return { kind: 'work_persist_failed', error: `git commit failed: ${detail(commit)}` }
136
+ }
137
+ }
138
+
139
+ // Nothing to push when the tree was clean AND there are no local-only commits.
140
+ const unpushed = countUnpushed(exec)
141
+ if (!dirty && unpushed <= 0) {
142
+ return { kind: 'work_none' }
143
+ }
144
+
145
+ const headRes = exec(['rev-parse', 'HEAD'])
146
+ const sha = headRes.exitCode === 0 ? headRes.stdout.trim() : ''
147
+
148
+ // Files: the checkpoint commit's tree when we just committed, else everything
149
+ // spanned by the unpushed commits.
150
+ const filesRes = dirty
151
+ ? exec(['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'])
152
+ : exec(['diff', '--name-only', '@{u}..HEAD'])
153
+ const files = filesRes.exitCode === 0 ? splitLines(filesRes.stdout) : []
154
+
155
+ const push = exec(['push'])
156
+ if (push.exitCode !== 0) {
157
+ return { kind: 'work_persist_failed', error: `git push failed: ${detail(push)}` }
158
+ }
159
+
160
+ return { kind: 'work_persisted', sha, files }
161
+ } catch (error) {
162
+ return { kind: 'work_persist_failed', error: error instanceof Error ? error.message : String(error) }
163
+ }
164
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Headless-run helpers (ALI-1037) for the in-sandbox loop.
3
+ *
4
+ * - `withIdleWatchdog`: wraps a harness event stream and aborts a prompt that
5
+ * makes NO progress (no harness event) for a bounded window, so a stalled
6
+ * agent can never hang a hosted run forever. On idle it invokes `onTimeout`
7
+ * (the loop aborts the harness) then throws `AgentStalledError`, which the
8
+ * loop turns into a `failed` terminal — AFTER running the end-of-run harvest.
9
+ *
10
+ * - `annotateHeadlessQuestions`: observability for the question-tool deny path.
11
+ * The interactive `question` tool is DENIED via OpenCode's permission map
12
+ * (see HEADLESS_DENIED_TOOLS in hosted-harness-opencode.ts), so a headless
13
+ * run never blocks on it. When the agent nonetheless attempts a `question`
14
+ * tool call, this wrapper emits a `question_auto_answered` event recording the
15
+ * question and the option that WOULD be recommended, so the run timeline shows
16
+ * the auto-handling. It touches only the normalized event stream — never the
17
+ * fragile OpenCode SSE transform.
18
+ */
19
+
20
+ import type { HarnessEvent } from './hosted-harness.js'
21
+
22
+ /** Thrown by `withIdleWatchdog` when the stream is idle past its timeout. */
23
+ export class AgentStalledError extends Error {
24
+ readonly idleMs: number
25
+ constructor(idleMs: number) {
26
+ super(`agent_stalled: no progress for ${formatIdleWindow(idleMs)}`)
27
+ this.name = 'AgentStalledError'
28
+ this.idleMs = idleMs
29
+ }
30
+ }
31
+
32
+ /** Render the idle window for the error message. Minutes (the operator-facing
33
+ * unit; default window is 10m), rounded to one decimal so sub-minute test
34
+ * windows still read sensibly. */
35
+ export function formatIdleWindow(ms: number): string {
36
+ const minutes = Math.round((ms / 60000) * 10) / 10
37
+ return `${minutes}m`
38
+ }
39
+
40
+ export interface IdleWatchdogOptions {
41
+ /** No harness event within this many ms aborts the prompt. <= 0 disables. */
42
+ timeoutMs: number
43
+ /** Invoked once on idle (the loop aborts the harness) before the throw. */
44
+ onTimeout: () => Promise<void> | void
45
+ }
46
+
47
+ const IDLE_SENTINEL = Symbol('idle')
48
+
49
+ /**
50
+ * Wrap `source` so that if no value arrives within `timeoutMs`, `onTimeout` runs
51
+ * and `AgentStalledError` is thrown. The idle timer resets on EVERY value, so it
52
+ * measures gaps between events (progress), not total duration. The still-pending
53
+ * `next()` is not left as an unhandled rejection, and the underlying iterator is
54
+ * returned/closed on any early exit.
55
+ */
56
+ export async function* withIdleWatchdog<T>(
57
+ source: AsyncIterable<T>,
58
+ opts: IdleWatchdogOptions
59
+ ): AsyncGenerator<T> {
60
+ const iterator = source[Symbol.asyncIterator]()
61
+ try {
62
+ for (;;) {
63
+ let timer: ReturnType<typeof setTimeout> | undefined
64
+ const idle = new Promise<typeof IDLE_SENTINEL>(resolve => {
65
+ timer = setTimeout(() => resolve(IDLE_SENTINEL), opts.timeoutMs)
66
+ })
67
+ const nextResult = iterator.next()
68
+ let raced: IteratorResult<T> | typeof IDLE_SENTINEL
69
+ try {
70
+ raced = await Promise.race([nextResult, idle])
71
+ } finally {
72
+ if (timer) clearTimeout(timer)
73
+ }
74
+ if (raced === IDLE_SENTINEL) {
75
+ // Swallow the abandoned next() so it never surfaces as an unhandled
76
+ // rejection once the underlying stream tears down.
77
+ void Promise.resolve(nextResult).catch(() => {})
78
+ await opts.onTimeout()
79
+ throw new AgentStalledError(opts.timeoutMs)
80
+ }
81
+ if (raced.done) return
82
+ yield raced.value
83
+ }
84
+ } finally {
85
+ if (iterator.return) {
86
+ try {
87
+ await iterator.return()
88
+ } catch {
89
+ // best-effort close
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // -- Headless question annotation --------------------------------------------
96
+
97
+ function optionText(option: unknown): string {
98
+ if (typeof option === 'string') return option
99
+ if (option && typeof option === 'object') {
100
+ const rec = option as Record<string, unknown>
101
+ for (const key of ['label', 'text', 'value', 'title', 'option', 'name']) {
102
+ if (typeof rec[key] === 'string') return rec[key] as string
103
+ }
104
+ }
105
+ return ''
106
+ }
107
+
108
+ export interface ParsedHeadlessQuestion {
109
+ question: string
110
+ chosenOption: string
111
+ }
112
+
113
+ /**
114
+ * Parse the OpenCode `question` tool args into the question text and the option
115
+ * we would pick headless: the one labeled "(Recommended)" if present, else the
116
+ * first. Defensive against the tool's shape drifting (options as strings or
117
+ * objects; the question under any of several keys).
118
+ */
119
+ export function parseHeadlessQuestion(args: unknown): ParsedHeadlessQuestion {
120
+ const rec = args && typeof args === 'object' ? (args as Record<string, unknown>) : {}
121
+ const questions = Array.isArray(rec.questions) ? rec.questions : []
122
+ const first = questions[0]
123
+
124
+ let question = ''
125
+ let options: unknown[] = []
126
+ if (typeof first === 'string') {
127
+ question = first
128
+ } else if (first && typeof first === 'object') {
129
+ const q = first as Record<string, unknown>
130
+ for (const key of ['question', 'prompt', 'text', 'title', 'message']) {
131
+ if (typeof q[key] === 'string') {
132
+ question = q[key] as string
133
+ break
134
+ }
135
+ }
136
+ if (Array.isArray(q.options)) options = q.options
137
+ }
138
+ if (options.length === 0 && Array.isArray(rec.options)) options = rec.options
139
+ if (!question && typeof rec.question === 'string') question = rec.question
140
+
141
+ const texts = options.map(optionText).filter(text => text.length > 0)
142
+ const recommended = texts.find(text => /\(recommended\)/i.test(text))
143
+ const chosenOption = recommended ?? texts[0] ?? ''
144
+ return { question, chosenOption }
145
+ }
146
+
147
+ /**
148
+ * Pass every harness event through unchanged; when a `question` tool call/result
149
+ * is seen, additionally emit a `question_auto_answered` event (deduped per
150
+ * callId+message) recording the auto-handling. The tool is denied at the
151
+ * permission layer, so this is a record — not an interactive reply.
152
+ */
153
+ export async function* annotateHeadlessQuestions(
154
+ source: AsyncIterable<HarnessEvent>
155
+ ): AsyncGenerator<HarnessEvent> {
156
+ const seen = new Set<string>()
157
+ for await (const event of source) {
158
+ yield event
159
+ if (event.kind !== 'tool_call' && event.kind !== 'tool_result') continue
160
+ const payload = event.payload as Record<string, unknown>
161
+ if (payload.tool !== 'question') continue
162
+ const callId = typeof payload.callId === 'string' ? payload.callId : ''
163
+ const key = `${callId}:${event.messageId ?? ''}`
164
+ if (seen.has(key)) continue
165
+ seen.add(key)
166
+ const parsed = parseHeadlessQuestion(payload.args)
167
+ yield {
168
+ kind: 'question_auto_answered',
169
+ messageId: event.messageId,
170
+ payload: {
171
+ question: parsed.question,
172
+ chosenOption: parsed.chosenOption,
173
+ note: 'auto-answered: headless run, no human available',
174
+ resolution: 'denied_headless',
175
+ },
176
+ }
177
+ }
178
+ }
@@ -40,10 +40,14 @@ import { join } from 'path'
40
40
 
41
41
  import type { AgentHarness, HarnessEvent, HarnessPrompt } from './hosted-harness.js'
42
42
  import {
43
+ HOSTED_TASK_PREAMBLE,
43
44
  PREBAKED_MARKER_PATH,
44
45
  SETUP_HOOK_RELATIVE_PATH,
46
+ composeHostedTaskPrompt,
45
47
  parsePrebakedMarker,
46
48
  } from './hosted-runtime-assets.js'
49
+ import { harvestWorkspace, type HarvestExec, type HarvestOutcome } from './hosted-harvest.js'
50
+ import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.js'
47
51
  import {
48
52
  OPENCODE_PINNED_VERSION,
49
53
  createOpenCodeHarness,
@@ -132,6 +136,28 @@ export interface HostedLoopContext {
132
136
  runSetupHook?: boolean
133
137
  }
134
138
 
139
+ /**
140
+ * Environment injected into the spawned OpenCode server so the AGENT's bash sees a
141
+ * PRE-AUTHENTICATED `orizu` CLI (ALI-1044). ORIZU_TOKEN_FILE points at the 0600
142
+ * rotated bearer file and ORIZU_BASE_URL at the Orizu API — the exact vars the
143
+ * CLI's auth resolution reads (resolveEnvBearerToken + resolveBaseUrl). The dummy
144
+ * ANTHROPIC key (model-key brokering, G3) is preserved. No real secret is carried
145
+ * here: the bearer stays on disk and is read fresh per request from the file.
146
+ */
147
+ export function buildOpenCodeSpawnEnv(context: HostedLoopContext): Record<string, string> | undefined {
148
+ const env: Record<string, string> = {}
149
+ if (context.anthropicDummyKey) {
150
+ env.ANTHROPIC_API_KEY = context.anthropicDummyKey
151
+ }
152
+ if (context.bearerFile) {
153
+ env.ORIZU_TOKEN_FILE = context.bearerFile
154
+ }
155
+ if (context.apiBaseUrl) {
156
+ env.ORIZU_BASE_URL = context.apiBaseUrl
157
+ }
158
+ return Object.keys(env).length > 0 ? env : undefined
159
+ }
160
+
135
161
  export interface InstallResult {
136
162
  ok: boolean
137
163
  detail: string
@@ -171,6 +197,24 @@ export interface RunHostedLoopOptions {
171
197
  * workspace, non-fatal). Injectable so tests exercise the deferred-hook path
172
198
  * with no real filesystem or child process. */
173
199
  runSetupHook?: (input: { workspaceDir: string }) => Promise<SetupHookOutcome> | SetupHookOutcome
200
+ /**
201
+ * Standing preamble wrapped around the user task (ALI-1036). Defaults to
202
+ * `HOSTED_TASK_PREAMBLE`; pass an override to customize it, or an empty string
203
+ * to send the task verbatim (tests). The user task is always kept verbatim
204
+ * beneath a delimiter (see `composeHostedTaskPrompt`).
205
+ */
206
+ taskPreamble?: string
207
+ /**
208
+ * Injectable git runner for the end-of-run auto-harvest (ALI-1036). Default:
209
+ * real `git` via child_process in `context.workspaceDir`.
210
+ */
211
+ harvestExec?: HarvestExec
212
+ /**
213
+ * Idle watchdog window (ALI-1037): abort the prompt + fail the run
214
+ * `agent_stalled` if NO harness event arrives for this many ms. Default: env
215
+ * `ORIZU_AGENT_IDLE_TIMEOUT_MS` or 10 minutes. <= 0 disables the watchdog.
216
+ */
217
+ idleTimeoutMs?: number
174
218
  }
175
219
 
176
220
  export interface HostedLoopResult {
@@ -363,6 +407,25 @@ function defaultRunSetupHook({ workspaceDir }: { workspaceDir: string }): SetupH
363
407
  return { ran: true, ok, detail: ok ? 'setup.sh ok' : `setup.sh exit ${res.status ?? 'unknown'}` }
364
408
  }
365
409
 
410
+ /** Default idle-watchdog window (ALI-1037): abort a prompt that makes no progress
411
+ * for this long. The timer resets on every harness EVENT, and a legitimate long
412
+ * single tool call (a big `npm install`, a build, an optimization step) emits no
413
+ * intermediate events — so this must comfortably exceed the longest expected
414
+ * quiet tool call (review finding #2). Overridable per-run via `idleTimeoutMs` or
415
+ * the `ORIZU_AGENT_IDLE_TIMEOUT_MS` env var. */
416
+ export const DEFAULT_AGENT_IDLE_TIMEOUT_MS = 25 * 60 * 1000
417
+
418
+ /** Resolve the idle window: explicit option wins, then env, then the default. */
419
+ function resolveIdleTimeoutMs(override: number | undefined): number {
420
+ if (typeof override === 'number' && Number.isFinite(override)) return override
421
+ const raw = process.env.ORIZU_AGENT_IDLE_TIMEOUT_MS
422
+ if (raw) {
423
+ const parsed = Number.parseInt(raw, 10)
424
+ if (Number.isFinite(parsed)) return parsed
425
+ }
426
+ return DEFAULT_AGENT_IDLE_TIMEOUT_MS
427
+ }
428
+
366
429
  /** Model-key env vars whose value must be scrubbed from every run event. */
367
430
  const MODEL_KEY_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']
368
431
 
@@ -552,6 +615,44 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
552
615
  }
553
616
  }
554
617
 
618
+ // End-of-run auto-harvest (ALI-1036): commit + push anything the agent left
619
+ // uncommitted/unpushed so a run NEVER loses work — on BOTH the success and
620
+ // failure paths. Runs at most once (guarded), records exactly one of
621
+ // work_persisted / work_none / work_persist_failed, and NEVER throws or changes
622
+ // the run's terminal status: a harvest failure is recorded and the run proceeds.
623
+ let harvested = false
624
+ const runAutoHarvest = async (): Promise<void> => {
625
+ if (harvested) return
626
+ harvested = true
627
+ let outcome: HarvestOutcome
628
+ try {
629
+ outcome = harvestWorkspace({
630
+ workspaceDir: context.workspaceDir,
631
+ runId: context.runId,
632
+ author: context.author,
633
+ exec: opts.harvestExec,
634
+ // Real harvest only inside a genuine hosted sandbox (prebaked marker
635
+ // present) — never on a host/test working tree (review finding #1).
636
+ enabled: existsSync(PREBAKED_MARKER_PATH),
637
+ })
638
+ } catch (error) {
639
+ outcome = { kind: 'work_persist_failed', error: error instanceof Error ? error.message : String(error) }
640
+ }
641
+ if (sink.sealed) return
642
+ try {
643
+ if (outcome.kind === 'work_persisted') {
644
+ await sink.append({ kind: 'work_persisted', payload: { sha: outcome.sha, files: outcome.files } })
645
+ } else if (outcome.kind === 'work_none') {
646
+ await sink.append({ kind: 'work_none', payload: {} })
647
+ } else {
648
+ await sink.append({ kind: 'work_persist_failed', payload: { error: outcome.error } })
649
+ }
650
+ } catch {
651
+ // Recording harvest is best-effort — never fail the run because the harvest
652
+ // event append failed.
653
+ }
654
+ }
655
+
555
656
  let spawned: SpawnedOpenCode | null = null
556
657
  let agentSessionId: string | null = null
557
658
  let installOk = false
@@ -604,7 +705,7 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
604
705
  model: context.model,
605
706
  cwd: context.workspaceDir,
606
707
  port: context.opencodePort,
607
- env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
708
+ env: buildOpenCodeSpawnEnv(context),
608
709
  spawn: nodeChildSpawner,
609
710
  logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
610
711
  signal,
@@ -629,21 +730,49 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
629
730
  })
630
731
  agentSessionId = started.agentSessionId
631
732
 
733
+ // Prompt scaffolding (ALI-1036): wrap the user task with the standing
734
+ // headless preamble, keeping the task verbatim beneath a delimiter.
632
735
  const prompt: HarnessPrompt = {
633
736
  runId: context.runId,
634
737
  messageId: context.messageId,
635
- content: taskPrompt,
738
+ content: composeHostedTaskPrompt(taskPrompt, opts.taskPreamble ?? HOSTED_TASK_PREAMBLE),
636
739
  author: context.author,
637
740
  }
638
- const status = await drainHarnessToSink(harness.runPrompt(prompt, signal), sink, {
741
+ // Drive the prompt through (a) question auto-handling annotation and (b) the
742
+ // idle watchdog (ALI-1037): no harness event for `idleTimeoutMs` aborts the
743
+ // prompt and throws `AgentStalledError` → the catch below harvests, then
744
+ // finishes the run `failed`. The prompt runs under a CHILD AbortController
745
+ // linked to the loop signal so the watchdog can abort the in-flight prompt
746
+ // (unblocking a signal-respecting harness the same way a cancel would), then
747
+ // best-effort `harness.stop()` the driver.
748
+ const idleTimeoutMs = resolveIdleTimeoutMs(opts.idleTimeoutMs)
749
+ const promptController = new AbortController()
750
+ if (signal.aborted) promptController.abort()
751
+ else signal.addEventListener('abort', () => promptController.abort(), { once: true })
752
+ const annotated = annotateHeadlessQuestions(harness.runPrompt(prompt, promptController.signal))
753
+ const drivenStream =
754
+ idleTimeoutMs > 0
755
+ ? withIdleWatchdog(annotated, {
756
+ timeoutMs: idleTimeoutMs,
757
+ onTimeout: async () => {
758
+ promptController.abort()
759
+ await harness.stop()
760
+ },
761
+ })
762
+ : annotated
763
+ const status = await drainHarnessToSink(drivenStream, sink, {
639
764
  summary: { agentSessionId },
765
+ // Auto-harvest runs on EVERY terminal path, before the sink seals.
766
+ beforeFinish: runAutoHarvest,
640
767
  })
641
768
  await harness.shutdown()
642
769
  return { status, agentSessionId, installOk, error: null }
643
770
  } catch (error) {
644
771
  const message = error instanceof Error ? error.message : String(error)
645
772
  // Fail closed: never leave the run non-terminal. finish() is idempotent and
646
- // no-ops when the drain already sealed the sink.
773
+ // no-ops when the drain already sealed the sink. Harvest FIRST (partial work
774
+ // is valuable, incl. an idle-watchdog abort) — before the terminal seals.
775
+ await runAutoHarvest()
647
776
  if (!sink.sealed) {
648
777
  try {
649
778
  await sink.finish('failed', { summary: { error: message } })
@@ -77,6 +77,13 @@ const EVENT_TYPE_BY_KIND: Partial<Record<HarnessEventKind, string>> = {
77
77
  // through this SAME sink (single writer). See EGRESS_*_EVENT_TYPE below.
78
78
  egress_blocked: EGRESS_BLOCKED_EVENT_TYPE,
79
79
  egress_allowed: EGRESS_ALLOWED_EVENT_TYPE,
80
+ // Durability + headless (ALI-1036 / ALI-1037), emitted by the in-sandbox loop
81
+ // through this SAME sink (single writer): auto-harvest results + headless
82
+ // question auto-handling record.
83
+ work_persisted: 'work_persisted',
84
+ work_none: 'work_none',
85
+ work_persist_failed: 'work_persist_failed',
86
+ question_auto_answered: 'agent_question_auto_answered',
80
87
  }
81
88
 
82
89
  /** The RunAPI event type a harness kind maps to, or null if it is not appendable
@@ -576,6 +583,15 @@ export interface DrainOptions {
576
583
  transcript?: string
577
584
  summary?: Record<string, unknown>
578
585
  evidence?: Record<string, unknown>
586
+ /**
587
+ * Hook invoked AFTER the stream yields its terminal event but BEFORE `finish()`
588
+ * seals the sink, on EVERY terminal path (success, failure, abnormal end). Used
589
+ * by the loop's end-of-run auto-harvest (ALI-1036) so `work_persisted` /
590
+ * `work_none` / `work_persist_failed` can still be appended while the sink is
591
+ * writable. Its own failure must never change the run's terminal status — the
592
+ * hook itself is responsible for swallowing errors.
593
+ */
594
+ beforeFinish?: () => Promise<void>
579
595
  }
580
596
 
581
597
  /**
@@ -590,6 +606,14 @@ export async function drainHarnessToSink(
590
606
  sink: RunEventSink,
591
607
  opts: DrainOptions = {}
592
608
  ): Promise<TerminalStatus> {
609
+ // Run the pre-terminal hook (auto-harvest) at most once, right before the first
610
+ // finish() on any terminal path — while the sink is still writable.
611
+ let beforeFinishRan = false
612
+ const runBeforeFinish = async (): Promise<void> => {
613
+ if (beforeFinishRan) return
614
+ beforeFinishRan = true
615
+ if (opts.beforeFinish) await opts.beforeFinish()
616
+ }
593
617
  for await (const event of events) {
594
618
  if (event.kind === 'execution_complete') {
595
619
  // A prompt the caller/agent stopped is a cancellation, not a failure: the
@@ -597,6 +621,7 @@ export async function drainHarnessToSink(
597
621
  // and stop()-initiated paths. A genuine failure (success:false, no abort
598
622
  // flag) still lands 'failed'.
599
623
  if (event.payload.aborted === true) {
624
+ await runBeforeFinish()
600
625
  await sink.finish('cancelled', {
601
626
  summary: opts.summary,
602
627
  evidence: opts.evidence,
@@ -609,10 +634,12 @@ export async function drainHarnessToSink(
609
634
  const summary = success
610
635
  ? opts.summary
611
636
  : { ...(opts.summary ?? {}), error: event.payload.error ?? 'execution failed' }
637
+ await runBeforeFinish()
612
638
  await sink.finish(status, { summary, evidence: opts.evidence, transcript: opts.transcript })
613
639
  return status
614
640
  }
615
641
  if (event.kind === 'error') {
642
+ await runBeforeFinish()
616
643
  await sink.finish('failed', {
617
644
  summary: { ...(opts.summary ?? {}), error: event.payload.error ?? 'agent error' },
618
645
  evidence: opts.evidence,
@@ -624,6 +651,7 @@ export async function drainHarnessToSink(
624
651
  }
625
652
  // The harness contract guarantees a terminal event; reaching here means the
626
653
  // stream ended abnormally. Fail closed so the run never dangles non-terminal.
654
+ await runBeforeFinish()
627
655
  await sink.finish('failed', {
628
656
  summary: { ...(opts.summary ?? {}), error: 'harness stream ended without a terminal event' },
629
657
  transcript: opts.transcript,
@@ -44,6 +44,37 @@ export const SETUP_HOOK_RELATIVE_PATH = '.orizu/setup.sh'
44
44
  */
45
45
  export const AGENT_GIT_IDENTITY = { name: 'Orizu Agent', email: 'agent@orizu.ai' } as const
46
46
 
47
+ // -- Hosted task prompt scaffolding (ALI-1036) -------------------------------
48
+
49
+ /**
50
+ * Standing preamble wrapped around every hosted task prompt so a NATURAL task
51
+ * ("review X and update the readme") runs unattended with zero magic words. The
52
+ * agent is told to work autonomously (never block on the human), commit as it
53
+ * goes, and treat Orizu — not the repo — as the home for production pointers and
54
+ * run metadata. An end-of-run auto-harvest (ALI-1036, `harvestWorkspace`) is the
55
+ * safety net for anything left uncommitted; the preamble names it so the agent
56
+ * knows partial work is preserved.
57
+ */
58
+ export const HOSTED_TASK_PREAMBLE =
59
+ 'You are running unattended in a hosted sandbox. Work autonomously: never ask the user ' +
60
+ 'questions — when uncertain, choose the most reasonable/reversible option and note the ' +
61
+ 'decision. Commit your work to the current branch in logical units as you go (an automatic ' +
62
+ 'checkpoint will also save anything uncommitted at the end). Never switch branches. ' +
63
+ 'Production/default pointers and run metadata live in Orizu, not this repo.'
64
+
65
+ /** Delimiter separating the standing preamble from the verbatim user task. */
66
+ export const HOSTED_TASK_DELIMITER = '--- YOUR TASK ---'
67
+
68
+ /**
69
+ * Compose the full hosted prompt: the standing preamble, a clear delimiter, then
70
+ * the user's task VERBATIM beneath it. Passing an empty preamble yields the task
71
+ * unchanged (an explicit opt-out for tests / callers that scaffold elsewhere).
72
+ */
73
+ export function composeHostedTaskPrompt(task: string, preamble: string = HOSTED_TASK_PREAMBLE): string {
74
+ if (!preamble) return task
75
+ return `${preamble}\n\n${HOSTED_TASK_DELIMITER}\n${task}`
76
+ }
77
+
47
78
  // -- Pre-baked runtime marker (ALI-1017) -------------------------------------
48
79
 
49
80
  /**