headlesscode 1.0.2
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/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DockerSessionProvider — a real `CloudProvider` implementation that runs each
|
|
3
|
+
* harness session in its own isolated Docker container.
|
|
4
|
+
*
|
|
5
|
+
* WHY (see docs/multi-tenant-hosting-design.md §1 for the full comparison):
|
|
6
|
+
* the multi-tenant security requirement — one tenant's session (buggy,
|
|
7
|
+
* malicious, or just aggressive) must not be able to see, affect, or exhaust
|
|
8
|
+
* resources for another tenant's session or the host — is not satisfied by
|
|
9
|
+
* the local process model's directory-level separation. Docker gives us a
|
|
10
|
+
* real kernel-enforced boundary per session:
|
|
11
|
+
*
|
|
12
|
+
* - cgroup resource limits (CPU/memory/pids): a runaway session is
|
|
13
|
+
* throttled/oom-killed by the kernel, never able to starve the host or
|
|
14
|
+
* sibling sessions;
|
|
15
|
+
* - per-container network namespace with no published ports: a session's
|
|
16
|
+
* sockets are unreachable from the host and from other sessions;
|
|
17
|
+
* - non-root user (host uid:gid — no root inside the container) +
|
|
18
|
+
* read-only rootfs (tmpfs /tmp): reduces the blast radius of a
|
|
19
|
+
* compromised harness without needing a rootless daemon.
|
|
20
|
+
*
|
|
21
|
+
* The container runs the harness INSIDE the boundary — `runHarness` is
|
|
22
|
+
* `docker exec`, not a host subprocess. `teardown` is `docker rm -f` and
|
|
23
|
+
* VERIFIES the container is actually gone (a leaked container per session is
|
|
24
|
+
* a real cost/security problem at scale — the test suite enumerates
|
|
25
|
+
* containers before/after rather than trusting an exit code).
|
|
26
|
+
*
|
|
27
|
+
* The harness repo (this checkout, containing src/cli.ts + node_modules) is
|
|
28
|
+
* mounted read-only at /harness; the target repo/worktree is mounted at
|
|
29
|
+
* /workspace. No image build per session; the session's own writes land in
|
|
30
|
+
* the workspace mount, so results survive container teardown. Secrets ride
|
|
31
|
+
* in via `CloudSessionRequest.env` (per-session container env, never
|
|
32
|
+
* written to disk/logs) — see the design doc §2.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { spawnSync } from "node:child_process"
|
|
36
|
+
import * as fs from "node:fs"
|
|
37
|
+
import * as path from "node:path"
|
|
38
|
+
import { fileURLToPath } from "node:url"
|
|
39
|
+
|
|
40
|
+
import { writeTaskFiles } from "../orchestrator/cli.js"
|
|
41
|
+
import type { CloudProvider, CloudSessionRequest, CommandResult, SessionHandle } from "./provider.js"
|
|
42
|
+
|
|
43
|
+
/** The harness repo root (parent of src/cloud) — same pattern as the CLI. */
|
|
44
|
+
const HARNESS_ROOT = fileURLToPath(new URL("../..", import.meta.url))
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_DOCKER_IMAGE = "node:22-bookworm-slim"
|
|
47
|
+
|
|
48
|
+
/** Default per-session CPU cap (fraction of a core, Docker `--cpus`). */
|
|
49
|
+
export const DEFAULT_DOCKER_CPUS = 2
|
|
50
|
+
/** Default per-session memory cap, MB (Docker `--memory`). */
|
|
51
|
+
export const DEFAULT_DOCKER_MEMORY_MB = 2048
|
|
52
|
+
/** Default per-session process cap (Docker `--pids-limit`). */
|
|
53
|
+
export const DEFAULT_DOCKER_PIDS_LIMIT = 512
|
|
54
|
+
|
|
55
|
+
export interface DockerSessionProviderOptions {
|
|
56
|
+
/** Image to run the session in (default: node:22-bookworm-slim). */
|
|
57
|
+
image?: string
|
|
58
|
+
/** Per-session CPU cap (fraction of a core). */
|
|
59
|
+
cpus?: number
|
|
60
|
+
/** Per-session memory cap, MB. */
|
|
61
|
+
memoryMb?: number
|
|
62
|
+
/** Per-session process count cap. */
|
|
63
|
+
pidsLimit?: number
|
|
64
|
+
/** Container name prefix (default: "hcls"). */
|
|
65
|
+
namePrefix?: string
|
|
66
|
+
/**
|
|
67
|
+
* Injectable `docker` runner (tests). Returns { exitCode, output } exactly
|
|
68
|
+
* like the local provider's `run` seam; default shells out to the real
|
|
69
|
+
* `docker` binary.
|
|
70
|
+
*/
|
|
71
|
+
run?: (command: string) => CommandResult
|
|
72
|
+
/** Injectable `docker inspect` result parser (tests): container state → readiness. */
|
|
73
|
+
isReady?: (inspectJson: string) => boolean
|
|
74
|
+
/** Injectable `docker ps -a` filter listing (tests): returns container names/lines. */
|
|
75
|
+
listContainers?: () => string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* DockerSessionProvider — one isolated container per session behind the
|
|
80
|
+
* existing `CloudProvider` interface. The orchestration layer calls the SAME
|
|
81
|
+
* five methods it calls on LocalProcessProvider; only the execution boundary
|
|
82
|
+
* changes (docs/multi-tenant-hosting-design.md §3).
|
|
83
|
+
*/
|
|
84
|
+
export class DockerSessionProvider implements CloudProvider {
|
|
85
|
+
readonly name = "docker"
|
|
86
|
+
private readonly image: string
|
|
87
|
+
private readonly cpus: number
|
|
88
|
+
private readonly memoryMb: number
|
|
89
|
+
private readonly pidsLimit: number
|
|
90
|
+
private readonly namePrefix: string
|
|
91
|
+
private readonly run: (command: string) => CommandResult
|
|
92
|
+
private readonly isReady: (inspectJson: string) => boolean
|
|
93
|
+
private readonly listContainers: () => string[]
|
|
94
|
+
|
|
95
|
+
constructor(options: DockerSessionProviderOptions = {}) {
|
|
96
|
+
this.image = options.image ?? dockerEnv("HEADLESSCODE_DOCKER_IMAGE", DEFAULT_DOCKER_IMAGE)
|
|
97
|
+
this.cpus = numberEnv("HEADLESSCODE_DOCKER_CPUS", options.cpus ?? DEFAULT_DOCKER_CPUS)
|
|
98
|
+
this.memoryMb = numberEnv("HEADLESSCODE_DOCKER_MEMORY_MB", options.memoryMb ?? DEFAULT_DOCKER_MEMORY_MB)
|
|
99
|
+
this.pidsLimit = numberEnv("HEADLESSCODE_DOCKER_PIDS_LIMIT", options.pidsLimit ?? DEFAULT_DOCKER_PIDS_LIMIT)
|
|
100
|
+
this.namePrefix = options.namePrefix ?? "hcls"
|
|
101
|
+
this.run =
|
|
102
|
+
options.run ??
|
|
103
|
+
((command) => {
|
|
104
|
+
const res = spawnSync("sh", ["-c", command], {
|
|
105
|
+
encoding: "utf-8",
|
|
106
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
107
|
+
})
|
|
108
|
+
const output = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim()
|
|
109
|
+
return { exitCode: res.status ?? 1, output }
|
|
110
|
+
})
|
|
111
|
+
this.isReady = options.isReady ?? ((json) => {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(json) as Array<{ State?: { Running?: boolean } }>
|
|
114
|
+
return parsed.length > 0 && parsed[0].State?.Running === true
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
this.listContainers = options.listContainers ?? (() => {
|
|
120
|
+
const res = this.run("docker ps -a --format '{{.Names}}'")
|
|
121
|
+
if (res.exitCode !== 0) {
|
|
122
|
+
return []
|
|
123
|
+
}
|
|
124
|
+
return res.output.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The container name for a handle (id is the session/worktree name). */
|
|
129
|
+
containerName(handle: SessionHandle): string {
|
|
130
|
+
return `${this.namePrefix}-${handle.id}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The tenant workspace mount path (must match what spawnWorktreeSession used). */
|
|
134
|
+
workspacePath(handle: SessionHandle): string {
|
|
135
|
+
if (!handle.address) {
|
|
136
|
+
throw new Error(`DockerSessionProvider: no workspace path on handle ${handle.id}`)
|
|
137
|
+
}
|
|
138
|
+
return handle.address
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async spawnWorktreeSession(request: CloudSessionRequest): Promise<SessionHandle> {
|
|
142
|
+
const { repo, issue, worktreeSpec } = request
|
|
143
|
+
const wtPath = path.join(path.resolve(repo), ".worktrees", worktreeSpec.name)
|
|
144
|
+
|
|
145
|
+
// 1. Task file under <repo>/plans/parallel-tasks/ — same builder the
|
|
146
|
+
// local provider / watcher use. The workspace mount carries it into
|
|
147
|
+
// the container as <workspace>/plans/parallel-tasks/<taskFile>.
|
|
148
|
+
writeTaskFiles(path.resolve(repo), [worktreeSpec], [issue])
|
|
149
|
+
|
|
150
|
+
// 2. Create + start the isolated container. Deliberately NO published
|
|
151
|
+
// ports (per-container network namespace; a session's sockets are
|
|
152
|
+
// unreachable from the host and from sibling sessions) and no
|
|
153
|
+
// --network host. Non-root user + read-only rootfs with tmpfs /tmp:
|
|
154
|
+
// the harness's own writes go to the workspace mount; everything
|
|
155
|
+
// else is immutable from inside the session.
|
|
156
|
+
const name = this.containerName({ id: worktreeSpec.name, provider: this.name })
|
|
157
|
+
// Two separate commands (newline-joined): a pre-clean of a stale
|
|
158
|
+
// container from a crashed run, then `docker create` (NOT `docker run
|
|
159
|
+
// -d ... tail -f /dev/null`). Using `docker run -d` with a
|
|
160
|
+
// long-running container process attached the container's stdio to
|
|
161
|
+
// OUR stdout pipe, which spawnSync never sees close (the container's
|
|
162
|
+
// tail holds the fd) → the spawn call hangs. `docker create` returns
|
|
163
|
+
// the id without executing; `docker start` detaches. Neither holds
|
|
164
|
+
// our stdio.
|
|
165
|
+
const envEntries = Object.entries(request.env ?? {}).filter(([k]) => k !== "TARGET_REPO")
|
|
166
|
+
const envFlags = [
|
|
167
|
+
`-e TARGET_REPO=/workspace`,
|
|
168
|
+
...envEntries.map(([k, v]) => `-e ${k}=${v}`),
|
|
169
|
+
]
|
|
170
|
+
// The pre-clean and the `docker create` must be SEPARATE shell
|
|
171
|
+
// statements (newline), but the docker flags themselves must be ONE
|
|
172
|
+
// space-joined line — a newline between flags turns each flag into a
|
|
173
|
+
// separate shell command (`docker create` alone with no args errors).
|
|
174
|
+
// A space join between the pre-clean and create would parse as
|
|
175
|
+
// `docker rm ... || true docker create ...` — `true` swallows the
|
|
176
|
+
// create and the container is never made.
|
|
177
|
+
const createFlags = [
|
|
178
|
+
`docker create`,
|
|
179
|
+
`--name ${name}`,
|
|
180
|
+
`--cpus ${this.cpus}`,
|
|
181
|
+
`--memory ${this.memoryMb}m`,
|
|
182
|
+
`--memory-swap ${this.memoryMb}m`,
|
|
183
|
+
`--pids-limit ${this.pidsLimit}`,
|
|
184
|
+
// Run as the HOST uid:gid (not the image's `node` user): the
|
|
185
|
+
// workspace is a host-owned bind mount, and the harness must be
|
|
186
|
+
// able to write .harness.* markers + edit files there. The
|
|
187
|
+
// isolation boundary is the container namespace/cgroup separation,
|
|
188
|
+
// not the uid — a non-root uid inside a container shares the host
|
|
189
|
+
// kernel but is still fully separated from sibling sessions.
|
|
190
|
+
`--user ${uidGid()}`,
|
|
191
|
+
`--read-only`,
|
|
192
|
+
`--tmpfs /tmp:rw,noexec,nosuid,size=256m`,
|
|
193
|
+
`-v ${path.resolve(repo)}:/workspace`,
|
|
194
|
+
`-v ${HARNESS_ROOT}:/harness:ro`,
|
|
195
|
+
`-w /workspace`,
|
|
196
|
+
...envFlags,
|
|
197
|
+
this.image,
|
|
198
|
+
`tail -f /dev/null`,
|
|
199
|
+
].join(" ")
|
|
200
|
+
const createCmd = [`docker rm -f ${name} 2>/dev/null || true`, createFlags].join("\n")
|
|
201
|
+
const createResult = this.run(createCmd)
|
|
202
|
+
if (createResult.exitCode !== 0) {
|
|
203
|
+
// Best-effort cleanup so a failed spawn never leaks a container.
|
|
204
|
+
this.run(`docker rm -f ${name} 2>/dev/null || true`)
|
|
205
|
+
throw new Error(`DockerSessionProvider: create failed (exit ${createResult.exitCode}): ${createResult.output}`)
|
|
206
|
+
}
|
|
207
|
+
// docker create prints the container id (possibly preceded by warning
|
|
208
|
+
// lines, e.g. the swap-limit cgroup warning seen on this host). Take
|
|
209
|
+
// the last whitespace-separated token that looks like a 64-hex id;
|
|
210
|
+
// fall back to the container name we chose (equally valid for inspect).
|
|
211
|
+
const containerId = [...createResult.output.trim().split(/\s+/)].reverse().find((t) => /^[0-9a-f]{64}$/.test(t)) ?? name
|
|
212
|
+
|
|
213
|
+
const startResult = this.run(`docker start ${containerId}`)
|
|
214
|
+
if (startResult.exitCode !== 0) {
|
|
215
|
+
this.run(`docker rm -f ${name} 2>/dev/null || true`)
|
|
216
|
+
throw new Error(`DockerSessionProvider: start failed (exit ${startResult.exitCode}): ${startResult.output}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { id: worktreeSpec.name, provider: this.name, address: wtPath, containerId }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async waitReady(handle: SessionHandle): Promise<void> {
|
|
223
|
+
const name = this.containerName(handle)
|
|
224
|
+
const deadline = Date.now() + 60_000
|
|
225
|
+
for (;;) {
|
|
226
|
+
const inspect = this.run(`docker inspect ${name}`)
|
|
227
|
+
if (inspect.exitCode === 0 && this.isReady(inspect.output)) {
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (Date.now() > deadline) {
|
|
231
|
+
throw new Error(`DockerSessionProvider: ${handle.id} not ready within 60s (last: ${inspect.output.slice(0, 200)})`)
|
|
232
|
+
}
|
|
233
|
+
await sleep(250)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run the harness command INSIDE the container (docker exec), not on the
|
|
239
|
+
* host. The command is wrapped in single quotes for the OUTER shell so the
|
|
240
|
+
* inner `bash -lc` receives it as ONE argument (JSON.stringify produces
|
|
241
|
+
* double-quoted strings that the outer shell re-splits on spaces — broken
|
|
242
|
+
* for any command with spaces, as the harness commands are). Single quotes
|
|
243
|
+
* inside the command are shell-escaped ('"'"' — the standard idiom).
|
|
244
|
+
*/
|
|
245
|
+
async runHarness(handle: SessionHandle, cmd: string): Promise<CommandResult> {
|
|
246
|
+
const name = this.containerName(handle)
|
|
247
|
+
const quoted = `'${cmd.replace(/'/g, `'\\''`)}'`
|
|
248
|
+
const result = this.run(`docker exec ${name} bash -lc ${quoted}`)
|
|
249
|
+
return result
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async collectResults(handle: SessionHandle): Promise<Record<string, unknown>> {
|
|
253
|
+
const wtPath = this.workspacePath(handle)
|
|
254
|
+
const exitCode = readFileInt(path.join(wtPath, ".harness.exit"))
|
|
255
|
+
const done = fs.existsSync(path.join(wtPath, ".harness.done"))
|
|
256
|
+
const summary = tailFile(path.join(wtPath, "harness.log"), 40)
|
|
257
|
+
return { exitCode, done, summary, worktree: wtPath }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Remove the container AND verify it is actually gone — a leaked container
|
|
262
|
+
* is a real cost/security problem. Docker's "removal of container ... is
|
|
263
|
+
* already in progress" (a benign race between `docker rm` calls on the
|
|
264
|
+
* same container, hit when parallel sessions teardown close together) is
|
|
265
|
+
* treated as success: the removal IS in progress, and the enumeration
|
|
266
|
+
* below is what actually proves the container is gone.
|
|
267
|
+
*/
|
|
268
|
+
async teardown(handle: SessionHandle): Promise<void> {
|
|
269
|
+
const name = this.containerName(handle)
|
|
270
|
+
const rm = this.run(`docker rm -f ${name}`)
|
|
271
|
+
if (rm.exitCode !== 0 && !rm.output.includes("No such container") && !rm.output.includes("is already in progress")) {
|
|
272
|
+
throw new Error(`DockerSessionProvider: teardown rm failed for ${name} (exit ${rm.exitCode}): ${rm.output}`)
|
|
273
|
+
}
|
|
274
|
+
// Prove removal by enumeration, not by trusting the exit code.
|
|
275
|
+
// `docker rm` is async in the daemon: a just-issued rm can still list
|
|
276
|
+
// the container for a few ms, so retry the enumeration briefly before
|
|
277
|
+
// declaring a leak — an eventual rm failure (auth, I/O) IS a real leak
|
|
278
|
+
// and still fails this loop.
|
|
279
|
+
const deadline = Date.now() + 5_000
|
|
280
|
+
for (;;) {
|
|
281
|
+
const remaining = this.listContainers().filter((n) => n === name)
|
|
282
|
+
if (remaining.length === 0) {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
if (Date.now() > deadline) {
|
|
286
|
+
throw new Error(`DockerSessionProvider: teardown did not remove ${name} (still listed by docker ps -a)`)
|
|
287
|
+
}
|
|
288
|
+
await sleep(100)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─── Small helpers ───────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
/** Host uid:gid as "uid:gid" for --user (see the spawn comment: the workspace mount must stay writable by the harness). */
|
|
296
|
+
function uidGid(): string {
|
|
297
|
+
return `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function dockerEnv(name: string, fallback: string): string {
|
|
301
|
+
const v = process.env[name]
|
|
302
|
+
return v && v.trim() !== "" ? v : fallback
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function numberEnv(name: string, fallback: number): number {
|
|
306
|
+
const v = process.env[name]
|
|
307
|
+
if (v === undefined || v.trim() === "") {
|
|
308
|
+
return fallback
|
|
309
|
+
}
|
|
310
|
+
const n = Number(v)
|
|
311
|
+
return Number.isFinite(n) && n > 0 ? n : fallback
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function readFileInt(file: string): number | undefined {
|
|
315
|
+
try {
|
|
316
|
+
const n = Number(fs.readFileSync(file, "utf-8").trim())
|
|
317
|
+
return Number.isFinite(n) ? n : undefined
|
|
318
|
+
} catch {
|
|
319
|
+
return undefined
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function tailFile(file: string, maxLines: number): string {
|
|
324
|
+
try {
|
|
325
|
+
const lines = fs.readFileSync(file, "utf-8").split(/\r?\n/).filter((l) => l.trim() !== "")
|
|
326
|
+
return lines.slice(-maxLines).join("\n")
|
|
327
|
+
} catch {
|
|
328
|
+
return ""
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function sleep(ms: number): Promise<void> {
|
|
333
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
334
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 6 — ephemeral compute abstraction (spec 6.1) + the local reference
|
|
3
|
+
* implementation.
|
|
4
|
+
*
|
|
5
|
+
* The goal of 6.1 is "container/VM per issue → run harness → tear down". That
|
|
6
|
+
* slots in BEHIND this interface: a cloud provider implements the same
|
|
7
|
+
* five-method lifecycle as `LocalProcessProvider`, but its
|
|
8
|
+
* `spawnWorktreeSession` creates a container/VM (Hetzner + Docker in the
|
|
9
|
+
* 6.2 evaluation) instead of a local git worktree, `runHarness` executes the
|
|
10
|
+
* harness inside it, and `teardown` deletes it. The orchestration layer
|
|
11
|
+
* (orchestrate/watch) keeps calling the SAME methods either way.
|
|
12
|
+
*
|
|
13
|
+
* The interface is deliberately small — the cloud-specific details (image,
|
|
14
|
+
* volume, network) are implementation concerns; the harness only needs
|
|
15
|
+
* "make me a session, wait until it's usable, run this command in it, get the
|
|
16
|
+
* results, delete it".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process"
|
|
20
|
+
import * as fs from "node:fs"
|
|
21
|
+
import * as path from "node:path"
|
|
22
|
+
import { fileURLToPath } from "node:url"
|
|
23
|
+
|
|
24
|
+
import { writeTaskFiles } from "../orchestrator/cli.js"
|
|
25
|
+
import type { WorktreeSpec } from "../orchestrator/split.js"
|
|
26
|
+
|
|
27
|
+
/** The worktree/issue spec a provider turns into an isolated session. */
|
|
28
|
+
export interface CloudSessionRequest {
|
|
29
|
+
/** Local clone of the target repo (worktrees are created under it). */
|
|
30
|
+
repo: string
|
|
31
|
+
/** The issue driving this session (for task-file generation + labels). */
|
|
32
|
+
issue: { number: number; title: string; body?: string }
|
|
33
|
+
/** The worktree group spec (name, issues, taskFile) from splitIssues. */
|
|
34
|
+
worktreeSpec: WorktreeSpec
|
|
35
|
+
/** Extra env to pass to the harness (e.g. ORCHESTRATOR_MODE). */
|
|
36
|
+
env?: Record<string, string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Opaque handle returned by spawnWorktreeSession; used by the other calls. */
|
|
40
|
+
export interface SessionHandle {
|
|
41
|
+
/** Unique id within the provider (e.g. the worktree/container name). */
|
|
42
|
+
id: string
|
|
43
|
+
/** Provider name (e.g. "local", "docker", "hetzner-docker"). */
|
|
44
|
+
provider: string
|
|
45
|
+
/** Where the session lives (worktree path / container address). */
|
|
46
|
+
address?: string
|
|
47
|
+
/** Provider-specific opaque identifier (e.g. container id) — optional. */
|
|
48
|
+
containerId?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CommandResult {
|
|
52
|
+
exitCode: number
|
|
53
|
+
/** Combined stdout+stderr. */
|
|
54
|
+
output: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The lifecycle contract every compute backend implements. All methods are
|
|
59
|
+
* async (cloud operations are slow/network-bound even when the local
|
|
60
|
+
* implementation is synchronous under the hood).
|
|
61
|
+
*/
|
|
62
|
+
export interface CloudProvider {
|
|
63
|
+
readonly name: string
|
|
64
|
+
|
|
65
|
+
/** Create the isolated session (worktree/container/VM) for one issue. */
|
|
66
|
+
spawnWorktreeSession(request: CloudSessionRequest): Promise<SessionHandle>
|
|
67
|
+
|
|
68
|
+
/** Wait until the session is ready to run the harness (no-op locally). */
|
|
69
|
+
waitReady(handle: SessionHandle): Promise<void>
|
|
70
|
+
|
|
71
|
+
/** Run the harness command inside the session; returns its exit code+output. */
|
|
72
|
+
runHarness(handle: SessionHandle, cmd: string): Promise<CommandResult>
|
|
73
|
+
|
|
74
|
+
/** Gather the session's results (exit code, done marker, log summary). */
|
|
75
|
+
collectResults(handle: SessionHandle): Promise<Record<string, unknown>>
|
|
76
|
+
|
|
77
|
+
/** Tear the session down (delete worktree/container/VM). */
|
|
78
|
+
teardown(handle: SessionHandle): Promise<void>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Local reference implementation ─────────────────────────────────────────
|
|
82
|
+
//
|
|
83
|
+
// `LocalProcessProvider` is the CURRENT behavior behind the interface:
|
|
84
|
+
// spawn a local git worktree (scripts/spawn-parallel-worktrees.sh) + a local
|
|
85
|
+
// harness process (scripts/run-worker.sh). It exists so the abstraction is
|
|
86
|
+
// real and tested locally — cloud providers (see the HetznerDockerProvider
|
|
87
|
+
// sketch in docs/phase6-cloud.md) implement the same contract.
|
|
88
|
+
|
|
89
|
+
/** The harness repo root (parent of src/cloud) — same pattern as the CLI. */
|
|
90
|
+
const HARNESS_ROOT = fileURLToPath(new URL("../..", import.meta.url))
|
|
91
|
+
|
|
92
|
+
export interface LocalProcessProviderOptions {
|
|
93
|
+
/** Override the harness repo root (default: this repo). */
|
|
94
|
+
harnessRoot?: string
|
|
95
|
+
/**
|
|
96
|
+
* Dry-run: record every command instead of executing it (no worktrees, no
|
|
97
|
+
* processes). Used by tests + `--dry-run`-style callers.
|
|
98
|
+
*/
|
|
99
|
+
dryRun?: boolean
|
|
100
|
+
/** Override the spawn script (default: scripts/spawn-parallel-worktrees.sh). */
|
|
101
|
+
spawnScript?: string
|
|
102
|
+
/** Override the run-worker script (default: scripts/run-worker.sh). */
|
|
103
|
+
runWorkerScript?: string
|
|
104
|
+
/** Injectable command runner (tests): returns a fake result instead of spawnSync. */
|
|
105
|
+
run?: (command: string, cwd: string, env: Record<string, string>) => CommandResult
|
|
106
|
+
/** Injectable readiness probe (tests): replaces the .harness.pid poll. */
|
|
107
|
+
waitForReady?: (worktree: string, timeoutMs: number) => Promise<void>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class LocalProcessProvider implements CloudProvider {
|
|
111
|
+
readonly name = "local"
|
|
112
|
+
private readonly harnessRoot: string
|
|
113
|
+
private readonly dryRun: boolean
|
|
114
|
+
private readonly spawnScript: string
|
|
115
|
+
private readonly runWorkerScript: string
|
|
116
|
+
private readonly run: (command: string, cwd: string, env: Record<string, string>) => CommandResult
|
|
117
|
+
private readonly waitForReady?: (worktree: string, timeoutMs: number) => Promise<void>
|
|
118
|
+
|
|
119
|
+
constructor(options: LocalProcessProviderOptions = {}) {
|
|
120
|
+
this.harnessRoot = options.harnessRoot ?? HARNESS_ROOT
|
|
121
|
+
this.dryRun = options.dryRun ?? false
|
|
122
|
+
this.spawnScript = options.spawnScript ?? path.join(this.harnessRoot, "scripts", "spawn-parallel-worktrees.sh")
|
|
123
|
+
this.runWorkerScript = options.runWorkerScript ?? path.join(this.harnessRoot, "scripts", "run-worker.sh")
|
|
124
|
+
this.run =
|
|
125
|
+
options.run ??
|
|
126
|
+
((command, cwd, env) => {
|
|
127
|
+
const res = spawnSync("bash", ["-c", command], {
|
|
128
|
+
cwd,
|
|
129
|
+
env: { ...process.env, ...env },
|
|
130
|
+
encoding: "utf-8",
|
|
131
|
+
})
|
|
132
|
+
const output = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim()
|
|
133
|
+
if (res.status !== 0) {
|
|
134
|
+
return { exitCode: res.status ?? 1, output }
|
|
135
|
+
}
|
|
136
|
+
return { exitCode: 0, output }
|
|
137
|
+
})
|
|
138
|
+
this.waitForReady = options.waitForReady
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The expected worktree path for a handle (repo/.worktrees/<id>). */
|
|
142
|
+
worktreePath(handle: SessionHandle): string {
|
|
143
|
+
if (handle.address) {
|
|
144
|
+
return handle.address
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`LocalProcessProvider: no address on handle ${handle.id}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async spawnWorktreeSession(request: CloudSessionRequest): Promise<SessionHandle> {
|
|
150
|
+
const { repo, issue, worktreeSpec } = request
|
|
151
|
+
const wtPath = path.join(path.resolve(repo), ".worktrees", worktreeSpec.name)
|
|
152
|
+
|
|
153
|
+
// 1. Task file under <repo>/plans/parallel-tasks/ (the spawner copies
|
|
154
|
+
// it into the worktree) — same builder the watcher/orchestrator use.
|
|
155
|
+
if (!this.dryRun) {
|
|
156
|
+
writeTaskFiles(path.resolve(repo), [worktreeSpec], [issue])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Spawn via the EXISTING bash spawner (name:offset:taskfile triples,
|
|
160
|
+
// cwd = repo so `git rev-parse --show-toplevel` resolves).
|
|
161
|
+
const triples = `${worktreeSpec.name}:0:plans/parallel-tasks/${worktreeSpec.taskFile}`
|
|
162
|
+
const command = `bash ${this.spawnScript} ${triples}`
|
|
163
|
+
const result = this.run(command, path.resolve(repo), {
|
|
164
|
+
TARGET_REPO: path.resolve(repo),
|
|
165
|
+
ORCHESTRATOR_MODE: request.env?.ORCHESTRATOR_MODE ?? "code",
|
|
166
|
+
// Operator knobs (spawner no-index guardrail + local-explore phase)
|
|
167
|
+
// flow from the ambient process env; an injected `run` only sees
|
|
168
|
+
// this explicit env, so forward them alongside TARGET_REPO.
|
|
169
|
+
...(process.env.ALLOW_UNINDEXED ? { ALLOW_UNINDEXED: process.env.ALLOW_UNINDEXED } : {}),
|
|
170
|
+
...(process.env.HEADLESSCODE_AUTO_INDEX ? { HEADLESSCODE_AUTO_INDEX: process.env.HEADLESSCODE_AUTO_INDEX } : {}),
|
|
171
|
+
...(process.env.HEADLESSCODE_LOCAL_EXPLORE ? { HEADLESSCODE_LOCAL_EXPLORE: process.env.HEADLESSCODE_LOCAL_EXPLORE } : {}),
|
|
172
|
+
...(process.env.HEADLESSCODE_LOCAL_EXPLORE_MODEL
|
|
173
|
+
? { HEADLESSCODE_LOCAL_EXPLORE_MODEL: process.env.HEADLESSCODE_LOCAL_EXPLORE_MODEL }
|
|
174
|
+
: {}),
|
|
175
|
+
...(process.env.HEADLESSCODE_LOCAL_EXPLORE_MAX_ITERATIONS
|
|
176
|
+
? { HEADLESSCODE_LOCAL_EXPLORE_MAX_ITERATIONS: process.env.HEADLESSCODE_LOCAL_EXPLORE_MAX_ITERATIONS }
|
|
177
|
+
: {}),
|
|
178
|
+
...(process.env.HEADLESSCODE_LOCAL_EXPLORE_CONTEXT_TOKENS
|
|
179
|
+
? { HEADLESSCODE_LOCAL_EXPLORE_CONTEXT_TOKENS: process.env.HEADLESSCODE_LOCAL_EXPLORE_CONTEXT_TOKENS }
|
|
180
|
+
: {}),
|
|
181
|
+
})
|
|
182
|
+
if (result.exitCode !== 0) {
|
|
183
|
+
throw new Error(`LocalProcessProvider: spawn failed (exit ${result.exitCode}): ${result.output}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { id: worktreeSpec.name, provider: this.name, address: wtPath }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async waitReady(handle: SessionHandle): Promise<void> {
|
|
190
|
+
if (this.dryRun) {
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
if (this.waitForReady) {
|
|
194
|
+
await this.waitForReady(this.worktreePath(handle), 30_000)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
// Default readiness probe: the worktree exists AND a worker pid file is
|
|
198
|
+
// present (i.e. runHarness has been started). Poll up to 30s.
|
|
199
|
+
const deadline = Date.now() + 30_000
|
|
200
|
+
for (;;) {
|
|
201
|
+
try {
|
|
202
|
+
const pid = fs.readFileSync(path.join(this.worktreePath(handle), ".harness.pid"), "utf-8").trim()
|
|
203
|
+
if (/^\d+$/.test(pid)) {
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// not ready yet
|
|
208
|
+
}
|
|
209
|
+
if (Date.now() > deadline) {
|
|
210
|
+
throw new Error(`LocalProcessProvider: ${handle.id} not ready within 30s`)
|
|
211
|
+
}
|
|
212
|
+
await sleep(200)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Run the harness in the session (default: scripts/run-worker.sh). */
|
|
217
|
+
async runHarness(handle: SessionHandle, cmd?: string): Promise<CommandResult> {
|
|
218
|
+
const wtPath = this.worktreePath(handle)
|
|
219
|
+
// The spawner copies the task file into each worktree as
|
|
220
|
+
// ORCHESTRATOR_TASK.md, so that is the default run-worker task. Callers
|
|
221
|
+
// can pass any command (e.g. a cloud harness command) instead.
|
|
222
|
+
const command =
|
|
223
|
+
cmd ?? `bash ${this.runWorkerScript} '${wtPath}' ORCHESTRATOR_TASK.md --mode '${process.env.ORCHESTRATOR_MODE ?? "code"}'`
|
|
224
|
+
if (this.dryRun) {
|
|
225
|
+
return { exitCode: 0, output: `[dry-run] ${command}` }
|
|
226
|
+
}
|
|
227
|
+
const result = this.run(command, wtPath, { TARGET_REPO: this.harnessRoot })
|
|
228
|
+
return result
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async collectResults(handle: SessionHandle): Promise<Record<string, unknown>> {
|
|
232
|
+
const wtPath = this.worktreePath(handle)
|
|
233
|
+
const exitCode = readFileInt(path.join(wtPath, ".harness.exit"))
|
|
234
|
+
const done = fs.existsSync(path.join(wtPath, ".harness.done"))
|
|
235
|
+
const summary = tailFile(path.join(wtPath, "harness.log"), 40)
|
|
236
|
+
return { exitCode, done, summary, worktree: wtPath }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async teardown(handle: SessionHandle): Promise<void> {
|
|
240
|
+
if (this.dryRun) {
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const wtPath = this.worktreePath(handle)
|
|
244
|
+
// git worktree remove detaches the worktree cleanly; force in case of
|
|
245
|
+
// uncommitted harness artifacts. Address is <repo>/.worktrees/<name>.
|
|
246
|
+
const repo = path.dirname(path.dirname(wtPath))
|
|
247
|
+
this.run(`git worktree remove '${wtPath}' --force`, repo, {})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Read a file's contents as an integer (undefined on missing/invalid). */
|
|
252
|
+
function readFileInt(file: string): number | undefined {
|
|
253
|
+
try {
|
|
254
|
+
const n = Number(fs.readFileSync(file, "utf-8").trim())
|
|
255
|
+
return Number.isFinite(n) ? n : undefined
|
|
256
|
+
} catch {
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Tail a file's last `maxLines` non-empty lines ("" when unreadable). */
|
|
262
|
+
function tailFile(file: string, maxLines: number): string {
|
|
263
|
+
try {
|
|
264
|
+
const lines = fs.readFileSync(file, "utf-8").split(/\r?\n/).filter((l) => l.trim() !== "")
|
|
265
|
+
return lines.slice(-maxLines).join("\n")
|
|
266
|
+
} catch {
|
|
267
|
+
return ""
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sleep(ms: number): Promise<void> {
|
|
272
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─── Hetzner evaluation hook (superseded by the Docker provider) ─────────────
|
|
276
|
+
//
|
|
277
|
+
// `HetznerDockerProvider` was deliberately a SKETCH ONLY — it documented the
|
|
278
|
+
// shape a real 6.1 cloud provider would take (per docs/phase6-cloud.md) but
|
|
279
|
+
// was NOT implemented against live Hetzner: no API credentials, no Docker
|
|
280
|
+
// SDK, no network.
|
|
281
|
+
//
|
|
282
|
+
// It is now SUPERSEDED by the real `DockerSessionProvider` in
|
|
283
|
+
// src/cloud/docker-provider.ts (docs/multi-tenant-hosting-design.md §5): a
|
|
284
|
+
// working container-per-session implementation of the same `CloudProvider`
|
|
285
|
+
// interface, proven against a real local Docker daemon. This sketch is kept
|
|
286
|
+
// as a backward-compatible marker for the Phase 6.2 evaluation history and
|
|
287
|
+
// for the existing provider contract test; the "reason" now points at the
|
|
288
|
+
// replacement instead of describing an unimplemented future.
|
|
289
|
+
export interface HetznerDockerProviderSketch {
|
|
290
|
+
readonly name: string
|
|
291
|
+
readonly implemented: false
|
|
292
|
+
reason: string
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export const hetznerDockerProviderSketch: HetznerDockerProviderSketch = {
|
|
296
|
+
name: "hetzner-docker",
|
|
297
|
+
implemented: false,
|
|
298
|
+
reason:
|
|
299
|
+
"Evaluation artifact (Phase 6, spec 6.2) superseded by DockerSessionProvider (src/cloud/docker-provider.ts) — see docs/multi-tenant-hosting-design.md.",
|
|
300
|
+
}
|