orizu 0.5.7 → 0.5.8
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/README.md +13 -2
- package/dist/credentials.js +44 -0
- package/dist/help.js +77 -7
- package/dist/hosted-bootstrap.js +39 -0
- package/dist/hosted-harness-opencode.js +21 -1
- package/dist/hosted-harness.js +9 -0
- package/dist/hosted-harvest.js +116 -0
- package/dist/hosted-headless.js +163 -0
- package/dist/hosted-loop.js +119 -5
- package/dist/hosted-run-event-sink.js +21 -0
- package/dist/hosted-runtime-assets.js +27 -0
- package/dist/hosted-session-cli.js +51 -9
- package/dist/http.js +16 -1
- package/dist/index.js +54 -86
- package/dist/manifests-cli.js +26 -4
- package/dist/report-comments-cli.js +325 -0
- package/dist/skill-installer.js +16 -1
- package/package.json +1 -1
- package/src/credentials.ts +46 -0
- package/src/help.ts +77 -7
- package/src/hosted-bootstrap.ts +46 -0
- package/src/hosted-harness-opencode.ts +24 -2
- package/src/hosted-harness.ts +9 -0
- package/src/hosted-harvest.ts +159 -0
- package/src/hosted-headless.ts +178 -0
- package/src/hosted-loop.ts +133 -4
- package/src/hosted-run-event-sink.ts +28 -0
- package/src/hosted-runtime-assets.ts +31 -0
- package/src/hosted-session-cli.ts +50 -9
- package/src/http.ts +23 -1
- package/src/index.ts +74 -156
- package/src/manifests-cli.ts +28 -4
- package/src/report-comments-cli.ts +524 -0
- package/src/skill-installer.ts +18 -1
- package/vendor/skills/orizu-cli/SKILL.md +9 -2
- package/vendor/skills/orizu-cli/references/prompt-control-plane.md +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
const status = exec(['status', '--porcelain'])
|
|
108
|
+
if (status.exitCode !== 0) {
|
|
109
|
+
return { kind: 'work_persist_failed', error: `git status failed: ${detail(status)}` }
|
|
110
|
+
}
|
|
111
|
+
const dirty = status.stdout.trim().length > 0
|
|
112
|
+
|
|
113
|
+
if (dirty) {
|
|
114
|
+
const add = exec(['add', '-A'])
|
|
115
|
+
if (add.exitCode !== 0) {
|
|
116
|
+
return { kind: 'work_persist_failed', error: `git add failed: ${detail(add)}` }
|
|
117
|
+
}
|
|
118
|
+
const commit = exec([
|
|
119
|
+
'-c',
|
|
120
|
+
`user.name=${opts.author.name}`,
|
|
121
|
+
'-c',
|
|
122
|
+
`user.email=${opts.author.email}`,
|
|
123
|
+
'commit',
|
|
124
|
+
'--author',
|
|
125
|
+
`${opts.author.name} <${opts.author.email}>`,
|
|
126
|
+
'-m',
|
|
127
|
+
`checkpoint: run ${opts.runId} auto-harvest`,
|
|
128
|
+
])
|
|
129
|
+
if (commit.exitCode !== 0) {
|
|
130
|
+
return { kind: 'work_persist_failed', error: `git commit failed: ${detail(commit)}` }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Nothing to push when the tree was clean AND there are no local-only commits.
|
|
135
|
+
const unpushed = countUnpushed(exec)
|
|
136
|
+
if (!dirty && unpushed <= 0) {
|
|
137
|
+
return { kind: 'work_none' }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const headRes = exec(['rev-parse', 'HEAD'])
|
|
141
|
+
const sha = headRes.exitCode === 0 ? headRes.stdout.trim() : ''
|
|
142
|
+
|
|
143
|
+
// Files: the checkpoint commit's tree when we just committed, else everything
|
|
144
|
+
// spanned by the unpushed commits.
|
|
145
|
+
const filesRes = dirty
|
|
146
|
+
? exec(['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'])
|
|
147
|
+
: exec(['diff', '--name-only', '@{u}..HEAD'])
|
|
148
|
+
const files = filesRes.exitCode === 0 ? splitLines(filesRes.stdout) : []
|
|
149
|
+
|
|
150
|
+
const push = exec(['push'])
|
|
151
|
+
if (push.exitCode !== 0) {
|
|
152
|
+
return { kind: 'work_persist_failed', error: `git push failed: ${detail(push)}` }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { kind: 'work_persisted', sha, files }
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { kind: 'work_persist_failed', error: error instanceof Error ? error.message : String(error) }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -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
|
+
}
|
package/src/hosted-loop.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
/**
|