@ttfw/envoi 1.0.0
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/README.md +238 -0
- package/dist/commands/app.d.ts +2 -0
- package/dist/commands/app.d.ts.map +1 -0
- package/dist/commands/app.js +31 -0
- package/dist/commands/app.js.map +1 -0
- package/dist/commands/autonomy.d.ts +6 -0
- package/dist/commands/autonomy.d.ts.map +1 -0
- package/dist/commands/autonomy.js +89 -0
- package/dist/commands/autonomy.js.map +1 -0
- package/dist/commands/builder.d.ts +13 -0
- package/dist/commands/builder.d.ts.map +1 -0
- package/dist/commands/builder.js +142 -0
- package/dist/commands/builder.js.map +1 -0
- package/dist/commands/idea.d.ts +12 -0
- package/dist/commands/idea.d.ts.map +1 -0
- package/dist/commands/idea.js +79 -0
- package/dist/commands/idea.js.map +1 -0
- package/dist/commands/init.d.ts +18 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +423 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/mode.d.ts +13 -0
- package/dist/commands/mode.d.ts.map +1 -0
- package/dist/commands/mode.js +96 -0
- package/dist/commands/mode.js.map +1 -0
- package/dist/commands/onboard.d.ts +37 -0
- package/dist/commands/onboard.d.ts.map +1 -0
- package/dist/commands/onboard.js +743 -0
- package/dist/commands/onboard.js.map +1 -0
- package/dist/commands/pr-note.d.ts +8 -0
- package/dist/commands/pr-note.d.ts.map +1 -0
- package/dist/commands/pr-note.js +27 -0
- package/dist/commands/pr-note.js.map +1 -0
- package/dist/commands/undo.d.ts +7 -0
- package/dist/commands/undo.d.ts.map +1 -0
- package/dist/commands/undo.js +59 -0
- package/dist/commands/undo.js.map +1 -0
- package/dist/commands/update.d.ts +24 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +248 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/constants/report_codes.d.ts +29 -0
- package/dist/constants/report_codes.d.ts.map +1 -0
- package/dist/constants/report_codes.js +69 -0
- package/dist/constants/report_codes.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +675 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/autonomy.d.ts +16 -0
- package/dist/lib/autonomy.d.ts.map +1 -0
- package/dist/lib/autonomy.js +38 -0
- package/dist/lib/autonomy.js.map +1 -0
- package/dist/lib/blocked.d.ts +87 -0
- package/dist/lib/blocked.d.ts.map +1 -0
- package/dist/lib/blocked.js +134 -0
- package/dist/lib/blocked.js.map +1 -0
- package/dist/lib/branding.d.ts +13 -0
- package/dist/lib/branding.d.ts.map +1 -0
- package/dist/lib/branding.js +19 -0
- package/dist/lib/branding.js.map +1 -0
- package/dist/lib/claude.d.ts +42 -0
- package/dist/lib/claude.d.ts.map +1 -0
- package/dist/lib/claude.js +291 -0
- package/dist/lib/claude.js.map +1 -0
- package/dist/lib/config.d.ts +71 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +410 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/diff.d.ts +150 -0
- package/dist/lib/diff.d.ts.map +1 -0
- package/dist/lib/diff.js +257 -0
- package/dist/lib/diff.js.map +1 -0
- package/dist/lib/doctor.d.ts +67 -0
- package/dist/lib/doctor.d.ts.map +1 -0
- package/dist/lib/doctor.js +211 -0
- package/dist/lib/doctor.js.map +1 -0
- package/dist/lib/fingerprint.d.ts +27 -0
- package/dist/lib/fingerprint.d.ts.map +1 -0
- package/dist/lib/fingerprint.js +116 -0
- package/dist/lib/fingerprint.js.map +1 -0
- package/dist/lib/fs.d.ts +93 -0
- package/dist/lib/fs.d.ts.map +1 -0
- package/dist/lib/fs.js +179 -0
- package/dist/lib/fs.js.map +1 -0
- package/dist/lib/git.d.ts +177 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +355 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/git_branching.d.ts +84 -0
- package/dist/lib/git_branching.d.ts.map +1 -0
- package/dist/lib/git_branching.js +327 -0
- package/dist/lib/git_branching.js.map +1 -0
- package/dist/lib/gitignore.d.ts +26 -0
- package/dist/lib/gitignore.d.ts.map +1 -0
- package/dist/lib/gitignore.js +119 -0
- package/dist/lib/gitignore.js.map +1 -0
- package/dist/lib/guardrails.d.ts +232 -0
- package/dist/lib/guardrails.d.ts.map +1 -0
- package/dist/lib/guardrails.js +323 -0
- package/dist/lib/guardrails.js.map +1 -0
- package/dist/lib/history.d.ts +110 -0
- package/dist/lib/history.d.ts.map +1 -0
- package/dist/lib/history.js +236 -0
- package/dist/lib/history.js.map +1 -0
- package/dist/lib/index.d.ts +29 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +29 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/json-extract.d.ts +42 -0
- package/dist/lib/json-extract.d.ts.map +1 -0
- package/dist/lib/json-extract.js +201 -0
- package/dist/lib/json-extract.js.map +1 -0
- package/dist/lib/judge.d.ts +237 -0
- package/dist/lib/judge.d.ts.map +1 -0
- package/dist/lib/judge.js +501 -0
- package/dist/lib/judge.js.map +1 -0
- package/dist/lib/lock.d.ts +79 -0
- package/dist/lib/lock.d.ts.map +1 -0
- package/dist/lib/lock.js +254 -0
- package/dist/lib/lock.js.map +1 -0
- package/dist/lib/migration.d.ts +9 -0
- package/dist/lib/migration.d.ts.map +1 -0
- package/dist/lib/migration.js +74 -0
- package/dist/lib/migration.js.map +1 -0
- package/dist/lib/paths.d.ts +18 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +27 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/preflight.d.ts +33 -0
- package/dist/lib/preflight.d.ts.map +1 -0
- package/dist/lib/preflight.js +177 -0
- package/dist/lib/preflight.js.map +1 -0
- package/dist/lib/prompt_budget.d.ts +18 -0
- package/dist/lib/prompt_budget.d.ts.map +1 -0
- package/dist/lib/prompt_budget.js +36 -0
- package/dist/lib/prompt_budget.js.map +1 -0
- package/dist/lib/report.d.ts +102 -0
- package/dist/lib/report.d.ts.map +1 -0
- package/dist/lib/report.js +347 -0
- package/dist/lib/report.js.map +1 -0
- package/dist/lib/reviewer-flow.d.ts +80 -0
- package/dist/lib/reviewer-flow.d.ts.map +1 -0
- package/dist/lib/reviewer-flow.js +138 -0
- package/dist/lib/reviewer-flow.js.map +1 -0
- package/dist/lib/reviewer.d.ts +53 -0
- package/dist/lib/reviewer.d.ts.map +1 -0
- package/dist/lib/reviewer.js +199 -0
- package/dist/lib/reviewer.js.map +1 -0
- package/dist/lib/risk.d.ts +127 -0
- package/dist/lib/risk.d.ts.map +1 -0
- package/dist/lib/risk.js +192 -0
- package/dist/lib/risk.js.map +1 -0
- package/dist/lib/rollback.d.ts +143 -0
- package/dist/lib/rollback.d.ts.map +1 -0
- package/dist/lib/rollback.js +244 -0
- package/dist/lib/rollback.js.map +1 -0
- package/dist/lib/schema.d.ts +47 -0
- package/dist/lib/schema.d.ts.map +1 -0
- package/dist/lib/schema.js +91 -0
- package/dist/lib/schema.js.map +1 -0
- package/dist/lib/scope.d.ts +89 -0
- package/dist/lib/scope.d.ts.map +1 -0
- package/dist/lib/scope.js +135 -0
- package/dist/lib/scope.js.map +1 -0
- package/dist/lib/self_update.d.ts +13 -0
- package/dist/lib/self_update.d.ts.map +1 -0
- package/dist/lib/self_update.js +172 -0
- package/dist/lib/self_update.js.map +1 -0
- package/dist/lib/state.d.ts +143 -0
- package/dist/lib/state.d.ts.map +1 -0
- package/dist/lib/state.js +258 -0
- package/dist/lib/state.js.map +1 -0
- package/dist/lib/tick.d.ts +310 -0
- package/dist/lib/tick.d.ts.map +1 -0
- package/dist/lib/tick.js +424 -0
- package/dist/lib/tick.js.map +1 -0
- package/dist/lib/transport.d.ts +145 -0
- package/dist/lib/transport.d.ts.map +1 -0
- package/dist/lib/transport.js +237 -0
- package/dist/lib/transport.js.map +1 -0
- package/dist/lib/verdict_labels.d.ts +5 -0
- package/dist/lib/verdict_labels.d.ts.map +1 -0
- package/dist/lib/verdict_labels.js +25 -0
- package/dist/lib/verdict_labels.js.map +1 -0
- package/dist/lib/verify-safety.d.ts +63 -0
- package/dist/lib/verify-safety.d.ts.map +1 -0
- package/dist/lib/verify-safety.js +123 -0
- package/dist/lib/verify-safety.js.map +1 -0
- package/dist/lib/verify.d.ts +139 -0
- package/dist/lib/verify.d.ts.map +1 -0
- package/dist/lib/verify.js +311 -0
- package/dist/lib/verify.js.map +1 -0
- package/dist/lib/workspace_state.d.ts +79 -0
- package/dist/lib/workspace_state.d.ts.map +1 -0
- package/dist/lib/workspace_state.js +283 -0
- package/dist/lib/workspace_state.js.map +1 -0
- package/dist/runner/builder.d.ts +58 -0
- package/dist/runner/builder.d.ts.map +1 -0
- package/dist/runner/builder.js +775 -0
- package/dist/runner/builder.js.map +1 -0
- package/dist/runner/builder_parse.d.ts +37 -0
- package/dist/runner/builder_parse.d.ts.map +1 -0
- package/dist/runner/builder_parse.js +76 -0
- package/dist/runner/builder_parse.js.map +1 -0
- package/dist/runner/index.d.ts +9 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/index.js +7 -0
- package/dist/runner/index.js.map +1 -0
- package/dist/runner/loop.d.ts +51 -0
- package/dist/runner/loop.d.ts.map +1 -0
- package/dist/runner/loop.js +221 -0
- package/dist/runner/loop.js.map +1 -0
- package/dist/runner/orchestrator.d.ts +67 -0
- package/dist/runner/orchestrator.d.ts.map +1 -0
- package/dist/runner/orchestrator.js +376 -0
- package/dist/runner/orchestrator.js.map +1 -0
- package/dist/runner/tick.d.ts +10 -0
- package/dist/runner/tick.d.ts.map +1 -0
- package/dist/runner/tick.js +1639 -0
- package/dist/runner/tick.js.map +1 -0
- package/dist/types/blocked.d.ts +52 -0
- package/dist/types/blocked.d.ts.map +1 -0
- package/dist/types/blocked.js +8 -0
- package/dist/types/blocked.js.map +1 -0
- package/dist/types/builder.d.ts +25 -0
- package/dist/types/builder.d.ts.map +1 -0
- package/dist/types/builder.js +7 -0
- package/dist/types/builder.js.map +1 -0
- package/dist/types/claude.d.ts +86 -0
- package/dist/types/claude.d.ts.map +1 -0
- package/dist/types/claude.js +48 -0
- package/dist/types/claude.js.map +1 -0
- package/dist/types/config.d.ts +384 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +7 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/lock.d.ts +21 -0
- package/dist/types/lock.d.ts.map +1 -0
- package/dist/types/lock.js +8 -0
- package/dist/types/lock.js.map +1 -0
- package/dist/types/preflight.d.ts +49 -0
- package/dist/types/preflight.d.ts.map +1 -0
- package/dist/types/preflight.js +8 -0
- package/dist/types/preflight.js.map +1 -0
- package/dist/types/report.d.ts +161 -0
- package/dist/types/report.d.ts.map +1 -0
- package/dist/types/report.js +8 -0
- package/dist/types/report.js.map +1 -0
- package/dist/types/reviewer.d.ts +66 -0
- package/dist/types/reviewer.d.ts.map +1 -0
- package/dist/types/reviewer.js +5 -0
- package/dist/types/reviewer.js.map +1 -0
- package/dist/types/state.d.ts +124 -0
- package/dist/types/state.d.ts.map +1 -0
- package/dist/types/state.js +20 -0
- package/dist/types/state.js.map +1 -0
- package/dist/types/task.d.ts +117 -0
- package/dist/types/task.d.ts.map +1 -0
- package/dist/types/task.js +7 -0
- package/dist/types/task.js.map +1 -0
- package/dist/types/workspace_state.d.ts +125 -0
- package/dist/types/workspace_state.d.ts.map +1 -0
- package/dist/types/workspace_state.js +10 -0
- package/dist/types/workspace_state.js.map +1 -0
- package/envoi.config.json +191 -0
- package/package.json +52 -0
- package/relais/prompts/.gitkeep +0 -0
- package/relais/prompts/builder.system.txt +13 -0
- package/relais/prompts/builder.user.txt +15 -0
- package/relais/prompts/orchestrator.system.txt +37 -0
- package/relais/prompts/orchestrator.user.txt +34 -0
- package/relais/prompts/reviewer.system.txt +33 -0
- package/relais/prompts/reviewer.user.txt +35 -0
- package/relais/schemas/.gitkeep +0 -0
- package/relais/schemas/builder_result.schema.json +29 -0
- package/relais/schemas/report.schema.json +195 -0
- package/relais/schemas/reviewer_result.schema.json +70 -0
- package/relais/schemas/task.schema.json +155 -0
|
@@ -0,0 +1,1639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main tick execution runner.
|
|
3
|
+
*
|
|
4
|
+
* Implements the Envoi state machine:
|
|
5
|
+
* LOCK → PREFLIGHT → ORCHESTRATE → BUILD → JUDGE → REPORT → END
|
|
6
|
+
*/
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { isValidReportCode } from '../constants/report_codes.js';
|
|
9
|
+
import { TickPhase } from '../types/state.js';
|
|
10
|
+
import { acquireLock, releaseLock, LockHeldError } from '../lib/lock.js';
|
|
11
|
+
import { runPreflight } from '../lib/preflight.js';
|
|
12
|
+
import { atomicWriteJson } from '../lib/fs.js';
|
|
13
|
+
import { createInitialState, transitionPhase, } from '../lib/state.js';
|
|
14
|
+
import { runOrchestrator } from './orchestrator.js';
|
|
15
|
+
import { requestStop } from './loop.js';
|
|
16
|
+
import { writeBlocked, buildOrchestratorBlockedData, buildBlockedData, deleteBlocked } from '../lib/blocked.js';
|
|
17
|
+
import { renderReportMarkdown, writeReportMarkdown } from '../lib/report.js';
|
|
18
|
+
import { runBuilder } from './builder.js';
|
|
19
|
+
import { isTransportStallError } from '../lib/transport.js';
|
|
20
|
+
import { handleTransportStall } from '../lib/tick.js';
|
|
21
|
+
import { getTouchedFiles, checkScopeViolations, computeBlastRadius, checkDiffLimits, checkHeadMoved, } from '../lib/judge.js';
|
|
22
|
+
import { rollbackToCommit, verifyCleanWorktree } from '../lib/rollback.js';
|
|
23
|
+
import { validateAllParams } from '../lib/verify-safety.js';
|
|
24
|
+
import { readWorkspaceState, writeWorkspaceState, ensureMilestone } from '../lib/workspace_state.js';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import { isInterruptedError, isTimeoutError } from '../types/claude.js';
|
|
27
|
+
import { persistBuilderFailure, persistOrchestratorFailure } from '../lib/history.js';
|
|
28
|
+
import { ensureBranchPerTick, ensureBranchPerNTasks, ensureBranchPerMilestone, } from '../lib/git_branching.js';
|
|
29
|
+
import { runReviewerIfNeeded } from '../lib/reviewer-flow.js';
|
|
30
|
+
import { computeRiskFlags } from '../lib/risk.js';
|
|
31
|
+
const TOKEN_WARNING_ORCHESTRATOR_PREFIX = '[tokens] orchestrator';
|
|
32
|
+
const TOKEN_WARNING_BUILDER_PREFIX = '[tokens] builder';
|
|
33
|
+
const TOKEN_WARNING_TOTAL_PREFIX = '[tokens] tick_total';
|
|
34
|
+
const MAX_WARNING_CHARS = 500;
|
|
35
|
+
let activeTickTokenUsage = null;
|
|
36
|
+
function isDebugEnabled() {
|
|
37
|
+
return process.env.ENVOI_DEBUG === '1';
|
|
38
|
+
}
|
|
39
|
+
function tokenNumber(value) {
|
|
40
|
+
return typeof value === 'number' ? String(value) : 'n/a';
|
|
41
|
+
}
|
|
42
|
+
function formatTokenUsageForLog(usage) {
|
|
43
|
+
if (!usage)
|
|
44
|
+
return 'input=n/a output=n/a total=n/a';
|
|
45
|
+
return `input=${tokenNumber(usage.input_tokens)} output=${tokenNumber(usage.output_tokens)} total=${tokenNumber(usage.total_tokens)}`;
|
|
46
|
+
}
|
|
47
|
+
function pushUniqueWarning(warnings, warning) {
|
|
48
|
+
if (!warnings.includes(warning)) {
|
|
49
|
+
warnings.push(warning);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function compactWarningText(raw, maxChars = MAX_WARNING_CHARS) {
|
|
53
|
+
const normalized = raw.replace(/\s+/g, ' ').trim();
|
|
54
|
+
if (normalized.length <= maxChars)
|
|
55
|
+
return normalized;
|
|
56
|
+
return `${normalized.slice(0, Math.max(0, maxChars - 38))}… [truncated ${normalized.length - maxChars} chars]`;
|
|
57
|
+
}
|
|
58
|
+
function annotateReportWithTokenUsage(report) {
|
|
59
|
+
if (!activeTickTokenUsage)
|
|
60
|
+
return report;
|
|
61
|
+
const warnings = report.budgets.warnings;
|
|
62
|
+
const orchestrator = activeTickTokenUsage.orchestrator;
|
|
63
|
+
const builder = activeTickTokenUsage.builder;
|
|
64
|
+
if (orchestrator) {
|
|
65
|
+
pushUniqueWarning(warnings, `${TOKEN_WARNING_ORCHESTRATOR_PREFIX} input=${tokenNumber(orchestrator.input_tokens)} output=${tokenNumber(orchestrator.output_tokens)} total=${tokenNumber(orchestrator.total_tokens)}`);
|
|
66
|
+
}
|
|
67
|
+
if (builder) {
|
|
68
|
+
pushUniqueWarning(warnings, `${TOKEN_WARNING_BUILDER_PREFIX} input=${tokenNumber(builder.input_tokens)} output=${tokenNumber(builder.output_tokens)} total=${tokenNumber(builder.total_tokens)}`);
|
|
69
|
+
}
|
|
70
|
+
const total = (orchestrator?.total_tokens ?? 0) + (builder?.total_tokens ?? 0);
|
|
71
|
+
const hasKnownTotal = orchestrator?.total_tokens !== null || builder?.total_tokens !== null;
|
|
72
|
+
pushUniqueWarning(warnings, `${TOKEN_WARNING_TOTAL_PREFIX} total=${hasKnownTotal ? total : 'n/a'}`);
|
|
73
|
+
return report;
|
|
74
|
+
}
|
|
75
|
+
function applyPlanningDecisionToWorkspaceState(wsState, task) {
|
|
76
|
+
const planningDecision = task.planning_decision;
|
|
77
|
+
if (!planningDecision)
|
|
78
|
+
return wsState;
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const considered = new Set(planningDecision.idea_ids_considered);
|
|
81
|
+
const mappedStatus = planningDecision.decision === 'defer' ? 'deferred' : 'scheduled';
|
|
82
|
+
const updatedInbox = (wsState.idea_inbox ?? []).map((entry) => {
|
|
83
|
+
if (!considered.has(entry.id))
|
|
84
|
+
return entry;
|
|
85
|
+
return {
|
|
86
|
+
...entry,
|
|
87
|
+
status: mappedStatus,
|
|
88
|
+
triaged_by_task_id: task.task_id,
|
|
89
|
+
triaged_at: now,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
const summaryPrefix = planningDecision.decision === 'schedule_now'
|
|
93
|
+
? 'Scheduled now'
|
|
94
|
+
: planningDecision.decision === 'schedule_next'
|
|
95
|
+
? 'Scheduled next'
|
|
96
|
+
: 'Deferred';
|
|
97
|
+
const summary = `${summaryPrefix}: ${planningDecision.rationale_short}`;
|
|
98
|
+
return {
|
|
99
|
+
...wsState,
|
|
100
|
+
idea_inbox: updatedInbox,
|
|
101
|
+
planning_digest: {
|
|
102
|
+
updated_at: now,
|
|
103
|
+
summary,
|
|
104
|
+
last_task_id: task.task_id,
|
|
105
|
+
suggested_milestone: planningDecision.suggested_milestone,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function upsertOpenProductQuestion(wsState, task, runId) {
|
|
110
|
+
if (task.task_kind !== 'question' || !task.question?.prompt) {
|
|
111
|
+
return wsState;
|
|
112
|
+
}
|
|
113
|
+
const openQuestions = wsState.open_product_questions ?? [];
|
|
114
|
+
const existingOpen = openQuestions.find((question) => !question.resolved && question.prompt.trim() === task.question?.prompt.trim());
|
|
115
|
+
if (existingOpen)
|
|
116
|
+
return wsState;
|
|
117
|
+
const question = {
|
|
118
|
+
id: `pq-${runId}`,
|
|
119
|
+
prompt: task.question.prompt,
|
|
120
|
+
choices: Array.isArray(task.question.choices) ? task.question.choices : undefined,
|
|
121
|
+
created_at: new Date().toISOString(),
|
|
122
|
+
resolved: false,
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
...wsState,
|
|
126
|
+
open_product_questions: [...openQuestions, question],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Generates a basic report from tick state.
|
|
131
|
+
*
|
|
132
|
+
* This is a placeholder implementation. Full report generation will be
|
|
133
|
+
* implemented in M6.
|
|
134
|
+
*
|
|
135
|
+
* @param state - Tick state
|
|
136
|
+
* @param code - Report code
|
|
137
|
+
* @param verdict - Verdict (success/stop/blocked)
|
|
138
|
+
* @returns Basic report data
|
|
139
|
+
*/
|
|
140
|
+
function generateReport(state, code, verdict) {
|
|
141
|
+
const endedAt = new Date().toISOString();
|
|
142
|
+
const startedAt = new Date(state.started_at);
|
|
143
|
+
const endedAtDate = new Date(endedAt);
|
|
144
|
+
const durationMs = endedAtDate.getTime() - startedAt.getTime();
|
|
145
|
+
return {
|
|
146
|
+
run_id: state.run_id,
|
|
147
|
+
started_at: state.started_at,
|
|
148
|
+
ended_at: endedAt,
|
|
149
|
+
duration_ms: durationMs,
|
|
150
|
+
base_commit: state.base_commit,
|
|
151
|
+
head_commit: state.base_commit, // Placeholder - will be updated in JUDGE phase
|
|
152
|
+
task: state.task
|
|
153
|
+
? {
|
|
154
|
+
task_id: state.task.task_id,
|
|
155
|
+
milestone_id: state.task.milestone_id,
|
|
156
|
+
task_kind: state.task.task_kind,
|
|
157
|
+
intent: state.task.intent,
|
|
158
|
+
}
|
|
159
|
+
: {
|
|
160
|
+
task_id: 'none',
|
|
161
|
+
milestone_id: 'none',
|
|
162
|
+
task_kind: 'execute',
|
|
163
|
+
intent: 'No task assigned',
|
|
164
|
+
},
|
|
165
|
+
verdict,
|
|
166
|
+
code,
|
|
167
|
+
blast_radius: {
|
|
168
|
+
files_touched: 0,
|
|
169
|
+
lines_added: 0,
|
|
170
|
+
lines_deleted: 0,
|
|
171
|
+
new_files: 0,
|
|
172
|
+
},
|
|
173
|
+
scope: {
|
|
174
|
+
ok: true,
|
|
175
|
+
violations: [],
|
|
176
|
+
touched_paths: [],
|
|
177
|
+
},
|
|
178
|
+
diff: {
|
|
179
|
+
files_changed: 0,
|
|
180
|
+
lines_changed: 0,
|
|
181
|
+
diff_patch_path: '',
|
|
182
|
+
},
|
|
183
|
+
verification: {
|
|
184
|
+
exec_mode: 'argv_no_shell',
|
|
185
|
+
runs: [],
|
|
186
|
+
verify_log_path: '',
|
|
187
|
+
},
|
|
188
|
+
budgets: {
|
|
189
|
+
milestone_id: state.task?.milestone_id || 'none',
|
|
190
|
+
ticks: 0,
|
|
191
|
+
orchestrator_calls: 0,
|
|
192
|
+
builder_calls: 0,
|
|
193
|
+
verify_runs: 0,
|
|
194
|
+
estimated_cost_usd: 0,
|
|
195
|
+
warnings: [],
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Persists all run artifacts atomically.
|
|
201
|
+
*
|
|
202
|
+
* This is the single point of artifact persistence for all tick outcomes.
|
|
203
|
+
* Every tick must call this before returning, regardless of verdict.
|
|
204
|
+
*
|
|
205
|
+
* Writes:
|
|
206
|
+
* 1. REPORT.json - Always written
|
|
207
|
+
* 2. REPORT.md - Written if config.runner.render_report_md.enabled (with hard truncation)
|
|
208
|
+
* 3. BLOCKED.json - Written if blocked, deleted otherwise (cleans up stale blocked files)
|
|
209
|
+
*
|
|
210
|
+
* @param options - Persistence options including config, report, and optional blocked data
|
|
211
|
+
*/
|
|
212
|
+
async function persistRunArtifacts(options) {
|
|
213
|
+
const { config, blockedData } = options;
|
|
214
|
+
const report = annotateReportWithTokenUsage(options.report);
|
|
215
|
+
const workspaceDir = config.workspace_dir;
|
|
216
|
+
// 1. ALWAYS write REPORT.json first (most critical)
|
|
217
|
+
await atomicWriteJson(join(workspaceDir, 'REPORT.json'), report);
|
|
218
|
+
// 1a. Update STATE.json with run metadata and budgets (non-critical)
|
|
219
|
+
try {
|
|
220
|
+
const wsState = await readWorkspaceState(workspaceDir);
|
|
221
|
+
const updatedState = {
|
|
222
|
+
...wsState,
|
|
223
|
+
last_run_id: report.run_id,
|
|
224
|
+
last_verdict: report.verdict,
|
|
225
|
+
budgets: {
|
|
226
|
+
ticks: wsState.budgets.ticks + report.budgets.ticks,
|
|
227
|
+
orchestrator_calls: wsState.budgets.orchestrator_calls + report.budgets.orchestrator_calls,
|
|
228
|
+
builder_calls: wsState.budgets.builder_calls + report.budgets.builder_calls,
|
|
229
|
+
verify_runs: wsState.budgets.verify_runs + report.budgets.verify_runs,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
await writeWorkspaceState(workspaceDir, updatedState);
|
|
233
|
+
}
|
|
234
|
+
catch (stateError) {
|
|
235
|
+
console.error(`Failed to update STATE.json: ${stateError}`);
|
|
236
|
+
}
|
|
237
|
+
// 2. Write REPORT.md if enabled (non-critical, don't block on failure)
|
|
238
|
+
if (config.runner.render_report_md?.enabled) {
|
|
239
|
+
try {
|
|
240
|
+
let markdown = renderReportMarkdown(report);
|
|
241
|
+
const maxChars = config.runner.render_report_md.max_chars;
|
|
242
|
+
if (markdown.length > maxChars) {
|
|
243
|
+
markdown = markdown.slice(0, maxChars - 4) + '\n...';
|
|
244
|
+
}
|
|
245
|
+
await writeReportMarkdown(markdown, join(workspaceDir, 'REPORT.md'));
|
|
246
|
+
}
|
|
247
|
+
catch (mdError) {
|
|
248
|
+
console.error(`Failed to write REPORT.md: ${mdError}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// 3. Handle BLOCKED.json (best-effort, don't block on failure)
|
|
252
|
+
const blockedPath = join(workspaceDir, 'BLOCKED.json');
|
|
253
|
+
try {
|
|
254
|
+
if (report.verdict === 'blocked' && blockedData) {
|
|
255
|
+
await writeBlocked(blockedData, blockedPath);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
await deleteBlocked(blockedPath);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (blockedError) {
|
|
262
|
+
console.error(`Failed to handle BLOCKED.json: ${blockedError}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Generates a BLOCKED report for transport stall.
|
|
267
|
+
*/
|
|
268
|
+
function generateStallReport(state, stallResult) {
|
|
269
|
+
const endedAt = new Date().toISOString();
|
|
270
|
+
const startedAt = new Date(state.started_at);
|
|
271
|
+
const endedAtDate = new Date(endedAt);
|
|
272
|
+
const durationMs = endedAtDate.getTime() - startedAt.getTime();
|
|
273
|
+
return {
|
|
274
|
+
run_id: state.run_id,
|
|
275
|
+
started_at: state.started_at,
|
|
276
|
+
ended_at: endedAt,
|
|
277
|
+
duration_ms: durationMs,
|
|
278
|
+
base_commit: state.base_commit,
|
|
279
|
+
head_commit: state.base_commit,
|
|
280
|
+
task: state.task
|
|
281
|
+
? {
|
|
282
|
+
task_id: state.task.task_id,
|
|
283
|
+
milestone_id: state.task.milestone_id,
|
|
284
|
+
task_kind: state.task.task_kind,
|
|
285
|
+
intent: state.task.intent,
|
|
286
|
+
}
|
|
287
|
+
: {
|
|
288
|
+
task_id: 'none',
|
|
289
|
+
milestone_id: 'none',
|
|
290
|
+
task_kind: 'execute',
|
|
291
|
+
intent: 'Transport stalled before task assignment',
|
|
292
|
+
},
|
|
293
|
+
verdict: 'blocked',
|
|
294
|
+
code: 'BLOCKED_TRANSPORT_STALLED',
|
|
295
|
+
blast_radius: {
|
|
296
|
+
files_touched: 0,
|
|
297
|
+
lines_added: 0,
|
|
298
|
+
lines_deleted: 0,
|
|
299
|
+
new_files: 0,
|
|
300
|
+
},
|
|
301
|
+
scope: {
|
|
302
|
+
ok: true,
|
|
303
|
+
violations: [],
|
|
304
|
+
touched_paths: [],
|
|
305
|
+
},
|
|
306
|
+
diff: {
|
|
307
|
+
files_changed: 0,
|
|
308
|
+
lines_changed: 0,
|
|
309
|
+
diff_patch_path: '',
|
|
310
|
+
},
|
|
311
|
+
verification: {
|
|
312
|
+
exec_mode: 'argv_no_shell',
|
|
313
|
+
runs: [],
|
|
314
|
+
verify_log_path: '',
|
|
315
|
+
},
|
|
316
|
+
budgets: {
|
|
317
|
+
milestone_id: state.task?.milestone_id || 'none',
|
|
318
|
+
ticks: 0,
|
|
319
|
+
orchestrator_calls: 0,
|
|
320
|
+
builder_calls: 0,
|
|
321
|
+
verify_runs: 0,
|
|
322
|
+
estimated_cost_usd: 0,
|
|
323
|
+
warnings: [`Transport stalled during ${stallResult.stage}. Request ID: ${stallResult.requestId || 'unknown'}`],
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Generates a STOP report for judge violations.
|
|
329
|
+
*/
|
|
330
|
+
function generateJudgeStopReport(state, stopCode, blastRadius, touchedPaths, violations, reason) {
|
|
331
|
+
const endedAt = new Date().toISOString();
|
|
332
|
+
const startedAt = new Date(state.started_at);
|
|
333
|
+
const endedAtDate = new Date(endedAt);
|
|
334
|
+
const durationMs = endedAtDate.getTime() - startedAt.getTime();
|
|
335
|
+
return {
|
|
336
|
+
run_id: state.run_id,
|
|
337
|
+
started_at: state.started_at,
|
|
338
|
+
ended_at: endedAt,
|
|
339
|
+
duration_ms: durationMs,
|
|
340
|
+
base_commit: state.base_commit,
|
|
341
|
+
head_commit: state.base_commit,
|
|
342
|
+
task: state.task
|
|
343
|
+
? {
|
|
344
|
+
task_id: state.task.task_id,
|
|
345
|
+
milestone_id: state.task.milestone_id,
|
|
346
|
+
task_kind: state.task.task_kind,
|
|
347
|
+
intent: state.task.intent,
|
|
348
|
+
}
|
|
349
|
+
: {
|
|
350
|
+
task_id: 'none',
|
|
351
|
+
milestone_id: 'none',
|
|
352
|
+
task_kind: 'execute',
|
|
353
|
+
intent: 'No task',
|
|
354
|
+
},
|
|
355
|
+
verdict: 'stop',
|
|
356
|
+
code: stopCode,
|
|
357
|
+
blast_radius: blastRadius,
|
|
358
|
+
scope: {
|
|
359
|
+
ok: false,
|
|
360
|
+
violations,
|
|
361
|
+
touched_paths: touchedPaths,
|
|
362
|
+
},
|
|
363
|
+
diff: {
|
|
364
|
+
files_changed: blastRadius.files_touched,
|
|
365
|
+
lines_changed: blastRadius.lines_added + blastRadius.lines_deleted,
|
|
366
|
+
diff_patch_path: '',
|
|
367
|
+
},
|
|
368
|
+
verification: {
|
|
369
|
+
exec_mode: 'argv_no_shell',
|
|
370
|
+
runs: [],
|
|
371
|
+
verify_log_path: '',
|
|
372
|
+
},
|
|
373
|
+
budgets: {
|
|
374
|
+
milestone_id: state.task?.milestone_id || 'none',
|
|
375
|
+
ticks: 0,
|
|
376
|
+
orchestrator_calls: 1,
|
|
377
|
+
builder_calls: 1,
|
|
378
|
+
verify_runs: 0,
|
|
379
|
+
estimated_cost_usd: 0,
|
|
380
|
+
warnings: [reason],
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Performs rollback and verifies worktree cleanliness.
|
|
386
|
+
*
|
|
387
|
+
* If rollback fails or worktree remains dirty, returns a BLOCKED code.
|
|
388
|
+
* Otherwise returns null (rollback succeeded and worktree is clean).
|
|
389
|
+
*
|
|
390
|
+
* @param baseCommit - Commit to rollback to
|
|
391
|
+
* @param untrackedPaths - Untracked paths to remove
|
|
392
|
+
* @returns RollbackWithCleanCheckResult with blocked code if needed
|
|
393
|
+
*/
|
|
394
|
+
function performRollbackWithCleanCheck(baseCommit, untrackedPaths) {
|
|
395
|
+
console.log(`[${TickPhase.JUDGE}] Rolling back to ${baseCommit}`);
|
|
396
|
+
const rollbackResult = rollbackToCommit(baseCommit, untrackedPaths);
|
|
397
|
+
if (!rollbackResult.ok) {
|
|
398
|
+
console.log(`[${TickPhase.JUDGE}] Rollback failed: ${rollbackResult.error}`);
|
|
399
|
+
return {
|
|
400
|
+
blockedCode: 'BLOCKED_ROLLBACK_FAILED',
|
|
401
|
+
reason: `Rollback failed: ${rollbackResult.error}`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
// Verify worktree is clean after rollback
|
|
405
|
+
const isClean = verifyCleanWorktree();
|
|
406
|
+
if (!isClean) {
|
|
407
|
+
console.log(`[${TickPhase.JUDGE}] Rollback succeeded but worktree is dirty`);
|
|
408
|
+
return {
|
|
409
|
+
blockedCode: 'BLOCKED_ROLLBACK_DIRTY',
|
|
410
|
+
reason: 'Rollback succeeded but worktree remains dirty (uncommitted changes or untracked files)',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
console.log(`[${TickPhase.JUDGE}] Rollback succeeded and worktree is clean`);
|
|
414
|
+
return {
|
|
415
|
+
blockedCode: null,
|
|
416
|
+
reason: null,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Generates a BLOCKED report for rollback failures.
|
|
421
|
+
*/
|
|
422
|
+
function generateRollbackBlockedReport(state, blockedCode, reason, blastRadius, touchedPaths) {
|
|
423
|
+
const endedAt = new Date().toISOString();
|
|
424
|
+
const startedAt = new Date(state.started_at);
|
|
425
|
+
const endedAtDate = new Date(endedAt);
|
|
426
|
+
const durationMs = endedAtDate.getTime() - startedAt.getTime();
|
|
427
|
+
return {
|
|
428
|
+
run_id: state.run_id,
|
|
429
|
+
started_at: state.started_at,
|
|
430
|
+
ended_at: endedAt,
|
|
431
|
+
duration_ms: durationMs,
|
|
432
|
+
base_commit: state.base_commit,
|
|
433
|
+
head_commit: state.base_commit,
|
|
434
|
+
task: state.task
|
|
435
|
+
? {
|
|
436
|
+
task_id: state.task.task_id,
|
|
437
|
+
milestone_id: state.task.milestone_id,
|
|
438
|
+
task_kind: state.task.task_kind,
|
|
439
|
+
intent: state.task.intent,
|
|
440
|
+
}
|
|
441
|
+
: {
|
|
442
|
+
task_id: 'none',
|
|
443
|
+
milestone_id: 'none',
|
|
444
|
+
task_kind: 'execute',
|
|
445
|
+
intent: 'No task',
|
|
446
|
+
},
|
|
447
|
+
verdict: 'blocked',
|
|
448
|
+
code: blockedCode,
|
|
449
|
+
blast_radius: blastRadius,
|
|
450
|
+
scope: {
|
|
451
|
+
ok: false,
|
|
452
|
+
violations: [],
|
|
453
|
+
touched_paths: touchedPaths,
|
|
454
|
+
},
|
|
455
|
+
diff: {
|
|
456
|
+
files_changed: blastRadius.files_touched,
|
|
457
|
+
lines_changed: blastRadius.lines_added + blastRadius.lines_deleted,
|
|
458
|
+
diff_patch_path: '',
|
|
459
|
+
},
|
|
460
|
+
verification: {
|
|
461
|
+
exec_mode: 'argv_no_shell',
|
|
462
|
+
runs: [],
|
|
463
|
+
verify_log_path: '',
|
|
464
|
+
},
|
|
465
|
+
budgets: {
|
|
466
|
+
milestone_id: state.task?.milestone_id || 'none',
|
|
467
|
+
ticks: 0,
|
|
468
|
+
orchestrator_calls: 1,
|
|
469
|
+
builder_calls: 1,
|
|
470
|
+
verify_runs: 0,
|
|
471
|
+
estimated_cost_usd: 0,
|
|
472
|
+
warnings: [reason],
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Runs a single verification command with timeout.
|
|
478
|
+
*
|
|
479
|
+
* @param cmd - Command to execute
|
|
480
|
+
* @param args - Command arguments
|
|
481
|
+
* @param timeoutSeconds - Timeout in seconds
|
|
482
|
+
* @returns VerifyCommandResult
|
|
483
|
+
*/
|
|
484
|
+
async function runVerifyCommand(cmd, args, timeoutSeconds) {
|
|
485
|
+
const startTime = Date.now();
|
|
486
|
+
return new Promise((resolve) => {
|
|
487
|
+
let stdout = '';
|
|
488
|
+
let stderr = '';
|
|
489
|
+
let timedOut = false;
|
|
490
|
+
const child = spawn(cmd, args, {
|
|
491
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
492
|
+
timeout: timeoutSeconds * 1000,
|
|
493
|
+
});
|
|
494
|
+
child.stdout?.on('data', (data) => {
|
|
495
|
+
stdout += data.toString();
|
|
496
|
+
});
|
|
497
|
+
child.stderr?.on('data', (data) => {
|
|
498
|
+
stderr += data.toString();
|
|
499
|
+
});
|
|
500
|
+
const timer = setTimeout(() => {
|
|
501
|
+
timedOut = true;
|
|
502
|
+
child.kill('SIGTERM');
|
|
503
|
+
}, timeoutSeconds * 1000);
|
|
504
|
+
child.on('close', (code) => {
|
|
505
|
+
clearTimeout(timer);
|
|
506
|
+
const durationMs = Date.now() - startTime;
|
|
507
|
+
resolve({
|
|
508
|
+
ok: code === 0 && !timedOut,
|
|
509
|
+
exitCode: code ?? -1,
|
|
510
|
+
timedOut,
|
|
511
|
+
stdout: stdout.slice(-2000),
|
|
512
|
+
stderr: stderr.slice(-2000),
|
|
513
|
+
durationMs,
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
child.on('error', (err) => {
|
|
517
|
+
clearTimeout(timer);
|
|
518
|
+
const durationMs = Date.now() - startTime;
|
|
519
|
+
resolve({
|
|
520
|
+
ok: false,
|
|
521
|
+
exitCode: -1,
|
|
522
|
+
timedOut: false,
|
|
523
|
+
stdout: '',
|
|
524
|
+
stderr: err.message,
|
|
525
|
+
durationMs,
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Expands a verification template with params.
|
|
532
|
+
*
|
|
533
|
+
* @param template - Template from config
|
|
534
|
+
* @param params - Params from task verification
|
|
535
|
+
* @returns Expanded command and args
|
|
536
|
+
*/
|
|
537
|
+
function expandTemplate(template, params) {
|
|
538
|
+
const expandArg = (arg) => {
|
|
539
|
+
return arg.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
540
|
+
const value = params[key];
|
|
541
|
+
return value !== null && value !== undefined ? String(value) : '';
|
|
542
|
+
});
|
|
543
|
+
};
|
|
544
|
+
return {
|
|
545
|
+
cmd: template.cmd,
|
|
546
|
+
args: template.args.map(expandArg),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Runs verification commands for a phase (fast or slow).
|
|
551
|
+
*
|
|
552
|
+
* @param templateIds - Template IDs to run
|
|
553
|
+
* @param templates - Available templates from config
|
|
554
|
+
* @param params - Params from task
|
|
555
|
+
* @param phase - 'fast' or 'slow'
|
|
556
|
+
* @param timeoutSeconds - Timeout per command
|
|
557
|
+
* @returns VerificationPhaseResult
|
|
558
|
+
*/
|
|
559
|
+
async function runVerificationPhase(templateIds, templates, params, phase, timeoutSeconds) {
|
|
560
|
+
const runs = [];
|
|
561
|
+
for (const templateId of templateIds) {
|
|
562
|
+
const template = templates.find(t => t.id === templateId);
|
|
563
|
+
if (!template) {
|
|
564
|
+
console.log(`[VERIFY] Template not found: ${templateId}`);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const templateParams = params[templateId] || {};
|
|
568
|
+
const expanded = expandTemplate(template, templateParams);
|
|
569
|
+
console.log(`[VERIFY] Running ${phase}: ${expanded.cmd} ${expanded.args.join(' ')}`);
|
|
570
|
+
const result = await runVerifyCommand(expanded.cmd, expanded.args, timeoutSeconds);
|
|
571
|
+
runs.push({
|
|
572
|
+
template_id: templateId,
|
|
573
|
+
phase,
|
|
574
|
+
cmd: expanded.cmd,
|
|
575
|
+
args: expanded.args,
|
|
576
|
+
exit_code: result.exitCode,
|
|
577
|
+
duration_ms: result.durationMs,
|
|
578
|
+
timed_out: result.timedOut,
|
|
579
|
+
});
|
|
580
|
+
if (result.timedOut) {
|
|
581
|
+
return {
|
|
582
|
+
ok: false,
|
|
583
|
+
stopCode: 'STOP_VERIFY_FLAKY_OR_TIMEOUT',
|
|
584
|
+
runs,
|
|
585
|
+
reason: `Verification timed out: ${templateId} (${timeoutSeconds}s)`,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
if (!result.ok) {
|
|
589
|
+
const stopCode = phase === 'fast' ? 'STOP_VERIFY_FAILED_FAST' : 'STOP_VERIFY_FAILED_SLOW';
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
stopCode,
|
|
593
|
+
runs,
|
|
594
|
+
reason: `Verification failed: ${templateId} (exit code ${result.exitCode})`,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
ok: true,
|
|
600
|
+
stopCode: null,
|
|
601
|
+
runs,
|
|
602
|
+
reason: null,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Executes one complete tick of the envoi loop.
|
|
607
|
+
*
|
|
608
|
+
* Phases:
|
|
609
|
+
* 1. LOCK: Acquire lock to prevent concurrent runs
|
|
610
|
+
* 2. PREFLIGHT: Run safety checks
|
|
611
|
+
* 3. ORCHESTRATE: Get task from orchestrator (placeholder)
|
|
612
|
+
* 4. BUILD: Execute task via builder (placeholder)
|
|
613
|
+
* 5. JUDGE: Validate changes and run verifications (placeholder)
|
|
614
|
+
* 6. REPORT: Generate REPORT.json and REPORT.md
|
|
615
|
+
* 7. END: Release lock and return report
|
|
616
|
+
*
|
|
617
|
+
* @param config - Envoi configuration
|
|
618
|
+
* @param signal - Optional AbortSignal for cancellation
|
|
619
|
+
* @returns Report data for this tick
|
|
620
|
+
*/
|
|
621
|
+
/**
|
|
622
|
+
* Sets up SIGINT/SIGTERM handlers for graceful shutdown.
|
|
623
|
+
* Must be called after lock acquisition and cleaned up before lock release.
|
|
624
|
+
* Also notifies the loop to stop after this tick completes.
|
|
625
|
+
*/
|
|
626
|
+
function setupSignalHandlers() {
|
|
627
|
+
const sigintHandler = () => {
|
|
628
|
+
console.log('\nSIGINT received during tick');
|
|
629
|
+
requestStop(); // Notify loop to stop after this tick
|
|
630
|
+
};
|
|
631
|
+
const sigtermHandler = () => {
|
|
632
|
+
console.log('\nSIGTERM received during tick');
|
|
633
|
+
requestStop(); // Notify loop to stop after this tick
|
|
634
|
+
};
|
|
635
|
+
process.on('SIGINT', sigintHandler);
|
|
636
|
+
process.on('SIGTERM', sigtermHandler);
|
|
637
|
+
return () => {
|
|
638
|
+
process.off('SIGINT', sigintHandler);
|
|
639
|
+
process.off('SIGTERM', sigtermHandler);
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
export async function runTick(config, signal) {
|
|
643
|
+
let state;
|
|
644
|
+
let lockAcquired = false;
|
|
645
|
+
let signalCleanup = null;
|
|
646
|
+
let currentBranchName = null;
|
|
647
|
+
const lockPath = config.runner.lockfile;
|
|
648
|
+
activeTickTokenUsage = { orchestrator: null, builder: null };
|
|
649
|
+
try {
|
|
650
|
+
// Phase 1: LOCK
|
|
651
|
+
console.log(`[${TickPhase.LOCK}] Acquiring lock...`);
|
|
652
|
+
try {
|
|
653
|
+
const lockInfo = await acquireLock(lockPath);
|
|
654
|
+
lockAcquired = true;
|
|
655
|
+
console.log(`[${TickPhase.LOCK}] Lock acquired (PID: ${lockInfo.pid})`);
|
|
656
|
+
// Install signal handlers AFTER lock acquisition
|
|
657
|
+
signalCleanup = setupSignalHandlers();
|
|
658
|
+
// Initialize state without touching git yet.
|
|
659
|
+
// Preflight is responsible for git checks (and for deriving base_commit).
|
|
660
|
+
state = createInitialState(config, '');
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
if (error instanceof LockHeldError) {
|
|
664
|
+
const report = generateReport({
|
|
665
|
+
phase: TickPhase.LOCK,
|
|
666
|
+
run_id: 'lock-failed',
|
|
667
|
+
started_at: new Date().toISOString(),
|
|
668
|
+
base_commit: '',
|
|
669
|
+
config,
|
|
670
|
+
task: null,
|
|
671
|
+
builder_result: null,
|
|
672
|
+
errors: [],
|
|
673
|
+
}, 'BLOCKED_LOCK_HELD', 'blocked');
|
|
674
|
+
report.budgets.ticks = 1;
|
|
675
|
+
const blockedData = buildBlockedData('BLOCKED_LOCK_HELD', 'Another process is holding the lock');
|
|
676
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
677
|
+
return report;
|
|
678
|
+
}
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
681
|
+
// Phase 2: PREFLIGHT
|
|
682
|
+
console.log(`[${TickPhase.PREFLIGHT}] Running preflight checks...`);
|
|
683
|
+
state = transitionPhase(state, TickPhase.PREFLIGHT);
|
|
684
|
+
const preflightResult = await runPreflight(config);
|
|
685
|
+
if (!preflightResult.ok) {
|
|
686
|
+
// Preflight failed - cleanup handlers and release lock
|
|
687
|
+
if (signalCleanup) {
|
|
688
|
+
signalCleanup();
|
|
689
|
+
signalCleanup = null;
|
|
690
|
+
}
|
|
691
|
+
if (lockAcquired) {
|
|
692
|
+
await releaseLock(lockPath);
|
|
693
|
+
lockAcquired = false;
|
|
694
|
+
}
|
|
695
|
+
const blockedCode = preflightResult.blocked_code || 'BLOCKED_MISSING_CONFIG';
|
|
696
|
+
const report = generateReport({
|
|
697
|
+
...state,
|
|
698
|
+
errors: preflightResult.blocked_reason
|
|
699
|
+
? [preflightResult.blocked_reason]
|
|
700
|
+
: [],
|
|
701
|
+
}, blockedCode, 'blocked');
|
|
702
|
+
report.budgets.ticks = 1;
|
|
703
|
+
const blockedData = buildBlockedData(blockedCode, preflightResult.blocked_reason || 'Preflight check failed');
|
|
704
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
705
|
+
return report;
|
|
706
|
+
}
|
|
707
|
+
if (preflightResult.warnings.length > 0) {
|
|
708
|
+
console.log(`[${TickPhase.PREFLIGHT}] Warnings:`);
|
|
709
|
+
for (const warning of preflightResult.warnings) {
|
|
710
|
+
console.log(` - ${warning}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (preflightResult.base_commit) {
|
|
714
|
+
// Update base commit from preflight if available
|
|
715
|
+
state = {
|
|
716
|
+
...state,
|
|
717
|
+
base_commit: preflightResult.base_commit,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
console.log(`[${TickPhase.PREFLIGHT}] Preflight passed (base: ${state.base_commit})`);
|
|
721
|
+
// Phase 3: ORCHESTRATE
|
|
722
|
+
console.log(`[${TickPhase.ORCHESTRATE}] Running orchestrator...`);
|
|
723
|
+
state = transitionPhase(state, TickPhase.ORCHESTRATE);
|
|
724
|
+
let orchestratorResult;
|
|
725
|
+
try {
|
|
726
|
+
orchestratorResult = await runOrchestrator(state, signal);
|
|
727
|
+
activeTickTokenUsage.orchestrator = orchestratorResult.tokenUsage ?? null;
|
|
728
|
+
console.log(`[${TickPhase.ORCHESTRATE}] Tokens: ${formatTokenUsageForLog(orchestratorResult.tokenUsage)}`);
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
// Check if this is a transport stall
|
|
732
|
+
if (isTransportStallError(error)) {
|
|
733
|
+
console.log(`[${TickPhase.ORCHESTRATE}] Transport stall detected`);
|
|
734
|
+
const stallResult = await handleTransportStall(error, state.base_commit);
|
|
735
|
+
if (lockAcquired) {
|
|
736
|
+
await releaseLock(lockPath);
|
|
737
|
+
lockAcquired = false;
|
|
738
|
+
}
|
|
739
|
+
const report = generateStallReport(state, stallResult);
|
|
740
|
+
report.budgets.ticks = 1;
|
|
741
|
+
report.budgets.orchestrator_calls = 1;
|
|
742
|
+
const blockedData = buildBlockedData('BLOCKED_TRANSPORT_STALLED', `Transport stalled during orchestrator: ${stallResult.requestId || 'unknown'}`);
|
|
743
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
744
|
+
return report;
|
|
745
|
+
}
|
|
746
|
+
// Check if this is an orchestrator timeout
|
|
747
|
+
if (isTimeoutError(error)) {
|
|
748
|
+
console.log(`[${TickPhase.ORCHESTRATE}] Orchestrator timeout detected`);
|
|
749
|
+
if (lockAcquired) {
|
|
750
|
+
await releaseLock(lockPath);
|
|
751
|
+
lockAcquired = false;
|
|
752
|
+
}
|
|
753
|
+
// Get configured timeout for display
|
|
754
|
+
const timeoutSeconds = config.orchestrator.timeout_seconds ?? config.runner.max_tick_seconds;
|
|
755
|
+
const timeoutDisplay = `${timeoutSeconds}s`;
|
|
756
|
+
const report = generateReport(state, 'STOP_ORCHESTRATOR_TIMEOUT', 'stop');
|
|
757
|
+
report.budgets.ticks = 1;
|
|
758
|
+
report.budgets.orchestrator_calls = 1;
|
|
759
|
+
report.task.intent = `Orchestrator timed out after ${timeoutDisplay}`;
|
|
760
|
+
report.budgets.warnings.push(`Orchestrator timed out after ${timeoutDisplay}`);
|
|
761
|
+
await persistRunArtifacts({ config, report });
|
|
762
|
+
return report;
|
|
763
|
+
}
|
|
764
|
+
// Not a stall or timeout - rethrow
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
if (!orchestratorResult.success || !orchestratorResult.task) {
|
|
768
|
+
// Orchestrator failed - release lock and return blocked report
|
|
769
|
+
if (lockAcquired) {
|
|
770
|
+
await releaseLock(lockPath);
|
|
771
|
+
lockAcquired = false;
|
|
772
|
+
}
|
|
773
|
+
// Build BLOCKED.json with diagnostics
|
|
774
|
+
const schemaErrors = orchestratorResult.diagnostics?.schemaErrors?.map((e) => ({
|
|
775
|
+
instancePath: e.instancePath,
|
|
776
|
+
schemaPath: e.schemaPath,
|
|
777
|
+
keyword: e.keyword,
|
|
778
|
+
params: e.params,
|
|
779
|
+
message: e.message,
|
|
780
|
+
}));
|
|
781
|
+
const blockedData = buildOrchestratorBlockedData(orchestratorResult.error || 'Orchestrator output invalid', {
|
|
782
|
+
schema_errors: schemaErrors,
|
|
783
|
+
stdout_excerpt: (orchestratorResult.rawCliStdout ?? orchestratorResult.rawResponse ?? '').slice(-2000),
|
|
784
|
+
stderr_excerpt: (orchestratorResult.rawStderr ?? '').slice(-2000),
|
|
785
|
+
json_excerpt: (() => {
|
|
786
|
+
const extracted = orchestratorResult.diagnostics?.extractedJson;
|
|
787
|
+
if (extracted !== undefined && extracted !== null) {
|
|
788
|
+
try {
|
|
789
|
+
return JSON.stringify(extracted).slice(0, 1000);
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
// fall through
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return (orchestratorResult.rawCliStdout ?? orchestratorResult.rawResponse ?? '').slice(0, 1000);
|
|
796
|
+
})(),
|
|
797
|
+
extract_method: orchestratorResult.diagnostics?.extractMethod || 'direct_parse',
|
|
798
|
+
});
|
|
799
|
+
// Generate REPORT.json with correct orchestrator_calls and warnings
|
|
800
|
+
const report = generateReport({
|
|
801
|
+
...state,
|
|
802
|
+
errors: orchestratorResult.error ? [orchestratorResult.error] : [],
|
|
803
|
+
}, 'BLOCKED_ORCHESTRATOR_OUTPUT_INVALID', 'blocked');
|
|
804
|
+
report.budgets.ticks = 1;
|
|
805
|
+
report.budgets.orchestrator_calls = orchestratorResult.attempts;
|
|
806
|
+
report.budgets.warnings.push(`Orchestrator output invalid after ${orchestratorResult.attempts} attempt(s): ${orchestratorResult.error || 'unknown error'}`);
|
|
807
|
+
// Persist orchestrator failure artifacts for debugging
|
|
808
|
+
try {
|
|
809
|
+
const meta = {
|
|
810
|
+
run_id: state.run_id,
|
|
811
|
+
phase: 'orchestrator',
|
|
812
|
+
model: config.models.orchestrator_model,
|
|
813
|
+
timeout_ms: config.runner.max_tick_seconds * 1000,
|
|
814
|
+
prompt_chars: 0, // Not available at this level
|
|
815
|
+
system_prompt_chars: 0, // Not available at this level
|
|
816
|
+
cwd: process.cwd(),
|
|
817
|
+
args_summary_redacted: `--max-turns ${config.orchestrator.max_turns} --permission-mode ${config.orchestrator.permission_mode} --model <model>`,
|
|
818
|
+
};
|
|
819
|
+
await persistOrchestratorFailure(state.run_id, orchestratorResult.rawCliStdout ?? orchestratorResult.rawResponse, orchestratorResult.rawStderr, orchestratorResult.diagnostics?.extractedJson ?? null, orchestratorResult.diagnostics?.schemaErrors ?? null, meta, config);
|
|
820
|
+
// Add pointer to history artifacts in warnings
|
|
821
|
+
report.budgets.warnings.push(`Orchestrator output invalid; see ${config.workspace_dir}/history/${state.run_id}/orchestrator/`);
|
|
822
|
+
}
|
|
823
|
+
catch (persistError) {
|
|
824
|
+
console.warn(`Failed to persist orchestrator failure: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
|
|
825
|
+
}
|
|
826
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
827
|
+
console.log(`[${TickPhase.ORCHESTRATE}] Artifacts persisted`);
|
|
828
|
+
return report;
|
|
829
|
+
}
|
|
830
|
+
// Update state with the task from orchestrator
|
|
831
|
+
state = {
|
|
832
|
+
...state,
|
|
833
|
+
task: orchestratorResult.task,
|
|
834
|
+
};
|
|
835
|
+
console.log(`[${TickPhase.ORCHESTRATE}] Task proposed: ${orchestratorResult.task.task_id} (${orchestratorResult.task.task_kind})`);
|
|
836
|
+
const task = orchestratorResult.task;
|
|
837
|
+
// Persist planning metadata and milestone context early for crash tolerance.
|
|
838
|
+
let wsState = await readWorkspaceState(config.workspace_dir);
|
|
839
|
+
wsState = applyPlanningDecisionToWorkspaceState(wsState, task);
|
|
840
|
+
wsState = upsertOpenProductQuestion(wsState, task, state.run_id);
|
|
841
|
+
if (task.milestone_id) {
|
|
842
|
+
const result = ensureMilestone(wsState, task.milestone_id);
|
|
843
|
+
wsState = result.state;
|
|
844
|
+
if (result.changed) {
|
|
845
|
+
console.log(`Milestone persisted early: ${task.milestone_id}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
await writeWorkspaceState(config.workspace_dir, wsState);
|
|
849
|
+
// control.action='stop' acts as completion stop for non-question tasks.
|
|
850
|
+
if (task.task_kind !== 'question' && task.control?.action === 'stop') {
|
|
851
|
+
console.log(`[${TickPhase.ORCHESTRATE}] Control action: stop (reason: ${task.control.reason || 'none'})`);
|
|
852
|
+
if (lockAcquired) {
|
|
853
|
+
await releaseLock(lockPath);
|
|
854
|
+
lockAcquired = false;
|
|
855
|
+
}
|
|
856
|
+
const report = generateReport(state, 'SUCCESS', 'success');
|
|
857
|
+
report.budgets.ticks = 1;
|
|
858
|
+
report.budgets.orchestrator_calls = 1;
|
|
859
|
+
report.budgets.warnings.push(`Orchestrator signaled stop: ${task.control.reason || 'no reason given'}`);
|
|
860
|
+
await persistRunArtifacts({ config, report });
|
|
861
|
+
return report;
|
|
862
|
+
}
|
|
863
|
+
// Question tasks: ask the user and stop immediately (no builder).
|
|
864
|
+
if (task.task_kind === 'question') {
|
|
865
|
+
// Safety: question tasks must have zero side effects. If anything changed between base_commit and now,
|
|
866
|
+
// rollback and STOP with STOP_QUESTION_SIDE_EFFECTS.
|
|
867
|
+
const touched = getTouchedFiles(state.base_commit);
|
|
868
|
+
if (touched.all.length > 0) {
|
|
869
|
+
const blastRadius = computeBlastRadius(state.base_commit, touched);
|
|
870
|
+
const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
|
|
871
|
+
if (rollbackCheck.blockedCode) {
|
|
872
|
+
if (lockAcquired) {
|
|
873
|
+
await releaseLock(lockPath);
|
|
874
|
+
lockAcquired = false;
|
|
875
|
+
}
|
|
876
|
+
const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
|
|
877
|
+
report.budgets.ticks = 1;
|
|
878
|
+
report.budgets.orchestrator_calls = orchestratorResult.attempts;
|
|
879
|
+
report.budgets.builder_calls = 0;
|
|
880
|
+
const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
|
|
881
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
882
|
+
return report;
|
|
883
|
+
}
|
|
884
|
+
if (lockAcquired) {
|
|
885
|
+
await releaseLock(lockPath);
|
|
886
|
+
lockAcquired = false;
|
|
887
|
+
}
|
|
888
|
+
const report = generateJudgeStopReport(state, 'STOP_QUESTION_SIDE_EFFECTS', blastRadius, touched.all, touched.all, 'Question task had side effects (git diff not empty)');
|
|
889
|
+
report.budgets.ticks = 1;
|
|
890
|
+
report.budgets.orchestrator_calls = orchestratorResult.attempts;
|
|
891
|
+
report.budgets.builder_calls = 0;
|
|
892
|
+
await persistRunArtifacts({ config, report });
|
|
893
|
+
return report;
|
|
894
|
+
}
|
|
895
|
+
const prompt = task.question?.prompt ?? '(missing question.prompt)';
|
|
896
|
+
const choices = Array.isArray(task.question?.choices) ? task.question.choices : [];
|
|
897
|
+
console.log('\n[QUESTION]');
|
|
898
|
+
console.log(prompt);
|
|
899
|
+
if (choices.length > 0) {
|
|
900
|
+
console.log('\nChoices:');
|
|
901
|
+
for (const c of choices)
|
|
902
|
+
console.log(`- ${c}`);
|
|
903
|
+
}
|
|
904
|
+
console.log('');
|
|
905
|
+
if (lockAcquired) {
|
|
906
|
+
await releaseLock(lockPath);
|
|
907
|
+
lockAcquired = false;
|
|
908
|
+
}
|
|
909
|
+
const report = generateReport(state, 'STOP_ORCHESTRATOR_ASK_QUESTION', 'stop');
|
|
910
|
+
report.budgets.ticks = 1;
|
|
911
|
+
report.budgets.orchestrator_calls = orchestratorResult.attempts;
|
|
912
|
+
report.budgets.builder_calls = 0;
|
|
913
|
+
report.task.intent = `Orchestrator asked a question:\n${prompt}${choices.length > 0 ? `\n\nChoices:\n${choices.map((c) => `- ${c}`).join('\n')}` : ''}`;
|
|
914
|
+
report.budgets.warnings.push('Answer the question, then rerun: envoi tick');
|
|
915
|
+
await persistRunArtifacts({ config, report });
|
|
916
|
+
return report;
|
|
917
|
+
}
|
|
918
|
+
// Optional reviewer gate (Second Brain) before builder execution.
|
|
919
|
+
if (task.task_kind === 'execute' && config.reviewer?.enabled && config.reviewer.trigger) {
|
|
920
|
+
const emptyAnalysis = {
|
|
921
|
+
files_touched: 0,
|
|
922
|
+
lines_added: 0,
|
|
923
|
+
lines_deleted: 0,
|
|
924
|
+
new_files: 0,
|
|
925
|
+
touched_paths: [],
|
|
926
|
+
};
|
|
927
|
+
const currentTick = wsState.budgets.ticks + 1;
|
|
928
|
+
const riskFlags = computeRiskFlags({
|
|
929
|
+
analysis: emptyAnalysis,
|
|
930
|
+
limits: task.diff_limits,
|
|
931
|
+
scope: task.scope,
|
|
932
|
+
trigger: config.reviewer.trigger,
|
|
933
|
+
stopHistory: [],
|
|
934
|
+
currentTick,
|
|
935
|
+
verifyFailed: false,
|
|
936
|
+
budgetWarning: wsState.budget_warning,
|
|
937
|
+
});
|
|
938
|
+
const reviewerResult = await runReviewerIfNeeded(config, {
|
|
939
|
+
riskFlags,
|
|
940
|
+
task,
|
|
941
|
+
stopHistory: [],
|
|
942
|
+
currentTick,
|
|
943
|
+
verifyFailed: false,
|
|
944
|
+
budgetWarning: wsState.budget_warning,
|
|
945
|
+
touchedPaths: [],
|
|
946
|
+
});
|
|
947
|
+
if (reviewerResult.stopCode) {
|
|
948
|
+
if (lockAcquired) {
|
|
949
|
+
await releaseLock(lockPath);
|
|
950
|
+
lockAcquired = false;
|
|
951
|
+
}
|
|
952
|
+
if (reviewerResult.stopCode === 'STOP_REVIEWER_ASK_QUESTION' && reviewerResult.question) {
|
|
953
|
+
console.log('\n[REVIEW QUESTION]');
|
|
954
|
+
console.log(reviewerResult.question.prompt);
|
|
955
|
+
if (reviewerResult.question.choices && reviewerResult.question.choices.length > 0) {
|
|
956
|
+
console.log('\nChoices:');
|
|
957
|
+
for (const choice of reviewerResult.question.choices) {
|
|
958
|
+
console.log(`- ${choice}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
console.log('');
|
|
962
|
+
}
|
|
963
|
+
const report = generateReport(state, reviewerResult.stopCode, 'stop');
|
|
964
|
+
report.budgets.ticks = 1;
|
|
965
|
+
report.budgets.orchestrator_calls = orchestratorResult.attempts;
|
|
966
|
+
report.budgets.builder_calls = 0;
|
|
967
|
+
if (reviewerResult.reviewerError) {
|
|
968
|
+
report.reviewer_error = reviewerResult.reviewerError;
|
|
969
|
+
report.budgets.warnings.push(`Reviewer error: ${compactWarningText(reviewerResult.reviewerError)}`);
|
|
970
|
+
}
|
|
971
|
+
if (reviewerResult.stopCode === 'STOP_REVIEWER_FORCED_PATCH') {
|
|
972
|
+
report.budgets.warnings.push('Reviewer requested manual intervention before builder execution.');
|
|
973
|
+
}
|
|
974
|
+
else if (reviewerResult.stopCode === 'STOP_REVIEWER_ASK_QUESTION') {
|
|
975
|
+
report.budgets.warnings.push('Reviewer asked a product question; answer then rerun.');
|
|
976
|
+
}
|
|
977
|
+
await persistRunArtifacts({ config, report });
|
|
978
|
+
return report;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// Branching: Create/switch branch before BUILD (runner-owned)
|
|
982
|
+
if (config.git?.branching && config.git.branching.mode !== 'off' && task.task_kind === 'execute') {
|
|
983
|
+
console.log(`[BRANCHING] Ensuring branch for mode=${config.git.branching.mode}...`);
|
|
984
|
+
let branchResult;
|
|
985
|
+
let branchingError = null;
|
|
986
|
+
// Validate config based on mode
|
|
987
|
+
if (config.git.branching.mode === 'per_n_tasks') {
|
|
988
|
+
if (!config.git.branching.n_tasks || config.git.branching.n_tasks < 1) {
|
|
989
|
+
branchingError = `per_n_tasks mode requires n_tasks >= 1, but got ${config.git.branching.n_tasks}`;
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
// Calculate batch index: use builder_calls as proxy for task count
|
|
993
|
+
// Each execute task results in a builder call
|
|
994
|
+
const batchIndex = Math.floor(wsState.budgets.builder_calls / config.git.branching.n_tasks);
|
|
995
|
+
branchResult = ensureBranchPerNTasks(config.git.branching, {
|
|
996
|
+
task_id: task.task_id,
|
|
997
|
+
milestone_id: task.milestone_id,
|
|
998
|
+
run_id: state.run_id,
|
|
999
|
+
tick_count: wsState.budgets.ticks + 1,
|
|
1000
|
+
seq: batchIndex,
|
|
1001
|
+
batch_index: batchIndex,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
else if (config.git.branching.mode === 'per_milestone') {
|
|
1006
|
+
if (!task.milestone_id) {
|
|
1007
|
+
branchingError = 'per_milestone mode requires task.milestone_id';
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
branchResult = ensureBranchPerMilestone(config.git.branching, {
|
|
1011
|
+
task_id: task.task_id,
|
|
1012
|
+
milestone_id: task.milestone_id,
|
|
1013
|
+
run_id: state.run_id,
|
|
1014
|
+
tick_count: wsState.budgets.ticks + 1,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
else if (config.git.branching.mode === 'per_tick') {
|
|
1019
|
+
branchResult = ensureBranchPerTick(config.git.branching, {
|
|
1020
|
+
task_id: task.task_id,
|
|
1021
|
+
milestone_id: task.milestone_id,
|
|
1022
|
+
run_id: state.run_id,
|
|
1023
|
+
tick_count: wsState.budgets.ticks + 1,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
// Unknown mode - should not happen due to TypeScript, but handle gracefully
|
|
1028
|
+
branchingError = `Unsupported branching mode: ${config.git.branching.mode}`;
|
|
1029
|
+
}
|
|
1030
|
+
// Handle branching errors or failures
|
|
1031
|
+
if (branchingError || (branchResult && !branchResult.ok)) {
|
|
1032
|
+
const errorMsg = branchingError || branchResult?.error || 'Unknown branching error';
|
|
1033
|
+
// Branching failed or config invalid - return BLOCKED report
|
|
1034
|
+
if (lockAcquired) {
|
|
1035
|
+
await releaseLock(lockPath);
|
|
1036
|
+
lockAcquired = false;
|
|
1037
|
+
}
|
|
1038
|
+
const report = generateReport({
|
|
1039
|
+
...state,
|
|
1040
|
+
errors: [`Branch creation/switch failed: ${errorMsg}`],
|
|
1041
|
+
}, 'BLOCKED_BRANCH_FAILED', 'blocked');
|
|
1042
|
+
report.budgets.ticks = 1;
|
|
1043
|
+
report.budgets.orchestrator_calls = 1;
|
|
1044
|
+
report.budgets.warnings.push(`Failed to create/switch branch: ${errorMsg}. ` +
|
|
1045
|
+
`Check git repository state and ensure branching configuration is valid.`);
|
|
1046
|
+
const blockedData = buildBlockedData('BLOCKED_BRANCH_FAILED', `Branch creation/switch failed: ${errorMsg}`);
|
|
1047
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1048
|
+
return report;
|
|
1049
|
+
}
|
|
1050
|
+
if (branchResult) {
|
|
1051
|
+
currentBranchName = branchResult.branchName;
|
|
1052
|
+
console.log(`[BRANCHING] Branch ensured: ${currentBranchName} (existed: ${branchResult.existed})`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
// Phase 4: BUILD
|
|
1056
|
+
console.log(`[${TickPhase.BUILD}] Running builder...`);
|
|
1057
|
+
if (isDebugEnabled() && state.task) {
|
|
1058
|
+
console.log(`[BUILD_DEBUG] task_id=${state.task.task_id}, task_kind=${state.task.task_kind}, max_turns=${state.task.builder?.max_turns ?? 'N/A'}`);
|
|
1059
|
+
}
|
|
1060
|
+
state = transitionPhase(state, TickPhase.BUILD);
|
|
1061
|
+
if (!state.task) {
|
|
1062
|
+
// This should not happen, but handle gracefully
|
|
1063
|
+
if (lockAcquired) {
|
|
1064
|
+
await releaseLock(lockPath);
|
|
1065
|
+
lockAcquired = false;
|
|
1066
|
+
}
|
|
1067
|
+
const report = generateReport({
|
|
1068
|
+
...state,
|
|
1069
|
+
errors: ['No task available for builder'],
|
|
1070
|
+
}, 'STOP_INTERRUPTED', 'stop');
|
|
1071
|
+
report.budgets.ticks = 1;
|
|
1072
|
+
report.budgets.orchestrator_calls = 1;
|
|
1073
|
+
await persistRunArtifacts({ config, report });
|
|
1074
|
+
return report;
|
|
1075
|
+
}
|
|
1076
|
+
let builderResult;
|
|
1077
|
+
try {
|
|
1078
|
+
builderResult = await runBuilder(state, state.task, signal);
|
|
1079
|
+
activeTickTokenUsage.builder = builderResult.tokenUsage ?? null;
|
|
1080
|
+
console.log(`[${TickPhase.BUILD}] Tokens: ${formatTokenUsageForLog(builderResult.tokenUsage)}`);
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
// Check if this is a transport stall
|
|
1084
|
+
if (isTransportStallError(error)) {
|
|
1085
|
+
console.log(`[${TickPhase.BUILD}] Transport stall detected`);
|
|
1086
|
+
const stallResult = await handleTransportStall(error, state.base_commit);
|
|
1087
|
+
if (lockAcquired) {
|
|
1088
|
+
await releaseLock(lockPath);
|
|
1089
|
+
lockAcquired = false;
|
|
1090
|
+
}
|
|
1091
|
+
const report = generateStallReport(state, stallResult);
|
|
1092
|
+
report.budgets.ticks = 1;
|
|
1093
|
+
report.budgets.orchestrator_calls = 1;
|
|
1094
|
+
report.budgets.builder_calls = 1;
|
|
1095
|
+
const blockedData = buildBlockedData('BLOCKED_TRANSPORT_STALLED', `Transport stalled during builder: ${stallResult.requestId || 'unknown'}`);
|
|
1096
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1097
|
+
return report;
|
|
1098
|
+
}
|
|
1099
|
+
// Not a stall - rethrow
|
|
1100
|
+
throw error;
|
|
1101
|
+
}
|
|
1102
|
+
if (!builderResult.success) {
|
|
1103
|
+
// Builder invocation failed - release lock and return stopped report
|
|
1104
|
+
if (lockAcquired) {
|
|
1105
|
+
await releaseLock(lockPath);
|
|
1106
|
+
lockAcquired = false;
|
|
1107
|
+
}
|
|
1108
|
+
// Check for cursor missing config - this is a deterministic BLOCKED condition
|
|
1109
|
+
if (builderResult.validationErrors.includes('STOP_CURSOR_CONFIG_MISSING')) {
|
|
1110
|
+
// Persist builder failure artifacts for debugging
|
|
1111
|
+
if (builderResult.rawResponse) {
|
|
1112
|
+
try {
|
|
1113
|
+
await persistBuilderFailure(state.run_id, builderResult.rawResponse, null, // stderr not available from builder
|
|
1114
|
+
{
|
|
1115
|
+
kind: 'cli_error',
|
|
1116
|
+
message: builderResult.validationErrors.join('; ') || 'Builder invocation failed',
|
|
1117
|
+
details: { validationErrors: builderResult.validationErrors },
|
|
1118
|
+
}, config);
|
|
1119
|
+
}
|
|
1120
|
+
catch (persistError) {
|
|
1121
|
+
// Log but don't fail the tick due to persistence issues
|
|
1122
|
+
console.warn(`Failed to persist builder failure: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const report = generateReport({
|
|
1126
|
+
...state,
|
|
1127
|
+
builder_result: builderResult.result,
|
|
1128
|
+
errors: ['Builder mode is cursor but cursor config is missing'],
|
|
1129
|
+
}, 'BLOCKED_MISSING_CONFIG', 'blocked');
|
|
1130
|
+
report.budgets.ticks = 1;
|
|
1131
|
+
report.budgets.orchestrator_calls = 1;
|
|
1132
|
+
report.budgets.builder_calls = 1;
|
|
1133
|
+
report.budgets.warnings.push('Task requested builder.mode="cursor" but envoi.config.json does not have builder.cursor configured. ' +
|
|
1134
|
+
'Either configure builder.cursor in envoi.config.json or change the task to use a different builder mode.');
|
|
1135
|
+
const blockedData = buildBlockedData('BLOCKED_MISSING_CONFIG', 'Cursor builder mode selected but cursor config is missing. Configure builder.cursor in envoi.config.json or use a different builder mode.');
|
|
1136
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1137
|
+
return report;
|
|
1138
|
+
}
|
|
1139
|
+
// Check if validationErrors contains a known BLOCKED_* report code
|
|
1140
|
+
const explicitBlockedCode = builderResult.validationErrors.find((err) => isValidReportCode(err) && err.startsWith('BLOCKED_'));
|
|
1141
|
+
if (explicitBlockedCode) {
|
|
1142
|
+
// Persist builder failure artifacts for debugging
|
|
1143
|
+
if (builderResult.rawResponse) {
|
|
1144
|
+
try {
|
|
1145
|
+
await persistBuilderFailure(state.run_id, builderResult.rawResponse, null, // stderr not available from builder
|
|
1146
|
+
{
|
|
1147
|
+
kind: 'cli_error',
|
|
1148
|
+
message: builderResult.validationErrors.join('; ') || 'Builder invocation failed',
|
|
1149
|
+
details: { validationErrors: builderResult.validationErrors },
|
|
1150
|
+
}, config);
|
|
1151
|
+
}
|
|
1152
|
+
catch (persistError) {
|
|
1153
|
+
// Log but don't fail the tick due to persistence issues
|
|
1154
|
+
console.warn(`Failed to persist builder failure: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
const report = generateReport({
|
|
1158
|
+
...state,
|
|
1159
|
+
builder_result: builderResult.result,
|
|
1160
|
+
errors: builderResult.rawResponse
|
|
1161
|
+
? [`Builder preflight failed: ${builderResult.rawResponse.substring(0, 200)}`]
|
|
1162
|
+
: ['Builder preflight failed'],
|
|
1163
|
+
}, explicitBlockedCode, 'blocked');
|
|
1164
|
+
report.budgets.ticks = 1;
|
|
1165
|
+
report.budgets.orchestrator_calls = 1;
|
|
1166
|
+
report.budgets.builder_calls = 1;
|
|
1167
|
+
if (builderResult.rawResponse) {
|
|
1168
|
+
report.budgets.warnings.push(compactWarningText(builderResult.rawResponse));
|
|
1169
|
+
}
|
|
1170
|
+
const blockedData = buildBlockedData(explicitBlockedCode, builderResult.rawResponse || 'Builder preflight failed');
|
|
1171
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1172
|
+
return report;
|
|
1173
|
+
}
|
|
1174
|
+
// Prefer explicit STOP_* codes from validationErrors over parseErrorKind mapping
|
|
1175
|
+
let reportCode = 'STOP_INTERRUPTED';
|
|
1176
|
+
// Check if validationErrors contains a known STOP_* report code
|
|
1177
|
+
const explicitStopCode = builderResult.validationErrors.find((err) => isValidReportCode(err) && err.startsWith('STOP_'));
|
|
1178
|
+
if (explicitStopCode) {
|
|
1179
|
+
reportCode = explicitStopCode;
|
|
1180
|
+
}
|
|
1181
|
+
else if (builderResult.parseErrorKind) {
|
|
1182
|
+
// Fall back to parseErrorKind mapping if no explicit code found
|
|
1183
|
+
switch (builderResult.parseErrorKind) {
|
|
1184
|
+
case 'json_parse':
|
|
1185
|
+
reportCode = 'STOP_BUILDER_JSON_PARSE';
|
|
1186
|
+
break;
|
|
1187
|
+
case 'schema':
|
|
1188
|
+
reportCode = 'STOP_BUILDER_SCHEMA_INVALID';
|
|
1189
|
+
break;
|
|
1190
|
+
case 'shape':
|
|
1191
|
+
reportCode = 'STOP_BUILDER_SHAPE_INVALID';
|
|
1192
|
+
break;
|
|
1193
|
+
case 'cli_error':
|
|
1194
|
+
reportCode = 'STOP_BUILDER_CLI_ERROR';
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// Persist builder failure artifacts for debugging
|
|
1199
|
+
if (builderResult.rawResponse) {
|
|
1200
|
+
try {
|
|
1201
|
+
await persistBuilderFailure(state.run_id, builderResult.rawResponse, null, // stderr not available from builder
|
|
1202
|
+
{
|
|
1203
|
+
kind: builderResult.parseErrorKind ?? 'cli_error',
|
|
1204
|
+
message: builderResult.validationErrors.join('; ') || 'Builder invocation failed',
|
|
1205
|
+
details: { validationErrors: builderResult.validationErrors },
|
|
1206
|
+
}, config);
|
|
1207
|
+
}
|
|
1208
|
+
catch (persistError) {
|
|
1209
|
+
// Log but don't fail the tick due to persistence issues
|
|
1210
|
+
console.warn(`Failed to persist builder failure: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const report = generateReport({
|
|
1214
|
+
...state,
|
|
1215
|
+
builder_result: builderResult.result,
|
|
1216
|
+
errors: builderResult.rawResponse
|
|
1217
|
+
? [`Builder invocation failed: ${builderResult.rawResponse.substring(0, 200)}`]
|
|
1218
|
+
: ['Builder invocation failed'],
|
|
1219
|
+
}, reportCode, 'stop');
|
|
1220
|
+
report.budgets.ticks = 1;
|
|
1221
|
+
report.budgets.orchestrator_calls = 1;
|
|
1222
|
+
report.budgets.builder_calls = 1;
|
|
1223
|
+
await persistRunArtifacts({ config, report });
|
|
1224
|
+
return report;
|
|
1225
|
+
}
|
|
1226
|
+
// Update state with builder result
|
|
1227
|
+
state = {
|
|
1228
|
+
...state,
|
|
1229
|
+
builder_result: builderResult.result,
|
|
1230
|
+
};
|
|
1231
|
+
if (builderResult.builderOutputValid) {
|
|
1232
|
+
console.log(`[${TickPhase.BUILD}] Builder completed successfully`);
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
console.log(`[${TickPhase.BUILD}] Builder completed but output was invalid JSON (lenient mode)`);
|
|
1236
|
+
}
|
|
1237
|
+
// Phase 5: JUDGE
|
|
1238
|
+
console.log(`[${TickPhase.JUDGE}] Running judge phase...`);
|
|
1239
|
+
state = transitionPhase(state, TickPhase.JUDGE);
|
|
1240
|
+
// Step 1: Check if HEAD moved externally
|
|
1241
|
+
const headCheck = checkHeadMoved(state.base_commit);
|
|
1242
|
+
if (!headCheck.ok) {
|
|
1243
|
+
console.log(`[${TickPhase.JUDGE}] HEAD moved externally`);
|
|
1244
|
+
if (lockAcquired) {
|
|
1245
|
+
await releaseLock(lockPath);
|
|
1246
|
+
lockAcquired = false;
|
|
1247
|
+
}
|
|
1248
|
+
const report = generateJudgeStopReport(state, 'STOP_HEAD_MOVED', { files_touched: 0, lines_added: 0, lines_deleted: 0, new_files: 0 }, [], [], headCheck.reason || 'HEAD moved externally');
|
|
1249
|
+
report.budgets.ticks = 1;
|
|
1250
|
+
report.budgets.orchestrator_calls = 1;
|
|
1251
|
+
report.budgets.builder_calls = 1;
|
|
1252
|
+
await persistRunArtifacts({ config, report });
|
|
1253
|
+
return report;
|
|
1254
|
+
}
|
|
1255
|
+
// Step 2: Get touched files
|
|
1256
|
+
let touched;
|
|
1257
|
+
try {
|
|
1258
|
+
touched = getTouchedFiles(state.base_commit);
|
|
1259
|
+
}
|
|
1260
|
+
catch (error) {
|
|
1261
|
+
console.log(`[${TickPhase.JUDGE}] Failed to get touched files: ${error}`);
|
|
1262
|
+
if (lockAcquired) {
|
|
1263
|
+
await releaseLock(lockPath);
|
|
1264
|
+
lockAcquired = false;
|
|
1265
|
+
}
|
|
1266
|
+
const report = generateReport(state, 'STOP_INTERRUPTED', 'stop');
|
|
1267
|
+
report.budgets.ticks = 1;
|
|
1268
|
+
report.budgets.orchestrator_calls = 1;
|
|
1269
|
+
report.budgets.builder_calls = 1;
|
|
1270
|
+
await persistRunArtifacts({ config, report });
|
|
1271
|
+
return report;
|
|
1272
|
+
}
|
|
1273
|
+
console.log(`[${TickPhase.JUDGE}] Touched files: ${touched.all.length}`);
|
|
1274
|
+
// Step 3: Compute blast radius
|
|
1275
|
+
let blastRadius;
|
|
1276
|
+
try {
|
|
1277
|
+
blastRadius = computeBlastRadius(state.base_commit, touched);
|
|
1278
|
+
}
|
|
1279
|
+
catch (error) {
|
|
1280
|
+
console.log(`[${TickPhase.JUDGE}] Failed to compute blast radius: ${error}`);
|
|
1281
|
+
blastRadius = { files_touched: touched.all.length, lines_added: 0, lines_deleted: 0, new_files: 0 };
|
|
1282
|
+
}
|
|
1283
|
+
console.log(`[${TickPhase.JUDGE}] Blast radius: ${blastRadius.files_touched} files, ${blastRadius.lines_added}+ ${blastRadius.lines_deleted}- lines`);
|
|
1284
|
+
// Step 4: Check scope violations
|
|
1285
|
+
if (state.task) {
|
|
1286
|
+
const scopeCheck = checkScopeViolations(touched, state.task.scope, config.scope, config.runner.runner_owned_globs);
|
|
1287
|
+
if (!scopeCheck.ok && scopeCheck.stopCode) {
|
|
1288
|
+
console.log(`[${TickPhase.JUDGE}] Scope violation: ${scopeCheck.stopCode}`);
|
|
1289
|
+
// Rollback and check cleanliness
|
|
1290
|
+
const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
|
|
1291
|
+
if (rollbackCheck.blockedCode) {
|
|
1292
|
+
// Rollback failed or worktree dirty - return BLOCKED
|
|
1293
|
+
if (lockAcquired) {
|
|
1294
|
+
await releaseLock(lockPath);
|
|
1295
|
+
lockAcquired = false;
|
|
1296
|
+
}
|
|
1297
|
+
const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
|
|
1298
|
+
report.budgets.ticks = 1;
|
|
1299
|
+
report.budgets.orchestrator_calls = 1;
|
|
1300
|
+
report.budgets.builder_calls = 1;
|
|
1301
|
+
const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
|
|
1302
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1303
|
+
return report;
|
|
1304
|
+
}
|
|
1305
|
+
// Rollback succeeded and worktree is clean - proceed with STOP code
|
|
1306
|
+
if (lockAcquired) {
|
|
1307
|
+
await releaseLock(lockPath);
|
|
1308
|
+
lockAcquired = false;
|
|
1309
|
+
}
|
|
1310
|
+
const report = generateJudgeStopReport(state, scopeCheck.stopCode, blastRadius, touched.all, scopeCheck.violatingFiles, scopeCheck.reason || 'Scope violation');
|
|
1311
|
+
report.budgets.ticks = 1;
|
|
1312
|
+
report.budgets.orchestrator_calls = 1;
|
|
1313
|
+
report.budgets.builder_calls = 1;
|
|
1314
|
+
await persistRunArtifacts({ config, report });
|
|
1315
|
+
return report;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
// Step 5: Check diff limits
|
|
1319
|
+
const diffLimits = state.task?.diff_limits || {
|
|
1320
|
+
max_files_touched: config.diff_limits.default_max_files_touched,
|
|
1321
|
+
max_lines_changed: config.diff_limits.default_max_lines_changed,
|
|
1322
|
+
};
|
|
1323
|
+
const diffCheck = checkDiffLimits(blastRadius, diffLimits);
|
|
1324
|
+
if (!diffCheck.ok && diffCheck.stopCode) {
|
|
1325
|
+
console.log(`[${TickPhase.JUDGE}] Diff too large: ${diffCheck.reason}`);
|
|
1326
|
+
// Rollback and check cleanliness
|
|
1327
|
+
const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
|
|
1328
|
+
if (rollbackCheck.blockedCode) {
|
|
1329
|
+
// Rollback failed or worktree dirty - return BLOCKED
|
|
1330
|
+
if (lockAcquired) {
|
|
1331
|
+
await releaseLock(lockPath);
|
|
1332
|
+
lockAcquired = false;
|
|
1333
|
+
}
|
|
1334
|
+
const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
|
|
1335
|
+
report.budgets.ticks = 1;
|
|
1336
|
+
report.budgets.orchestrator_calls = 1;
|
|
1337
|
+
report.budgets.builder_calls = 1;
|
|
1338
|
+
const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
|
|
1339
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1340
|
+
return report;
|
|
1341
|
+
}
|
|
1342
|
+
// Rollback succeeded and worktree is clean - proceed with STOP code
|
|
1343
|
+
if (lockAcquired) {
|
|
1344
|
+
await releaseLock(lockPath);
|
|
1345
|
+
lockAcquired = false;
|
|
1346
|
+
}
|
|
1347
|
+
const report = generateJudgeStopReport(state, diffCheck.stopCode, blastRadius, touched.all, [], diffCheck.reason || 'Diff too large');
|
|
1348
|
+
report.budgets.ticks = 1;
|
|
1349
|
+
report.budgets.orchestrator_calls = 1;
|
|
1350
|
+
report.budgets.builder_calls = 1;
|
|
1351
|
+
await persistRunArtifacts({ config, report });
|
|
1352
|
+
return report;
|
|
1353
|
+
}
|
|
1354
|
+
// Step 6: Check task_kind side effects
|
|
1355
|
+
if (state.task && touched.all.length > 0) {
|
|
1356
|
+
if (state.task.task_kind === 'question') {
|
|
1357
|
+
console.log(`[${TickPhase.JUDGE}] Question task has side effects`);
|
|
1358
|
+
// Rollback and check cleanliness
|
|
1359
|
+
const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
|
|
1360
|
+
if (rollbackCheck.blockedCode) {
|
|
1361
|
+
// Rollback failed or worktree dirty - return BLOCKED
|
|
1362
|
+
if (lockAcquired) {
|
|
1363
|
+
await releaseLock(lockPath);
|
|
1364
|
+
lockAcquired = false;
|
|
1365
|
+
}
|
|
1366
|
+
const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
|
|
1367
|
+
report.budgets.ticks = 1;
|
|
1368
|
+
report.budgets.orchestrator_calls = 1;
|
|
1369
|
+
report.budgets.builder_calls = 1;
|
|
1370
|
+
const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
|
|
1371
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1372
|
+
return report;
|
|
1373
|
+
}
|
|
1374
|
+
// Rollback succeeded and worktree is clean - proceed with STOP code
|
|
1375
|
+
if (lockAcquired) {
|
|
1376
|
+
await releaseLock(lockPath);
|
|
1377
|
+
lockAcquired = false;
|
|
1378
|
+
}
|
|
1379
|
+
const report = generateJudgeStopReport(state, 'STOP_QUESTION_SIDE_EFFECTS', blastRadius, touched.all, touched.all, 'Question task made file changes');
|
|
1380
|
+
report.budgets.ticks = 1;
|
|
1381
|
+
report.budgets.orchestrator_calls = 1;
|
|
1382
|
+
report.budgets.builder_calls = 1;
|
|
1383
|
+
await persistRunArtifacts({ config, report });
|
|
1384
|
+
return report;
|
|
1385
|
+
}
|
|
1386
|
+
if (state.task.task_kind === 'verify_only') {
|
|
1387
|
+
console.log(`[${TickPhase.JUDGE}] Verify-only task has side effects`);
|
|
1388
|
+
// Rollback and check cleanliness
|
|
1389
|
+
const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
|
|
1390
|
+
if (rollbackCheck.blockedCode) {
|
|
1391
|
+
// Rollback failed or worktree dirty - return BLOCKED
|
|
1392
|
+
if (lockAcquired) {
|
|
1393
|
+
await releaseLock(lockPath);
|
|
1394
|
+
lockAcquired = false;
|
|
1395
|
+
}
|
|
1396
|
+
const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
|
|
1397
|
+
report.budgets.ticks = 1;
|
|
1398
|
+
report.budgets.orchestrator_calls = 1;
|
|
1399
|
+
report.budgets.builder_calls = 1;
|
|
1400
|
+
const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
|
|
1401
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1402
|
+
return report;
|
|
1403
|
+
}
|
|
1404
|
+
// Rollback succeeded and worktree is clean - proceed with STOP code
|
|
1405
|
+
if (lockAcquired) {
|
|
1406
|
+
await releaseLock(lockPath);
|
|
1407
|
+
lockAcquired = false;
|
|
1408
|
+
}
|
|
1409
|
+
const report = generateJudgeStopReport(state, 'STOP_VERIFY_ONLY_SIDE_EFFECTS', blastRadius, touched.all, touched.all, 'Verify-only task made file changes');
|
|
1410
|
+
report.budgets.ticks = 1;
|
|
1411
|
+
report.budgets.orchestrator_calls = 1;
|
|
1412
|
+
report.budgets.builder_calls = 1;
|
|
1413
|
+
await persistRunArtifacts({ config, report });
|
|
1414
|
+
return report;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
console.log(`[${TickPhase.JUDGE}] All checks passed`);
|
|
1418
|
+
// Step 7: Validate verification params
|
|
1419
|
+
if (state.task && state.task.verification) {
|
|
1420
|
+
const allParams = {};
|
|
1421
|
+
const taskParams = state.task.verification.params || {};
|
|
1422
|
+
for (const [templateId, templateParams] of Object.entries(taskParams)) {
|
|
1423
|
+
for (const [key, value] of Object.entries(templateParams)) {
|
|
1424
|
+
if (typeof value === 'string') {
|
|
1425
|
+
allParams[`${templateId}.${key}`] = value;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
if (Object.keys(allParams).length > 0) {
|
|
1430
|
+
const paramCheck = validateAllParams(allParams, config.verification);
|
|
1431
|
+
if (!paramCheck.ok) {
|
|
1432
|
+
console.log(`[${TickPhase.JUDGE}] Verification params tainted: ${paramCheck.reason}`);
|
|
1433
|
+
// Rollback and check cleanliness
|
|
1434
|
+
const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
|
|
1435
|
+
if (rollbackCheck.blockedCode) {
|
|
1436
|
+
// Rollback failed or worktree dirty - return BLOCKED
|
|
1437
|
+
if (lockAcquired) {
|
|
1438
|
+
await releaseLock(lockPath);
|
|
1439
|
+
lockAcquired = false;
|
|
1440
|
+
}
|
|
1441
|
+
const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
|
|
1442
|
+
report.budgets.ticks = 1;
|
|
1443
|
+
report.budgets.orchestrator_calls = 1;
|
|
1444
|
+
report.budgets.builder_calls = 1;
|
|
1445
|
+
const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
|
|
1446
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1447
|
+
return report;
|
|
1448
|
+
}
|
|
1449
|
+
// Rollback succeeded and worktree is clean - proceed with STOP code
|
|
1450
|
+
if (lockAcquired) {
|
|
1451
|
+
await releaseLock(lockPath);
|
|
1452
|
+
lockAcquired = false;
|
|
1453
|
+
}
|
|
1454
|
+
const report = generateJudgeStopReport(state, 'STOP_VERIFY_TAINTED', blastRadius, touched.all, [], paramCheck.reason || 'Verification params tainted');
|
|
1455
|
+
report.budgets.ticks = 1;
|
|
1456
|
+
report.budgets.orchestrator_calls = 1;
|
|
1457
|
+
report.budgets.builder_calls = 1;
|
|
1458
|
+
await persistRunArtifacts({ config, report });
|
|
1459
|
+
return report;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
// Step 8: Run fast verification
|
|
1464
|
+
if (state.task?.verification?.fast && state.task.verification.fast.length > 0) {
|
|
1465
|
+
console.log(`[${TickPhase.JUDGE}] Running fast verification...`);
|
|
1466
|
+
const fastResult = await runVerificationPhase(state.task.verification.fast, config.verification.templates, state.task.verification.params || {}, 'fast', config.verification.timeout_fast_seconds);
|
|
1467
|
+
if (!fastResult.ok && fastResult.stopCode) {
|
|
1468
|
+
console.log(`[${TickPhase.JUDGE}] Fast verification failed: ${fastResult.reason}`);
|
|
1469
|
+
// Rollback and check cleanliness
|
|
1470
|
+
const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
|
|
1471
|
+
if (rollbackCheck.blockedCode) {
|
|
1472
|
+
// Rollback failed or worktree dirty - return BLOCKED
|
|
1473
|
+
if (lockAcquired) {
|
|
1474
|
+
await releaseLock(lockPath);
|
|
1475
|
+
lockAcquired = false;
|
|
1476
|
+
}
|
|
1477
|
+
const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
|
|
1478
|
+
report.verification.runs = fastResult.runs;
|
|
1479
|
+
report.budgets.ticks = 1;
|
|
1480
|
+
report.budgets.orchestrator_calls = 1;
|
|
1481
|
+
report.budgets.builder_calls = 1;
|
|
1482
|
+
report.budgets.verify_runs = fastResult.runs.length;
|
|
1483
|
+
const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
|
|
1484
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1485
|
+
return report;
|
|
1486
|
+
}
|
|
1487
|
+
// Rollback succeeded and worktree is clean - proceed with STOP code
|
|
1488
|
+
if (lockAcquired) {
|
|
1489
|
+
await releaseLock(lockPath);
|
|
1490
|
+
lockAcquired = false;
|
|
1491
|
+
}
|
|
1492
|
+
const report = generateJudgeStopReport(state, fastResult.stopCode, blastRadius, touched.all, [], fastResult.reason || 'Fast verification failed');
|
|
1493
|
+
report.verification.runs = fastResult.runs;
|
|
1494
|
+
report.budgets.ticks = 1;
|
|
1495
|
+
report.budgets.orchestrator_calls = 1;
|
|
1496
|
+
report.budgets.builder_calls = 1;
|
|
1497
|
+
report.budgets.verify_runs = fastResult.runs.length;
|
|
1498
|
+
await persistRunArtifacts({ config, report });
|
|
1499
|
+
return report;
|
|
1500
|
+
}
|
|
1501
|
+
console.log(`[${TickPhase.JUDGE}] Fast verification passed`);
|
|
1502
|
+
}
|
|
1503
|
+
// Step 9: Run slow verification (only if fast passed)
|
|
1504
|
+
if (state.task?.verification?.slow && state.task.verification.slow.length > 0) {
|
|
1505
|
+
console.log(`[${TickPhase.JUDGE}] Running slow verification...`);
|
|
1506
|
+
const slowResult = await runVerificationPhase(state.task.verification.slow, config.verification.templates, state.task.verification.params || {}, 'slow', config.verification.timeout_slow_seconds);
|
|
1507
|
+
if (!slowResult.ok && slowResult.stopCode) {
|
|
1508
|
+
console.log(`[${TickPhase.JUDGE}] Slow verification failed: ${slowResult.reason}`);
|
|
1509
|
+
// Rollback and check cleanliness
|
|
1510
|
+
const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
|
|
1511
|
+
if (rollbackCheck.blockedCode) {
|
|
1512
|
+
// Rollback failed or worktree dirty - return BLOCKED
|
|
1513
|
+
if (lockAcquired) {
|
|
1514
|
+
await releaseLock(lockPath);
|
|
1515
|
+
lockAcquired = false;
|
|
1516
|
+
}
|
|
1517
|
+
const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
|
|
1518
|
+
report.verification.runs = slowResult.runs;
|
|
1519
|
+
// Count fast verification runs that passed (from state.task.verification.fast)
|
|
1520
|
+
const fastVerifyCount = state.task?.verification?.fast?.length || 0;
|
|
1521
|
+
report.budgets.ticks = 1;
|
|
1522
|
+
report.budgets.orchestrator_calls = 1;
|
|
1523
|
+
report.budgets.builder_calls = 1;
|
|
1524
|
+
report.budgets.verify_runs = fastVerifyCount + slowResult.runs.length;
|
|
1525
|
+
const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
|
|
1526
|
+
await persistRunArtifacts({ config, report, blockedData });
|
|
1527
|
+
return report;
|
|
1528
|
+
}
|
|
1529
|
+
// Rollback succeeded and worktree is clean - proceed with STOP code
|
|
1530
|
+
if (lockAcquired) {
|
|
1531
|
+
await releaseLock(lockPath);
|
|
1532
|
+
lockAcquired = false;
|
|
1533
|
+
}
|
|
1534
|
+
const report = generateJudgeStopReport(state, slowResult.stopCode, blastRadius, touched.all, [], slowResult.reason || 'Slow verification failed');
|
|
1535
|
+
report.verification.runs = slowResult.runs;
|
|
1536
|
+
// Count fast verification runs that passed (from state.task.verification.fast)
|
|
1537
|
+
const fastVerifyCount = state.task?.verification?.fast?.length || 0;
|
|
1538
|
+
report.budgets.ticks = 1;
|
|
1539
|
+
report.budgets.orchestrator_calls = 1;
|
|
1540
|
+
report.budgets.builder_calls = 1;
|
|
1541
|
+
report.budgets.verify_runs = fastVerifyCount + slowResult.runs.length;
|
|
1542
|
+
await persistRunArtifacts({ config, report });
|
|
1543
|
+
return report;
|
|
1544
|
+
}
|
|
1545
|
+
console.log(`[${TickPhase.JUDGE}] Slow verification passed`);
|
|
1546
|
+
}
|
|
1547
|
+
console.log(`[${TickPhase.JUDGE}] Verification complete`);
|
|
1548
|
+
// Phase 6: REPORT
|
|
1549
|
+
console.log(`[${TickPhase.REPORT}] Generating report...`);
|
|
1550
|
+
state = transitionPhase(state, TickPhase.REPORT);
|
|
1551
|
+
const report = generateReport(state, 'SUCCESS', 'success');
|
|
1552
|
+
// Count verification runs for budgets
|
|
1553
|
+
const fastVerifyCount = state.task?.verification?.fast?.length || 0;
|
|
1554
|
+
const slowVerifyCount = state.task?.verification?.slow?.length || 0;
|
|
1555
|
+
report.budgets.ticks = 1;
|
|
1556
|
+
report.budgets.orchestrator_calls = 1;
|
|
1557
|
+
report.budgets.builder_calls = 1;
|
|
1558
|
+
report.budgets.verify_runs = fastVerifyCount + slowVerifyCount;
|
|
1559
|
+
// Add branch name to warnings for traceability (if branching was used)
|
|
1560
|
+
if (currentBranchName) {
|
|
1561
|
+
report.budgets.warnings.push(`Branch: ${currentBranchName}`);
|
|
1562
|
+
}
|
|
1563
|
+
const orchestratorTotal = activeTickTokenUsage?.orchestrator?.total_tokens ?? null;
|
|
1564
|
+
const builderTotal = activeTickTokenUsage?.builder?.total_tokens ?? null;
|
|
1565
|
+
const tickTotal = orchestratorTotal !== null || builderTotal !== null
|
|
1566
|
+
? (orchestratorTotal ?? 0) + (builderTotal ?? 0)
|
|
1567
|
+
: null;
|
|
1568
|
+
console.log(`[${TickPhase.REPORT}] Token totals: orchestrator=${tokenNumber(orchestratorTotal)} builder=${tokenNumber(builderTotal)} tick_total=${tokenNumber(tickTotal)}`);
|
|
1569
|
+
await persistRunArtifacts({ config, report });
|
|
1570
|
+
console.log(`[${TickPhase.REPORT}] Artifacts persisted`);
|
|
1571
|
+
// Phase 7: END
|
|
1572
|
+
console.log(`[${TickPhase.END}] Releasing lock...`);
|
|
1573
|
+
state = transitionPhase(state, TickPhase.END);
|
|
1574
|
+
// Cleanup signal handlers before releasing lock
|
|
1575
|
+
if (signalCleanup) {
|
|
1576
|
+
signalCleanup();
|
|
1577
|
+
signalCleanup = null;
|
|
1578
|
+
}
|
|
1579
|
+
await releaseLock(lockPath);
|
|
1580
|
+
lockAcquired = false;
|
|
1581
|
+
console.log(`[${TickPhase.END}] Lock released`);
|
|
1582
|
+
return report;
|
|
1583
|
+
}
|
|
1584
|
+
catch (error) {
|
|
1585
|
+
// Cleanup signal handlers before releasing lock
|
|
1586
|
+
if (signalCleanup) {
|
|
1587
|
+
signalCleanup();
|
|
1588
|
+
signalCleanup = null;
|
|
1589
|
+
}
|
|
1590
|
+
// Ensure lock is released on error
|
|
1591
|
+
if (lockAcquired) {
|
|
1592
|
+
try {
|
|
1593
|
+
await releaseLock(lockPath);
|
|
1594
|
+
}
|
|
1595
|
+
catch (releaseError) {
|
|
1596
|
+
console.error(`Failed to release lock: ${releaseError}`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
// Generate error report
|
|
1600
|
+
const errorState = state || {
|
|
1601
|
+
phase: TickPhase.END,
|
|
1602
|
+
run_id: 'error',
|
|
1603
|
+
started_at: new Date().toISOString(),
|
|
1604
|
+
base_commit: '',
|
|
1605
|
+
config,
|
|
1606
|
+
task: null,
|
|
1607
|
+
builder_result: null,
|
|
1608
|
+
errors: [error instanceof Error ? error.message : String(error)],
|
|
1609
|
+
};
|
|
1610
|
+
const report = generateReport(errorState, 'STOP_INTERRUPTED', 'stop');
|
|
1611
|
+
report.budgets.ticks = 1;
|
|
1612
|
+
// Set budget counts based on which phase we reached
|
|
1613
|
+
if (state) {
|
|
1614
|
+
const phase = state.phase;
|
|
1615
|
+
// If we got past ORCHESTRATE, count orchestrator call
|
|
1616
|
+
if (phase !== TickPhase.LOCK && phase !== TickPhase.PREFLIGHT && phase !== TickPhase.ORCHESTRATE) {
|
|
1617
|
+
report.budgets.orchestrator_calls = 1;
|
|
1618
|
+
}
|
|
1619
|
+
// If we got past BUILD, count builder call
|
|
1620
|
+
if (phase === TickPhase.JUDGE || phase === TickPhase.REPORT || phase === TickPhase.END) {
|
|
1621
|
+
report.budgets.builder_calls = 1;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
// Try to write error report
|
|
1625
|
+
try {
|
|
1626
|
+
await persistRunArtifacts({ config, report });
|
|
1627
|
+
}
|
|
1628
|
+
catch (writeError) {
|
|
1629
|
+
console.error(`Failed to write error report: ${writeError}`);
|
|
1630
|
+
}
|
|
1631
|
+
// For interrupt, return gracefully (don't re-throw)
|
|
1632
|
+
if (isInterruptedError(error)) {
|
|
1633
|
+
console.log('[INTERRUPT] Abort signal received; persisting STOP_INTERRUPTED report');
|
|
1634
|
+
return report;
|
|
1635
|
+
}
|
|
1636
|
+
throw error;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
//# sourceMappingURL=tick.js.map
|