@galda/cli 0.10.34 → 0.10.36
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 +475 -107
- package/bin/manager-for-ai.mjs +12 -2
- package/engine/lib.mjs +119 -1
- package/engine/relay-supervisor.mjs +113 -0
- package/engine/server.mjs +275 -38
- package/engine/verify.mjs +9 -2
- package/package.json +1 -1
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, detectPauseIntent, 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
|
|
|
@@ -657,11 +657,17 @@ if (process.env.MANAGER_SEED_RUNNING_FIXTURE === '1') {
|
|
|
657
657
|
const goalId = nextId++, taskId = nextId++;
|
|
658
658
|
goals.push({
|
|
659
659
|
id: goalId, projectId: 'default', text: '[verify fixture] 実行中ゴール', status: 'running',
|
|
660
|
+
// startedAt is what a REAL running goal carries (server.mjs sets it when the
|
|
661
|
+
// first task starts — see runTask). Without it the board bucketed this as
|
|
662
|
+
// To Do while the list showed it under Doing (task-level) — the two views
|
|
663
|
+
// disagreed on a malformed fixture. Set it so the seed matches reality:
|
|
664
|
+
// both views place a running goal in Doing (Masa 2026-07-19).
|
|
665
|
+
startedAt: at,
|
|
660
666
|
wantsPR: false, model: 'sonnet', images: [], plan: ['fixture task'], pr: undefined, createdAt: at,
|
|
661
667
|
});
|
|
662
668
|
tasks.push({
|
|
663
669
|
id: taskId, num: 1, goalId, projectId: 'default', title: 'fixture task', detail: '', passCondition: '',
|
|
664
|
-
model: 'sonnet', status: 'running', createdAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
|
|
670
|
+
model: 'sonnet', status: 'running', createdAt: at, startedAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
|
|
665
671
|
activity: [
|
|
666
672
|
'Read app/index.html', 'Grep taskBlock', 'Edit app/index.html', 'Read engine/lib.mjs',
|
|
667
673
|
'Bash node --test engine/test/*.test.mjs', '✓ 12 tests passed', 'Edit engine/server.mjs',
|
|
@@ -1302,12 +1308,22 @@ function globalRunningCount() {
|
|
|
1302
1308
|
return n;
|
|
1303
1309
|
}
|
|
1304
1310
|
|
|
1311
|
+
// Per-project play/pause (Masa: Doing列の一時停止/再生). A paused project neither
|
|
1312
|
+
// starts new stacked goals nor pumps queued tasks — the two choke points below
|
|
1313
|
+
// (startNextStacked / pump) read this, so EVERY caller of them is covered by one
|
|
1314
|
+
// guard each. The flag lives on the project object (projects.json), so it
|
|
1315
|
+
// survives a restart; nothing auto-resumes a paused project.
|
|
1316
|
+
function isProjectPaused(projectId) {
|
|
1317
|
+
return !!projects.find((p) => p.id === projectId)?.paused;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1305
1320
|
// Goals stack up per project: while one goal is being planned or executed,
|
|
1306
1321
|
// newer goals wait as 'stacked' (editable / deletable until picked up).
|
|
1307
1322
|
function goalActive(projectId) {
|
|
1308
1323
|
return goals.some((g) => g.projectId === projectId && ['planning', 'running'].includes(g.status));
|
|
1309
1324
|
}
|
|
1310
1325
|
function startNextStacked(projectId) {
|
|
1326
|
+
if (isProjectPaused(projectId)) return; // paused → don't begin planning queued goals
|
|
1311
1327
|
if (goalActive(projectId)) return;
|
|
1312
1328
|
const next = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
|
|
1313
1329
|
.sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id)[0];
|
|
@@ -1496,6 +1512,9 @@ function pump(projectId) {
|
|
|
1496
1512
|
// to sign `claude` in and relaunch). Never spawn a worker that can only fail
|
|
1497
1513
|
// with "Not logged in". Cleared automatically once a probe succeeds.
|
|
1498
1514
|
if (!workerAuth.ok) return;
|
|
1515
|
+
// Project paused by the user (Doing列の一時停止 / chat) → hold this project's
|
|
1516
|
+
// queue exactly like the auth hold above. Resume flips the flag and pumps.
|
|
1517
|
+
if (isProjectPaused(projectId)) return;
|
|
1499
1518
|
const q = queues.get(projectId);
|
|
1500
1519
|
// sortQueueByPriority is a stable sort: it only ever moves a task ahead of
|
|
1501
1520
|
// a lower-priority one, never disturbs relative order within the same
|
|
@@ -1729,6 +1748,48 @@ function rateLimitResumeSweep() {
|
|
|
1729
1748
|
const rateLimitSweepTimer = setInterval(rateLimitResumeSweep, RATE_LIMIT_SWEEP_MS);
|
|
1730
1749
|
rateLimitSweepTimer.unref?.();
|
|
1731
1750
|
|
|
1751
|
+
// Project play/pause (Masa: Doing列の一時停止ボタン + チャット). Pause STOPS the
|
|
1752
|
+
// project's in-flight Doing work and prevents new ToDo from starting; Resume
|
|
1753
|
+
// continues from where it left off. Modelled on the rate-limit pause above (a
|
|
1754
|
+
// SIGTERM'd worker becomes an 'interrupted' task with its sessionId preserved,
|
|
1755
|
+
// so runTask's retry/resume path re-continues the same conversation) — the only
|
|
1756
|
+
// differences are: it's user-driven (no resumeAt / auto-resume), and it flips
|
|
1757
|
+
// the persisted project.paused flag that gates pump()/startNextStacked().
|
|
1758
|
+
//
|
|
1759
|
+
// Race note: setting goal.status='blocked' HERE (synchronously) BEFORE the
|
|
1760
|
+
// SIGTERM'd worker's async exit runs means finishGoalIfComplete later sees a
|
|
1761
|
+
// non-'running'/'partial' goal and bails at its status guard without clobbering
|
|
1762
|
+
// the paused state — see finishGoalIfComplete's early return.
|
|
1763
|
+
function pauseProject(projectId) {
|
|
1764
|
+
const project = projects.find((p) => p.id === projectId);
|
|
1765
|
+
if (!project || project.paused) return false;
|
|
1766
|
+
project.paused = true;
|
|
1767
|
+
saveProjects();
|
|
1768
|
+
for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'running')) {
|
|
1769
|
+
killGoalWorkers(g.id); // SIGTERM → task becomes 'interrupted' (resumable), not cancelled
|
|
1770
|
+
g.status = 'blocked';
|
|
1771
|
+
g.blocked = { kind: 'paused', reason: 'paused by user — resume to continue' };
|
|
1772
|
+
saveGoal(g);
|
|
1773
|
+
}
|
|
1774
|
+
send({ ev: 'projects', projects });
|
|
1775
|
+
return true;
|
|
1776
|
+
}
|
|
1777
|
+
function resumeProject(projectId) {
|
|
1778
|
+
const project = projects.find((p) => p.id === projectId);
|
|
1779
|
+
if (!project || !project.paused) return false;
|
|
1780
|
+
project.paused = false;
|
|
1781
|
+
saveProjects();
|
|
1782
|
+
// Re-queue each paused goal's interrupted tasks and flip it back to running
|
|
1783
|
+
// (reuses the proven rate-limit resume: continues from the preserved session).
|
|
1784
|
+
for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'blocked' && g.blocked?.kind === 'paused')) {
|
|
1785
|
+
resumeRateLimitedGoal(g);
|
|
1786
|
+
}
|
|
1787
|
+
startNextStacked(projectId);
|
|
1788
|
+
pump(projectId);
|
|
1789
|
+
send({ ev: 'projects', projects });
|
|
1790
|
+
return true;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1732
1793
|
// Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
|
|
1733
1794
|
// runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
|
|
1734
1795
|
function queueReplyTask(goal, text) {
|
|
@@ -1755,7 +1816,17 @@ function queueReplyTask(goal, text) {
|
|
|
1755
1816
|
tasks.push(task);
|
|
1756
1817
|
saveTask(task);
|
|
1757
1818
|
queues.get(goal.projectId).waiting.push(task);
|
|
1758
|
-
|
|
1819
|
+
// Spawn the worker on the NEXT event-loop turn, not synchronously here. runTask() runs
|
|
1820
|
+
// straight through to its first await — and that stretch includes goalWorkDir()'s
|
|
1821
|
+
// `git worktree add` (measured 1.78s on a checkout with ~135 worktrees). Callers like
|
|
1822
|
+
// POST /api/goals/:id/dismiss and /reply invoke queueReplyTask from inside their HTTP
|
|
1823
|
+
// handler and send the 200 right after, so a synchronous pump() made the response wait on
|
|
1824
|
+
// the worktree — the review UI looked stuck on "review" for seconds while the Ledger had
|
|
1825
|
+
// already claimed "Feedback sent → back to To Do" (Masa 2026-07-19). setImmediate lets the
|
|
1826
|
+
// response flush first; the task is already queued and persisted, so nothing is lost if the
|
|
1827
|
+
// worker starts a turn later. (setImmediate, not nextTick/microtask: those run before the
|
|
1828
|
+
// socket write flushes, so the blocking git call would still delay the response.)
|
|
1829
|
+
setImmediate(() => pump(goal.projectId));
|
|
1759
1830
|
return task;
|
|
1760
1831
|
}
|
|
1761
1832
|
|
|
@@ -2192,8 +2263,12 @@ async function runTask(task) {
|
|
|
2192
2263
|
} finally {
|
|
2193
2264
|
eph?.kill();
|
|
2194
2265
|
}
|
|
2195
|
-
sendAct(task, `verify: ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
|
|
2196
|
-
|
|
2266
|
+
sendAct(task, `verify: ${verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (check could not run)' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
|
|
2267
|
+
// An inconclusive verdict = the check itself couldn't run (authored check
|
|
2268
|
+
// referenced hooks the app doesn't expose — #487). Retrying will just
|
|
2269
|
+
// throw again, so stop and let it route to human review rather than
|
|
2270
|
+
// burning attempts and scoring the change as failed.
|
|
2271
|
+
if (verdict.pass || verdict.inconclusive) break;
|
|
2197
2272
|
lastFailure = verdict.detail;
|
|
2198
2273
|
} catch (e) {
|
|
2199
2274
|
sendAct(task, `verify: could not run — ${String(e.message ?? e).slice(0, 120)}`);
|
|
@@ -2212,11 +2287,19 @@ async function runTask(task) {
|
|
|
2212
2287
|
// FAIL verdict on a killed (incomplete) worker isn't authoritative either,
|
|
2213
2288
|
// so the stop reason takes precedence. (Mirrors the budget-breach path.)
|
|
2214
2289
|
// 3. a verify that actually ran and FAILED = 'failed'.
|
|
2215
|
-
// 4.
|
|
2290
|
+
// 4. an INCONCLUSIVE verify (the check threw / returned garbage — the
|
|
2291
|
+
// verifier is at fault, not necessarily the change, #487) is NOT
|
|
2292
|
+
// authoritative: fall through to the exit code so a cleanly-finished
|
|
2293
|
+
// worker still reaches review for a human, instead of a false 'failed'.
|
|
2294
|
+
// 5. no verify = the exit code decides.
|
|
2216
2295
|
const forcedStop = isForcedStop(code, timedOut);
|
|
2296
|
+
// A verdict only counts as authoritative when the check actually judged the
|
|
2297
|
+
// change. An inconclusive verdict (check threw / returned garbage) is treated
|
|
2298
|
+
// like "no verdict" everywhere below — status, proof, and snapshot capture.
|
|
2299
|
+
const conclusive = verdict && !verdict.inconclusive;
|
|
2217
2300
|
task.status = verdict?.pass ? 'done'
|
|
2218
2301
|
: forcedStop ? 'interrupted'
|
|
2219
|
-
:
|
|
2302
|
+
: conclusive ? 'failed'
|
|
2220
2303
|
: (code === 0 ? 'done' : 'failed');
|
|
2221
2304
|
// Never leave the reason as a bare "(no output)": if the worker produced no
|
|
2222
2305
|
// report text, result already carries workerExitReason(); make doubly sure a
|
|
@@ -2225,19 +2308,26 @@ async function runTask(task) {
|
|
|
2225
2308
|
task.result = result.slice(0, 4000);
|
|
2226
2309
|
task.usage = sumUsage(usages);
|
|
2227
2310
|
if (verdict) {
|
|
2228
|
-
|
|
2229
|
-
task.
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2311
|
+
const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
|
|
2312
|
+
task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0, 4000);
|
|
2313
|
+
// Only attach an authoritative proof for a conclusive verdict. An
|
|
2314
|
+
// inconclusive one must NOT leave a pass:false proof — that would trip
|
|
2315
|
+
// verifyGate's proofFailing and re-block the goal through the back door
|
|
2316
|
+
// (#487). Fall through to the snapshot capture below instead.
|
|
2317
|
+
if (conclusive) {
|
|
2318
|
+
task.proof = {
|
|
2319
|
+
pass: verdict.pass,
|
|
2320
|
+
detail: verdict.detail.slice(0, 500),
|
|
2321
|
+
gif: verdict.gifPath?.split('/').pop() ?? null,
|
|
2322
|
+
png: verdict.shotPath?.split('/').pop() ?? null,
|
|
2323
|
+
mp4: verdict.videoPath?.split('/').pop() ?? null,
|
|
2324
|
+
// STRONG proof: this came from an independent passCondition verification,
|
|
2325
|
+
// not a generic auto-snapshot. Only this flag lets a force-stopped (interrupted)
|
|
2326
|
+
// task count toward goal completion (isProofVerified) — a weak "UI capture"
|
|
2327
|
+
// snapshot never auto-promotes an interrupted goal past human review.
|
|
2328
|
+
verified: true,
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2241
2331
|
}
|
|
2242
2332
|
const after = await gitChanges(workDir);
|
|
2243
2333
|
task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
@@ -2250,7 +2340,7 @@ async function runTask(task) {
|
|
|
2250
2340
|
// baselineDir = the user's real project dir (unmodified, pre-goal code) —
|
|
2251
2341
|
// when it's genuinely different from wproject.dir (a real worktree, not
|
|
2252
2342
|
// the shared-dir fallback), also attempt a before/after pair.
|
|
2253
|
-
if (!
|
|
2343
|
+
if (!conclusive && task.status === 'done' && shouldCaptureProof({ changedFiles: task.changedFiles, hasProof: Boolean(task.proof), canCapture: canSnapshot(wproject) })) {
|
|
2254
2344
|
try {
|
|
2255
2345
|
sendAct(task, 'proof: capturing UI change (screenshot + clip) of the edited app…');
|
|
2256
2346
|
await captureSnapshotProof(task, wproject, { baselineDir: project.dir, goal });
|
|
@@ -2305,7 +2395,9 @@ function runTestsOnce(dir) {
|
|
|
2305
2395
|
const timeoutFlakes = (text.match(/did not report ready in time/g) || []).length;
|
|
2306
2396
|
const realAssertion = /AssertionError|Expected values to be|expected .+ (?:to |but )/i.test(text);
|
|
2307
2397
|
const failed = realAssertion ? failedTotal : Math.max(0, failedTotal - timeoutFlakes);
|
|
2308
|
-
|
|
2398
|
+
// Capture the failing test NAMES too (not just the count) so the gate can
|
|
2399
|
+
// diff them against the base's failures and block only on NEW ones.
|
|
2400
|
+
res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
|
|
2309
2401
|
});
|
|
2310
2402
|
});
|
|
2311
2403
|
}
|
|
@@ -2320,13 +2412,80 @@ async function runProjectTests(dir) {
|
|
|
2320
2412
|
// never blocked on a flaky test.
|
|
2321
2413
|
if (r.failed > 0) {
|
|
2322
2414
|
const r2 = await runTestsOnce(dir);
|
|
2415
|
+
const kept = r2.failed <= r.failed ? r2 : r; // the run that saw fewer failures
|
|
2323
2416
|
r = {
|
|
2324
2417
|
passed: Math.max(r.passed, r2.passed),
|
|
2325
2418
|
failed: Math.min(r.failed, r2.failed),
|
|
2326
|
-
detail:
|
|
2419
|
+
detail: kept.detail || r.detail || r2.detail || '',
|
|
2420
|
+
failingNames: kept.failingNames ?? r.failingNames ?? [],
|
|
2327
2421
|
};
|
|
2328
2422
|
}
|
|
2329
|
-
return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '' };
|
|
2423
|
+
return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// Failing tests on the BASE (pre-change) checkout — the set the review gate
|
|
2427
|
+
// subtracts so a goal is blocked only on failures IT introduced, never on a
|
|
2428
|
+
// test that was already red (e.g. persist-failure on clean origin/main). Lazy
|
|
2429
|
+
// + cached by the base-ref sha in MANAGER_HOME: computed only when a goal's own
|
|
2430
|
+
// run is red, then reused across goals sharing the same base (main moves a few
|
|
2431
|
+
// times a day, so this is a handful of full runs a day, not one per goal).
|
|
2432
|
+
// Any failure to compute → [] = "unknown", and the caller falls back to the
|
|
2433
|
+
// old block-on-any-red behaviour (fail closed).
|
|
2434
|
+
//
|
|
2435
|
+
// 🔴 The base MUST be the ref the goal worktree was branched from —
|
|
2436
|
+
// origin/<default> (goalWorkDir: `git worktree add … origin/<br>`), NOT the
|
|
2437
|
+
// registry dir's working HEAD. Those diverge in the wild: a dogfood checkout
|
|
2438
|
+
// left on an old branch, or uncommitted local work. Measuring the baseline on a
|
|
2439
|
+
// stale HEAD runs a suite where today's red tests don't exist yet → an EMPTY
|
|
2440
|
+
// failing set → the gate falls closed and blocks EVERY goal on a red that was
|
|
2441
|
+
// actually pre-existing. (Masa 2026-07-19: the registry dir sat 612 commits
|
|
2442
|
+
// behind origin/main, so every goal Failed on "テスト失敗 1件" the baseline
|
|
2443
|
+
// should have excused.) So resolve origin/<default> and run the base suite in a
|
|
2444
|
+
// throwaway detached worktree of that exact commit — nested under
|
|
2445
|
+
// repoRoot/.manager-wt so node's upward node_modules resolution still finds the
|
|
2446
|
+
// repo's deps (the same trick goal worktrees rely on), and so we never mutate
|
|
2447
|
+
// the registry checkout (prod procs read it; it may be on any branch).
|
|
2448
|
+
const BASELINE_CACHE = join(DATA_DIR, 'test-baseline.json');
|
|
2449
|
+
const baselineInFlight = new Map(); // sha -> Promise<string[]> — dedupe concurrent goal closes
|
|
2450
|
+
async function getBaselineFailures(baseProject) {
|
|
2451
|
+
const dir = baseProject?.dir;
|
|
2452
|
+
if (!dir) return [];
|
|
2453
|
+
let repoRoot;
|
|
2454
|
+
try { repoRoot = gitSync(dir, ['rev-parse', '--show-toplevel']); } catch { return []; }
|
|
2455
|
+
const br = repoDefaultBranch(dir);
|
|
2456
|
+
try { gitSync(repoRoot, ['fetch', 'origin', br, '--quiet']); } catch { /* offline — use whatever origin/<br> we already have */ }
|
|
2457
|
+
// The base ref the goal worktree is cut from: origin/<br>, else the local
|
|
2458
|
+
// branch tip, else the working HEAD (offline single-checkout fallback).
|
|
2459
|
+
const sha = (gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${br}`])
|
|
2460
|
+
|| gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', br])
|
|
2461
|
+
|| gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD']) || '').trim();
|
|
2462
|
+
if (!sha) return [];
|
|
2463
|
+
try {
|
|
2464
|
+
const cached = JSON.parse(readFileSync(BASELINE_CACHE, 'utf8'));
|
|
2465
|
+
if (cached && cached.sha === sha && Array.isArray(cached.failingNames)) return cached.failingNames;
|
|
2466
|
+
} catch { /* no cache yet */ }
|
|
2467
|
+
if (baselineInFlight.has(sha)) return baselineInFlight.get(sha);
|
|
2468
|
+
const job = (async () => {
|
|
2469
|
+
const wt = join(repoRoot, '.manager-wt', `baseline-${sha.slice(0, 12)}`);
|
|
2470
|
+
let base = null;
|
|
2471
|
+
try {
|
|
2472
|
+
try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* nothing to clean */ }
|
|
2473
|
+
gitSync(repoRoot, ['worktree', 'add', '--detach', wt, sha]);
|
|
2474
|
+
base = await runProjectTests(wt);
|
|
2475
|
+
} catch {
|
|
2476
|
+
// Worktree couldn't be made (odd repo / offline no-remote) — fall back to
|
|
2477
|
+
// the registry dir in place = the pre-fix behaviour. Never worse.
|
|
2478
|
+
try { base = await runProjectTests(dir); } catch { base = null; }
|
|
2479
|
+
} finally {
|
|
2480
|
+
try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
|
|
2481
|
+
}
|
|
2482
|
+
if (!base?.ran) return [];
|
|
2483
|
+
const failingNames = base.failingNames ?? [];
|
|
2484
|
+
try { writeFileSync(BASELINE_CACHE, JSON.stringify({ sha, failingNames, at: new Date().toISOString() })); } catch { /* best effort */ }
|
|
2485
|
+
return failingNames;
|
|
2486
|
+
})();
|
|
2487
|
+
baselineInFlight.set(sha, job);
|
|
2488
|
+
try { return await job; } finally { baselineInFlight.delete(sha); }
|
|
2330
2489
|
}
|
|
2331
2490
|
|
|
2332
2491
|
// ---- See all Ledger engine jobs (HANDOFF-v45 §5 / PRD §5.1) ----------------
|
|
@@ -2704,7 +2863,42 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2704
2863
|
}
|
|
2705
2864
|
const proofMissing = canCapture && uiTasks.length > 0 && !uiTasks.some((t) => t.proof);
|
|
2706
2865
|
const proofFailing = canCapture && uiTasks.some((t) => t.proof && t.proof.pass === false);
|
|
2707
|
-
|
|
2866
|
+
let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
|
|
2867
|
+
// Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
|
|
2868
|
+
// introduced. A test already red on the base — e.g. persist-failure on clean
|
|
2869
|
+
// origin/main — is pre-existing noise and must not knock a proof-verified
|
|
2870
|
+
// goal (#474/#494) or even a no-op goal (#465) back to blocked/Failed. Only
|
|
2871
|
+
// consult the base when the goal's own run is red (keeps the green path free
|
|
2872
|
+
// and pays the base run at most a few times a day, cached by base sha). An
|
|
2873
|
+
// empty baseline = "unknown" → fall through to the old block-on-any-red.
|
|
2874
|
+
// Only trust a baseline diff when the base is a genuinely SEPARATE checkout
|
|
2875
|
+
// from the changed worktree. finishGoalIfComplete runs in the goal's own
|
|
2876
|
+
// worktree (prProject.dir === the worktree), so the base must be the user's
|
|
2877
|
+
// ORIGINAL project dir (on main ≈ origin/main, without this goal's change) —
|
|
2878
|
+
// resolved from the registry, not the worktree-scoped project we were handed.
|
|
2879
|
+
// When they'd be the same dir (shared-dir fallback, no isolated worktree) the
|
|
2880
|
+
// "base" already contains this change, so fall back to the old block-on-any-
|
|
2881
|
+
// red (fail closed).
|
|
2882
|
+
const baseDir = projects.find((p) => p.id === goal.projectId)?.dir;
|
|
2883
|
+
const canBaseline = baseDir && prProject?.dir && baseDir !== prProject.dir && existsSync(baseDir);
|
|
2884
|
+
if (testsFailing && canBaseline) {
|
|
2885
|
+
const baseline = await getBaselineFailures({ dir: baseDir });
|
|
2886
|
+
if (baseline.length) {
|
|
2887
|
+
const gate = classifyTestGate({ current: goal.testResult.failingNames ?? [], baseline });
|
|
2888
|
+
goal.testResult.newFailures = gate.newFailures;
|
|
2889
|
+
goal.testResult.preExisting = gate.preExisting;
|
|
2890
|
+
if (!gate.blocked) {
|
|
2891
|
+
testsFailing = false;
|
|
2892
|
+
goal.testResult.preExistingOnly = true;
|
|
2893
|
+
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(' / ')}`);
|
|
2894
|
+
} else {
|
|
2895
|
+
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(' / ')}`);
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
// Count that drives the block message: the NEW failures when we could diff
|
|
2900
|
+
// against the base, else the raw total (fail-closed when the base is unknown).
|
|
2901
|
+
const blockingFailCount = goal.testResult.newFailures?.length ?? goal.testResult.failed;
|
|
2708
2902
|
// §4補足: requireVerifyPass (opt-in, off by default) makes the gate strict —
|
|
2709
2903
|
// EVERY task must carry a passing proof, not just UI tasks. Only ever tightens;
|
|
2710
2904
|
// never conflicts with §6 (still can't reach Done without a human).
|
|
@@ -2722,16 +2916,19 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2722
2916
|
if (testsFailing) {
|
|
2723
2917
|
const failure = recordGoalFailure(goal, {
|
|
2724
2918
|
kind: 'test',
|
|
2725
|
-
reason: `テスト失敗 ${
|
|
2919
|
+
reason: `テスト失敗 ${blockingFailCount} 件`,
|
|
2726
2920
|
changedFiles,
|
|
2727
2921
|
testResult: goal.testResult,
|
|
2728
2922
|
});
|
|
2923
|
+
const newFailList = goal.testResult.newFailures?.length
|
|
2924
|
+
? `Failures introduced by this change (fix ONLY these — pre-existing reds are not your fault):\n- ${goal.testResult.newFailures.join('\n- ')}`
|
|
2925
|
+
: '';
|
|
2729
2926
|
const used = Number(goal.autoRework?.testFailures ?? 0);
|
|
2730
2927
|
if (used < MAX_AUTO_TEST_REWORKS) {
|
|
2731
2928
|
goal.autoRework = {
|
|
2732
2929
|
...(goal.autoRework ?? {}),
|
|
2733
2930
|
testFailures: used + 1,
|
|
2734
|
-
lastReason: `テスト失敗 ${
|
|
2931
|
+
lastReason: `テスト失敗 ${blockingFailCount} 件`,
|
|
2735
2932
|
lastAt: new Date().toISOString(),
|
|
2736
2933
|
};
|
|
2737
2934
|
goal.blocked = null;
|
|
@@ -2739,7 +2936,8 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2739
2936
|
saveGoal(goal);
|
|
2740
2937
|
goalAct(`Tests failed; queued automatic worker feedback (${used + 1}/${MAX_AUTO_TEST_REWORKS}) with the failing output.`);
|
|
2741
2938
|
queueReplyTask(goal, [
|
|
2742
|
-
`Manager auto-feedback: npm test failed (${
|
|
2939
|
+
`Manager auto-feedback: npm test failed (${blockingFailCount} failing).`,
|
|
2940
|
+
newFailList,
|
|
2743
2941
|
failure?.whatHappened ? `What happened: ${failure.whatHappened}` : '',
|
|
2744
2942
|
failure?.yourCall ?? 'Please inspect the failure output below, fix the regression, and rerun the project tests until they pass.',
|
|
2745
2943
|
failure?.nextPolicy ? `Prevention policy: ${failure.nextPolicy}` : '',
|
|
@@ -2751,23 +2949,32 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2751
2949
|
return;
|
|
2752
2950
|
}
|
|
2753
2951
|
}
|
|
2754
|
-
|
|
2952
|
+
// Proof is advisory now (Masa 2026-07-19): proofMissing / proofFailing no
|
|
2953
|
+
// longer block — the work is done; a screenshot that could not be captured or
|
|
2954
|
+
// a pixel heuristic that could not auto-confirm the target is a NOTE on the
|
|
2955
|
+
// review, not a Failed goal (audit false-failure #3/#2). The proof artifact
|
|
2956
|
+
// (when there is one) is still attached for the human to look at. What still
|
|
2957
|
+
// blocks: newly-failing tests, a promised-but-undone task, opt-in verifyFail.
|
|
2958
|
+
const gate = classifyVerifyGate({ testsFailing, proofMissing, proofFailing, verifyFail, nothingVerifiable });
|
|
2959
|
+
goal.proofNote = gate.proofAdvisory === 'missing'
|
|
2960
|
+
? 'UI変更ですが proof(スクショ/動画) を撮影できませんでした — 目視で確認してください'
|
|
2961
|
+
: gate.proofAdvisory === 'unconfirmed'
|
|
2962
|
+
? 'proof は撮れましたが、依頼対象のUIを自動確認できませんでした — 目視で確認してください'
|
|
2963
|
+
: null;
|
|
2964
|
+
if (goal.proofNote) goalAct(`Proof advisory (not blocking): ${goal.proofNote}`);
|
|
2965
|
+
if (gate.blocked) {
|
|
2755
2966
|
if (!testsFailing) {
|
|
2756
2967
|
recordGoalFailure(goal, {
|
|
2757
|
-
kind:
|
|
2758
|
-
reason:
|
|
2759
|
-
: proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
|
|
2760
|
-
: nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
|
|
2968
|
+
kind: gate.kind,
|
|
2969
|
+
reason: nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
|
|
2761
2970
|
: '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
|
|
2762
2971
|
changedFiles,
|
|
2763
2972
|
testResult: goal.testResult,
|
|
2764
2973
|
});
|
|
2765
2974
|
}
|
|
2766
2975
|
goal.blocked = {
|
|
2767
|
-
kind:
|
|
2768
|
-
reason: testsFailing ? `テスト失敗 ${
|
|
2769
|
-
: proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
|
|
2770
|
-
: proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
|
|
2976
|
+
kind: gate.kind,
|
|
2977
|
+
reason: testsFailing ? `テスト失敗 ${blockingFailCount} 件(自動修正 ${Number(goal.autoRework?.testFailures ?? 0)} 回後も失敗)`
|
|
2771
2978
|
: nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
|
|
2772
2979
|
: '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
|
|
2773
2980
|
};
|
|
@@ -2777,7 +2984,10 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2777
2984
|
goal.blocked = null;
|
|
2778
2985
|
if (goal.autoRework?.testFailures) goal.autoRework = { ...goal.autoRework, resolvedAt: new Date().toISOString() };
|
|
2779
2986
|
goal.status = nextGoalStatus({ allDone, wantsPR: goal.wantsPR, allVerified, reviewDefinition });
|
|
2780
|
-
|
|
2987
|
+
const preNote = goal.testResult?.preExistingOnly
|
|
2988
|
+
? ` (${goal.testResult.preExisting?.length ?? 0} pre-existing test failure(s) ignored as unrelated to this change)`
|
|
2989
|
+
: '';
|
|
2990
|
+
goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}`);
|
|
2781
2991
|
}
|
|
2782
2992
|
}
|
|
2783
2993
|
|
|
@@ -3158,6 +3368,18 @@ const server = createServer(async (req, res) => {
|
|
|
3158
3368
|
return;
|
|
3159
3369
|
}
|
|
3160
3370
|
|
|
3371
|
+
// Per-project play/pause (Masa: Doing列の一時停止/再生・チャットからも同じ). POST
|
|
3372
|
+
// .../pause stops in-flight Doing work + holds the queue; .../resume continues
|
|
3373
|
+
// from where it left off. Broadcasts the projects change so every client's
|
|
3374
|
+
// toggle updates. Idempotent: pausing an already-paused project is a 200 no-op.
|
|
3375
|
+
const pauseMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/(pause|resume)$/);
|
|
3376
|
+
if (pauseMatch && req.method === 'POST') {
|
|
3377
|
+
const project = projects.find((p) => p.id === pauseMatch[1]);
|
|
3378
|
+
if (!project) return json(res, 404, { error: 'project not found' });
|
|
3379
|
+
if (pauseMatch[2] === 'pause') pauseProject(project.id); else resumeProject(project.id);
|
|
3380
|
+
return json(res, 200, { project, projects, paused: !!project.paused });
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3161
3383
|
// proof artifacts: /proof/<taskId>/<file> (gif/png/mp4 from that task's proof dir)
|
|
3162
3384
|
const proofMatch = url.pathname.match(/^\/proof\/(\d+)\/([\w.-]+)$/);
|
|
3163
3385
|
if (proofMatch) {
|
|
@@ -3187,6 +3409,21 @@ const server = createServer(async (req, res) => {
|
|
|
3187
3409
|
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
|
|
3188
3410
|
const project = projects.find((p) => p.id === projectId);
|
|
3189
3411
|
if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
|
|
3412
|
+
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
|
|
3413
|
+
// (no LLM), checked BEFORE the intent router below: a short command-like
|
|
3414
|
+
// "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
|
|
3415
|
+
// — it never becomes a To-Do. /later and /review skip this like the router.
|
|
3416
|
+
if (!pending && !review) {
|
|
3417
|
+
const pauseIntent = detectPauseIntent(text.trim());
|
|
3418
|
+
if (pauseIntent === 'pause') {
|
|
3419
|
+
pauseProject(projectId);
|
|
3420
|
+
return json(res, 200, { kind: 'chat', reply: `${project.name} を一時停止しました。動いていた作業を止め、新しいToDoも始めません。再生(▶)で続きから再開します。` });
|
|
3421
|
+
}
|
|
3422
|
+
if (pauseIntent === 'resume') {
|
|
3423
|
+
resumeProject(projectId);
|
|
3424
|
+
return json(res, 200, { kind: 'chat', reply: `${project.name} を再開しました。止めた作業を続きから進めます。` });
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3190
3427
|
// Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
|
|
3191
3428
|
// composer is conversation-first. A chat/help/settings message ("what can
|
|
3192
3429
|
// you do?", "reply in English", a greeting) gets a conversational reply
|
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