@galda/cli 0.10.37 → 0.10.39
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.md +7 -3
- package/app/index.html +742 -401
- package/engine/lib.mjs +344 -37
- package/engine/server.mjs +248 -248
- package/engine/shot.mjs +96 -0
- package/package.json +1 -1
package/engine/server.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
15
|
import { spawn, execFile, spawnSync } from 'node:child_process';
|
|
16
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
16
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync, unlinkSync, rmSync } from 'node:fs';
|
|
17
17
|
import { emitEvent } from './analytics-client.mjs';
|
|
18
18
|
import { randomBytes, createHash, randomUUID } from 'node:crypto';
|
|
19
19
|
import { resolve, dirname, join, basename } from 'node:path';
|
|
@@ -21,7 +21,8 @@ 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, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, 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,
|
|
24
|
+
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, 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, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, 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, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames } from './lib.mjs';
|
|
25
|
+
import { createSerialQueue } from './lib.mjs';
|
|
25
26
|
import { openPR } from './pr.mjs';
|
|
26
27
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
27
28
|
|
|
@@ -63,6 +64,12 @@ function maybeEmitSetupCompleted(req) {
|
|
|
63
64
|
// timeout→'interrupted' path is testable (a test sets a short value + a slow
|
|
64
65
|
// fake worker instead of waiting 10 real minutes).
|
|
65
66
|
const WORKER_TIMEOUT_MS = Number(process.env.MANAGER_WORKER_TIMEOUT_MS) || 15 * 60 * 1000;
|
|
67
|
+
// The worker's own words ARE the deliverable for every request that doesn't
|
|
68
|
+
// produce something runnable (research, summaries, answers to a question). The
|
|
69
|
+
// old 4000-char cap silently ate the middle of exactly those answers, so the
|
|
70
|
+
// review card showed a truncated report and called it evidence. Keep a cap —
|
|
71
|
+
// this lands in persisted state — but one that fits a real answer.
|
|
72
|
+
const RESULT_MAX = 20000;
|
|
66
73
|
const MODELS = ['sonnet', 'opus', 'haiku'];
|
|
67
74
|
const CODEX_MODELS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'];
|
|
68
75
|
const WORKER_AGENTS = ['claude-code', 'codex'];
|
|
@@ -729,7 +736,33 @@ for (const fix of orphanFixes) {
|
|
|
729
736
|
const g = goals.find((x) => x.id === fix.id);
|
|
730
737
|
if (g) { console.log(`[manager] goal ${g.id}: reaped as orphaned (no live worker after restart) → interrupted`); saveGoal(g); }
|
|
731
738
|
}
|
|
739
|
+
// The implementation log (P1.5, Masa 2026-07-22).
|
|
740
|
+
//
|
|
741
|
+
// The tool-call stream — ⏺ Read / Edit / Bash and their results — existed only while the
|
|
742
|
+
// worker ran: sendAct() kept it on the task, streamed it, and saveTask() then dropped it
|
|
743
|
+
// on the floor. So the moment a task finished (or the page reloaded), there was no way to
|
|
744
|
+
// see what the AI had actually done. Claude Code's own app keeps that history and Masa
|
|
745
|
+
// reads it; we threw ours away.
|
|
746
|
+
//
|
|
747
|
+
// Frozen to a file rather than into the state log on purpose: 1000 lines × 500 chars is
|
|
748
|
+
// up to ~500KB per task, and the state log is replayed in full on every boot. The file is
|
|
749
|
+
// read on demand by GET /api/tasks/:id/activity — the same "only when asked" shape as
|
|
750
|
+
// /preview. Nothing is summarised: what the worker did is what gets stored.
|
|
751
|
+
const ACTIVITY_TERMINAL = new Set(['done', 'failed', 'interrupted', 'skipped']);
|
|
752
|
+
const activityLogFile = (taskId) => join(logDir, `activity-${taskId}.log`);
|
|
753
|
+
function freezeActivityLog(t) {
|
|
754
|
+
if (!ACTIVITY_TERMINAL.has(t.status) || !t.activity?.length) return false;
|
|
755
|
+
try { writeFileSync(activityLogFile(t.id), t.activity.join('\n')); return true; }
|
|
756
|
+
catch (e) { console.error(`[manager] activity log write failed for task ${t.id} (${e?.code || e})`); return false; }
|
|
757
|
+
}
|
|
758
|
+
function readActivityLog(taskId) {
|
|
759
|
+
try { return readFileSync(activityLogFile(taskId), 'utf8').split('\n').filter(Boolean); }
|
|
760
|
+
catch { return []; }
|
|
761
|
+
}
|
|
732
762
|
function saveTask(t) {
|
|
763
|
+
// freeze BEFORE the destructure strips `activity`, and mark the task so the UI knows a
|
|
764
|
+
// log exists without having to ask for one that was never written.
|
|
765
|
+
if (freezeActivityLog(t)) t.activityLog = true;
|
|
733
766
|
const { activity, todos, ...persist } = t; // activity + todos are ephemeral (live only)
|
|
734
767
|
logAppend(JSON.stringify({ ...persist, kind: 'task' }));
|
|
735
768
|
const goal = goals.find((g) => g.id === t.goalId);
|
|
@@ -1401,7 +1434,9 @@ async function planGoal(goal) {
|
|
|
1401
1434
|
'- 複数タスクに分けるのは、依頼に「明確に別々の成果物・トピック」が含まれる時「だけ」(例:「音声入力を追加して、あとビジュアル作成も」→ 音声入力 / ビジュアル作成 の2つ)。工程での分割は絶対にしない。',
|
|
1402
1435
|
'- 被っている・言い換えているだけの依頼は、自分で理解して1つにまとめる。',
|
|
1403
1436
|
'- タスク数は最小に。既定1個、多くても distinct な依頼の数だけ(現実的に1〜3、最大4)。依存順に並べる。',
|
|
1404
|
-
'- 各タスクの title
|
|
1437
|
+
'- 各タスクの title は「ユーザーが頼んだこと」を、ユーザーの言葉・語彙をそのまま活かして短く書く。実装のやり方(「〜するように直した」「〜を計算する」等)や、完了報告の文(「〜した」「〜しました」)を title にしない。エンジニアリングの手順名(設計/実装/テスト等)にもしない。例: 依頼「一回たたむとsettingボタンがズレるバグ修正して」→ title「settingボタンがズレるバグの修正」(✕「アニメーションが終わってから歯車の位置を計算するように直した」)。',
|
|
1438
|
+
'- バグ修正の依頼では、title に「どの問題を直すか」がわかるように書く(解決する問題=ユーザーの言葉)。依頼に直し方の方針が書かれていればそれも短く添えてよいが、無ければ問題だけでよい。工程・実装詳細は detail に書く。',
|
|
1439
|
+
'- detail には worker への完全な指示を書く:そのworkerが1人で実装・テスト・proofまで完結させる。工程ごとの兄弟タスクは来ない前提で書くこと。',
|
|
1405
1440
|
'- passCondition はユーザーが依頼文で明示的に検証条件(「成功条件:」「〜したら〜になる」等)を書いた場合のみ設定する。見た目の調整・文言変更・スタイル変更のタスクには付けない(null)。検証は高価なので、明示された時だけ。',
|
|
1406
1441
|
'- 検証対象のページがあれば entry に書く(プロジェクト相対のhtmlパス、または http URL。無ければ null)。依頼中に書かれていればそれを使い、無ければリポジトリを見て推測してよい。',
|
|
1407
1442
|
'- 依頼が曖昧で重要な前提が1つ欠けている(例: 対象・範囲・形式が不明)か、既存の作業と矛盾する場合「のみ」、タスクではなく確認質問を返す: {"question":"確認したい1点(短く)","options":["選択肢A","選択肢B"]}。ただし基本は合理的な仮定で進めること。質問は本当に必要な時だけ、1問に絞る。',
|
|
@@ -1457,22 +1492,25 @@ async function planGoal(goal) {
|
|
|
1457
1492
|
plan.tasks = refinePlanTasks(plan.tasks, { goalText: goal.text });
|
|
1458
1493
|
goal.status = 'running';
|
|
1459
1494
|
goal.entry = plan.entry;
|
|
1460
|
-
|
|
1495
|
+
// Demoted planner (Masa 2026-07-21, P0b): the planner LLM still resolves the
|
|
1496
|
+
// entry URL, asks for clarification, and warms the session — but it no longer
|
|
1497
|
+
// DECOMPOSES the request into tasks the worker must follow. The request goes
|
|
1498
|
+
// to the worker verbatim as ONE task; the worker (Claude Code / Codex) decides
|
|
1499
|
+
// its own breakdown and emits it as its own TodoWrite, which the board shows.
|
|
1500
|
+
// One card = one request. A user-written pass condition still rides along.
|
|
1501
|
+
const title = goalTaskTitle(goal.text);
|
|
1502
|
+
goal.plan = [title];
|
|
1461
1503
|
saveGoal(goal);
|
|
1462
1504
|
const base = tasks.filter((t) => t.projectId === goal.projectId).map((t) => t.num);
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
tasks.push(task);
|
|
1473
|
-
saveTask(task);
|
|
1474
|
-
queues.get(goal.projectId).waiting.push(task);
|
|
1475
|
-
}
|
|
1505
|
+
const num = (base.length ? Math.max(...base) : 0) + 1;
|
|
1506
|
+
const task = buildGoalTask(goal, {
|
|
1507
|
+
id: nextId++, num,
|
|
1508
|
+
passCondition: plan.tasks[0]?.passCondition ?? null,
|
|
1509
|
+
createdAt: new Date().toISOString(),
|
|
1510
|
+
});
|
|
1511
|
+
tasks.push(task);
|
|
1512
|
+
saveTask(task);
|
|
1513
|
+
queues.get(goal.projectId).waiting.push(task);
|
|
1476
1514
|
pump(goal.projectId);
|
|
1477
1515
|
}
|
|
1478
1516
|
|
|
@@ -1838,44 +1876,8 @@ function testOutputExcerpt(text) {
|
|
|
1838
1876
|
return chosen.join('\n').slice(-3000);
|
|
1839
1877
|
}
|
|
1840
1878
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
// haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
|
|
1844
|
-
// instruction as the FIRST line; a bare "/name" first line did not consistently
|
|
1845
|
-
// trigger the skill in `claude -p`. See commit body for the run evidence.
|
|
1846
|
-
const skill = task?.skill ?? goal?.skill;
|
|
1847
|
-
const agentName = workerAgent(task?.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code';
|
|
1848
|
-
// Report back to the human in the SAME language they wrote the goal in (Masa
|
|
1849
|
-
// 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
|
|
1850
|
-
const replyLang = detectRequestLanguage(goal?.text || task?.title || '') === 'ja' ? '日本語' : 'English';
|
|
1851
|
-
return [
|
|
1852
|
-
skill ? `FIRST, use the /${skill} skill via the Skill tool (invoke Skill with the "${skill}" skill) before doing anything else. Then carry out the task below.` : '',
|
|
1853
|
-
`You are a ${agentName} worker managed by "Manager for AI". Do ONLY the task below inside this project directory.`,
|
|
1854
|
-
goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
|
|
1855
|
-
'',
|
|
1856
|
-
`TASK ${task.num}: ${task.title}`,
|
|
1857
|
-
task.detail ? `DETAIL: ${task.detail}` : '',
|
|
1858
|
-
goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
|
|
1859
|
-
task.passCondition ? `\nEXPLICIT PASS CONDITION (verified externally by a headless browser after you finish — NOT by you): ${task.passCondition}` : '',
|
|
1860
|
-
lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
|
|
1861
|
-
goal ? `\n(全体ゴールの一部です: ${goal.text.slice(0, 500)})` : '',
|
|
1862
|
-
'',
|
|
1863
|
-
'RULES:',
|
|
1864
|
-
'- Edit only files inside this directory. Keep changes minimal.',
|
|
1865
|
-
'- Keep the work bounded: inspect only files needed for this task; avoid broad repository scans unless they are necessary.',
|
|
1866
|
-
'- If the task is larger than one focused patch, make the smallest safe change and report the next step instead of expanding scope.',
|
|
1867
|
-
"- You may use the user's installed skills (Skill tool, e.g. /galda-doc) when one clearly fits the task.",
|
|
1868
|
-
// Token-frugality + the 10-min run cap: the worker used to run the WHOLE suite
|
|
1869
|
-
// (npm test) repeatedly, which the Manager ALSO runs at close-out — double work
|
|
1870
|
-
// that regularly overran the cap (→ a SIGTERM 'interrupted'). Write the test,
|
|
1871
|
-
// run only the RELEVANT file once, and leave the full-suite + verification to
|
|
1872
|
-
// the Manager. (Masa: worker が自前でテスト全回し→止まる、が最大の無駄。)',
|
|
1873
|
-
'- 変更に対応するテストを書く。確認は該当テストファイル1つだけを1回実行する(例: node --test path/to/only-this.test.mjs)。プロジェクト全体の npm test を繰り返し回さない — 全体テストと最終検証は Manager 側が実行する。テストが適用できない変更は理由と手動確認手順を書く。',
|
|
1874
|
-
'- 検証しすぎない: 自分の変更が動くと確認できたら、それ以上ログを漁ったり再検証を重ねたりせず、すぐに報告して終える(時間と token の無駄を避ける)。',
|
|
1875
|
-
'- Do not commit (git is handled by the Manager).',
|
|
1876
|
-
`- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
|
|
1877
|
-
].filter(Boolean).join('\n');
|
|
1878
|
-
}
|
|
1879
|
+
// workerPrompt moved to lib.mjs (pure + unit-tested). Caller resolves the
|
|
1880
|
+
// agent id via workerAgent(); lib keeps the presentation.
|
|
1879
1881
|
|
|
1880
1882
|
// A fresh ephemeral instance starts with zero history, but some pass
|
|
1881
1883
|
// conditions are about completion-time behavior (e.g. task 46: a goal
|
|
@@ -1963,148 +1965,121 @@ function entryUrlFor(goal, project) {
|
|
|
1963
1965
|
return resolveEntryUrl({ entry: goal?.entry, projectDir: project.dir, managerRoot: ROOT, port: PORT, accessKey: ACCESS_KEY });
|
|
1964
1966
|
}
|
|
1965
1967
|
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1968
|
+
// ---- EVIDENCE: what the worker made, actually reachable ---------------------
|
|
1969
|
+
// Everything that used to live here (proofIntentFor / verifyManagerProofTarget /
|
|
1970
|
+
// captureSnapshotProof) staged a photograph: it booted an ephemeral copy of the
|
|
1971
|
+
// project and screenshotted the MANAGER'S OWN flagship board, picking WHICH
|
|
1972
|
+
// region to shoot by keyword-matching the request text. That produced pictures
|
|
1973
|
+
// unrelated to the change (#496) and, because canSnapshot() demanded
|
|
1974
|
+
// engine/server.mjs + app/index.html, it never ran at all outside this repo.
|
|
1975
|
+
//
|
|
1976
|
+
// The replacement asks the only AI in the pipeline — the worker that just did
|
|
1977
|
+
// the work — how a human can run and see the result, and then does exactly
|
|
1978
|
+
// that. This layer never guesses the command and never classifies the request.
|
|
1969
1979
|
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1980
|
+
// Consume the worker's run declaration from the worktree root. It is a message
|
|
1981
|
+
// to the Manager, not part of the deliverable, so it is deleted after reading:
|
|
1982
|
+
// it must not reach the diff or the PR.
|
|
1983
|
+
//
|
|
1984
|
+
// Silence is a valid answer. A request with nothing to run (research, a
|
|
1985
|
+
// summary, an answer to a question) leaves no declaration and gets no "run it"
|
|
1986
|
+
// button — there, the worker's own words are the whole deliverable.
|
|
1987
|
+
function readRunDeclaration(task, workDir) {
|
|
1988
|
+
const file = join(workDir, RUN_DECL_FILE);
|
|
1989
|
+
let raw = null;
|
|
1990
|
+
try { raw = readFileSync(file, 'utf8'); } catch { return; }
|
|
1991
|
+
try { unlinkSync(file); } catch { /* best effort — never fail a task over cleanup */ }
|
|
1992
|
+
const run = parseRunDeclaration(raw);
|
|
1993
|
+
if (!run) {
|
|
1994
|
+
sendAct(task, `run declaration ignored — ${RUN_DECL_FILE} was not usable JSON`);
|
|
1995
|
+
return;
|
|
1974
1996
|
}
|
|
1975
|
-
|
|
1976
|
-
|
|
1997
|
+
task.run = { ...run, dir: workDir };
|
|
1998
|
+
sendProcessAct(task, `Worker declared how to see the result: ${run.cmd ?? run.url}`);
|
|
1977
1999
|
}
|
|
1978
2000
|
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
const lane = document.querySelector('#fsLane');
|
|
2005
|
-
const row = document.querySelector('#fsTodoSec .drow.err');
|
|
2006
|
-
const msg = row?.querySelector('.errline .msg');
|
|
2007
|
-
const acts = row?.querySelector('.acts2');
|
|
2008
|
-
const buttonText = [...(acts?.querySelectorAll('button') ?? [])].map((b) => b.textContent.trim()).join(' ');
|
|
2009
|
-
const rootR = rect(root), feedR = rect(feed), laneR = rect(lane), msgR = rect(msg), actsR = rect(acts);
|
|
2010
|
-
return {
|
|
2011
|
-
buttonText,
|
|
2012
|
-
hasRetry: /Retry|Resume/.test(buttonText),
|
|
2013
|
-
hasLog: /View log/.test(buttonText),
|
|
2014
|
-
hasArchive: /Archive/.test(buttonText),
|
|
2015
|
-
actionGap: msgR && actsR ? Math.round(actsR.top - msgR.bottom) : null,
|
|
2016
|
-
laneWidth: Math.round(laneR?.width ?? 0),
|
|
2017
|
-
feedWidth: Math.round(feedR?.width ?? 0),
|
|
2018
|
-
rootWidth: Math.round(rootR?.width ?? 0),
|
|
2019
|
-
laneVisible: Boolean(laneR && laneR.width >= 260 && laneR.right <= window.innerWidth + 2),
|
|
2020
|
-
};
|
|
2021
|
-
});
|
|
2022
|
-
const actionClose = metrics.actionGap == null || metrics.actionGap <= 18;
|
|
2023
|
-
const laneSane = metrics.laneVisible && metrics.feedWidth > 420 && metrics.laneWidth > 260;
|
|
2024
|
-
return {
|
|
2025
|
-
pass: Boolean(metrics.hasRetry && metrics.hasLog && actionClose && laneSane),
|
|
2026
|
-
detail: `Manager UI proof target: failed-row actions visible (${metrics.buttonText || 'none'}), action gap ${metrics.actionGap ?? 'n/a'}px, lane ${metrics.laneWidth}px/feed ${metrics.feedWidth}px`,
|
|
2027
|
-
};
|
|
2001
|
+
// Take whatever the worker photographed out of the worktree (SHOT_DIR) and put
|
|
2002
|
+
// it where the Manager already serves images from, so the pictures are reachable
|
|
2003
|
+
// at a URL instead of sitting in a temp checkout that gets deleted.
|
|
2004
|
+
//
|
|
2005
|
+
// Moved, not copied, and moved BEFORE the diff is computed: a screenshot is the
|
|
2006
|
+
// worker talking to us about the deliverable, not the deliverable, and it must
|
|
2007
|
+
// not turn up as a binary file in the pull request.
|
|
2008
|
+
//
|
|
2009
|
+
// This function does not know what a good picture is. It never shoots anything
|
|
2010
|
+
// itself, and an empty SHOT_DIR is a normal outcome — plenty of requests have
|
|
2011
|
+
// nothing to look at.
|
|
2012
|
+
function collectWorkerShots(task, workDir) {
|
|
2013
|
+
const dir = join(workDir, SHOT_DIR);
|
|
2014
|
+
let names = [];
|
|
2015
|
+
try { names = collectShotNames(readdirSync(dir)); } catch { return; }
|
|
2016
|
+
const outDir = join(logDir, 'uploads');
|
|
2017
|
+
mkdirSync(outDir, { recursive: true });
|
|
2018
|
+
const shots = [];
|
|
2019
|
+
for (const name of names) {
|
|
2020
|
+
const safe = name.replace(/[^\w.-]/g, '_').slice(-80);
|
|
2021
|
+
const file = `${Date.now()}-shot-${task.id}-${safe}`;
|
|
2022
|
+
try {
|
|
2023
|
+
writeFileSync(join(outDir, file), readFileSync(join(dir, name)));
|
|
2024
|
+
shots.push({ name, url: `/upload/${file}`, path: join(outDir, file) });
|
|
2025
|
+
} catch { /* one unreadable shot must not cost the task its other shots */ }
|
|
2028
2026
|
}
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
const feed = (document.querySelector('#fsFeedWrap') || document.querySelector('#fsFeed')?.parentElement)?.getBoundingClientRect();
|
|
2034
|
-
return { laneWidth: Math.round(lane?.width ?? 0), feedWidth: Math.round(feed?.width ?? 0) };
|
|
2035
|
-
});
|
|
2036
|
-
return { pass: true, detail: `Manager UI capture (${intent}); lane ${metrics.laneWidth}px/feed ${metrics.feedWidth}px` };
|
|
2027
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
2028
|
+
if (!shots.length) return;
|
|
2029
|
+
task.shots = [...(task.shots ?? []), ...shots];
|
|
2030
|
+
sendProcessAct(task, `Worker attached ${shots.length} screenshot${shots.length === 1 ? '' : 's'} of the result.`);
|
|
2037
2031
|
}
|
|
2038
2032
|
|
|
2039
|
-
//
|
|
2040
|
-
// current code, stored as the task's proof. Used automatically for UI tasks
|
|
2041
|
-
// without an explicit pass condition, and on demand via
|
|
2042
|
-
// POST /api/tasks/:id/snapshot to backfill old "No capture yet" reviews.
|
|
2033
|
+
// Live previews, started on demand by POST /api/tasks/:id/preview.
|
|
2043
2034
|
//
|
|
2044
|
-
//
|
|
2045
|
-
//
|
|
2046
|
-
//
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
// proof.beforePng/beforeGif (beforeAfterMediaHtml in app/index.html).
|
|
2060
|
-
async function captureSnapshotProof(task, project, opts = {}) {
|
|
2061
|
-
const eph = await startEphemeralApp(project.dir);
|
|
2062
|
-
if (!eph) throw new Error('ephemeral instance failed to boot');
|
|
2063
|
-
try {
|
|
2064
|
-
const proofDir = join(logDir, `proof-${task.id}`);
|
|
2065
|
-
mkdirSync(proofDir, { recursive: true });
|
|
2066
|
-
const managerApp = canSnapshot(project);
|
|
2067
|
-
const snap = await runVerification({
|
|
2068
|
-
entryUrl: eph.url,
|
|
2069
|
-
verify: async (page, ui) => managerApp
|
|
2070
|
-
? verifyManagerProofTarget(page, ui, { goal: opts.goal, task })
|
|
2071
|
-
: (await exerciseUi(page, ui), { pass: true, detail: 'UI change capture (auto, unverified)' }),
|
|
2072
|
-
outBase: join(proofDir, 'snapshot'),
|
|
2073
|
-
viewport: managerApp ? { width: 1440, height: 900, deviceScaleFactor: 2 } : undefined,
|
|
2074
|
-
});
|
|
2075
|
-
task.proof = {
|
|
2076
|
-
pass: snap.pass, detail: snap.detail || 'UI change capture (auto, unverified)',
|
|
2077
|
-
gif: snap.gifPath?.split('/').pop() ?? null,
|
|
2078
|
-
png: snap.shotPath?.split('/').pop() ?? null,
|
|
2079
|
-
mp4: snap.videoPath?.split('/').pop() ?? null,
|
|
2080
|
-
};
|
|
2081
|
-
const baselineDir = opts.baselineDir;
|
|
2082
|
-
if (shouldCaptureBaseline({ baselineDir, afterDir: project.dir, canCaptureBaseline: baselineDir ? canSnapshot({ dir: baselineDir }) : false })) {
|
|
2083
|
-
let beph = null;
|
|
2084
|
-
try {
|
|
2085
|
-
beph = await startEphemeralApp(baselineDir);
|
|
2086
|
-
if (beph) {
|
|
2087
|
-
const bsnap = await runVerification({
|
|
2088
|
-
entryUrl: beph.url,
|
|
2089
|
-
verify: async (page, ui) => managerApp
|
|
2090
|
-
? verifyManagerProofTarget(page, ui, { goal: opts.goal, task })
|
|
2091
|
-
: (await exerciseUi(page, ui), { pass: true, detail: 'baseline (before)' }),
|
|
2092
|
-
outBase: join(proofDir, 'before'),
|
|
2093
|
-
viewport: managerApp ? { width: 1440, height: 900, deviceScaleFactor: 2 } : undefined,
|
|
2094
|
-
record: false, // comparison shot only — not worth a second video encode
|
|
2095
|
-
});
|
|
2096
|
-
task.proof.beforePng = bsnap.shotPath?.split('/').pop() ?? null;
|
|
2097
|
-
}
|
|
2098
|
-
} catch (e) {
|
|
2099
|
-
// Before/after is a nice-to-have on top of the mandatory "after"
|
|
2100
|
-
// capture above (already saved) — never let a flaky baseline boot
|
|
2101
|
-
// fail the whole capture. Report it and move on.
|
|
2102
|
-
sendAct(task, `proof: baseline (before) capture skipped — ${String(e.message ?? e).slice(0, 100)}`);
|
|
2103
|
-
} finally { beph?.kill(); }
|
|
2035
|
+
// On demand rather than always-on: a preview costs a real process, and most
|
|
2036
|
+
// finished goals are never opened. Idle ones are reaped, so a reviewer who
|
|
2037
|
+
// opens twenty cards doesn't leave twenty servers behind.
|
|
2038
|
+
const previews = new Map(); // taskId -> { proc, url, expires }
|
|
2039
|
+
const PREVIEW_IDLE_MS = 10 * 60 * 1000;
|
|
2040
|
+
const PREVIEW_BOOT_TRIES = 60; // × 250ms = 15s
|
|
2041
|
+
|
|
2042
|
+
function installPreviewReaper() {
|
|
2043
|
+
if (installPreviewReaper.done) return;
|
|
2044
|
+
installPreviewReaper.done = true;
|
|
2045
|
+
setInterval(() => {
|
|
2046
|
+
for (const [id, p] of previews) {
|
|
2047
|
+
if (p.expires > Date.now()) continue;
|
|
2048
|
+
try { p.proc.kill(); } catch { /* already gone */ }
|
|
2049
|
+
previews.delete(id);
|
|
2104
2050
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2051
|
+
}, 60 * 1000).unref?.();
|
|
2052
|
+
const killAll = () => { for (const p of previews.values()) { try { p.proc.kill(); } catch { /* already gone */ } } };
|
|
2053
|
+
process.on('exit', killAll);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
async function startPreview(task) {
|
|
2057
|
+
installPreviewReaper();
|
|
2058
|
+
const { cmd, url, dir } = task.run;
|
|
2059
|
+
const live = previews.get(task.id);
|
|
2060
|
+
if (live && live.proc.exitCode === null) {
|
|
2061
|
+
live.expires = Date.now() + PREVIEW_IDLE_MS;
|
|
2062
|
+
return { url: live.url, reused: true };
|
|
2063
|
+
}
|
|
2064
|
+
// Declared a URL but no command: the thing is already reachable (an app the
|
|
2065
|
+
// worker left running, a hosted page). Nothing to start.
|
|
2066
|
+
if (!cmd) return { url, reused: false, started: false };
|
|
2067
|
+
const proc = spawn(cmd, { cwd: dir, shell: true, stdio: 'ignore' });
|
|
2068
|
+
const entry = { proc, url: url ?? null, expires: Date.now() + PREVIEW_IDLE_MS };
|
|
2069
|
+
previews.set(task.id, entry);
|
|
2070
|
+
proc.on('close', () => { if (previews.get(task.id) === entry) previews.delete(task.id); });
|
|
2071
|
+
// No URL to poll — we started it and say so; the note tells the human where
|
|
2072
|
+
// to look.
|
|
2073
|
+
if (!url) return { url: null, reused: false, started: true };
|
|
2074
|
+
for (let i = 0; i < PREVIEW_BOOT_TRIES; i++) {
|
|
2075
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
2076
|
+
if (proc.exitCode !== null) throw new Error(`run command exited with code ${proc.exitCode} before ${url} answered`);
|
|
2077
|
+
// Any HTTP answer means it's up; a 404 on the root is still a live server.
|
|
2078
|
+
try { await fetch(url); return { url, reused: false, started: true }; } catch { /* not up yet */ }
|
|
2079
|
+
}
|
|
2080
|
+
try { proc.kill(); } catch { /* already gone */ }
|
|
2081
|
+
previews.delete(task.id);
|
|
2082
|
+
throw new Error(`${url} did not answer within 15s of running: ${cmd}`);
|
|
2108
2083
|
}
|
|
2109
2084
|
|
|
2110
2085
|
async function runTask(task) {
|
|
@@ -2163,7 +2138,7 @@ async function runTask(task) {
|
|
|
2163
2138
|
sendProcessAct(task, `Worker finished with exit code ${r.code}.`);
|
|
2164
2139
|
task.secs = Math.round((Date.now() - t0) / 1000);
|
|
2165
2140
|
task.status = r.code === 0 ? 'done' : 'failed';
|
|
2166
|
-
task.result = r.result.slice(0,
|
|
2141
|
+
task.result = r.result.slice(0, RESULT_MAX);
|
|
2167
2142
|
task.usage = sumUsage([r.usage]);
|
|
2168
2143
|
if (r.sessionId && goal) { goal.sessionId = r.sessionId; saveGoal(goal); }
|
|
2169
2144
|
const afterR = await gitChanges(workDir);
|
|
@@ -2196,7 +2171,7 @@ async function runTask(task) {
|
|
|
2196
2171
|
let callT0 = Date.now();
|
|
2197
2172
|
const plan = goal?.executionPlan;
|
|
2198
2173
|
const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
|
|
2199
|
-
const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, lastFailure)].filter(Boolean).join('\n\n');
|
|
2174
|
+
const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, lastFailure, workerAgent(task.agent ?? goal?.agent))].filter(Boolean).join('\n\n');
|
|
2200
2175
|
sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
|
|
2201
2176
|
let r = await runWorkerAgent({
|
|
2202
2177
|
agent: task.agent ?? goal?.agent,
|
|
@@ -2225,7 +2200,7 @@ async function runTask(task) {
|
|
|
2225
2200
|
sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
|
|
2226
2201
|
r = await runWorkerAgent({
|
|
2227
2202
|
agent: task.agent ?? goal?.agent,
|
|
2228
|
-
prompt: workerPrompt(task, goal, lastFailure), cwd: workDir, tools: WORKER_TOOLS,
|
|
2203
|
+
prompt: workerPrompt(task, goal, lastFailure, workerAgent(task.agent ?? goal?.agent)), cwd: workDir, tools: WORKER_TOOLS,
|
|
2229
2204
|
permissionMode,
|
|
2230
2205
|
model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
|
|
2231
2206
|
onTodos: (todos) => sendTodos(task, todos),
|
|
@@ -2301,15 +2276,20 @@ async function runTask(task) {
|
|
|
2301
2276
|
: forcedStop ? 'interrupted'
|
|
2302
2277
|
: conclusive ? 'failed'
|
|
2303
2278
|
: (code === 0 ? 'done' : 'failed');
|
|
2279
|
+
// Freeze the worker's own final TodoWrite list (P0b): live `todos` are stripped
|
|
2280
|
+
// on save, so snapshot them into `todosFinal` — persisted (blacklist saveTask /
|
|
2281
|
+
// trimTaskActivityForState keep it) so the board/review can show what the AI
|
|
2282
|
+
// actually did after it finishes, not just while it runs.
|
|
2283
|
+
task.todosFinal = Array.isArray(task.todos) ? task.todos : [];
|
|
2304
2284
|
// Never leave the reason as a bare "(no output)": if the worker produced no
|
|
2305
2285
|
// report text, result already carries workerExitReason(); make doubly sure a
|
|
2306
2286
|
// force-stopped task states WHY so the Attention card explains itself.
|
|
2307
2287
|
if (forcedStop && (!result || result === '(no output)')) result = workerExitReason(code, timedOut, WORKER_TIMEOUT_MS);
|
|
2308
|
-
task.result = result.slice(0,
|
|
2288
|
+
task.result = result.slice(0, RESULT_MAX);
|
|
2309
2289
|
task.usage = sumUsage(usages);
|
|
2310
2290
|
if (verdict) {
|
|
2311
2291
|
const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
|
|
2312
|
-
task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0,
|
|
2292
|
+
task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0, RESULT_MAX);
|
|
2313
2293
|
// Only attach an authoritative proof for a conclusive verdict. An
|
|
2314
2294
|
// inconclusive one must NOT leave a pass:false proof — that would trip
|
|
2315
2295
|
// verifyGate's proofFailing and re-block the goal through the back door
|
|
@@ -2329,28 +2309,28 @@ async function runTask(task) {
|
|
|
2329
2309
|
};
|
|
2330
2310
|
}
|
|
2331
2311
|
}
|
|
2312
|
+
// Evidence = the thing the worker actually made, reachable by the human.
|
|
2313
|
+
//
|
|
2314
|
+
// What used to be here (after changedFiles): an auto "proof" snapshot. It
|
|
2315
|
+
// booted an ephemeral app and screenshotted the MANAGER'S OWN flagship board
|
|
2316
|
+
// — never the deliverable — and canSnapshot() required engine/server.mjs +
|
|
2317
|
+
// app/index.html, so it only ever ran when the project was this repo. In
|
|
2318
|
+
// every customer project it was dead code, and in ours it produced a picture
|
|
2319
|
+
// unrelated to the change (#496: "3案見せて" → a screenshot of the board).
|
|
2320
|
+
// Staging a photo ourselves is the one thing this layer must not do, so it is
|
|
2321
|
+
// gone rather than fixed.
|
|
2322
|
+
//
|
|
2323
|
+
// What replaces it: whatever the worker itself declared as the way to see the
|
|
2324
|
+
// result. No classification of the request, no per-project knowledge here.
|
|
2325
|
+
//
|
|
2326
|
+
// Runs BEFORE changedFiles is computed because it consumes (reads + removes)
|
|
2327
|
+
// the declaration file — it's a message to the Manager, not part of the
|
|
2328
|
+
// deliverable, and it must not show up in the diff or the PR.
|
|
2329
|
+
readRunDeclaration(task, workDir);
|
|
2330
|
+
collectWorkerShots(task, workDir);
|
|
2332
2331
|
const after = await gitChanges(workDir);
|
|
2333
2332
|
task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
2334
2333
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
|
|
2335
|
-
|
|
2336
|
-
// A UI change with no explicit pass condition used to finish with no
|
|
2337
|
-
// evidence at all ("UI変更のはずなのに写真が撮られてない"). Capture a cheap
|
|
2338
|
-
// zero-LLM snapshot (screenshot + short clip) from an ephemeral instance
|
|
2339
|
-
// of the edited app so the review card always has something to look at.
|
|
2340
|
-
// baselineDir = the user's real project dir (unmodified, pre-goal code) —
|
|
2341
|
-
// when it's genuinely different from wproject.dir (a real worktree, not
|
|
2342
|
-
// the shared-dir fallback), also attempt a before/after pair.
|
|
2343
|
-
if (!conclusive && task.status === 'done' && shouldCaptureProof({ changedFiles: task.changedFiles, hasProof: Boolean(task.proof), canCapture: canSnapshot(wproject) })) {
|
|
2344
|
-
try {
|
|
2345
|
-
sendAct(task, 'proof: capturing UI change (screenshot + clip) of the edited app…');
|
|
2346
|
-
await captureSnapshotProof(task, wproject, { baselineDir: project.dir, goal });
|
|
2347
|
-
sendProcessAct(task, `Proof capture finished: ${task.proof?.pass === false ? 'target check failed' : 'screenshot/clip attached'}.`);
|
|
2348
|
-
if (task.proof && task.proof.pass === false) {
|
|
2349
|
-
task.status = 'failed';
|
|
2350
|
-
task.result = `proof FAIL: ${task.proof.detail}\n\n${task.result ?? ''}`.slice(0, 4000);
|
|
2351
|
-
}
|
|
2352
|
-
} catch (e) { sendAct(task, `proof: capture failed — ${String(e.message ?? e).slice(0, 100)}`); }
|
|
2353
|
-
}
|
|
2354
2334
|
task.finishedAt = new Date().toISOString();
|
|
2355
2335
|
saveTask(task);
|
|
2356
2336
|
sendProcessAct(task, `Task saved as ${task.status}.`);
|
|
@@ -2376,11 +2356,20 @@ async function runTask(task) {
|
|
|
2376
2356
|
}
|
|
2377
2357
|
}
|
|
2378
2358
|
|
|
2359
|
+
// Machine-wide lock around actually running `npm test` (see createSerialQueue
|
|
2360
|
+
// in lib.mjs): the suite spawns real headless Chrome + real servers, so two
|
|
2361
|
+
// goals verifying at once compete for the same finite CPU/ports/tmp-dirs and
|
|
2362
|
+
// throw a DIFFERENT flaky failure each time — which then never converges
|
|
2363
|
+
// under the baseline-diff gate (postmortem: goal #552/#487, 2026-07-21; both
|
|
2364
|
+
// goals' actual requested feature was already correct). Only the expensive
|
|
2365
|
+
// test-run itself is serialized — planning/working on other goals is
|
|
2366
|
+
// unaffected, they just queue for their verification turn.
|
|
2367
|
+
const testRunQueue = createSerialQueue();
|
|
2379
2368
|
// Manager-run tests (never trust the worker's "all green" claim): run the
|
|
2380
2369
|
// project's own `npm test` in the goal's worktree and parse node --test's
|
|
2381
2370
|
// "ℹ pass N / ℹ fail N" (or TAP "# pass/# fail"). Returns {ran,passed,failed,ok}.
|
|
2382
2371
|
function runTestsOnce(dir) {
|
|
2383
|
-
return new Promise((res) => {
|
|
2372
|
+
return testRunQueue.run(() => new Promise((res) => {
|
|
2384
2373
|
execFile('npm', ['test'], { cwd: dir, timeout: 240000, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
|
|
2385
2374
|
const text = `${out}\n${err}`;
|
|
2386
2375
|
const n = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
|
|
@@ -2399,7 +2388,7 @@ function runTestsOnce(dir) {
|
|
|
2399
2388
|
// diff them against the base's failures and block only on NEW ones.
|
|
2400
2389
|
res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
|
|
2401
2390
|
});
|
|
2402
|
-
});
|
|
2391
|
+
}));
|
|
2403
2392
|
}
|
|
2404
2393
|
async function runProjectTests(dir) {
|
|
2405
2394
|
let hasTest = false;
|
|
@@ -2839,28 +2828,22 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2839
2828
|
goalAct('Writing the review summary from the worker report and diff.');
|
|
2840
2829
|
goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
|
|
2841
2830
|
}
|
|
2842
|
-
|
|
2843
|
-
//
|
|
2844
|
-
//
|
|
2845
|
-
//
|
|
2846
|
-
//
|
|
2847
|
-
//
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
}
|
|
2859
|
-
}
|
|
2860
|
-
saveTask(t);
|
|
2861
|
-
}
|
|
2862
|
-
const proofMissing = canCapture && uiTasks.length > 0 && !uiTasks.some((t) => t.proof);
|
|
2863
|
-
const proofFailing = canCapture && uiTasks.some((t) => t.proof && t.proof.pass === false);
|
|
2831
|
+
// The goal-level proof retry loop (3 attempts to screenshot the Manager's own
|
|
2832
|
+
// board) is gone with the rest of the staged-photo machinery — see the note
|
|
2833
|
+
// where the per-task capture used to be. Nothing here photographs anything
|
|
2834
|
+
// now; the human gets the worker's run declaration and its own report.
|
|
2835
|
+
//
|
|
2836
|
+
// proofMissing is retired with it, and deliberately not replaced. It meant
|
|
2837
|
+
// "a UI task carries no auto-snapshot" — with no auto-snapshot left, keeping
|
|
2838
|
+
// it would block every UI goal forever, and blocking a goal because WE failed
|
|
2839
|
+
// to take a picture is exactly the Failed-manufacturing this product decided
|
|
2840
|
+
// against (検証は薄く任意・Failed を作らない).
|
|
2841
|
+
const proofMissing = false;
|
|
2842
|
+
// A genuinely FAILED verification still blocks: this only ever comes from an
|
|
2843
|
+
// explicit passCondition run (task.proof.verified), which is the opt-in strong
|
|
2844
|
+
// path we keep. No canSnapshot() gate — that gate silently limited this to
|
|
2845
|
+
// Manager-shaped repos.
|
|
2846
|
+
const proofFailing = siblings.some((t) => t.proof && t.proof.pass === false);
|
|
2864
2847
|
let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
|
|
2865
2848
|
// Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
|
|
2866
2849
|
// introduced. A test already red on the base — e.g. persist-failure on clean
|
|
@@ -2903,9 +2886,10 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2903
2886
|
const verifyFail = reviewDefinition.requireVerifyPass && !allVerified;
|
|
2904
2887
|
// 段5 検証ゲート厳格化 (Masa 2026-07-15: "proof無しは完了と言わない"): passCondition
|
|
2905
2888
|
// を約束したタスクなのに 0 ファイル変更で proof も無い=約束した検証が実行されずに
|
|
2906
|
-
//
|
|
2907
|
-
//
|
|
2908
|
-
//
|
|
2889
|
+
// すり抜けたサイン。passCondition が無い調査系ゴール(0ファイルが正当な完了)は
|
|
2890
|
+
// 対象外 — 人間が読んで判断すれば足りる。
|
|
2891
|
+
// これは「約束した強検証が走らなかった」検知であって、写真の有無ではない
|
|
2892
|
+
// (P1で自動撮影と proofMissing は撤去済み)。
|
|
2909
2893
|
const nothingVerifiable = isNothingVerifiable({
|
|
2910
2894
|
changedFiles,
|
|
2911
2895
|
hasPassCondition: siblings.some((t) => t.passCondition),
|
|
@@ -3659,21 +3643,37 @@ const server = createServer(async (req, res) => {
|
|
|
3659
3643
|
return json(res, 200, task);
|
|
3660
3644
|
}
|
|
3661
3645
|
|
|
3662
|
-
//
|
|
3663
|
-
//
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3646
|
+
// Start what the worker said to start, and hand back the URL to look at.
|
|
3647
|
+
//
|
|
3648
|
+
// This is the "動かして見られる状態" the review card offers. It replaces the
|
|
3649
|
+
// old POST /api/tasks/:id/snapshot (backfill a staged screenshot), which is
|
|
3650
|
+
// gone along with the rest of the photo-staging.
|
|
3651
|
+
//
|
|
3652
|
+
// On demand, not always-on: a goal's preview costs a process, and most goals
|
|
3653
|
+
// are never opened. Started here, reaped when idle.
|
|
3654
|
+
const previewMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/preview$/);
|
|
3655
|
+
if (previewMatch && req.method === 'POST') {
|
|
3656
|
+
const task = tasks.find((t) => t.id === Number(previewMatch[1]));
|
|
3667
3657
|
if (!task) return json(res, 404, { error: 'task not found' });
|
|
3668
3658
|
if (!requireTaskGoalOwnership(req, res, task)) return;
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
.then(() => json(res, 200, task))
|
|
3659
|
+
if (!task.run) return json(res, 409, { error: 'no run declaration for this task' });
|
|
3660
|
+
startPreview(task)
|
|
3661
|
+
.then((p) => json(res, 200, p))
|
|
3673
3662
|
.catch((e) => json(res, 500, { error: String(e.message ?? e).slice(0, 200) }));
|
|
3674
3663
|
return;
|
|
3675
3664
|
}
|
|
3676
3665
|
|
|
3666
|
+
// The implementation log for one task, read on demand (see freezeActivityLog above).
|
|
3667
|
+
// A task still running answers from memory — the same lines the SSE stream is feeding
|
|
3668
|
+
// the page — so opening a card mid-run shows the work in progress, not an empty box.
|
|
3669
|
+
const activityMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/activity$/);
|
|
3670
|
+
if (activityMatch && req.method === 'GET') {
|
|
3671
|
+
const task = tasks.find((t) => t.id === Number(activityMatch[1]));
|
|
3672
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
3673
|
+
if (!requireTaskGoalOwnership(req, res, task)) return;
|
|
3674
|
+
return json(res, 200, { lines: task.activity?.length ? task.activity : readActivityLog(task.id) });
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
3677
|
// close a stalled task without running it (failure-taxonomy: skipped)
|
|
3678
3678
|
const skipMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/skip$/);
|
|
3679
3679
|
if (skipMatch && req.method === 'POST') {
|