@galda/cli 0.10.34 → 0.10.35
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/app/index.html +430 -101
- package/bin/manager-for-ai.mjs +12 -2
- package/engine/lib.mjs +103 -1
- package/engine/relay-supervisor.mjs +113 -0
- package/engine/server.mjs +151 -37
- package/engine/verify.mjs +9 -2
- package/package.json +1 -1
package/bin/manager-for-ai.mjs
CHANGED
|
@@ -118,8 +118,18 @@ await import(pathToFileURL(join(ROOT, 'engine', 'server.mjs')).href);
|
|
|
118
118
|
// public build will default it to the deployed relay. See relay/DEPLOY.md.
|
|
119
119
|
if (process.env.RELAY_URL) {
|
|
120
120
|
const { spawn } = await import('node:child_process');
|
|
121
|
-
const
|
|
122
|
-
|
|
121
|
+
const { superviseRelay } = await import(pathToFileURL(join(ROOT, 'engine', 'relay-supervisor.mjs')).href);
|
|
122
|
+
// Supervise the relay-client: if the CHILD exits (uncaught throw, single-
|
|
123
|
+
// instance lock refuse) we bring it back with backoff, otherwise the app goes
|
|
124
|
+
// silently offline ("Unsent"). A tight crash loop (a config/lock error) trips a
|
|
125
|
+
// circuit breaker so we stop hammering and say why. relay-client heals its own
|
|
126
|
+
// dropped socket; this heals the whole process dying. See engine/relay-supervisor.mjs.
|
|
127
|
+
const sup = superviseRelay({
|
|
128
|
+
spawn: () => spawn(process.execPath, [join(ROOT, 'engine', 'relay-client.mjs')], { stdio: 'inherit', env: process.env }),
|
|
129
|
+
log: ({ code, signal, delayMs }) => say(`relay-client exited (code ${code ?? 'n/a'}${signal ? `, ${signal}` : ''}) — restarting in ${Math.round(delayMs / 1000)}s`),
|
|
130
|
+
onGiveUp: () => say('relay-client keeps exiting immediately — giving up; run `npx @galda/cli --signin` or check RELAY_URL'),
|
|
131
|
+
});
|
|
132
|
+
const stop = () => sup.stop();
|
|
123
133
|
process.on('exit', stop);
|
|
124
134
|
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stop(); process.exit(0); });
|
|
125
135
|
say(`relay: connecting to ${process.env.RELAY_URL} — your fixed URL goes live once you sign in`);
|
package/engine/lib.mjs
CHANGED
|
@@ -906,6 +906,73 @@ export function hasTestRelevantChanges(changedFiles) {
|
|
|
906
906
|
return files.some((f) => !DOC_ONLY_RE.test(f));
|
|
907
907
|
}
|
|
908
908
|
|
|
909
|
+
// Extract the set of FAILING test titles from a `node --test` (or TAP) run.
|
|
910
|
+
// The gate needs NAMES, not just a count, so it can tell a NEW failure (this
|
|
911
|
+
// change's fault) from one that was ALREADY red on the base (e.g. a known-red
|
|
912
|
+
// like persist-failure that fails even on clean origin/main — CLAUDE.md).
|
|
913
|
+
// node --test prints `✖ <title> (1234.5ms)` (both inline and again under the
|
|
914
|
+
// `✖ failing tests:` summary header, which carries no title and is skipped);
|
|
915
|
+
// TAP prints `not ok N - <title>` (a `# SKIP`/`# TODO` directive is not a
|
|
916
|
+
// failure). Titles are de-duplicated and sorted for a stable set-diff.
|
|
917
|
+
export function parseFailingTestNames(text) {
|
|
918
|
+
const out = new Set();
|
|
919
|
+
const stripMs = (s) => s.replace(/\s*\(\d[\d.]*m?s\)\s*$/i, '').trim();
|
|
920
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
921
|
+
const line = raw.trim();
|
|
922
|
+
if (!line) continue;
|
|
923
|
+
let m = line.match(/^[✖✗✘×]\s+(.*)$/);
|
|
924
|
+
if (m) {
|
|
925
|
+
const title = stripMs(m[1].trim());
|
|
926
|
+
if (!title || /^failing tests:?$/i.test(title)) continue; // summary header
|
|
927
|
+
out.add(title);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
m = line.match(/^not ok\s+\d+\s*-?\s*(.*)$/i);
|
|
931
|
+
if (m) {
|
|
932
|
+
if (/#\s*(SKIP|TODO)\b/i.test(line)) continue; // directive, not a real failure
|
|
933
|
+
const title = stripMs(m[1].replace(/\s+#.*$/, '').trim());
|
|
934
|
+
if (title) out.add(title);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return [...out].sort();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Compare the CURRENT run's failing tests against the BASE (pre-change) run's
|
|
941
|
+
// so the review gate blocks ONLY on failures this change introduced. A test
|
|
942
|
+
// that was already red on the base is pre-existing noise (unrelated to the
|
|
943
|
+
// goal) — it must not knock a proof-verified goal back to blocked/Failed.
|
|
944
|
+
// Callers should treat an EMPTY baseline as "unknown" and fall back to the old
|
|
945
|
+
// block-on-any-red behaviour (fail closed) — see verifyGate.
|
|
946
|
+
export function classifyTestGate({ current = [], baseline = [] } = {}) {
|
|
947
|
+
const base = new Set(baseline);
|
|
948
|
+
const cur = [...new Set((current ?? []).filter(Boolean))];
|
|
949
|
+
const newFailures = cur.filter((t) => !base.has(t)).sort();
|
|
950
|
+
const preExisting = cur.filter((t) => base.has(t)).sort();
|
|
951
|
+
return { newFailures, preExisting, blocked: newFailures.length > 0 };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// The verification-gate decision (Masa 2026-07-19: "検証/proof は Failed を作らない").
|
|
955
|
+
// PROOF IS ADVISORY: a UI goal whose screenshot could not be captured
|
|
956
|
+
// (proofMissing) or whose pixel heuristic could not auto-confirm the target
|
|
957
|
+
// (proofFailing) is NOT blocked — the work is done and reaches Review with a
|
|
958
|
+
// note; proof adds confidence, it never subtracts a correct result. What still
|
|
959
|
+
// blocks a goal from Review: newly-failing tests (testsFailing — the caller has
|
|
960
|
+
// already made this baseline-aware via classifyTestGate), a promised-but-undone
|
|
961
|
+
// task (nothingVerifiable = a passCondition with no file change and no proof),
|
|
962
|
+
// and the opt-in requireVerifyPass gate (verifyFail, off by default). Pure so
|
|
963
|
+
// the whole gate decision is unit-tested rather than living only inside the
|
|
964
|
+
// server's big async verifyGate.
|
|
965
|
+
export function classifyVerifyGate({
|
|
966
|
+
testsFailing = false, proofMissing = false, proofFailing = false,
|
|
967
|
+
nothingVerifiable = false, verifyFail = false,
|
|
968
|
+
} = {}) {
|
|
969
|
+
const blocked = Boolean(testsFailing || nothingVerifiable || verifyFail);
|
|
970
|
+
const kind = testsFailing ? 'test' : nothingVerifiable ? 'noop' : verifyFail ? 'verify' : null;
|
|
971
|
+
// Advisory note surfaced on the review even when the goal is NOT blocked.
|
|
972
|
+
const proofAdvisory = proofMissing ? 'missing' : proofFailing ? 'unconfirmed' : null;
|
|
973
|
+
return { blocked, kind, proofAdvisory };
|
|
974
|
+
}
|
|
975
|
+
|
|
909
976
|
// Whether to ALSO capture a BASELINE ("before") shot for a before/after
|
|
910
977
|
// comparison. Only worth attempting when the baseline is a genuinely
|
|
911
978
|
// different checkout of the code than the one just captured as "after" — a
|
|
@@ -1907,7 +1974,42 @@ export const WORKFLOW_BUCKETS = ['todo', 'doing', 'review', 'done'];
|
|
|
1907
1974
|
// Tools a `claude -p` worker may call (--allowedTools). TodoWrite must stay
|
|
1908
1975
|
// in this list — it's how the UI's To-do section (sendTodos / SSE 'todos')
|
|
1909
1976
|
// gets any data at all; without it workers can never emit {kind:'todos'}.
|
|
1910
|
-
|
|
1977
|
+
//
|
|
1978
|
+
// Bash was locked to node/npm/npx, which produced the "works in Claude Code,
|
|
1979
|
+
// fails here" class: a goal that legitimately needs python, a build tool, or
|
|
1980
|
+
// even a read-only `git log` would just fail — an error a plain Claude Code
|
|
1981
|
+
// session never hits. Widened here to the common legitimate commands.
|
|
1982
|
+
//
|
|
1983
|
+
// Honest note on what this list does and does NOT buy: it is friction control,
|
|
1984
|
+
// not a security boundary. Bash(node:*) already permits arbitrary execution
|
|
1985
|
+
// (`node -e "..."` can touch the network, delete files, anything), so the old
|
|
1986
|
+
// narrow list contained nothing — it only blocked honest direct commands.
|
|
1987
|
+
// Real isolation would need an OS sandbox (codex runs with --sandbox; the
|
|
1988
|
+
// claude-code worker does not). Given that, the exclusions below are posture,
|
|
1989
|
+
// not a wall:
|
|
1990
|
+
// - git WRITE ops (commit/push/reset/checkout) are left off the fast path so
|
|
1991
|
+
// the worker does not fight the Manager, which owns git in the worktree
|
|
1992
|
+
// (the worker prompt already says "do not commit"). Read-only git is on.
|
|
1993
|
+
// - rm / curl / wget stay off the fast path. (They are reachable via node
|
|
1994
|
+
// anyway; keeping them non-first-class is a stated-posture choice. Whether
|
|
1995
|
+
// to simplify to a blanket `Bash` — technically equivalent risk — is a
|
|
1996
|
+
// customer-safety decision for Masa, not a silent flip here.)
|
|
1997
|
+
const WORKER_BASH = [
|
|
1998
|
+
// JS/TS runtimes and package managers
|
|
1999
|
+
'node', 'npm', 'npx', 'pnpm', 'yarn', 'bun', 'deno',
|
|
2000
|
+
// other languages a task might actually need
|
|
2001
|
+
'python', 'python3', 'pip', 'pip3', 'pytest', 'go', 'cargo', 'rustc',
|
|
2002
|
+
'ruby', 'bundle', 'make',
|
|
2003
|
+
// build/test/lint runners invoked directly
|
|
2004
|
+
'tsc', 'jest', 'vitest', 'eslint', 'prettier',
|
|
2005
|
+
// read-only git — inspect history/diffs like you would locally
|
|
2006
|
+
'git status', 'git log', 'git diff', 'git show', 'git branch',
|
|
2007
|
+
'git blame', 'git ls-files', 'git rev-parse',
|
|
2008
|
+
// read/inspect shell utilities (file EDITS still go through Edit/Write)
|
|
2009
|
+
'cat', 'ls', 'head', 'tail', 'wc', 'find', 'grep', 'rg',
|
|
2010
|
+
'sed', 'awk', 'sort', 'uniq', 'diff', 'echo', 'which', 'env', 'pwd', 'date',
|
|
2011
|
+
].map((c) => `Bash(${c}:*)`).join(',');
|
|
2012
|
+
export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,${WORKER_BASH}`;
|
|
1911
2013
|
|
|
1912
2014
|
// ---- SKILL picker ----------------------------------------------------------
|
|
1913
2015
|
// Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Launcher-side supervisor for the relay-client CHILD process.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists (2026-07-18 incident): the launcher (bin/manager-for-ai.mjs)
|
|
4
|
+
// spawns engine/relay-client.mjs exactly ONCE and only kills it when the parent
|
|
5
|
+
// goes down — there was no path the other way (child dies -> bring it back).
|
|
6
|
+
// relay-client already self-heals a dropped SOCKET (backoff reconnect, capped at
|
|
7
|
+
// 15s), but when the whole PROCESS exits — an uncaught throw, or the single-
|
|
8
|
+
// instance lock refusing to start — nothing respawns it, so the user's app
|
|
9
|
+
// silently goes offline and every new goal shows "Unsent – tap to resend". This
|
|
10
|
+
// is the customer-facing half of the fix (the operator's own box is now kept up
|
|
11
|
+
// by a launchd KeepAlive job; npx users have no launchd, only this launcher).
|
|
12
|
+
//
|
|
13
|
+
// Split into a PURE policy reducer (relayRespawnDecision) and an impure driver
|
|
14
|
+
// (superviseRelay) that owns spawn()/timers/clock — same shape as
|
|
15
|
+
// relayReconnectDecision in engine/lib.mjs. The policy is unit-tested without any
|
|
16
|
+
// real process; the driver is integration-tested with a fake child.
|
|
17
|
+
|
|
18
|
+
// Fresh supervisor state. `delayMs` is the last backoff used; `quickExits` counts
|
|
19
|
+
// consecutive exits that happened inside the "quick-crash" window; `gaveUp` latches
|
|
20
|
+
// once the circuit breaker trips.
|
|
21
|
+
export const relaySupervisorInit = { delayMs: 0, quickExits: 0, gaveUp: false };
|
|
22
|
+
|
|
23
|
+
// Decide what to do when the supervised child exits. Pure — no clock, no I/O.
|
|
24
|
+
//
|
|
25
|
+
// state : previous supervisor state (start from relaySupervisorInit)
|
|
26
|
+
// event : { uptimeMs } — how long the child that just exited had been alive
|
|
27
|
+
// opts : tunables (defaults match the relay socket backoff feel)
|
|
28
|
+
// minMs floor delay for the first / a reset respawn (1s)
|
|
29
|
+
// maxMs ceiling the exponential backoff saturates at (15s)
|
|
30
|
+
// quickMs a child that lived less than this "crash-looped" (10s)
|
|
31
|
+
// limit consecutive quick exits before we give up (5)
|
|
32
|
+
//
|
|
33
|
+
// Returns { restart, delayMs, gaveUp, state }.
|
|
34
|
+
// - A child that lived past `quickMs` is treated as having made progress: the
|
|
35
|
+
// backoff and the quick-exit counter both reset (a healthy long run should not
|
|
36
|
+
// inherit a punishing delay from an earlier bad patch).
|
|
37
|
+
// - Consecutive quick exits escalate the delay (min, 2x, 4x, ... capped at max)
|
|
38
|
+
// and, once they reach `limit`, trip the circuit breaker so we stop hammering a
|
|
39
|
+
// config/lock error forever. The caller logs why and stands down.
|
|
40
|
+
export function relayRespawnDecision(state, event, opts = {}) {
|
|
41
|
+
const { minMs = 1000, maxMs = 15000, quickMs = 10000, limit = 5 } = opts;
|
|
42
|
+
const prev = Number(state?.delayMs);
|
|
43
|
+
const lastDelay = Number.isFinite(prev) ? prev : 0;
|
|
44
|
+
const uptimeMs = Number(event?.uptimeMs) || 0;
|
|
45
|
+
const quick = uptimeMs < quickMs;
|
|
46
|
+
const quickExits = quick ? (Number(state?.quickExits) || 0) + 1 : 0;
|
|
47
|
+
|
|
48
|
+
if (quickExits >= limit) {
|
|
49
|
+
return { restart: false, delayMs: 0, gaveUp: true, state: { delayMs: lastDelay, quickExits, gaveUp: true } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const delayMs = quick
|
|
53
|
+
? (lastDelay < minMs ? minMs : Math.min(lastDelay * 2, maxMs))
|
|
54
|
+
: minMs;
|
|
55
|
+
return { restart: true, delayMs, gaveUp: false, state: { delayMs, quickExits, gaveUp: false } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Drive a respawning child. Impure: owns spawn()/timers/clock, all injectable so
|
|
59
|
+
// the loop is testable with a fake child and fast delays.
|
|
60
|
+
//
|
|
61
|
+
// spawn () => ChildProcess-like (must emit 'exit' and have .kill())
|
|
62
|
+
// now () => ms clock (default Date.now)
|
|
63
|
+
// setTimer/clearTimer timer fns (default global setTimeout/clearTimeout)
|
|
64
|
+
// log ({ code, signal, delayMs }) => void — a respawn was scheduled
|
|
65
|
+
// onGiveUp () => void — circuit breaker tripped
|
|
66
|
+
// opts passed through to relayRespawnDecision
|
|
67
|
+
//
|
|
68
|
+
// Returns { stop, child, state } — stop() suppresses further respawns (normal
|
|
69
|
+
// shutdown) and kills the current child.
|
|
70
|
+
export function superviseRelay({
|
|
71
|
+
spawn,
|
|
72
|
+
now = () => Date.now(),
|
|
73
|
+
setTimer = setTimeout,
|
|
74
|
+
clearTimer = clearTimeout,
|
|
75
|
+
log = () => {},
|
|
76
|
+
onGiveUp = () => {},
|
|
77
|
+
opts,
|
|
78
|
+
} = {}) {
|
|
79
|
+
let shuttingDown = false;
|
|
80
|
+
let state = { ...relaySupervisorInit };
|
|
81
|
+
let child = null;
|
|
82
|
+
let startedAt = 0;
|
|
83
|
+
let timer = null;
|
|
84
|
+
|
|
85
|
+
const start = () => {
|
|
86
|
+
startedAt = now();
|
|
87
|
+
child = spawn();
|
|
88
|
+
child.on('exit', (code, signal) => {
|
|
89
|
+
child = null;
|
|
90
|
+
if (shuttingDown) return; // parent asked us to stop — don't fight it
|
|
91
|
+
const uptimeMs = now() - startedAt;
|
|
92
|
+
const decision = relayRespawnDecision(state, { uptimeMs }, opts);
|
|
93
|
+
state = decision.state;
|
|
94
|
+
if (!decision.restart) { onGiveUp(); return; }
|
|
95
|
+
log({ code, signal, delayMs: decision.delayMs });
|
|
96
|
+
timer = setTimer(start, decision.delayMs);
|
|
97
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const stop = () => {
|
|
102
|
+
shuttingDown = true;
|
|
103
|
+
if (timer) { clearTimer(timer); timer = null; }
|
|
104
|
+
try { child?.kill(); } catch { /* already gone */ }
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
start();
|
|
108
|
+
return {
|
|
109
|
+
stop,
|
|
110
|
+
get child() { return child; },
|
|
111
|
+
get state() { return state; },
|
|
112
|
+
};
|
|
113
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
|
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
23
|
import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
|
|
25
25
|
import { openPR } from './pr.mjs';
|
|
26
26
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
27
27
|
|
|
@@ -1755,7 +1755,17 @@ function queueReplyTask(goal, text) {
|
|
|
1755
1755
|
tasks.push(task);
|
|
1756
1756
|
saveTask(task);
|
|
1757
1757
|
queues.get(goal.projectId).waiting.push(task);
|
|
1758
|
-
|
|
1758
|
+
// Spawn the worker on the NEXT event-loop turn, not synchronously here. runTask() runs
|
|
1759
|
+
// straight through to its first await — and that stretch includes goalWorkDir()'s
|
|
1760
|
+
// `git worktree add` (measured 1.78s on a checkout with ~135 worktrees). Callers like
|
|
1761
|
+
// POST /api/goals/:id/dismiss and /reply invoke queueReplyTask from inside their HTTP
|
|
1762
|
+
// handler and send the 200 right after, so a synchronous pump() made the response wait on
|
|
1763
|
+
// the worktree — the review UI looked stuck on "review" for seconds while the Ledger had
|
|
1764
|
+
// already claimed "Feedback sent → back to To Do" (Masa 2026-07-19). setImmediate lets the
|
|
1765
|
+
// response flush first; the task is already queued and persisted, so nothing is lost if the
|
|
1766
|
+
// worker starts a turn later. (setImmediate, not nextTick/microtask: those run before the
|
|
1767
|
+
// socket write flushes, so the blocking git call would still delay the response.)
|
|
1768
|
+
setImmediate(() => pump(goal.projectId));
|
|
1759
1769
|
return task;
|
|
1760
1770
|
}
|
|
1761
1771
|
|
|
@@ -2192,8 +2202,12 @@ async function runTask(task) {
|
|
|
2192
2202
|
} finally {
|
|
2193
2203
|
eph?.kill();
|
|
2194
2204
|
}
|
|
2195
|
-
sendAct(task, `verify: ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
|
|
2196
|
-
|
|
2205
|
+
sendAct(task, `verify: ${verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (check could not run)' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
|
|
2206
|
+
// An inconclusive verdict = the check itself couldn't run (authored check
|
|
2207
|
+
// referenced hooks the app doesn't expose — #487). Retrying will just
|
|
2208
|
+
// throw again, so stop and let it route to human review rather than
|
|
2209
|
+
// burning attempts and scoring the change as failed.
|
|
2210
|
+
if (verdict.pass || verdict.inconclusive) break;
|
|
2197
2211
|
lastFailure = verdict.detail;
|
|
2198
2212
|
} catch (e) {
|
|
2199
2213
|
sendAct(task, `verify: could not run — ${String(e.message ?? e).slice(0, 120)}`);
|
|
@@ -2212,11 +2226,19 @@ async function runTask(task) {
|
|
|
2212
2226
|
// FAIL verdict on a killed (incomplete) worker isn't authoritative either,
|
|
2213
2227
|
// so the stop reason takes precedence. (Mirrors the budget-breach path.)
|
|
2214
2228
|
// 3. a verify that actually ran and FAILED = 'failed'.
|
|
2215
|
-
// 4.
|
|
2229
|
+
// 4. an INCONCLUSIVE verify (the check threw / returned garbage — the
|
|
2230
|
+
// verifier is at fault, not necessarily the change, #487) is NOT
|
|
2231
|
+
// authoritative: fall through to the exit code so a cleanly-finished
|
|
2232
|
+
// worker still reaches review for a human, instead of a false 'failed'.
|
|
2233
|
+
// 5. no verify = the exit code decides.
|
|
2216
2234
|
const forcedStop = isForcedStop(code, timedOut);
|
|
2235
|
+
// A verdict only counts as authoritative when the check actually judged the
|
|
2236
|
+
// change. An inconclusive verdict (check threw / returned garbage) is treated
|
|
2237
|
+
// like "no verdict" everywhere below — status, proof, and snapshot capture.
|
|
2238
|
+
const conclusive = verdict && !verdict.inconclusive;
|
|
2217
2239
|
task.status = verdict?.pass ? 'done'
|
|
2218
2240
|
: forcedStop ? 'interrupted'
|
|
2219
|
-
:
|
|
2241
|
+
: conclusive ? 'failed'
|
|
2220
2242
|
: (code === 0 ? 'done' : 'failed');
|
|
2221
2243
|
// Never leave the reason as a bare "(no output)": if the worker produced no
|
|
2222
2244
|
// report text, result already carries workerExitReason(); make doubly sure a
|
|
@@ -2225,19 +2247,26 @@ async function runTask(task) {
|
|
|
2225
2247
|
task.result = result.slice(0, 4000);
|
|
2226
2248
|
task.usage = sumUsage(usages);
|
|
2227
2249
|
if (verdict) {
|
|
2228
|
-
|
|
2229
|
-
task.
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2250
|
+
const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
|
|
2251
|
+
task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0, 4000);
|
|
2252
|
+
// Only attach an authoritative proof for a conclusive verdict. An
|
|
2253
|
+
// inconclusive one must NOT leave a pass:false proof — that would trip
|
|
2254
|
+
// verifyGate's proofFailing and re-block the goal through the back door
|
|
2255
|
+
// (#487). Fall through to the snapshot capture below instead.
|
|
2256
|
+
if (conclusive) {
|
|
2257
|
+
task.proof = {
|
|
2258
|
+
pass: verdict.pass,
|
|
2259
|
+
detail: verdict.detail.slice(0, 500),
|
|
2260
|
+
gif: verdict.gifPath?.split('/').pop() ?? null,
|
|
2261
|
+
png: verdict.shotPath?.split('/').pop() ?? null,
|
|
2262
|
+
mp4: verdict.videoPath?.split('/').pop() ?? null,
|
|
2263
|
+
// STRONG proof: this came from an independent passCondition verification,
|
|
2264
|
+
// not a generic auto-snapshot. Only this flag lets a force-stopped (interrupted)
|
|
2265
|
+
// task count toward goal completion (isProofVerified) — a weak "UI capture"
|
|
2266
|
+
// snapshot never auto-promotes an interrupted goal past human review.
|
|
2267
|
+
verified: true,
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2241
2270
|
}
|
|
2242
2271
|
const after = await gitChanges(workDir);
|
|
2243
2272
|
task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
@@ -2250,7 +2279,7 @@ async function runTask(task) {
|
|
|
2250
2279
|
// baselineDir = the user's real project dir (unmodified, pre-goal code) —
|
|
2251
2280
|
// when it's genuinely different from wproject.dir (a real worktree, not
|
|
2252
2281
|
// the shared-dir fallback), also attempt a before/after pair.
|
|
2253
|
-
if (!
|
|
2282
|
+
if (!conclusive && task.status === 'done' && shouldCaptureProof({ changedFiles: task.changedFiles, hasProof: Boolean(task.proof), canCapture: canSnapshot(wproject) })) {
|
|
2254
2283
|
try {
|
|
2255
2284
|
sendAct(task, 'proof: capturing UI change (screenshot + clip) of the edited app…');
|
|
2256
2285
|
await captureSnapshotProof(task, wproject, { baselineDir: project.dir, goal });
|
|
@@ -2305,7 +2334,9 @@ function runTestsOnce(dir) {
|
|
|
2305
2334
|
const timeoutFlakes = (text.match(/did not report ready in time/g) || []).length;
|
|
2306
2335
|
const realAssertion = /AssertionError|Expected values to be|expected .+ (?:to |but )/i.test(text);
|
|
2307
2336
|
const failed = realAssertion ? failedTotal : Math.max(0, failedTotal - timeoutFlakes);
|
|
2308
|
-
|
|
2337
|
+
// Capture the failing test NAMES too (not just the count) so the gate can
|
|
2338
|
+
// diff them against the base's failures and block only on NEW ones.
|
|
2339
|
+
res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
|
|
2309
2340
|
});
|
|
2310
2341
|
});
|
|
2311
2342
|
}
|
|
@@ -2320,13 +2351,45 @@ async function runProjectTests(dir) {
|
|
|
2320
2351
|
// never blocked on a flaky test.
|
|
2321
2352
|
if (r.failed > 0) {
|
|
2322
2353
|
const r2 = await runTestsOnce(dir);
|
|
2354
|
+
const kept = r2.failed <= r.failed ? r2 : r; // the run that saw fewer failures
|
|
2323
2355
|
r = {
|
|
2324
2356
|
passed: Math.max(r.passed, r2.passed),
|
|
2325
2357
|
failed: Math.min(r.failed, r2.failed),
|
|
2326
|
-
detail:
|
|
2358
|
+
detail: kept.detail || r.detail || r2.detail || '',
|
|
2359
|
+
failingNames: kept.failingNames ?? r.failingNames ?? [],
|
|
2327
2360
|
};
|
|
2328
2361
|
}
|
|
2329
|
-
return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '' };
|
|
2362
|
+
return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// Failing tests on the BASE (pre-change) checkout — the set the review gate
|
|
2366
|
+
// subtracts so a goal is blocked only on failures IT introduced, never on a
|
|
2367
|
+
// test that was already red (e.g. persist-failure on clean origin/main). Lazy
|
|
2368
|
+
// + cached by the base's HEAD sha in MANAGER_HOME: computed only when a goal's
|
|
2369
|
+
// own run is red, then reused across goals sharing the same base (main moves a
|
|
2370
|
+
// few times a day, so this is a handful of full runs a day, not one per goal).
|
|
2371
|
+
// Any failure to compute → [] = "unknown", and the caller falls back to the
|
|
2372
|
+
// old block-on-any-red behaviour (fail closed).
|
|
2373
|
+
const BASELINE_CACHE = join(DATA_DIR, 'test-baseline.json');
|
|
2374
|
+
async function getBaselineFailures(baseProject) {
|
|
2375
|
+
const dir = baseProject?.dir;
|
|
2376
|
+
if (!dir) return [];
|
|
2377
|
+
let sha = '';
|
|
2378
|
+
try {
|
|
2379
|
+
const r = spawnSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
2380
|
+
if (r.status === 0) sha = String(r.stdout || '').trim();
|
|
2381
|
+
} catch { return []; }
|
|
2382
|
+
if (!sha) return [];
|
|
2383
|
+
try {
|
|
2384
|
+
const cached = JSON.parse(readFileSync(BASELINE_CACHE, 'utf8'));
|
|
2385
|
+
if (cached && cached.sha === sha && Array.isArray(cached.failingNames)) return cached.failingNames;
|
|
2386
|
+
} catch { /* no cache yet */ }
|
|
2387
|
+
let base;
|
|
2388
|
+
try { base = await runProjectTests(dir); } catch { return []; }
|
|
2389
|
+
if (!base?.ran) return [];
|
|
2390
|
+
const failingNames = base.failingNames ?? [];
|
|
2391
|
+
try { writeFileSync(BASELINE_CACHE, JSON.stringify({ sha, failingNames, at: new Date().toISOString() })); } catch { /* best effort */ }
|
|
2392
|
+
return failingNames;
|
|
2330
2393
|
}
|
|
2331
2394
|
|
|
2332
2395
|
// ---- See all Ledger engine jobs (HANDOFF-v45 §5 / PRD §5.1) ----------------
|
|
@@ -2704,7 +2767,42 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2704
2767
|
}
|
|
2705
2768
|
const proofMissing = canCapture && uiTasks.length > 0 && !uiTasks.some((t) => t.proof);
|
|
2706
2769
|
const proofFailing = canCapture && uiTasks.some((t) => t.proof && t.proof.pass === false);
|
|
2707
|
-
|
|
2770
|
+
let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
|
|
2771
|
+
// Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
|
|
2772
|
+
// introduced. A test already red on the base — e.g. persist-failure on clean
|
|
2773
|
+
// origin/main — is pre-existing noise and must not knock a proof-verified
|
|
2774
|
+
// goal (#474/#494) or even a no-op goal (#465) back to blocked/Failed. Only
|
|
2775
|
+
// consult the base when the goal's own run is red (keeps the green path free
|
|
2776
|
+
// and pays the base run at most a few times a day, cached by base sha). An
|
|
2777
|
+
// empty baseline = "unknown" → fall through to the old block-on-any-red.
|
|
2778
|
+
// Only trust a baseline diff when the base is a genuinely SEPARATE checkout
|
|
2779
|
+
// from the changed worktree. finishGoalIfComplete runs in the goal's own
|
|
2780
|
+
// worktree (prProject.dir === the worktree), so the base must be the user's
|
|
2781
|
+
// ORIGINAL project dir (on main ≈ origin/main, without this goal's change) —
|
|
2782
|
+
// resolved from the registry, not the worktree-scoped project we were handed.
|
|
2783
|
+
// When they'd be the same dir (shared-dir fallback, no isolated worktree) the
|
|
2784
|
+
// "base" already contains this change, so fall back to the old block-on-any-
|
|
2785
|
+
// red (fail closed).
|
|
2786
|
+
const baseDir = projects.find((p) => p.id === goal.projectId)?.dir;
|
|
2787
|
+
const canBaseline = baseDir && prProject?.dir && baseDir !== prProject.dir && existsSync(baseDir);
|
|
2788
|
+
if (testsFailing && canBaseline) {
|
|
2789
|
+
const baseline = await getBaselineFailures({ dir: baseDir });
|
|
2790
|
+
if (baseline.length) {
|
|
2791
|
+
const gate = classifyTestGate({ current: goal.testResult.failingNames ?? [], baseline });
|
|
2792
|
+
goal.testResult.newFailures = gate.newFailures;
|
|
2793
|
+
goal.testResult.preExisting = gate.preExisting;
|
|
2794
|
+
if (!gate.blocked) {
|
|
2795
|
+
testsFailing = false;
|
|
2796
|
+
goal.testResult.preExistingOnly = true;
|
|
2797
|
+
goalAct(`Project tests: ${gate.preExisting.length} failing test(s) were already red on the base (unrelated to this change), 0 new — not blocking. Pre-existing: ${gate.preExisting.slice(0, 3).join(' / ')}`);
|
|
2798
|
+
} else {
|
|
2799
|
+
goalAct(`Project tests: ${gate.newFailures.length} NEW failure(s) from this change (of ${goal.testResult.failed} total; ${gate.preExisting.length} pre-existing): ${gate.newFailures.slice(0, 3).join(' / ')}`);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
// Count that drives the block message: the NEW failures when we could diff
|
|
2804
|
+
// against the base, else the raw total (fail-closed when the base is unknown).
|
|
2805
|
+
const blockingFailCount = goal.testResult.newFailures?.length ?? goal.testResult.failed;
|
|
2708
2806
|
// §4補足: requireVerifyPass (opt-in, off by default) makes the gate strict —
|
|
2709
2807
|
// EVERY task must carry a passing proof, not just UI tasks. Only ever tightens;
|
|
2710
2808
|
// never conflicts with §6 (still can't reach Done without a human).
|
|
@@ -2722,16 +2820,19 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2722
2820
|
if (testsFailing) {
|
|
2723
2821
|
const failure = recordGoalFailure(goal, {
|
|
2724
2822
|
kind: 'test',
|
|
2725
|
-
reason: `テスト失敗 ${
|
|
2823
|
+
reason: `テスト失敗 ${blockingFailCount} 件`,
|
|
2726
2824
|
changedFiles,
|
|
2727
2825
|
testResult: goal.testResult,
|
|
2728
2826
|
});
|
|
2827
|
+
const newFailList = goal.testResult.newFailures?.length
|
|
2828
|
+
? `Failures introduced by this change (fix ONLY these — pre-existing reds are not your fault):\n- ${goal.testResult.newFailures.join('\n- ')}`
|
|
2829
|
+
: '';
|
|
2729
2830
|
const used = Number(goal.autoRework?.testFailures ?? 0);
|
|
2730
2831
|
if (used < MAX_AUTO_TEST_REWORKS) {
|
|
2731
2832
|
goal.autoRework = {
|
|
2732
2833
|
...(goal.autoRework ?? {}),
|
|
2733
2834
|
testFailures: used + 1,
|
|
2734
|
-
lastReason: `テスト失敗 ${
|
|
2835
|
+
lastReason: `テスト失敗 ${blockingFailCount} 件`,
|
|
2735
2836
|
lastAt: new Date().toISOString(),
|
|
2736
2837
|
};
|
|
2737
2838
|
goal.blocked = null;
|
|
@@ -2739,7 +2840,8 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2739
2840
|
saveGoal(goal);
|
|
2740
2841
|
goalAct(`Tests failed; queued automatic worker feedback (${used + 1}/${MAX_AUTO_TEST_REWORKS}) with the failing output.`);
|
|
2741
2842
|
queueReplyTask(goal, [
|
|
2742
|
-
`Manager auto-feedback: npm test failed (${
|
|
2843
|
+
`Manager auto-feedback: npm test failed (${blockingFailCount} failing).`,
|
|
2844
|
+
newFailList,
|
|
2743
2845
|
failure?.whatHappened ? `What happened: ${failure.whatHappened}` : '',
|
|
2744
2846
|
failure?.yourCall ?? 'Please inspect the failure output below, fix the regression, and rerun the project tests until they pass.',
|
|
2745
2847
|
failure?.nextPolicy ? `Prevention policy: ${failure.nextPolicy}` : '',
|
|
@@ -2751,23 +2853,32 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2751
2853
|
return;
|
|
2752
2854
|
}
|
|
2753
2855
|
}
|
|
2754
|
-
|
|
2856
|
+
// Proof is advisory now (Masa 2026-07-19): proofMissing / proofFailing no
|
|
2857
|
+
// longer block — the work is done; a screenshot that could not be captured or
|
|
2858
|
+
// a pixel heuristic that could not auto-confirm the target is a NOTE on the
|
|
2859
|
+
// review, not a Failed goal (audit false-failure #3/#2). The proof artifact
|
|
2860
|
+
// (when there is one) is still attached for the human to look at. What still
|
|
2861
|
+
// blocks: newly-failing tests, a promised-but-undone task, opt-in verifyFail.
|
|
2862
|
+
const gate = classifyVerifyGate({ testsFailing, proofMissing, proofFailing, verifyFail, nothingVerifiable });
|
|
2863
|
+
goal.proofNote = gate.proofAdvisory === 'missing'
|
|
2864
|
+
? 'UI変更ですが proof(スクショ/動画) を撮影できませんでした — 目視で確認してください'
|
|
2865
|
+
: gate.proofAdvisory === 'unconfirmed'
|
|
2866
|
+
? 'proof は撮れましたが、依頼対象のUIを自動確認できませんでした — 目視で確認してください'
|
|
2867
|
+
: null;
|
|
2868
|
+
if (goal.proofNote) goalAct(`Proof advisory (not blocking): ${goal.proofNote}`);
|
|
2869
|
+
if (gate.blocked) {
|
|
2755
2870
|
if (!testsFailing) {
|
|
2756
2871
|
recordGoalFailure(goal, {
|
|
2757
|
-
kind:
|
|
2758
|
-
reason:
|
|
2759
|
-
: proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
|
|
2760
|
-
: nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
|
|
2872
|
+
kind: gate.kind,
|
|
2873
|
+
reason: nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
|
|
2761
2874
|
: '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
|
|
2762
2875
|
changedFiles,
|
|
2763
2876
|
testResult: goal.testResult,
|
|
2764
2877
|
});
|
|
2765
2878
|
}
|
|
2766
2879
|
goal.blocked = {
|
|
2767
|
-
kind:
|
|
2768
|
-
reason: testsFailing ? `テスト失敗 ${
|
|
2769
|
-
: proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
|
|
2770
|
-
: proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
|
|
2880
|
+
kind: gate.kind,
|
|
2881
|
+
reason: testsFailing ? `テスト失敗 ${blockingFailCount} 件(自動修正 ${Number(goal.autoRework?.testFailures ?? 0)} 回後も失敗)`
|
|
2771
2882
|
: nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
|
|
2772
2883
|
: '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
|
|
2773
2884
|
};
|
|
@@ -2777,7 +2888,10 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2777
2888
|
goal.blocked = null;
|
|
2778
2889
|
if (goal.autoRework?.testFailures) goal.autoRework = { ...goal.autoRework, resolvedAt: new Date().toISOString() };
|
|
2779
2890
|
goal.status = nextGoalStatus({ allDone, wantsPR: goal.wantsPR, allVerified, reviewDefinition });
|
|
2780
|
-
|
|
2891
|
+
const preNote = goal.testResult?.preExistingOnly
|
|
2892
|
+
? ` (${goal.testResult.preExisting?.length ?? 0} pre-existing test failure(s) ignored as unrelated to this change)`
|
|
2893
|
+
: '';
|
|
2894
|
+
goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}`);
|
|
2781
2895
|
}
|
|
2782
2896
|
}
|
|
2783
2897
|
|
package/engine/verify.mjs
CHANGED
|
@@ -129,10 +129,17 @@ export async function runVerification({ entryUrl, verify, outBase, record = true
|
|
|
129
129
|
try {
|
|
130
130
|
result = await verify(page, makeUiHelpers(page));
|
|
131
131
|
if (!result || typeof result.pass !== 'boolean') {
|
|
132
|
-
|
|
132
|
+
// The check did not produce a verdict — the VERIFIER is at fault, not
|
|
133
|
+
// necessarily the change. Mark inconclusive so the caller routes it to
|
|
134
|
+
// human review instead of scoring the worker's change as failed.
|
|
135
|
+
result = { pass: false, inconclusive: true, detail: `verify script returned an invalid result: ${JSON.stringify(result).slice(0, 200)}` };
|
|
133
136
|
}
|
|
134
137
|
} catch (e) {
|
|
135
|
-
|
|
138
|
+
// The check threw (e.g. an authored check referencing renderTasks/
|
|
139
|
+
// window.state that the booted app doesn't expose — goal #487). It never
|
|
140
|
+
// reached a real judgement, so this is inconclusive, NOT "the change is
|
|
141
|
+
// broken". The caller must not mark the task failed on this alone.
|
|
142
|
+
result = { pass: false, inconclusive: true, detail: `verify script threw: ${String(e.message ?? e).slice(0, 300)}` };
|
|
136
143
|
}
|
|
137
144
|
await sleep(1200); // keep the outcome on screen in the video
|
|
138
145
|
if (recorder) await recorder.stop();
|
package/package.json
CHANGED