@galda/cli 0.10.38 → 0.10.40
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 +511 -342
- package/engine/lib.mjs +450 -37
- package/engine/server.mjs +274 -279
- 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, parseCodexEvents, buildCodexArgs, 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);
|
|
@@ -1047,54 +1080,49 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
|
|
|
1047
1080
|
});
|
|
1048
1081
|
});
|
|
1049
1082
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
|
|
1055
|
-
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
|
1056
|
-
cache_read_input_tokens: usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0,
|
|
1057
|
-
};
|
|
1058
|
-
}
|
|
1059
|
-
function runCodex({ prompt, cwd, onEvent, permissionMode = 'acceptEdits', model, effort, onChild }) {
|
|
1083
|
+
// The Codex half of the worker bridge. Same contract as runClaude, because
|
|
1084
|
+
// nothing downstream may care which agent ran: live activity, the agent's own
|
|
1085
|
+
// to-dos (the board), the thread id to talk to it again, usage, final message.
|
|
1086
|
+
function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acceptEdits', model, effort, onChild }) {
|
|
1060
1087
|
return new Promise((done) => {
|
|
1061
1088
|
const sandbox = permissionMode === 'plan' ? 'read-only' : 'workspace-write';
|
|
1062
|
-
const args =
|
|
1089
|
+
const args = buildCodexArgs({ model: workerModel('codex', model), effort: workerEffort('codex', effort), sandbox, cwd, resume });
|
|
1063
1090
|
const child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1064
1091
|
onChild?.(child);
|
|
1065
1092
|
child.stdin.end(prompt);
|
|
1066
|
-
let result = '', err = '', buf = '', usage = null, timedOut = false, lastAct = '';
|
|
1093
|
+
let result = '', err = '', buf = '', sessionId = null, usage = null, timedOut = false, lastAct = '';
|
|
1067
1094
|
const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, WORKER_TIMEOUT_MS);
|
|
1068
1095
|
child.stdout.on('data', (d) => {
|
|
1069
1096
|
buf += d;
|
|
1070
1097
|
const lines = buf.split('\n'); buf = lines.pop();
|
|
1071
1098
|
for (const line of lines) {
|
|
1072
1099
|
if (!line.trim()) continue;
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1100
|
+
const evs = parseCodexEvents(line);
|
|
1101
|
+
// A line we cannot parse is still the worker talking — show it rather
|
|
1102
|
+
// than swallow it (how this ran before the bridge existed).
|
|
1103
|
+
if (!evs.length && !line.trim().startsWith('{')) {
|
|
1104
|
+
lastAct = line.trim().slice(0, 240);
|
|
1105
|
+
onEvent?.(lastAct);
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
for (const ev of evs) {
|
|
1109
|
+
if (ev.kind === 'session') sessionId = ev.id;
|
|
1110
|
+
else if (ev.kind === 'usage') usage = ev.usage;
|
|
1111
|
+
else if (ev.kind === 'todos') onTodos?.(ev.todos);
|
|
1112
|
+
else if (ev.kind === 'message') {
|
|
1113
|
+
result = ev.text;
|
|
1114
|
+
onEvent?.(`✎ ${ev.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
|
|
1115
|
+
} else if (ev.kind === 'error') {
|
|
1116
|
+
err += `${ev.text}\n`;
|
|
1117
|
+
onEvent?.(`Error ${ev.text}`);
|
|
1118
|
+
} else { lastAct = ev.text; onEvent?.(ev.text); }
|
|
1091
1119
|
}
|
|
1092
1120
|
}
|
|
1093
1121
|
});
|
|
1094
1122
|
child.stderr.on('data', (d) => { err += d; });
|
|
1095
1123
|
child.on('close', (code) => {
|
|
1096
1124
|
clearTimeout(timer);
|
|
1097
|
-
done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId
|
|
1125
|
+
done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId, usage });
|
|
1098
1126
|
});
|
|
1099
1127
|
});
|
|
1100
1128
|
}
|
|
@@ -1401,7 +1429,9 @@ async function planGoal(goal) {
|
|
|
1401
1429
|
'- 複数タスクに分けるのは、依頼に「明確に別々の成果物・トピック」が含まれる時「だけ」(例:「音声入力を追加して、あとビジュアル作成も」→ 音声入力 / ビジュアル作成 の2つ)。工程での分割は絶対にしない。',
|
|
1402
1430
|
'- 被っている・言い換えているだけの依頼は、自分で理解して1つにまとめる。',
|
|
1403
1431
|
'- タスク数は最小に。既定1個、多くても distinct な依頼の数だけ(現実的に1〜3、最大4)。依存順に並べる。',
|
|
1404
|
-
'- 各タスクの title
|
|
1432
|
+
'- 各タスクの title は「ユーザーが頼んだこと」を、ユーザーの言葉・語彙をそのまま活かして短く書く。実装のやり方(「〜するように直した」「〜を計算する」等)や、完了報告の文(「〜した」「〜しました」)を title にしない。エンジニアリングの手順名(設計/実装/テスト等)にもしない。例: 依頼「一回たたむとsettingボタンがズレるバグ修正して」→ title「settingボタンがズレるバグの修正」(✕「アニメーションが終わってから歯車の位置を計算するように直した」)。',
|
|
1433
|
+
'- バグ修正の依頼では、title に「どの問題を直すか」がわかるように書く(解決する問題=ユーザーの言葉)。依頼に直し方の方針が書かれていればそれも短く添えてよいが、無ければ問題だけでよい。工程・実装詳細は detail に書く。',
|
|
1434
|
+
'- detail には worker への完全な指示を書く:そのworkerが1人で実装・テスト・proofまで完結させる。工程ごとの兄弟タスクは来ない前提で書くこと。',
|
|
1405
1435
|
'- passCondition はユーザーが依頼文で明示的に検証条件(「成功条件:」「〜したら〜になる」等)を書いた場合のみ設定する。見た目の調整・文言変更・スタイル変更のタスクには付けない(null)。検証は高価なので、明示された時だけ。',
|
|
1406
1436
|
'- 検証対象のページがあれば entry に書く(プロジェクト相対のhtmlパス、または http URL。無ければ null)。依頼中に書かれていればそれを使い、無ければリポジトリを見て推測してよい。',
|
|
1407
1437
|
'- 依頼が曖昧で重要な前提が1つ欠けている(例: 対象・範囲・形式が不明)か、既存の作業と矛盾する場合「のみ」、タスクではなく確認質問を返す: {"question":"確認したい1点(短く)","options":["選択肢A","選択肢B"]}。ただし基本は合理的な仮定で進めること。質問は本当に必要な時だけ、1問に絞る。',
|
|
@@ -1457,22 +1487,25 @@ async function planGoal(goal) {
|
|
|
1457
1487
|
plan.tasks = refinePlanTasks(plan.tasks, { goalText: goal.text });
|
|
1458
1488
|
goal.status = 'running';
|
|
1459
1489
|
goal.entry = plan.entry;
|
|
1460
|
-
|
|
1490
|
+
// Demoted planner (Masa 2026-07-21, P0b): the planner LLM still resolves the
|
|
1491
|
+
// entry URL, asks for clarification, and warms the session — but it no longer
|
|
1492
|
+
// DECOMPOSES the request into tasks the worker must follow. The request goes
|
|
1493
|
+
// to the worker verbatim as ONE task; the worker (Claude Code / Codex) decides
|
|
1494
|
+
// its own breakdown and emits it as its own TodoWrite, which the board shows.
|
|
1495
|
+
// One card = one request. A user-written pass condition still rides along.
|
|
1496
|
+
const title = goalTaskTitle(goal.text);
|
|
1497
|
+
goal.plan = [title];
|
|
1461
1498
|
saveGoal(goal);
|
|
1462
1499
|
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
|
-
}
|
|
1500
|
+
const num = (base.length ? Math.max(...base) : 0) + 1;
|
|
1501
|
+
const task = buildGoalTask(goal, {
|
|
1502
|
+
id: nextId++, num,
|
|
1503
|
+
passCondition: plan.tasks[0]?.passCondition ?? null,
|
|
1504
|
+
createdAt: new Date().toISOString(),
|
|
1505
|
+
});
|
|
1506
|
+
tasks.push(task);
|
|
1507
|
+
saveTask(task);
|
|
1508
|
+
queues.get(goal.projectId).waiting.push(task);
|
|
1476
1509
|
pump(goal.projectId);
|
|
1477
1510
|
}
|
|
1478
1511
|
|
|
@@ -1838,44 +1871,8 @@ function testOutputExcerpt(text) {
|
|
|
1838
1871
|
return chosen.join('\n').slice(-3000);
|
|
1839
1872
|
}
|
|
1840
1873
|
|
|
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
|
-
}
|
|
1874
|
+
// workerPrompt moved to lib.mjs (pure + unit-tested). Caller resolves the
|
|
1875
|
+
// agent id via workerAgent(); lib keeps the presentation.
|
|
1879
1876
|
|
|
1880
1877
|
// A fresh ephemeral instance starts with zero history, but some pass
|
|
1881
1878
|
// conditions are about completion-time behavior (e.g. task 46: a goal
|
|
@@ -1963,148 +1960,121 @@ function entryUrlFor(goal, project) {
|
|
|
1963
1960
|
return resolveEntryUrl({ entry: goal?.entry, projectDir: project.dir, managerRoot: ROOT, port: PORT, accessKey: ACCESS_KEY });
|
|
1964
1961
|
}
|
|
1965
1962
|
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1963
|
+
// ---- EVIDENCE: what the worker made, actually reachable ---------------------
|
|
1964
|
+
// Everything that used to live here (proofIntentFor / verifyManagerProofTarget /
|
|
1965
|
+
// captureSnapshotProof) staged a photograph: it booted an ephemeral copy of the
|
|
1966
|
+
// project and screenshotted the MANAGER'S OWN flagship board, picking WHICH
|
|
1967
|
+
// region to shoot by keyword-matching the request text. That produced pictures
|
|
1968
|
+
// unrelated to the change (#496) and, because canSnapshot() demanded
|
|
1969
|
+
// engine/server.mjs + app/index.html, it never ran at all outside this repo.
|
|
1970
|
+
//
|
|
1971
|
+
// The replacement asks the only AI in the pipeline — the worker that just did
|
|
1972
|
+
// the work — how a human can run and see the result, and then does exactly
|
|
1973
|
+
// that. This layer never guesses the command and never classifies the request.
|
|
1969
1974
|
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1975
|
+
// Consume the worker's run declaration from the worktree root. It is a message
|
|
1976
|
+
// to the Manager, not part of the deliverable, so it is deleted after reading:
|
|
1977
|
+
// it must not reach the diff or the PR.
|
|
1978
|
+
//
|
|
1979
|
+
// Silence is a valid answer. A request with nothing to run (research, a
|
|
1980
|
+
// summary, an answer to a question) leaves no declaration and gets no "run it"
|
|
1981
|
+
// button — there, the worker's own words are the whole deliverable.
|
|
1982
|
+
function readRunDeclaration(task, workDir) {
|
|
1983
|
+
const file = join(workDir, RUN_DECL_FILE);
|
|
1984
|
+
let raw = null;
|
|
1985
|
+
try { raw = readFileSync(file, 'utf8'); } catch { return; }
|
|
1986
|
+
try { unlinkSync(file); } catch { /* best effort — never fail a task over cleanup */ }
|
|
1987
|
+
const run = parseRunDeclaration(raw);
|
|
1988
|
+
if (!run) {
|
|
1989
|
+
sendAct(task, `run declaration ignored — ${RUN_DECL_FILE} was not usable JSON`);
|
|
1990
|
+
return;
|
|
1974
1991
|
}
|
|
1975
|
-
|
|
1976
|
-
|
|
1992
|
+
task.run = { ...run, dir: workDir };
|
|
1993
|
+
sendProcessAct(task, `Worker declared how to see the result: ${run.cmd ?? run.url}`);
|
|
1977
1994
|
}
|
|
1978
1995
|
|
|
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
|
-
};
|
|
1996
|
+
// Take whatever the worker photographed out of the worktree (SHOT_DIR) and put
|
|
1997
|
+
// it where the Manager already serves images from, so the pictures are reachable
|
|
1998
|
+
// at a URL instead of sitting in a temp checkout that gets deleted.
|
|
1999
|
+
//
|
|
2000
|
+
// Moved, not copied, and moved BEFORE the diff is computed: a screenshot is the
|
|
2001
|
+
// worker talking to us about the deliverable, not the deliverable, and it must
|
|
2002
|
+
// not turn up as a binary file in the pull request.
|
|
2003
|
+
//
|
|
2004
|
+
// This function does not know what a good picture is. It never shoots anything
|
|
2005
|
+
// itself, and an empty SHOT_DIR is a normal outcome — plenty of requests have
|
|
2006
|
+
// nothing to look at.
|
|
2007
|
+
function collectWorkerShots(task, workDir) {
|
|
2008
|
+
const dir = join(workDir, SHOT_DIR);
|
|
2009
|
+
let names = [];
|
|
2010
|
+
try { names = collectShotNames(readdirSync(dir)); } catch { return; }
|
|
2011
|
+
const outDir = join(logDir, 'uploads');
|
|
2012
|
+
mkdirSync(outDir, { recursive: true });
|
|
2013
|
+
const shots = [];
|
|
2014
|
+
for (const name of names) {
|
|
2015
|
+
const safe = name.replace(/[^\w.-]/g, '_').slice(-80);
|
|
2016
|
+
const file = `${Date.now()}-shot-${task.id}-${safe}`;
|
|
2017
|
+
try {
|
|
2018
|
+
writeFileSync(join(outDir, file), readFileSync(join(dir, name)));
|
|
2019
|
+
shots.push({ name, url: `/upload/${file}`, path: join(outDir, file) });
|
|
2020
|
+
} catch { /* one unreadable shot must not cost the task its other shots */ }
|
|
2028
2021
|
}
|
|
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` };
|
|
2022
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
2023
|
+
if (!shots.length) return;
|
|
2024
|
+
task.shots = [...(task.shots ?? []), ...shots];
|
|
2025
|
+
sendProcessAct(task, `Worker attached ${shots.length} screenshot${shots.length === 1 ? '' : 's'} of the result.`);
|
|
2037
2026
|
}
|
|
2038
2027
|
|
|
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.
|
|
2028
|
+
// Live previews, started on demand by POST /api/tasks/:id/preview.
|
|
2043
2029
|
//
|
|
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(); }
|
|
2030
|
+
// On demand rather than always-on: a preview costs a real process, and most
|
|
2031
|
+
// finished goals are never opened. Idle ones are reaped, so a reviewer who
|
|
2032
|
+
// opens twenty cards doesn't leave twenty servers behind.
|
|
2033
|
+
const previews = new Map(); // taskId -> { proc, url, expires }
|
|
2034
|
+
const PREVIEW_IDLE_MS = 10 * 60 * 1000;
|
|
2035
|
+
const PREVIEW_BOOT_TRIES = 60; // × 250ms = 15s
|
|
2036
|
+
|
|
2037
|
+
function installPreviewReaper() {
|
|
2038
|
+
if (installPreviewReaper.done) return;
|
|
2039
|
+
installPreviewReaper.done = true;
|
|
2040
|
+
setInterval(() => {
|
|
2041
|
+
for (const [id, p] of previews) {
|
|
2042
|
+
if (p.expires > Date.now()) continue;
|
|
2043
|
+
try { p.proc.kill(); } catch { /* already gone */ }
|
|
2044
|
+
previews.delete(id);
|
|
2104
2045
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2046
|
+
}, 60 * 1000).unref?.();
|
|
2047
|
+
const killAll = () => { for (const p of previews.values()) { try { p.proc.kill(); } catch { /* already gone */ } } };
|
|
2048
|
+
process.on('exit', killAll);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
async function startPreview(task) {
|
|
2052
|
+
installPreviewReaper();
|
|
2053
|
+
const { cmd, url, dir } = task.run;
|
|
2054
|
+
const live = previews.get(task.id);
|
|
2055
|
+
if (live && live.proc.exitCode === null) {
|
|
2056
|
+
live.expires = Date.now() + PREVIEW_IDLE_MS;
|
|
2057
|
+
return { url: live.url, reused: true };
|
|
2058
|
+
}
|
|
2059
|
+
// Declared a URL but no command: the thing is already reachable (an app the
|
|
2060
|
+
// worker left running, a hosted page). Nothing to start.
|
|
2061
|
+
if (!cmd) return { url, reused: false, started: false };
|
|
2062
|
+
const proc = spawn(cmd, { cwd: dir, shell: true, stdio: 'ignore' });
|
|
2063
|
+
const entry = { proc, url: url ?? null, expires: Date.now() + PREVIEW_IDLE_MS };
|
|
2064
|
+
previews.set(task.id, entry);
|
|
2065
|
+
proc.on('close', () => { if (previews.get(task.id) === entry) previews.delete(task.id); });
|
|
2066
|
+
// No URL to poll — we started it and say so; the note tells the human where
|
|
2067
|
+
// to look.
|
|
2068
|
+
if (!url) return { url: null, reused: false, started: true };
|
|
2069
|
+
for (let i = 0; i < PREVIEW_BOOT_TRIES; i++) {
|
|
2070
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
2071
|
+
if (proc.exitCode !== null) throw new Error(`run command exited with code ${proc.exitCode} before ${url} answered`);
|
|
2072
|
+
// Any HTTP answer means it's up; a 404 on the root is still a live server.
|
|
2073
|
+
try { await fetch(url); return { url, reused: false, started: true }; } catch { /* not up yet */ }
|
|
2074
|
+
}
|
|
2075
|
+
try { proc.kill(); } catch { /* already gone */ }
|
|
2076
|
+
previews.delete(task.id);
|
|
2077
|
+
throw new Error(`${url} did not answer within 15s of running: ${cmd}`);
|
|
2108
2078
|
}
|
|
2109
2079
|
|
|
2110
2080
|
async function runTask(task) {
|
|
@@ -2163,7 +2133,7 @@ async function runTask(task) {
|
|
|
2163
2133
|
sendProcessAct(task, `Worker finished with exit code ${r.code}.`);
|
|
2164
2134
|
task.secs = Math.round((Date.now() - t0) / 1000);
|
|
2165
2135
|
task.status = r.code === 0 ? 'done' : 'failed';
|
|
2166
|
-
task.result = r.result.slice(0,
|
|
2136
|
+
task.result = r.result.slice(0, RESULT_MAX);
|
|
2167
2137
|
task.usage = sumUsage([r.usage]);
|
|
2168
2138
|
if (r.sessionId && goal) { goal.sessionId = r.sessionId; saveGoal(goal); }
|
|
2169
2139
|
const afterR = await gitChanges(workDir);
|
|
@@ -2196,7 +2166,7 @@ async function runTask(task) {
|
|
|
2196
2166
|
let callT0 = Date.now();
|
|
2197
2167
|
const plan = goal?.executionPlan;
|
|
2198
2168
|
const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
|
|
2199
|
-
const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, lastFailure)].filter(Boolean).join('\n\n');
|
|
2169
|
+
const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, lastFailure, workerAgent(task.agent ?? goal?.agent))].filter(Boolean).join('\n\n');
|
|
2200
2170
|
sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
|
|
2201
2171
|
let r = await runWorkerAgent({
|
|
2202
2172
|
agent: task.agent ?? goal?.agent,
|
|
@@ -2225,7 +2195,7 @@ async function runTask(task) {
|
|
|
2225
2195
|
sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
|
|
2226
2196
|
r = await runWorkerAgent({
|
|
2227
2197
|
agent: task.agent ?? goal?.agent,
|
|
2228
|
-
prompt: workerPrompt(task, goal, lastFailure), cwd: workDir, tools: WORKER_TOOLS,
|
|
2198
|
+
prompt: workerPrompt(task, goal, lastFailure, workerAgent(task.agent ?? goal?.agent)), cwd: workDir, tools: WORKER_TOOLS,
|
|
2229
2199
|
permissionMode,
|
|
2230
2200
|
model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
|
|
2231
2201
|
onTodos: (todos) => sendTodos(task, todos),
|
|
@@ -2301,15 +2271,20 @@ async function runTask(task) {
|
|
|
2301
2271
|
: forcedStop ? 'interrupted'
|
|
2302
2272
|
: conclusive ? 'failed'
|
|
2303
2273
|
: (code === 0 ? 'done' : 'failed');
|
|
2274
|
+
// Freeze the worker's own final TodoWrite list (P0b): live `todos` are stripped
|
|
2275
|
+
// on save, so snapshot them into `todosFinal` — persisted (blacklist saveTask /
|
|
2276
|
+
// trimTaskActivityForState keep it) so the board/review can show what the AI
|
|
2277
|
+
// actually did after it finishes, not just while it runs.
|
|
2278
|
+
task.todosFinal = Array.isArray(task.todos) ? task.todos : [];
|
|
2304
2279
|
// Never leave the reason as a bare "(no output)": if the worker produced no
|
|
2305
2280
|
// report text, result already carries workerExitReason(); make doubly sure a
|
|
2306
2281
|
// force-stopped task states WHY so the Attention card explains itself.
|
|
2307
2282
|
if (forcedStop && (!result || result === '(no output)')) result = workerExitReason(code, timedOut, WORKER_TIMEOUT_MS);
|
|
2308
|
-
task.result = result.slice(0,
|
|
2283
|
+
task.result = result.slice(0, RESULT_MAX);
|
|
2309
2284
|
task.usage = sumUsage(usages);
|
|
2310
2285
|
if (verdict) {
|
|
2311
2286
|
const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
|
|
2312
|
-
task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0,
|
|
2287
|
+
task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0, RESULT_MAX);
|
|
2313
2288
|
// Only attach an authoritative proof for a conclusive verdict. An
|
|
2314
2289
|
// inconclusive one must NOT leave a pass:false proof — that would trip
|
|
2315
2290
|
// verifyGate's proofFailing and re-block the goal through the back door
|
|
@@ -2329,28 +2304,28 @@ async function runTask(task) {
|
|
|
2329
2304
|
};
|
|
2330
2305
|
}
|
|
2331
2306
|
}
|
|
2307
|
+
// Evidence = the thing the worker actually made, reachable by the human.
|
|
2308
|
+
//
|
|
2309
|
+
// What used to be here (after changedFiles): an auto "proof" snapshot. It
|
|
2310
|
+
// booted an ephemeral app and screenshotted the MANAGER'S OWN flagship board
|
|
2311
|
+
// — never the deliverable — and canSnapshot() required engine/server.mjs +
|
|
2312
|
+
// app/index.html, so it only ever ran when the project was this repo. In
|
|
2313
|
+
// every customer project it was dead code, and in ours it produced a picture
|
|
2314
|
+
// unrelated to the change (#496: "3案見せて" → a screenshot of the board).
|
|
2315
|
+
// Staging a photo ourselves is the one thing this layer must not do, so it is
|
|
2316
|
+
// gone rather than fixed.
|
|
2317
|
+
//
|
|
2318
|
+
// What replaces it: whatever the worker itself declared as the way to see the
|
|
2319
|
+
// result. No classification of the request, no per-project knowledge here.
|
|
2320
|
+
//
|
|
2321
|
+
// Runs BEFORE changedFiles is computed because it consumes (reads + removes)
|
|
2322
|
+
// the declaration file — it's a message to the Manager, not part of the
|
|
2323
|
+
// deliverable, and it must not show up in the diff or the PR.
|
|
2324
|
+
readRunDeclaration(task, workDir);
|
|
2325
|
+
collectWorkerShots(task, workDir);
|
|
2332
2326
|
const after = await gitChanges(workDir);
|
|
2333
2327
|
task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
2334
2328
|
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
2329
|
task.finishedAt = new Date().toISOString();
|
|
2355
2330
|
saveTask(task);
|
|
2356
2331
|
sendProcessAct(task, `Task saved as ${task.status}.`);
|
|
@@ -2376,11 +2351,20 @@ async function runTask(task) {
|
|
|
2376
2351
|
}
|
|
2377
2352
|
}
|
|
2378
2353
|
|
|
2354
|
+
// Machine-wide lock around actually running `npm test` (see createSerialQueue
|
|
2355
|
+
// in lib.mjs): the suite spawns real headless Chrome + real servers, so two
|
|
2356
|
+
// goals verifying at once compete for the same finite CPU/ports/tmp-dirs and
|
|
2357
|
+
// throw a DIFFERENT flaky failure each time — which then never converges
|
|
2358
|
+
// under the baseline-diff gate (postmortem: goal #552/#487, 2026-07-21; both
|
|
2359
|
+
// goals' actual requested feature was already correct). Only the expensive
|
|
2360
|
+
// test-run itself is serialized — planning/working on other goals is
|
|
2361
|
+
// unaffected, they just queue for their verification turn.
|
|
2362
|
+
const testRunQueue = createSerialQueue();
|
|
2379
2363
|
// Manager-run tests (never trust the worker's "all green" claim): run the
|
|
2380
2364
|
// project's own `npm test` in the goal's worktree and parse node --test's
|
|
2381
2365
|
// "ℹ pass N / ℹ fail N" (or TAP "# pass/# fail"). Returns {ran,passed,failed,ok}.
|
|
2382
2366
|
function runTestsOnce(dir) {
|
|
2383
|
-
return new Promise((res) => {
|
|
2367
|
+
return testRunQueue.run(() => new Promise((res) => {
|
|
2384
2368
|
execFile('npm', ['test'], { cwd: dir, timeout: 240000, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
|
|
2385
2369
|
const text = `${out}\n${err}`;
|
|
2386
2370
|
const n = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
|
|
@@ -2399,7 +2383,7 @@ function runTestsOnce(dir) {
|
|
|
2399
2383
|
// diff them against the base's failures and block only on NEW ones.
|
|
2400
2384
|
res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
|
|
2401
2385
|
});
|
|
2402
|
-
});
|
|
2386
|
+
}));
|
|
2403
2387
|
}
|
|
2404
2388
|
async function runProjectTests(dir) {
|
|
2405
2389
|
let hasTest = false;
|
|
@@ -2839,28 +2823,22 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2839
2823
|
goalAct('Writing the review summary from the worker report and diff.');
|
|
2840
2824
|
goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
|
|
2841
2825
|
}
|
|
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);
|
|
2826
|
+
// The goal-level proof retry loop (3 attempts to screenshot the Manager's own
|
|
2827
|
+
// board) is gone with the rest of the staged-photo machinery — see the note
|
|
2828
|
+
// where the per-task capture used to be. Nothing here photographs anything
|
|
2829
|
+
// now; the human gets the worker's run declaration and its own report.
|
|
2830
|
+
//
|
|
2831
|
+
// proofMissing is retired with it, and deliberately not replaced. It meant
|
|
2832
|
+
// "a UI task carries no auto-snapshot" — with no auto-snapshot left, keeping
|
|
2833
|
+
// it would block every UI goal forever, and blocking a goal because WE failed
|
|
2834
|
+
// to take a picture is exactly the Failed-manufacturing this product decided
|
|
2835
|
+
// against (検証は薄く任意・Failed を作らない).
|
|
2836
|
+
const proofMissing = false;
|
|
2837
|
+
// A genuinely FAILED verification still blocks: this only ever comes from an
|
|
2838
|
+
// explicit passCondition run (task.proof.verified), which is the opt-in strong
|
|
2839
|
+
// path we keep. No canSnapshot() gate — that gate silently limited this to
|
|
2840
|
+
// Manager-shaped repos.
|
|
2841
|
+
const proofFailing = siblings.some((t) => t.proof && t.proof.pass === false);
|
|
2864
2842
|
let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
|
|
2865
2843
|
// Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
|
|
2866
2844
|
// introduced. A test already red on the base — e.g. persist-failure on clean
|
|
@@ -2903,9 +2881,10 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2903
2881
|
const verifyFail = reviewDefinition.requireVerifyPass && !allVerified;
|
|
2904
2882
|
// 段5 検証ゲート厳格化 (Masa 2026-07-15: "proof無しは完了と言わない"): passCondition
|
|
2905
2883
|
// を約束したタスクなのに 0 ファイル変更で proof も無い=約束した検証が実行されずに
|
|
2906
|
-
//
|
|
2907
|
-
//
|
|
2908
|
-
//
|
|
2884
|
+
// すり抜けたサイン。passCondition が無い調査系ゴール(0ファイルが正当な完了)は
|
|
2885
|
+
// 対象外 — 人間が読んで判断すれば足りる。
|
|
2886
|
+
// これは「約束した強検証が走らなかった」検知であって、写真の有無ではない
|
|
2887
|
+
// (P1で自動撮影と proofMissing は撤去済み)。
|
|
2909
2888
|
const nothingVerifiable = isNothingVerifiable({
|
|
2910
2889
|
changedFiles,
|
|
2911
2890
|
hasPassCondition: siblings.some((t) => t.passCondition),
|
|
@@ -3659,21 +3638,37 @@ const server = createServer(async (req, res) => {
|
|
|
3659
3638
|
return json(res, 200, task);
|
|
3660
3639
|
}
|
|
3661
3640
|
|
|
3662
|
-
//
|
|
3663
|
-
//
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3641
|
+
// Start what the worker said to start, and hand back the URL to look at.
|
|
3642
|
+
//
|
|
3643
|
+
// This is the "動かして見られる状態" the review card offers. It replaces the
|
|
3644
|
+
// old POST /api/tasks/:id/snapshot (backfill a staged screenshot), which is
|
|
3645
|
+
// gone along with the rest of the photo-staging.
|
|
3646
|
+
//
|
|
3647
|
+
// On demand, not always-on: a goal's preview costs a process, and most goals
|
|
3648
|
+
// are never opened. Started here, reaped when idle.
|
|
3649
|
+
const previewMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/preview$/);
|
|
3650
|
+
if (previewMatch && req.method === 'POST') {
|
|
3651
|
+
const task = tasks.find((t) => t.id === Number(previewMatch[1]));
|
|
3667
3652
|
if (!task) return json(res, 404, { error: 'task not found' });
|
|
3668
3653
|
if (!requireTaskGoalOwnership(req, res, task)) return;
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
.then(() => json(res, 200, task))
|
|
3654
|
+
if (!task.run) return json(res, 409, { error: 'no run declaration for this task' });
|
|
3655
|
+
startPreview(task)
|
|
3656
|
+
.then((p) => json(res, 200, p))
|
|
3673
3657
|
.catch((e) => json(res, 500, { error: String(e.message ?? e).slice(0, 200) }));
|
|
3674
3658
|
return;
|
|
3675
3659
|
}
|
|
3676
3660
|
|
|
3661
|
+
// The implementation log for one task, read on demand (see freezeActivityLog above).
|
|
3662
|
+
// A task still running answers from memory — the same lines the SSE stream is feeding
|
|
3663
|
+
// the page — so opening a card mid-run shows the work in progress, not an empty box.
|
|
3664
|
+
const activityMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/activity$/);
|
|
3665
|
+
if (activityMatch && req.method === 'GET') {
|
|
3666
|
+
const task = tasks.find((t) => t.id === Number(activityMatch[1]));
|
|
3667
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
3668
|
+
if (!requireTaskGoalOwnership(req, res, task)) return;
|
|
3669
|
+
return json(res, 200, { lines: task.activity?.length ? task.activity : readActivityLog(task.id) });
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3677
3672
|
// close a stalled task without running it (failure-taxonomy: skipped)
|
|
3678
3673
|
const skipMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/skip$/);
|
|
3679
3674
|
if (skipMatch && req.method === 'POST') {
|