@gr8ful/spf 0.9.2 → 0.10.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.
Files changed (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +354 -51
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. 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
- const hasCommitPhase = watchChain.phases.includes("commit");
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
- * Keep the `.spf/data` symlink invisible to `git status`/`git add -A` in the
26
- * attempt's own worktree. `.spf/data/` a trailing-slash DIRECTORY pattern,
27
- * the shape this repo's own gitignore uses — does NOT match a SYMLINK of
28
- * the same name, so without this, `git status` shows `?? .spf/data` and a
29
- * committing chain's `git_helper.commitAll` (`git add -A`) stages it.
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 declare function excludeSpfDataFromGit(worktreePath: string): void;
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 { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync } from "node:fs";
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
- * Keep the `.spf/data` symlink invisible to `git status`/`git add -A` in the
136
- * attempt's own worktree. `.spf/data/` a trailing-slash DIRECTORY pattern,
137
- * the shape this repo's own gitignore uses — does NOT match a SYMLINK of
138
- * the same name, so without this, `git status` shows `?? .spf/data` and a
139
- * committing chain's `git_helper.commitAll` (`git add -A`) stages it.
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 function excludeSpfDataFromGit(worktreePath) {
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 worktreesDir = path.join(homedir(), ".spf", "fanout", path.basename(anchor.repo_root), "worktrees");
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
- if (existsSync(worktreesDir)) {
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` (default
255
- // 2) is already this repo's answer to "how many chains may run at once
256
- // here", and it is the same machine, the same SQLite and the same agent
257
- // quota either way. `--concurrency` overrides it for one invocation.
258
- const concurrency = options["concurrency"] ? Number.parseInt(options["concurrency"], 10) : cfg.watch.concurrency;
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}`);
@@ -0,0 +1,2 @@
1
+ export declare const USAGE: string;
2
+ export declare function loopCommand(argv: string[]): Promise<number>;