@galda/cli 0.10.68 → 0.10.70
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 +434 -118
- package/app/review-merge-celebration-preview.html +21 -0
- package/engine/chrome-safety.mjs +34 -0
- package/engine/lib.mjs +563 -3
- package/engine/server.mjs +479 -113
- package/engine/shot.mjs +4 -0
- package/engine/verify.mjs +27 -6
- 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, 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;
|
|
@@ -1846,6 +2061,7 @@ function queueBudgetContinuation(goal, task, budget) {
|
|
|
1846
2061
|
goal.contextHandoff = buildContextHandoffSummary({ goal, tasks: tasks.filter((t) => t.goalId === goal.id) });
|
|
1847
2062
|
goal.contextHandoffAt = now;
|
|
1848
2063
|
goal.sessionId = null;
|
|
2064
|
+
goal.sessions = {};
|
|
1849
2065
|
goal.runElapsedMs = 0;
|
|
1850
2066
|
goal.runTokens = 0;
|
|
1851
2067
|
goal.runAttempts = 0;
|
|
@@ -1975,17 +2191,17 @@ function resumeProject(projectId) {
|
|
|
1975
2191
|
}
|
|
1976
2192
|
|
|
1977
2193
|
// Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
|
|
1978
|
-
// runTask()のtask.reply
|
|
2194
|
+
// runTask()のtask.reply分岐が各agentのsessionをresumeに渡して同じ会話を続ける。
|
|
1979
2195
|
function applyReplyWorker(goal, requested = {}) {
|
|
1980
2196
|
if (requested.agent == null) return { ok: true };
|
|
1981
2197
|
if (!WORKER_AGENTS.includes(requested.agent)) return { ok: false, error: 'unsupported agent' };
|
|
1982
2198
|
const agent = workerAgent(requested.agent);
|
|
1983
2199
|
if (!AVAILABLE_AGENTS.includes(agent)) return { ok: false, error: `${agent} is not connected on this device` };
|
|
1984
2200
|
const switchedAgent = agent !== goal.agent;
|
|
1985
|
-
goal
|
|
2201
|
+
if (switchedAgent) switchGoalAgentSession(goal, agent);
|
|
2202
|
+
else goal.agent = agent;
|
|
1986
2203
|
goal.model = workerModel(agent, requested.model);
|
|
1987
2204
|
goal.effort = workerEffort(agent, requested.effort);
|
|
1988
|
-
if (switchedAgent) goal.sessionId = null;
|
|
1989
2205
|
return { ok: true };
|
|
1990
2206
|
}
|
|
1991
2207
|
|
|
@@ -2002,7 +2218,7 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
|
|
|
2002
2218
|
usage: goal.runUsage,
|
|
2003
2219
|
runTokens: goal.runTokens,
|
|
2004
2220
|
runAttempts: goal.runAttempts,
|
|
2005
|
-
previousSession: Boolean(goal.
|
|
2221
|
+
previousSession: Boolean(goalSessionFor(goal, goal.agent) || goal.contextHandoff),
|
|
2006
2222
|
});
|
|
2007
2223
|
if (shouldFreshSessionForPlan(goal.executionPlan)) refreshGoalHandoff(goal);
|
|
2008
2224
|
saveGoal(goal);
|
|
@@ -2011,6 +2227,8 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
|
|
|
2011
2227
|
id: nextId++, num: (nums.length ? Math.max(...nums) : 0) + 1,
|
|
2012
2228
|
goalId: goal.id, projectId: goal.projectId, reply: true, model: goal.model, effort: goal.effort, agent: goal.agent,
|
|
2013
2229
|
title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000),
|
|
2230
|
+
workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
|
|
2231
|
+
worktree: goal.worktree === false ? false : undefined,
|
|
2014
2232
|
passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: '中',
|
|
2015
2233
|
result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
|
|
2016
2234
|
};
|
|
@@ -2029,8 +2247,8 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
|
|
|
2029
2247
|
// follow-up reply to an already-running goal must stay DOING (its clock stands).
|
|
2030
2248
|
if (goal.status === 'running' && ![...q.running].some((t) => t.goalId === goal.id)) {
|
|
2031
2249
|
const slow = slowModeSettings();
|
|
2032
|
-
const projectCap =
|
|
2033
|
-
const slots = Math.min(projectCap -
|
|
2250
|
+
const projectCap = projectParallelCap();
|
|
2251
|
+
const slots = Math.min(projectCap - activeSlotCount(goal.projectId), GLOBAL_MAX_PARALLEL - activeSlotCount());
|
|
2034
2252
|
const runningGoalIds = [...q.running].map((t) => t.goalId);
|
|
2035
2253
|
const eligible = q.waiting.filter((candidate) => workerCanStart({ agent: candidate.agent, auth: workerAuth }));
|
|
2036
2254
|
const startsNow = workerCanStart({ agent: task.agent, auth: workerAuth })
|
|
@@ -2217,6 +2435,39 @@ function readRunDeclaration(task, workDir) {
|
|
|
2217
2435
|
if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
|
|
2218
2436
|
}
|
|
2219
2437
|
|
|
2438
|
+
// A direct/shared workspace can still contain a declaration left by an older
|
|
2439
|
+
// run (for example when that run was interrupted before readRunDeclaration).
|
|
2440
|
+
// Remove it before this task starts so only a file created by THIS worker can
|
|
2441
|
+
// name the review card or supply its Open it target.
|
|
2442
|
+
function clearStaleRunDeclaration(task, workDir) {
|
|
2443
|
+
const file = join(workDir, RUN_DECL_FILE);
|
|
2444
|
+
if (!existsSync(file)) return;
|
|
2445
|
+
try {
|
|
2446
|
+
unlinkSync(file);
|
|
2447
|
+
sendProcessAct(task, `Removed a stale ${RUN_DECL_FILE} before starting this task.`);
|
|
2448
|
+
} catch (err) {
|
|
2449
|
+
sendProcessAct(task, `Could not remove stale ${RUN_DECL_FILE}: ${String(err?.code ?? err).slice(0, 80)}`);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function inheritPreviousRunDeclaration(task, goalId) {
|
|
2454
|
+
if (task.run || !goalId) return;
|
|
2455
|
+
const prev = [...tasks].reverse().find((t) => t.goalId === goalId && t.id !== task.id && t.run && (t.run.cmd || t.run.url));
|
|
2456
|
+
if (!prev) return;
|
|
2457
|
+
const note = [
|
|
2458
|
+
'Reusing the previous preview because this follow-up did not declare a new preview.',
|
|
2459
|
+
prev.run.note || prev.run.cmd || prev.run.url || '',
|
|
2460
|
+
].filter(Boolean).join(' ');
|
|
2461
|
+
task.run = {
|
|
2462
|
+
...prev.run,
|
|
2463
|
+
note: note.slice(0, 300),
|
|
2464
|
+
inherited: true,
|
|
2465
|
+
inheritedFromTaskId: prev.id,
|
|
2466
|
+
inheritedFromAttemptId: prev.attemptId ?? null,
|
|
2467
|
+
};
|
|
2468
|
+
sendProcessAct(task, `No new run declaration; explicitly reusing preview from task ${prev.id}.`);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2220
2471
|
// A declared pair only survives if both pictures actually arrived. The card
|
|
2221
2472
|
// draws a red box over whatever differs between the two, so a name matching no
|
|
2222
2473
|
// collected shot would leave the reviewer half a comparison — show the plain
|
|
@@ -2343,12 +2594,22 @@ async function runTask(task) {
|
|
|
2343
2594
|
saveTask(task);
|
|
2344
2595
|
sendProcessAct(task, `Working on "${task.title}"${task.reply ? ' as a follow-up' : ''}.`);
|
|
2345
2596
|
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2597
|
+
// The workspace mode is chosen at goal creation and then held through every
|
|
2598
|
+
// worker/review/PR step.
|
|
2348
2599
|
const workDir = goal ? goalWorkDir(goal, project) : project.dir;
|
|
2349
2600
|
const wproject = { ...project, dir: workDir };
|
|
2350
|
-
|
|
2601
|
+
const workspaceMode = resolveWorkspaceMode(task.workspaceMode ?? goal?.workspaceMode ?? goal?.worktree);
|
|
2602
|
+
sendProcessAct(task, workspaceMode === 'direct-workspace'
|
|
2603
|
+
? `Using direct workspace: ${workDir}`
|
|
2604
|
+
: `Prepared isolated worktree: ${workDir}`);
|
|
2605
|
+
if (goal?.workspaceWarning) sendProcessAct(task, goal.workspaceWarning);
|
|
2606
|
+
clearStaleRunDeclaration(task, workDir);
|
|
2351
2607
|
const before = await gitChanges(workDir);
|
|
2608
|
+
task.attemptId = task.attemptId || newAttemptId(goal, task);
|
|
2609
|
+
if (goal) {
|
|
2610
|
+
goal.latestAttemptId = task.attemptId;
|
|
2611
|
+
noteGoalTaskBaseline(goal, project, workDir, before);
|
|
2612
|
+
}
|
|
2352
2613
|
sendProcessAct(task, `Checked starting git state (${before.length} changed file${before.length === 1 ? '' : 's'} before this task).`);
|
|
2353
2614
|
// Plan mode makes no edits, so there is nothing to verify — never author a
|
|
2354
2615
|
// check or boot an ephemeral instance for it.
|
|
@@ -2362,22 +2623,31 @@ async function runTask(task) {
|
|
|
2362
2623
|
// same conversation, no re-briefing (and no wasted tokens).
|
|
2363
2624
|
if (task.reply) {
|
|
2364
2625
|
const plan = goal?.executionPlan;
|
|
2365
|
-
const
|
|
2626
|
+
const runAgent = workerAgent(task.agent ?? goal?.agent);
|
|
2627
|
+
const runModel = task.model ?? goal?.model;
|
|
2628
|
+
const runEffort = task.effort ?? goal?.effort;
|
|
2629
|
+
const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goalSessionFor(goal, runAgent) ?? undefined);
|
|
2630
|
+
const replyPermissionMode = permissionModeFor(goal?.mode === 'plan' && goal?.planPhase !== 'executing' ? 'plan' : undefined);
|
|
2631
|
+
const runContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode: replyPermissionMode, cwd: workDir, resumeSession, plan });
|
|
2632
|
+
// A follow-up is a full worker run, so it needs the same deliverable contract as
|
|
2633
|
+
// the first attempt (artifacts, run declaration, tests, language). The old short
|
|
2634
|
+
// prompt omitted those rules, which let a reply finish with prose only and left
|
|
2635
|
+
// Review showing the original task's stale "Skipped." result.
|
|
2366
2636
|
const prompt = [
|
|
2367
2637
|
managerHandoffPrompt(goal, plan),
|
|
2368
|
-
task
|
|
2369
|
-
'',
|
|
2370
|
-
|
|
2371
|
-
].filter(Boolean).join('\n');
|
|
2638
|
+
workerPrompt(task, goal, null, runAgent, runContext),
|
|
2639
|
+
'(User follow-up on the goal you worked on. Apply it to the same goal and deliver the requested result.)',
|
|
2640
|
+
].filter(Boolean).join('\n\n');
|
|
2372
2641
|
const callT0 = Date.now();
|
|
2373
|
-
|
|
2642
|
+
sendWorkerContextAct(task, runContext);
|
|
2643
|
+
sendProcessAct(task, `Starting ${runAgent === 'codex' ? 'Codex' : 'Claude Code'} worker with the existing goal context.`);
|
|
2374
2644
|
const r = await runWorkerAgent({
|
|
2375
|
-
agent:
|
|
2645
|
+
agent: runAgent,
|
|
2376
2646
|
prompt, cwd: workDir, tools: WORKER_TOOLS, resume: resumeSession,
|
|
2377
2647
|
// A reply that revises a plan-review must stay plan mode (no edits); a
|
|
2378
2648
|
// reply/rework on a normal goal stays acceptEdits as before.
|
|
2379
|
-
permissionMode:
|
|
2380
|
-
model:
|
|
2649
|
+
permissionMode: replyPermissionMode,
|
|
2650
|
+
model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
|
|
2381
2651
|
images: goal?.images,
|
|
2382
2652
|
onTodos: (todos) => sendTodos(task, todos),
|
|
2383
2653
|
onChild: (c) => trackGoalWorker(goal?.id, c),
|
|
@@ -2394,11 +2664,19 @@ async function runTask(task) {
|
|
|
2394
2664
|
task.status = r.code === 0 ? 'done' : 'failed';
|
|
2395
2665
|
task.result = r.result.slice(0, RESULT_MAX);
|
|
2396
2666
|
task.usage = sumUsage([r.usage]);
|
|
2397
|
-
if (r.sessionId && goal) { goal
|
|
2667
|
+
if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
|
|
2668
|
+
// A follow-up owns its own evidence too. Previously only the initial task
|
|
2669
|
+
// consumed .manager-run.json, so replies either left the new declaration
|
|
2670
|
+
// behind or Review kept showing an older task's title/URL.
|
|
2671
|
+
readRunDeclaration(task, workDir);
|
|
2672
|
+
collectWorkerShots(task, workDir);
|
|
2673
|
+
reconcileComparePair(task);
|
|
2674
|
+
inheritPreviousRunDeclaration(task, goal?.id);
|
|
2398
2675
|
const afterR = await gitChanges(workDir);
|
|
2399
2676
|
task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
2400
2677
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
|
|
2401
2678
|
task.finishedAt = new Date().toISOString();
|
|
2679
|
+
task.resultAttemptId = task.attemptId;
|
|
2402
2680
|
saveTask(task);
|
|
2403
2681
|
// A rework (comment/dismiss) must re-enter the verify gate — re-test,
|
|
2404
2682
|
// re-capture proof, update the SAME PR (openPR existingBranch) — and land
|
|
@@ -2426,14 +2704,19 @@ async function runTask(task) {
|
|
|
2426
2704
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2427
2705
|
let callT0 = Date.now();
|
|
2428
2706
|
const plan = goal?.executionPlan;
|
|
2429
|
-
const
|
|
2430
|
-
const
|
|
2431
|
-
|
|
2707
|
+
const runAgent = workerAgent(task.agent ?? goal?.agent);
|
|
2708
|
+
const runModel = task.model ?? goal?.model;
|
|
2709
|
+
const runEffort = task.effort ?? goal?.effort;
|
|
2710
|
+
const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goalSessionFor(goal, runAgent) ?? undefined);
|
|
2711
|
+
const runContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode, cwd: workDir, resumeSession, plan });
|
|
2712
|
+
const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, null, runAgent, runContext)].filter(Boolean).join('\n\n');
|
|
2713
|
+
sendWorkerContextAct(task, runContext);
|
|
2714
|
+
sendProcessAct(task, `Starting ${runAgent === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
|
|
2432
2715
|
let r = await runWorkerAgent({
|
|
2433
|
-
agent:
|
|
2716
|
+
agent: runAgent,
|
|
2434
2717
|
prompt, cwd: workDir, tools: WORKER_TOOLS,
|
|
2435
2718
|
resume: resumeSession, permissionMode,
|
|
2436
|
-
model:
|
|
2719
|
+
model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
|
|
2437
2720
|
images: goal?.images,
|
|
2438
2721
|
onTodos: (todos) => sendTodos(task, todos),
|
|
2439
2722
|
onChild: (c) => trackGoalWorker(goal?.id, c),
|
|
@@ -2448,18 +2731,21 @@ async function runTask(task) {
|
|
|
2448
2731
|
// "succeeded".
|
|
2449
2732
|
let budget = trackGoalRunUsage(goal, Date.now() - callT0, r.usage);
|
|
2450
2733
|
if (budget?.exceeded) { blockForBudget(goal, task, budget); return; }
|
|
2451
|
-
if (r.code !== 0 && goal
|
|
2734
|
+
if (r.code !== 0 && goalSessionFor(goal, runAgent)) {
|
|
2452
2735
|
// The warm session can expire or be cleaned up between tasks; one cold
|
|
2453
2736
|
// retry distinguishes "resume broke" from "the task itself failed".
|
|
2454
2737
|
sendAct(task, 'warm session unavailable — retrying with a fresh session…');
|
|
2455
|
-
goal.
|
|
2738
|
+
if (goal?.sessions && typeof goal.sessions === 'object') delete goal.sessions[runAgent];
|
|
2739
|
+
if (goal) { goal.sessionId = null; goal.sessionAgent = runAgent; saveGoal(goal); }
|
|
2456
2740
|
callT0 = Date.now();
|
|
2457
2741
|
sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
|
|
2742
|
+
const freshContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode, cwd: workDir, resumeSession: undefined, plan: { ...plan, sessionMode: 'cold' } });
|
|
2743
|
+
sendWorkerContextAct(task, freshContext);
|
|
2458
2744
|
r = await runWorkerAgent({
|
|
2459
|
-
agent:
|
|
2460
|
-
prompt: workerPrompt(task, goal, null,
|
|
2745
|
+
agent: runAgent,
|
|
2746
|
+
prompt: workerPrompt(task, goal, null, runAgent, freshContext), cwd: workDir, tools: WORKER_TOOLS,
|
|
2461
2747
|
permissionMode,
|
|
2462
|
-
model:
|
|
2748
|
+
model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
|
|
2463
2749
|
images: goal?.images,
|
|
2464
2750
|
onTodos: (todos) => sendTodos(task, todos),
|
|
2465
2751
|
onChild: (c) => trackGoalWorker(goal?.id, c),
|
|
@@ -2475,7 +2761,7 @@ async function runTask(task) {
|
|
|
2475
2761
|
sendProcessAct(task, timedOut
|
|
2476
2762
|
? `Worker attempt ${attempt} was stopped at the ${Math.round(WORKER_TIMEOUT_MS / 60000)}-minute run limit (exit ${code}).`
|
|
2477
2763
|
: `Worker attempt ${attempt} finished with exit code ${code}.`);
|
|
2478
|
-
if (r.sessionId && goal) { goal
|
|
2764
|
+
if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
|
|
2479
2765
|
if (!entryUrl) break;
|
|
2480
2766
|
try {
|
|
2481
2767
|
if (!verify) {
|
|
@@ -2586,6 +2872,7 @@ async function runTask(task) {
|
|
|
2586
2872
|
task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
2587
2873
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
|
|
2588
2874
|
task.finishedAt = new Date().toISOString();
|
|
2875
|
+
task.resultAttemptId = task.attemptId;
|
|
2589
2876
|
saveTask(task);
|
|
2590
2877
|
sendProcessAct(task, `Task saved as ${task.status}.`);
|
|
2591
2878
|
|
|
@@ -2916,6 +3203,23 @@ function goalChangedFileUnion(goalId) {
|
|
|
2916
3203
|
return [...new Set(tasks.filter((t) => t.goalId === goalId).flatMap((t) => t.changedFiles ?? []))]
|
|
2917
3204
|
.filter((f) => f && !SCRATCH_FILE_RE.test(f));
|
|
2918
3205
|
}
|
|
3206
|
+
function computeWorktreeChangeLocations(dir) {
|
|
3207
|
+
try {
|
|
3208
|
+
return parseGitUnifiedDiffLocations(gitOut(dir, ['diff', '--unified=0']) ?? '');
|
|
3209
|
+
} catch {
|
|
3210
|
+
return [];
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
function foldCandidateShape(g) {
|
|
3214
|
+
return {
|
|
3215
|
+
id: g.id,
|
|
3216
|
+
changedFiles: goalChangedFileUnion(g.id),
|
|
3217
|
+
changeLocations: g.changeLocations ?? [],
|
|
3218
|
+
text: g.text,
|
|
3219
|
+
title: g.reviewSummary?.title ?? g.plan?.[0],
|
|
3220
|
+
summary: g.reviewSummary?.changed ?? '',
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
2919
3223
|
const CONFLICT_CANDIDATE_STATUSES = ['running', 'review', 'blocked', 'partial', 'retesting'];
|
|
2920
3224
|
function updateGoalConflicts(goal) {
|
|
2921
3225
|
const files = goalChangedFileUnion(goal.id);
|
|
@@ -2946,6 +3250,10 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
2946
3250
|
if (!siblings.length || !siblings.every((t) => ['done', 'failed', 'interrupted', 'skipped'].includes(t.status))) return;
|
|
2947
3251
|
if (!['running', 'partial'].includes(goal.status)) { startNextStacked(goal.projectId); return; }
|
|
2948
3252
|
goalAct(`Close-out started for goal-${goal.id}: ${siblings.length} task${siblings.length === 1 ? '' : 's'} ready for final checks.`);
|
|
3253
|
+
if (activityTask?.attemptId) {
|
|
3254
|
+
goal.closeoutAttemptId = activityTask.attemptId;
|
|
3255
|
+
goal.latestAttemptId = activityTask.attemptId;
|
|
3256
|
+
}
|
|
2949
3257
|
// Plan mode: the worker produced a PLAN and made no edits. Land it in Review
|
|
2950
3258
|
// as a plan-review (no PR, no proof gate — nothing changed) with the plan
|
|
2951
3259
|
// body captured, so the reviewer sees "PLAN — approve to execute". Approving
|
|
@@ -3012,15 +3320,16 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3012
3320
|
// (that would be an instant loop, since the target still sits in review with
|
|
3013
3321
|
// the overlapping files). The card then opens its own PR and both stay in
|
|
3014
3322
|
// Review, flagged via conflictsWith — the visible collision Masa asked for.
|
|
3015
|
-
|
|
3323
|
+
goal.changeLocations = computeWorktreeChangeLocations(wdir);
|
|
3324
|
+
const foldTarget = (autoFoldReviewsEnabled(project) && conflicts.length && !goal.noFold)
|
|
3016
3325
|
? goals.find((g) => g.id === pickFoldTarget(
|
|
3017
|
-
|
|
3326
|
+
foldCandidateShape(goal),
|
|
3018
3327
|
goals
|
|
3019
3328
|
.filter((c) => c.id !== goal.id
|
|
3020
3329
|
&& c.projectId === goal.projectId
|
|
3021
3330
|
&& c.status === 'review'
|
|
3022
3331
|
&& (c.owner ?? 'owner') === (goal.owner ?? 'owner'))
|
|
3023
|
-
.map(
|
|
3332
|
+
.map(foldCandidateShape),
|
|
3024
3333
|
))
|
|
3025
3334
|
: null;
|
|
3026
3335
|
if (foldTarget) {
|
|
@@ -3048,6 +3357,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3048
3357
|
// time the PR is opened, not only on the review card (Masa 2026-07-16).
|
|
3049
3358
|
goalAct('Writing the review summary from the worker report and diff.');
|
|
3050
3359
|
goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
|
|
3360
|
+
if (activityTask?.attemptId) goal.reviewSummaryAttemptId = activityTask.attemptId;
|
|
3051
3361
|
goalAct('Checking changed files and preparing the PR branch if code changed.');
|
|
3052
3362
|
await createGoalPR(goal, prProject, siblings, reviewDefinition, activityTask);
|
|
3053
3363
|
// goal 427 §4: classify the change's risk from the SAME changed-files/diff
|
|
@@ -3056,6 +3366,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3056
3366
|
// string/regex matching (classifyChangeRisk in lib.mjs).
|
|
3057
3367
|
goal.risk = computeGoalRisk(goal, prProject, siblings);
|
|
3058
3368
|
goal.summary = buildGoalSummary({ goalText: goal.text, tasks: siblings });
|
|
3369
|
+
if (activityTask?.attemptId) goal.summaryAttemptId = activityTask.attemptId;
|
|
3059
3370
|
await verifyGate(goal, prProject, siblings, project, activityTask);
|
|
3060
3371
|
// A high-risk change (secrets/auth/payment/db-migration/destructive) must
|
|
3061
3372
|
// never slip past a human — verifyGate's nextGoalStatus never returns
|
|
@@ -3067,8 +3378,10 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3067
3378
|
if (goal.risk?.shouldPause && !['blocked', 'running'].includes(goal.status)) goal.status = 'review';
|
|
3068
3379
|
if (['review', 'done', 'blocked', 'archived'].includes(goal.status)) {
|
|
3069
3380
|
refreshGoalHandoff(goal);
|
|
3070
|
-
if (goal.
|
|
3381
|
+
if (goalSessionFor(goal, goal.agent) && shouldFreshSessionForPlan(goal.executionPlan)) {
|
|
3382
|
+
if (goal.sessions && typeof goal.sessions === 'object') delete goal.sessions[goal.agent];
|
|
3071
3383
|
goal.sessionId = null;
|
|
3384
|
+
goal.sessionAgent = goal.agent;
|
|
3072
3385
|
goal.sessionClosedAt = new Date().toISOString();
|
|
3073
3386
|
goal.executionPlan = {
|
|
3074
3387
|
...(goal.executionPlan ?? {}),
|
|
@@ -3095,6 +3408,13 @@ function computeGoalRisk(goal, project, siblings) {
|
|
|
3095
3408
|
return { level: r.level, shouldPause: r.shouldPause, categories: r.categories, sensitiveFiles: r.sensitiveFiles };
|
|
3096
3409
|
}
|
|
3097
3410
|
|
|
3411
|
+
function prSafetyIssue(goal, project, files) {
|
|
3412
|
+
const baseline = goal?.prBaseline;
|
|
3413
|
+
const currentBranch = gitSyncOrNull(project.dir, ['branch', '--show-current']);
|
|
3414
|
+
const lines = spawnSync('git', ['-C', project.dir, 'status', '--porcelain', '-uall'], { encoding: 'utf8' }).stdout?.split('\n').filter(Boolean) ?? [];
|
|
3415
|
+
return classifyPrSafety({ baseline, files, currentFiles: gitStatusPaths(lines), currentBranch });
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3098
3418
|
// The Manager-verified review gate (shared by goal completion and /reverify):
|
|
3099
3419
|
// run OUR OWN tests, generate a plain-language headline, and require proof for a
|
|
3100
3420
|
// UI change — capturing it (3 tries) if missing. Any failure → 'blocked' (shown
|
|
@@ -3267,7 +3587,9 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
3267
3587
|
: gate.proofAdvisory === 'unconfirmed'
|
|
3268
3588
|
? 'proof は撮れましたが、依頼対象のUIを自動確認できませんでした — 目視で確認してください'
|
|
3269
3589
|
: null;
|
|
3590
|
+
goal.verificationInfraError = verificationInfraErrorFrom({ testResult: goal.testResult, proofNote: goal.proofNote });
|
|
3270
3591
|
if (goal.proofNote) goalAct(`Proof advisory (not blocking): ${goal.proofNote}`);
|
|
3592
|
+
if (goal.verificationInfraError) goalAct(`Verification infrastructure issue (not blocking): ${goal.verificationInfraError.detail}`);
|
|
3271
3593
|
if (gate.blocked) {
|
|
3272
3594
|
if (!testsFailing) {
|
|
3273
3595
|
recordGoalFailure(goal, {
|
|
@@ -3311,6 +3633,14 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
|
|
|
3311
3633
|
// round can't actually hit this — its files are a superset of the ones that
|
|
3312
3634
|
// already got it a PR — but the guard is harmless either way.)
|
|
3313
3635
|
if (!files.length) { goal.pr = null; goal.prError = null; saveGoal(goal); goalAct('No code changes detected, so PR creation is skipped.'); return; }
|
|
3636
|
+
const unsafe = prSafetyIssue(goal, project, files);
|
|
3637
|
+
if (unsafe) {
|
|
3638
|
+
goal.prError = unsafe.message.slice(0, 300);
|
|
3639
|
+
goal.prSafety = { code: unsafe.code, message: unsafe.message, at: new Date().toISOString() };
|
|
3640
|
+
goalAct(unsafe.message);
|
|
3641
|
+
saveGoal(goal);
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
3314
3644
|
// Rework: the goal already has an open PR from an earlier finish. Push onto
|
|
3315
3645
|
// that same branch instead of opening a second PR for the goal (see openPR's
|
|
3316
3646
|
// existingBranch/existingPrUrl mode in pr.mjs).
|
|
@@ -3362,6 +3692,12 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
|
|
|
3362
3692
|
goal.pr = url;
|
|
3363
3693
|
goal.prBranch = branch;
|
|
3364
3694
|
goal.prError = null;
|
|
3695
|
+
goal.prErrorClass = null;
|
|
3696
|
+
goal.prRepair = null;
|
|
3697
|
+
goal.stalePr = null;
|
|
3698
|
+
goal.prSafety = null;
|
|
3699
|
+
if (activityTask?.attemptId) goal.prAttemptId = activityTask.attemptId;
|
|
3700
|
+
if (!goal.prAttemptId) goal.prAttemptId = goal.latestAttemptId ?? goalTasks.map((t) => t.attemptId || `task-${t.id}`).filter(Boolean).at(-1) ?? null;
|
|
3365
3701
|
goalAct(`PR ready: ${url}`);
|
|
3366
3702
|
} catch (e) {
|
|
3367
3703
|
// A rework push failing shouldn't blow away an already-working PR url —
|
|
@@ -3448,6 +3784,16 @@ const server = createServer(async (req, res) => {
|
|
|
3448
3784
|
return res.end(readFileSync(file));
|
|
3449
3785
|
}
|
|
3450
3786
|
|
|
3787
|
+
if (url.pathname === '/api/internal/quality-metrics' && req.method === 'GET') {
|
|
3788
|
+
if (identityFor(req) !== 'owner') return json(res, 403, { error: 'owner/debug only' });
|
|
3789
|
+
return json(res, 200, computeInternalQualityMetrics({
|
|
3790
|
+
goals,
|
|
3791
|
+
tasks,
|
|
3792
|
+
events: readInternalQualityEvents(),
|
|
3793
|
+
nowMs: Date.now(),
|
|
3794
|
+
}));
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3451
3797
|
if (url.pathname === '/api/state') {
|
|
3452
3798
|
await syncAllExternal();
|
|
3453
3799
|
await syncGoalMerges();
|
|
@@ -3493,7 +3839,8 @@ const server = createServer(async (req, res) => {
|
|
|
3493
3839
|
const projectsWithBranch = projects.map((p) => {
|
|
3494
3840
|
let branch = null;
|
|
3495
3841
|
try { branch = gitSync(p.dir, ['rev-parse', '--abbrev-ref', 'HEAD']); } catch { /* not a repo → no branch, and the bar shows none */ }
|
|
3496
|
-
|
|
3842
|
+
const dirty = projectDirtyInfo(p.dir);
|
|
3843
|
+
return { ...p, ...(branch ? { branch } : {}), ...dirty };
|
|
3497
3844
|
});
|
|
3498
3845
|
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() });
|
|
3499
3846
|
}
|
|
@@ -3644,7 +3991,7 @@ const server = createServer(async (req, res) => {
|
|
|
3644
3991
|
const model = CODEX_MODELS[0];
|
|
3645
3992
|
for (const task of queued) { task.agent = 'codex'; task.model = model; task.sessionId = null; saveTask(task); }
|
|
3646
3993
|
for (const goal of goals.filter((item) => goalIds.has(item.id))) {
|
|
3647
|
-
goal
|
|
3994
|
+
switchGoalAgentSession(goal, 'codex'); goal.model = model; goal.startedAt = null; saveGoal(goal);
|
|
3648
3995
|
}
|
|
3649
3996
|
for (const projectId of new Set(queued.map((task) => task.projectId))) pump(projectId);
|
|
3650
3997
|
return json(res, 200, { changed: queued.length, goals: [...goalIds] });
|
|
@@ -3808,7 +4155,7 @@ const server = createServer(async (req, res) => {
|
|
|
3808
4155
|
req.on('data', (d) => { body += d; });
|
|
3809
4156
|
req.on('end', async () => {
|
|
3810
4157
|
try {
|
|
3811
|
-
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree } = JSON.parse(body);
|
|
4158
|
+
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree, workspaceMode } = JSON.parse(body);
|
|
3812
4159
|
const project = projects.find((p) => p.id === projectId);
|
|
3813
4160
|
if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
|
|
3814
4161
|
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
|
|
@@ -3923,9 +4270,11 @@ const server = createServer(async (req, res) => {
|
|
|
3923
4270
|
// present would otherwise spawn a worker doomed to fail at launch.
|
|
3924
4271
|
const goalAgent = pickAvailableAgent(workerAgent(agent), AVAILABLE_AGENTS);
|
|
3925
4272
|
const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
|
|
4273
|
+
const resolvedWorkspaceMode = resolveWorkspaceMode(workspaceMode ?? worktree);
|
|
4274
|
+
const workspaceDirty = resolvedWorkspaceMode === 'direct-workspace' ? projectDirtyInfo(project.dir) : {};
|
|
3926
4275
|
const goal = {
|
|
3927
4276
|
id: nextId++, projectId, text: text.trim().slice(0, 8000),
|
|
3928
|
-
status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : '
|
|
4277
|
+
status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'stacked',
|
|
3929
4278
|
question: folderQuestion || reviewPrefQuestion,
|
|
3930
4279
|
reviewOnly: review ? true : undefined,
|
|
3931
4280
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
@@ -3938,11 +4287,16 @@ const server = createServer(async (req, res) => {
|
|
|
3938
4287
|
mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
|
|
3939
4288
|
// SKILL: an optional user/project skill (or command) to invoke first.
|
|
3940
4289
|
skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
|
|
3941
|
-
//
|
|
3942
|
-
//
|
|
3943
|
-
//
|
|
3944
|
-
//
|
|
3945
|
-
|
|
4290
|
+
// WORKSPACE MODE: isolated-worktree is the default and uses a clean per-goal
|
|
4291
|
+
// checkout; direct-workspace is explicit and runs in the folder the person is
|
|
4292
|
+
// looking at, inheriting its current dirty/clean state. Keep the old
|
|
4293
|
+
// worktree:false wire contract too, so existing clients and old goals retain
|
|
4294
|
+
// their behavior while Review/PR can show the explicit mode name.
|
|
4295
|
+
workspaceMode: resolvedWorkspaceMode,
|
|
4296
|
+
worktree: resolvedWorkspaceMode === 'direct-workspace' ? false : undefined,
|
|
4297
|
+
workspaceWarning: workspaceDirty.dirty
|
|
4298
|
+
? `Dirty workspace warning: direct workspace will inherit current uncommitted changes (${(workspaceDirty.dirtyPaths ?? []).join(', ')}).`
|
|
4299
|
+
: undefined,
|
|
3946
4300
|
images: Array.isArray(images) ? images.slice(0, 6) : [],
|
|
3947
4301
|
source: resolveGoalSource(source), sourceRef: null,
|
|
3948
4302
|
executionPlan: buildExecutionPlan({
|
|
@@ -3965,11 +4319,7 @@ const server = createServer(async (req, res) => {
|
|
|
3965
4319
|
// pending = deliberately shelved (Phase 2 material): never planned,
|
|
3966
4320
|
// never queued, until POST /api/goals/:id/activate. review-only items
|
|
3967
4321
|
// are terminal-until-human too, so they also skip planGoal.
|
|
3968
|
-
if (runnable && !needsFolder && !needsReviewPref)
|
|
3969
|
-
planGoal(goal).catch((e) => {
|
|
3970
|
-
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
3971
|
-
});
|
|
3972
|
-
}
|
|
4322
|
+
if (runnable && !needsFolder && !needsReviewPref) startNextStacked(goal.projectId);
|
|
3973
4323
|
json(res, 200, goal);
|
|
3974
4324
|
} catch { json(res, 400, { error: 'bad json' }); }
|
|
3975
4325
|
});
|
|
@@ -4062,6 +4412,7 @@ const server = createServer(async (req, res) => {
|
|
|
4062
4412
|
if (!task) return json(res, 404, { error: 'task not found' });
|
|
4063
4413
|
if (!requireTaskGoalOwnership(req, res, task)) return;
|
|
4064
4414
|
if (!['interrupted', 'failed'].includes(task.status)) return json(res, 409, { error: 'only interrupted/failed tasks can be retried' });
|
|
4415
|
+
appendInternalQualityEvent({ type: 'task_retry_requested', taskId: task.id, goalId: task.goalId, projectId: task.projectId }, { once: false });
|
|
4065
4416
|
task.status = 'queued'; task.result = null; task.secs = null; task.proof = null;
|
|
4066
4417
|
saveTask(task);
|
|
4067
4418
|
const goal = goals.find((g) => g.id === task.goalId);
|
|
@@ -4108,7 +4459,7 @@ const server = createServer(async (req, res) => {
|
|
|
4108
4459
|
saveTask(task);
|
|
4109
4460
|
}
|
|
4110
4461
|
for (const goal of goals.filter((item) => goalIds.has(item.id))) {
|
|
4111
|
-
goal
|
|
4462
|
+
switchGoalAgentSession(goal, agent); goal.model = model; goal.effort = effort; goal.startedAt = null;
|
|
4112
4463
|
saveGoal(goal);
|
|
4113
4464
|
}
|
|
4114
4465
|
pump(projectId);
|
|
@@ -4129,7 +4480,7 @@ const server = createServer(async (req, res) => {
|
|
|
4129
4480
|
const queuedTasks = tasks.filter((task) => task.goalId === goal.id && task.status === 'queued');
|
|
4130
4481
|
if (!queuedTasks.length) return json(res, 409, { error: 'this goal has no queued work' });
|
|
4131
4482
|
const model = CODEX_MODELS[0];
|
|
4132
|
-
goal
|
|
4483
|
+
switchGoalAgentSession(goal, 'codex'); goal.model = model; goal.startedAt = null;
|
|
4133
4484
|
saveGoal(goal);
|
|
4134
4485
|
for (const task of queuedTasks) {
|
|
4135
4486
|
task.agent = 'codex'; task.model = model; task.sessionId = null;
|
|
@@ -4153,9 +4504,26 @@ const server = createServer(async (req, res) => {
|
|
|
4153
4504
|
if (!task) return json(res, 404, { error: 'task not found' });
|
|
4154
4505
|
if (!requireTaskGoalOwnership(req, res, task)) return;
|
|
4155
4506
|
if (!task.run) return json(res, 409, { error: 'no run declaration for this task' });
|
|
4507
|
+
const goal = goals.find((g) => g.id === task.goalId);
|
|
4156
4508
|
startPreview(task)
|
|
4157
|
-
.then((p) =>
|
|
4158
|
-
|
|
4509
|
+
.then((p) => {
|
|
4510
|
+
if (goal?.verificationInfraError?.source === 'preview') {
|
|
4511
|
+
goal.verificationInfraError = null;
|
|
4512
|
+
saveGoal(goal);
|
|
4513
|
+
}
|
|
4514
|
+
json(res, 200, p);
|
|
4515
|
+
})
|
|
4516
|
+
.catch((e) => {
|
|
4517
|
+
const detail = String(e.message ?? e).slice(0, 200);
|
|
4518
|
+
if (goal && goal.status === 'review') {
|
|
4519
|
+
goal.verificationInfraError = buildVerificationInfraError({
|
|
4520
|
+
source: 'preview',
|
|
4521
|
+
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.`,
|
|
4522
|
+
});
|
|
4523
|
+
saveGoal(goal);
|
|
4524
|
+
}
|
|
4525
|
+
json(res, 500, { error: detail });
|
|
4526
|
+
});
|
|
4159
4527
|
return;
|
|
4160
4528
|
}
|
|
4161
4529
|
|
|
@@ -4331,6 +4699,7 @@ const server = createServer(async (req, res) => {
|
|
|
4331
4699
|
goal.foldedInto = null;
|
|
4332
4700
|
goal.noFold = true;
|
|
4333
4701
|
goal.status = 'running'; // let finishGoalIfComplete re-run close-out (its tasks are all done → opens its own PR)
|
|
4702
|
+
appendInternalQualityEvent({ type: 'goal_fold_unfolded', goalId: goal.id, projectId: goal.projectId }, { once: false });
|
|
4334
4703
|
saveGoal(goal);
|
|
4335
4704
|
finishGoalIfComplete(goal, project).catch((e) => {
|
|
4336
4705
|
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
@@ -4346,8 +4715,8 @@ const server = createServer(async (req, res) => {
|
|
|
4346
4715
|
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
4347
4716
|
if (!requireGoalOwnership(req, res, goal)) return;
|
|
4348
4717
|
if (goal.status !== 'pending') return json(res, 409, { error: 'only pending goals can be activated' });
|
|
4349
|
-
goal.status = '
|
|
4350
|
-
|
|
4718
|
+
goal.status = 'stacked'; saveGoal(goal);
|
|
4719
|
+
startNextStacked(goal.projectId);
|
|
4351
4720
|
return json(res, 200, goal);
|
|
4352
4721
|
}
|
|
4353
4722
|
const pendMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/pend$/);
|
|
@@ -4383,13 +4752,14 @@ const server = createServer(async (req, res) => {
|
|
|
4383
4752
|
if (!proj) {
|
|
4384
4753
|
proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
|
|
4385
4754
|
projects.push(proj); saveProjects();
|
|
4755
|
+
queues.set(proj.id, { running: new Set(), waiting: [] });
|
|
4386
4756
|
}
|
|
4387
4757
|
goal.projectId = proj.id;
|
|
4388
4758
|
goal.question = null;
|
|
4389
4759
|
goal.clarified = true;
|
|
4390
|
-
goal.status = '
|
|
4760
|
+
goal.status = 'stacked';
|
|
4391
4761
|
saveGoal(goal);
|
|
4392
|
-
|
|
4762
|
+
startNextStacked(goal.projectId);
|
|
4393
4763
|
return json(res, 200, goal);
|
|
4394
4764
|
};
|
|
4395
4765
|
// Intent router (no free-text box is a dead-end). Before any per-kind
|
|
@@ -4512,17 +4882,17 @@ const server = createServer(async (req, res) => {
|
|
|
4512
4882
|
goal.wantsPR = resolveWantsPR(undefined, goal.text, patch.defaultWantsPR);
|
|
4513
4883
|
goal.question = null;
|
|
4514
4884
|
goal.clarified = true;
|
|
4515
|
-
goal.status = '
|
|
4885
|
+
goal.status = 'stacked';
|
|
4516
4886
|
saveGoal(goal);
|
|
4517
|
-
|
|
4887
|
+
startNextStacked(goal.projectId);
|
|
4518
4888
|
return json(res, 200, goal);
|
|
4519
4889
|
}
|
|
4520
4890
|
goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
|
|
4521
4891
|
goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
|
|
4522
4892
|
goal.question = null;
|
|
4523
|
-
goal.status = '
|
|
4893
|
+
goal.status = 'stacked';
|
|
4524
4894
|
saveGoal(goal);
|
|
4525
|
-
|
|
4895
|
+
startNextStacked(goal.projectId);
|
|
4526
4896
|
json(res, 200, goal);
|
|
4527
4897
|
});
|
|
4528
4898
|
return;
|
|
@@ -4624,6 +4994,7 @@ const server = createServer(async (req, res) => {
|
|
|
4624
4994
|
goal.autoRework = { ...(goal.autoRework ?? {}), testFailures: 0, manualRetryAt: new Date().toISOString() };
|
|
4625
4995
|
saveGoal(goal);
|
|
4626
4996
|
const task = queueReplyTask(goal, prompt, { from: 'manager', via: 'retry' });
|
|
4997
|
+
appendInternalQualityEvent({ type: 'task_retry_requested', taskId: task.id, goalId: goal.id, projectId: goal.projectId }, { once: false });
|
|
4627
4998
|
return json(res, 200, { goal, task, repairing: true });
|
|
4628
4999
|
}
|
|
4629
5000
|
const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
|
|
@@ -4782,7 +5153,7 @@ async function judgeReplyIntent(goal, reply) {
|
|
|
4782
5153
|
|
|
4783
5154
|
// 「聞いてるだけ」への返事 (ONE-CONVERSATION.md §1.3 answer)。
|
|
4784
5155
|
//
|
|
4785
|
-
// 答えるのは**作業した AI
|
|
5156
|
+
// 答えるのは**作業した AI 本人**=その agent の session を resume する。状況一覧しか
|
|
4786
5157
|
// 知らない別AIでは「なぜそうしたか」に答えられない(Masa確定 2026-07-22)。
|
|
4787
5158
|
// コードは1文字も変わらない: ツールは Read だけ、permissionMode は 'plan'
|
|
4788
5159
|
// (Codex 側は read-only サンドボックスに落ちる)。タスクも作らないので看板も
|
|
@@ -4803,7 +5174,7 @@ async function answerGoalQuestion(goal, question) {
|
|
|
4803
5174
|
const r = await runWorkerAgent({
|
|
4804
5175
|
agent: goal.agent, model: goal.model, prompt, cwd,
|
|
4805
5176
|
tools: 'Read', permissionMode: 'plan',
|
|
4806
|
-
resume: goal.
|
|
5177
|
+
resume: goalSessionFor(goal, goal.agent) ?? undefined,
|
|
4807
5178
|
onChild: (c) => trackGoalWorker(goal.id, c),
|
|
4808
5179
|
});
|
|
4809
5180
|
const answer = String(r?.result ?? '').trim();
|
|
@@ -4870,7 +5241,7 @@ async function answerGoalQuestion(goal, question) {
|
|
|
4870
5241
|
}
|
|
4871
5242
|
|
|
4872
5243
|
// answer: 聞かれただけ。ゴールは review のまま、ワーカーも起こさない(§1.3)。
|
|
4873
|
-
// 答えるのは**その作業をやった AI 本人**(
|
|
5244
|
+
// 答えるのは**その作業をやった AI 本人**(agent別sessionを resume)なので
|
|
4874
5245
|
// 「なぜそうしたか」に答えられる。HTTP は待たせない——答えは会話に1発言
|
|
4875
5246
|
// 積まれ、SSE の goal イベント(messageCount)で画面に出る。
|
|
4876
5247
|
if (result.answers) {
|
|
@@ -5092,27 +5463,22 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
5092
5463
|
setInterval(() => {
|
|
5093
5464
|
send({ ev: 'ping', uiVersion: uiVersion() });
|
|
5094
5465
|
syncGoalMerges().catch(() => {});
|
|
5095
|
-
for (const p of projects) {
|
|
5096
|
-
pump(p.id);
|
|
5097
|
-
for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
|
|
5098
|
-
g.status = 'planning'; saveGoal(g);
|
|
5099
|
-
planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
|
|
5100
|
-
}
|
|
5101
|
-
}
|
|
5102
5466
|
}, 30_000).unref();
|
|
5467
|
+
// Queue liveness is a product invariant, not something an SSE refresh or a
|
|
5468
|
+
// person pressing Retry should have to restore. Five seconds keeps recovery
|
|
5469
|
+
// quick without doing meaningful work while idle.
|
|
5470
|
+
setInterval(() => {
|
|
5471
|
+
for (const p of projects) reconcileAndPumpProject(p.id);
|
|
5472
|
+
}, 5_000).unref();
|
|
5103
5473
|
for (const p of projects) {
|
|
5104
5474
|
const pending = tasks.filter((t) => t.projectId === p.id && t.status === 'queued').sort((a, b) => a.num - b.num);
|
|
5105
5475
|
const q = queues.get(p.id);
|
|
5106
5476
|
reconcileQueuedGoalStarts(p.id);
|
|
5107
5477
|
q.waiting.push(...pending);
|
|
5108
5478
|
if (pending.length) console.log(`[manager] ${p.id}: re-queued ${pending.length} pending task(s) after restart`);
|
|
5109
|
-
pump(p.id);
|
|
5110
5479
|
// breakdown-first: any goal still sitting 'stacked' (legacy or crash
|
|
5111
|
-
// leftovers) gets planned immediately so it can never rot in the queue
|
|
5112
|
-
|
|
5113
|
-
g.status = 'planning'; saveGoal(g);
|
|
5114
|
-
planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
|
|
5115
|
-
}
|
|
5480
|
+
// leftovers) gets planned immediately so it can never rot in the queue.
|
|
5481
|
+
reconcileAndPumpProject(p.id);
|
|
5116
5482
|
// A restart must not orphan an in-flight Ledger retest (v45 §5.3): the
|
|
5117
5483
|
// intent is persisted as status 'retesting' in tasks.jsonl (replayQueueLog
|
|
5118
5484
|
// passes it through untouched) — resume the job from run 1.
|