wogiflow 2.31.2 → 2.33.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/.claude/commands/wogi-start.md +4 -1
- package/.claude/docs/claude-code-compatibility.md +51 -0
- package/.claude/docs/scheduled-mode.md +213 -0
- package/.claude/docs/skill-portability.md +190 -0
- package/.claude/rules/alternative-hook-args-exec-form.md +6 -0
- package/.claude/settings.json +2 -1
- package/.claude/skills/_template/skill.md +1 -0
- package/.claude/skills/conventional-commit/knowledge/examples.md +65 -0
- package/.claude/skills/conventional-commit/skill.md +76 -0
- package/bin/flow +16 -0
- package/lib/scheduled-mode.js +377 -0
- package/lib/skill-export-agentskills.js +211 -0
- package/lib/skill-export-claude-plugin.js +143 -0
- package/lib/skill-portability.js +324 -0
- package/lib/skill-registry.js +32 -2
- package/package.json +2 -2
- package/scripts/flow +8 -0
- package/scripts/flow-config-defaults.js +20 -0
- package/scripts/flow-schedule.js +469 -0
- package/scripts/flow-scheduled-runner.js +614 -0
- package/scripts/flow-skill-export.js +334 -0
- package/scripts/hooks/adapters/claude-code.js +15 -1
- package/scripts/hooks/core/git-safety-gate.js +92 -20
- package/scripts/hooks/core/long-input-enforcement.js +139 -4
- package/scripts/hooks/core/research-required-classifier.js +73 -17
- package/scripts/hooks/core/research-required-gate.js +16 -6
- package/scripts/hooks/core/user-prompt-orchestrator.js +10 -3
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wogi Flow — Scheduled / Background Headless Runner (Phase 1A — wf-b211a076).
|
|
7
|
+
*
|
|
8
|
+
* Entry point invoked by `.github/workflows/wogi-scheduled.yml` (or by a
|
|
9
|
+
* launchd/cron/systemd unit installed via `flow schedule install`).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/flow-scheduled-runner.js <job-name> [--dry-run] [--repo=owner/name]
|
|
13
|
+
*
|
|
14
|
+
* Jobs:
|
|
15
|
+
* nightly-regression Wraps scripts/flow-step-regression.js; skipped on empty diff.
|
|
16
|
+
* weekly-audit Headless `claude -p` running /wogi-audit.
|
|
17
|
+
* weekly-digest Headless `claude -p` running /wogi-debt + /wogi-gate-stats.
|
|
18
|
+
* per-pr-review Headless `claude -p` running ultrareview on a PR.
|
|
19
|
+
*
|
|
20
|
+
* Read-only-by-default invariants (HARD GATES — also enforced by tests):
|
|
21
|
+
* - Runs only on the default branch (origin/HEAD).
|
|
22
|
+
* - Operates in a temp worktree created via scripts/flow-worktree.js
|
|
23
|
+
* (real `runInWorktree` wrap as of R-379 fix; failure to create worktree
|
|
24
|
+
* is a hard error — no silent fallback to the user's working dir).
|
|
25
|
+
* - Never `git push origin master` and never `gh pr merge`.
|
|
26
|
+
* - Never writes to .workflow/state/decisions.md.
|
|
27
|
+
*
|
|
28
|
+
* Safeguards:
|
|
29
|
+
* - --dry-run prints $/month projection and exits 0 without invoking claude.
|
|
30
|
+
* - scheduledMode.dailyTokenBudget cap — same-day jobs no-op once hit.
|
|
31
|
+
* - One persistent labelled issue per job (`wogi/scheduled-${job}`).
|
|
32
|
+
* - Silence-on-green default — nightly posts nothing when all tests pass.
|
|
33
|
+
* - Skip-on-empty-diff — nightly checks `git diff @{yesterday}..HEAD`.
|
|
34
|
+
* - 10-minute hard timeout per job via AbortController; on timeout opens a
|
|
35
|
+
* `wogi/scheduled-failure` issue with captured stderr.
|
|
36
|
+
* - Retry-1×-then-alert with 30s backoff on transient failures.
|
|
37
|
+
* - Clears routing-pending.json + pending-question.json before each invocation.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
const path = require('node:path');
|
|
42
|
+
const { execFileSync } = require('node:child_process');
|
|
43
|
+
|
|
44
|
+
const {
|
|
45
|
+
JOB_NAMES,
|
|
46
|
+
DEFAULT_JOB_TIMEOUT_MS,
|
|
47
|
+
TRANSIENT_RETRY_DELAY_MS,
|
|
48
|
+
clearStaleMarkers,
|
|
49
|
+
loadHeadlessProfile,
|
|
50
|
+
projectMonthlyCost,
|
|
51
|
+
withTimeout,
|
|
52
|
+
enforceTokenBudget,
|
|
53
|
+
updateDedupIssue,
|
|
54
|
+
validateModelName,
|
|
55
|
+
isTransientError,
|
|
56
|
+
DEFAULT_TOKENS_PER_INVOCATION,
|
|
57
|
+
} = require('../lib/scheduled-mode');
|
|
58
|
+
|
|
59
|
+
const { runInWorktree } = require('./flow-worktree');
|
|
60
|
+
|
|
61
|
+
const { PATHS, getConfig, safeJsonParse } = require('./flow-utils');
|
|
62
|
+
|
|
63
|
+
// ============================================================
|
|
64
|
+
// Constants
|
|
65
|
+
// ============================================================
|
|
66
|
+
|
|
67
|
+
const USAGE_LOG_PATH = path.join(PATHS.state, 'scheduled-usage-log.json');
|
|
68
|
+
const FAILURE_LABEL = 'wogi/scheduled-failure';
|
|
69
|
+
|
|
70
|
+
// ============================================================
|
|
71
|
+
// CLI arg parsing
|
|
72
|
+
// ============================================================
|
|
73
|
+
|
|
74
|
+
function parseArgs(argv) {
|
|
75
|
+
const args = { job: null, dryRun: false, repo: null, _raw: argv.slice() };
|
|
76
|
+
for (let i = 0; i < argv.length; i++) {
|
|
77
|
+
const a = argv[i];
|
|
78
|
+
if (a === '--dry-run') args.dryRun = true;
|
|
79
|
+
else if (a.startsWith('--repo=')) args.repo = a.slice('--repo='.length);
|
|
80
|
+
else if (!a.startsWith('--') && !args.job) args.job = a;
|
|
81
|
+
}
|
|
82
|
+
return args;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================
|
|
86
|
+
// Subprocess helpers (execFile only — no shell interpolation)
|
|
87
|
+
// ============================================================
|
|
88
|
+
|
|
89
|
+
function execSafe(cmd, args, opts = {}) {
|
|
90
|
+
try {
|
|
91
|
+
const out = execFileSync(cmd, args, {
|
|
92
|
+
encoding: 'utf-8',
|
|
93
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
94
|
+
...opts,
|
|
95
|
+
});
|
|
96
|
+
return { ok: true, stdout: out.trim(), stderr: '' };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
stdout: (err.stdout && err.stdout.toString()) || '',
|
|
101
|
+
stderr: (err.stderr && err.stderr.toString()) || err.message,
|
|
102
|
+
error: err,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============================================================
|
|
108
|
+
// Default-branch detection
|
|
109
|
+
// ============================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Detect the repo's default branch via origin/HEAD.
|
|
113
|
+
* Falls back to 'master' then 'main' if origin/HEAD is unconfigured.
|
|
114
|
+
*/
|
|
115
|
+
function detectDefaultBranch(cwd) {
|
|
116
|
+
const r = execSafe('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd });
|
|
117
|
+
if (r.ok && r.stdout) {
|
|
118
|
+
// "refs/remotes/origin/master" → "master"
|
|
119
|
+
return r.stdout.replace(/^refs\/remotes\/origin\//, '').trim();
|
|
120
|
+
}
|
|
121
|
+
// Fallback probes
|
|
122
|
+
for (const candidate of ['master', 'main']) {
|
|
123
|
+
const probe = execSafe('git', ['rev-parse', '--verify', `origin/${candidate}`], { cwd });
|
|
124
|
+
if (probe.ok) return candidate;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================
|
|
130
|
+
// Usage log (token budget tracking)
|
|
131
|
+
// ============================================================
|
|
132
|
+
|
|
133
|
+
function readUsageLog() {
|
|
134
|
+
const parsed = safeJsonParse(USAGE_LOG_PATH, null);
|
|
135
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function writeUsageLog(log) {
|
|
139
|
+
try {
|
|
140
|
+
fs.mkdirSync(path.dirname(USAGE_LOG_PATH), { recursive: true });
|
|
141
|
+
fs.writeFileSync(USAGE_LOG_PATH, JSON.stringify(log, null, 2));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.warn(`[scheduled-runner] failed to persist usage log: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function recordUsage(jobName, tokens, now = Date.now()) {
|
|
148
|
+
const key = new Date(now).toISOString().slice(0, 10);
|
|
149
|
+
const log = readUsageLog();
|
|
150
|
+
if (!log[key]) log[key] = {};
|
|
151
|
+
log[key][jobName] = (log[key][jobName] || 0) + tokens;
|
|
152
|
+
writeUsageLog(log);
|
|
153
|
+
return log;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ============================================================
|
|
157
|
+
// Empty-diff check for nightly-regression
|
|
158
|
+
// ============================================================
|
|
159
|
+
|
|
160
|
+
function hasDiffSinceYesterday(cwd) {
|
|
161
|
+
// Use a SHA range — @{yesterday} can fail in CI where reflog is empty.
|
|
162
|
+
// We anchor against the merge-base with origin/<default-branch> from ~24h ago.
|
|
163
|
+
// Simpler + portable: compare HEAD vs HEAD~ at >=24h old commit if available.
|
|
164
|
+
const r = execSafe('git', ['log', '--since=24 hours ago', '--oneline', '-1'], { cwd });
|
|
165
|
+
if (!r.ok) {
|
|
166
|
+
// Be conservative: if we can't tell, treat as "has diff" so we don't silently skip.
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return r.stdout.trim().length > 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================
|
|
173
|
+
// Dedup-issue execution
|
|
174
|
+
// ============================================================
|
|
175
|
+
|
|
176
|
+
function listDedupIssues(jobName, repo) {
|
|
177
|
+
const label = `wogi/scheduled-${jobName}`;
|
|
178
|
+
const args = ['issue', 'list', '--label', label, '--state', 'open', '--json', 'number'];
|
|
179
|
+
if (repo) args.push('--repo', repo);
|
|
180
|
+
const r = execSafe('gh', args);
|
|
181
|
+
if (!r.ok) return [];
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(r.stdout || '[]');
|
|
184
|
+
if (!Array.isArray(parsed)) return [];
|
|
185
|
+
return parsed.map((x) => x && x.number).filter((n) => Number.isFinite(n));
|
|
186
|
+
} catch (_err) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function postDedupIssue(jobName, body, repo) {
|
|
192
|
+
const existing = listDedupIssues(jobName, repo);
|
|
193
|
+
const { mode, argv } = updateDedupIssue(jobName, body, { existingIssueNumbers: existing });
|
|
194
|
+
const fullArgv = repo ? [...argv, '--repo', repo] : argv;
|
|
195
|
+
const r = execSafe('gh', fullArgv);
|
|
196
|
+
return { mode, ok: r.ok, error: r.ok ? null : r.stderr };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function openFailureIssue(jobName, summary, stderr, repo) {
|
|
200
|
+
const title = `[scheduled-failure] ${jobName} — ${new Date().toISOString().slice(0, 10)}`;
|
|
201
|
+
const body = [
|
|
202
|
+
`### Scheduled job failure`,
|
|
203
|
+
`- **Job**: \`${jobName}\``,
|
|
204
|
+
`- **Time**: ${new Date().toISOString()}`,
|
|
205
|
+
``,
|
|
206
|
+
`### Summary`,
|
|
207
|
+
summary,
|
|
208
|
+
``,
|
|
209
|
+
`### stderr (last 4 KB)`,
|
|
210
|
+
'```',
|
|
211
|
+
(stderr || '').slice(-4096),
|
|
212
|
+
'```',
|
|
213
|
+
].join('\n');
|
|
214
|
+
const argv = ['issue', 'create', '--title', title, '--body', body, '--label', FAILURE_LABEL];
|
|
215
|
+
const fullArgv = repo ? [...argv, '--repo', repo] : argv;
|
|
216
|
+
return execSafe('gh', fullArgv);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================
|
|
220
|
+
// Read-only invariant guard (defense-in-depth — runner self-check)
|
|
221
|
+
// ============================================================
|
|
222
|
+
|
|
223
|
+
function assertReadOnlyEnv() {
|
|
224
|
+
// Defense-in-depth: refuse to start if env hints at write-mode operation.
|
|
225
|
+
// This is belt-and-braces — the workflow YAML uses contents:read where
|
|
226
|
+
// possible, but a misconfigured local cron unit could grant more.
|
|
227
|
+
if (process.env.WOGI_SCHEDULED_ALLOW_WRITE === '1') {
|
|
228
|
+
throw new Error(
|
|
229
|
+
'scheduled-runner: refusing to run with WOGI_SCHEDULED_ALLOW_WRITE=1 — read-only invariant.'
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ============================================================
|
|
235
|
+
// Job implementations
|
|
236
|
+
// ============================================================
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Run the nightly regression job. Wraps flow-step-regression.js.
|
|
240
|
+
* Silence-on-green: posts nothing if all pass.
|
|
241
|
+
*/
|
|
242
|
+
async function runNightlyRegression(ctx) {
|
|
243
|
+
const { profile, cwd, repo, signal } = ctx;
|
|
244
|
+
validateModelName(profile.model);
|
|
245
|
+
|
|
246
|
+
if (signal && signal.aborted) {
|
|
247
|
+
return { passed: false, message: 'aborted', skipped: false };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!hasDiffSinceYesterday(cwd)) {
|
|
251
|
+
return { passed: true, skipped: true, message: 'no diff in last 24h — skipped' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Delegate to the existing step runner — do NOT rewrite it.
|
|
255
|
+
// The step runner shells out via execSync internally; we capture its result.
|
|
256
|
+
const scriptPath = path.join(__dirname, 'flow-step-regression.js');
|
|
257
|
+
if (!fs.existsSync(scriptPath)) {
|
|
258
|
+
return { passed: false, message: 'flow-step-regression.js not found', skipped: false };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const stepRunner = require(scriptPath);
|
|
262
|
+
let result;
|
|
263
|
+
try {
|
|
264
|
+
result = await stepRunner.run({ stepConfig: { sampleSize: 3 }, mode: 'auto' });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return { passed: false, message: `regression runner threw: ${err.message}`, skipped: false };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (result && result.passed) {
|
|
270
|
+
// Silence-on-green: do NOT touch the dedup issue.
|
|
271
|
+
return { passed: true, skipped: false, message: result.message, silent: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Failure: build a markdown body and post to the dedup issue.
|
|
275
|
+
const body = [
|
|
276
|
+
`### Nightly regression — FAILED`,
|
|
277
|
+
`- **Time**: ${new Date().toISOString()}`,
|
|
278
|
+
`- **Branch**: ${ctx.defaultBranch}`,
|
|
279
|
+
``,
|
|
280
|
+
`### Message`,
|
|
281
|
+
result?.message || '(no message)',
|
|
282
|
+
``,
|
|
283
|
+
`### Details`,
|
|
284
|
+
'```json',
|
|
285
|
+
JSON.stringify(result?.details || {}, null, 2),
|
|
286
|
+
'```',
|
|
287
|
+
].join('\n');
|
|
288
|
+
if (!profile.dryRun) {
|
|
289
|
+
postDedupIssue('nightly-regression', body, repo);
|
|
290
|
+
}
|
|
291
|
+
return { passed: false, skipped: false, message: result?.message || 'regression failed' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Generic claude-headless invoker for audit / digest / per-pr-review jobs.
|
|
296
|
+
*
|
|
297
|
+
* NOTE: We do not actually shell out to `claude -p` unless the binary exists.
|
|
298
|
+
* In CI we expect the GH Actions workflow to provide it; locally on a dev
|
|
299
|
+
* machine, the headless runner is typically invoked in --dry-run mode.
|
|
300
|
+
*/
|
|
301
|
+
async function runClaudeHeadless(jobName, prompt, ctx) {
|
|
302
|
+
const { profile, signal } = ctx;
|
|
303
|
+
const model = validateModelName(profile.model);
|
|
304
|
+
|
|
305
|
+
if (profile.dryRun) {
|
|
306
|
+
return {
|
|
307
|
+
passed: true,
|
|
308
|
+
skipped: true,
|
|
309
|
+
message: `dry-run: would invoke claude -p --model=${model} on ${jobName}`,
|
|
310
|
+
silent: true,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (signal && signal.aborted) {
|
|
315
|
+
return { passed: false, message: 'aborted', skipped: false };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Probe for the `claude` binary. In environments without it, we exit
|
|
319
|
+
// cleanly rather than crash — the failure-issue path covers reporting.
|
|
320
|
+
const which = execSafe('which', ['claude']);
|
|
321
|
+
if (!which.ok) {
|
|
322
|
+
return {
|
|
323
|
+
passed: false,
|
|
324
|
+
skipped: false,
|
|
325
|
+
message: 'claude CLI not on PATH — headless invocation not possible',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const r = execSafe('claude', ['-p', '--model', model, prompt], {
|
|
330
|
+
cwd: ctx.cwd,
|
|
331
|
+
env: { ...process.env },
|
|
332
|
+
timeout: ctx.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// F4 / R-379: record the actual pre-flight estimate (not 0). Without this,
|
|
336
|
+
// enforceTokenBudget always sees 0 spent and the daily cap is a no-op.
|
|
337
|
+
recordUsage(jobName, ctx.estimatedTokens ?? 0);
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
passed: r.ok,
|
|
341
|
+
skipped: false,
|
|
342
|
+
message: r.ok ? `${jobName} ran cleanly` : `claude exited non-zero`,
|
|
343
|
+
stdout: r.stdout,
|
|
344
|
+
stderr: r.stderr,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function runWeeklyAudit(ctx) {
|
|
349
|
+
const result = await runClaudeHeadless('weekly-audit', '/wogi-audit', ctx);
|
|
350
|
+
if (result.passed && !result.skipped && !ctx.profile.dryRun) {
|
|
351
|
+
const body = [
|
|
352
|
+
`### Weekly Audit Report`,
|
|
353
|
+
`- **Time**: ${new Date().toISOString()}`,
|
|
354
|
+
``,
|
|
355
|
+
'```',
|
|
356
|
+
(result.stdout || '').slice(0, 8000),
|
|
357
|
+
'```',
|
|
358
|
+
].join('\n');
|
|
359
|
+
postDedupIssue('weekly-audit', body, ctx.repo);
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function runWeeklyDigest(ctx) {
|
|
365
|
+
const prompt = '/wogi-debt and then /wogi-gate-stats --since=7d. Produce a single digest.';
|
|
366
|
+
const result = await runClaudeHeadless('weekly-digest', prompt, ctx);
|
|
367
|
+
if (result.passed && !result.skipped && !ctx.profile.dryRun) {
|
|
368
|
+
const body = [
|
|
369
|
+
`### Weekly Debt + Gate-stats Digest`,
|
|
370
|
+
`- **Time**: ${new Date().toISOString()}`,
|
|
371
|
+
``,
|
|
372
|
+
'```',
|
|
373
|
+
(result.stdout || '').slice(0, 8000),
|
|
374
|
+
'```',
|
|
375
|
+
].join('\n');
|
|
376
|
+
postDedupIssue('weekly-digest', body, ctx.repo);
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function runPerPrReview(ctx) {
|
|
382
|
+
const prNumber = process.env.PR_NUMBER || '0';
|
|
383
|
+
// F3 / R-379: must be the slash-command form (/ultrareview …) so Claude Code
|
|
384
|
+
// routes it to the ultrareview skill. The bare-string form ("ultrareview 42")
|
|
385
|
+
// sends a literal text prompt that goes nowhere useful. Mirrors how
|
|
386
|
+
// runWeeklyAudit invokes "/wogi-audit" above.
|
|
387
|
+
const prompt = `/ultrareview ${prNumber}`;
|
|
388
|
+
const result = await runClaudeHeadless('per-pr-review', prompt, ctx);
|
|
389
|
+
// Per-PR review posts on the PR (gh pr comment) — handled by the GH workflow
|
|
390
|
+
// step using the returned stdout. Runner doesn't auto-merge.
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const JOB_HANDLERS = {
|
|
395
|
+
'nightly-regression': runNightlyRegression,
|
|
396
|
+
'weekly-audit': runWeeklyAudit,
|
|
397
|
+
'weekly-digest': runWeeklyDigest,
|
|
398
|
+
'per-pr-review': runPerPrReview,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// ============================================================
|
|
402
|
+
// Orchestrator — single job invocation with timeout + retry
|
|
403
|
+
// ============================================================
|
|
404
|
+
|
|
405
|
+
async function runOnce(jobName, ctx) {
|
|
406
|
+
const handler = JOB_HANDLERS[jobName];
|
|
407
|
+
if (!handler) {
|
|
408
|
+
throw new Error(`scheduled-runner: unknown job "${jobName}"`);
|
|
409
|
+
}
|
|
410
|
+
return withTimeout(
|
|
411
|
+
({ signal }) => handler({ ...ctx, signal }),
|
|
412
|
+
ctx.timeoutMs || DEFAULT_JOB_TIMEOUT_MS,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function runJobWithRetry(jobName, ctx) {
|
|
417
|
+
const first = await runOnce(jobName, ctx);
|
|
418
|
+
if (first.ok) return first;
|
|
419
|
+
|
|
420
|
+
// Retry once on transient failures only.
|
|
421
|
+
if (first.error && isTransientError(first.error)) {
|
|
422
|
+
await new Promise((r) => setTimeout(r, TRANSIENT_RETRY_DELAY_MS));
|
|
423
|
+
return runOnce(jobName, ctx);
|
|
424
|
+
}
|
|
425
|
+
return first;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ============================================================
|
|
429
|
+
// Main entry
|
|
430
|
+
// ============================================================
|
|
431
|
+
|
|
432
|
+
async function main(argv = process.argv.slice(2), deps = {}) {
|
|
433
|
+
const args = parseArgs(argv);
|
|
434
|
+
if (!args.job) {
|
|
435
|
+
console.error(
|
|
436
|
+
`Usage: node scripts/flow-scheduled-runner.js <job-name> [--dry-run] [--repo=owner/name]\n` +
|
|
437
|
+
`Jobs: ${JOB_NAMES.join(', ')}`
|
|
438
|
+
);
|
|
439
|
+
return 2;
|
|
440
|
+
}
|
|
441
|
+
if (!JOB_NAMES.includes(args.job)) {
|
|
442
|
+
console.error(`scheduled-runner: unknown job "${args.job}". Allowed: ${JOB_NAMES.join(', ')}`);
|
|
443
|
+
return 2;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
assertReadOnlyEnv();
|
|
447
|
+
|
|
448
|
+
const config = (deps.config || getConfig());
|
|
449
|
+
const sm = (config && config.scheduledMode) || {};
|
|
450
|
+
if (!sm.enabled && !args.dryRun) {
|
|
451
|
+
console.log(`scheduled-runner: scheduledMode.enabled is false — exiting with 0`);
|
|
452
|
+
return 0;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const profile = loadHeadlessProfile(config, args.job);
|
|
456
|
+
profile.dryRun = profile.dryRun || args.dryRun;
|
|
457
|
+
|
|
458
|
+
// --- Dry-run path: print projection and exit, no claude invocation. ---
|
|
459
|
+
if (profile.dryRun) {
|
|
460
|
+
const projection = projectMonthlyCost(config || {});
|
|
461
|
+
const lines = [
|
|
462
|
+
`scheduled-runner: DRY-RUN (job=${args.job}, model=${profile.model})`,
|
|
463
|
+
`Projected monthly cost across all configured jobs: $${projection.total}`,
|
|
464
|
+
``,
|
|
465
|
+
`Per-job:`,
|
|
466
|
+
];
|
|
467
|
+
for (const [name, info] of Object.entries(projection.byJob)) {
|
|
468
|
+
lines.push(` ${name.padEnd(22)} ${info.model.padEnd(8)} ` +
|
|
469
|
+
`${info.invocations}× ${info.tokens.toLocaleString()} tok → $${info.cost}`);
|
|
470
|
+
}
|
|
471
|
+
console.log(lines.join('\n'));
|
|
472
|
+
return 0;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --- Token-budget check ---
|
|
476
|
+
const budgetVerdict = enforceTokenBudget(
|
|
477
|
+
readUsageLog(),
|
|
478
|
+
profile.dailyTokenBudget,
|
|
479
|
+
Date.now(),
|
|
480
|
+
args.job,
|
|
481
|
+
);
|
|
482
|
+
if (!budgetVerdict.allowed) {
|
|
483
|
+
console.warn(`scheduled-runner: ${args.job} skipped — ${budgetVerdict.reason}`);
|
|
484
|
+
return 0; // no-op, exit clean
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- Default-branch + worktree enforcement ---
|
|
488
|
+
const cwd = process.cwd();
|
|
489
|
+
const defaultBranch = detectDefaultBranch(cwd) || 'master';
|
|
490
|
+
const currentBranch = execSafe('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }).stdout;
|
|
491
|
+
// We do NOT crash if the current branch isn't default — the runner itself
|
|
492
|
+
// creates a worktree on the default branch. But we record it so the audit trail
|
|
493
|
+
// is complete.
|
|
494
|
+
|
|
495
|
+
// --- Clear stale markers ---
|
|
496
|
+
const cleared = clearStaleMarkers(PATHS.state);
|
|
497
|
+
if (cleared.cleared.length > 0) {
|
|
498
|
+
console.log(`scheduled-runner: cleared ${cleared.cleared.join(', ')}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// --- Execute the job (with timeout + retry, in a temp worktree) ---
|
|
502
|
+
// F2 / R-379: the runner MUST operate in a temp worktree on the default
|
|
503
|
+
// branch — never touch the user's working dir. Failure to create the worktree
|
|
504
|
+
// is a hard error (no silent fallback); the invariant matters even if Claude
|
|
505
|
+
// never writes to disk, because regression scripts and tests do.
|
|
506
|
+
// F4 / R-379: ctx.estimatedTokens now carries the real per-job estimate so
|
|
507
|
+
// enforceTokenBudget can actually do its job.
|
|
508
|
+
const buildCtx = (worktreeCwd) => ({
|
|
509
|
+
profile,
|
|
510
|
+
cwd: worktreeCwd ?? cwd,
|
|
511
|
+
repo: args.repo,
|
|
512
|
+
defaultBranch,
|
|
513
|
+
currentBranch,
|
|
514
|
+
estimatedTokens: DEFAULT_TOKENS_PER_INVOCATION[args.job] ?? 0,
|
|
515
|
+
timeoutMs: DEFAULT_JOB_TIMEOUT_MS,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const worktreeOpts = {
|
|
519
|
+
taskId: `scheduled-${args.job}-${Date.now()}`,
|
|
520
|
+
baseBranch: defaultBranch,
|
|
521
|
+
cwd,
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
let worktreeWrap;
|
|
525
|
+
try {
|
|
526
|
+
worktreeWrap = await (deps.runInWorktree || runInWorktree)(
|
|
527
|
+
worktreeOpts,
|
|
528
|
+
async (worktreePath) => runJobWithRetry(args.job, buildCtx(worktreePath)),
|
|
529
|
+
);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
// Hard-error: refuse to silently fall back to the user's working directory.
|
|
532
|
+
openFailureIssue(
|
|
533
|
+
args.job,
|
|
534
|
+
`Worktree creation failed; refusing to run on the user's working tree (read-only invariant).`,
|
|
535
|
+
String(err && err.message ? err.message : err),
|
|
536
|
+
args.repo,
|
|
537
|
+
);
|
|
538
|
+
return 1;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!worktreeWrap.success) {
|
|
542
|
+
// Worktree was created but the wrapped job threw; report and exit.
|
|
543
|
+
openFailureIssue(
|
|
544
|
+
args.job,
|
|
545
|
+
`Job threw inside the temp worktree.`,
|
|
546
|
+
String(worktreeWrap.error || '(no error message)'),
|
|
547
|
+
args.repo,
|
|
548
|
+
);
|
|
549
|
+
return 1;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const verdict = worktreeWrap.result;
|
|
553
|
+
if (!verdict.ok) {
|
|
554
|
+
const stderr =
|
|
555
|
+
(verdict.error && verdict.error.stderr) ||
|
|
556
|
+
(verdict.error && verdict.error.message) ||
|
|
557
|
+
'(no stderr)';
|
|
558
|
+
if (verdict.timedOut) {
|
|
559
|
+
openFailureIssue(
|
|
560
|
+
args.job,
|
|
561
|
+
`Job exceeded ${DEFAULT_JOB_TIMEOUT_MS}ms hard timeout.`,
|
|
562
|
+
stderr,
|
|
563
|
+
args.repo,
|
|
564
|
+
);
|
|
565
|
+
} else {
|
|
566
|
+
openFailureIssue(args.job, `Job failed permanently after retry.`, stderr, args.repo);
|
|
567
|
+
}
|
|
568
|
+
return 1;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const result = verdict.result || {};
|
|
572
|
+
if (result.skipped) {
|
|
573
|
+
console.log(`scheduled-runner: ${args.job} skipped — ${result.message}`);
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
if (result.silent) {
|
|
577
|
+
console.log(`scheduled-runner: ${args.job} green — silence-on-green`);
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
580
|
+
console.log(`scheduled-runner: ${args.job} → passed=${result.passed}`);
|
|
581
|
+
return result.passed ? 0 : 1;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ============================================================
|
|
585
|
+
// Exports (for tests) + CLI
|
|
586
|
+
// ============================================================
|
|
587
|
+
|
|
588
|
+
module.exports = {
|
|
589
|
+
main,
|
|
590
|
+
parseArgs,
|
|
591
|
+
detectDefaultBranch,
|
|
592
|
+
hasDiffSinceYesterday,
|
|
593
|
+
listDedupIssues,
|
|
594
|
+
postDedupIssue,
|
|
595
|
+
openFailureIssue,
|
|
596
|
+
readUsageLog,
|
|
597
|
+
writeUsageLog,
|
|
598
|
+
recordUsage,
|
|
599
|
+
runOnce,
|
|
600
|
+
runJobWithRetry,
|
|
601
|
+
assertReadOnlyEnv,
|
|
602
|
+
// Exported for R-379 fix tests:
|
|
603
|
+
runPerPrReview,
|
|
604
|
+
JOB_HANDLERS,
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
if (require.main === module) {
|
|
608
|
+
main(process.argv.slice(2))
|
|
609
|
+
.then((code) => process.exit(code))
|
|
610
|
+
.catch((err) => {
|
|
611
|
+
console.error(`scheduled-runner: fatal: ${err.stack || err.message}`);
|
|
612
|
+
process.exit(1);
|
|
613
|
+
});
|
|
614
|
+
}
|