headlesscode 1.2.1 → 1.3.0
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 +272 -456
- package/package.json +8 -3
- package/shared/openshell/headlesscode-openrouter.yaml +21 -0
- package/shared/openshell/headlesscode-policy.yaml +21 -0
- package/src/cli.ts +77 -18
- package/src/cloud/openshell-preflight.ts +16 -0
- package/src/cloud/openshell-provider.ts +642 -0
- package/src/cloud/openshell-session.ts +119 -0
- package/src/cloud/openshell-subsession.ts +63 -0
- package/src/cloud/openshell-worker.ts +124 -0
- package/src/engine/events.ts +3 -0
- package/src/engine/loop.ts +143 -0
- package/src/engine/types.ts +42 -0
- package/src/llm/ollama.ts +25 -19
- package/src/monitoring/controller.ts +73 -0
- package/src/monitoring/features.ts +103 -0
- package/src/monitoring/index.ts +4 -0
- package/src/monitoring/predictor.ts +246 -0
- package/src/monitoring/types.ts +192 -0
- package/src/orchestrator/cli.ts +24 -0
- package/src/orchestrator/reviewer.ts +11 -0
- package/src/project-store.ts +14 -0
- package/src/qa/qa.ts +12 -0
- package/src/rsi/adaptive.ts +49 -0
- package/src/rsi/adversarial.ts +106 -0
- package/src/rsi/archive.ts +6 -5
- package/src/rsi/artifact-store.ts +158 -0
- package/src/rsi/config.ts +50 -2
- package/src/rsi/controller.ts +561 -42
- package/src/rsi/curriculum.ts +135 -16
- package/src/rsi/evaluator.ts +6 -37
- package/src/rsi/fitness.ts +39 -4
- package/src/rsi/index.ts +1 -0
- package/src/rsi/migrations/001_postgres_fleet_queue.sql +65 -0
- package/src/rsi/migrations/002_external_artifacts_and_job_leases.sql +39 -0
- package/src/rsi/migrations/003_model_training_jobs.sql +6 -0
- package/src/rsi/model-training.ts +256 -0
- package/src/rsi/mutation.ts +1 -77
- package/src/rsi/openshell.ts +639 -0
- package/src/rsi/postgres-queue.ts +424 -0
- package/src/rsi/promote-curriculum.ts +21 -0
- package/src/rsi/reports.ts +29 -2
- package/src/rsi/roles.ts +13 -3
- package/src/rsi/selection.ts +7 -1
- package/src/rsi/training-data.ts +103 -0
- package/src/rsi/trajectory.ts +1 -1
- package/src/rsi/types.ts +115 -2
- package/src/rsi/worker.ts +264 -0
- package/src/rsi/workspace.ts +14 -3
- package/src/watcher/cli.ts +18 -0
- package/src/watcher/watch.ts +3 -1
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import * as fs from "node:fs"
|
|
4
|
+
import * as os from "node:os"
|
|
5
|
+
import * as path from "node:path"
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises"
|
|
7
|
+
import { fileURLToPath } from "node:url"
|
|
8
|
+
import { DEFAULT_PROTECTED_FILES, findMatchingPattern } from "../permissions/protected-files.js"
|
|
9
|
+
import { OpenShellSessionProvider, OPENSHELL_HARNESS_ROOT } from "../cloud/openshell-provider.js"
|
|
10
|
+
import type { OpenShellSessionProviderOptions } from "../cloud/openshell-provider.js"
|
|
11
|
+
import type { CloudSessionRequest, CommandResult, SessionHandle } from "../cloud/provider.js"
|
|
12
|
+
import type { CandidateRecord, CommandRunner, RsiConfig, TrialResult } from "./types.js"
|
|
13
|
+
import { ADVERSARIAL_RUNNER_PATH, ADVERSARIAL_TESTS_PATH } from "./adversarial.js"
|
|
14
|
+
|
|
15
|
+
const POLICY_PATH = fileURLToPath(new URL("../../shared/openshell/headlesscode-policy.yaml", import.meta.url))
|
|
16
|
+
const MAX_SNAPSHOT_BYTES = 512 * 1024 * 1024
|
|
17
|
+
const MAX_BUNDLE_BYTES = 2 * 1024 * 1024 * 1024
|
|
18
|
+
const IMAGE_DEPENDENCIES_LINK = "node_modules"
|
|
19
|
+
const RSI_OPENSHELL_IMAGE = "headlesscode-openshell-rsi:local"
|
|
20
|
+
const RSI_TRAINING_OPENSHELL_IMAGE = "headlesscode-openshell-rsi-training:local"
|
|
21
|
+
export const RSI_CPU_MODEL_EVALUATION_MAX_CHECKPOINT_BYTES = 2 * 1024 * 1024 * 1024
|
|
22
|
+
const OPENSHELL_INITIAL_CONFIG_SETTLE_MS = 15_000
|
|
23
|
+
|
|
24
|
+
export interface RsiOpenShellProvider {
|
|
25
|
+
spawnExistingWorktreeSession(request: CloudSessionRequest, worktreePath: string): Promise<SessionHandle>
|
|
26
|
+
waitReady(handle: SessionHandle): Promise<void>
|
|
27
|
+
runHarness(handle: SessionHandle, command: string): Promise<CommandResult>
|
|
28
|
+
teardown(handle: SessionHandle): Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type RsiOpenShellProviderFactory = (options: OpenShellSessionProviderOptions) => RsiOpenShellProvider
|
|
32
|
+
|
|
33
|
+
export interface IsolatedRunOptions {
|
|
34
|
+
config: RsiConfig
|
|
35
|
+
sourceRoot: string
|
|
36
|
+
commit: string
|
|
37
|
+
command: string
|
|
38
|
+
timeoutMs: number
|
|
39
|
+
mode: "mutation" | "evaluation" | "training" | "model-evaluation"
|
|
40
|
+
sessionName?: string
|
|
41
|
+
env?: Record<string, string>
|
|
42
|
+
providerFactory?: RsiOpenShellProviderFactory
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function execFileBuffer(command: string, args: string[], options: { cwd?: string; input?: Buffer | string } = {}): Buffer {
|
|
46
|
+
return execFileSync(command, args, {
|
|
47
|
+
cwd: options.cwd,
|
|
48
|
+
input: options.input,
|
|
49
|
+
encoding: "buffer",
|
|
50
|
+
maxBuffer: MAX_BUNDLE_BYTES,
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
}) as Buffer
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pathIsSafe(relativePath: string): boolean {
|
|
56
|
+
return relativePath.length > 0 && !relativePath.includes("\0") && !path.posix.isAbsolute(relativePath) && !relativePath.split("/").some((part) => part === ".." || part === "")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function alwaysOmit(relativePath: string): boolean {
|
|
60
|
+
const normalized = relativePath.replaceAll("\\", "/")
|
|
61
|
+
return normalized.startsWith("__headlesscode_rsi_adversarial__/") || findMatchingPattern(normalized, [...DEFAULT_PROTECTED_FILES, ".headlesscode/", ".worktrees/", "node_modules/", "src/rsi/", "scripts/eval-suite/"]) !== null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function shouldInclude(relativePath: string, mode: IsolatedRunOptions["mode"], config: RsiConfig): boolean {
|
|
65
|
+
if (alwaysOmit(relativePath)) return false
|
|
66
|
+
if (mode === "mutation") {
|
|
67
|
+
// These are supervisor-controlled RSI policy/evaluation inputs. Enforce
|
|
68
|
+
// their exclusion even when a caller supplies an incomplete protectedPaths list.
|
|
69
|
+
const segments = relativePath.split("/")
|
|
70
|
+
if (segments.includes("__tests__") || /(?:^|\/)(?:test|spec)\.[^/]+$/.test(relativePath) || /\.(?:test|spec)\.[^/]+$/.test(relativePath)) return false
|
|
71
|
+
if (findMatchingPattern(relativePath, ["src/rsi/", "tests/", "test/", "scripts/", "fixtures/rsi-curriculum/"]) !== null) return false
|
|
72
|
+
if (findMatchingPattern(relativePath, config.protectedPaths) !== null) return false
|
|
73
|
+
}
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface TreeFile {
|
|
78
|
+
path: string
|
|
79
|
+
mode: "100644" | "100755"
|
|
80
|
+
objectId: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function treeFiles(sourceRoot: string, commit: string, mode: IsolatedRunOptions["mode"], config: RsiConfig): TreeFile[] {
|
|
84
|
+
const raw = execFileBuffer("git", ["ls-tree", "-r", "-z", "--full-tree", commit], { cwd: sourceRoot })
|
|
85
|
+
const files: TreeFile[] = []
|
|
86
|
+
for (const entry of raw.toString("utf8").split("\0")) {
|
|
87
|
+
if (!entry) continue
|
|
88
|
+
const tab = entry.indexOf("\t")
|
|
89
|
+
if (tab < 0) throw new Error("OpenShell snapshot rejected malformed Git tree entry")
|
|
90
|
+
const [fileMode, objectType, objectId] = entry.slice(0, tab).split(" ")
|
|
91
|
+
const relativePath = entry.slice(tab + 1)
|
|
92
|
+
if (!pathIsSafe(relativePath)) throw new Error(`OpenShell snapshot rejected unsafe repository path: ${relativePath}`)
|
|
93
|
+
if (fileMode === "160000") throw new Error(`OpenShell snapshot does not permit submodules: ${relativePath}`)
|
|
94
|
+
if (fileMode === "120000") throw new Error(`OpenShell snapshot does not permit symlink source files: ${relativePath}`)
|
|
95
|
+
if (objectType !== "blob" || (fileMode !== "100644" && fileMode !== "100755")) continue
|
|
96
|
+
if (shouldInclude(relativePath, mode, config)) files.push({ path: relativePath, mode: fileMode, objectId })
|
|
97
|
+
}
|
|
98
|
+
return files
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readBlobBatch(sourceRoot: string, files: TreeFile[]): Map<string, Buffer> {
|
|
102
|
+
const input = Buffer.from(files.map((file) => file.objectId).join("\n") + "\n")
|
|
103
|
+
const output = execFileBuffer("git", ["cat-file", "--batch"], { cwd: sourceRoot, input })
|
|
104
|
+
const blobs = new Map<string, Buffer>()
|
|
105
|
+
let offset = 0
|
|
106
|
+
let total = 0
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const headerEnd = output.indexOf(0x0a, offset)
|
|
109
|
+
if (headerEnd < 0) throw new Error("OpenShell snapshot rejected truncated Git blob response")
|
|
110
|
+
const header = output.subarray(offset, headerEnd).toString("ascii").split(" ")
|
|
111
|
+
const size = Number(header[2])
|
|
112
|
+
if (header[1] !== "blob" || !Number.isSafeInteger(size) || size < 0) throw new Error(`OpenShell snapshot rejected invalid blob metadata for ${file.path}`)
|
|
113
|
+
total += size
|
|
114
|
+
if (total > MAX_SNAPSHOT_BYTES || offset + (headerEnd - offset + 1) + size >= output.length) {
|
|
115
|
+
if (total > MAX_SNAPSHOT_BYTES) throw new Error("OpenShell snapshot exceeds the 512 MiB experiment limit")
|
|
116
|
+
if (offset + (headerEnd - offset + 1) + size > output.length) throw new Error(`OpenShell snapshot rejected truncated blob ${file.path}`)
|
|
117
|
+
}
|
|
118
|
+
const start = headerEnd + 1
|
|
119
|
+
const end = start + size
|
|
120
|
+
if (output[end] !== 0x0a) throw new Error(`OpenShell snapshot rejected malformed blob terminator for ${file.path}`)
|
|
121
|
+
blobs.set(file.path, output.subarray(start, end))
|
|
122
|
+
offset = end + 1
|
|
123
|
+
}
|
|
124
|
+
return blobs
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function initializeSnapshot(sourceRoot: string, commit: string, destination: string, mode: IsolatedRunOptions["mode"], config: RsiConfig, includeHarnessDependencies = true): string {
|
|
128
|
+
const files = treeFiles(sourceRoot, commit, mode, config)
|
|
129
|
+
const blobs = readBlobBatch(sourceRoot, files)
|
|
130
|
+
fs.mkdirSync(destination, { recursive: true, mode: 0o700 })
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const target = path.join(destination, file.path)
|
|
133
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
|
|
134
|
+
fs.writeFileSync(target, blobs.get(file.path)!, { mode: file.mode === "100755" ? 0o755 : 0o644, flag: "wx" })
|
|
135
|
+
}
|
|
136
|
+
if (mode === "evaluation" && includeHarnessDependencies) fs.symlinkSync(`${OPENSHELL_HARNESS_ROOT}/node_modules`, path.join(destination, IMAGE_DEPENDENCIES_LINK))
|
|
137
|
+
execFileSync("git", ["init", "--quiet", "--initial-branch=main"], { cwd: destination })
|
|
138
|
+
execFileSync("git", ["config", "user.name", "HeadlessCode RSI Snapshot"], { cwd: destination })
|
|
139
|
+
execFileSync("git", ["config", "user.email", "rsi-snapshot@invalid.example"], { cwd: destination })
|
|
140
|
+
execFileSync("git", ["add", "--all"], { cwd: destination })
|
|
141
|
+
if (mode === "evaluation" && includeHarnessDependencies) execFileSync("git", ["add", "--force", "--", IMAGE_DEPENDENCIES_LINK], { cwd: destination })
|
|
142
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "commit", "--quiet", "-m", `RSI ${mode} snapshot ${commit}`], { cwd: destination, env: snapshotCommitEnvironment() })
|
|
143
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: destination, encoding: "utf8" }).trim()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function snapshotCommitEnvironment(): NodeJS.ProcessEnv {
|
|
147
|
+
const date = "2000-01-01T00:00:00Z"
|
|
148
|
+
return { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Make an evaluator snapshot containing only fixed, supervisor-generated model-job inputs. */
|
|
152
|
+
export function createModelJobSnapshotBundle(sourceRoot: string, commit: string, config: RsiConfig, inputs: { dataset: Buffer; manifest: Buffer; cells: Buffer; adapterArchive?: Buffer }): { content: Buffer; snapshotCommit: string } {
|
|
153
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "headlesscode-rsi-model-snapshot-"))
|
|
154
|
+
const snapshot = path.join(tempRoot, "repo")
|
|
155
|
+
const bundlePath = path.join(tempRoot, "snapshot.bundle")
|
|
156
|
+
try {
|
|
157
|
+
initializeSnapshot(sourceRoot, commit, snapshot, "model-evaluation", config, false)
|
|
158
|
+
const modelInput = path.join(snapshot, ".headlesscode-rsi-model")
|
|
159
|
+
fs.mkdirSync(modelInput, { recursive: true, mode: 0o700 })
|
|
160
|
+
for (const [name, content] of [["dataset.jsonl", inputs.dataset], ["manifest.json", inputs.manifest], ["evaluation-cells.jsonl", inputs.cells]] as const) {
|
|
161
|
+
fs.writeFileSync(path.join(modelInput, name), content, { mode: 0o600, flag: "wx" })
|
|
162
|
+
}
|
|
163
|
+
if (inputs.adapterArchive) fs.writeFileSync(path.join(modelInput, "adapter.tar"), inputs.adapterArchive, { mode: 0o600, flag: "wx" })
|
|
164
|
+
execFileSync("git", ["add", "--all"], { cwd: snapshot })
|
|
165
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "commit", "--amend", "--quiet", "--no-edit"], { cwd: snapshot, env: snapshotCommitEnvironment() })
|
|
166
|
+
const snapshotCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: snapshot, encoding: "utf8" }).trim()
|
|
167
|
+
if (execFileSync("git", ["rev-list", "--count", "--all"], { cwd: snapshot, encoding: "utf8" }).trim() !== "1") throw new Error("model job snapshot must contain exactly one commit")
|
|
168
|
+
execFileSync("git", ["bundle", "create", bundlePath, "--all"], { cwd: snapshot, stdio: "pipe" })
|
|
169
|
+
execFileSync("git", ["bundle", "verify", bundlePath], { cwd: snapshot, stdio: "pipe" })
|
|
170
|
+
const content = fs.readFileSync(bundlePath)
|
|
171
|
+
if (content.byteLength > MAX_SNAPSHOT_BYTES) throw new Error("model job snapshot exceeds the 512 MiB limit")
|
|
172
|
+
return { content, snapshotCommit }
|
|
173
|
+
} finally {
|
|
174
|
+
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Build a one-commit mutation-only bundle for transfer to a remote worker. */
|
|
179
|
+
export function createMutationSnapshotBundle(sourceRoot: string, commit: string, config: RsiConfig): { content: Buffer; snapshotCommit: string } {
|
|
180
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "headlesscode-rsi-fleet-snapshot-"))
|
|
181
|
+
const snapshot = path.join(tempRoot, "repo")
|
|
182
|
+
const bundlePath = path.join(tempRoot, "snapshot.bundle")
|
|
183
|
+
try {
|
|
184
|
+
const snapshotCommit = initializeSnapshot(sourceRoot, commit, snapshot, "mutation", config)
|
|
185
|
+
const commitCount = execFileSync("git", ["rev-list", "--count", "--all"], { cwd: snapshot, encoding: "utf8" }).trim()
|
|
186
|
+
if (commitCount !== "1") throw new Error("fleet mutation snapshot must contain exactly one sanitized commit")
|
|
187
|
+
execFileSync("git", ["bundle", "create", bundlePath, "--all"], { cwd: snapshot, stdio: "pipe" })
|
|
188
|
+
execFileSync("git", ["bundle", "verify", bundlePath], { cwd: snapshot, stdio: "pipe" })
|
|
189
|
+
const content = fs.readFileSync(bundlePath)
|
|
190
|
+
if (content.byteLength > MAX_SNAPSHOT_BYTES) throw new Error("fleet mutation bundle exceeds the 512 MiB snapshot limit")
|
|
191
|
+
return { content, snapshotCommit }
|
|
192
|
+
} finally {
|
|
193
|
+
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Build a one-commit evaluation snapshot without supervisor scoring inputs. */
|
|
198
|
+
export function createEvaluationSnapshotBundle(sourceRoot: string, commit: string, config: RsiConfig): { content: Buffer; snapshotCommit: string } {
|
|
199
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "headlesscode-rsi-fleet-eval-"))
|
|
200
|
+
const snapshot = path.join(tempRoot, "repo")
|
|
201
|
+
const bundlePath = path.join(tempRoot, "snapshot.bundle")
|
|
202
|
+
try {
|
|
203
|
+
const snapshotCommit = initializeSnapshot(sourceRoot, commit, snapshot, "evaluation", config, false)
|
|
204
|
+
if (execFileSync("git", ["rev-list", "--count", "--all"], { cwd: snapshot, encoding: "utf8" }).trim() !== "1") throw new Error("fleet evaluation snapshot must contain exactly one sanitized commit")
|
|
205
|
+
execFileSync("git", ["bundle", "create", bundlePath, "--all"], { cwd: snapshot, stdio: "pipe" })
|
|
206
|
+
execFileSync("git", ["bundle", "verify", bundlePath], { cwd: snapshot, stdio: "pipe" })
|
|
207
|
+
const content = fs.readFileSync(bundlePath)
|
|
208
|
+
if (content.byteLength > MAX_SNAPSHOT_BYTES) throw new Error("fleet evaluation bundle exceeds the 512 MiB snapshot limit")
|
|
209
|
+
return { content, snapshotCommit }
|
|
210
|
+
} finally {
|
|
211
|
+
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ADVERSARIAL_RUNNER_SOURCE = `import assert from "node:assert/strict"
|
|
216
|
+
import { readFile } from "node:fs/promises"
|
|
217
|
+
import path from "node:path"
|
|
218
|
+
import { pathToFileURL } from "node:url"
|
|
219
|
+
|
|
220
|
+
const root = process.cwd()
|
|
221
|
+
const spec = JSON.parse(await readFile(path.join(root, "${ADVERSARIAL_TESTS_PATH}"), "utf8"))
|
|
222
|
+
if (!Array.isArray(spec.tests) || spec.tests.length < 1 || spec.tests.length > 8) throw new Error("invalid adversarial test manifest")
|
|
223
|
+
for (const test of spec.tests) {
|
|
224
|
+
if (typeof test.modulePath !== "string" || !/^(?:src|lib|app)\\//.test(test.modulePath) || test.modulePath.includes("..") || test.modulePath.includes("\\\\")) throw new Error("unsafe test module path")
|
|
225
|
+
const target = path.resolve(root, test.modulePath)
|
|
226
|
+
if (!target.startsWith(root + path.sep)) throw new Error("test module escaped the candidate snapshot")
|
|
227
|
+
if (typeof test.exportName !== "string" || !/^[A-Za-z_$][A-Za-z0-9_$]{0,79}$/.test(test.exportName) || !Array.isArray(test.args) || test.args.length > 16) throw new Error("invalid adversarial test invocation")
|
|
228
|
+
const module = await import(pathToFileURL(target).href)
|
|
229
|
+
if (typeof module[test.exportName] !== "function") throw new Error("missing candidate export " + test.exportName)
|
|
230
|
+
const actual = await module[test.exportName](...test.args)
|
|
231
|
+
assert.deepStrictEqual(actual, test.expected, "adversarial test " + test.id + " failed: " + test.reason)
|
|
232
|
+
process.stdout.write("PASS " + test.id + "\\n")
|
|
233
|
+
}
|
|
234
|
+
`
|
|
235
|
+
|
|
236
|
+
/** Bundle a candidate commit with a bounded declarative test manifest and fixed runner. */
|
|
237
|
+
export function createAdversarialEvaluationSnapshotBundle(sourceRoot: string, commit: string, config: RsiConfig, tests: Buffer): { content: Buffer; snapshotCommit: string } {
|
|
238
|
+
if (tests.byteLength < 2 || tests.byteLength > 64 * 1024) throw new Error("adversarial test artifact must be between 2 bytes and 64 KiB")
|
|
239
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "headlesscode-rsi-adversarial-snapshot-"))
|
|
240
|
+
const snapshot = path.join(tempRoot, "repo")
|
|
241
|
+
const bundlePath = path.join(tempRoot, "snapshot.bundle")
|
|
242
|
+
try {
|
|
243
|
+
initializeSnapshot(sourceRoot, commit, snapshot, "evaluation", config, false)
|
|
244
|
+
const reserved = path.join(snapshot, "__headlesscode_rsi_adversarial__")
|
|
245
|
+
fs.mkdirSync(reserved, { recursive: true, mode: 0o700 })
|
|
246
|
+
fs.writeFileSync(path.join(snapshot, ADVERSARIAL_TESTS_PATH), tests, { mode: 0o600, flag: "wx" })
|
|
247
|
+
fs.writeFileSync(path.join(snapshot, ADVERSARIAL_RUNNER_PATH), ADVERSARIAL_RUNNER_SOURCE, { mode: 0o600, flag: "wx" })
|
|
248
|
+
execFileSync("git", ["add", "--all"], { cwd: snapshot })
|
|
249
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "commit", "--amend", "--quiet", "--no-edit"], { cwd: snapshot, env: snapshotCommitEnvironment() })
|
|
250
|
+
const snapshotCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: snapshot, encoding: "utf8" }).trim()
|
|
251
|
+
if (execFileSync("git", ["rev-list", "--count", "--all"], { cwd: snapshot, encoding: "utf8" }).trim() !== "1") throw new Error("adversarial evaluation snapshot must contain exactly one commit")
|
|
252
|
+
execFileSync("git", ["bundle", "create", bundlePath, "--all"], { cwd: snapshot, stdio: "pipe" })
|
|
253
|
+
execFileSync("git", ["bundle", "verify", bundlePath], { cwd: snapshot, stdio: "pipe" })
|
|
254
|
+
const content = fs.readFileSync(bundlePath)
|
|
255
|
+
if (content.byteLength > MAX_SNAPSHOT_BYTES) throw new Error("adversarial evaluation bundle exceeds the 512 MiB snapshot limit")
|
|
256
|
+
return { content, snapshotCommit }
|
|
257
|
+
} finally {
|
|
258
|
+
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function shellQuote(value: string): string {
|
|
263
|
+
return `'${value.replaceAll("'", "'\\''")}'`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function workerEnvironment(config: RsiConfig, candidate: CandidateRecord): Record<string, string> {
|
|
267
|
+
const worker = config.roles?.worker
|
|
268
|
+
if (worker && worker.provider !== "ollama") throw new Error("RSI OpenShell currently permits only the local Ollama worker; other providers require an explicit reviewed credential policy")
|
|
269
|
+
const host = process.env.HEADLESSCODE_RSI_OLLAMA_HOST?.trim() || "host.openshell.internal"
|
|
270
|
+
if (host !== "host.openshell.internal") throw new Error("RSI OpenShell permits Ollama access only through host.openshell.internal:11434")
|
|
271
|
+
return {
|
|
272
|
+
HEADLESSCODE_CODE_MODE_BACKEND: "ollama",
|
|
273
|
+
HEADLESSCODE_LOCAL_BACKEND_MODES: "code",
|
|
274
|
+
HEADLESSCODE_CODE_MODE_MODEL: worker?.model ?? candidate.model,
|
|
275
|
+
HEADLESSCODE_OLLAMA_URL: `http://${host}:11434`,
|
|
276
|
+
HEADLESSCODE_OLLAMA_THINK: "0",
|
|
277
|
+
HEADLESSCODE_CAPTURE_TRANSCRIPT_DIR: path.join("/workspace", ".headlesscode", "rsi-transcripts"),
|
|
278
|
+
HEADLESSCODE_MAX_ITERATIONS: String(config.maxIterations),
|
|
279
|
+
HEADLESSCODE_MAX_DURATION_MS: String(config.commandTimeoutMs),
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function networkRules(mode: IsolatedRunOptions["mode"]): Record<string, unknown> {
|
|
284
|
+
if (mode !== "mutation") return {}
|
|
285
|
+
const host = process.env.HEADLESSCODE_RSI_OLLAMA_HOST?.trim() || "host.openshell.internal"
|
|
286
|
+
if (host !== "host.openshell.internal") throw new Error("RSI OpenShell permits Ollama access only through host.openshell.internal:11434")
|
|
287
|
+
return {
|
|
288
|
+
rsi_local_ollama: {
|
|
289
|
+
name: "rsi-local-ollama-only",
|
|
290
|
+
endpoints: [{ host, port: 11434, protocol: "tcp" }],
|
|
291
|
+
binaries: [{ path: "/usr/local/bin/node" }],
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function inspectLocalRsiCheckpoint(configuredBase: string, mode: IsolatedRunOptions["mode"]): { path: string; files: number; bytes: number } {
|
|
297
|
+
const stat = fs.lstatSync(configuredBase)
|
|
298
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("configured RSI base model must be a real directory, not a symlink")
|
|
299
|
+
const source = fs.realpathSync(configuredBase)
|
|
300
|
+
if (!fs.existsSync(path.join(source, "config.json"))) throw new Error("configured RSI base model is not a local Hugging Face checkpoint")
|
|
301
|
+
const pending = [source]
|
|
302
|
+
let files = 0
|
|
303
|
+
let bytes = 0
|
|
304
|
+
while (pending.length) {
|
|
305
|
+
const directory = pending.pop()!
|
|
306
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
307
|
+
const target = path.join(directory, entry.name)
|
|
308
|
+
const entryStat = fs.lstatSync(target)
|
|
309
|
+
if (entryStat.isSymbolicLink() || (!entryStat.isDirectory() && !entryStat.isFile())) throw new Error("RSI base checkpoint contains a symlink or special file")
|
|
310
|
+
if (entryStat.isDirectory()) pending.push(target)
|
|
311
|
+
else {
|
|
312
|
+
files += 1
|
|
313
|
+
bytes += entryStat.size
|
|
314
|
+
if (files > 100_000 || bytes > 64 * 1024 * 1024 * 1024) throw new Error("RSI base checkpoint exceeds the 100,000-file or 64 GiB mount bound")
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (mode === "model-evaluation" && bytes > RSI_CPU_MODEL_EVALUATION_MAX_CHECKPOINT_BYTES) throw new Error("CPU-only paired model evaluation requires a checkpoint no larger than 2 GiB; GPU-backed evaluation is not implemented")
|
|
319
|
+
return { path: source, files, bytes }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function providerOptions(config: RsiConfig, mode: IsolatedRunOptions["mode"], timeoutMs: number): OpenShellSessionProviderOptions {
|
|
323
|
+
const policy = process.env.HEADLESSCODE_OPENSHELL_POLICY ?? POLICY_PATH
|
|
324
|
+
const configuredImage = process.env.HEADLESSCODE_OPENSHELL_IMAGE?.trim()
|
|
325
|
+
const trainingMode = mode === "training" || mode === "model-evaluation"
|
|
326
|
+
const image = trainingMode ? RSI_TRAINING_OPENSHELL_IMAGE : RSI_OPENSHELL_IMAGE
|
|
327
|
+
if (configuredImage && configuredImage !== image) {
|
|
328
|
+
throw new Error(`RSI requires the verified OpenShell image ${image} for ${mode}; custom images are not verified`)
|
|
329
|
+
}
|
|
330
|
+
let readOnlyMounts: Array<{ source: string; target: string }> = []
|
|
331
|
+
if (trainingMode) {
|
|
332
|
+
const configuredBase = process.env.HEADLESSCODE_RSI_BASE_MODEL_PATH?.trim()
|
|
333
|
+
if (!configuredBase) throw new Error("model training/evaluation requires HEADLESSCODE_RSI_BASE_MODEL_PATH on the worker host")
|
|
334
|
+
const checkpoint = inspectLocalRsiCheckpoint(configuredBase, mode)
|
|
335
|
+
readOnlyMounts = [{ source: checkpoint.path, target: "/workspace/.rsi-base-model" }]
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
image,
|
|
339
|
+
policy,
|
|
340
|
+
providers: [],
|
|
341
|
+
autoProviders: false,
|
|
342
|
+
importResults: true,
|
|
343
|
+
includeProjectData: false,
|
|
344
|
+
projectIdentityRoot: "/workspace",
|
|
345
|
+
commandTimeoutMs: timeoutMs,
|
|
346
|
+
networkPolicy: networkRules(mode),
|
|
347
|
+
...(trainingMode ? { readOnlyMounts } : {}),
|
|
348
|
+
...(mode === "training" ? { gpu: 1 } : {}),
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function makeRequest(repoRoot: string, name: string, env: Record<string, string>): CloudSessionRequest {
|
|
353
|
+
return {
|
|
354
|
+
repo: repoRoot,
|
|
355
|
+
issue: { number: 0, title: `RSI isolated ${name}` },
|
|
356
|
+
worktreeSpec: { name, issues: [], taskFile: "RSI_TASK.md" },
|
|
357
|
+
env,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function snapshotChanges(snapshot: string, baseCommit: string): Array<{ path: string; mode: string; status: string }> {
|
|
362
|
+
const raw = execFileBuffer("git", ["diff", "--raw", "-z", "--no-renames", `${baseCommit}...HEAD`], { cwd: snapshot })
|
|
363
|
+
const records = raw.toString("utf8").split("\0")
|
|
364
|
+
const changes: Array<{ path: string; mode: string; status: string }> = []
|
|
365
|
+
for (let index = 0; index < records.length - 1; index += 2) {
|
|
366
|
+
const metadata = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])$/.exec(records[index])
|
|
367
|
+
const file = records[index + 1]
|
|
368
|
+
if (!metadata || !file) throw new Error("OpenShell mutation artifact contains malformed Git diff metadata")
|
|
369
|
+
changes.push({ path: file, mode: metadata[2], status: metadata[3] })
|
|
370
|
+
}
|
|
371
|
+
return changes
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function applyCandidatePatch(snapshot: string, snapshotBase: string, candidate: CandidateRecord, config: RsiConfig): string[] {
|
|
375
|
+
const changes = snapshotChanges(snapshot, snapshotBase)
|
|
376
|
+
const files = changes.map((change) => change.path)
|
|
377
|
+
if (files.some((file) => !pathIsSafe(file))) throw new Error("OpenShell mutation artifact contains an unsafe path")
|
|
378
|
+
const invalidModes = changes.filter((change) => change.status !== "D" && change.mode !== "100644" && change.mode !== "100755")
|
|
379
|
+
if (invalidModes.length > 0) throw new Error(`OpenShell mutation artifact contains symlinks, submodules, or special files: ${invalidModes.map((change) => change.path).join(", ")}`)
|
|
380
|
+
const forbiddenMetadata = new Set([".gitattributes", ".gitmodules", ".gitignore"])
|
|
381
|
+
const violations = files.filter((file) => findMatchingPattern(file, config.protectedPaths) !== null || alwaysOmit(file) || file === "node_modules" || forbiddenMetadata.has(file))
|
|
382
|
+
if (violations.length > 0) throw new Error(`OpenShell mutation artifact changed protected paths: ${violations.join(", ")}`)
|
|
383
|
+
const commits = execFileSync("git", ["rev-list", "--count", `${snapshotBase}..HEAD`], { cwd: snapshot, encoding: "utf8" }).trim()
|
|
384
|
+
if (Number(commits) === 0 || files.length === 0) throw new Error("OpenShell mutation produced no committed source changes")
|
|
385
|
+
const patch = execFileBuffer("git", ["diff", "--binary", "--no-ext-diff", "--no-renames", `${snapshotBase}...HEAD`], { cwd: snapshot })
|
|
386
|
+
const tempPatch = path.join(path.dirname(candidate.worktree), `.rsi-${candidate.id}-${randomUUID()}.patch`)
|
|
387
|
+
try {
|
|
388
|
+
fs.writeFileSync(tempPatch, patch, { mode: 0o600, flag: "wx" })
|
|
389
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "apply", "--check", "--index", tempPatch], { cwd: candidate.worktree, stdio: "pipe" })
|
|
390
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "apply", "--index", tempPatch], { cwd: candidate.worktree, stdio: "pipe" })
|
|
391
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "commit", "--quiet", "-m", `RSI candidate ${candidate.id}`], { cwd: candidate.worktree, stdio: "pipe" })
|
|
392
|
+
} finally {
|
|
393
|
+
fs.rmSync(tempPatch, { force: true })
|
|
394
|
+
}
|
|
395
|
+
return files
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Validate and import an untrusted worker patch into the supervisor candidate worktree. */
|
|
399
|
+
export function applyFleetMutationPatch(patch: Buffer, candidate: CandidateRecord, config: RsiConfig): string[] {
|
|
400
|
+
if (patch.byteLength === 0 || patch.byteLength > MAX_SNAPSHOT_BYTES) throw new Error("fleet worker returned an empty or oversized patch")
|
|
401
|
+
const tempPatch = path.join(path.dirname(candidate.worktree), `.rsi-fleet-${candidate.id}-${randomUUID()}.patch`)
|
|
402
|
+
try {
|
|
403
|
+
fs.writeFileSync(tempPatch, patch, { mode: 0o600, flag: "wx" })
|
|
404
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "apply", "--check", "--index", tempPatch], { cwd: candidate.worktree, stdio: "pipe" })
|
|
405
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "apply", "--index", tempPatch], { cwd: candidate.worktree, stdio: "pipe" })
|
|
406
|
+
// The patch is applied with --index, before the supervisor creates its
|
|
407
|
+
// commit. Compare the staged index directly; a base...HEAD diff is empty
|
|
408
|
+
// at this point and would reject every valid worker patch.
|
|
409
|
+
const changes = stagedSnapshotChanges(candidate.worktree)
|
|
410
|
+
const files = changes.map((change) => change.path)
|
|
411
|
+
if (files.length === 0 || files.some((file) => !pathIsSafe(file))) throw new Error("fleet worker patch produced no files or an unsafe path")
|
|
412
|
+
const invalidModes = changes.filter((change) => change.status !== "D" && change.mode !== "100644" && change.mode !== "100755")
|
|
413
|
+
if (invalidModes.length > 0) throw new Error(`fleet worker patch contains symlinks or special files: ${invalidModes.map((change) => change.path).join(", ")}`)
|
|
414
|
+
const forbiddenMetadata = new Set([".gitattributes", ".gitmodules", ".gitignore"])
|
|
415
|
+
const violations = files.filter((file) => findMatchingPattern(file, config.protectedPaths) !== null || alwaysOmit(file) || file === "node_modules" || forbiddenMetadata.has(file))
|
|
416
|
+
if (violations.length > 0) throw new Error(`fleet worker patch changed protected paths: ${violations.join(", ")}`)
|
|
417
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "commit", "--quiet", "-m", `RSI candidate ${candidate.id}`], { cwd: candidate.worktree, stdio: "pipe" })
|
|
418
|
+
return files
|
|
419
|
+
} catch (error) {
|
|
420
|
+
execFileSync("git", ["reset", "--hard", "HEAD"], { cwd: candidate.worktree, stdio: "ignore" })
|
|
421
|
+
throw error
|
|
422
|
+
} finally {
|
|
423
|
+
fs.rmSync(tempPatch, { force: true })
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function stagedSnapshotChanges(snapshot: string): Array<{ path: string; mode: string; status: string }> {
|
|
428
|
+
const raw = execFileBuffer("git", ["diff", "--cached", "--raw", "-z", "--no-renames", "HEAD"], { cwd: snapshot })
|
|
429
|
+
const records = raw.toString("utf8").split("\0")
|
|
430
|
+
const changes: Array<{ path: string; mode: string; status: string }> = []
|
|
431
|
+
for (let index = 0; index < records.length - 1; index += 2) {
|
|
432
|
+
const metadata = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])$/.exec(records[index])
|
|
433
|
+
const file = records[index + 1]
|
|
434
|
+
if (!metadata || !file) throw new Error("fleet worker patch produced malformed staged Git diff metadata")
|
|
435
|
+
changes.push({ path: file, mode: metadata[2], status: metadata[3] })
|
|
436
|
+
}
|
|
437
|
+
return changes
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function runSnapshot(options: IsolatedRunOptions & { preserveSnapshot?: boolean }): Promise<{ result: TrialResult; snapshot: string; snapshotBase: string }> {
|
|
441
|
+
if (options.config.hiddenEvalCommands.length > 0) {
|
|
442
|
+
throw new Error("RSI hidden evaluations are disabled until an evaluator service can keep test inputs outside candidate execution")
|
|
443
|
+
}
|
|
444
|
+
if (options.mode === "evaluation" && /(?:scripts\/eval-suite|\.headlesscode)/i.test(options.command)) {
|
|
445
|
+
throw new Error("RSI evaluation command refers to supervisor-only evaluation material")
|
|
446
|
+
}
|
|
447
|
+
const name = options.sessionName ?? `rsi-${options.mode}-${randomUUID().slice(0, 12)}`
|
|
448
|
+
const snapshot = path.join(options.config.repoRoot, ".worktrees", name)
|
|
449
|
+
fs.mkdirSync(path.dirname(snapshot), { recursive: true })
|
|
450
|
+
const snapshotBase = initializeSnapshot(options.sourceRoot, options.commit, snapshot, options.mode, options.config)
|
|
451
|
+
const factory = options.providerFactory ?? ((providerConfig) => new OpenShellSessionProvider(providerConfig))
|
|
452
|
+
const provider = factory(providerOptions(options.config, options.mode, options.timeoutMs))
|
|
453
|
+
const request = makeRequest(options.config.repoRoot, name, options.env ?? {})
|
|
454
|
+
let handle: SessionHandle | undefined
|
|
455
|
+
const started = Date.now()
|
|
456
|
+
try {
|
|
457
|
+
const startedHandle = await provider.spawnExistingWorktreeSession(request, snapshot)
|
|
458
|
+
handle = startedHandle
|
|
459
|
+
await provider.waitReady(startedHandle)
|
|
460
|
+
if (options.mode === "mutation") {
|
|
461
|
+
// OpenShell 0.1.2 polls provider environment revisions about every
|
|
462
|
+
// 10 seconds. Its first empty-environment sync advances the policy
|
|
463
|
+
// generation and closes older in-flight tunnels, even with no attached
|
|
464
|
+
// providers. Let that initial sync settle before local inference starts.
|
|
465
|
+
const remaining = OPENSHELL_INITIAL_CONFIG_SETTLE_MS - (Date.now() - started)
|
|
466
|
+
if (remaining > 0) await delay(remaining)
|
|
467
|
+
}
|
|
468
|
+
const command = `cd /workspace && ${options.command}`
|
|
469
|
+
const result = await provider.runHarness(startedHandle, command)
|
|
470
|
+
return {
|
|
471
|
+
result: {
|
|
472
|
+
ok: result.exitCode === 0,
|
|
473
|
+
command: options.command,
|
|
474
|
+
exitCode: result.exitCode,
|
|
475
|
+
durationMs: Date.now() - started,
|
|
476
|
+
stdout: result.output,
|
|
477
|
+
stderr: "",
|
|
478
|
+
},
|
|
479
|
+
snapshot,
|
|
480
|
+
snapshotBase,
|
|
481
|
+
}
|
|
482
|
+
} finally {
|
|
483
|
+
try {
|
|
484
|
+
if (handle) await provider.teardown(handle)
|
|
485
|
+
} finally {
|
|
486
|
+
if (!options.preserveSnapshot && options.mode !== "mutation" && !fs.existsSync(path.join(snapshot, ".harness.openshell-sandbox"))) {
|
|
487
|
+
fs.rmSync(snapshot, { recursive: true, force: true })
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Run the fixed QLoRA or matched-model evaluator command inside OpenShell. */
|
|
494
|
+
export async function runOpenShellModelJob(candidate: CandidateRecord, config: RsiConfig, task: NonNullable<import("./postgres-queue.js").FleetJobPayload["modelTask"]>, providerFactory?: RsiOpenShellProviderFactory): Promise<{ result: Record<string, unknown>; adapterArchive?: Buffer }> {
|
|
495
|
+
if (task.seed !== 1337 || task.baseModelPath !== "/workspace/.rsi-base-model" || !/^[A-Za-z0-9._:@/-]{1,180}$/.test(task.baseModelId)) throw new Error("model job does not match the fixed dataset seed, base-model mount, or model identity")
|
|
496
|
+
const training = task.kind === "qlora-train"
|
|
497
|
+
if (training && (task.maxSteps !== 8 || task.adapterPath || task.modelKind)) throw new Error("training job differs from the bounded QLoRA policy")
|
|
498
|
+
if (!training && (!task.modelKind || !task.cellIds.length || (task.modelKind === "adapter" && !task.adapterPath) || (task.modelKind === "base" && task.adapterPath))) throw new Error("model evaluation job is missing or mismatches its fixed cell/model identity")
|
|
499
|
+
const output = "/workspace/.headlesscode-rsi-model-output"
|
|
500
|
+
const command = training
|
|
501
|
+
? `python3 /opt/headlesscode-rsi-training/train_qlora.py --base-model /workspace/.rsi-base-model --base-model-id '${task.baseModelId}' --dataset /workspace/.headlesscode-rsi-model/dataset.jsonl --manifest /workspace/.headlesscode-rsi-model/manifest.json --output /workspace/.headlesscode-rsi-model-output`
|
|
502
|
+
: `python3 /opt/headlesscode-rsi-training/evaluate_adapter.py --base-model /workspace/.rsi-base-model --base-model-id '${task.baseModelId}' --cells /workspace/.headlesscode-rsi-model/evaluation-cells.jsonl --output ${output}/evaluation-result.json${task.modelKind === "adapter" ? " --adapter-archive /workspace/.headlesscode-rsi-model/adapter.tar" : ""}`
|
|
503
|
+
const isolated = await runSnapshot({
|
|
504
|
+
config,
|
|
505
|
+
sourceRoot: candidate.worktree,
|
|
506
|
+
commit: execFileSync("git", ["rev-parse", "HEAD"], { cwd: candidate.worktree, encoding: "utf8" }).trim(),
|
|
507
|
+
command,
|
|
508
|
+
timeoutMs: config.commandTimeoutMs,
|
|
509
|
+
mode: training ? "training" : "model-evaluation",
|
|
510
|
+
sessionName: `rsi-model-${candidate.id}-${randomUUID().slice(0, 8)}`,
|
|
511
|
+
providerFactory,
|
|
512
|
+
preserveSnapshot: true,
|
|
513
|
+
})
|
|
514
|
+
try {
|
|
515
|
+
const resultName = training ? "training-result.json" : "evaluation-result.json"
|
|
516
|
+
const resultPath = path.join(isolated.snapshot, ".headlesscode-rsi-model-output", resultName)
|
|
517
|
+
if (!fs.existsSync(resultPath)) throw new Error(`OpenShell model job omitted ${resultName}; exit=${isolated.result.exitCode}`)
|
|
518
|
+
const result = JSON.parse(fs.readFileSync(resultPath, "utf8")) as Record<string, unknown>
|
|
519
|
+
if (result.schemaVersion !== 1 || !["completed", "failed", "partial"].includes(String(result.status))) throw new Error("OpenShell model job returned an invalid result record")
|
|
520
|
+
if (training && result.status === "completed") {
|
|
521
|
+
const archive = execFileBuffer("git", ["archive", "--format=tar", "HEAD", ".headlesscode-rsi-model-output/adapter"], { cwd: isolated.snapshot })
|
|
522
|
+
if (archive.byteLength < 1) throw new Error("completed QLoRA job produced no adapter archive")
|
|
523
|
+
return { result, adapterArchive: archive }
|
|
524
|
+
}
|
|
525
|
+
return { result }
|
|
526
|
+
} finally {
|
|
527
|
+
if (!fs.existsSync(path.join(isolated.snapshot, ".harness.openshell-sandbox"))) fs.rmSync(isolated.snapshot, { recursive: true, force: true })
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export async function runOpenShellMutation(candidate: CandidateRecord, config: RsiConfig, providerFactory?: RsiOpenShellProviderFactory): Promise<{ ok: boolean; result?: TrialResult; error?: string }> {
|
|
532
|
+
try {
|
|
533
|
+
const workerCommand = [
|
|
534
|
+
"/opt/headlesscode/node_modules/.bin/tsx",
|
|
535
|
+
"/opt/headlesscode/src/cli.ts",
|
|
536
|
+
"--mode",
|
|
537
|
+
"code",
|
|
538
|
+
"--max-iterations",
|
|
539
|
+
String(config.maxIterations),
|
|
540
|
+
"--no-checkpoints",
|
|
541
|
+
"--task",
|
|
542
|
+
mutationTask(candidate, config),
|
|
543
|
+
].map(shellQuote).join(" ")
|
|
544
|
+
const boundaryGuard = [
|
|
545
|
+
'test "$(git -C /workspace rev-list --count HEAD)" = 1',
|
|
546
|
+
"test ! -e /workspace/src/rsi",
|
|
547
|
+
"test ! -e /workspace/scripts",
|
|
548
|
+
"test ! -e /workspace/tests",
|
|
549
|
+
"test ! -e /workspace/test",
|
|
550
|
+
"test ! -e /workspace/.env",
|
|
551
|
+
"test ! -e /workspace/.headlesscode",
|
|
552
|
+
"test ! -e /opt/headlesscode/src/rsi",
|
|
553
|
+
"test ! -e /opt/headlesscode/scripts/eval-suite",
|
|
554
|
+
"test -z \"$(find /workspace /opt/headlesscode/src -type f \\( -path '*/__tests__/*' -o -name '*.test.*' -o -name '*.spec.*' \\) -print -quit)\"",
|
|
555
|
+
'test -z "${HEADLESSCODE_OPENROUTER_API_KEY:-}${OPENROUTER_API_KEY:-}${OPENAI_API_KEY:-}${ANTHROPIC_API_KEY:-}"',
|
|
556
|
+
`exec ${workerCommand}`,
|
|
557
|
+
].join(" && ")
|
|
558
|
+
const env = workerEnvironment(config, candidate)
|
|
559
|
+
const isolation = await runSnapshot({
|
|
560
|
+
config,
|
|
561
|
+
sourceRoot: candidate.worktree,
|
|
562
|
+
commit: candidate.baseCommit,
|
|
563
|
+
command: boundaryGuard,
|
|
564
|
+
timeoutMs: config.commandTimeoutMs,
|
|
565
|
+
mode: "mutation",
|
|
566
|
+
sessionName: `rsi-mutation-${candidate.id}-${randomUUID().slice(0, 6)}`,
|
|
567
|
+
env,
|
|
568
|
+
providerFactory,
|
|
569
|
+
})
|
|
570
|
+
if (!isolation.result.ok) {
|
|
571
|
+
const detail = isolation.result.stderr || isolation.result.stdout
|
|
572
|
+
return { ok: false, result: isolation.result, error: `OpenShell mutation worker failed (exit ${isolation.result.exitCode ?? "unknown"})${detail ? `: ${detail.slice(0, 3000)}` : ""}` }
|
|
573
|
+
}
|
|
574
|
+
const changedFiles = applyCandidatePatch(isolation.snapshot, isolation.snapshotBase, candidate, config)
|
|
575
|
+
if (isolation.result.ok && changedFiles.length === 0) return { ok: false, result: isolation.result, error: "OpenShell worker returned success without a candidate change" }
|
|
576
|
+
return { ok: true, result: isolation.result }
|
|
577
|
+
} catch (error) {
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
result: { ok: false, command: "openshell-rsi-mutation", exitCode: null, durationMs: 0, stdout: "", stderr: error instanceof Error ? error.message : String(error) },
|
|
581
|
+
error: error instanceof Error ? error.message : String(error),
|
|
582
|
+
}
|
|
583
|
+
} finally {
|
|
584
|
+
const snapshotPrefix = `rsi-mutation-${candidate.id}`
|
|
585
|
+
try {
|
|
586
|
+
for (const name of fs.readdirSync(path.join(config.repoRoot, ".worktrees"))) {
|
|
587
|
+
if (name.startsWith(snapshotPrefix)) {
|
|
588
|
+
const snapshot = path.join(config.repoRoot, ".worktrees", name)
|
|
589
|
+
if (!fs.existsSync(path.join(snapshot, ".harness.openshell-sandbox"))) fs.rmSync(snapshot, { recursive: true, force: true })
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
} catch {
|
|
593
|
+
// Keep a failed cleanup visible for operator recovery.
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function mutationTask(candidate: CandidateRecord, config: RsiConfig): string {
|
|
599
|
+
const hypothesis = candidate.hypothesis
|
|
600
|
+
return [
|
|
601
|
+
"You are a bounded RSI worker inside an OpenShell sandbox.",
|
|
602
|
+
`Improve the isolated candidate snapshot for generation ${candidate.generation}.`,
|
|
603
|
+
`Mutation class: ${candidate.mutationKind ?? "corrective"}. Compute policy: ${config.computePolicy ?? "single"}.`,
|
|
604
|
+
`Objective: ${config.mutationTask}`,
|
|
605
|
+
hypothesis ? `Hypothesis: ${hypothesis.statement}\nExpected effect: ${hypothesis.expectedEffect}\nPotential downside: ${hypothesis.potentialDownside}${hypothesis.evidence ? `\nEvidence: ${hypothesis.evidence}` : ""}` : "",
|
|
606
|
+
"The workspace contains only candidate source files. Supervisor evaluation scripts, scoring state, project history, and operator data are not mounted.",
|
|
607
|
+
"Make a focused source change and commit it. Do not run tests; the supervisor will evaluate the sealed artifact in a separate OpenShell session.",
|
|
608
|
+
].filter(Boolean).join("\n\n")
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function createOpenShellCommandRunner(config: RsiConfig): CommandRunner {
|
|
612
|
+
return async (command, cwd, timeoutMs, env) => {
|
|
613
|
+
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim()
|
|
614
|
+
const safeEnv: Record<string, string> = {}
|
|
615
|
+
if (env?.HEADLESSCODE_RSI_CANDIDATE_ROOT) safeEnv.HEADLESSCODE_RSI_CANDIDATE_ROOT = "/workspace"
|
|
616
|
+
try {
|
|
617
|
+
const isolated = await runSnapshot({ config, sourceRoot: cwd, commit, command, timeoutMs, mode: "evaluation", env: safeEnv })
|
|
618
|
+
return isolated.result
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return { ok: false, command, exitCode: null, durationMs: 0, stdout: "", stderr: error instanceof Error ? error.message : String(error) }
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Execute only visible candidate checks inside a fresh OpenShell guest. */
|
|
626
|
+
export async function runOpenShellEvaluation(candidate: CandidateRecord, config: RsiConfig): Promise<{ regression: TrialResult; visible: TrialResult[] }> {
|
|
627
|
+
if (config.hiddenEvalCommands.length > 0) throw new Error("hidden RSI evaluation is supervisor-only and cannot be submitted as a worker job")
|
|
628
|
+
const commands = [config.regressionCommand, ...config.evalCommands]
|
|
629
|
+
const results: TrialResult[] = []
|
|
630
|
+
for (const command of commands) {
|
|
631
|
+
const isolated = await runSnapshot({ config, sourceRoot: candidate.worktree, commit: execFileSync("git", ["rev-parse", "HEAD"], { cwd: candidate.worktree, encoding: "utf8" }).trim(), command, timeoutMs: config.commandTimeoutMs, mode: "evaluation" })
|
|
632
|
+
results.push(isolated.result)
|
|
633
|
+
}
|
|
634
|
+
return { regression: results[0], visible: results.slice(1) }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function openShellMutationRunner(): typeof runOpenShellMutation {
|
|
638
|
+
return runOpenShellMutation
|
|
639
|
+
}
|