@gr8ful/spf 0.9.1 → 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.
- 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/cli/interview.js +40 -16
- 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 +354 -51
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spf loop <chain> "<goal>" --until-suite <name> --max N` — the wiring half
|
|
3
|
+
* of `core/loop.ts` (which owns the driver, the ledger, and the breakers):
|
|
4
|
+
* config, the issue fetch for `--issue`, the per-iteration `runChain`
|
|
5
|
+
* dispatch, the sqlite readback, and the final table. Same split as
|
|
6
|
+
* `core/fanout.ts` / `cli/commands/fanout.ts` for the same reason — `core/`
|
|
7
|
+
* stays out of `src/chains/`'s dependency direction, so the driver is
|
|
8
|
+
* testable with no chains, no agents, no real git.
|
|
9
|
+
*
|
|
10
|
+
* `--issue <id>` resolves the SAME `IssueProvider` `spf watch` does
|
|
11
|
+
* (`resolveIssueProvider`, exported from `watch.ts` for exactly this reuse)
|
|
12
|
+
* and builds the goal prompt the same way `core/watch.ts`'s `runIssue` does
|
|
13
|
+
* — `${issue.title}\n\n${issue.body}`.trim() — so a ticket-pointed loop and
|
|
14
|
+
* a hand-typed one share one prompt convention. This is deliberately the
|
|
15
|
+
* ONLY thing `--issue` does: it does not claim, label, or comment on the
|
|
16
|
+
* issue, and no daemon watches it. Filing the goal AS a ticket the daemon
|
|
17
|
+
* picks up on its own is a different, larger feature (a `goal-ready` watch
|
|
18
|
+
* lane) that needs its own crash-safe marker schema and is not this.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import * as agents from "../../core/agents.js";
|
|
22
|
+
import * as paths from "../../core/paths.js";
|
|
23
|
+
import * as quality from "../../core/quality.js";
|
|
24
|
+
import { makeGit } from "../../core/git_helper.js";
|
|
25
|
+
import { cumulativeSpend, resolveGoalId, runLoop, summarize, } from "../../core/loop.js";
|
|
26
|
+
import { findChain, runChain as runChainDef } from "../../chains/index.js";
|
|
27
|
+
import { SfDb } from "../../ui/server/db.js";
|
|
28
|
+
import { parseCli, resolvePrompt } from "../../core/utils.js";
|
|
29
|
+
import { isInteractive } from "../ask.js";
|
|
30
|
+
import { resolveIssueProvider } from "./watch.js";
|
|
31
|
+
/**
|
|
32
|
+
* Just enough of `Run` for `quality.resolveSuite`/`runSuite` (`RunLike` in
|
|
33
|
+
* `core/quality.ts`) to work against a real filesystem/console without a
|
|
34
|
+
* full agent-capable `Run` — this driver never opens an agent phase itself
|
|
35
|
+
* (`runChainDef` already did, inside the chain), it only needs to run one
|
|
36
|
+
* more deterministic check afterward. `phases: [{seq: 0, ...}]` gives
|
|
37
|
+
* `checkDir`'s `run.phases[run.phases.length - 1].seq` something real to
|
|
38
|
+
* read, mirroring a genuine single-phase run rather than faking the type.
|
|
39
|
+
*/
|
|
40
|
+
function qualityRunLike(input) {
|
|
41
|
+
return {
|
|
42
|
+
cfg: { quality: input.cfg.quality },
|
|
43
|
+
phases: [{ phase_id: `${input.adwId}_00_loop_stop`, adw_id: input.adwId, seq: 0, params: { name: "loop_stop", kind: "code", owner: "quality", description: "loop stop check", retries: 0 }, status: "running", attempt: 0 }],
|
|
44
|
+
context_handoff_dir: input.contextHandoffDir,
|
|
45
|
+
repo_root: input.repoRoot,
|
|
46
|
+
console: { note: (message) => console.log(`[spf] loop ${message}`) },
|
|
47
|
+
tracer: { event: () => "" },
|
|
48
|
+
adw_id: input.adwId,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const KNOWN_OPTIONS = ["config", "cwd", "issue", "until-suite", "max", "max-cost", "max-tokens", "stuck-after", "min-interval-ms", "goal-id", "base"];
|
|
52
|
+
export const USAGE = `usage: spf loop <chain> "<goal or path/to/goal.md>" --until-suite <name> --max <N> [--issue <id>] ` +
|
|
53
|
+
`[--max-cost <usd>] [--max-tokens <n>] [--stuck-after <n>=3] [--min-interval-ms <n>=0] [--goal-id <id>] ` +
|
|
54
|
+
`[--config <path>] [--cwd <dir>] [--base <ref>]\n` +
|
|
55
|
+
`A goal cannot be judged by pixels in v1 — --until-suite names a quality.suites check (exit code = met), never a raw shell string.`;
|
|
56
|
+
/** `<title>\n\n<body>` — the exact prompt convention `core/watch.ts`'s `runIssue` uses, so a ticket-pointed loop and the build lane read a ticket identically. */
|
|
57
|
+
function promptFromIssue(issue) {
|
|
58
|
+
return `${issue.title}\n\n${issue.body}`.trim();
|
|
59
|
+
}
|
|
60
|
+
export async function loopCommand(argv) {
|
|
61
|
+
const { positionals, options } = parseCli(argv, KNOWN_OPTIONS);
|
|
62
|
+
if (positionals.length < 1) {
|
|
63
|
+
console.error(USAGE);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const [chainName, promptArg] = positionals;
|
|
67
|
+
if (!options["until-suite"]) {
|
|
68
|
+
console.error(`--until-suite is required — name a quality.suites check whose exit code decides when the goal is met.\n${USAGE}`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
if (!options["max"]) {
|
|
72
|
+
console.error(`--max is required — an outer iteration is a whole chain run, so there is no safe default.\n${USAGE}`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const max = Number.parseInt(options["max"], 10);
|
|
76
|
+
if (!Number.isInteger(max) || max < 1) {
|
|
77
|
+
console.error(`--max must be a positive integer (got ${JSON.stringify(options["max"])})`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
if (promptArg === undefined && !options["issue"]) {
|
|
81
|
+
console.error(`a goal is required — either a prompt/path positional, or --issue <id>.\n${USAGE}`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
if (promptArg !== undefined && options["issue"]) {
|
|
85
|
+
console.error(`pass a goal OR --issue <id>, not both`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
const chain = findChain(chainName);
|
|
89
|
+
if (!chain) {
|
|
90
|
+
console.error(`unknown chain: ${chainName} — run \`spf list\` to see every chain`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
const anchor = paths.resolveAnchor(options["cwd"]);
|
|
94
|
+
const configPaths = paths.resolveConfigPaths(anchor, options["config"]).paths;
|
|
95
|
+
const cfg = agents.loadConfig(configPaths);
|
|
96
|
+
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
97
|
+
let goal;
|
|
98
|
+
if (options["issue"]) {
|
|
99
|
+
const provider = resolveIssueProvider(cfg);
|
|
100
|
+
if (!provider)
|
|
101
|
+
return 1;
|
|
102
|
+
const issue = await provider.getIssue(options["issue"]);
|
|
103
|
+
if (!issue) {
|
|
104
|
+
console.error(`issue ${options["issue"]} not found`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
goal = promptFromIssue(issue);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
goal = resolvePrompt(promptArg);
|
|
111
|
+
}
|
|
112
|
+
const stop = { kind: "script", suite: options["until-suite"] };
|
|
113
|
+
// Fail fast on an unconfigured suite BEFORE minting a ledger or spending
|
|
114
|
+
// anything — the same "before any check runs, let alone any agent spawns"
|
|
115
|
+
// discipline `quality.resolveSuite` documents for itself.
|
|
116
|
+
try {
|
|
117
|
+
quality.resolveSuite(qualityRunLike({ cfg, repoRoot: anchor.repo_root, contextHandoffDir: dataPaths.sessions_dir, adwId: "preflight" }), stop.suite);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
const maxCost = options["max-cost"] ? Number.parseFloat(options["max-cost"]) : undefined;
|
|
124
|
+
const maxTokens = options["max-tokens"] ? Number.parseInt(options["max-tokens"], 10) : undefined;
|
|
125
|
+
const stuckAfter = options["stuck-after"] ? Number.parseInt(options["stuck-after"], 10) : 3;
|
|
126
|
+
const minIntervalMs = options["min-interval-ms"] ? Number.parseInt(options["min-interval-ms"], 10) : 0;
|
|
127
|
+
const goalId = resolveGoalId(options["goal-id"]);
|
|
128
|
+
const runIteration = async (iteration) => {
|
|
129
|
+
const ctx = {
|
|
130
|
+
prompt: iteration.prompt,
|
|
131
|
+
config_paths: configPaths,
|
|
132
|
+
adw_id: iteration.adw_id,
|
|
133
|
+
cwd: anchor.cwd,
|
|
134
|
+
chain_name: chain.name,
|
|
135
|
+
// Nobody is at a TTY for iteration 2 of N — same reasoning `fanout.ts`
|
|
136
|
+
// gives for its own per-attempt runs.
|
|
137
|
+
unattended: true,
|
|
138
|
+
chain_source: chain.source,
|
|
139
|
+
};
|
|
140
|
+
const chainOptions = {};
|
|
141
|
+
if (options["base"] !== undefined)
|
|
142
|
+
chainOptions["base"] = options["base"];
|
|
143
|
+
let exitCode = null;
|
|
144
|
+
let error = null;
|
|
145
|
+
try {
|
|
146
|
+
exitCode = await runChainDef(chain, ctx, chainOptions);
|
|
147
|
+
}
|
|
148
|
+
catch (caught) {
|
|
149
|
+
error = caught.message;
|
|
150
|
+
}
|
|
151
|
+
// Read back tokens/cost/gates by adw_id regardless of outcome — the
|
|
152
|
+
// same "never throw, rank as zero" discipline `fanout.ts`'s own
|
|
153
|
+
// `readMetrics` uses, since these numbers are for the ledger's spend
|
|
154
|
+
// total, not for deciding correctness.
|
|
155
|
+
let tokens = 0;
|
|
156
|
+
let cost = 0;
|
|
157
|
+
if (existsSync(dataPaths.db_path)) {
|
|
158
|
+
const db = new SfDb(dataPaths.db_path);
|
|
159
|
+
try {
|
|
160
|
+
const session = db.session(iteration.adw_id);
|
|
161
|
+
tokens = session?.total_tokens ?? 0;
|
|
162
|
+
cost = session?.total_cost ?? 0;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
db.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
let commitSha = null;
|
|
169
|
+
let stopVerdict = null;
|
|
170
|
+
if (exitCode === 0 && error === null) {
|
|
171
|
+
const git = makeGit(anchor.repo_root);
|
|
172
|
+
commitSha = git.shortSha();
|
|
173
|
+
const result = quality.runSuite(qualityRunLike({ cfg, repoRoot: anchor.repo_root, contextHandoffDir: dataPaths.sessions_dir, adwId: iteration.adw_id }), stop.suite);
|
|
174
|
+
stopVerdict = { passed: result.passed, failures: result.failures, artifacts: result.artifacts };
|
|
175
|
+
}
|
|
176
|
+
return { exit_code: exitCode, error, commit_sha: commitSha, tokens, cost, stop_verdict: stopVerdict };
|
|
177
|
+
};
|
|
178
|
+
const result = await runLoop({
|
|
179
|
+
chainName: chain.name,
|
|
180
|
+
goal,
|
|
181
|
+
stop,
|
|
182
|
+
max,
|
|
183
|
+
budget: { maxCost, maxTokens },
|
|
184
|
+
stuckAfter,
|
|
185
|
+
minIntervalMs,
|
|
186
|
+
dataDir: dataPaths.data_dir,
|
|
187
|
+
goalId,
|
|
188
|
+
runIteration,
|
|
189
|
+
log: (message) => console.log(`[spf] ${message}`),
|
|
190
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
191
|
+
});
|
|
192
|
+
console.log(`\n${summarize(result)}`);
|
|
193
|
+
const spend = cumulativeSpend(result.ledger.attempts);
|
|
194
|
+
if (isInteractive() && result.ledger.attempts.length > 0) {
|
|
195
|
+
console.log(` ${result.ledger.attempts.length} attempt(s), $${spend.cost.toFixed(4)}`);
|
|
196
|
+
}
|
|
197
|
+
return result.exitCode;
|
|
198
|
+
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Shared by both `spf <chain> "..."` and `spf run <chain> "..."` — same dispatch. */
|
|
2
2
|
import * as paths from "../../core/paths.js";
|
|
3
3
|
import * as agents from "../../core/agents.js";
|
|
4
|
-
import { parseCli, resolvePrompt } from "../../core/utils.js";
|
|
4
|
+
import { newId, parseCli, resolvePrompt } from "../../core/utils.js";
|
|
5
|
+
import { withRunScope } from "../../core/sandbox.js";
|
|
5
6
|
import { runChain } from "../../chains/index.js";
|
|
6
7
|
import { isInteractive } from "../ask.js";
|
|
7
8
|
const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue", "suite", "priority"];
|
|
@@ -24,10 +25,15 @@ export async function dispatchChain(chain, argv) {
|
|
|
24
25
|
return 2;
|
|
25
26
|
}
|
|
26
27
|
const anchor = paths.resolveAnchor(options["cwd"]);
|
|
28
|
+
// Minted here, not left null-and-minted-later inside session.ensure — a
|
|
29
|
+
// sandbox lease is keyed on `<adw_id>/<agent>` (design §4.3) and
|
|
30
|
+
// `withRunScope` below needs the SAME id up front to know which leases
|
|
31
|
+
// this run's `finally` must tear down.
|
|
32
|
+
const adwId = options["adw-id"] ?? newId(8);
|
|
27
33
|
const ctx = {
|
|
28
34
|
prompt: resolvePrompt(positionals[0]),
|
|
29
35
|
config_paths: paths.resolveConfigPaths(anchor, options["config"]).paths,
|
|
30
|
-
adw_id:
|
|
36
|
+
adw_id: adwId,
|
|
31
37
|
cwd: anchor.cwd,
|
|
32
38
|
chain_name: chain.name,
|
|
33
39
|
// Only `refine` reads this (a "## Parent: #<id>" back-reference on
|
|
@@ -74,11 +80,15 @@ export async function dispatchChain(chain, argv) {
|
|
|
74
80
|
const dashboard = mountRunDashboard({ maxCost: cfg.defaults.max_run_cost, maxTokens: cfg.defaults.max_run_tokens });
|
|
75
81
|
ctx.render_hooks = { sink: dashboard.sink, observer: dashboard.observer, pause: dashboard.pause, resume: dashboard.resume };
|
|
76
82
|
try {
|
|
77
|
-
|
|
83
|
+
// Same withRunScope as the unattended path below — an interactive
|
|
84
|
+
// dispatch is just as capable of opening a sandbox lease (design
|
|
85
|
+
// §4.3), and skipping this here would leak it silently since nothing
|
|
86
|
+
// else in this branch calls teardownRun.
|
|
87
|
+
return await withRunScope(adwId, () => runChain(chain, ctx, chainOptions));
|
|
78
88
|
}
|
|
79
89
|
finally {
|
|
80
90
|
await dashboard.close();
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
|
-
return runChain(chain, ctx, chainOptions);
|
|
93
|
+
return withRunScope(adwId, () => runChain(chain, ctx, chainOptions));
|
|
84
94
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { IssueProvider } from "../../core/issues/provider.ts";
|
|
2
|
+
import { type WatchFanoutDeps } from "../../core/watch.ts";
|
|
3
|
+
import { type ChainDefinition } from "../../chains/index.ts";
|
|
4
|
+
import { type ReviewOutputT, type SFConfig } from "../../core/data_types.ts";
|
|
5
|
+
import type { DataPaths } from "../../core/paths.ts";
|
|
2
6
|
/**
|
|
3
7
|
* `&`/`<`/`>` are active markup in every destination a digest lands in —
|
|
4
8
|
* `<url|text>`/`*bold*` in Slack mrkdwn, raw HTML in GitHub's markdown
|
|
@@ -16,6 +20,14 @@ export declare function escapeForMarkup(text: string): string;
|
|
|
16
20
|
export declare function truncateDigest(text: string): string;
|
|
17
21
|
/** A `ReviewOutput` envelope, reduced to the short digest threaded into the PR body and the `pr_opened` notification — see `core/watch.ts`'s `ChainRunResult.reviewSummary`. */
|
|
18
22
|
export declare function formatReviewDigest(review: ReviewOutputT): string;
|
|
23
|
+
/**
|
|
24
|
+
* Shared by `watch`, `watch init`, and `loop`'s `--issue` flag: resolve
|
|
25
|
+
* config into an `IssueProvider` — checking only what every caller needs.
|
|
26
|
+
* `watch`'s own extra checks (a real git repo, a registered chain) don't
|
|
27
|
+
* apply to seeding labels or to a one-shot issue fetch. Prints its own
|
|
28
|
+
* error and returns `null` on failure — the caller just needs to `return 1`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveIssueProvider(cfg: SFConfig): IssueProvider | null;
|
|
19
31
|
/**
|
|
20
32
|
* `spf watch init` — idempotently seed the `<prefix>:*` labels the state
|
|
21
33
|
* machine needs, with sensible colors/descriptions. Doesn't touch git or
|
|
@@ -31,4 +43,20 @@ export declare function formatReviewDigest(review: ReviewOutputT): string;
|
|
|
31
43
|
* seed — see `jira_provider.ts`'s module comment).
|
|
32
44
|
*/
|
|
33
45
|
export declare function watchInitCommand(argv: string[]): Promise<number>;
|
|
46
|
+
/**
|
|
47
|
+
* The fan-out lane's dispatch trio, as a plain function of config + the
|
|
48
|
+
* resolved chain — mirrors `cli/commands/fanout.ts`'s own `runAttempt`
|
|
49
|
+
* (`:375-404`) and `readMetrics` (`:355-373`) closures, plus `adwIdsFree`
|
|
50
|
+
* (the sessions-db half of that command's reuse preflight) and `reviewFor`
|
|
51
|
+
* (§6.5 — the two expressions `runChain` above already computes, pointed at
|
|
52
|
+
* a WINNER instead of the sole attempt). Exported as a plain function of its
|
|
53
|
+
* inputs — not a `watchCommand`-local closure — the same reason
|
|
54
|
+
* `linkFanoutDataDir` is exported from `cli/commands/fanout.ts`: it can then
|
|
55
|
+
* be exercised directly against a real repo, real worktrees and a fake
|
|
56
|
+
* chain, with no tracker, no agent and no control plane
|
|
57
|
+
* (`src/test/watch_fanout_sandbox_wiring.test.ts`). `watchCommand` itself
|
|
58
|
+
* calls this SAME factory and spreads the result into `deps.fanout`, so the
|
|
59
|
+
* tested object and the shipped object are the same object.
|
|
60
|
+
*/
|
|
61
|
+
export declare function makeWatchFanoutDispatch(cfg: SFConfig, configPaths: string[], dataPaths: DataPaths, chainDef: ChainDefinition): Pick<WatchFanoutDeps, "runAttempt" | "readMetrics" | "adwIdsFree" | "reviewFor">;
|
|
34
62
|
export declare function watchCommand(argv: string[]): Promise<number>;
|
|
@@ -18,7 +18,9 @@ import { JiraProvider } from "../../core/issues/jira_provider.js";
|
|
|
18
18
|
import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
|
|
19
19
|
import { isAuthoringProvider } from "../../core/issues/provider.js";
|
|
20
20
|
import { createWatchState, tick } from "../../core/watch.js";
|
|
21
|
-
import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
|
|
21
|
+
import { findChain, hasCommitStep, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
|
|
22
|
+
import { withRunScope } from "../../core/sandbox.js";
|
|
23
|
+
import { excludeSpfDataFromGit } from "../../core/worktree_data.js";
|
|
22
24
|
import { ReviewOutput } from "../../core/data_types.js";
|
|
23
25
|
import { SfDb } from "../../ui/server/db.js";
|
|
24
26
|
import { parseCli } from "../../core/utils.js";
|
|
@@ -72,12 +74,13 @@ export function formatReviewDigest(review) {
|
|
|
72
74
|
return truncateDigest(lines.join("\n"));
|
|
73
75
|
}
|
|
74
76
|
/**
|
|
75
|
-
* Shared by `watch
|
|
76
|
-
*
|
|
77
|
-
* repo, a registered chain) don't
|
|
77
|
+
* Shared by `watch`, `watch init`, and `loop`'s `--issue` flag: resolve
|
|
78
|
+
* config into an `IssueProvider` — checking only what every caller needs.
|
|
79
|
+
* `watch`'s own extra checks (a real git repo, a registered chain) don't
|
|
80
|
+
* apply to seeding labels or to a one-shot issue fetch. Prints its own
|
|
78
81
|
* error and returns `null` on failure — the caller just needs to `return 1`.
|
|
79
82
|
*/
|
|
80
|
-
function resolveIssueProvider(cfg) {
|
|
83
|
+
export function resolveIssueProvider(cfg) {
|
|
81
84
|
if (cfg.watch.issue_provider === "github") {
|
|
82
85
|
// `issue_repo` (falling back to `repo`) — NOT `repo` alone — because
|
|
83
86
|
// `repo` always names `code_host`'s own repo (see WatchConfigSchema's
|
|
@@ -214,6 +217,152 @@ export async function watchInitCommand(argv) {
|
|
|
214
217
|
}
|
|
215
218
|
return 0;
|
|
216
219
|
}
|
|
220
|
+
/** Shared by `runChain`/`runRefine`/the fan-out lane's dispatch: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. Top-level (not a `watchCommand` local) so `makeWatchFanoutDispatch` below can share it — see that factory's own doc comment for why. */
|
|
221
|
+
function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
|
|
222
|
+
let detail = prefix;
|
|
223
|
+
let db;
|
|
224
|
+
try {
|
|
225
|
+
const wtAnchor = paths.resolveAnchor(cwd);
|
|
226
|
+
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
227
|
+
db = new SfDb(wtDataPaths.db_path);
|
|
228
|
+
const failed = db.phases(adwId).find((p) => p.status === "fail");
|
|
229
|
+
if (failed)
|
|
230
|
+
detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// best-effort — the generic message above still points at where to look
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
db?.close();
|
|
237
|
+
}
|
|
238
|
+
return detail;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Best-effort: the reviewer's latest verdict for this run, read back from
|
|
242
|
+
* the worktree's own (symlinked) trace db and reduced to a digest — same
|
|
243
|
+
* DB, same try/catch shape as `detailFromFailedPhase` above, but on the
|
|
244
|
+
* SUCCESS path. `undefined` on any DB/parse hiccup, or when the chain
|
|
245
|
+
* never produced a `ReviewOutput` envelope at all — never thrown: a digest
|
|
246
|
+
* is a nice-to-have, not something that gets to block the PR-open flow
|
|
247
|
+
* it's decorating. Top-level for the same reason as `detailFromFailedPhase`.
|
|
248
|
+
*/
|
|
249
|
+
function reviewSummaryFor(cfg, cwd, adwId) {
|
|
250
|
+
let db;
|
|
251
|
+
try {
|
|
252
|
+
const wtAnchor = paths.resolveAnchor(cwd);
|
|
253
|
+
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
254
|
+
db = new SfDb(wtDataPaths.db_path);
|
|
255
|
+
const envelope = db
|
|
256
|
+
.envelopes(adwId)
|
|
257
|
+
.filter((e) => e.output_type === ReviewOutput.name)
|
|
258
|
+
.at(-1); // the LATEST verdict — a revise loop can produce several
|
|
259
|
+
if (!envelope?.payload_json)
|
|
260
|
+
return undefined;
|
|
261
|
+
const review = v.parse(ReviewOutput.schema, JSON.parse(envelope.payload_json));
|
|
262
|
+
return formatReviewDigest(review);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return undefined; // best-effort — see the doc comment above
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
db?.close();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The fan-out lane's dispatch trio, as a plain function of config + the
|
|
273
|
+
* resolved chain — mirrors `cli/commands/fanout.ts`'s own `runAttempt`
|
|
274
|
+
* (`:375-404`) and `readMetrics` (`:355-373`) closures, plus `adwIdsFree`
|
|
275
|
+
* (the sessions-db half of that command's reuse preflight) and `reviewFor`
|
|
276
|
+
* (§6.5 — the two expressions `runChain` above already computes, pointed at
|
|
277
|
+
* a WINNER instead of the sole attempt). Exported as a plain function of its
|
|
278
|
+
* inputs — not a `watchCommand`-local closure — the same reason
|
|
279
|
+
* `linkFanoutDataDir` is exported from `cli/commands/fanout.ts`: it can then
|
|
280
|
+
* be exercised directly against a real repo, real worktrees and a fake
|
|
281
|
+
* chain, with no tracker, no agent and no control plane
|
|
282
|
+
* (`src/test/watch_fanout_sandbox_wiring.test.ts`). `watchCommand` itself
|
|
283
|
+
* calls this SAME factory and spreads the result into `deps.fanout`, so the
|
|
284
|
+
* tested object and the shipped object are the same object.
|
|
285
|
+
*/
|
|
286
|
+
export function makeWatchFanoutDispatch(cfg, configPaths, dataPaths, chainDef) {
|
|
287
|
+
const runAttempt = async (dispatch) => {
|
|
288
|
+
// NO `dispatch.isAborted()` early return, unlike `spf fanout`'s own
|
|
289
|
+
// runAttempt: that check only ever fires under `firstSuccess`, which
|
|
290
|
+
// `spf watch` never sets (`decided` is never flipped — see
|
|
291
|
+
// `core/fanout.ts`). Including a check that can never observe true would
|
|
292
|
+
// be a lie about the abort model this dispatch actually honors.
|
|
293
|
+
const ctx = {
|
|
294
|
+
prompt: dispatch.prompt,
|
|
295
|
+
config_paths: configPaths,
|
|
296
|
+
adw_id: dispatch.adwId, // "issue-<id>-<i>" (or the salted/random base's own) — fanout.ts's attemptAdwId
|
|
297
|
+
cwd: dispatch.cwd, // THIS attempt's own worktree — the Run's repo_root anchor
|
|
298
|
+
chain_name: chainDef.name,
|
|
299
|
+
unattended: true, // same as runChain's and spf fanout's own dispatch — nobody is at a TTY for attempt 2 of 3
|
|
300
|
+
chain_source: chainDef.source,
|
|
301
|
+
};
|
|
302
|
+
// `cfg.watch.chain_options` reaches every attempt exactly as it reaches
|
|
303
|
+
// the single dispatch above — one shared map for all N attempts.
|
|
304
|
+
return withRunScope(dispatch.adwId, () => runChainDef(chainDef, ctx, cfg.watch.chain_options));
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* Opened and closed PER CALL, unlike `spf fanout`'s lazily-held single
|
|
308
|
+
* handle: `spf watch` is a long-lived daemon with several issues (and
|
|
309
|
+
* fan-outs) potentially in flight at once and no single `finally` to close
|
|
310
|
+
* a shared handle in — the same per-call shape `detailFromFailedPhase`/
|
|
311
|
+
* `reviewSummaryFor` above already use against the same WAL db.
|
|
312
|
+
*/
|
|
313
|
+
const readMetrics = (adwId) => {
|
|
314
|
+
if (!existsSync(dataPaths.db_path))
|
|
315
|
+
return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
|
|
316
|
+
let db;
|
|
317
|
+
try {
|
|
318
|
+
db = new SfDb(dataPaths.db_path);
|
|
319
|
+
const gates = db.gates(adwId);
|
|
320
|
+
const session = db.session(adwId);
|
|
321
|
+
// `passed` is a SQLite integer boolean that CAN be NULL on a row an
|
|
322
|
+
// older tracer wrote. Counted explicitly in both directions, never as
|
|
323
|
+
// `!g.passed`: a NULL is unknown, and letting it read as a failure
|
|
324
|
+
// would let a garbled row decide which candidate wins.
|
|
325
|
+
return {
|
|
326
|
+
gate_passes: gates.filter((g) => g.passed === 1).length,
|
|
327
|
+
gate_failures: gates.filter((g) => g.passed === 0).length,
|
|
328
|
+
cost: session?.total_cost ?? 0,
|
|
329
|
+
tokens: session?.total_tokens ?? 0,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
db?.close();
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* The other half of `spf fanout`'s reuse preflight — "does this adw_id
|
|
341
|
+
* have a session row yet" — one `SfDb.session(id)` point lookup per
|
|
342
|
+
* candidate id. Safe direction is FALSE (see `WatchFanoutDeps.adwIdsFree`'s
|
|
343
|
+
* own doc comment): an unreadable db reports "taken" rather than "free".
|
|
344
|
+
*/
|
|
345
|
+
const adwIdsFree = (adwIds) => {
|
|
346
|
+
if (!existsSync(dataPaths.db_path))
|
|
347
|
+
return true; // no db yet — nothing to collide with
|
|
348
|
+
let db;
|
|
349
|
+
try {
|
|
350
|
+
db = new SfDb(dataPaths.db_path);
|
|
351
|
+
return adwIds.every((id) => db.session(id) === null);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
db?.close();
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const reviewFor = (opts) => ({
|
|
361
|
+
reviewRequired: resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer"),
|
|
362
|
+
reviewSummary: reviewSummaryFor(cfg, opts.cwd, opts.adwId),
|
|
363
|
+
});
|
|
364
|
+
return { runAttempt, readMetrics, adwIdsFree, reviewFor };
|
|
365
|
+
}
|
|
217
366
|
export async function watchCommand(argv) {
|
|
218
367
|
const { options, flags } = parseCli(argv, ["cwd", "config"], ["dry-run", "once"]);
|
|
219
368
|
const anchor = paths.resolveAnchor(options["cwd"]);
|
|
@@ -266,6 +415,39 @@ export async function watchCommand(argv) {
|
|
|
266
415
|
}
|
|
267
416
|
}
|
|
268
417
|
}
|
|
418
|
+
// watch.fanout.n > 1 startup gates — all guarded so watch.fanout.n: 1 (the
|
|
419
|
+
// default) reaches none of them, and a bad configuration stops the daemon
|
|
420
|
+
// before it starts rather than failing silently every tick.
|
|
421
|
+
if (cfg.watch.fanout.n > 1) {
|
|
422
|
+
// §5.1 — `spf fanout` refuses a chain with no commit step
|
|
423
|
+
// (`cli/commands/fanout.ts`); `spf watch`'s single-dispatch lane has no
|
|
424
|
+
// such requirement (a chain that commits nothing is handled honestly by
|
|
425
|
+
// `openPrForWinner`'s own `diffFiles` -> blocked path). For `n > 1` the
|
|
426
|
+
// same gate MUST apply, for a worse reason: `runBestOf` force-removes
|
|
427
|
+
// every SUCCESSFUL LOSER's worktree after selection
|
|
428
|
+
// (`git worktree remove --force`, which deletes uncommitted/untracked
|
|
429
|
+
// files with no confirmation). A chain with no commit step leaves its
|
|
430
|
+
// entire payload as uncommitted edits, so fan-out would destroy N-1
|
|
431
|
+
// candidates outright and then block the issue anyway once the winner's
|
|
432
|
+
// own empty diff is discovered.
|
|
433
|
+
const watchChain = findChain(cfg.watch.chain); // already checked above
|
|
434
|
+
if (!hasCommitStep(watchChain.phases)) {
|
|
435
|
+
console.error(`watch.fanout.n is ${cfg.watch.fanout.n} but watch.chain ${JSON.stringify(cfg.watch.chain)} has no commit phase ` +
|
|
436
|
+
`(${watchChain.phases}) — best-of-N discards every losing attempt's worktree (uncommitted work included), ` +
|
|
437
|
+
`so a chain that leaves its payload uncommitted would destroy N-1 candidates and then block the issue for ` +
|
|
438
|
+
`an empty diff. Use a chain that commits (plan-build, plan-build-test, plan-build-test-quality, simple-sdlc, ` +
|
|
439
|
+
`or a repo-local chain with a commit step), or set watch.fanout.n: 1.`);
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
// §5.2 — `spf fanout` prints this once, interactively, when it's about
|
|
443
|
+
// to spend --n times over an unbounded ceiling; a daemon would otherwise
|
|
444
|
+
// repeat that bill every tick with nobody watching, so it's said once,
|
|
445
|
+
// loudly, at startup instead.
|
|
446
|
+
if (cfg.defaults.max_run_cost === undefined && cfg.defaults.max_run_tokens === undefined) {
|
|
447
|
+
console.log(`[spf] watch no defaults.max_run_cost / defaults.max_run_tokens configured — watch.fanout.n=${cfg.watch.fanout.n} ` +
|
|
448
|
+
`means every claimed issue runs ${cfg.watch.fanout.n} attempts to completion, unbounded`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
269
451
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
270
452
|
const lockPath = path.join(dataPaths.data_dir, "watch.lock");
|
|
271
453
|
try {
|
|
@@ -308,61 +490,20 @@ export async function watchCommand(argv) {
|
|
|
308
490
|
console.error(`watch: refusing to link ${target} to itself — worktree path resolved to the main repo's own data_dir`);
|
|
309
491
|
return;
|
|
310
492
|
}
|
|
311
|
-
if (existsSync(target))
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
symlinkSync(dataPaths.data_dir, target, "dir");
|
|
315
|
-
}
|
|
316
|
-
/** Shared by `runChain`/`runRefine`: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. */
|
|
317
|
-
function detailFromFailedPhase(cwd, adwId, prefix) {
|
|
318
|
-
let detail = prefix;
|
|
319
|
-
let db;
|
|
320
|
-
try {
|
|
321
|
-
const wtAnchor = paths.resolveAnchor(cwd);
|
|
322
|
-
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
323
|
-
db = new SfDb(wtDataPaths.db_path);
|
|
324
|
-
const failed = db.phases(adwId).find((p) => p.status === "fail");
|
|
325
|
-
if (failed)
|
|
326
|
-
detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
|
|
327
|
-
}
|
|
328
|
-
catch {
|
|
329
|
-
// best-effort — the generic message above still points at where to look
|
|
330
|
-
}
|
|
331
|
-
finally {
|
|
332
|
-
db?.close();
|
|
333
|
-
}
|
|
334
|
-
return detail;
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Best-effort: the reviewer's latest verdict for this run, read back from
|
|
338
|
-
* the worktree's own (symlinked) trace db and reduced to a digest — same
|
|
339
|
-
* DB, same try/catch shape as `detailFromFailedPhase` above, but on the
|
|
340
|
-
* SUCCESS path. `undefined` on any DB/parse hiccup, or when the chain
|
|
341
|
-
* never produced a `ReviewOutput` envelope at all — never thrown: a digest
|
|
342
|
-
* is a nice-to-have, not something that gets to block the PR-open flow
|
|
343
|
-
* it's decorating.
|
|
344
|
-
*/
|
|
345
|
-
function reviewSummaryFor(cwd, adwId) {
|
|
346
|
-
let db;
|
|
347
|
-
try {
|
|
348
|
-
const wtAnchor = paths.resolveAnchor(cwd);
|
|
349
|
-
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
350
|
-
db = new SfDb(wtDataPaths.db_path);
|
|
351
|
-
const envelope = db
|
|
352
|
-
.envelopes(adwId)
|
|
353
|
-
.filter((e) => e.output_type === ReviewOutput.name)
|
|
354
|
-
.at(-1); // the LATEST verdict — a revise loop can produce several
|
|
355
|
-
if (!envelope?.payload_json)
|
|
356
|
-
return undefined;
|
|
357
|
-
const review = v.parse(ReviewOutput.schema, JSON.parse(envelope.payload_json));
|
|
358
|
-
return formatReviewDigest(review);
|
|
359
|
-
}
|
|
360
|
-
catch {
|
|
361
|
-
return undefined; // best-effort — see the doc comment above
|
|
362
|
-
}
|
|
363
|
-
finally {
|
|
364
|
-
db?.close();
|
|
493
|
+
if (!existsSync(target)) {
|
|
494
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
495
|
+
symlinkSync(dataPaths.data_dir, target, "dir");
|
|
365
496
|
}
|
|
497
|
+
// Keeps the symlink invisible to `git status`/`git add -A` in THIS
|
|
498
|
+
// worktree — see `core/worktree_data.ts`'s doc comment for the ELOOP
|
|
499
|
+
// disaster this closes. Called every time, guarded by nothing of its
|
|
500
|
+
// own: it's idempotent and best-effort (a `git rev-parse` failure is
|
|
501
|
+
// swallowed), so there's no wrong time to call it, including when
|
|
502
|
+
// resuming an orphaned worktree whose `.spf/data` symlink already
|
|
503
|
+
// exists. Previously only `spf fanout`'s own worktrees got this; `spf
|
|
504
|
+
// watch`'s single-dispatch worktrees carried the same exposure and now
|
|
505
|
+
// get the identical fix.
|
|
506
|
+
excludeSpfDataFromGit(worktreePath);
|
|
366
507
|
}
|
|
367
508
|
const runChain = async (opts) => {
|
|
368
509
|
// WATCH DIVERGENCE: `findChain` here resolves against the registry
|
|
@@ -397,15 +538,15 @@ export async function watchCommand(argv) {
|
|
|
397
538
|
// LIMITATION from PR #20: an unattended watch dispatch used to call
|
|
398
539
|
// `runChainDef` with no options at all, so nothing --suite-shaped could
|
|
399
540
|
// ever reach it.
|
|
400
|
-
const code = await runChainDef(chainDef, ctx, opts.chainOptions);
|
|
541
|
+
const code = await withRunScope(opts.adwId, () => runChainDef(chainDef, ctx, opts.chainOptions));
|
|
401
542
|
// Resolved with the SAME options `runChainDef` just ran the chain with,
|
|
402
543
|
// so a `chain_options` override that swaps a "reviewer" step in or out
|
|
403
544
|
// is reflected here too, not just each chain's static/YAML default.
|
|
404
545
|
const reviewRequired = resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer");
|
|
405
546
|
if (code === 0) {
|
|
406
|
-
return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(opts.cwd, opts.adwId) };
|
|
547
|
+
return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(cfg, opts.cwd, opts.adwId) };
|
|
407
548
|
}
|
|
408
|
-
const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
549
|
+
const detail = detailFromFailedPhase(cfg, opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
409
550
|
return { accepted: false, adwId: opts.adwId, detail, reviewRequired };
|
|
410
551
|
};
|
|
411
552
|
/**
|
|
@@ -435,9 +576,9 @@ export async function watchCommand(argv) {
|
|
|
435
576
|
};
|
|
436
577
|
// Same `watch.chain_options` threading as runChain above — see its
|
|
437
578
|
// comment for the KNOWN LIMITATION this fixes.
|
|
438
|
-
const code = await runChainDef(chainDef, ctx, opts.chainOptions);
|
|
579
|
+
const code = await withRunScope(opts.adwId, () => runChainDef(chainDef, ctx, opts.chainOptions));
|
|
439
580
|
if (code !== 0) {
|
|
440
|
-
const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
581
|
+
const detail = detailFromFailedPhase(cfg, opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
441
582
|
return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [] };
|
|
442
583
|
}
|
|
443
584
|
const wtAnchor = paths.resolveAnchor(opts.cwd);
|
|
@@ -523,6 +664,20 @@ export async function watchCommand(argv) {
|
|
|
523
664
|
notifier?.send(event); // unaffected either way — see mountWatchDashboard's own doc comment
|
|
524
665
|
dashboard?.mirrorNotify(event);
|
|
525
666
|
},
|
|
667
|
+
// `undefined` at the default `watch.fanout.n: 1` — `core/watch.ts`'s
|
|
668
|
+
// `runIssue` never even looks at `deps.fanout` in that case, so the
|
|
669
|
+
// object below doesn't exist at runtime at all unless best-of-N is
|
|
670
|
+
// actually on. `makeWatchFanoutDispatch` is the SAME factory
|
|
671
|
+
// `src/test/watch_fanout_sandbox_wiring.test.ts` exercises directly —
|
|
672
|
+
// the tested object and this shipped object are the same object.
|
|
673
|
+
fanout: cfg.watch.fanout.n > 1
|
|
674
|
+
? {
|
|
675
|
+
n: cfg.watch.fanout.n,
|
|
676
|
+
concurrency: cfg.watch.fanout.concurrency,
|
|
677
|
+
repoRoot: anchor.repo_root, // GitHandle has no repo-root accessor — this is the same value `git` above is bound to
|
|
678
|
+
...makeWatchFanoutDispatch(cfg, configPaths, dataPaths, findChain(cfg.watch.chain)), // checked at startup
|
|
679
|
+
}
|
|
680
|
+
: undefined,
|
|
526
681
|
};
|
|
527
682
|
const state = createWatchState();
|
|
528
683
|
let stopping = false;
|