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
package/src/rsi/types.ts
CHANGED
|
@@ -3,7 +3,21 @@ import type { ChildProcess } from "node:child_process"
|
|
|
3
3
|
export type CandidateStatus = "planned" | "mutating" | "evaluating" | "accepted" | "rejected" | "failed"
|
|
4
4
|
export type MutationKind = "corrective" | "architectural" | "search-policy" | "curriculum" | "model-adaptation"
|
|
5
5
|
export type ParentSelectionPolicy = "champion-specialist-novelty" | "pareto-front" | "all-eligible"
|
|
6
|
-
export type ComputePolicy = "single" | "independent" | "planner-executors" | "critic-retry"
|
|
6
|
+
export type ComputePolicy = "single" | "independent" | "adaptive-independent" | "planner-executors" | "critic-retry"
|
|
7
|
+
|
|
8
|
+
export interface AdaptiveSearchDecision {
|
|
9
|
+
generation: number
|
|
10
|
+
decision: "continue" | "stop"
|
|
11
|
+
reason: "mixed-evidence" | "consistent-evidence" | "trajectory-cap" | "iteration-cap" | "runtime-cap" | "generation-cap" | "insufficient-results"
|
|
12
|
+
candidateIds: string[]
|
|
13
|
+
evidence: Array<{ candidateId: string; status: CandidateStatus; visiblePassRate?: number }>
|
|
14
|
+
allocatedTrajectories: number
|
|
15
|
+
remainingTrajectories: number
|
|
16
|
+
allocatedIterations: number
|
|
17
|
+
remainingIterations: number
|
|
18
|
+
elapsedMs: number
|
|
19
|
+
decidedAt: string
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
export interface MutationHypothesis {
|
|
9
23
|
statement: string
|
|
@@ -73,8 +87,30 @@ export interface ModelCandidate {
|
|
|
73
87
|
trainingConfig: TrainingConfig
|
|
74
88
|
artifactPath?: string
|
|
75
89
|
artifactHash?: string
|
|
76
|
-
status: "base" | "prepared" | "trained" | "evaluated" | "accepted" | "rejected"
|
|
90
|
+
status: "base" | "prepared" | "trained" | "evaluated" | "accepted" | "rejected" | "failed" | "partial"
|
|
77
91
|
createdAt: string
|
|
92
|
+
eligibleForSelection?: boolean
|
|
93
|
+
trainingResult?: {
|
|
94
|
+
status: "completed" | "failed" | "partial"
|
|
95
|
+
datasetVersion: string
|
|
96
|
+
datasetSha256: string
|
|
97
|
+
baseModelSha256: string
|
|
98
|
+
artifactSha256?: string
|
|
99
|
+
artifactBytes?: number
|
|
100
|
+
stepsCompleted: number
|
|
101
|
+
maxSteps: number
|
|
102
|
+
error?: string
|
|
103
|
+
}
|
|
104
|
+
pairedEvaluation?: {
|
|
105
|
+
cellSetSha256: string
|
|
106
|
+
baseResultSha256: string
|
|
107
|
+
adapterResultSha256?: string
|
|
108
|
+
baseMetric: number
|
|
109
|
+
adapterMetric?: number
|
|
110
|
+
completedCellCount: number
|
|
111
|
+
expectedCellCount: number
|
|
112
|
+
seed: number
|
|
113
|
+
}
|
|
78
114
|
provenance?: Record<string, string>
|
|
79
115
|
}
|
|
80
116
|
|
|
@@ -151,8 +187,19 @@ export interface CurriculumTask {
|
|
|
151
187
|
difficulty: 1 | 2 | 3 | 4 | 5
|
|
152
188
|
capability: string
|
|
153
189
|
groundTruthCommand: string
|
|
190
|
+
fixtureId: string
|
|
191
|
+
fixtureDescription: string
|
|
154
192
|
provenance: { sourceCandidateIds: string[]; failureClass: string; generatedAt: string }
|
|
155
193
|
validated: boolean
|
|
194
|
+
validation?: {
|
|
195
|
+
baseCommit: string
|
|
196
|
+
command: string
|
|
197
|
+
validatedAt: string
|
|
198
|
+
replays: Array<{ idempotencyKey: string; ok: boolean; exitCode: number | null; outputSha256?: string; failure?: string }>
|
|
199
|
+
reproducible: boolean
|
|
200
|
+
reason: string
|
|
201
|
+
}
|
|
202
|
+
promotedAt?: string
|
|
156
203
|
}
|
|
157
204
|
|
|
158
205
|
export interface RsiConfig {
|
|
@@ -162,6 +209,7 @@ export interface RsiConfig {
|
|
|
162
209
|
generations: number
|
|
163
210
|
maxConcurrent: number
|
|
164
211
|
mutationTask: string
|
|
212
|
+
regressionCommand: string
|
|
165
213
|
evalCommands: string[]
|
|
166
214
|
hiddenEvalCommands: string[]
|
|
167
215
|
archiveDir: string
|
|
@@ -171,6 +219,9 @@ export interface RsiConfig {
|
|
|
171
219
|
dryRun: boolean
|
|
172
220
|
keepWorktrees: boolean
|
|
173
221
|
maxIterations: number
|
|
222
|
+
maxTrajectories?: number
|
|
223
|
+
maxTotalIterations?: number
|
|
224
|
+
maxRuntimeMs?: number
|
|
174
225
|
protectedPaths: string[]
|
|
175
226
|
commandTimeoutMs: number
|
|
176
227
|
parentSelectionPolicy?: ParentSelectionPolicy
|
|
@@ -212,9 +263,21 @@ export interface CandidateRecord {
|
|
|
212
263
|
failure?: string
|
|
213
264
|
result?: TrialResult
|
|
214
265
|
fitness?: Fitness
|
|
266
|
+
pairedComparison?: PairedComparison
|
|
215
267
|
trajectory?: TrajectorySummary
|
|
216
268
|
}
|
|
217
269
|
|
|
270
|
+
export interface PairedComparison {
|
|
271
|
+
baselinePasses: number
|
|
272
|
+
candidatePasses: number
|
|
273
|
+
trialCount: number
|
|
274
|
+
baselineDurationMs: number
|
|
275
|
+
candidateDurationMs: number
|
|
276
|
+
durationRatio: number
|
|
277
|
+
improved: boolean
|
|
278
|
+
reason: string
|
|
279
|
+
}
|
|
280
|
+
|
|
218
281
|
export interface TrialResult {
|
|
219
282
|
ok: boolean
|
|
220
283
|
command: string
|
|
@@ -223,6 +286,8 @@ export interface TrialResult {
|
|
|
223
286
|
stdout: string
|
|
224
287
|
stderr: string
|
|
225
288
|
timedOut?: boolean
|
|
289
|
+
outputArtifactSha256?: string
|
|
290
|
+
outputArtifactBytes?: number
|
|
226
291
|
}
|
|
227
292
|
|
|
228
293
|
export interface EvaluationSummary {
|
|
@@ -236,12 +301,15 @@ export interface EvaluationSummary {
|
|
|
236
301
|
committed?: boolean
|
|
237
302
|
complexity?: ComplexityMetrics
|
|
238
303
|
failureClassification?: string
|
|
304
|
+
adversarialPass?: boolean
|
|
305
|
+
adversarialPenalty?: number
|
|
239
306
|
}
|
|
240
307
|
|
|
241
308
|
export interface HardGates {
|
|
242
309
|
regressionPass: boolean
|
|
243
310
|
visibleEvalPass: boolean
|
|
244
311
|
hiddenEvalPass: boolean
|
|
312
|
+
adversarialPass: boolean
|
|
245
313
|
noProtectedPathViolation: boolean
|
|
246
314
|
completed: boolean
|
|
247
315
|
noCrash: boolean
|
|
@@ -262,6 +330,46 @@ export interface Fitness {
|
|
|
262
330
|
paretoRank?: number
|
|
263
331
|
hardGates: HardGates
|
|
264
332
|
reason: string
|
|
333
|
+
adversarialPenalty?: number
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface AdversarialTestCase {
|
|
337
|
+
id: string
|
|
338
|
+
modulePath: string
|
|
339
|
+
exportName: string
|
|
340
|
+
args: unknown[]
|
|
341
|
+
expected: unknown
|
|
342
|
+
reason: string
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface AdversarialFinding {
|
|
346
|
+
severity: "major" | "minor"
|
|
347
|
+
message: string
|
|
348
|
+
testId?: string
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface AdversarialReviewRecord {
|
|
352
|
+
candidateId: string
|
|
353
|
+
role: "adversary"
|
|
354
|
+
provider: RoleModelConfig["provider"]
|
|
355
|
+
model: string
|
|
356
|
+
promptVersion: string
|
|
357
|
+
promptSha256: string
|
|
358
|
+
promptArtifactSha256: string
|
|
359
|
+
summary?: string
|
|
360
|
+
resultSha256?: string
|
|
361
|
+
resultArtifactSha256?: string
|
|
362
|
+
errorSha256?: string
|
|
363
|
+
errorArtifactSha256?: string
|
|
364
|
+
testArtifactSha256?: string
|
|
365
|
+
snapshotArtifactSha256?: string
|
|
366
|
+
createdAt: string
|
|
367
|
+
status: "passed" | "failed" | "error"
|
|
368
|
+
findings: AdversarialFinding[]
|
|
369
|
+
testResults: Array<{ id: string; passed: boolean; outputArtifactSha256?: string; failure?: string }>
|
|
370
|
+
penaltyPoints: number
|
|
371
|
+
jobId?: string
|
|
372
|
+
error?: string
|
|
265
373
|
}
|
|
266
374
|
|
|
267
375
|
export interface RsiRunRecord {
|
|
@@ -269,6 +377,8 @@ export interface RsiRunRecord {
|
|
|
269
377
|
startedAt: string
|
|
270
378
|
finishedAt?: string
|
|
271
379
|
baseline?: TrialResult
|
|
380
|
+
baselineEvaluation?: EvaluationSummary
|
|
381
|
+
baselineFitness?: Fitness
|
|
272
382
|
model: string
|
|
273
383
|
baseRef: string
|
|
274
384
|
baseCommit: string
|
|
@@ -281,7 +391,9 @@ export interface RsiRunRecord {
|
|
|
281
391
|
combinations?: HarnessModelCombination[]
|
|
282
392
|
jobs?: ExperimentJob[]
|
|
283
393
|
trajectoryRefs?: string[]
|
|
394
|
+
adaptiveSearch?: AdaptiveSearchDecision[]
|
|
284
395
|
curriculumTasks?: CurriculumTask[]
|
|
396
|
+
adversarialReviews?: AdversarialReviewRecord[]
|
|
285
397
|
candidates: CandidateRecord[]
|
|
286
398
|
reports: string[]
|
|
287
399
|
}
|
|
@@ -310,6 +422,7 @@ export interface MutationRunner {
|
|
|
310
422
|
export interface RsiHooks {
|
|
311
423
|
runCommand?: CommandRunner
|
|
312
424
|
runMutation?: MutationRunner
|
|
425
|
+
runAdversary?: (request: { role: RoleModelConfig; prompt: string }) => Promise<string>
|
|
313
426
|
now?: () => string
|
|
314
427
|
log?: (line: string) => void
|
|
315
428
|
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import * as fs from "node:fs/promises"
|
|
3
|
+
import * as os from "node:os"
|
|
4
|
+
import * as path from "node:path"
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises"
|
|
6
|
+
import { openshellPreflight } from "../cloud/openshell-preflight.js"
|
|
7
|
+
import { PostgresRsiJobQueue, fleetQueuePolicy, requiredFleetJobLeaseMs, type FleetJobLease, type FleetJobPayload } from "./postgres-queue.js"
|
|
8
|
+
import { runOpenShellEvaluation, runOpenShellModelJob, runOpenShellMutation } from "./openshell.js"
|
|
9
|
+
import type { CandidateRecord, ComputePolicy, EvaluationSummary, MutationHypothesis, MutationKind, RsiConfig, RsiRoleConfig, TrialResult } from "./types.js"
|
|
10
|
+
|
|
11
|
+
const EVALUATION_PREVIEW_CHARS = 2048
|
|
12
|
+
|
|
13
|
+
export async function externalizeEvaluationOutputs(
|
|
14
|
+
queue: PostgresRsiJobQueue,
|
|
15
|
+
evaluation: Pick<EvaluationSummary, "regression" | "visible">,
|
|
16
|
+
): Promise<{ evaluation: Pick<EvaluationSummary, "regression" | "visible">; outputArtifact: { sha256: string; bytes: number } }> {
|
|
17
|
+
const trials = [evaluation.regression, ...evaluation.visible]
|
|
18
|
+
const rawOutputs = Buffer.from(JSON.stringify(trials.map((trial) => ({
|
|
19
|
+
command: trial.command,
|
|
20
|
+
stdout: trial.stdout,
|
|
21
|
+
stderr: trial.stderr,
|
|
22
|
+
}))))
|
|
23
|
+
const artifact = await queue.putArtifact(rawOutputs)
|
|
24
|
+
const compact = (trial: TrialResult): TrialResult => ({
|
|
25
|
+
...trial,
|
|
26
|
+
command: trial.command.slice(0, 512),
|
|
27
|
+
stdout: trial.stdout.slice(0, EVALUATION_PREVIEW_CHARS),
|
|
28
|
+
stderr: trial.stderr.slice(0, EVALUATION_PREVIEW_CHARS),
|
|
29
|
+
outputArtifactSha256: artifact.sha256,
|
|
30
|
+
outputArtifactBytes: artifact.byteLength,
|
|
31
|
+
})
|
|
32
|
+
return {
|
|
33
|
+
evaluation: { regression: compact(evaluation.regression), visible: evaluation.visible.map(compact) },
|
|
34
|
+
outputArtifact: { sha256: artifact.sha256, bytes: artifact.byteLength },
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RsiWorkerOptions {
|
|
39
|
+
queue: PostgresRsiJobQueue
|
|
40
|
+
workerId: string
|
|
41
|
+
gatewayId: string
|
|
42
|
+
hostname?: string
|
|
43
|
+
maxActive: number
|
|
44
|
+
resourceClasses: Array<"LOCAL_GPU" | "CPU" | "REMOTE_API" | "TRAINING_GPU">
|
|
45
|
+
scratchRoot: string
|
|
46
|
+
pollMs?: number
|
|
47
|
+
maxJobs?: number
|
|
48
|
+
signal?: AbortSignal
|
|
49
|
+
runMutation?: typeof runOpenShellMutation
|
|
50
|
+
runEvaluation?: typeof runOpenShellEvaluation
|
|
51
|
+
runModelJob?: typeof runOpenShellModelJob
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parsePositiveInteger(value: string | undefined, fallback: number, name: string): number {
|
|
55
|
+
if (value === undefined || value.trim() === "") return fallback
|
|
56
|
+
const parsed = Number(value)
|
|
57
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`)
|
|
58
|
+
return parsed
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function taskConfig(payload: FleetJobPayload, root: string, queue: PostgresRsiJobQueue): RsiConfig {
|
|
62
|
+
const roles: RsiRoleConfig = { worker: { provider: "ollama", model: payload.model } }
|
|
63
|
+
return {
|
|
64
|
+
repoRoot: root,
|
|
65
|
+
model: payload.model,
|
|
66
|
+
population: 1,
|
|
67
|
+
generations: 1,
|
|
68
|
+
maxConcurrent: queue.policy.maxInFlight,
|
|
69
|
+
mutationTask: payload.task,
|
|
70
|
+
regressionCommand: payload.regressionCommand ?? "true",
|
|
71
|
+
evalCommands: payload.evalCommands ?? [],
|
|
72
|
+
hiddenEvalCommands: [],
|
|
73
|
+
archiveDir: path.join(root, ".headlesscode", "rsi-worker"),
|
|
74
|
+
worktreeDir: path.join(root, ".worktrees"),
|
|
75
|
+
baseRef: payload.snapshotCommit,
|
|
76
|
+
seed: `fleet-${payload.candidate.id}`,
|
|
77
|
+
dryRun: false,
|
|
78
|
+
keepWorktrees: true,
|
|
79
|
+
maxIterations: payload.maxIterations,
|
|
80
|
+
protectedPaths: payload.protectedPaths,
|
|
81
|
+
commandTimeoutMs: payload.timeoutMs,
|
|
82
|
+
computePolicy: (payload.candidate.computePolicy as ComputePolicy | undefined) ?? "single",
|
|
83
|
+
mutationKind: payload.candidate.mutationKind as MutationKind | undefined,
|
|
84
|
+
hypothesis: payload.candidate.hypothesis as MutationHypothesis | undefined,
|
|
85
|
+
roles,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function candidateFromPayload(payload: FleetJobPayload, root: string): CandidateRecord {
|
|
90
|
+
const descriptor = payload.candidate
|
|
91
|
+
const id = String(descriptor.id ?? "")
|
|
92
|
+
if (!/^[A-Za-z0-9._-]{1,100}$/.test(id) || !Number.isInteger(descriptor.generation)) throw new Error("fleet job candidate descriptor is malformed")
|
|
93
|
+
return {
|
|
94
|
+
id,
|
|
95
|
+
generation: Number(descriptor.generation),
|
|
96
|
+
parent: typeof descriptor.parent === "string" ? descriptor.parent : "baseline",
|
|
97
|
+
branch: `rsi-worker-${id}`,
|
|
98
|
+
worktree: root,
|
|
99
|
+
baseCommit: payload.snapshotCommit,
|
|
100
|
+
status: "mutating",
|
|
101
|
+
model: payload.model,
|
|
102
|
+
mutation: payload.task,
|
|
103
|
+
createdAt: new Date().toISOString(),
|
|
104
|
+
updatedAt: new Date().toISOString(),
|
|
105
|
+
commits: [],
|
|
106
|
+
changedFiles: [],
|
|
107
|
+
protectedPathViolations: [],
|
|
108
|
+
...(descriptor.mutationKind ? { mutationKind: descriptor.mutationKind as MutationKind } : {}),
|
|
109
|
+
...(descriptor.hypothesis ? { hypothesis: descriptor.hypothesis as MutationHypothesis } : {}),
|
|
110
|
+
...(descriptor.modelCandidateId ? { modelCandidateId: String(descriptor.modelCandidateId) } : {}),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function verifySanitizedBundle(root: string, expectedCommit: string, jobKind: FleetJobLease["job"]["jobKind"]): void {
|
|
115
|
+
const actual = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim()
|
|
116
|
+
if (actual !== expectedCommit) throw new Error("fleet snapshot commit did not match the queued digest-bound commit")
|
|
117
|
+
const count = execFileSync("git", ["rev-list", "--count", "--all"], { cwd: root, encoding: "utf8" }).trim()
|
|
118
|
+
if (count !== "1") throw new Error("fleet snapshot must contain exactly one commit")
|
|
119
|
+
const entries = execFileSync("git", ["ls-tree", "-r", "-z", "--full-tree", "HEAD"], { cwd: root, encoding: "buffer" }).toString("utf8").split("\0").filter(Boolean)
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const tab = entry.indexOf("\t")
|
|
122
|
+
if (tab < 0) throw new Error("fleet snapshot contains malformed Git tree metadata")
|
|
123
|
+
const [mode] = entry.slice(0, tab).split(" ")
|
|
124
|
+
const relativePath = entry.slice(tab + 1).replaceAll("\\", "/")
|
|
125
|
+
if (mode !== "100644" && mode !== "100755") throw new Error("fleet snapshot contains a symlink, submodule or special file")
|
|
126
|
+
if (relativePath === ".env" || relativePath.startsWith(".env.") || relativePath.startsWith("src/rsi/") || relativePath.startsWith(".headlesscode/") || relativePath.startsWith("scripts/eval-suite/") || (jobKind === "mutation" && (relativePath.startsWith("fixtures/rsi-curriculum/") || relativePath.startsWith("scripts/") || relativePath.startsWith("tests/") || relativePath.startsWith("test/") || relativePath.includes("/__tests__/") || /(?:^|\/)\D[^/]*\.(?:test|spec)\.[^/]+$/.test(relativePath)))) {
|
|
127
|
+
throw new Error(`fleet snapshot contains protected evaluator or credential material: ${relativePath}`)
|
|
128
|
+
}
|
|
129
|
+
if ((jobKind === "training" || jobKind === "model-evaluation") && !relativePath.startsWith(".headlesscode-rsi-model/")) {
|
|
130
|
+
// Model jobs may include ordinary application source, but reject any accidental
|
|
131
|
+
// local checkpoint/result files or repository history in the transfer bundle.
|
|
132
|
+
if (/^(?:\.rsi-base-model|\.headlesscode-rsi-model-output)(?:\/|$)/.test(relativePath)) throw new Error("model job snapshot contains a local model or prior result artifact")
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function executeLease(lease: FleetJobLease, options: RsiWorkerOptions): Promise<void> {
|
|
138
|
+
const payload = lease.job.payload
|
|
139
|
+
if (payload.schemaVersion !== 1 || payload.snapshotSha256 !== lease.job.artifactSha256 || !Number.isSafeInteger(payload.snapshotBytes) || payload.snapshotBytes < 1) {
|
|
140
|
+
await options.queue.fail(lease, "malformed or unbound fleet snapshot payload")
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const requiredLeaseMs = requiredFleetJobLeaseMs(lease.job.jobKind, payload.timeoutMs, payload.evalCommands?.length ?? 0)
|
|
144
|
+
if (lease.job.leaseDurationMs < requiredLeaseMs) {
|
|
145
|
+
await options.queue.fail(lease, `job lease ${lease.job.leaseDurationMs}ms is below its OpenShell worst-case budget ${requiredLeaseMs}ms`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const root = await fs.mkdtemp(path.join(options.scratchRoot, `${lease.job.jobId}-${lease.job.attempts}-`))
|
|
149
|
+
const bundlePath = path.join(root, "snapshot.bundle")
|
|
150
|
+
const repoRoot = path.join(root, "repo")
|
|
151
|
+
try {
|
|
152
|
+
const bundle = await options.queue.getArtifact(payload.snapshotSha256)
|
|
153
|
+
if (bundle.byteLength !== payload.snapshotBytes) throw new Error("fleet snapshot artifact length differed from queued metadata")
|
|
154
|
+
await fs.writeFile(bundlePath, bundle, { mode: 0o600, flag: "wx" })
|
|
155
|
+
const verifyRoot = path.join(root, "verify.git")
|
|
156
|
+
execFileSync("git", ["init", "--bare", "--quiet", verifyRoot], { cwd: root, stdio: "pipe" })
|
|
157
|
+
execFileSync("git", ["bundle", "verify", bundlePath], { cwd: verifyRoot, stdio: "pipe" })
|
|
158
|
+
execFileSync("git", ["clone", "--quiet", "--no-hardlinks", bundlePath, repoRoot], { cwd: root, stdio: "pipe" })
|
|
159
|
+
verifySanitizedBundle(repoRoot, payload.snapshotCommit, lease.job.jobKind)
|
|
160
|
+
const candidate = candidateFromPayload(payload, repoRoot)
|
|
161
|
+
const config = taskConfig(payload, repoRoot, options.queue)
|
|
162
|
+
const started = Date.now()
|
|
163
|
+
let result: Record<string, unknown>
|
|
164
|
+
if (lease.job.jobKind === "mutation") {
|
|
165
|
+
const mutation = await (options.runMutation ?? runOpenShellMutation)(candidate, config)
|
|
166
|
+
if (!mutation.ok) throw new Error(mutation.error ?? mutation.result?.stderr ?? "OpenShell mutation failed")
|
|
167
|
+
const patch = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "--no-renames", `${candidate.baseCommit}...HEAD`], { cwd: repoRoot, encoding: "buffer", maxBuffer: options.queue.policy.maxInFlight * 1024 * 1024 * 16 })
|
|
168
|
+
if (patch.byteLength === 0) throw new Error("OpenShell mutation produced no patch")
|
|
169
|
+
const artifact = await options.queue.putArtifact(patch)
|
|
170
|
+
result = { ok: true, patchSha256: artifact.sha256, patchBytes: artifact.byteLength, durationMs: Date.now() - started }
|
|
171
|
+
} else if (lease.job.jobKind === "evaluation") {
|
|
172
|
+
const evaluation = await (options.runEvaluation ?? runOpenShellEvaluation)(candidate, config)
|
|
173
|
+
const externalized = await externalizeEvaluationOutputs(options.queue, evaluation)
|
|
174
|
+
result = { ok: true, ...externalized, durationMs: Date.now() - started }
|
|
175
|
+
} else {
|
|
176
|
+
const modelTask = payload.modelTask
|
|
177
|
+
if (!modelTask || (lease.job.jobKind === "training") !== (modelTask.kind === "qlora-train")) throw new Error("fleet model job kind and payload do not match")
|
|
178
|
+
const model = await (options.runModelJob ?? runOpenShellModelJob)(candidate, config, modelTask)
|
|
179
|
+
const outputArtifact = await options.queue.putArtifact(Buffer.from(JSON.stringify(model.result)))
|
|
180
|
+
const modelResult: Record<string, unknown> = { ...model.result, outputArtifactSha256: outputArtifact.sha256, outputArtifactBytes: outputArtifact.byteLength }
|
|
181
|
+
if (lease.job.jobKind === "training" && model.result.status === "completed") {
|
|
182
|
+
if (!model.adapterArchive) throw new Error("completed QLoRA job did not return its adapter archive")
|
|
183
|
+
const adapterArtifact = await options.queue.putArtifact(model.adapterArchive)
|
|
184
|
+
modelResult.adapterArtifactSha256 = adapterArtifact.sha256
|
|
185
|
+
modelResult.adapterArtifactBytes = adapterArtifact.byteLength
|
|
186
|
+
}
|
|
187
|
+
result = { ok: model.result.status === "completed", modelResult, durationMs: Date.now() - started }
|
|
188
|
+
}
|
|
189
|
+
const completion = await options.queue.complete(lease, result)
|
|
190
|
+
if (!completion.accepted) throw new Error("OpenShell result was fenced because this worker no longer owns the lease")
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await options.queue.fail(lease, error instanceof Error ? error.message : String(error))
|
|
193
|
+
} finally {
|
|
194
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function runRsiWorker(options: RsiWorkerOptions): Promise<void> {
|
|
199
|
+
if (options.queue.artifactBackend !== "s3") throw new Error("fleet RSI workers require a shared S3-compatible artifact store; file storage is same-host development only")
|
|
200
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(options.workerId) || !/^[A-Za-z0-9._-]{1,64}$/.test(options.gatewayId)) throw new Error("worker and gateway IDs must be simple configured identifiers")
|
|
201
|
+
if (options.maxActive !== 1) throw new Error("each RSI OpenShell worker process currently runs one guest at a time; configure maxActive=1")
|
|
202
|
+
await fs.mkdir(options.scratchRoot, { recursive: true, mode: 0o700 })
|
|
203
|
+
await options.queue.migrate()
|
|
204
|
+
await options.queue.registerWorker({
|
|
205
|
+
workerId: options.workerId,
|
|
206
|
+
gatewayId: options.gatewayId,
|
|
207
|
+
hostname: options.hostname ?? os.hostname(),
|
|
208
|
+
maxActive: options.maxActive,
|
|
209
|
+
resourceClasses: options.resourceClasses,
|
|
210
|
+
})
|
|
211
|
+
let processed = 0
|
|
212
|
+
while (!options.signal?.aborted && (options.maxJobs === undefined || processed < options.maxJobs)) {
|
|
213
|
+
await options.queue.heartbeatWorker(options.workerId)
|
|
214
|
+
const lease = await options.queue.claim(options.workerId)
|
|
215
|
+
if (!lease) {
|
|
216
|
+
await delay(options.pollMs ?? 1000, undefined, { signal: options.signal }).catch(() => undefined)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
const heartbeat = setInterval(() => {
|
|
220
|
+
void options.queue.heartbeat(lease).catch(() => false)
|
|
221
|
+
void options.queue.heartbeatWorker(options.workerId).catch(() => false)
|
|
222
|
+
}, Math.max(1000, Math.floor(options.queue.policy.leaseMs / 3)))
|
|
223
|
+
try { await executeLease(lease, options) } finally { clearInterval(heartbeat) }
|
|
224
|
+
processed += 1
|
|
225
|
+
}
|
|
226
|
+
await options.queue.stopWorker(options.workerId)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function rsiWorkerMain(env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
230
|
+
const workerId = env.HEADLESSCODE_RSI_WORKER_ID?.trim()
|
|
231
|
+
const gatewayId = env.HEADLESSCODE_RSI_GATEWAY_ID?.trim()
|
|
232
|
+
if (!workerId || !gatewayId) throw new Error("HEADLESSCODE_RSI_WORKER_ID and HEADLESSCODE_RSI_GATEWAY_ID are required")
|
|
233
|
+
const queueName = env.HEADLESSCODE_RSI_QUEUE_NAME?.trim() || "rsi-default"
|
|
234
|
+
const maxInFlight = parsePositiveInteger(env.HEADLESSCODE_RSI_MAX_IN_FLIGHT, 1, "HEADLESSCODE_RSI_MAX_IN_FLIGHT")
|
|
235
|
+
const policy = fleetQueuePolicy(queueName, maxInFlight, env)
|
|
236
|
+
const queue = PostgresRsiJobQueue.fromEnvironment(policy, env)
|
|
237
|
+
const maxActive = parsePositiveInteger(env.HEADLESSCODE_RSI_WORKER_MAX_ACTIVE, 1, "HEADLESSCODE_RSI_WORKER_MAX_ACTIVE")
|
|
238
|
+
const resourceClasses = (env.HEADLESSCODE_RSI_WORKER_RESOURCE_CLASSES ?? "LOCAL_GPU,CPU").split(",").map((entry) => entry.trim()).filter(Boolean) as RsiWorkerOptions["resourceClasses"]
|
|
239
|
+
if (resourceClasses.some((value) => !["LOCAL_GPU", "CPU", "REMOTE_API", "TRAINING_GPU"].includes(value))) throw new Error("HEADLESSCODE_RSI_WORKER_RESOURCE_CLASSES contains an unsupported class")
|
|
240
|
+
process.env.OPENSHELL_GATEWAY = gatewayId
|
|
241
|
+
const preflight = openshellPreflight({ requireOpenRouterCredential: false })
|
|
242
|
+
if (preflight) throw new Error(preflight)
|
|
243
|
+
const controller = new AbortController()
|
|
244
|
+
const stop = () => controller.abort()
|
|
245
|
+
process.once("SIGINT", stop)
|
|
246
|
+
process.once("SIGTERM", stop)
|
|
247
|
+
try {
|
|
248
|
+
await runRsiWorker({
|
|
249
|
+
queue,
|
|
250
|
+
workerId,
|
|
251
|
+
gatewayId,
|
|
252
|
+
maxActive,
|
|
253
|
+
resourceClasses,
|
|
254
|
+
scratchRoot: path.resolve(env.HEADLESSCODE_RSI_WORKER_SCRATCH_DIR ?? path.join(os.tmpdir(), "headlesscode-rsi-worker")),
|
|
255
|
+
pollMs: parsePositiveInteger(env.HEADLESSCODE_RSI_WORKER_POLL_MS, 1000, "HEADLESSCODE_RSI_WORKER_POLL_MS"),
|
|
256
|
+
signal: controller.signal,
|
|
257
|
+
})
|
|
258
|
+
return 0
|
|
259
|
+
} finally {
|
|
260
|
+
process.removeListener("SIGINT", stop)
|
|
261
|
+
process.removeListener("SIGTERM", stop)
|
|
262
|
+
await queue.close()
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/rsi/workspace.ts
CHANGED
|
@@ -81,11 +81,22 @@ export async function removeCandidateWorktree(config: RsiConfig, candidate: Cand
|
|
|
81
81
|
|
|
82
82
|
export async function changedFiles(repoRoot: string, baseCommit: string): Promise<string[]> {
|
|
83
83
|
const output = await gitOutput(repoRoot, ["diff", "--name-only", `${baseCommit}...HEAD`])
|
|
84
|
-
const status = await gitOutput(repoRoot, ["status", "--short"])
|
|
85
84
|
const files = new Set(output.split("\n").map((file) => file.trim()).filter(Boolean))
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
const status = await execFile("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
86
|
+
cwd: repoRoot,
|
|
87
|
+
encoding: "utf8",
|
|
88
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
89
|
+
})
|
|
90
|
+
const entries = status.stdout.split("\0")
|
|
91
|
+
for (let index = 0; index < entries.length; index++) {
|
|
92
|
+
const entry = entries[index]
|
|
93
|
+
if (!entry) continue
|
|
94
|
+
const file = entry.slice(3)
|
|
88
95
|
if (file) files.add(file)
|
|
96
|
+
if (/[RC]/.test(entry.slice(0, 2))) {
|
|
97
|
+
const original = entries[++index]
|
|
98
|
+
if (original) files.add(original)
|
|
99
|
+
}
|
|
89
100
|
}
|
|
90
101
|
return [...files].sort()
|
|
91
102
|
}
|
package/src/watcher/cli.ts
CHANGED
|
@@ -32,6 +32,7 @@ import * as path from "node:path"
|
|
|
32
32
|
|
|
33
33
|
import { Logger } from "../engine/logger.js"
|
|
34
34
|
import { watchIssues, type SweepResult } from "./watch.js"
|
|
35
|
+
import { openshellPreflight } from "../cloud/openshell-preflight.js"
|
|
35
36
|
|
|
36
37
|
const WATCH_USAGE = `headlesscode watch — Phase 5 GitHub issue watcher
|
|
37
38
|
|
|
@@ -61,6 +62,7 @@ Options:
|
|
|
61
62
|
<repo>/.worktrees/.watcher-state.json)
|
|
62
63
|
--mode <slug> Harness mode for workers (default: code; forwarded
|
|
63
64
|
to the spawner as ORCHESTRATOR_MODE)
|
|
65
|
+
--execution-provider <local|openshell> Worker runtime (default: local)
|
|
64
66
|
--qa Pass-through: record that a QA round should follow
|
|
65
67
|
each batch (see docs/phase5-issue-watcher.md)
|
|
66
68
|
--deploy Pass-through: record that a deploy gate should follow
|
|
@@ -92,6 +94,7 @@ export interface WatchCliOptions {
|
|
|
92
94
|
maxConcurrentSessions: number
|
|
93
95
|
stateFile?: string
|
|
94
96
|
mode: string
|
|
97
|
+
executionProvider: "local" | "openshell"
|
|
95
98
|
qa: boolean
|
|
96
99
|
deploy: boolean
|
|
97
100
|
memoryDir?: string
|
|
@@ -110,6 +113,7 @@ export function parseWatchArgs(argv: string[]): { options: WatchCliOptions; erro
|
|
|
110
113
|
maxPerSweep: 5,
|
|
111
114
|
maxConcurrentSessions: 3,
|
|
112
115
|
mode: "code",
|
|
116
|
+
executionProvider: process.env.HEADLESSCODE_EXECUTION_PROVIDER === "openshell" ? "openshell" : "local",
|
|
113
117
|
qa: false,
|
|
114
118
|
deploy: false,
|
|
115
119
|
dryRun: false,
|
|
@@ -135,6 +139,12 @@ export function parseWatchArgs(argv: string[]): { options: WatchCliOptions; erro
|
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
switch (flag) {
|
|
142
|
+
case "--execution-provider": {
|
|
143
|
+
const v = next()
|
|
144
|
+
if (v !== "local" && v !== "openshell") return { options, error: "--execution-provider must be local or openshell" }
|
|
145
|
+
options.executionProvider = v
|
|
146
|
+
break
|
|
147
|
+
}
|
|
138
148
|
case "--owner": {
|
|
139
149
|
const v = next()
|
|
140
150
|
if (v === undefined) {
|
|
@@ -300,6 +310,13 @@ export async function watchMain(argv: string[]): Promise<number> {
|
|
|
300
310
|
return 2
|
|
301
311
|
}
|
|
302
312
|
}
|
|
313
|
+
if (!options.dryRun && options.executionProvider === "openshell") {
|
|
314
|
+
const issue = openshellPreflight()
|
|
315
|
+
if (issue) {
|
|
316
|
+
process.stderr.write(`headlesscode watch: OpenShell preflight failed: ${issue}\n`)
|
|
317
|
+
return 2
|
|
318
|
+
}
|
|
319
|
+
}
|
|
303
320
|
|
|
304
321
|
const ghRepo = options.ghRepo ?? path.basename(repoRoot)
|
|
305
322
|
const logger = new Logger({ level: "info" })
|
|
@@ -328,6 +345,7 @@ export async function watchMain(argv: string[]): Promise<number> {
|
|
|
328
345
|
retryFailed: options.retryFailed,
|
|
329
346
|
dryRun: options.dryRun,
|
|
330
347
|
mode: options.mode,
|
|
348
|
+
executionProvider: options.executionProvider,
|
|
331
349
|
memoryDir: options.memoryDir,
|
|
332
350
|
qa: options.qa,
|
|
333
351
|
deploy: options.deploy,
|
package/src/watcher/watch.ts
CHANGED
|
@@ -129,6 +129,7 @@ export interface WatcherConfig {
|
|
|
129
129
|
spawn?: (batch: SpawnBatch) => SpawnResult | Promise<SpawnResult>
|
|
130
130
|
/** Harness mode for workers (forwarded to the spawner as ORCHESTRATOR_MODE). */
|
|
131
131
|
mode?: string
|
|
132
|
+
executionProvider?: "local" | "openshell"
|
|
132
133
|
/** Memory dir for workers (forwarded to the spawner as HEADLESSCODE_MEMORY_DIR). */
|
|
133
134
|
memoryDir?: string
|
|
134
135
|
/** Phase 4 pass-through: recorded on the batch entry (see docs). */
|
|
@@ -480,7 +481,7 @@ function batchId(): string {
|
|
|
480
481
|
* with cwd = the target repo so `git rev-parse --show-toplevel` resolves).
|
|
481
482
|
* No worktree creation happens in TS; the bash script remains the spawner.
|
|
482
483
|
*/
|
|
483
|
-
export function defaultSpawn(batch: SpawnBatch, config: Pick<WatcherConfig, "mode" | "memoryDir">): SpawnResult {
|
|
484
|
+
export function defaultSpawn(batch: SpawnBatch, config: Pick<WatcherConfig, "mode" | "memoryDir" | "executionProvider">): SpawnResult {
|
|
484
485
|
const issues: SplitIssue[] = [
|
|
485
486
|
{ number: batch.issue.number, title: batch.issue.title, body: batch.issue.body },
|
|
486
487
|
]
|
|
@@ -511,6 +512,7 @@ export function defaultSpawn(batch: SpawnBatch, config: Pick<WatcherConfig, "mod
|
|
|
511
512
|
...process.env,
|
|
512
513
|
TARGET_REPO: batch.repoRoot,
|
|
513
514
|
ORCHESTRATOR_MODE: config.mode ?? "code",
|
|
515
|
+
HEADLESSCODE_EXECUTION_PROVIDER: config.executionProvider ?? process.env.HEADLESSCODE_EXECUTION_PROVIDER ?? "local",
|
|
514
516
|
HEADLESSCODE_PROJECT: path.basename(batch.repoRoot),
|
|
515
517
|
...(config.memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(config.memoryDir) } : {}),
|
|
516
518
|
},
|