@galda/cli 0.10.69 → 0.10.71
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 +440 -149
- package/app/review-merge-celebration-preview.html +21 -0
- package/engine/chrome-safety.mjs +9 -0
- package/engine/lib.mjs +575 -2
- package/engine/server.mjs +497 -145
- 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, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, 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, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, 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, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
|
|
25
25
|
import { createSerialQueue } from './lib.mjs';
|
|
26
26
|
import { isPrClosed } from './lib.mjs';
|
|
27
27
|
import { collectProjectRules } from './lib.mjs';
|
|
@@ -611,6 +611,71 @@ function uniqueProjectId(name = 'project') {
|
|
|
611
611
|
const logDir = join(DATA_DIR, 'chat-runs');
|
|
612
612
|
mkdirSync(logDir, { recursive: true });
|
|
613
613
|
const logFile = join(logDir, 'tasks.jsonl');
|
|
614
|
+
const internalQualityEventsFile = join(logDir, 'internal-quality-events.jsonl');
|
|
615
|
+
const internalQualityEventKeys = new Set();
|
|
616
|
+
function internalQualityKey(e) {
|
|
617
|
+
const subject = e.goalId != null ? `g${e.goalId}` : e.taskId != null ? `t${e.taskId}` : 'global';
|
|
618
|
+
const status = e.status ? `:${e.status}` : '';
|
|
619
|
+
return `${e.type}:${subject}${status}`;
|
|
620
|
+
}
|
|
621
|
+
function appendInternalQualityEvent(e, { once = true } = {}) {
|
|
622
|
+
const safe = {
|
|
623
|
+
type: String(e.type || ''),
|
|
624
|
+
at: e.at || new Date().toISOString(),
|
|
625
|
+
goalId: e.goalId ?? undefined,
|
|
626
|
+
taskId: e.taskId ?? undefined,
|
|
627
|
+
projectId: e.projectId ?? undefined,
|
|
628
|
+
status: e.status ?? undefined,
|
|
629
|
+
category: e.category ?? undefined,
|
|
630
|
+
agent: e.agent ?? undefined,
|
|
631
|
+
};
|
|
632
|
+
if (!safe.type) return;
|
|
633
|
+
const key = internalQualityKey(safe);
|
|
634
|
+
if (once && internalQualityEventKeys.has(key)) return;
|
|
635
|
+
internalQualityEventKeys.add(key);
|
|
636
|
+
try { appendFileSync(internalQualityEventsFile, JSON.stringify(safe) + '\n'); }
|
|
637
|
+
catch (err) { console.error(`[manager] internal quality event write failed (${err?.code || err})`); }
|
|
638
|
+
}
|
|
639
|
+
function readInternalQualityEvents() {
|
|
640
|
+
try {
|
|
641
|
+
return readFileSync(internalQualityEventsFile, 'utf8')
|
|
642
|
+
.split('\n')
|
|
643
|
+
.filter(Boolean)
|
|
644
|
+
.map((line) => JSON.parse(line));
|
|
645
|
+
} catch {
|
|
646
|
+
return [];
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
for (const e of readInternalQualityEvents()) internalQualityEventKeys.add(internalQualityKey(e));
|
|
650
|
+
function emitGoalQualityEvents(g) {
|
|
651
|
+
if (!g || g.id == null) return;
|
|
652
|
+
appendInternalQualityEvent({ type: 'goal_created', goalId: g.id, projectId: g.projectId, at: g.createdAt });
|
|
653
|
+
if (g.status === 'running' && g.startedAt) appendInternalQualityEvent({ type: 'goal_doing_started', goalId: g.id, projectId: g.projectId, at: g.startedAt });
|
|
654
|
+
if (g.status === 'review') appendInternalQualityEvent({ type: 'goal_review_reached', goalId: g.id, projectId: g.projectId });
|
|
655
|
+
if (['failed', 'blocked', 'interrupted', 'partial'].includes(g.status)) {
|
|
656
|
+
appendInternalQualityEvent({ type: 'goal_failed', goalId: g.id, projectId: g.projectId, status: g.status });
|
|
657
|
+
}
|
|
658
|
+
if (g.status === 'folded') appendInternalQualityEvent({ type: 'goal_folded', goalId: g.id, projectId: g.projectId });
|
|
659
|
+
if (g.status === 'reverted') appendInternalQualityEvent({ type: 'goal_reverted', goalId: g.id, projectId: g.projectId });
|
|
660
|
+
}
|
|
661
|
+
function emitTaskQualityEvents(t) {
|
|
662
|
+
if (!t || t.id == null) return;
|
|
663
|
+
appendInternalQualityEvent({ type: 'task_created', taskId: t.id, goalId: t.goalId, projectId: t.projectId, at: t.createdAt });
|
|
664
|
+
if (t.status === 'running') appendInternalQualityEvent({ type: 'task_doing_started', taskId: t.id, goalId: t.goalId, projectId: t.projectId, at: t.startedAt, agent: t.agent });
|
|
665
|
+
if (t.status === 'skipped') appendInternalQualityEvent({ type: 'task_skipped', taskId: t.id, goalId: t.goalId, projectId: t.projectId, at: t.finishedAt });
|
|
666
|
+
if (['done', 'failed', 'interrupted', 'skipped'].includes(t.status)) {
|
|
667
|
+
appendInternalQualityEvent({
|
|
668
|
+
type: 'task_finished',
|
|
669
|
+
taskId: t.id,
|
|
670
|
+
goalId: t.goalId,
|
|
671
|
+
projectId: t.projectId,
|
|
672
|
+
status: t.status,
|
|
673
|
+
category: classifyInternalQualityTaskError(t),
|
|
674
|
+
agent: t.agent,
|
|
675
|
+
at: t.finishedAt,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
614
679
|
|
|
615
680
|
// Per-project workflow columns (task 44: the Tasks-panel column editor).
|
|
616
681
|
// Column names/order are cosmetic; every column still maps to one of the
|
|
@@ -791,7 +856,11 @@ function logAppend(line) {
|
|
|
791
856
|
try { appendFileSync(logFile, line + '\n'); markSyncDirty(); return true; }
|
|
792
857
|
catch (e) { console.error(`[manager] log write failed (${e?.code || e}) — state not persisted this write, UI still updated`); return false; }
|
|
793
858
|
}
|
|
794
|
-
function saveGoal(g) {
|
|
859
|
+
function saveGoal(g) {
|
|
860
|
+
emitGoalQualityEvents(g);
|
|
861
|
+
logAppend(JSON.stringify({ ...g, kind: 'goal' }));
|
|
862
|
+
sendGoalEvent({ ev: 'goal', goal: g }, g);
|
|
863
|
+
}
|
|
795
864
|
// Persist the orphan-goal reclassification computed right after replayQueueLog
|
|
796
865
|
// above (deferred to here because saveGoal/send/sseClients don't exist yet at
|
|
797
866
|
// that point in the module) — writes it to the restart-safe log so it settles
|
|
@@ -849,6 +918,7 @@ function readGoalMessages(goalId) {
|
|
|
849
918
|
}
|
|
850
919
|
|
|
851
920
|
function saveTask(t) {
|
|
921
|
+
emitTaskQualityEvents(t);
|
|
852
922
|
// freeze BEFORE the destructure strips `activity`, and mark the task so the UI knows a
|
|
853
923
|
// log exists without having to ask for one that was never written.
|
|
854
924
|
if (freezeActivityLog(t)) t.activityLog = true;
|
|
@@ -1106,10 +1176,7 @@ function rebuildQueuesAfterAdopt() {
|
|
|
1106
1176
|
reconcileQueuedGoalStarts(p.id);
|
|
1107
1177
|
queues.get(p.id).waiting.push(...pending);
|
|
1108
1178
|
pump(p.id);
|
|
1109
|
-
|
|
1110
|
-
g.status = 'planning'; saveGoal(g);
|
|
1111
|
-
planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
|
|
1112
|
-
}
|
|
1179
|
+
startNextStacked(p.id);
|
|
1113
1180
|
}
|
|
1114
1181
|
}
|
|
1115
1182
|
|
|
@@ -1249,6 +1316,56 @@ function runWorkerAgent({ agent, ...args }) {
|
|
|
1249
1316
|
return workerAgent(agent) === 'codex' ? runCodex(args) : runClaude(args);
|
|
1250
1317
|
}
|
|
1251
1318
|
|
|
1319
|
+
function collectWorkerSkills(dir) {
|
|
1320
|
+
const home = homedir();
|
|
1321
|
+
return collectSkills([
|
|
1322
|
+
{ dir: join(home, '.claude', 'skills'), source: 'user', kind: 'skill' },
|
|
1323
|
+
{ dir: join(home, '.claude', 'commands'), source: 'user', kind: 'command' },
|
|
1324
|
+
{ dir: join(dir, '.claude', 'skills'), source: 'project', kind: 'skill' },
|
|
1325
|
+
{ dir: join(dir, '.claude', 'commands'), source: 'project', kind: 'command' },
|
|
1326
|
+
]);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function collectWorkerMcpSettings(dir) {
|
|
1330
|
+
const candidates = [
|
|
1331
|
+
'.mcp.json',
|
|
1332
|
+
'.claude/settings.json',
|
|
1333
|
+
'.claude/settings.local.json',
|
|
1334
|
+
'.codex/config.toml',
|
|
1335
|
+
];
|
|
1336
|
+
return candidates
|
|
1337
|
+
.filter((name) => existsSync(join(dir, name)))
|
|
1338
|
+
.map((name) => `${name}: present`);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function workerRunContext({ goal, task, agent, model, effort, permissionMode, cwd, resumeSession, plan }) {
|
|
1342
|
+
const projectRules = collectProjectRules(cwd, (dir, name) => {
|
|
1343
|
+
try { return readFileSync(join(dir, name), 'utf8'); } catch { return null; }
|
|
1344
|
+
}).map((f) => ({ name: f.name }));
|
|
1345
|
+
return {
|
|
1346
|
+
agentName: workerAgent(agent),
|
|
1347
|
+
model,
|
|
1348
|
+
effort,
|
|
1349
|
+
permissionMode,
|
|
1350
|
+
cwd,
|
|
1351
|
+
resumeSession,
|
|
1352
|
+
sessionMode: plan?.sessionMode,
|
|
1353
|
+
projectRules,
|
|
1354
|
+
skills: collectWorkerSkills(cwd),
|
|
1355
|
+
mcpSettings: collectWorkerMcpSettings(cwd),
|
|
1356
|
+
images: goal?.images ?? [],
|
|
1357
|
+
selectedSkill: task?.skill ?? goal?.skill ?? null,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function sendWorkerContextAct(task, context) {
|
|
1362
|
+
const rules = context.projectRules?.map((r) => r.name).join(', ') || 'CLAUDE.md/AGENTS.md not detected';
|
|
1363
|
+
const skills = context.skills?.length ? `${context.skills.length} available` : 'not detected';
|
|
1364
|
+
const mcp = context.mcpSettings?.join(', ') || 'not detected or not introspected';
|
|
1365
|
+
const session = context.resumeSession ? `resume ${context.resumeSession}` : (context.sessionMode || 'fresh');
|
|
1366
|
+
sendProcessAct(task, `Context inherited: agent=${context.agentName}, model=${context.model}, effort=${context.effort}, session=${session}, cwd=${context.cwd}, rules=${rules}, skills=${skills}, MCP/settings=${mcp}, images=${context.images?.length || 0}.`);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1252
1369
|
// Track worker (and planner) children per goal so a goal can be cancelled by
|
|
1253
1370
|
// SIGTERMing exactly its own child(ren) — never another goal's, and never a
|
|
1254
1371
|
// user process. The child auto-unregisters on close.
|
|
@@ -1297,6 +1414,37 @@ function gitSync(cwd, args) {
|
|
|
1297
1414
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || r.stdout || '').trim().slice(0, 200)}`);
|
|
1298
1415
|
return r.stdout.trim();
|
|
1299
1416
|
}
|
|
1417
|
+
function gitSyncOrNull(cwd, args) {
|
|
1418
|
+
try { return gitSync(cwd, args); } catch { return null; }
|
|
1419
|
+
}
|
|
1420
|
+
function gitStatusPaths(lines = []) {
|
|
1421
|
+
return [...new Set((lines ?? []).map((line) => String(line).slice(3).trim()).filter(Boolean))];
|
|
1422
|
+
}
|
|
1423
|
+
function newAttemptId(goal, task) {
|
|
1424
|
+
return `goal-${goal?.id ?? 'none'}-task-${task.id}-${Date.now()}-${randomBytes(3).toString('hex')}`;
|
|
1425
|
+
}
|
|
1426
|
+
function noteGoalTaskBaseline(goal, project, workDir, before) {
|
|
1427
|
+
if (!goal || goal.prBaseline) return;
|
|
1428
|
+
const repoRoot = gitSyncOrNull(workDir, ['rev-parse', '--show-toplevel']);
|
|
1429
|
+
const branch = repoRoot ? gitSyncOrNull(workDir, ['branch', '--show-current']) : null;
|
|
1430
|
+
const head = repoRoot ? gitSyncOrNull(workDir, ['rev-parse', 'HEAD']) : null;
|
|
1431
|
+
let aheadOfDefault = 0;
|
|
1432
|
+
if (repoRoot) {
|
|
1433
|
+
const defaultBranch = repoDefaultBranch(workDir);
|
|
1434
|
+
aheadOfDefault = Number(gitSyncOrNull(workDir, ['rev-list', '--count', `origin/${defaultBranch}..HEAD`])) || 0;
|
|
1435
|
+
}
|
|
1436
|
+
goal.prBaseline = {
|
|
1437
|
+
workDir,
|
|
1438
|
+
projectDir: project?.dir ?? null,
|
|
1439
|
+
sharedCheckout: goal.worktree === false || workDir === project?.dir,
|
|
1440
|
+
dirtyFiles: gitStatusPaths(before),
|
|
1441
|
+
branch,
|
|
1442
|
+
head,
|
|
1443
|
+
aheadOfDefault,
|
|
1444
|
+
capturedAt: new Date().toISOString(),
|
|
1445
|
+
};
|
|
1446
|
+
saveGoal(goal);
|
|
1447
|
+
}
|
|
1300
1448
|
function repoDefaultBranch(dir) {
|
|
1301
1449
|
try { return gitSync(dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).replace(/^origin\//, ''); }
|
|
1302
1450
|
catch { return 'main'; }
|
|
@@ -1338,7 +1486,7 @@ function goalWorkDir(goal, project) {
|
|
|
1338
1486
|
// at, which is what they want when they mean to watch it happen. On (the default)
|
|
1339
1487
|
// keeps today's behaviour: a private copy per goal, so a run can never leave their
|
|
1340
1488
|
// checkout half-edited.
|
|
1341
|
-
if (goal?.worktree ===
|
|
1489
|
+
if (resolveWorkspaceMode(goal?.workspaceMode ?? goal?.worktree) === 'direct-workspace') return project.dir;
|
|
1342
1490
|
const cached = goalWorkDirs.get(goal.id);
|
|
1343
1491
|
if (cached && existsSync(cached)) return cached;
|
|
1344
1492
|
let repoRoot;
|
|
@@ -1358,6 +1506,15 @@ function goalWorkDir(goal, project) {
|
|
|
1358
1506
|
goalWorkDirs.set(goal.id, wt);
|
|
1359
1507
|
return wt;
|
|
1360
1508
|
}
|
|
1509
|
+
function projectDirtyInfo(dir) {
|
|
1510
|
+
try {
|
|
1511
|
+
const raw = gitSync(dir, ['status', '--porcelain']);
|
|
1512
|
+
const paths = raw.split('\n').filter((line) => line.trim()).slice(0, 8);
|
|
1513
|
+
return paths.length ? { dirty: true, dirtyPaths: paths } : { dirty: false };
|
|
1514
|
+
} catch {
|
|
1515
|
+
return {};
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1361
1518
|
function removeGoalWorkDir(goal, project) {
|
|
1362
1519
|
const wt = goalWorkDirs.get(goal.id);
|
|
1363
1520
|
if (!wt) return;
|
|
@@ -1467,21 +1624,62 @@ function isProjectPaused(projectId) {
|
|
|
1467
1624
|
|
|
1468
1625
|
// Goals stack up per project: while one goal is being planned or executed,
|
|
1469
1626
|
// newer goals wait as 'stacked' (editable / deletable until picked up).
|
|
1470
|
-
function
|
|
1471
|
-
|
|
1627
|
+
function projectParallelCap() {
|
|
1628
|
+
const slow = slowModeSettings();
|
|
1629
|
+
return slow.enabled ? Math.min(PROJECT_MAX_PARALLEL, slow.maxParallel) : PROJECT_MAX_PARALLEL;
|
|
1630
|
+
}
|
|
1631
|
+
function planningCount(projectId = null) {
|
|
1632
|
+
return goals.filter((g) => g.status === 'planning' && (projectId == null || g.projectId === projectId)).length;
|
|
1633
|
+
}
|
|
1634
|
+
function activeSlotCount(projectId = null) {
|
|
1635
|
+
if (projectId != null) return (queues.get(projectId)?.running.size ?? 0) + planningCount(projectId);
|
|
1636
|
+
return globalRunningCount() + planningCount();
|
|
1637
|
+
}
|
|
1638
|
+
function stackedStartSlots(projectId) {
|
|
1639
|
+
return Math.min(projectParallelCap() - activeSlotCount(projectId), GLOBAL_MAX_PARALLEL - activeSlotCount());
|
|
1640
|
+
}
|
|
1641
|
+
function startPlanningGoal(goal) {
|
|
1642
|
+
goal.status = 'planning';
|
|
1643
|
+
saveGoal(goal);
|
|
1644
|
+
planGoal(goal).catch((e) => {
|
|
1645
|
+
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
1646
|
+
startNextStacked(goal.projectId);
|
|
1647
|
+
});
|
|
1472
1648
|
}
|
|
1473
1649
|
function startNextStacked(projectId) {
|
|
1474
1650
|
if (isProjectPaused(projectId)) return; // paused → don't begin planning queued goals
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
}
|
|
1651
|
+
let slots = stackedStartSlots(projectId);
|
|
1652
|
+
if (!canStartMore(0, slots)) return;
|
|
1653
|
+
const nextGoals = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
|
|
1654
|
+
.sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id);
|
|
1655
|
+
if (!nextGoals.length) return;
|
|
1656
|
+
for (const next of nextGoals) {
|
|
1657
|
+
if (slots <= 0) break;
|
|
1658
|
+
startPlanningGoal(next);
|
|
1659
|
+
slots--;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Runtime queue invariant: persisted queued tasks are the source of truth. A live
|
|
1664
|
+
// board takeover, an interrupted request handler, or a process race must not leave
|
|
1665
|
+
// one saved as `queued` but absent from the in-memory waiting list. Repair that
|
|
1666
|
+
// drift before pumping, then fill every available planning slot from `stacked`.
|
|
1667
|
+
// Deliberately ignores `pending`: those are Later items, explicitly shelved until
|
|
1668
|
+
// the person taps Start.
|
|
1669
|
+
function reconcileAndPumpProject(projectId) {
|
|
1670
|
+
if (isProjectPaused(projectId)) return;
|
|
1671
|
+
const q = queues.get(projectId);
|
|
1672
|
+
if (!q) return;
|
|
1673
|
+
const known = new Set([...q.running, ...q.waiting].map((task) => task.id));
|
|
1674
|
+
const missing = tasks
|
|
1675
|
+
.filter((task) => task.projectId === projectId && task.status === 'queued' && !known.has(task.id))
|
|
1676
|
+
.sort((a, b) => a.num - b.num);
|
|
1677
|
+
if (missing.length) {
|
|
1678
|
+
q.waiting.push(...missing);
|
|
1679
|
+
console.log(`[manager] ${projectId}: repaired ${missing.length} queued task(s) missing from memory`);
|
|
1680
|
+
}
|
|
1681
|
+
startNextStacked(projectId);
|
|
1682
|
+
pump(projectId);
|
|
1485
1683
|
}
|
|
1486
1684
|
|
|
1487
1685
|
// Worker auth preflight state. The manager runs every worker by spawning a
|
|
@@ -1563,6 +1761,36 @@ async function relocalizeGeneralQuestion(goal, lang, res) {
|
|
|
1563
1761
|
|
|
1564
1762
|
async function planGoal(goal) {
|
|
1565
1763
|
const project = projects.find((p) => p.id === goal.projectId);
|
|
1764
|
+
const enqueueSingleTask = (entry = null) => {
|
|
1765
|
+
goal.question = null;
|
|
1766
|
+
goal.status = 'running';
|
|
1767
|
+
goal.entry = entry;
|
|
1768
|
+
const title = goalTaskTitle(goal.text);
|
|
1769
|
+
goal.plan = [title];
|
|
1770
|
+
saveGoal(goal);
|
|
1771
|
+
const base = tasks.filter((t) => t.projectId === goal.projectId).map((t) => t.num);
|
|
1772
|
+
const num = (base.length ? Math.max(...base) : 0) + 1;
|
|
1773
|
+
const task = buildGoalTask(goal, {
|
|
1774
|
+
id: nextId++, num,
|
|
1775
|
+
createdAt: new Date().toISOString(),
|
|
1776
|
+
});
|
|
1777
|
+
tasks.push(task);
|
|
1778
|
+
saveTask(task);
|
|
1779
|
+
queues.get(goal.projectId).waiting.push(task);
|
|
1780
|
+
pump(goal.projectId);
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1783
|
+
// Stability roadmap 03/08: normal implementation requests bypass the intake
|
|
1784
|
+
// planner entirely. The user's original text, attachments, selected
|
|
1785
|
+
// Agent/Model/effort, and project settings already live on the goal and flow
|
|
1786
|
+
// straight into buildGoalTask()/runTask(). Only explicit Plan mode keeps the
|
|
1787
|
+
// planner hop for clarification/entry resolution before the worker receives
|
|
1788
|
+
// the same verbatim request.
|
|
1789
|
+
if (!shouldUsePlannerForGoal(goal)) {
|
|
1790
|
+
enqueueSingleTask(null);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1566
1794
|
const prompt = [
|
|
1567
1795
|
'あなたはユーザーの依頼を受けるプロダクトマネージャ。以下のユーザー依頼を、ユーザーから見た「成果物(トピック)」の単位で整理する。エンジニアリングの工程には分解しない。',
|
|
1568
1796
|
'【言語】ユーザーが読む文字列(各タスクの title、確認の question / options)は、末尾の「依頼:」と同じ言語で書く。英語の依頼には英語で、日本語の依頼には日本語で返す(例: "test" → 英語、"テスト" → 日本語)。detail(worker への指示)は言語自由。',
|
|
@@ -1605,12 +1833,12 @@ async function planGoal(goal) {
|
|
|
1605
1833
|
// SLOWER — the cost is claude cold-start + repo reading, not inference, and
|
|
1606
1834
|
// haiku took more tool round-trips. The instant Planning… row is the real
|
|
1607
1835
|
// "it's queued" feedback; detailed tasks follow in ~13s.)
|
|
1608
|
-
const { result, sessionId } = await runWorkerAgent({ agent: goal.agent, prompt, cwd: workDir, tools: 'Read,Glob,Grep', model: goal.model, effort: goal.effort, onChild: (c) => trackGoalWorker(goal.id, c) });
|
|
1836
|
+
const { result, sessionId } = await runWorkerAgent({ agent: goal.agent, prompt, cwd: workDir, tools: 'Read,Glob,Grep', permissionMode: 'plan', model: goal.model, effort: goal.effort, onChild: (c) => trackGoalWorker(goal.id, c) });
|
|
1609
1837
|
if (goal.cancelled || !goals.some((g) => g.id === goal.id)) return; // deleted/cancelled while planning
|
|
1610
1838
|
// Warm handoff: the planner already read the repo in this session, so the
|
|
1611
1839
|
// goal's workers resume it instead of cold-starting a fresh claude each
|
|
1612
1840
|
// task (faster first token, no re-briefing tokens).
|
|
1613
|
-
if (sessionId) goal.
|
|
1841
|
+
if (sessionId) setGoalAgentSession(goal, goal.agent, sessionId);
|
|
1614
1842
|
// Smart intake: the planner may return a clarifying question instead of tasks
|
|
1615
1843
|
// when the goal is genuinely ambiguous. Surface it as a "needsInput" goal (a
|
|
1616
1844
|
// ❓ card in To Do) and wait for the answer — no tasks created yet, no worker.
|
|
@@ -1634,7 +1862,6 @@ async function planGoal(goal) {
|
|
|
1634
1862
|
// decomposition the LLM may still emit into user-facing deliverables (Masa:
|
|
1635
1863
|
// プログラムエンジニアのタスク単位で分解しないで — 1メッセージ=1トピック既定).
|
|
1636
1864
|
plan.tasks = refinePlanTasks(plan.tasks, { goalText: goal.text });
|
|
1637
|
-
goal.status = 'running';
|
|
1638
1865
|
goal.entry = plan.entry;
|
|
1639
1866
|
// Demoted planner (Masa 2026-07-21, P0b): the planner LLM still resolves the
|
|
1640
1867
|
// entry URL, asks for clarification, and warms the session — but it no longer
|
|
@@ -1646,19 +1873,7 @@ async function planGoal(goal) {
|
|
|
1646
1873
|
// what "correct" means for it, and it did so at random (see the prompt note
|
|
1647
1874
|
// above). Nothing here sets one, so `entry` now only survives as the page a
|
|
1648
1875
|
// future explicit, user-written condition would open.
|
|
1649
|
-
|
|
1650
|
-
goal.plan = [title];
|
|
1651
|
-
saveGoal(goal);
|
|
1652
|
-
const base = tasks.filter((t) => t.projectId === goal.projectId).map((t) => t.num);
|
|
1653
|
-
const num = (base.length ? Math.max(...base) : 0) + 1;
|
|
1654
|
-
const task = buildGoalTask(goal, {
|
|
1655
|
-
id: nextId++, num,
|
|
1656
|
-
createdAt: new Date().toISOString(),
|
|
1657
|
-
});
|
|
1658
|
-
tasks.push(task);
|
|
1659
|
-
saveTask(task);
|
|
1660
|
-
queues.get(goal.projectId).waiting.push(task);
|
|
1661
|
-
pump(goal.projectId);
|
|
1876
|
+
enqueueSingleTask(plan.entry);
|
|
1662
1877
|
}
|
|
1663
1878
|
|
|
1664
1879
|
// Author the executable check once per task (the only LLM cost of
|
|
@@ -1712,8 +1927,8 @@ function pump(projectId) {
|
|
|
1712
1927
|
// queued tasks are actually safe to start within that many slots: at most
|
|
1713
1928
|
// one per goalId not already running, so a goal's own next task never
|
|
1714
1929
|
// jumps ahead of its still-running sibling.
|
|
1715
|
-
const projectCap =
|
|
1716
|
-
const slots = Math.min(projectCap -
|
|
1930
|
+
const projectCap = projectParallelCap();
|
|
1931
|
+
const slots = Math.min(projectCap - activeSlotCount(projectId), GLOBAL_MAX_PARALLEL - activeSlotCount());
|
|
1717
1932
|
if (!canStartMore(0, slots)) return;
|
|
1718
1933
|
if (slow.enabled) {
|
|
1719
1934
|
const last = slowModeLastStart.get(projectId) ?? 0;
|
|
@@ -1789,24 +2004,6 @@ function managerHandoffPrompt(goal, plan) {
|
|
|
1789
2004
|
].filter(Boolean).join('\n\n');
|
|
1790
2005
|
}
|
|
1791
2006
|
|
|
1792
|
-
function recentProjectFailureMemory(projectId, text) {
|
|
1793
|
-
const haystack = String(text ?? '').toLowerCase();
|
|
1794
|
-
return goals
|
|
1795
|
-
.filter((g) => g.projectId === projectId && Array.isArray(g.failureMemory) && g.failureMemory.length)
|
|
1796
|
-
.flatMap((g) => g.failureMemory.map((entry) => ({ goal: g, entry })))
|
|
1797
|
-
.filter(({ entry }) => {
|
|
1798
|
-
if (!haystack) return true;
|
|
1799
|
-
const blob = `${entry?.recurrenceKey ?? ''} ${entry?.rootCause ?? ''} ${entry?.whatHappened ?? ''}`.toLowerCase();
|
|
1800
|
-
return blob.includes('test') && /test|テスト|検証/.test(haystack)
|
|
1801
|
-
|| blob.includes('budget') && /token|budget|予算|コンテキスト/.test(haystack)
|
|
1802
|
-
|| blob.includes('proof') && /ui|proof|スクショ|画面/.test(haystack)
|
|
1803
|
-
|| blob.includes('non-code') && /docs?|prd|調査|レポート|document/.test(haystack);
|
|
1804
|
-
})
|
|
1805
|
-
.sort((a, b) => String(b.entry.createdAt ?? '').localeCompare(String(a.entry.createdAt ?? '')))
|
|
1806
|
-
.slice(0, 3)
|
|
1807
|
-
.map(({ entry }) => entry);
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
2007
|
function recordGoalFailure(goal, { kind, reason = '', usage = null, changedFiles = [], testResult = null, attentionHadDetail = true } = {}) {
|
|
1811
2008
|
if (!goal) return null;
|
|
1812
2009
|
const siblings = tasks.filter((t) => t.goalId === goal.id);
|
|
@@ -1846,6 +2043,7 @@ function queueBudgetContinuation(goal, task, budget) {
|
|
|
1846
2043
|
goal.contextHandoff = buildContextHandoffSummary({ goal, tasks: tasks.filter((t) => t.goalId === goal.id) });
|
|
1847
2044
|
goal.contextHandoffAt = now;
|
|
1848
2045
|
goal.sessionId = null;
|
|
2046
|
+
goal.sessions = {};
|
|
1849
2047
|
goal.runElapsedMs = 0;
|
|
1850
2048
|
goal.runTokens = 0;
|
|
1851
2049
|
goal.runAttempts = 0;
|
|
@@ -1975,17 +2173,17 @@ function resumeProject(projectId) {
|
|
|
1975
2173
|
}
|
|
1976
2174
|
|
|
1977
2175
|
// Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
|
|
1978
|
-
// runTask()のtask.reply
|
|
2176
|
+
// runTask()のtask.reply分岐が各agentのsessionをresumeに渡して同じ会話を続ける。
|
|
1979
2177
|
function applyReplyWorker(goal, requested = {}) {
|
|
1980
2178
|
if (requested.agent == null) return { ok: true };
|
|
1981
2179
|
if (!WORKER_AGENTS.includes(requested.agent)) return { ok: false, error: 'unsupported agent' };
|
|
1982
2180
|
const agent = workerAgent(requested.agent);
|
|
1983
2181
|
if (!AVAILABLE_AGENTS.includes(agent)) return { ok: false, error: `${agent} is not connected on this device` };
|
|
1984
2182
|
const switchedAgent = agent !== goal.agent;
|
|
1985
|
-
goal
|
|
2183
|
+
if (switchedAgent) switchGoalAgentSession(goal, agent);
|
|
2184
|
+
else goal.agent = agent;
|
|
1986
2185
|
goal.model = workerModel(agent, requested.model);
|
|
1987
2186
|
goal.effort = workerEffort(agent, requested.effort);
|
|
1988
|
-
if (switchedAgent) goal.sessionId = null;
|
|
1989
2187
|
return { ok: true };
|
|
1990
2188
|
}
|
|
1991
2189
|
|
|
@@ -2002,7 +2200,7 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
|
|
|
2002
2200
|
usage: goal.runUsage,
|
|
2003
2201
|
runTokens: goal.runTokens,
|
|
2004
2202
|
runAttempts: goal.runAttempts,
|
|
2005
|
-
previousSession: Boolean(goal.
|
|
2203
|
+
previousSession: Boolean(goalSessionFor(goal, goal.agent) || goal.contextHandoff),
|
|
2006
2204
|
});
|
|
2007
2205
|
if (shouldFreshSessionForPlan(goal.executionPlan)) refreshGoalHandoff(goal);
|
|
2008
2206
|
saveGoal(goal);
|
|
@@ -2011,6 +2209,8 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
|
|
|
2011
2209
|
id: nextId++, num: (nums.length ? Math.max(...nums) : 0) + 1,
|
|
2012
2210
|
goalId: goal.id, projectId: goal.projectId, reply: true, model: goal.model, effort: goal.effort, agent: goal.agent,
|
|
2013
2211
|
title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000),
|
|
2212
|
+
workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
|
|
2213
|
+
worktree: goal.worktree === false ? false : undefined,
|
|
2014
2214
|
passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: '中',
|
|
2015
2215
|
result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
|
|
2016
2216
|
};
|
|
@@ -2029,8 +2229,8 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
|
|
|
2029
2229
|
// follow-up reply to an already-running goal must stay DOING (its clock stands).
|
|
2030
2230
|
if (goal.status === 'running' && ![...q.running].some((t) => t.goalId === goal.id)) {
|
|
2031
2231
|
const slow = slowModeSettings();
|
|
2032
|
-
const projectCap =
|
|
2033
|
-
const slots = Math.min(projectCap -
|
|
2232
|
+
const projectCap = projectParallelCap();
|
|
2233
|
+
const slots = Math.min(projectCap - activeSlotCount(goal.projectId), GLOBAL_MAX_PARALLEL - activeSlotCount());
|
|
2034
2234
|
const runningGoalIds = [...q.running].map((t) => t.goalId);
|
|
2035
2235
|
const eligible = q.waiting.filter((candidate) => workerCanStart({ agent: candidate.agent, auth: workerAuth }));
|
|
2036
2236
|
const startsNow = workerCanStart({ agent: task.agent, auth: workerAuth })
|
|
@@ -2217,6 +2417,39 @@ function readRunDeclaration(task, workDir) {
|
|
|
2217
2417
|
if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
|
|
2218
2418
|
}
|
|
2219
2419
|
|
|
2420
|
+
// A direct/shared workspace can still contain a declaration left by an older
|
|
2421
|
+
// run (for example when that run was interrupted before readRunDeclaration).
|
|
2422
|
+
// Remove it before this task starts so only a file created by THIS worker can
|
|
2423
|
+
// name the review card or supply its Open it target.
|
|
2424
|
+
function clearStaleRunDeclaration(task, workDir) {
|
|
2425
|
+
const file = join(workDir, RUN_DECL_FILE);
|
|
2426
|
+
if (!existsSync(file)) return;
|
|
2427
|
+
try {
|
|
2428
|
+
unlinkSync(file);
|
|
2429
|
+
sendProcessAct(task, `Removed a stale ${RUN_DECL_FILE} before starting this task.`);
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
sendProcessAct(task, `Could not remove stale ${RUN_DECL_FILE}: ${String(err?.code ?? err).slice(0, 80)}`);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function inheritPreviousRunDeclaration(task, goalId) {
|
|
2436
|
+
if (task.run || !goalId) return;
|
|
2437
|
+
const prev = [...tasks].reverse().find((t) => t.goalId === goalId && t.id !== task.id && t.run && (t.run.cmd || t.run.url));
|
|
2438
|
+
if (!prev) return;
|
|
2439
|
+
const note = [
|
|
2440
|
+
'Reusing the previous preview because this follow-up did not declare a new preview.',
|
|
2441
|
+
prev.run.note || prev.run.cmd || prev.run.url || '',
|
|
2442
|
+
].filter(Boolean).join(' ');
|
|
2443
|
+
task.run = {
|
|
2444
|
+
...prev.run,
|
|
2445
|
+
note: note.slice(0, 300),
|
|
2446
|
+
inherited: true,
|
|
2447
|
+
inheritedFromTaskId: prev.id,
|
|
2448
|
+
inheritedFromAttemptId: prev.attemptId ?? null,
|
|
2449
|
+
};
|
|
2450
|
+
sendProcessAct(task, `No new run declaration; explicitly reusing preview from task ${prev.id}.`);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2220
2453
|
// A declared pair only survives if both pictures actually arrived. The card
|
|
2221
2454
|
// draws a red box over whatever differs between the two, so a name matching no
|
|
2222
2455
|
// collected shot would leave the reviewer half a comparison — show the plain
|
|
@@ -2302,10 +2535,11 @@ async function startPreview(task) {
|
|
|
2302
2535
|
if (managerPackage && existsSync(join(dir, 'engine', 'server.mjs')) && existsSync(join(dir, 'app', 'index.html'))) {
|
|
2303
2536
|
const eph = await startEphemeralApp(dir);
|
|
2304
2537
|
if (!eph) throw new Error('Could not start the local preview. Please try again.');
|
|
2305
|
-
const
|
|
2538
|
+
const previewUrl = rebasePreviewUrl(url, eph.url);
|
|
2539
|
+
const entry = { proc: eph.child, url: previewUrl, expires: Date.now() + PREVIEW_IDLE_MS };
|
|
2306
2540
|
previews.set(task.id, entry);
|
|
2307
2541
|
eph.child.on('close', () => { if (previews.get(task.id) === entry) previews.delete(task.id); });
|
|
2308
|
-
return { url:
|
|
2542
|
+
return { url: previewUrl, reused: false, started: true };
|
|
2309
2543
|
}
|
|
2310
2544
|
// Declared a URL but no command: the thing is already reachable (an app the
|
|
2311
2545
|
// worker left running, a hosted page). Nothing to start.
|
|
@@ -2343,12 +2577,22 @@ async function runTask(task) {
|
|
|
2343
2577
|
saveTask(task);
|
|
2344
2578
|
sendProcessAct(task, `Working on "${task.title}"${task.reply ? ' as a follow-up' : ''}.`);
|
|
2345
2579
|
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2580
|
+
// The workspace mode is chosen at goal creation and then held through every
|
|
2581
|
+
// worker/review/PR step.
|
|
2348
2582
|
const workDir = goal ? goalWorkDir(goal, project) : project.dir;
|
|
2349
2583
|
const wproject = { ...project, dir: workDir };
|
|
2350
|
-
|
|
2584
|
+
const workspaceMode = resolveWorkspaceMode(task.workspaceMode ?? goal?.workspaceMode ?? goal?.worktree);
|
|
2585
|
+
sendProcessAct(task, workspaceMode === 'direct-workspace'
|
|
2586
|
+
? `Using direct workspace: ${workDir}`
|
|
2587
|
+
: `Prepared isolated worktree: ${workDir}`);
|
|
2588
|
+
if (goal?.workspaceWarning) sendProcessAct(task, goal.workspaceWarning);
|
|
2589
|
+
clearStaleRunDeclaration(task, workDir);
|
|
2351
2590
|
const before = await gitChanges(workDir);
|
|
2591
|
+
task.attemptId = task.attemptId || newAttemptId(goal, task);
|
|
2592
|
+
if (goal) {
|
|
2593
|
+
goal.latestAttemptId = task.attemptId;
|
|
2594
|
+
noteGoalTaskBaseline(goal, project, workDir, before);
|
|
2595
|
+
}
|
|
2352
2596
|
sendProcessAct(task, `Checked starting git state (${before.length} changed file${before.length === 1 ? '' : 's'} before this task).`);
|
|
2353
2597
|
// Plan mode makes no edits, so there is nothing to verify — never author a
|
|
2354
2598
|
// check or boot an ephemeral instance for it.
|
|
@@ -2362,25 +2606,31 @@ async function runTask(task) {
|
|
|
2362
2606
|
// same conversation, no re-briefing (and no wasted tokens).
|
|
2363
2607
|
if (task.reply) {
|
|
2364
2608
|
const plan = goal?.executionPlan;
|
|
2365
|
-
const
|
|
2609
|
+
const runAgent = workerAgent(task.agent ?? goal?.agent);
|
|
2610
|
+
const runModel = task.model ?? goal?.model;
|
|
2611
|
+
const runEffort = task.effort ?? goal?.effort;
|
|
2612
|
+
const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goalSessionFor(goal, runAgent) ?? undefined);
|
|
2613
|
+
const replyPermissionMode = permissionModeFor(goal?.mode === 'plan' && goal?.planPhase !== 'executing' ? 'plan' : undefined);
|
|
2614
|
+
const runContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode: replyPermissionMode, cwd: workDir, resumeSession, plan });
|
|
2366
2615
|
// A follow-up is a full worker run, so it needs the same deliverable contract as
|
|
2367
2616
|
// the first attempt (artifacts, run declaration, tests, language). The old short
|
|
2368
2617
|
// prompt omitted those rules, which let a reply finish with prose only and left
|
|
2369
2618
|
// Review showing the original task's stale "Skipped." result.
|
|
2370
2619
|
const prompt = [
|
|
2371
2620
|
managerHandoffPrompt(goal, plan),
|
|
2372
|
-
workerPrompt(task, goal, null,
|
|
2621
|
+
workerPrompt(task, goal, null, runAgent, runContext),
|
|
2373
2622
|
'(User follow-up on the goal you worked on. Apply it to the same goal and deliver the requested result.)',
|
|
2374
2623
|
].filter(Boolean).join('\n\n');
|
|
2375
2624
|
const callT0 = Date.now();
|
|
2376
|
-
|
|
2625
|
+
sendWorkerContextAct(task, runContext);
|
|
2626
|
+
sendProcessAct(task, `Starting ${runAgent === 'codex' ? 'Codex' : 'Claude Code'} worker with the existing goal context.`);
|
|
2377
2627
|
const r = await runWorkerAgent({
|
|
2378
|
-
agent:
|
|
2628
|
+
agent: runAgent,
|
|
2379
2629
|
prompt, cwd: workDir, tools: WORKER_TOOLS, resume: resumeSession,
|
|
2380
2630
|
// A reply that revises a plan-review must stay plan mode (no edits); a
|
|
2381
2631
|
// reply/rework on a normal goal stays acceptEdits as before.
|
|
2382
|
-
permissionMode:
|
|
2383
|
-
model:
|
|
2632
|
+
permissionMode: replyPermissionMode,
|
|
2633
|
+
model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
|
|
2384
2634
|
images: goal?.images,
|
|
2385
2635
|
onTodos: (todos) => sendTodos(task, todos),
|
|
2386
2636
|
onChild: (c) => trackGoalWorker(goal?.id, c),
|
|
@@ -2397,11 +2647,19 @@ async function runTask(task) {
|
|
|
2397
2647
|
task.status = r.code === 0 ? 'done' : 'failed';
|
|
2398
2648
|
task.result = r.result.slice(0, RESULT_MAX);
|
|
2399
2649
|
task.usage = sumUsage([r.usage]);
|
|
2400
|
-
if (r.sessionId && goal) { goal
|
|
2650
|
+
if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
|
|
2651
|
+
// A follow-up owns its own evidence too. Previously only the initial task
|
|
2652
|
+
// consumed .manager-run.json, so replies either left the new declaration
|
|
2653
|
+
// behind or Review kept showing an older task's title/URL.
|
|
2654
|
+
readRunDeclaration(task, workDir);
|
|
2655
|
+
collectWorkerShots(task, workDir);
|
|
2656
|
+
reconcileComparePair(task);
|
|
2657
|
+
inheritPreviousRunDeclaration(task, goal?.id);
|
|
2401
2658
|
const afterR = await gitChanges(workDir);
|
|
2402
2659
|
task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
2403
2660
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
|
|
2404
2661
|
task.finishedAt = new Date().toISOString();
|
|
2662
|
+
task.resultAttemptId = task.attemptId;
|
|
2405
2663
|
saveTask(task);
|
|
2406
2664
|
// A rework (comment/dismiss) must re-enter the verify gate — re-test,
|
|
2407
2665
|
// re-capture proof, update the SAME PR (openPR existingBranch) — and land
|
|
@@ -2429,14 +2687,19 @@ async function runTask(task) {
|
|
|
2429
2687
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2430
2688
|
let callT0 = Date.now();
|
|
2431
2689
|
const plan = goal?.executionPlan;
|
|
2432
|
-
const
|
|
2433
|
-
const
|
|
2434
|
-
|
|
2690
|
+
const runAgent = workerAgent(task.agent ?? goal?.agent);
|
|
2691
|
+
const runModel = task.model ?? goal?.model;
|
|
2692
|
+
const runEffort = task.effort ?? goal?.effort;
|
|
2693
|
+
const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goalSessionFor(goal, runAgent) ?? undefined);
|
|
2694
|
+
const runContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode, cwd: workDir, resumeSession, plan });
|
|
2695
|
+
const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, null, runAgent, runContext)].filter(Boolean).join('\n\n');
|
|
2696
|
+
sendWorkerContextAct(task, runContext);
|
|
2697
|
+
sendProcessAct(task, `Starting ${runAgent === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
|
|
2435
2698
|
let r = await runWorkerAgent({
|
|
2436
|
-
agent:
|
|
2699
|
+
agent: runAgent,
|
|
2437
2700
|
prompt, cwd: workDir, tools: WORKER_TOOLS,
|
|
2438
2701
|
resume: resumeSession, permissionMode,
|
|
2439
|
-
model:
|
|
2702
|
+
model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
|
|
2440
2703
|
images: goal?.images,
|
|
2441
2704
|
onTodos: (todos) => sendTodos(task, todos),
|
|
2442
2705
|
onChild: (c) => trackGoalWorker(goal?.id, c),
|
|
@@ -2451,18 +2714,21 @@ async function runTask(task) {
|
|
|
2451
2714
|
// "succeeded".
|
|
2452
2715
|
let budget = trackGoalRunUsage(goal, Date.now() - callT0, r.usage);
|
|
2453
2716
|
if (budget?.exceeded) { blockForBudget(goal, task, budget); return; }
|
|
2454
|
-
if (r.code !== 0 && goal
|
|
2717
|
+
if (r.code !== 0 && goalSessionFor(goal, runAgent)) {
|
|
2455
2718
|
// The warm session can expire or be cleaned up between tasks; one cold
|
|
2456
2719
|
// retry distinguishes "resume broke" from "the task itself failed".
|
|
2457
2720
|
sendAct(task, 'warm session unavailable — retrying with a fresh session…');
|
|
2458
|
-
goal.
|
|
2721
|
+
if (goal?.sessions && typeof goal.sessions === 'object') delete goal.sessions[runAgent];
|
|
2722
|
+
if (goal) { goal.sessionId = null; goal.sessionAgent = runAgent; saveGoal(goal); }
|
|
2459
2723
|
callT0 = Date.now();
|
|
2460
2724
|
sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
|
|
2725
|
+
const freshContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode, cwd: workDir, resumeSession: undefined, plan: { ...plan, sessionMode: 'cold' } });
|
|
2726
|
+
sendWorkerContextAct(task, freshContext);
|
|
2461
2727
|
r = await runWorkerAgent({
|
|
2462
|
-
agent:
|
|
2463
|
-
prompt: workerPrompt(task, goal, null,
|
|
2728
|
+
agent: runAgent,
|
|
2729
|
+
prompt: workerPrompt(task, goal, null, runAgent, freshContext), cwd: workDir, tools: WORKER_TOOLS,
|
|
2464
2730
|
permissionMode,
|
|
2465
|
-
model:
|
|
2731
|
+
model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
|
|
2466
2732
|
images: goal?.images,
|
|
2467
2733
|
onTodos: (todos) => sendTodos(task, todos),
|
|
2468
2734
|
onChild: (c) => trackGoalWorker(goal?.id, c),
|
|
@@ -2478,7 +2744,7 @@ async function runTask(task) {
|
|
|
2478
2744
|
sendProcessAct(task, timedOut
|
|
2479
2745
|
? `Worker attempt ${attempt} was stopped at the ${Math.round(WORKER_TIMEOUT_MS / 60000)}-minute run limit (exit ${code}).`
|
|
2480
2746
|
: `Worker attempt ${attempt} finished with exit code ${code}.`);
|
|
2481
|
-
if (r.sessionId && goal) { goal
|
|
2747
|
+
if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
|
|
2482
2748
|
if (!entryUrl) break;
|
|
2483
2749
|
try {
|
|
2484
2750
|
if (!verify) {
|
|
@@ -2589,6 +2855,7 @@ async function runTask(task) {
|
|
|
2589
2855
|
task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
2590
2856
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
|
|
2591
2857
|
task.finishedAt = new Date().toISOString();
|
|
2858
|
+
task.resultAttemptId = task.attemptId;
|
|
2592
2859
|
saveTask(task);
|
|
2593
2860
|
sendProcessAct(task, `Task saved as ${task.status}.`);
|
|
2594
2861
|
|
|
@@ -2919,6 +3186,23 @@ function goalChangedFileUnion(goalId) {
|
|
|
2919
3186
|
return [...new Set(tasks.filter((t) => t.goalId === goalId).flatMap((t) => t.changedFiles ?? []))]
|
|
2920
3187
|
.filter((f) => f && !SCRATCH_FILE_RE.test(f));
|
|
2921
3188
|
}
|
|
3189
|
+
function computeWorktreeChangeLocations(dir) {
|
|
3190
|
+
try {
|
|
3191
|
+
return parseGitUnifiedDiffLocations(gitOut(dir, ['diff', '--unified=0']) ?? '');
|
|
3192
|
+
} catch {
|
|
3193
|
+
return [];
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
function foldCandidateShape(g) {
|
|
3197
|
+
return {
|
|
3198
|
+
id: g.id,
|
|
3199
|
+
changedFiles: goalChangedFileUnion(g.id),
|
|
3200
|
+
changeLocations: g.changeLocations ?? [],
|
|
3201
|
+
text: g.text,
|
|
3202
|
+
title: g.reviewSummary?.title ?? g.plan?.[0],
|
|
3203
|
+
summary: g.reviewSummary?.changed ?? '',
|
|
3204
|
+
};
|
|
3205
|
+
}
|
|
2922
3206
|
const CONFLICT_CANDIDATE_STATUSES = ['running', 'review', 'blocked', 'partial', 'retesting'];
|
|
2923
3207
|
function updateGoalConflicts(goal) {
|
|
2924
3208
|
const files = goalChangedFileUnion(goal.id);
|
|
@@ -2949,6 +3233,10 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
2949
3233
|
if (!siblings.length || !siblings.every((t) => ['done', 'failed', 'interrupted', 'skipped'].includes(t.status))) return;
|
|
2950
3234
|
if (!['running', 'partial'].includes(goal.status)) { startNextStacked(goal.projectId); return; }
|
|
2951
3235
|
goalAct(`Close-out started for goal-${goal.id}: ${siblings.length} task${siblings.length === 1 ? '' : 's'} ready for final checks.`);
|
|
3236
|
+
if (activityTask?.attemptId) {
|
|
3237
|
+
goal.closeoutAttemptId = activityTask.attemptId;
|
|
3238
|
+
goal.latestAttemptId = activityTask.attemptId;
|
|
3239
|
+
}
|
|
2952
3240
|
// Plan mode: the worker produced a PLAN and made no edits. Land it in Review
|
|
2953
3241
|
// as a plan-review (no PR, no proof gate — nothing changed) with the plan
|
|
2954
3242
|
// body captured, so the reviewer sees "PLAN — approve to execute". Approving
|
|
@@ -2974,11 +3262,10 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
2974
3262
|
// (e.g. the skip endpoint) still PR from the isolated worktree, not the shared dir.
|
|
2975
3263
|
const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
|
|
2976
3264
|
const prProject = { ...project, dir: wdir };
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
2980
|
-
// (
|
|
2981
|
-
// it (not just `if (!goal.pr)`): when a goal already has a PR — e.g. a review
|
|
3265
|
+
// Close-out always delegates to createGoalPR so it can record an explicit
|
|
3266
|
+
// No PR decision as well as create/update a requested PR. `goal.wantsPR` is
|
|
3267
|
+
// authoritative: changed files alone must never override the user's choice.
|
|
3268
|
+
// Always call it (not just `if (!goal.pr)`): when a goal already has a PR — e.g. a review
|
|
2982
3269
|
// comment sent it back for rework and the reply task just finished — this is
|
|
2983
3270
|
// the only place that pushes the rework diff, and createGoalPR itself decides
|
|
2984
3271
|
// "new PR" vs "push onto the existing PR's branch" from goal.pr/goal.prBranch.
|
|
@@ -3015,15 +3302,16 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3015
3302
|
// (that would be an instant loop, since the target still sits in review with
|
|
3016
3303
|
// the overlapping files). The card then opens its own PR and both stay in
|
|
3017
3304
|
// Review, flagged via conflictsWith — the visible collision Masa asked for.
|
|
3018
|
-
|
|
3305
|
+
goal.changeLocations = computeWorktreeChangeLocations(wdir);
|
|
3306
|
+
const foldTarget = (autoFoldReviewsEnabled(project) && conflicts.length && !goal.noFold)
|
|
3019
3307
|
? goals.find((g) => g.id === pickFoldTarget(
|
|
3020
|
-
|
|
3308
|
+
foldCandidateShape(goal),
|
|
3021
3309
|
goals
|
|
3022
3310
|
.filter((c) => c.id !== goal.id
|
|
3023
3311
|
&& c.projectId === goal.projectId
|
|
3024
3312
|
&& c.status === 'review'
|
|
3025
3313
|
&& (c.owner ?? 'owner') === (goal.owner ?? 'owner'))
|
|
3026
|
-
.map(
|
|
3314
|
+
.map(foldCandidateShape),
|
|
3027
3315
|
))
|
|
3028
3316
|
: null;
|
|
3029
3317
|
if (foldTarget) {
|
|
@@ -3051,6 +3339,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3051
3339
|
// time the PR is opened, not only on the review card (Masa 2026-07-16).
|
|
3052
3340
|
goalAct('Writing the review summary from the worker report and diff.');
|
|
3053
3341
|
goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
|
|
3342
|
+
if (activityTask?.attemptId) goal.reviewSummaryAttemptId = activityTask.attemptId;
|
|
3054
3343
|
goalAct('Checking changed files and preparing the PR branch if code changed.');
|
|
3055
3344
|
await createGoalPR(goal, prProject, siblings, reviewDefinition, activityTask);
|
|
3056
3345
|
// goal 427 §4: classify the change's risk from the SAME changed-files/diff
|
|
@@ -3059,6 +3348,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3059
3348
|
// string/regex matching (classifyChangeRisk in lib.mjs).
|
|
3060
3349
|
goal.risk = computeGoalRisk(goal, prProject, siblings);
|
|
3061
3350
|
goal.summary = buildGoalSummary({ goalText: goal.text, tasks: siblings });
|
|
3351
|
+
if (activityTask?.attemptId) goal.summaryAttemptId = activityTask.attemptId;
|
|
3062
3352
|
await verifyGate(goal, prProject, siblings, project, activityTask);
|
|
3063
3353
|
// A high-risk change (secrets/auth/payment/db-migration/destructive) must
|
|
3064
3354
|
// never slip past a human — verifyGate's nextGoalStatus never returns
|
|
@@ -3070,8 +3360,10 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3070
3360
|
if (goal.risk?.shouldPause && !['blocked', 'running'].includes(goal.status)) goal.status = 'review';
|
|
3071
3361
|
if (['review', 'done', 'blocked', 'archived'].includes(goal.status)) {
|
|
3072
3362
|
refreshGoalHandoff(goal);
|
|
3073
|
-
if (goal.
|
|
3363
|
+
if (goalSessionFor(goal, goal.agent) && shouldFreshSessionForPlan(goal.executionPlan)) {
|
|
3364
|
+
if (goal.sessions && typeof goal.sessions === 'object') delete goal.sessions[goal.agent];
|
|
3074
3365
|
goal.sessionId = null;
|
|
3366
|
+
goal.sessionAgent = goal.agent;
|
|
3075
3367
|
goal.sessionClosedAt = new Date().toISOString();
|
|
3076
3368
|
goal.executionPlan = {
|
|
3077
3369
|
...(goal.executionPlan ?? {}),
|
|
@@ -3098,6 +3390,13 @@ function computeGoalRisk(goal, project, siblings) {
|
|
|
3098
3390
|
return { level: r.level, shouldPause: r.shouldPause, categories: r.categories, sensitiveFiles: r.sensitiveFiles };
|
|
3099
3391
|
}
|
|
3100
3392
|
|
|
3393
|
+
function prSafetyIssue(goal, project, files) {
|
|
3394
|
+
const baseline = goal?.prBaseline;
|
|
3395
|
+
const currentBranch = gitSyncOrNull(project.dir, ['branch', '--show-current']);
|
|
3396
|
+
const lines = spawnSync('git', ['-C', project.dir, 'status', '--porcelain', '-uall'], { encoding: 'utf8' }).stdout?.split('\n').filter(Boolean) ?? [];
|
|
3397
|
+
return classifyPrSafety({ baseline, files, currentFiles: gitStatusPaths(lines), currentBranch });
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3101
3400
|
// The Manager-verified review gate (shared by goal completion and /reverify):
|
|
3102
3401
|
// run OUR OWN tests, generate a plain-language headline, and require proof for a
|
|
3103
3402
|
// UI change — capturing it (3 tries) if missing. Any failure → 'blocked' (shown
|
|
@@ -3270,7 +3569,9 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
3270
3569
|
: gate.proofAdvisory === 'unconfirmed'
|
|
3271
3570
|
? 'proof は撮れましたが、依頼対象のUIを自動確認できませんでした — 目視で確認してください'
|
|
3272
3571
|
: null;
|
|
3572
|
+
goal.verificationInfraError = verificationInfraErrorFrom({ testResult: goal.testResult, proofNote: goal.proofNote });
|
|
3273
3573
|
if (goal.proofNote) goalAct(`Proof advisory (not blocking): ${goal.proofNote}`);
|
|
3574
|
+
if (goal.verificationInfraError) goalAct(`Verification infrastructure issue (not blocking): ${goal.verificationInfraError.detail}`);
|
|
3274
3575
|
if (gate.blocked) {
|
|
3275
3576
|
if (!testsFailing) {
|
|
3276
3577
|
recordGoalFailure(goal, {
|
|
@@ -3307,13 +3608,24 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
|
|
|
3307
3608
|
// the diff. Proof artifacts (.manager-proof) are intentional and kept.
|
|
3308
3609
|
const SCRATCH = /(^|\/)\.tmp|\.log$|(^|\/)\.manager-wt(\/|$)|(^|\/)node_modules(\/|$)|(^|\/)secret\.key$/;
|
|
3309
3610
|
const files = [...new Set(goalTasks.flatMap((t) => t.changedFiles))].filter((f) => f && !SCRATCH.test(f));
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3611
|
+
if (!shouldCreateGoalPR(goal, files)) {
|
|
3612
|
+
goal.pr = null;
|
|
3613
|
+
goal.prBranch = null;
|
|
3614
|
+
goal.prError = null;
|
|
3615
|
+
saveGoal(goal);
|
|
3616
|
+
goalAct(goal.wantsPR === false
|
|
3617
|
+
? 'Deliverable is No PR, so PR creation is skipped even though files changed.'
|
|
3618
|
+
: 'No code changes detected, so PR creation is skipped.');
|
|
3619
|
+
return;
|
|
3620
|
+
}
|
|
3621
|
+
const unsafe = prSafetyIssue(goal, project, files);
|
|
3622
|
+
if (unsafe) {
|
|
3623
|
+
goal.prError = unsafe.message.slice(0, 300);
|
|
3624
|
+
goal.prSafety = { code: unsafe.code, message: unsafe.message, at: new Date().toISOString() };
|
|
3625
|
+
goalAct(unsafe.message);
|
|
3626
|
+
saveGoal(goal);
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3317
3629
|
// Rework: the goal already has an open PR from an earlier finish. Push onto
|
|
3318
3630
|
// that same branch instead of opening a second PR for the goal (see openPR's
|
|
3319
3631
|
// existingBranch/existingPrUrl mode in pr.mjs).
|
|
@@ -3365,6 +3677,12 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
|
|
|
3365
3677
|
goal.pr = url;
|
|
3366
3678
|
goal.prBranch = branch;
|
|
3367
3679
|
goal.prError = null;
|
|
3680
|
+
goal.prErrorClass = null;
|
|
3681
|
+
goal.prRepair = null;
|
|
3682
|
+
goal.stalePr = null;
|
|
3683
|
+
goal.prSafety = null;
|
|
3684
|
+
if (activityTask?.attemptId) goal.prAttemptId = activityTask.attemptId;
|
|
3685
|
+
if (!goal.prAttemptId) goal.prAttemptId = goal.latestAttemptId ?? goalTasks.map((t) => t.attemptId || `task-${t.id}`).filter(Boolean).at(-1) ?? null;
|
|
3368
3686
|
goalAct(`PR ready: ${url}`);
|
|
3369
3687
|
} catch (e) {
|
|
3370
3688
|
// A rework push failing shouldn't blow away an already-working PR url —
|
|
@@ -3451,6 +3769,16 @@ const server = createServer(async (req, res) => {
|
|
|
3451
3769
|
return res.end(readFileSync(file));
|
|
3452
3770
|
}
|
|
3453
3771
|
|
|
3772
|
+
if (url.pathname === '/api/internal/quality-metrics' && req.method === 'GET') {
|
|
3773
|
+
if (identityFor(req) !== 'owner') return json(res, 403, { error: 'owner/debug only' });
|
|
3774
|
+
return json(res, 200, computeInternalQualityMetrics({
|
|
3775
|
+
goals,
|
|
3776
|
+
tasks,
|
|
3777
|
+
events: readInternalQualityEvents(),
|
|
3778
|
+
nowMs: Date.now(),
|
|
3779
|
+
}));
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3454
3782
|
if (url.pathname === '/api/state') {
|
|
3455
3783
|
await syncAllExternal();
|
|
3456
3784
|
await syncGoalMerges();
|
|
@@ -3496,7 +3824,8 @@ const server = createServer(async (req, res) => {
|
|
|
3496
3824
|
const projectsWithBranch = projects.map((p) => {
|
|
3497
3825
|
let branch = null;
|
|
3498
3826
|
try { branch = gitSync(p.dir, ['rev-parse', '--abbrev-ref', 'HEAD']); } catch { /* not a repo → no branch, and the bar shows none */ }
|
|
3499
|
-
|
|
3827
|
+
const dirty = projectDirtyInfo(p.dir);
|
|
3828
|
+
return { ...p, ...(branch ? { branch } : {}), ...dirty };
|
|
3500
3829
|
});
|
|
3501
3830
|
return json(res, 200, { projects: projectsWithBranch, goals: visibleGoals, tasks: trimTaskActivityForState(visibleTasks), models: MODELS, agentModels: { 'claude-code': MODELS, codex: CODEX_MODELS }, agentEfforts: { 'claude-code': CLAUDE_EFFORTS, codex: CODEX_EFFORTS }, agents: AVAILABLE_AGENTS, connectCommand, queueOrder, externalActivity, workflowColumns: projectColumns, reviewDefinitions: projectReviewDefs, runningCounts, parallelLimits, uiVersion: uiVersion(), billing, auth: publicAuth() });
|
|
3502
3831
|
}
|
|
@@ -3647,7 +3976,7 @@ const server = createServer(async (req, res) => {
|
|
|
3647
3976
|
const model = CODEX_MODELS[0];
|
|
3648
3977
|
for (const task of queued) { task.agent = 'codex'; task.model = model; task.sessionId = null; saveTask(task); }
|
|
3649
3978
|
for (const goal of goals.filter((item) => goalIds.has(item.id))) {
|
|
3650
|
-
goal
|
|
3979
|
+
switchGoalAgentSession(goal, 'codex'); goal.model = model; goal.startedAt = null; saveGoal(goal);
|
|
3651
3980
|
}
|
|
3652
3981
|
for (const projectId of new Set(queued.map((task) => task.projectId))) pump(projectId);
|
|
3653
3982
|
return json(res, 200, { changed: queued.length, goals: [...goalIds] });
|
|
@@ -3811,7 +4140,7 @@ const server = createServer(async (req, res) => {
|
|
|
3811
4140
|
req.on('data', (d) => { body += d; });
|
|
3812
4141
|
req.on('end', async () => {
|
|
3813
4142
|
try {
|
|
3814
|
-
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree } = JSON.parse(body);
|
|
4143
|
+
const { projectId, text, pr, existingPr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree, workspaceMode } = JSON.parse(body);
|
|
3815
4144
|
const project = projects.find((p) => p.id === projectId);
|
|
3816
4145
|
if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
|
|
3817
4146
|
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
|
|
@@ -3925,14 +4254,20 @@ const server = createServer(async (req, res) => {
|
|
|
3925
4254
|
// (connected-agents Phase A) — a requested/default agent that isn't
|
|
3926
4255
|
// present would otherwise spawn a worker doomed to fail at launch.
|
|
3927
4256
|
const goalAgent = pickAvailableAgent(workerAgent(agent), AVAILABLE_AGENTS);
|
|
3928
|
-
|
|
4257
|
+
// Failure memory belongs to one conversation. A new Goal must not
|
|
4258
|
+
// inherit an unrelated Goal's old failure just because both mention UI.
|
|
4259
|
+
const priorFailureMemory = [];
|
|
4260
|
+
const resolvedWorkspaceMode = resolveWorkspaceMode(workspaceMode ?? worktree);
|
|
4261
|
+
const workspaceDirty = resolvedWorkspaceMode === 'direct-workspace' ? projectDirtyInfo(project.dir) : {};
|
|
4262
|
+
const importedPr = review ? normalizePullRequestUrl(existingPr) : null;
|
|
4263
|
+
if (review && existingPr && !importedPr) return json(res, 400, { error: 'existingPr must be a GitHub pull request URL' });
|
|
3929
4264
|
const goal = {
|
|
3930
4265
|
id: nextId++, projectId, text: text.trim().slice(0, 8000),
|
|
3931
|
-
status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : '
|
|
4266
|
+
status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'stacked',
|
|
3932
4267
|
question: folderQuestion || reviewPrefQuestion,
|
|
3933
4268
|
reviewOnly: review ? true : undefined,
|
|
3934
4269
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
3935
|
-
wantsPR: review ?
|
|
4270
|
+
wantsPR: review ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
3936
4271
|
model: workerModel(goalAgent, model),
|
|
3937
4272
|
effort: workerEffort(goalAgent, effort),
|
|
3938
4273
|
agent: goalAgent,
|
|
@@ -3941,11 +4276,16 @@ const server = createServer(async (req, res) => {
|
|
|
3941
4276
|
mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
|
|
3942
4277
|
// SKILL: an optional user/project skill (or command) to invoke first.
|
|
3943
4278
|
skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
|
|
3944
|
-
//
|
|
3945
|
-
//
|
|
3946
|
-
//
|
|
3947
|
-
//
|
|
3948
|
-
|
|
4279
|
+
// WORKSPACE MODE: isolated-worktree is the default and uses a clean per-goal
|
|
4280
|
+
// checkout; direct-workspace is explicit and runs in the folder the person is
|
|
4281
|
+
// looking at, inheriting its current dirty/clean state. Keep the old
|
|
4282
|
+
// worktree:false wire contract too, so existing clients and old goals retain
|
|
4283
|
+
// their behavior while Review/PR can show the explicit mode name.
|
|
4284
|
+
workspaceMode: resolvedWorkspaceMode,
|
|
4285
|
+
worktree: resolvedWorkspaceMode === 'direct-workspace' ? false : undefined,
|
|
4286
|
+
workspaceWarning: workspaceDirty.dirty
|
|
4287
|
+
? `Dirty workspace warning: direct workspace will inherit current uncommitted changes (${(workspaceDirty.dirtyPaths ?? []).join(', ')}).`
|
|
4288
|
+
: undefined,
|
|
3949
4289
|
images: Array.isArray(images) ? images.slice(0, 6) : [],
|
|
3950
4290
|
source: resolveGoalSource(source), sourceRef: null,
|
|
3951
4291
|
executionPlan: buildExecutionPlan({
|
|
@@ -3954,7 +4294,7 @@ const server = createServer(async (req, res) => {
|
|
|
3954
4294
|
previousSession: false,
|
|
3955
4295
|
}),
|
|
3956
4296
|
priorFailureMemory: priorFailureMemory.length ? priorFailureMemory : undefined,
|
|
3957
|
-
plan: null, pr: undefined, createdAt: new Date().toISOString(),
|
|
4297
|
+
plan: null, pr: importedPr || undefined, createdAt: new Date().toISOString(),
|
|
3958
4298
|
// Stage 2 (multi-user): who this goal belongs to — 'owner' for
|
|
3959
4299
|
// key-auth/MANAGER_OWNER_EMAIL, else the creator's Google email.
|
|
3960
4300
|
owner: identity,
|
|
@@ -3968,11 +4308,7 @@ const server = createServer(async (req, res) => {
|
|
|
3968
4308
|
// pending = deliberately shelved (Phase 2 material): never planned,
|
|
3969
4309
|
// never queued, until POST /api/goals/:id/activate. review-only items
|
|
3970
4310
|
// are terminal-until-human too, so they also skip planGoal.
|
|
3971
|
-
if (runnable && !needsFolder && !needsReviewPref)
|
|
3972
|
-
planGoal(goal).catch((e) => {
|
|
3973
|
-
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
3974
|
-
});
|
|
3975
|
-
}
|
|
4311
|
+
if (runnable && !needsFolder && !needsReviewPref) startNextStacked(goal.projectId);
|
|
3976
4312
|
json(res, 200, goal);
|
|
3977
4313
|
} catch { json(res, 400, { error: 'bad json' }); }
|
|
3978
4314
|
});
|
|
@@ -4065,6 +4401,7 @@ const server = createServer(async (req, res) => {
|
|
|
4065
4401
|
if (!task) return json(res, 404, { error: 'task not found' });
|
|
4066
4402
|
if (!requireTaskGoalOwnership(req, res, task)) return;
|
|
4067
4403
|
if (!['interrupted', 'failed'].includes(task.status)) return json(res, 409, { error: 'only interrupted/failed tasks can be retried' });
|
|
4404
|
+
appendInternalQualityEvent({ type: 'task_retry_requested', taskId: task.id, goalId: task.goalId, projectId: task.projectId }, { once: false });
|
|
4068
4405
|
task.status = 'queued'; task.result = null; task.secs = null; task.proof = null;
|
|
4069
4406
|
saveTask(task);
|
|
4070
4407
|
const goal = goals.find((g) => g.id === task.goalId);
|
|
@@ -4111,7 +4448,7 @@ const server = createServer(async (req, res) => {
|
|
|
4111
4448
|
saveTask(task);
|
|
4112
4449
|
}
|
|
4113
4450
|
for (const goal of goals.filter((item) => goalIds.has(item.id))) {
|
|
4114
|
-
goal
|
|
4451
|
+
switchGoalAgentSession(goal, agent); goal.model = model; goal.effort = effort; goal.startedAt = null;
|
|
4115
4452
|
saveGoal(goal);
|
|
4116
4453
|
}
|
|
4117
4454
|
pump(projectId);
|
|
@@ -4132,7 +4469,7 @@ const server = createServer(async (req, res) => {
|
|
|
4132
4469
|
const queuedTasks = tasks.filter((task) => task.goalId === goal.id && task.status === 'queued');
|
|
4133
4470
|
if (!queuedTasks.length) return json(res, 409, { error: 'this goal has no queued work' });
|
|
4134
4471
|
const model = CODEX_MODELS[0];
|
|
4135
|
-
goal
|
|
4472
|
+
switchGoalAgentSession(goal, 'codex'); goal.model = model; goal.startedAt = null;
|
|
4136
4473
|
saveGoal(goal);
|
|
4137
4474
|
for (const task of queuedTasks) {
|
|
4138
4475
|
task.agent = 'codex'; task.model = model; task.sessionId = null;
|
|
@@ -4156,9 +4493,26 @@ const server = createServer(async (req, res) => {
|
|
|
4156
4493
|
if (!task) return json(res, 404, { error: 'task not found' });
|
|
4157
4494
|
if (!requireTaskGoalOwnership(req, res, task)) return;
|
|
4158
4495
|
if (!task.run) return json(res, 409, { error: 'no run declaration for this task' });
|
|
4496
|
+
const goal = goals.find((g) => g.id === task.goalId);
|
|
4159
4497
|
startPreview(task)
|
|
4160
|
-
.then((p) =>
|
|
4161
|
-
|
|
4498
|
+
.then((p) => {
|
|
4499
|
+
if (goal?.verificationInfraError?.source === 'preview') {
|
|
4500
|
+
goal.verificationInfraError = null;
|
|
4501
|
+
saveGoal(goal);
|
|
4502
|
+
}
|
|
4503
|
+
json(res, 200, p);
|
|
4504
|
+
})
|
|
4505
|
+
.catch((e) => {
|
|
4506
|
+
const detail = String(e.message ?? e).slice(0, 200);
|
|
4507
|
+
if (goal && goal.status === 'review') {
|
|
4508
|
+
goal.verificationInfraError = buildVerificationInfraError({
|
|
4509
|
+
source: 'preview',
|
|
4510
|
+
detail: `Local preview could not start: ${detail}. The implementation result stays in Review; press 再検証 to rerun verification only, or try Open it again when the local environment is idle.`,
|
|
4511
|
+
});
|
|
4512
|
+
saveGoal(goal);
|
|
4513
|
+
}
|
|
4514
|
+
json(res, 500, { error: detail });
|
|
4515
|
+
});
|
|
4162
4516
|
return;
|
|
4163
4517
|
}
|
|
4164
4518
|
|
|
@@ -4334,6 +4688,7 @@ const server = createServer(async (req, res) => {
|
|
|
4334
4688
|
goal.foldedInto = null;
|
|
4335
4689
|
goal.noFold = true;
|
|
4336
4690
|
goal.status = 'running'; // let finishGoalIfComplete re-run close-out (its tasks are all done → opens its own PR)
|
|
4691
|
+
appendInternalQualityEvent({ type: 'goal_fold_unfolded', goalId: goal.id, projectId: goal.projectId }, { once: false });
|
|
4337
4692
|
saveGoal(goal);
|
|
4338
4693
|
finishGoalIfComplete(goal, project).catch((e) => {
|
|
4339
4694
|
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
@@ -4349,8 +4704,8 @@ const server = createServer(async (req, res) => {
|
|
|
4349
4704
|
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
4350
4705
|
if (!requireGoalOwnership(req, res, goal)) return;
|
|
4351
4706
|
if (goal.status !== 'pending') return json(res, 409, { error: 'only pending goals can be activated' });
|
|
4352
|
-
goal.status = '
|
|
4353
|
-
|
|
4707
|
+
goal.status = 'stacked'; saveGoal(goal);
|
|
4708
|
+
startNextStacked(goal.projectId);
|
|
4354
4709
|
return json(res, 200, goal);
|
|
4355
4710
|
}
|
|
4356
4711
|
const pendMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/pend$/);
|
|
@@ -4386,13 +4741,14 @@ const server = createServer(async (req, res) => {
|
|
|
4386
4741
|
if (!proj) {
|
|
4387
4742
|
proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
|
|
4388
4743
|
projects.push(proj); saveProjects();
|
|
4744
|
+
queues.set(proj.id, { running: new Set(), waiting: [] });
|
|
4389
4745
|
}
|
|
4390
4746
|
goal.projectId = proj.id;
|
|
4391
4747
|
goal.question = null;
|
|
4392
4748
|
goal.clarified = true;
|
|
4393
|
-
goal.status = '
|
|
4749
|
+
goal.status = 'stacked';
|
|
4394
4750
|
saveGoal(goal);
|
|
4395
|
-
|
|
4751
|
+
startNextStacked(goal.projectId);
|
|
4396
4752
|
return json(res, 200, goal);
|
|
4397
4753
|
};
|
|
4398
4754
|
// Intent router (no free-text box is a dead-end). Before any per-kind
|
|
@@ -4515,17 +4871,17 @@ const server = createServer(async (req, res) => {
|
|
|
4515
4871
|
goal.wantsPR = resolveWantsPR(undefined, goal.text, patch.defaultWantsPR);
|
|
4516
4872
|
goal.question = null;
|
|
4517
4873
|
goal.clarified = true;
|
|
4518
|
-
goal.status = '
|
|
4874
|
+
goal.status = 'stacked';
|
|
4519
4875
|
saveGoal(goal);
|
|
4520
|
-
|
|
4876
|
+
startNextStacked(goal.projectId);
|
|
4521
4877
|
return json(res, 200, goal);
|
|
4522
4878
|
}
|
|
4523
4879
|
goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
|
|
4524
4880
|
goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
|
|
4525
4881
|
goal.question = null;
|
|
4526
|
-
goal.status = '
|
|
4882
|
+
goal.status = 'stacked';
|
|
4527
4883
|
saveGoal(goal);
|
|
4528
|
-
|
|
4884
|
+
startNextStacked(goal.projectId);
|
|
4529
4885
|
json(res, 200, goal);
|
|
4530
4886
|
});
|
|
4531
4887
|
return;
|
|
@@ -4627,6 +4983,7 @@ const server = createServer(async (req, res) => {
|
|
|
4627
4983
|
goal.autoRework = { ...(goal.autoRework ?? {}), testFailures: 0, manualRetryAt: new Date().toISOString() };
|
|
4628
4984
|
saveGoal(goal);
|
|
4629
4985
|
const task = queueReplyTask(goal, prompt, { from: 'manager', via: 'retry' });
|
|
4986
|
+
appendInternalQualityEvent({ type: 'task_retry_requested', taskId: task.id, goalId: goal.id, projectId: goal.projectId }, { once: false });
|
|
4630
4987
|
return json(res, 200, { goal, task, repairing: true });
|
|
4631
4988
|
}
|
|
4632
4989
|
const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
|
|
@@ -4785,7 +5142,7 @@ async function judgeReplyIntent(goal, reply) {
|
|
|
4785
5142
|
|
|
4786
5143
|
// 「聞いてるだけ」への返事 (ONE-CONVERSATION.md §1.3 answer)。
|
|
4787
5144
|
//
|
|
4788
|
-
// 答えるのは**作業した AI
|
|
5145
|
+
// 答えるのは**作業した AI 本人**=その agent の session を resume する。状況一覧しか
|
|
4789
5146
|
// 知らない別AIでは「なぜそうしたか」に答えられない(Masa確定 2026-07-22)。
|
|
4790
5147
|
// コードは1文字も変わらない: ツールは Read だけ、permissionMode は 'plan'
|
|
4791
5148
|
// (Codex 側は read-only サンドボックスに落ちる)。タスクも作らないので看板も
|
|
@@ -4806,7 +5163,7 @@ async function answerGoalQuestion(goal, question) {
|
|
|
4806
5163
|
const r = await runWorkerAgent({
|
|
4807
5164
|
agent: goal.agent, model: goal.model, prompt, cwd,
|
|
4808
5165
|
tools: 'Read', permissionMode: 'plan',
|
|
4809
|
-
resume: goal.
|
|
5166
|
+
resume: goalSessionFor(goal, goal.agent) ?? undefined,
|
|
4810
5167
|
onChild: (c) => trackGoalWorker(goal.id, c),
|
|
4811
5168
|
});
|
|
4812
5169
|
const answer = String(r?.result ?? '').trim();
|
|
@@ -4873,7 +5230,7 @@ async function answerGoalQuestion(goal, question) {
|
|
|
4873
5230
|
}
|
|
4874
5231
|
|
|
4875
5232
|
// answer: 聞かれただけ。ゴールは review のまま、ワーカーも起こさない(§1.3)。
|
|
4876
|
-
// 答えるのは**その作業をやった AI 本人**(
|
|
5233
|
+
// 答えるのは**その作業をやった AI 本人**(agent別sessionを resume)なので
|
|
4877
5234
|
// 「なぜそうしたか」に答えられる。HTTP は待たせない——答えは会話に1発言
|
|
4878
5235
|
// 積まれ、SSE の goal イベント(messageCount)で画面に出る。
|
|
4879
5236
|
if (result.answers) {
|
|
@@ -5095,27 +5452,22 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
5095
5452
|
setInterval(() => {
|
|
5096
5453
|
send({ ev: 'ping', uiVersion: uiVersion() });
|
|
5097
5454
|
syncGoalMerges().catch(() => {});
|
|
5098
|
-
for (const p of projects) {
|
|
5099
|
-
pump(p.id);
|
|
5100
|
-
for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
|
|
5101
|
-
g.status = 'planning'; saveGoal(g);
|
|
5102
|
-
planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
|
|
5103
|
-
}
|
|
5104
|
-
}
|
|
5105
5455
|
}, 30_000).unref();
|
|
5456
|
+
// Queue liveness is a product invariant, not something an SSE refresh or a
|
|
5457
|
+
// person pressing Retry should have to restore. Five seconds keeps recovery
|
|
5458
|
+
// quick without doing meaningful work while idle.
|
|
5459
|
+
setInterval(() => {
|
|
5460
|
+
for (const p of projects) reconcileAndPumpProject(p.id);
|
|
5461
|
+
}, 5_000).unref();
|
|
5106
5462
|
for (const p of projects) {
|
|
5107
5463
|
const pending = tasks.filter((t) => t.projectId === p.id && t.status === 'queued').sort((a, b) => a.num - b.num);
|
|
5108
5464
|
const q = queues.get(p.id);
|
|
5109
5465
|
reconcileQueuedGoalStarts(p.id);
|
|
5110
5466
|
q.waiting.push(...pending);
|
|
5111
5467
|
if (pending.length) console.log(`[manager] ${p.id}: re-queued ${pending.length} pending task(s) after restart`);
|
|
5112
|
-
pump(p.id);
|
|
5113
5468
|
// breakdown-first: any goal still sitting 'stacked' (legacy or crash
|
|
5114
|
-
// leftovers) gets planned immediately so it can never rot in the queue
|
|
5115
|
-
|
|
5116
|
-
g.status = 'planning'; saveGoal(g);
|
|
5117
|
-
planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
|
|
5118
|
-
}
|
|
5469
|
+
// leftovers) gets planned immediately so it can never rot in the queue.
|
|
5470
|
+
reconcileAndPumpProject(p.id);
|
|
5119
5471
|
// A restart must not orphan an in-flight Ledger retest (v45 §5.3): the
|
|
5120
5472
|
// intent is persisted as status 'retesting' in tasks.jsonl (replayQueueLog
|
|
5121
5473
|
// passes it through untouched) — resume the job from run 1.
|