@gr8ful/spf 0.9.2 → 0.10.1
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 +56 -0
- package/assets/defaults/spf.config.yaml +75 -0
- package/assets/skill/references/config.md +98 -4
- package/dist/chains/index.d.ts +2 -0
- package/dist/chains/index.js +4 -0
- package/dist/cli/commands/doctor.js +339 -2
- package/dist/cli/commands/fanout.d.ts +7 -14
- package/dist/cli/commands/fanout.js +45 -39
- package/dist/cli/commands/loop.d.ts +2 -0
- package/dist/cli/commands/loop.js +198 -0
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/commands/watch.d.ts +29 -1
- package/dist/cli/commands/watch.js +219 -64
- package/dist/cli/index.js +14 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +25 -2
- package/dist/core/agent_flue.js +14 -5
- package/dist/core/agents.d.ts +61 -1
- package/dist/core/agents.js +363 -6
- package/dist/core/data_types.d.ts +316 -0
- package/dist/core/data_types.js +143 -0
- package/dist/core/loop.d.ts +230 -0
- package/dist/core/loop.js +290 -0
- package/dist/core/quality.d.ts +1 -2
- package/dist/core/sandbox.d.ts +236 -0
- package/dist/core/sandbox.js +655 -0
- package/dist/core/sandbox_cloudflare.d.ts +137 -0
- package/dist/core/sandbox_cloudflare.js +505 -0
- package/dist/core/sandbox_opensandbox.d.ts +59 -0
- package/dist/core/sandbox_opensandbox.js +484 -0
- package/dist/core/sandbox_sdk_types.d.ts +171 -0
- package/dist/core/sandbox_sdk_types.js +20 -0
- package/dist/core/watch.d.ts +56 -0
- package/dist/core/watch.js +358 -52
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- package/package.json +1 -1
|
@@ -21,7 +21,9 @@ import { binaryOnPath, parseCli } from "../../core/utils.js";
|
|
|
21
21
|
import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
|
|
22
22
|
import { probeServedOllamaTags, resolveTiering } from "../../core/tiering.js";
|
|
23
23
|
import { isRepoAt } from "../../core/git_helper.js";
|
|
24
|
-
import { allChains, findChain, repoChainProblems, resolveRequiredAgents, resolveRequiredSuites } from "../../chains/index.js";
|
|
24
|
+
import { allChains, findChain, hasCommitStep, repoChainProblems, resolveRequiredAgents, resolveRequiredSuites } from "../../chains/index.js";
|
|
25
|
+
import * as sandbox from "../../core/sandbox.js";
|
|
26
|
+
import { loadOpenSandboxSdk } from "../../core/sandbox_opensandbox.js";
|
|
25
27
|
import { isInteractive } from "../ask.js";
|
|
26
28
|
import { paint as paintPlain } from "../../core/console.js";
|
|
27
29
|
/**
|
|
@@ -194,6 +196,42 @@ async function probeAnthropicMessages(base, model) {
|
|
|
194
196
|
clearTimeout(timer);
|
|
195
197
|
}
|
|
196
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* `GET {base}/health`, parsed for the OpenSandbox control plane's own
|
|
201
|
+
* positive signal (`{"status":"healthy"}`) — checked in ADDITION to the
|
|
202
|
+
* HTTP status, because a 200 from something else entirely (the spike's own
|
|
203
|
+
* "unrelated process on 8090" trap — see check #3/#5 below) is not the same
|
|
204
|
+
* finding as a genuinely healthy control plane; a 404 specifically is that
|
|
205
|
+
* trap's own signature. Never throws.
|
|
206
|
+
*/
|
|
207
|
+
async function probeOpenSandboxHealth(baseUrl) {
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(`${baseUrl}/health`, { signal: controller.signal });
|
|
212
|
+
let healthy = false;
|
|
213
|
+
if (res.status === 200) {
|
|
214
|
+
try {
|
|
215
|
+
const body = (await res.json());
|
|
216
|
+
healthy = body?.status === "healthy";
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
healthy = false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, status: res.status, healthy };
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return { ok: false, error: error.message };
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Loopback host check for #4/#5's "is this control plane reachable from off-box" gate — same shape as the OTel `insecure` check above. */
|
|
232
|
+
function isLoopbackUrl(url) {
|
|
233
|
+
return /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(url);
|
|
234
|
+
}
|
|
197
235
|
export async function doctorCommand(argv) {
|
|
198
236
|
const { options, flags } = parseCli(argv, ["cwd", "config"], ["json", "no-probe"]);
|
|
199
237
|
const report = { ok: true, checks: [] };
|
|
@@ -501,6 +539,248 @@ export async function doctorCommand(argv) {
|
|
|
501
539
|
// actually wrote without a second command.
|
|
502
540
|
check(report, `repo chain "${chain.name}" phases`, true, chain.phases, "info");
|
|
503
541
|
}
|
|
542
|
+
// ── Sandbox backends (SPF #15) ──────────────────────────────────────────
|
|
543
|
+
// Gated on the RESOLVED backend, per check — not on "not local" — the same
|
|
544
|
+
// usesOllamaFlue precedent above (:318-320): a repo running only
|
|
545
|
+
// backend: cloudflare imports no OpenSandbox SDK at all, so a coarse gate
|
|
546
|
+
// would fail #2 on a package that backend never loads. #2-6 fire only when
|
|
547
|
+
// some agent resolves to "opensandbox"; #7-8 only "cloudflare". #1/#9-17
|
|
548
|
+
// are backend-agnostic and roster-wide (agents.validate() above is
|
|
549
|
+
// chain-scoped to the roster only because doctor passed the WHOLE roster
|
|
550
|
+
// as `required` — see the "roster + suites validate" check — but #12-15/
|
|
551
|
+
// #17 are reported here too, on purpose, so the specific rule that failed
|
|
552
|
+
// has its own name rather than one line buried in a combined error).
|
|
553
|
+
{
|
|
554
|
+
const resolvedBackend = (agent) => agent.sandbox ?? cfg.sandbox.backend;
|
|
555
|
+
const nonLocalAgents = cfg.agents.filter((a) => resolvedBackend(a) !== "local");
|
|
556
|
+
const openSandboxAgents = nonLocalAgents.filter((a) => resolvedBackend(a) === "opensandbox");
|
|
557
|
+
const cloudflareAgents = nonLocalAgents.filter((a) => resolvedBackend(a) === "cloudflare");
|
|
558
|
+
// #1 — backend/scope/dirs/image/fanout, plus the resulting sandbox count
|
|
559
|
+
// PER CHAIN this repo can run: distinct agents the chain DISPATCHES
|
|
560
|
+
// (kind: "agent" phases only) that resolve non-local, deduplicated by
|
|
561
|
+
// name, via resolveRequiredAgents — never chain.phases or the whole
|
|
562
|
+
// roster, both of which over-count (engineer/code phases like
|
|
563
|
+
// request/quality/commit dispatch no agent, and a fix loop's revise step
|
|
564
|
+
// reuses its builder's own lease rather than opening a second one).
|
|
565
|
+
check(report, "sandbox.backend", true, `backend=${cfg.sandbox.backend}, scope=${cfg.sandbox.scope} (default: agent), workspace_dir=${cfg.sandbox.workspace_dir}, ` +
|
|
566
|
+
`scratch_dir=${cfg.sandbox.scratch_dir}, handoff_dir=${cfg.sandbox.handoff_dir}, image=${cfg.sandbox.image || "(none)"}, ` +
|
|
567
|
+
`fanout=${cfg.sandbox.fanout}` +
|
|
568
|
+
(nonLocalAgents.length > 0
|
|
569
|
+
? `; per-agent overrides to a non-local backend: ${nonLocalAgents.map((a) => `${a.name}(${resolvedBackend(a)})`).join(", ")}`
|
|
570
|
+
: "; no agent resolves to a non-local backend"), "info");
|
|
571
|
+
if (nonLocalAgents.length > 0) {
|
|
572
|
+
for (const chain of allChains()) {
|
|
573
|
+
const required = resolveRequiredAgents(chain, {});
|
|
574
|
+
const sandboxed = required.filter((name) => {
|
|
575
|
+
const agent = cfg.agents.find((a) => a.name === name);
|
|
576
|
+
return agent !== undefined && resolvedBackend(agent) !== "local";
|
|
577
|
+
});
|
|
578
|
+
if (sandboxed.length === 0)
|
|
579
|
+
continue;
|
|
580
|
+
const perAttempt = cfg.sandbox.scope === "run" ? 1 : sandboxed.length;
|
|
581
|
+
check(report, `sandbox cost: chain "${chain.name}"`, true, `${perAttempt} sandbox(es) per attempt under scope: ${cfg.sandbox.scope} (dispatched agents: ${sandboxed.join(", ")}) — multiply by --n for a fan-out estimate`, "info");
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// #2 — provider SDK present, opensandbox only, hard ✗. MUST go through
|
|
585
|
+
// the adapter's own indirected loader (never doctor's own `import()`
|
|
586
|
+
// with a literal specifier — that fails `tsc` with TS2307 on every
|
|
587
|
+
// machine, same class of bug as a literal specifier in the adapter
|
|
588
|
+
// itself). Same static, network-free misconfiguration class as the
|
|
589
|
+
// "claude CLI" check above (SPF_CLAUDE_CMD on PATH).
|
|
590
|
+
if (openSandboxAgents.length > 0) {
|
|
591
|
+
try {
|
|
592
|
+
await loadOpenSandboxSdk();
|
|
593
|
+
check(report, "opensandbox SDK present", true, "@alibaba-group/opensandbox loaded");
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
check(report, "opensandbox SDK present", false, error.message);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// #3 — opensandbox control plane reachability, info/warn (never a hard
|
|
600
|
+
// failure — the rule every reachability probe in this file follows). A
|
|
601
|
+
// 404 specifically is the spike's own trap: something ELSE listening on
|
|
602
|
+
// that port, not this control plane being down.
|
|
603
|
+
let openSandboxHealthResult = null;
|
|
604
|
+
if (openSandboxAgents.length > 0 && !flags["no-probe"]) {
|
|
605
|
+
const baseUrl = cfg.sandbox.opensandbox.base_url.replace(/\/+$/, "");
|
|
606
|
+
openSandboxHealthResult = await probeOpenSandboxHealth(baseUrl);
|
|
607
|
+
check(report, "opensandbox control plane", true, !openSandboxHealthResult.ok
|
|
608
|
+
? `unreachable: GET ${baseUrl}/health -> ${openSandboxHealthResult.error}`
|
|
609
|
+
: openSandboxHealthResult.healthy
|
|
610
|
+
? `reachable: GET ${baseUrl}/health -> HTTP ${openSandboxHealthResult.status} {"status":"healthy"}`
|
|
611
|
+
: openSandboxHealthResult.status === 404
|
|
612
|
+
? `GET ${baseUrl}/health -> HTTP 404 — usually means something ELSE is listening on this port, not this control plane (the spike hit exactly this on 8090 and remapped to 8095 — a troubleshooting note, not a default; see check #5)`
|
|
613
|
+
: `GET ${baseUrl}/health -> HTTP ${openSandboxHealthResult.status}, not the expected {"status":"healthy"}`, openSandboxHealthResult.ok && openSandboxHealthResult.healthy ? "info" : "warn");
|
|
614
|
+
}
|
|
615
|
+
// #4 — API key posture, opensandbox only. Prints the key NAME only,
|
|
616
|
+
// never the value. warn (not a hard fail) when unset AND the control
|
|
617
|
+
// plane is on a routable (non-loopback) address — an insecure control
|
|
618
|
+
// plane reachable off-box.
|
|
619
|
+
if (openSandboxAgents.length > 0) {
|
|
620
|
+
const keyEnv = cfg.sandbox.opensandbox.api_key_env;
|
|
621
|
+
const set = Boolean(process.env[keyEnv]);
|
|
622
|
+
const baseUrl = cfg.sandbox.opensandbox.base_url;
|
|
623
|
+
const routable = !isLoopbackUrl(baseUrl);
|
|
624
|
+
check(report, "opensandbox API key posture", true, set
|
|
625
|
+
? `${keyEnv} is set`
|
|
626
|
+
: routable
|
|
627
|
+
? `${keyEnv} is NOT set, and sandbox.opensandbox.base_url (${baseUrl}) is a routable address — an insecure control plane reachable off-box`
|
|
628
|
+
: `${keyEnv} is not set (fine for a loopback control plane)`, set || !routable ? "info" : "warn");
|
|
629
|
+
// #5 — [docker].host_ip note. CONDITIONAL, on purpose: this gotcha
|
|
630
|
+
// applies only to a containerized control plane; the README-canonical
|
|
631
|
+
// `uvx opensandbox-server` host process needs nothing here, and
|
|
632
|
+
// warning on plain loopback would be pure noise. Fires only when #3's
|
|
633
|
+
// probe failed/timed out AND base_url is loopback.
|
|
634
|
+
const probeFailed = openSandboxHealthResult !== null && (!openSandboxHealthResult.ok || !openSandboxHealthResult.healthy);
|
|
635
|
+
if (probeFailed && isLoopbackUrl(baseUrl)) {
|
|
636
|
+
check(report, "[docker].host_ip note", true, "if your control plane runs in a container (the compose route), its readiness probes and this host-side client must resolve [docker].host_ip to the SAME reachable address — the LAN IP (`ipconfig getifaddr en0`) is the only value that works for both; getting it wrong produces a 30s \"Egress sidecar did not become ready\". If you are running `uvx opensandbox-server` on the host, ignore this.", "info");
|
|
637
|
+
}
|
|
638
|
+
// #6 — image pre-pull + readiness reminder, info only.
|
|
639
|
+
check(report, "opensandbox image pre-pull", true, `the first sandbox of each kind pulls execd/egress lazily (30-90s cold, sub-2s warm) — this is a READINESS wait, separate from sandbox.request_timeout_seconds (${cfg.sandbox.request_timeout_seconds}s, the client-side HTTP timeout on control-plane calls); confusing the two is how "Egress sidecar did not become ready" gets misdiagnosed as an HTTP timeout. skipHealthCheck is deliberately not used.`, "info");
|
|
640
|
+
}
|
|
641
|
+
// #7 — cloudflare config, hard ✗ if selected and bridge_url/api_token_env
|
|
642
|
+
// unset. Static, knowable without network. `bridge_url`/`api_token_env`
|
|
643
|
+
// are sandbox-wide (not per-agent), so this is naturally roster-wide.
|
|
644
|
+
if (cloudflareAgents.length > 0) {
|
|
645
|
+
const bridgeUrl = cfg.sandbox.cloudflare.bridge_url.trim();
|
|
646
|
+
const tokenEnv = cfg.sandbox.cloudflare.api_token_env.trim();
|
|
647
|
+
check(report, "cloudflare config", Boolean(bridgeUrl) && Boolean(tokenEnv), !bridgeUrl
|
|
648
|
+
? "sandbox.cloudflare.bridge_url is empty — required when the resolved backend is \"cloudflare\""
|
|
649
|
+
: !tokenEnv
|
|
650
|
+
? "sandbox.cloudflare.api_token_env is unset — required when the resolved backend is \"cloudflare\""
|
|
651
|
+
: `bridge_url=${bridgeUrl}, api_token_env=${tokenEnv}${process.env[tokenEnv] ? " (set)" : " (NOT set)"}`);
|
|
652
|
+
// #8 — cloudflare bridge reachability, info/warn. The bridge's own
|
|
653
|
+
// unauthenticated `GET /health` (design doc §4.2/O-2).
|
|
654
|
+
if (bridgeUrl && !flags["no-probe"]) {
|
|
655
|
+
const result = await probeGet(`${bridgeUrl.replace(/\/+$/, "")}/health`);
|
|
656
|
+
check(report, "cloudflare bridge reachability", true, result.ok
|
|
657
|
+
? `reachable: GET ${bridgeUrl}/health -> HTTP ${result.status}`
|
|
658
|
+
: `unreachable: GET ${bridgeUrl}/health -> ${result.error}`, result.ok ? "info" : "warn");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// #9 — env injection posture, one line per NON-LOCAL agent (the resolved
|
|
662
|
+
// key set is per-agent — §3.1). Never prints values, only key NAMES.
|
|
663
|
+
// warn for an allowlisted key absent from the operator env, and warn
|
|
664
|
+
// when env_allowlist is non-empty while egress.default: allow.
|
|
665
|
+
for (const agent of nonLocalAgents) {
|
|
666
|
+
const agentAllow = agent.env_allowlist; // undefined/null = no further narrowing
|
|
667
|
+
const keyNames = [...new Set(agentAllow == null ? cfg.sandbox.env_allowlist : cfg.sandbox.env_allowlist.filter((k) => agentAllow.includes(k)))].sort();
|
|
668
|
+
const missing = keyNames.filter((k) => process.env[k] === undefined);
|
|
669
|
+
const broadEgressWithCreds = keyNames.length > 0 && cfg.sandbox.egress.default === "allow";
|
|
670
|
+
const warn = missing.length > 0 || broadEgressWithCreds;
|
|
671
|
+
check(report, `agent "${agent.name}" sandbox env injection`, true, keyNames.length === 0
|
|
672
|
+
? "no keys injected (sandbox.env_allowlist is empty, or this agent's own env_allowlist narrows it to nothing)"
|
|
673
|
+
: `injects: ${keyNames.join(", ")}` +
|
|
674
|
+
(missing.length > 0 ? ` — NOT set in the operator env: ${missing.join(", ")}` : "") +
|
|
675
|
+
(broadEgressWithCreds ? " — egress.default: allow with live credentials in the sandbox is worth a second look" : ""), warn ? "warn" : "info");
|
|
676
|
+
}
|
|
677
|
+
// #9b — SPF #15 PR B (§6.1/§7): the credential broker's own describe()
|
|
678
|
+
// line, one per non-local agent. `static` (the only registered broker)
|
|
679
|
+
// issues exactly the key set check #9 just printed — reused here rather
|
|
680
|
+
// than re-resolved, and reused rather than a real `broker.issue(spec)`
|
|
681
|
+
// call: doctor has no live SandboxSpec (no Run exists), and deliberately
|
|
682
|
+
// never reads an operator-env VALUE just to build this string.
|
|
683
|
+
if (nonLocalAgents.length > 0) {
|
|
684
|
+
const brokerKnown = sandbox.KNOWN_CREDENTIAL_BROKER_IDS.includes(cfg.sandbox.credentials.broker);
|
|
685
|
+
for (const agent of nonLocalAgents) {
|
|
686
|
+
const agentAllow = agent.env_allowlist;
|
|
687
|
+
const keyNames = agentAllow == null ? cfg.sandbox.env_allowlist : cfg.sandbox.env_allowlist.filter((k) => agentAllow.includes(k));
|
|
688
|
+
check(report, `agent "${agent.name}" credential grant`, brokerKnown, brokerKnown
|
|
689
|
+
? sandbox.describeStaticCredentials(keyNames)
|
|
690
|
+
: `sandbox.credentials.broker ${JSON.stringify(cfg.sandbox.credentials.broker)} is not registered — known: ${sandbox.KNOWN_CREDENTIAL_BROKER_IDS.join(", ")}`, brokerKnown ? "info" : undefined);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// #10 — egress policy, verbatim, info only.
|
|
694
|
+
if (nonLocalAgents.length > 0) {
|
|
695
|
+
check(report, "sandbox egress policy", true, `default=${cfg.sandbox.egress.default}, allow=[${cfg.sandbox.egress.allow.join(", ")}]`, "info");
|
|
696
|
+
}
|
|
697
|
+
// #11 — live leases. The lease JOURNAL (<data_dir>/sandboxes.json,
|
|
698
|
+
// design doc §4.3) is not written by this build's core sandbox module —
|
|
699
|
+
// only the in-process registry exists, which is always empty inside
|
|
700
|
+
// `spf doctor`'s own process (doctor never creates a lease itself), so
|
|
701
|
+
// this cannot yet report an orphan left by a killed run. Reported
|
|
702
|
+
// honestly rather than fabricating a journal read.
|
|
703
|
+
if (nonLocalAgents.length > 0) {
|
|
704
|
+
const live = sandbox.leases();
|
|
705
|
+
check(report, "sandbox live leases", true, `${live.length} in THIS process (the lease journal at <data_dir>/sandboxes.json is not implemented in this build — cross-process/orphan visibility via \`spf sandbox list\` is not yet available)`, "info");
|
|
706
|
+
}
|
|
707
|
+
// #12 — claude_code x remote backend, hard ✗, roster-wide (validate()
|
|
708
|
+
// above is chain-scoped to whatever `required` it was called with —
|
|
709
|
+
// doctor happens to pass the whole roster there too, but this is named
|
|
710
|
+
// separately so the specific rule that failed has its own line).
|
|
711
|
+
for (const agent of cfg.agents) {
|
|
712
|
+
const backend = resolvedBackend(agent);
|
|
713
|
+
if (agent.coding_agent === "claude_code" && backend !== "local") {
|
|
714
|
+
check(report, `agent "${agent.name}" coding_agent x sandbox`, false, `coding_agent "claude_code" cannot use sandbox backend ${JSON.stringify(backend)} — it always spawns a host process with no sandbox seam; set agent.sandbox: local (or sandbox.backend: local) for this agent`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// #13 — image set, and able to run the transport. Hard ✗ only when
|
|
718
|
+
// empty on a resolved opensandbox backend (the key has no default and
|
|
719
|
+
// is required for that backend); otherwise a warn on a known-git-less
|
|
720
|
+
// base image.
|
|
721
|
+
if (openSandboxAgents.length > 0) {
|
|
722
|
+
const image = cfg.sandbox.image.trim();
|
|
723
|
+
if (!image) {
|
|
724
|
+
check(report, "sandbox.image", false, 'sandbox.image is empty — required when the resolved backend is "opensandbox": the image MUST contain git, tar and base64 (the workspace transport shells out to all three)');
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
const knownGitless = /python:.*-slim|^alpine(:|$)/.test(image) && !cfg.sandbox.setup.some((cmd) => /\bgit\b/.test(cmd));
|
|
728
|
+
check(report, "sandbox.image", true, knownGitless
|
|
729
|
+
? `${image} is a known git-less base — the mandatory create-time preflight (git/tar/base64) will fail unless sandbox.setup installs git`
|
|
730
|
+
: `${image} (git/tar/base64 verified at create time by the mandatory preflight, not here)`, knownGitless ? "warn" : "info");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// #14 — handoff/scratch dir placement, hard ✗, roster-wide. Same rule,
|
|
734
|
+
// same reason as validateSandboxConfig's (agents.ts) — duplicated here
|
|
735
|
+
// in miniature because that function is not exported, and this is a
|
|
736
|
+
// static, network-free, config-shape check that doesn't need a per-agent
|
|
737
|
+
// loop (workspace_dir/handoff_dir/scratch_dir are sandbox-wide, not
|
|
738
|
+
// per-agent). Reported here as well as in validate() because the
|
|
739
|
+
// failure it prevents (a PermissionBreach on turn 1 of every sandboxed
|
|
740
|
+
// run) is expensive and its cause is non-obvious.
|
|
741
|
+
if (nonLocalAgents.length > 0) {
|
|
742
|
+
const posixUnderOrEqual = (root, child) => {
|
|
743
|
+
const normRoot = path.posix.normalize(root).replace(/\/+$/, "") || "/";
|
|
744
|
+
const normChild = path.posix.normalize(child);
|
|
745
|
+
return normChild === normRoot || normChild.startsWith(`${normRoot}/`);
|
|
746
|
+
};
|
|
747
|
+
const placementProblems = [];
|
|
748
|
+
for (const key of ["handoff_dir", "scratch_dir"]) {
|
|
749
|
+
if (posixUnderOrEqual(cfg.sandbox.workspace_dir, cfg.sandbox[key])) {
|
|
750
|
+
placementProblems.push(`sandbox.${key} (${cfg.sandbox[key]}) is inside sandbox.workspace_dir (${cfg.sandbox.workspace_dir})`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (path.posix.normalize(cfg.sandbox.handoff_dir) === path.posix.normalize(cfg.sandbox.scratch_dir)) {
|
|
754
|
+
placementProblems.push(`sandbox.scratch_dir must not equal sandbox.handoff_dir (${cfg.sandbox.handoff_dir})`);
|
|
755
|
+
}
|
|
756
|
+
check(report, "sandbox handoff/scratch dir placement", placementProblems.length === 0, placementProblems.length === 0
|
|
757
|
+
? `handoff_dir=${cfg.sandbox.handoff_dir}, scratch_dir=${cfg.sandbox.scratch_dir}, both outside workspace_dir=${cfg.sandbox.workspace_dir}`
|
|
758
|
+
: placementProblems.join("; "));
|
|
759
|
+
}
|
|
760
|
+
// #15 — scope: run x divergent env, hard ✗, roster-wide. validate()
|
|
761
|
+
// above is chain-scoped to whatever `required` it ran with; this calls
|
|
762
|
+
// the SAME exported function over the WHOLE roster, so a repo with
|
|
763
|
+
// several chains learns scope: run is unsafe for this roster before
|
|
764
|
+
// picking the one chain that would trip it.
|
|
765
|
+
const runScopeProblems = agents.validateSandboxRunScope(cfg, cfg.agents.map((a) => a.name));
|
|
766
|
+
if (cfg.sandbox.scope === "run") {
|
|
767
|
+
check(report, "sandbox scope: run env divergence (roster-wide)", runScopeProblems.length === 0, runScopeProblems.length === 0 ? "every non-local agent in the roster resolves to the same backend + env key set" : runScopeProblems.join("; "));
|
|
768
|
+
}
|
|
769
|
+
// #16 — submodules present, warn, static, network-free. Deliberately a
|
|
770
|
+
// warning, not a validate() failure: a submodule-bearing repo must still
|
|
771
|
+
// be able to run (the superproject alone is seeded/synced/extracted).
|
|
772
|
+
if (nonLocalAgents.length > 0 && existsSync(path.join(anchor.repo_root, ".gitmodules"))) {
|
|
773
|
+
check(report, "sandbox + submodules", true, `${anchor.repo_root} has .gitmodules — submodule content is not seeded, synced, or extracted by the sandbox transport (the superproject only is)`, "warn");
|
|
774
|
+
}
|
|
775
|
+
// #17 — timeout relation, hard ✗, roster-wide, network-free. The
|
|
776
|
+
// DEFAULT deadline must fit inside the client timeout; a caller-supplied
|
|
777
|
+
// timeoutMs still passes through unmodified (no clamp — sandbox.ts).
|
|
778
|
+
if (nonLocalAgents.length > 0) {
|
|
779
|
+
check(report, "sandbox timeout relation", cfg.sandbox.request_timeout_seconds >= cfg.sandbox.exec_timeout_seconds, cfg.sandbox.request_timeout_seconds >= cfg.sandbox.exec_timeout_seconds
|
|
780
|
+
? `request_timeout_seconds=${cfg.sandbox.request_timeout_seconds} >= exec_timeout_seconds=${cfg.sandbox.exec_timeout_seconds} — a model-requested timeout longer than exec_timeout_seconds is honored, not clamped, exactly as on backend: local`
|
|
781
|
+
: `sandbox.request_timeout_seconds (${cfg.sandbox.request_timeout_seconds}) must be >= sandbox.exec_timeout_seconds (${cfg.sandbox.exec_timeout_seconds})`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
504
784
|
// A protected_files pattern matching nothing anywhere in the trace is
|
|
505
785
|
// usually a stale convention (e.g. an old adws/... pattern) that silently
|
|
506
786
|
// stopped protecting anything.
|
|
@@ -582,9 +862,66 @@ export async function doctorCommand(argv) {
|
|
|
582
862
|
// that is entirely built around a review loop, the moment its reviewer
|
|
583
863
|
// is renamed.
|
|
584
864
|
const hasReviewer = watchChain.phases.includes("(revise)");
|
|
585
|
-
|
|
865
|
+
// `hasCommitStep` (structural: the derived phase string shows a real
|
|
866
|
+
// `git(commit` step), not a substring match on the word "commit" — the
|
|
867
|
+
// same predicate `watch.fanout commit phase` below uses, so a chain
|
|
868
|
+
// whose phase LABEL merely contains "commit" without an actual commit
|
|
869
|
+
// step can't get "includes" here and a hard ✗ on the very next line.
|
|
870
|
+
const hasCommitPhase = hasCommitStep(watchChain.phases);
|
|
586
871
|
check(report, "watch.chain review posture", true, `${hasReviewer ? "includes" : "does not include"} a reviewer; ${hasCommitPhase ? "includes" : "does not include"} a commit phase`);
|
|
587
872
|
}
|
|
873
|
+
// watch.fanout — the best-of-N multiplier `watch.fanout.n`/
|
|
874
|
+
// `watch.fanout.concurrency` drive (see `WatchFanoutConfigSchema`'s doc
|
|
875
|
+
// comment). This line always prints, even at the default n=1: n=1 is a
|
|
876
|
+
// no-op for the running DAEMON (`core/watch.ts`'s design doc, §7), but
|
|
877
|
+
// not a total no-op at the CLI surface — an operator should be able to
|
|
878
|
+
// see the posture either way. b-d below only fire when n > 1.
|
|
879
|
+
{
|
|
880
|
+
const n = cfg.watch.fanout.n;
|
|
881
|
+
const a = cfg.watch.fanout.concurrency;
|
|
882
|
+
const c = cfg.watch.concurrency;
|
|
883
|
+
if (n <= 1) {
|
|
884
|
+
check(report, "watch.fanout", true, "n=1 — single dispatch per claimed issue (best-of-N off)", "info");
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
check(report, "watch.fanout", true, `n=${n}, attempts in flight per issue=${a}; with watch.concurrency=${c} that is up to ` +
|
|
888
|
+
`${c} x ${a} = ${c * a} chain runs in flight, up to ${c} x ${n} = ${c * n} worktrees on disk at once ` +
|
|
889
|
+
`(a successful attempt's tree is kept until every sibling settles), and ${n} chain runs per claimed issue`, "info");
|
|
890
|
+
if (watchChain) {
|
|
891
|
+
// b — only when SOME dispatched agent of watch.chain resolves
|
|
892
|
+
// non-local. Reuses check #1's own resolvedBackend +
|
|
893
|
+
// resolveRequiredAgents computation (recomputed here, scoped to
|
|
894
|
+
// THIS chain rather than every chain) so the two can never
|
|
895
|
+
// disagree about what "resolves non-local" means.
|
|
896
|
+
const resolvedBackendForFanout = (agent) => agent.sandbox ?? cfg.sandbox.backend;
|
|
897
|
+
const required = resolveRequiredAgents(watchChain, cfg.watch.chain_options);
|
|
898
|
+
const sandboxed = required.filter((name) => {
|
|
899
|
+
const agent = cfg.agents.find((a2) => a2.name === name);
|
|
900
|
+
return agent !== undefined && resolvedBackendForFanout(agent) !== "local";
|
|
901
|
+
});
|
|
902
|
+
if (sandboxed.length > 0) {
|
|
903
|
+
const perAttempt = cfg.sandbox.scope === "run" ? 1 : sandboxed.length;
|
|
904
|
+
check(report, "watch.fanout sandbox cost", true, `${perAttempt} sandbox(es) per attempt under scope: ${cfg.sandbox.scope} (dispatched agents: ${sandboxed.join(", ")}) ` +
|
|
905
|
+
`-> up to ${c} x ${a} x ${perAttempt} = ${c * a * perAttempt} in flight; ${n} x ${perAttempt} = ${n * perAttempt} sandbox creations per claimed issue`, "info");
|
|
906
|
+
}
|
|
907
|
+
// c — hard ✗ when n > 1 and watch.chain has no commit phase.
|
|
908
|
+
// Mirrors `spf watch`'s own startup refusal, so `spf doctor`
|
|
909
|
+
// catches this before the daemon is ever started.
|
|
910
|
+
check(report, "watch.fanout commit phase", hasCommitStep(watchChain.phases), hasCommitStep(watchChain.phases)
|
|
911
|
+
? `watch.chain "${cfg.watch.chain}" has a commit phase`
|
|
912
|
+
: `watch.fanout.n is ${n} but watch.chain "${cfg.watch.chain}" has no commit phase (${watchChain.phases}) — ` +
|
|
913
|
+
`best-of-N discards every losing attempt's worktree, uncommitted work included; see \`spf watch\`'s own startup refusal for the full explanation`);
|
|
914
|
+
}
|
|
915
|
+
// d — warn, not a hard failure: an unbounded run is the documented
|
|
916
|
+
// default (every reachability/posture check in this file is
|
|
917
|
+
// info/warn rather than a hard failure). A daemon repeats this bill
|
|
918
|
+
// every tick, though, which is what earns the warn over check #1's
|
|
919
|
+
// plain info line.
|
|
920
|
+
if (cfg.defaults.max_run_cost === undefined && cfg.defaults.max_run_tokens === undefined) {
|
|
921
|
+
check(report, "watch.fanout budget ceiling", true, `no defaults.max_run_cost / defaults.max_run_tokens configured — every claimed issue runs ${n} attempts to completion, unbounded`, "warn");
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
588
925
|
if (cfg.watch.refine.enabled) {
|
|
589
926
|
const refineChain = findChain(cfg.watch.refine.chain);
|
|
590
927
|
check(report, "watch.refine.chain", Boolean(refineChain), refineChain ? `${cfg.watch.refine.chain}${refineChain.source ? ` (repo: ${repoChainLabel(refineChain.source)})` : ""}` : `"${cfg.watch.refine.chain}" is not a registered chain`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { excludeSpfDataFromGit } from "../../core/worktree_data.ts";
|
|
1
2
|
/**
|
|
2
3
|
* Wire `<worktreePath>/.spf/data` to the main repo's `dataDir` — same
|
|
3
4
|
* purpose as `cli/commands/watch.ts`'s own `linkDataDir`: without it an
|
|
@@ -22,19 +23,11 @@
|
|
|
22
23
|
*/
|
|
23
24
|
export declare function linkFanoutDataDir(worktreePath: string, dataDir: string): void;
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* Following the printed `git merge <winner-branch>` instruction then
|
|
31
|
-
* fast-forwards the symlink straight into the MAIN repo, replacing its real
|
|
32
|
-
* `.spf/data` directory with a symlink pointing back at itself — destroying
|
|
33
|
-
* the trace db (`ls .spf/data` becomes `ELOOP`). `info/exclude` is
|
|
34
|
-
* per-worktree, local-only, and idempotent to append to — the opposite of
|
|
35
|
-
* adding a pattern to a tracked `.gitignore`, which would ship this
|
|
36
|
-
* workaround into every clone for a symlink only `spf fanout` itself ever
|
|
37
|
-
* creates.
|
|
26
|
+
* Re-exported so existing callers (`src/test/fanout_cli.test.ts`,
|
|
27
|
+
* `src/test/sandbox_fanout.test.ts`) keep compiling with no edit — the
|
|
28
|
+
* function itself moved to `core/worktree_data.ts` (§6.6) so
|
|
29
|
+
* `cli/commands/watch.ts`'s own `linkDataDir` can call it too without
|
|
30
|
+
* importing this command module.
|
|
38
31
|
*/
|
|
39
|
-
export
|
|
32
|
+
export { excludeSpfDataFromGit };
|
|
40
33
|
export declare function fanoutCommand(argv: string[]): Promise<number>;
|
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
* makes N machine-written candidates safe to have produced at all.
|
|
22
22
|
*/
|
|
23
23
|
import { spawnSync } from "node:child_process";
|
|
24
|
-
import {
|
|
24
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import * as agents from "../../core/agents.js";
|
|
28
28
|
import * as paths from "../../core/paths.js";
|
|
29
29
|
import { isRepoAt, makeGit } from "../../core/git_helper.js";
|
|
30
30
|
import { ABORTED_EXIT, attemptAdwId, runBestOf, ZERO_METRICS, } from "../../core/fanout.js";
|
|
31
|
-
import { findChain, runChain as runChainDef } from "../../chains/index.js";
|
|
31
|
+
import { findChain, hasCommitStep, runChain as runChainDef } from "../../chains/index.js";
|
|
32
|
+
import { withRunScope } from "../../core/sandbox.js";
|
|
33
|
+
import { excludeSpfDataFromGit } from "../../core/worktree_data.js";
|
|
32
34
|
import { SfDb } from "../../ui/server/db.js";
|
|
33
35
|
import { newId, parseCli, resolvePrompt } from "../../core/utils.js";
|
|
34
36
|
import { isInteractive } from "../ask.js";
|
|
@@ -43,10 +45,6 @@ const MAX_N = 8;
|
|
|
43
45
|
const USAGE = `usage: spf fanout <chain> "<prompt or path/to/prompt.md>" [--n 3] [--concurrency N] ` +
|
|
44
46
|
`[--base <branch>] [--adw-id <id>] [--first-success] [--config <path>] [--cwd <dir>]\n` +
|
|
45
47
|
` spf fanout --clean <base-adw-id> [--cwd <dir>] # remove leftover worktrees/branches from a killed or discarded run`;
|
|
46
|
-
/** Chains eligible to fan out: their derived phase string must show at least one commit step. See the check at dispatch time for why. */
|
|
47
|
-
function hasCommitStep(phases) {
|
|
48
|
-
return phases.includes("git(commit");
|
|
49
|
-
}
|
|
50
48
|
/** `42.1s` / `3m 07s` — a wall time a human reads, not a millisecond count. */
|
|
51
49
|
function formatDuration(ms) {
|
|
52
50
|
const seconds = ms / 1000;
|
|
@@ -132,32 +130,13 @@ export function linkFanoutDataDir(worktreePath, dataDir) {
|
|
|
132
130
|
excludeSpfDataFromGit(worktreePath);
|
|
133
131
|
}
|
|
134
132
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* Following the printed `git merge <winner-branch>` instruction then
|
|
141
|
-
* fast-forwards the symlink straight into the MAIN repo, replacing its real
|
|
142
|
-
* `.spf/data` directory with a symlink pointing back at itself — destroying
|
|
143
|
-
* the trace db (`ls .spf/data` becomes `ELOOP`). `info/exclude` is
|
|
144
|
-
* per-worktree, local-only, and idempotent to append to — the opposite of
|
|
145
|
-
* adding a pattern to a tracked `.gitignore`, which would ship this
|
|
146
|
-
* workaround into every clone for a symlink only `spf fanout` itself ever
|
|
147
|
-
* creates.
|
|
133
|
+
* Re-exported so existing callers (`src/test/fanout_cli.test.ts`,
|
|
134
|
+
* `src/test/sandbox_fanout.test.ts`) keep compiling with no edit — the
|
|
135
|
+
* function itself moved to `core/worktree_data.ts` (§6.6) so
|
|
136
|
+
* `cli/commands/watch.ts`'s own `linkDataDir` can call it too without
|
|
137
|
+
* importing this command module.
|
|
148
138
|
*/
|
|
149
|
-
export
|
|
150
|
-
const resolved = spawnSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: worktreePath, encoding: "utf-8" });
|
|
151
|
-
if (resolved.status !== 0)
|
|
152
|
-
return; // best-effort: worst case is the pre-existing staging risk, not a crash
|
|
153
|
-
const excludePath = path.resolve(worktreePath, resolved.stdout.trim());
|
|
154
|
-
const line = ".spf/data";
|
|
155
|
-
const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf-8") : "";
|
|
156
|
-
if (existing.split("\n").some((l) => l.trim() === line))
|
|
157
|
-
return;
|
|
158
|
-
mkdirSync(path.dirname(excludePath), { recursive: true });
|
|
159
|
-
appendFileSync(excludePath, `${existing && !existing.endsWith("\n") ? "\n" : ""}${line}\n`);
|
|
160
|
-
}
|
|
139
|
+
export { excludeSpfDataFromGit };
|
|
161
140
|
/**
|
|
162
141
|
* `spf fanout --clean <base-adw-id>` — the cleanup half of the deterministic
|
|
163
142
|
* naming, for the two cases nothing else reaches: a run killed before its
|
|
@@ -170,6 +149,16 @@ export function excludeSpfDataFromGit(worktreePath) {
|
|
|
170
149
|
* explicitly a "the trees are trash now" command, unlike a normal run's own
|
|
171
150
|
* cleanup, which never force-removes a dirty tree it didn't already decide
|
|
172
151
|
* to discard (see `git_helper.ts`'s `createRunWorktree`).
|
|
152
|
+
*
|
|
153
|
+
* Scans BOTH `~/.spf/fanout/<basename>/worktrees` (this command's own attempt
|
|
154
|
+
* trees) AND `~/.spf/watch/<basename>/worktrees` (`spf watch`'s fan-out lane,
|
|
155
|
+
* `watch.fanout.n > 1` — see `core/watch.ts`'s design doc): the attempt naming
|
|
156
|
+
* scheme (`fanout-<base>-<i>` / `spf/fanout/<base>-<i>`) is identical either
|
|
157
|
+
* way, watch's automatic pre-sweep only ever reaches an issue it re-claims,
|
|
158
|
+
* and an issue `reconcileOrphans` gave up on permanently is never claimed
|
|
159
|
+
* again — so its attempt trees, and a §8.7 salt cap-fallback base's debris
|
|
160
|
+
* (whose random suffix the pre-sweep cannot enumerate by construction), have
|
|
161
|
+
* no other operator-facing cleanup tool.
|
|
173
162
|
*/
|
|
174
163
|
function cleanFanoutAttempts(baseAdwId, cwdOpt) {
|
|
175
164
|
const anchor = paths.resolveAnchor(cwdOpt);
|
|
@@ -177,10 +166,15 @@ function cleanFanoutAttempts(baseAdwId, cwdOpt) {
|
|
|
177
166
|
console.error(`${anchor.repo_root} is not a git repository`);
|
|
178
167
|
return 1;
|
|
179
168
|
}
|
|
180
|
-
const
|
|
169
|
+
const worktreesDirs = [
|
|
170
|
+
path.join(homedir(), ".spf", "fanout", path.basename(anchor.repo_root), "worktrees"),
|
|
171
|
+
path.join(homedir(), ".spf", "watch", path.basename(anchor.repo_root), "worktrees"),
|
|
172
|
+
];
|
|
181
173
|
const git = makeGit(anchor.repo_root);
|
|
182
174
|
let removed = 0;
|
|
183
|
-
|
|
175
|
+
for (const worktreesDir of worktreesDirs) {
|
|
176
|
+
if (!existsSync(worktreesDir))
|
|
177
|
+
continue;
|
|
184
178
|
for (const entry of readdirSync(worktreesDir)) {
|
|
185
179
|
if (!entry.startsWith(`fanout-${baseAdwId}-`))
|
|
186
180
|
continue;
|
|
@@ -251,11 +245,14 @@ export async function fanoutCommand(argv) {
|
|
|
251
245
|
const cfg = agents.loadConfig(configPaths);
|
|
252
246
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
253
247
|
const git = makeGit(anchor.repo_root);
|
|
254
|
-
// Reused rather than given a knob of its own: `watch.concurrency`
|
|
255
|
-
// 2) is already this repo's answer to "how many
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
248
|
+
// Reused rather than given a knob of its own: `watch.fanout.concurrency`
|
|
249
|
+
// (default 2) is already this repo's answer to "how many ATTEMPTS of one
|
|
250
|
+
// prompt may run at once" — the exact thing `--concurrency` controls here —
|
|
251
|
+
// and it is the same machine, the same SQLite and the same agent quota
|
|
252
|
+
// either way. NOT `watch.concurrency`, which counts ISSUES in flight under
|
|
253
|
+
// `spf watch` and means something different (see `WatchFanoutConfigSchema`'s
|
|
254
|
+
// doc comment). `--concurrency` overrides it for one invocation.
|
|
255
|
+
const concurrency = options["concurrency"] ? Number.parseInt(options["concurrency"], 10) : cfg.watch.fanout.concurrency;
|
|
259
256
|
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
260
257
|
console.error(`--concurrency must be a positive integer (got ${JSON.stringify(options["concurrency"])})`);
|
|
261
258
|
return 1;
|
|
@@ -353,7 +350,7 @@ export async function fanoutCommand(argv) {
|
|
|
353
350
|
unattended: true,
|
|
354
351
|
chain_source: chain.source,
|
|
355
352
|
};
|
|
356
|
-
return runChainDef(chain, ctx);
|
|
353
|
+
return withRunScope(dispatch.adwId, () => runChainDef(chain, ctx));
|
|
357
354
|
};
|
|
358
355
|
// Best-effort operator guidance on an abnormal exit — NOT a real
|
|
359
356
|
// cancellation. Every code phase runs through a synchronous `spawnSync`
|
|
@@ -428,6 +425,15 @@ export async function fanoutCommand(argv) {
|
|
|
428
425
|
console.log(`basis: ${result.basis}`);
|
|
429
426
|
console.log(` (selection basis: succeeded > fewest gate failures > most gate passes > lowest cost > ` +
|
|
430
427
|
`fewest tokens > lowest wall time (contended under concurrency, last resort) > adw_id)`);
|
|
428
|
+
// SPF #15 PR B, design §5.5 — the one line of printed posture sandboxing
|
|
429
|
+
// adds to this contract. `sandbox.fanout: "sandbox"` rides the ordinary
|
|
430
|
+
// per-attempt SandboxSpec (each attempt's own worktree/adw_id — see
|
|
431
|
+
// core/agents.ts's sandboxSpecFor); this line just says so, honestly:
|
|
432
|
+
// the winner's WORKTREE and BRANCH are still local, no matter where the
|
|
433
|
+
// attempts' agent calls ran.
|
|
434
|
+
if (cfg.sandbox.fanout === "sandbox" && cfg.sandbox.backend !== "local") {
|
|
435
|
+
console.log(`attempts ran in ${cfg.sandbox.backend} sandboxes; branch is local`);
|
|
436
|
+
}
|
|
431
437
|
console.log(`kept: ${result.winner.worktree}`);
|
|
432
438
|
console.log(`\nSPF does not merge the winner — you do:`);
|
|
433
439
|
console.log(` git merge ${result.winner.branch} # or: git cherry-pick <sha> / git diff ${baseBranch}..${result.winner.branch}`);
|