@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-strategy.mjs — Codex-powered merge decision engine.
|
|
3
|
+
*
|
|
4
|
+
* When a task completes (agent goes idle, PR exists or is missing, commits
|
|
5
|
+
* detected upstream), this module hands context to Codex SDK and receives a
|
|
6
|
+
* structured action:
|
|
7
|
+
*
|
|
8
|
+
* { action: "merge_after_ci_pass" }
|
|
9
|
+
* { action: "prompt", message: "Fix the lint error in foo.ts" }
|
|
10
|
+
* { action: "close_pr", reason: "Duplicate of #123" }
|
|
11
|
+
* { action: "re_attempt", reason: "Agent didn't implement the feature" }
|
|
12
|
+
* { action: "manual_review", reason: "Big PR needs human eyes" }
|
|
13
|
+
* { action: "wait", seconds: 300, reason: "CI still running" }
|
|
14
|
+
*
|
|
15
|
+
* Enhanced with thread-aware execution:
|
|
16
|
+
* - "prompt" resumes the original agent thread (full context preserved)
|
|
17
|
+
* - "re_attempt" uses execWithRetry for automatic error recovery
|
|
18
|
+
* - "merge_after_ci_pass" enables gh auto-merge
|
|
19
|
+
* - "close_pr" closes the PR with a comment
|
|
20
|
+
* - Self-contained: can use agent-pool directly without injected execCodex
|
|
21
|
+
*
|
|
22
|
+
* Safety:
|
|
23
|
+
* - 10 minute timeout (configurable via MERGE_STRATEGY_TIMEOUT_MS)
|
|
24
|
+
* - Structured JSON parsing with fallback
|
|
25
|
+
* - Audit logs for every decision
|
|
26
|
+
* - Dedup: won't re-analyze the same attempt within cooldown
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
30
|
+
import { resolve } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { execSync } from "node:child_process";
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
execPooledPrompt,
|
|
36
|
+
launchOrResumeThread,
|
|
37
|
+
execWithRetry,
|
|
38
|
+
getThreadRecord,
|
|
39
|
+
invalidateThread,
|
|
40
|
+
} from "./agent-pool.mjs";
|
|
41
|
+
import { resolvePromptTemplate } from "./agent-prompts.mjs";
|
|
42
|
+
|
|
43
|
+
const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
44
|
+
|
|
45
|
+
// ── Valid actions ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const VALID_ACTIONS = new Set([
|
|
48
|
+
"merge_after_ci_pass",
|
|
49
|
+
"prompt",
|
|
50
|
+
"close_pr",
|
|
51
|
+
"re_attempt",
|
|
52
|
+
"manual_review",
|
|
53
|
+
"wait",
|
|
54
|
+
"noop",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// ── Dedup / rate limiting ───────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** @type {Map<string, number>} attemptId → last analysis timestamp */
|
|
60
|
+
const analysisDedup = new Map();
|
|
61
|
+
const ANALYSIS_COOLDOWN_MS = 10 * 60 * 1000; // 10 min per attempt
|
|
62
|
+
|
|
63
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {object} MergeContext
|
|
67
|
+
* @property {string} attemptId - Full attempt UUID
|
|
68
|
+
* @property {string} shortId - Short ID for logging
|
|
69
|
+
* @property {string} status - Attempt status (completed, failed, etc.)
|
|
70
|
+
* @property {string} [agentLastMessage] - Last message from the agent
|
|
71
|
+
* @property {string} [prTitle] - PR title (if created)
|
|
72
|
+
* @property {number} [prNumber] - PR number (if created)
|
|
73
|
+
* @property {string} [prUrl] - PR URL (if created)
|
|
74
|
+
* @property {string} [prState] - PR state (open, closed, merged)
|
|
75
|
+
* @property {string} [branch] - Branch name
|
|
76
|
+
* @property {number} [commitsAhead] - Commits ahead of main
|
|
77
|
+
* @property {number} [commitsBehind] - Commits behind main
|
|
78
|
+
* @property {number} [filesChanged] - Number of files changed
|
|
79
|
+
* @property {string} [diffStat] - Git diff --stat output
|
|
80
|
+
* @property {string[]} [changedFiles] - List of changed file paths
|
|
81
|
+
* @property {string} [taskTitle] - Original task title
|
|
82
|
+
* @property {string} [taskDescription] - Original task description
|
|
83
|
+
* @property {string} [worktreeDir] - Local worktree directory
|
|
84
|
+
* @property {string} [ciStatus] - CI status if known (pending, passing, failing)
|
|
85
|
+
* @property {string} [taskKey] - Task key for thread registry lookup (links to original agent thread)
|
|
86
|
+
* @property {string} [baseBranch] - Base branch for diff comparison (default: "origin/main")
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {object} MergeDecision
|
|
91
|
+
* @property {string} action - One of VALID_ACTIONS
|
|
92
|
+
* @property {string} [message] - For "prompt" action
|
|
93
|
+
* @property {string} [reason] - Explanation for the decision
|
|
94
|
+
* @property {number} [seconds] - For "wait" action
|
|
95
|
+
* @property {boolean} success - Whether analysis completed
|
|
96
|
+
* @property {string} rawOutput - Raw Codex output for audit
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {object} ExecutionResult
|
|
101
|
+
* @property {boolean} executed Whether the action was executed
|
|
102
|
+
* @property {string} action The action that was taken
|
|
103
|
+
* @property {boolean} success Whether execution succeeded
|
|
104
|
+
* @property {string} [output] Agent output (for prompt/re_attempt)
|
|
105
|
+
* @property {string} [error] Error message if failed
|
|
106
|
+
* @property {boolean} [resumed] Whether an existing thread was resumed
|
|
107
|
+
* @property {number} [attempts] Number of attempts (for re_attempt)
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
// ── Prompt builder ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build the analysis prompt for Codex SDK.
|
|
114
|
+
*/
|
|
115
|
+
function buildMergeStrategyPrompt(ctx, promptTemplate = "") {
|
|
116
|
+
const parts = [];
|
|
117
|
+
|
|
118
|
+
parts.push(`# Merge Strategy Decision
|
|
119
|
+
|
|
120
|
+
You are a senior engineering reviewer. An AI agent has completed (or attempted) a task.
|
|
121
|
+
Review the context below and decide the NEXT ACTION.
|
|
122
|
+
|
|
123
|
+
## Task Context`);
|
|
124
|
+
|
|
125
|
+
if (ctx.taskTitle) parts.push(`**Task:** ${ctx.taskTitle}`);
|
|
126
|
+
if (ctx.taskDescription) {
|
|
127
|
+
parts.push(`**Description:** ${ctx.taskDescription.slice(0, 2000)}`);
|
|
128
|
+
}
|
|
129
|
+
parts.push(`**Status:** ${ctx.status}`);
|
|
130
|
+
if (ctx.branch) parts.push(`**Branch:** ${ctx.branch}`);
|
|
131
|
+
|
|
132
|
+
// Agent's last message — this is the key signal
|
|
133
|
+
if (ctx.agentLastMessage) {
|
|
134
|
+
parts.push(`
|
|
135
|
+
## Agent's Last Message
|
|
136
|
+
\`\`\`
|
|
137
|
+
${ctx.agentLastMessage.slice(0, 8000)}
|
|
138
|
+
\`\`\``);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// PR details
|
|
142
|
+
if (ctx.prNumber) {
|
|
143
|
+
parts.push(`
|
|
144
|
+
## Pull Request
|
|
145
|
+
- PR #${ctx.prNumber}: ${ctx.prTitle || "(no title)"}
|
|
146
|
+
- State: ${ctx.prState || "unknown"}
|
|
147
|
+
- URL: ${ctx.prUrl || "N/A"}
|
|
148
|
+
- CI: ${ctx.ciStatus || "unknown"}`);
|
|
149
|
+
} else {
|
|
150
|
+
parts.push(`
|
|
151
|
+
## Pull Request
|
|
152
|
+
No PR has been created yet.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Diff / files
|
|
156
|
+
if (ctx.filesChanged != null || ctx.changedFiles?.length) {
|
|
157
|
+
parts.push(`
|
|
158
|
+
## Changes
|
|
159
|
+
- Files changed: ${ctx.filesChanged ?? ctx.changedFiles?.length ?? "unknown"}
|
|
160
|
+
- Commits ahead: ${ctx.commitsAhead ?? "unknown"}
|
|
161
|
+
- Commits behind: ${ctx.commitsBehind ?? "unknown"}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (ctx.changedFiles?.length) {
|
|
165
|
+
const fileList = ctx.changedFiles.slice(0, 50).join("\n");
|
|
166
|
+
parts.push(`
|
|
167
|
+
### Changed Files
|
|
168
|
+
\`\`\`
|
|
169
|
+
${fileList}${ctx.changedFiles.length > 50 ? `\n... and ${ctx.changedFiles.length - 50} more` : ""}
|
|
170
|
+
\`\`\``);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (ctx.diffStat) {
|
|
174
|
+
parts.push(`
|
|
175
|
+
### Diff Stats
|
|
176
|
+
\`\`\`
|
|
177
|
+
${ctx.diffStat.slice(0, 3000)}
|
|
178
|
+
\`\`\``);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (ctx.worktreeDir) {
|
|
182
|
+
parts.push(`
|
|
183
|
+
## Worktree
|
|
184
|
+
Directory: ${ctx.worktreeDir}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Decision framework
|
|
188
|
+
parts.push(`
|
|
189
|
+
## Decision Rules
|
|
190
|
+
|
|
191
|
+
Based on the above context, choose ONE action:
|
|
192
|
+
|
|
193
|
+
1. **merge_after_ci_pass** — Agent completed the task successfully, PR looks good, merge when CI passes.
|
|
194
|
+
Use when: Agent reports success ("✅ Task Complete"), changes match the task description, no obvious issues.
|
|
195
|
+
|
|
196
|
+
2. **prompt** — Agent needs to do more work. Provide a specific message telling the agent what to fix.
|
|
197
|
+
Use when: Task partially done, lint/test failures mentioned, missing files, incomplete implementation.
|
|
198
|
+
IMPORTANT: Include SPECIFIC instructions (file names, error messages, what to change).
|
|
199
|
+
|
|
200
|
+
3. **close_pr** — PR should be closed (bad implementation, wrong approach, duplicate).
|
|
201
|
+
Use when: Agent went off-track, made destructive changes, or the PR is fundamentally broken.
|
|
202
|
+
|
|
203
|
+
4. **re_attempt** — Start the task over with a fresh agent.
|
|
204
|
+
Use when: Agent crashed without useful work, context window exhausted, or approach was wrong.
|
|
205
|
+
|
|
206
|
+
5. **manual_review** — Escalate to human reviewer.
|
|
207
|
+
Use when: Large/risky changes, security-sensitive code, or you're unsure about the approach.
|
|
208
|
+
|
|
209
|
+
6. **wait** — CI is still running, wait before deciding.
|
|
210
|
+
Use when: CI status is "pending" and the changes look reasonable.
|
|
211
|
+
|
|
212
|
+
7. **noop** — No action needed (informational only, or task was already handled).
|
|
213
|
+
|
|
214
|
+
## Response Format
|
|
215
|
+
|
|
216
|
+
Respond with ONLY a JSON object (no markdown, no explanation outside the JSON):
|
|
217
|
+
|
|
218
|
+
\`\`\`json
|
|
219
|
+
{
|
|
220
|
+
"action": "merge_after_ci_pass",
|
|
221
|
+
"reason": "Agent completed all acceptance criteria. 3 files changed, tests added."
|
|
222
|
+
}
|
|
223
|
+
\`\`\`
|
|
224
|
+
|
|
225
|
+
Or for prompt:
|
|
226
|
+
|
|
227
|
+
\`\`\`json
|
|
228
|
+
{
|
|
229
|
+
"action": "prompt",
|
|
230
|
+
"message": "The ESLint check failed on src/handler.ts:42. Please fix the unused variable warning and push again.",
|
|
231
|
+
"reason": "Agent's last message mentions lint errors but didn't fix them."
|
|
232
|
+
}
|
|
233
|
+
\`\`\`
|
|
234
|
+
|
|
235
|
+
Or for wait:
|
|
236
|
+
|
|
237
|
+
\`\`\`json
|
|
238
|
+
{
|
|
239
|
+
"action": "wait",
|
|
240
|
+
"seconds": 300,
|
|
241
|
+
"reason": "CI is still running. Wait 5 minutes before re-checking."
|
|
242
|
+
}
|
|
243
|
+
\`\`\`
|
|
244
|
+
|
|
245
|
+
RESPOND WITH ONLY THE JSON OBJECT.`);
|
|
246
|
+
|
|
247
|
+
const fallback = parts.join("\n");
|
|
248
|
+
const taskContextBlock = [
|
|
249
|
+
ctx.taskTitle ? `**Task:** ${ctx.taskTitle}` : "",
|
|
250
|
+
ctx.taskDescription
|
|
251
|
+
? `**Description:** ${ctx.taskDescription.slice(0, 2000)}`
|
|
252
|
+
: "",
|
|
253
|
+
`**Status:** ${ctx.status}`,
|
|
254
|
+
ctx.branch ? `**Branch:** ${ctx.branch}` : "",
|
|
255
|
+
]
|
|
256
|
+
.filter(Boolean)
|
|
257
|
+
.join("\n");
|
|
258
|
+
return resolvePromptTemplate(
|
|
259
|
+
promptTemplate,
|
|
260
|
+
{
|
|
261
|
+
TASK_CONTEXT_BLOCK: taskContextBlock,
|
|
262
|
+
AGENT_LAST_MESSAGE_BLOCK: ctx.agentLastMessage
|
|
263
|
+
? `## Agent's Last Message\n\`\`\`\n${ctx.agentLastMessage.slice(0, 8000)}\n\`\`\``
|
|
264
|
+
: "",
|
|
265
|
+
PULL_REQUEST_BLOCK: ctx.prNumber
|
|
266
|
+
? `## Pull Request\n- PR #${ctx.prNumber}: ${ctx.prTitle || "(no title)"}\n- State: ${ctx.prState || "unknown"}\n- URL: ${ctx.prUrl || "N/A"}\n- CI: ${ctx.ciStatus || "unknown"}`
|
|
267
|
+
: "## Pull Request\nNo PR has been created yet.",
|
|
268
|
+
CHANGES_BLOCK:
|
|
269
|
+
ctx.filesChanged != null || ctx.changedFiles?.length
|
|
270
|
+
? `## Changes\n- Files changed: ${ctx.filesChanged ?? ctx.changedFiles?.length ?? "unknown"}\n- Commits ahead: ${ctx.commitsAhead ?? "unknown"}\n- Commits behind: ${ctx.commitsBehind ?? "unknown"}`
|
|
271
|
+
: "",
|
|
272
|
+
CHANGED_FILES_BLOCK: ctx.changedFiles?.length
|
|
273
|
+
? `### Changed Files\n\`\`\`\n${ctx.changedFiles.slice(0, 50).join("\n")}${ctx.changedFiles.length > 50 ? `\n... and ${ctx.changedFiles.length - 50} more` : ""}\n\`\`\``
|
|
274
|
+
: "",
|
|
275
|
+
DIFF_STATS_BLOCK: ctx.diffStat
|
|
276
|
+
? `### Diff Stats\n\`\`\`\n${ctx.diffStat.slice(0, 3000)}\n\`\`\``
|
|
277
|
+
: "",
|
|
278
|
+
WORKTREE_BLOCK: ctx.worktreeDir ? `## Worktree\nDirectory: ${ctx.worktreeDir}` : "",
|
|
279
|
+
},
|
|
280
|
+
fallback,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── JSON extraction ─────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Extract a JSON action from Codex output, which may contain markdown
|
|
288
|
+
* fences or surrounding text.
|
|
289
|
+
*/
|
|
290
|
+
function extractActionJson(raw) {
|
|
291
|
+
if (!raw || typeof raw !== "string") return null;
|
|
292
|
+
|
|
293
|
+
// Try direct parse
|
|
294
|
+
try {
|
|
295
|
+
const parsed = JSON.parse(raw.trim());
|
|
296
|
+
if (parsed && typeof parsed.action === "string") return parsed;
|
|
297
|
+
} catch {
|
|
298
|
+
/* not pure JSON */
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Try extracting from markdown fences
|
|
302
|
+
const fenceMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
303
|
+
if (fenceMatch) {
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(fenceMatch[1].trim());
|
|
306
|
+
if (parsed && typeof parsed.action === "string") return parsed;
|
|
307
|
+
} catch {
|
|
308
|
+
/* bad JSON in fence */
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Try finding {..."action":...} anywhere
|
|
313
|
+
const braceMatch = raw.match(/\{[\s\S]*?"action"\s*:\s*"[^"]+?"[\s\S]*?\}/);
|
|
314
|
+
if (braceMatch) {
|
|
315
|
+
try {
|
|
316
|
+
const parsed = JSON.parse(braceMatch[0]);
|
|
317
|
+
if (parsed && typeof parsed.action === "string") return parsed;
|
|
318
|
+
} catch {
|
|
319
|
+
/* partial match */
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Git helpers ─────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Read the last agent message from the worktree's git log or status file.
|
|
330
|
+
*/
|
|
331
|
+
function readLastAgentMessage(worktreeDir) {
|
|
332
|
+
if (!worktreeDir) return null;
|
|
333
|
+
try {
|
|
334
|
+
// Get the last commit message (agent typically commits with a summary)
|
|
335
|
+
const msg = execSync("git log -1 --pretty=format:%B", {
|
|
336
|
+
cwd: worktreeDir,
|
|
337
|
+
encoding: "utf8",
|
|
338
|
+
timeout: 5000,
|
|
339
|
+
}).trim();
|
|
340
|
+
return msg || null;
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get diff stats for the branch vs its upstream/base.
|
|
348
|
+
* @param {string} worktreeDir
|
|
349
|
+
* @param {string} [baseBranch] - upstream branch to diff against, defaults to origin/main
|
|
350
|
+
*/
|
|
351
|
+
function getDiffDetails(worktreeDir, baseBranch) {
|
|
352
|
+
if (!worktreeDir)
|
|
353
|
+
return { diffStat: null, changedFiles: [], filesChanged: 0 };
|
|
354
|
+
const base = baseBranch || "origin/main";
|
|
355
|
+
try {
|
|
356
|
+
const stat = execSync(`git diff --stat ${base}...HEAD`, {
|
|
357
|
+
cwd: worktreeDir,
|
|
358
|
+
encoding: "utf8",
|
|
359
|
+
timeout: 10000,
|
|
360
|
+
}).trim();
|
|
361
|
+
|
|
362
|
+
const files = execSync(`git diff --name-only ${base}...HEAD`, {
|
|
363
|
+
cwd: worktreeDir,
|
|
364
|
+
encoding: "utf8",
|
|
365
|
+
timeout: 10000,
|
|
366
|
+
})
|
|
367
|
+
.trim()
|
|
368
|
+
.split(/\r?\n/)
|
|
369
|
+
.filter(Boolean);
|
|
370
|
+
|
|
371
|
+
return { diffStat: stat, changedFiles: files, filesChanged: files.length };
|
|
372
|
+
} catch {
|
|
373
|
+
return { diffStat: null, changedFiles: [], filesChanged: 0 };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Main analysis function ──────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Analyze a completed task and return a merge strategy decision.
|
|
381
|
+
*
|
|
382
|
+
* Uses the Codex SDK (persistent shell) via execCodexPrompt to review
|
|
383
|
+
* the agent's work and decide what to do next.
|
|
384
|
+
*
|
|
385
|
+
* @param {MergeContext} ctx - Context about the completed task
|
|
386
|
+
* @param {object} opts
|
|
387
|
+
* @param {function} [opts.execCodex] - execCodexPrompt function from codex-shell (legacy; optional if agent-pool available)
|
|
388
|
+
* @param {number} [opts.timeoutMs=600000] - Analysis timeout (default: 10 min)
|
|
389
|
+
* @param {string} opts.logDir - Directory for audit logs
|
|
390
|
+
* @param {function} [opts.onTelegram] - Telegram notification callback
|
|
391
|
+
* @param {boolean} [opts.useAgentPool=true] - When true and no execCodex, use agent-pool's execPooledPrompt
|
|
392
|
+
* @param {object} [opts.promptTemplates] - Optional prompt template overrides
|
|
393
|
+
* @returns {Promise<MergeDecision>}
|
|
394
|
+
*/
|
|
395
|
+
export async function analyzeMergeStrategy(ctx, opts = {}) {
|
|
396
|
+
const {
|
|
397
|
+
execCodex,
|
|
398
|
+
timeoutMs = 10 * 60 * 1000,
|
|
399
|
+
logDir,
|
|
400
|
+
onTelegram,
|
|
401
|
+
useAgentPool = true,
|
|
402
|
+
promptTemplates = {},
|
|
403
|
+
} = opts;
|
|
404
|
+
|
|
405
|
+
const tag = `merge-strategy(${ctx.shortId})`;
|
|
406
|
+
|
|
407
|
+
// ── Dedup check ────────────────────────────────────────────
|
|
408
|
+
const lastAnalysis = analysisDedup.get(ctx.attemptId);
|
|
409
|
+
if (lastAnalysis && Date.now() - lastAnalysis < ANALYSIS_COOLDOWN_MS) {
|
|
410
|
+
const waitSec = Math.round(
|
|
411
|
+
(ANALYSIS_COOLDOWN_MS - (Date.now() - lastAnalysis)) / 1000,
|
|
412
|
+
);
|
|
413
|
+
console.log(`[${tag}] skipping — analyzed ${waitSec}s ago (cooldown)`);
|
|
414
|
+
return {
|
|
415
|
+
action: "noop",
|
|
416
|
+
reason: `Already analyzed recently (${waitSec}s ago)`,
|
|
417
|
+
success: true,
|
|
418
|
+
rawOutput: "",
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
analysisDedup.set(ctx.attemptId, Date.now());
|
|
422
|
+
|
|
423
|
+
// ── Enrich context with git data if worktree available ─────
|
|
424
|
+
if (ctx.worktreeDir) {
|
|
425
|
+
if (!ctx.agentLastMessage) {
|
|
426
|
+
ctx.agentLastMessage = readLastAgentMessage(ctx.worktreeDir);
|
|
427
|
+
}
|
|
428
|
+
if (!ctx.diffStat || !ctx.changedFiles?.length) {
|
|
429
|
+
const diff = getDiffDetails(ctx.worktreeDir, ctx.baseBranch);
|
|
430
|
+
ctx.diffStat = ctx.diffStat || diff.diffStat;
|
|
431
|
+
ctx.changedFiles = ctx.changedFiles?.length
|
|
432
|
+
? ctx.changedFiles
|
|
433
|
+
: diff.changedFiles;
|
|
434
|
+
ctx.filesChanged = ctx.filesChanged ?? diff.filesChanged;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Build prompt ───────────────────────────────────────────
|
|
439
|
+
const prompt = buildMergeStrategyPrompt(ctx, promptTemplates.mergeStrategy);
|
|
440
|
+
|
|
441
|
+
console.log(
|
|
442
|
+
`[${tag}] starting Codex merge analysis (timeout: ${timeoutMs / 1000}s)...`,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
if (onTelegram) {
|
|
446
|
+
onTelegram(
|
|
447
|
+
`🔍 Merge strategy analysis started for ${ctx.shortId}` +
|
|
448
|
+
(ctx.taskTitle ? ` — "${ctx.taskTitle}"` : "") +
|
|
449
|
+
(ctx.prNumber ? ` (PR #${ctx.prNumber})` : ""),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── Run Codex ──────────────────────────────────────────────
|
|
454
|
+
const startMs = Date.now();
|
|
455
|
+
let rawOutput = "";
|
|
456
|
+
let codexError = null;
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
let result;
|
|
460
|
+
if (execCodex) {
|
|
461
|
+
// Legacy path: injected function (backward compat)
|
|
462
|
+
result = await execCodex(prompt, { timeoutMs });
|
|
463
|
+
} else if (useAgentPool) {
|
|
464
|
+
// New path: use agent-pool's execPooledPrompt (self-contained)
|
|
465
|
+
result = await execPooledPrompt(prompt, { timeoutMs });
|
|
466
|
+
} else {
|
|
467
|
+
throw new Error(
|
|
468
|
+
"No execution backend: provide opts.execCodex or enable useAgentPool",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
rawOutput = result?.finalResponse || "";
|
|
472
|
+
} catch (err) {
|
|
473
|
+
codexError = err?.message || String(err);
|
|
474
|
+
console.warn(`[${tag}] Codex analysis failed: ${codexError}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const elapsed = Date.now() - startMs;
|
|
478
|
+
|
|
479
|
+
// ── Parse decision ─────────────────────────────────────────
|
|
480
|
+
let decision = extractActionJson(rawOutput);
|
|
481
|
+
|
|
482
|
+
if (!decision) {
|
|
483
|
+
// Codex didn't return valid JSON — build a fallback
|
|
484
|
+
console.warn(
|
|
485
|
+
`[${tag}] Codex returned non-JSON output (${rawOutput.length} chars)`,
|
|
486
|
+
);
|
|
487
|
+
decision = {
|
|
488
|
+
action: "manual_review",
|
|
489
|
+
reason: codexError
|
|
490
|
+
? `Codex error: ${codexError}`
|
|
491
|
+
: `Could not parse Codex response (${rawOutput.slice(0, 200)})`,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Validate action
|
|
496
|
+
if (!VALID_ACTIONS.has(decision.action)) {
|
|
497
|
+
console.warn(
|
|
498
|
+
`[${tag}] invalid action "${decision.action}" — defaulting to manual_review`,
|
|
499
|
+
);
|
|
500
|
+
decision.action = "manual_review";
|
|
501
|
+
decision.reason = `Invalid action "${decision.action}" — ${decision.reason || "unknown"}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Audit log ──────────────────────────────────────────────
|
|
505
|
+
if (logDir) {
|
|
506
|
+
try {
|
|
507
|
+
await mkdir(resolve(logDir), { recursive: true });
|
|
508
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
509
|
+
const auditPath = resolve(
|
|
510
|
+
logDir,
|
|
511
|
+
`merge-strategy-${ctx.shortId}-${stamp}.log`,
|
|
512
|
+
);
|
|
513
|
+
await writeFile(
|
|
514
|
+
auditPath,
|
|
515
|
+
[
|
|
516
|
+
`# Merge Strategy Analysis`,
|
|
517
|
+
`# Attempt: ${ctx.attemptId}`,
|
|
518
|
+
`# Task: ${ctx.taskTitle || "unknown"}`,
|
|
519
|
+
`# Status: ${ctx.status}`,
|
|
520
|
+
`# Elapsed: ${elapsed}ms`,
|
|
521
|
+
`# Timestamp: ${new Date().toISOString()}`,
|
|
522
|
+
"",
|
|
523
|
+
"## Prompt:",
|
|
524
|
+
prompt,
|
|
525
|
+
"",
|
|
526
|
+
"## Raw Codex Output:",
|
|
527
|
+
rawOutput || "(empty)",
|
|
528
|
+
codexError ? `\n## Error: ${codexError}` : "",
|
|
529
|
+
"",
|
|
530
|
+
"## Parsed Decision:",
|
|
531
|
+
JSON.stringify(decision, null, 2),
|
|
532
|
+
].join("\n"),
|
|
533
|
+
"utf8",
|
|
534
|
+
);
|
|
535
|
+
} catch {
|
|
536
|
+
/* audit write failed — non-critical */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── Notify ─────────────────────────────────────────────────
|
|
541
|
+
const actionEmoji = {
|
|
542
|
+
merge_after_ci_pass: "✅",
|
|
543
|
+
prompt: "💬",
|
|
544
|
+
close_pr: "🚫",
|
|
545
|
+
re_attempt: "🔄",
|
|
546
|
+
manual_review: "👀",
|
|
547
|
+
wait: "⏳",
|
|
548
|
+
noop: "➖",
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const emoji = actionEmoji[decision.action] || "❓";
|
|
552
|
+
console.log(
|
|
553
|
+
`[${tag}] decision: ${emoji} ${decision.action}` +
|
|
554
|
+
(decision.reason ? ` — ${decision.reason.slice(0, 120)}` : "") +
|
|
555
|
+
` (${elapsed}ms)`,
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
if (onTelegram) {
|
|
559
|
+
const lines = [
|
|
560
|
+
`${emoji} Merge Strategy: **${decision.action}**`,
|
|
561
|
+
`Task: ${ctx.taskTitle || ctx.shortId}`,
|
|
562
|
+
];
|
|
563
|
+
if (decision.reason) lines.push(`Reason: ${decision.reason.slice(0, 300)}`);
|
|
564
|
+
if (decision.message)
|
|
565
|
+
lines.push(`Message: ${decision.message.slice(0, 300)}`);
|
|
566
|
+
if (ctx.prNumber) lines.push(`PR: #${ctx.prNumber}`);
|
|
567
|
+
lines.push(`Analysis: ${Math.round(elapsed / 1000)}s`);
|
|
568
|
+
onTelegram(lines.join("\n"));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
...decision,
|
|
573
|
+
success: !codexError,
|
|
574
|
+
rawOutput,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Reset the dedup cache (useful when clearing state).
|
|
580
|
+
*/
|
|
581
|
+
export function resetMergeStrategyDedup() {
|
|
582
|
+
analysisDedup.clear();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Decision execution ──────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Execute a merge strategy decision by acting on the chosen action.
|
|
589
|
+
*
|
|
590
|
+
* Key behaviors:
|
|
591
|
+
* - "prompt": Resumes the ORIGINAL agent thread (via taskKey) with the fix instruction,
|
|
592
|
+
* so the agent has full context from the initial work. Falls back to fresh thread
|
|
593
|
+
* if no existing thread found.
|
|
594
|
+
* - "re_attempt": Uses execWithRetry to launch a fresh attempt with error recovery.
|
|
595
|
+
* The original thread is invalidated first.
|
|
596
|
+
* - "merge_after_ci_pass": Enables auto-merge on the PR via `gh pr merge --auto`.
|
|
597
|
+
* - "close_pr": Closes the PR via `gh pr close`.
|
|
598
|
+
* - "wait": Returns with the wait duration (caller handles scheduling).
|
|
599
|
+
* - "manual_review": Sends notification (caller handles telegram).
|
|
600
|
+
* - "noop": Does nothing.
|
|
601
|
+
*
|
|
602
|
+
* @param {MergeDecision} decision The decision from analyzeMergeStrategy
|
|
603
|
+
* @param {MergeContext} ctx The context used for the analysis
|
|
604
|
+
* @param {object} opts
|
|
605
|
+
* @param {string} [opts.logDir] Audit log directory
|
|
606
|
+
* @param {function} [opts.onTelegram] Telegram notification callback
|
|
607
|
+
* @param {number} [opts.timeoutMs] Timeout for agent operations (default: 15 min)
|
|
608
|
+
* @param {number} [opts.maxRetries] Max retries for re_attempt (default: 2)
|
|
609
|
+
* @param {object} [opts.promptTemplates] Optional prompt template overrides
|
|
610
|
+
* @returns {Promise<ExecutionResult>}
|
|
611
|
+
*/
|
|
612
|
+
export async function executeDecision(decision, ctx, opts = {}) {
|
|
613
|
+
const {
|
|
614
|
+
logDir,
|
|
615
|
+
onTelegram,
|
|
616
|
+
timeoutMs = 15 * 60 * 1000,
|
|
617
|
+
maxRetries = 2,
|
|
618
|
+
promptTemplates = {},
|
|
619
|
+
} = opts;
|
|
620
|
+
|
|
621
|
+
const tag = `merge-exec(${ctx.shortId})`;
|
|
622
|
+
const taskKey = ctx.taskKey || ctx.attemptId;
|
|
623
|
+
const cwd = ctx.worktreeDir || undefined;
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
switch (decision.action) {
|
|
627
|
+
case "prompt":
|
|
628
|
+
return await executePromptAction(decision, ctx, {
|
|
629
|
+
tag,
|
|
630
|
+
taskKey,
|
|
631
|
+
cwd,
|
|
632
|
+
timeoutMs,
|
|
633
|
+
logDir,
|
|
634
|
+
onTelegram,
|
|
635
|
+
promptTemplate: promptTemplates.mergeStrategyFix,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
case "re_attempt":
|
|
639
|
+
return await executeReAttemptAction(decision, ctx, {
|
|
640
|
+
tag,
|
|
641
|
+
taskKey,
|
|
642
|
+
cwd,
|
|
643
|
+
timeoutMs,
|
|
644
|
+
maxRetries,
|
|
645
|
+
logDir,
|
|
646
|
+
onTelegram,
|
|
647
|
+
promptTemplate: promptTemplates.mergeStrategyReAttempt,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
case "merge_after_ci_pass":
|
|
651
|
+
return await executeMergeAction(decision, ctx, { tag, onTelegram });
|
|
652
|
+
|
|
653
|
+
case "close_pr":
|
|
654
|
+
return await executeCloseAction(decision, ctx, { tag, onTelegram });
|
|
655
|
+
|
|
656
|
+
case "wait":
|
|
657
|
+
return {
|
|
658
|
+
executed: true,
|
|
659
|
+
action: "wait",
|
|
660
|
+
success: true,
|
|
661
|
+
waitSeconds: decision.seconds || 300,
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
case "manual_review":
|
|
665
|
+
if (onTelegram) {
|
|
666
|
+
onTelegram(
|
|
667
|
+
`👀 Manual review needed for ${ctx.taskTitle || ctx.shortId}: ${decision.reason || "no reason"}`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return { executed: true, action: "manual_review", success: true };
|
|
671
|
+
|
|
672
|
+
case "noop":
|
|
673
|
+
return { executed: true, action: "noop", success: true };
|
|
674
|
+
|
|
675
|
+
default:
|
|
676
|
+
return {
|
|
677
|
+
executed: false,
|
|
678
|
+
action: decision.action,
|
|
679
|
+
success: false,
|
|
680
|
+
error: `Unknown action: ${decision.action}`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
} catch (err) {
|
|
684
|
+
console.error(
|
|
685
|
+
`[${tag}] executeDecision threw unexpectedly: ${err.message}`,
|
|
686
|
+
);
|
|
687
|
+
return {
|
|
688
|
+
executed: false,
|
|
689
|
+
action: decision.action,
|
|
690
|
+
success: false,
|
|
691
|
+
error: err.message,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ── Prompt (resume) action ──────────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Execute a "prompt" action: resume the original agent thread with the fix instruction.
|
|
700
|
+
*
|
|
701
|
+
* Flow:
|
|
702
|
+
* 1. Look up the original thread via taskKey in thread registry
|
|
703
|
+
* 2. If found and alive → resume that thread with the fix message (full context preserved)
|
|
704
|
+
* 3. If not found → launch a fresh thread with context-carrying preamble
|
|
705
|
+
* 4. Return the agent's response
|
|
706
|
+
*/
|
|
707
|
+
async function executePromptAction(decision, ctx, execOpts) {
|
|
708
|
+
const { tag, taskKey, cwd, timeoutMs, logDir, onTelegram, promptTemplate } =
|
|
709
|
+
execOpts;
|
|
710
|
+
|
|
711
|
+
const fixMessage =
|
|
712
|
+
decision.message ||
|
|
713
|
+
decision.reason ||
|
|
714
|
+
"Please review and fix the remaining issues.";
|
|
715
|
+
|
|
716
|
+
// Check if original thread exists
|
|
717
|
+
const existingThread = getThreadRecord(taskKey);
|
|
718
|
+
const hasLiveThread = !!(
|
|
719
|
+
existingThread &&
|
|
720
|
+
existingThread.alive &&
|
|
721
|
+
existingThread.threadId
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
if (hasLiveThread) {
|
|
725
|
+
console.log(
|
|
726
|
+
`[${tag}] resuming original agent thread (${existingThread.sdk}, turn ${existingThread.turnCount + 1}) with fix: "${fixMessage.slice(0, 100)}..."`,
|
|
727
|
+
);
|
|
728
|
+
} else {
|
|
729
|
+
console.log(
|
|
730
|
+
`[${tag}] no existing thread for task "${taskKey}" — launching fresh with context`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (onTelegram) {
|
|
735
|
+
onTelegram(
|
|
736
|
+
`💬 ${hasLiveThread ? "Resuming" : "Starting"} agent for ${ctx.taskTitle || ctx.shortId}: ${fixMessage.slice(0, 200)}`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Build a rich prompt for the fix action
|
|
741
|
+
const fixPrompt = buildFixPrompt(
|
|
742
|
+
fixMessage,
|
|
743
|
+
ctx,
|
|
744
|
+
hasLiveThread,
|
|
745
|
+
promptTemplate,
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
const result = await launchOrResumeThread(fixPrompt, cwd, timeoutMs, {
|
|
750
|
+
taskKey,
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// Audit log
|
|
754
|
+
await auditDecisionExecution(logDir, ctx, decision, result);
|
|
755
|
+
|
|
756
|
+
if (result.success) {
|
|
757
|
+
console.log(
|
|
758
|
+
`[${tag}] ✅ agent ${result.resumed ? "resumed" : "launched"} successfully for fix`,
|
|
759
|
+
);
|
|
760
|
+
if (onTelegram) {
|
|
761
|
+
onTelegram(
|
|
762
|
+
`✅ Agent ${result.resumed ? "resumed" : "completed"} fix for ${ctx.taskTitle || ctx.shortId}`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
console.warn(`[${tag}] ❌ agent fix failed: ${result.error}`);
|
|
767
|
+
if (onTelegram) {
|
|
768
|
+
onTelegram(
|
|
769
|
+
`❌ Agent fix failed for ${ctx.taskTitle || ctx.shortId}: ${result.error?.slice(0, 200)}`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
executed: true,
|
|
776
|
+
action: "prompt",
|
|
777
|
+
success: result.success,
|
|
778
|
+
output: result.output,
|
|
779
|
+
error: result.error,
|
|
780
|
+
resumed: result.resumed || false,
|
|
781
|
+
};
|
|
782
|
+
} catch (err) {
|
|
783
|
+
console.error(`[${tag}] executePromptAction threw: ${err.message}`);
|
|
784
|
+
return {
|
|
785
|
+
executed: true,
|
|
786
|
+
action: "prompt",
|
|
787
|
+
success: false,
|
|
788
|
+
error: err.message,
|
|
789
|
+
resumed: false,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Build a rich prompt for the agent to fix issues.
|
|
796
|
+
* If the thread is being resumed, the prompt is shorter (agent has context).
|
|
797
|
+
* If starting fresh, includes full task context.
|
|
798
|
+
*/
|
|
799
|
+
function buildFixPrompt(fixMessage, ctx, isResume, promptTemplate = "") {
|
|
800
|
+
const parts = [];
|
|
801
|
+
|
|
802
|
+
if (isResume) {
|
|
803
|
+
// Resuming — agent already knows the task
|
|
804
|
+
parts.push(`# Fix Required\n`);
|
|
805
|
+
parts.push(`Your previous work on this task needs some fixes:\n`);
|
|
806
|
+
parts.push(fixMessage);
|
|
807
|
+
if (ctx.ciStatus === "failing") {
|
|
808
|
+
parts.push(
|
|
809
|
+
`\n\n**CI is currently failing.** Please fix CI issues before pushing.`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
parts.push(`\n\nAfter fixing, commit and push the changes.`);
|
|
813
|
+
} else {
|
|
814
|
+
// Fresh start — include full context
|
|
815
|
+
parts.push(`# Fix Required — Task Context\n`);
|
|
816
|
+
if (ctx.taskTitle) parts.push(`**Task:** ${ctx.taskTitle}`);
|
|
817
|
+
if (ctx.taskDescription)
|
|
818
|
+
parts.push(`**Description:** ${ctx.taskDescription.slice(0, 2000)}`);
|
|
819
|
+
if (ctx.branch) parts.push(`**Branch:** ${ctx.branch}`);
|
|
820
|
+
if (ctx.prNumber) parts.push(`**PR:** #${ctx.prNumber}`);
|
|
821
|
+
parts.push(`\n## What Needs Fixing\n`);
|
|
822
|
+
parts.push(fixMessage);
|
|
823
|
+
if (ctx.changedFiles?.length) {
|
|
824
|
+
parts.push(
|
|
825
|
+
`\n## Files Already Changed\n\`\`\`\n${ctx.changedFiles.slice(0, 30).join("\n")}\n\`\`\``,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
if (ctx.ciStatus === "failing") {
|
|
829
|
+
parts.push(`\n**CI is currently failing.** Please fix CI issues.`);
|
|
830
|
+
}
|
|
831
|
+
parts.push(`\n\nFix the issues, then commit and push.`);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const fallback = parts.join("\n");
|
|
835
|
+
return resolvePromptTemplate(
|
|
836
|
+
promptTemplate,
|
|
837
|
+
{
|
|
838
|
+
FIX_MESSAGE: fixMessage,
|
|
839
|
+
TASK_CONTEXT_BLOCK: [
|
|
840
|
+
ctx.taskTitle ? `Task: ${ctx.taskTitle}` : "",
|
|
841
|
+
ctx.taskDescription
|
|
842
|
+
? `Description: ${ctx.taskDescription.slice(0, 2000)}`
|
|
843
|
+
: "",
|
|
844
|
+
ctx.branch ? `Branch: ${ctx.branch}` : "",
|
|
845
|
+
ctx.prNumber ? `PR: #${ctx.prNumber}` : "",
|
|
846
|
+
]
|
|
847
|
+
.filter(Boolean)
|
|
848
|
+
.join("\n"),
|
|
849
|
+
CI_STATUS_LINE:
|
|
850
|
+
ctx.ciStatus === "failing"
|
|
851
|
+
? "CI is currently failing. Fix CI issues before pushing."
|
|
852
|
+
: "",
|
|
853
|
+
},
|
|
854
|
+
fallback,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── Re-attempt action ───────────────────────────────────────────────────────
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Execute a "re_attempt" action: invalidate the old thread and start fresh with error recovery.
|
|
862
|
+
*/
|
|
863
|
+
async function executeReAttemptAction(decision, ctx, execOpts) {
|
|
864
|
+
const {
|
|
865
|
+
tag,
|
|
866
|
+
taskKey,
|
|
867
|
+
cwd,
|
|
868
|
+
timeoutMs,
|
|
869
|
+
maxRetries,
|
|
870
|
+
logDir,
|
|
871
|
+
onTelegram,
|
|
872
|
+
promptTemplate,
|
|
873
|
+
} = execOpts;
|
|
874
|
+
|
|
875
|
+
const reason = decision.reason || "Previous attempt failed";
|
|
876
|
+
|
|
877
|
+
console.log(
|
|
878
|
+
`[${tag}] re-attempting task "${ctx.taskTitle || taskKey}" (max ${maxRetries} retries). Reason: ${reason}`,
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
// Invalidate the old thread so we start completely fresh
|
|
882
|
+
invalidateThread(taskKey);
|
|
883
|
+
|
|
884
|
+
if (onTelegram) {
|
|
885
|
+
onTelegram(
|
|
886
|
+
`🔄 Re-attempting task "${ctx.taskTitle || ctx.shortId}": ${reason.slice(0, 200)}`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Build the re-attempt prompt with full context
|
|
891
|
+
const reAttemptPrompt = buildReAttemptPrompt(ctx, reason, promptTemplate);
|
|
892
|
+
|
|
893
|
+
try {
|
|
894
|
+
const result = await execWithRetry(reAttemptPrompt, {
|
|
895
|
+
taskKey: `${taskKey}-reattempt`, // New key so it doesn't conflict with old thread
|
|
896
|
+
cwd,
|
|
897
|
+
timeoutMs,
|
|
898
|
+
maxRetries,
|
|
899
|
+
shouldRetry: (res) => !res.success, // Retry on any failure
|
|
900
|
+
buildRetryPrompt: (lastResult, attempt) =>
|
|
901
|
+
`# Retry ${attempt} — Previous Error\n\n\`\`\`\n${lastResult?.error || lastResult?.output?.slice(0, 500) || "unknown"}\n\`\`\`\n\nPlease fix the error and complete the task.\n\nOriginal task: ${ctx.taskTitle || "unknown"}\nBranch: ${ctx.branch || "unknown"}`,
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
await auditDecisionExecution(logDir, ctx, decision, result);
|
|
905
|
+
|
|
906
|
+
if (result.success) {
|
|
907
|
+
console.log(
|
|
908
|
+
`[${tag}] ✅ re-attempt succeeded after ${result.attempts} attempt(s)`,
|
|
909
|
+
);
|
|
910
|
+
if (onTelegram) {
|
|
911
|
+
onTelegram(
|
|
912
|
+
`✅ Re-attempt succeeded for "${ctx.taskTitle || ctx.shortId}" (${result.attempts} attempt(s))`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
} else {
|
|
916
|
+
console.warn(
|
|
917
|
+
`[${tag}] ❌ re-attempt failed after ${result.attempts} attempt(s): ${result.error}`,
|
|
918
|
+
);
|
|
919
|
+
if (onTelegram) {
|
|
920
|
+
onTelegram(
|
|
921
|
+
`❌ Re-attempt failed for "${ctx.taskTitle || ctx.shortId}" after ${result.attempts} attempt(s): ${result.error?.slice(0, 200)}`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return {
|
|
927
|
+
executed: true,
|
|
928
|
+
action: "re_attempt",
|
|
929
|
+
success: result.success,
|
|
930
|
+
output: result.output,
|
|
931
|
+
error: result.error,
|
|
932
|
+
attempts: result.attempts,
|
|
933
|
+
resumed: false,
|
|
934
|
+
};
|
|
935
|
+
} catch (err) {
|
|
936
|
+
console.error(`[${tag}] executeReAttemptAction threw: ${err.message}`);
|
|
937
|
+
return {
|
|
938
|
+
executed: true,
|
|
939
|
+
action: "re_attempt",
|
|
940
|
+
success: false,
|
|
941
|
+
error: err.message,
|
|
942
|
+
attempts: 0,
|
|
943
|
+
resumed: false,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Build a full-context prompt for re-attempting a task from scratch.
|
|
950
|
+
*/
|
|
951
|
+
function buildReAttemptPrompt(ctx, reason, promptTemplate = "") {
|
|
952
|
+
const parts = [];
|
|
953
|
+
parts.push(`# Task Re-Attempt\n`);
|
|
954
|
+
parts.push(
|
|
955
|
+
`A previous agent attempt at this task failed. Start fresh and complete the task.\n`,
|
|
956
|
+
);
|
|
957
|
+
parts.push(`**Failure reason:** ${reason}\n`);
|
|
958
|
+
if (ctx.taskTitle) parts.push(`**Task:** ${ctx.taskTitle}`);
|
|
959
|
+
if (ctx.taskDescription)
|
|
960
|
+
parts.push(`**Description:** ${ctx.taskDescription.slice(0, 3000)}`);
|
|
961
|
+
if (ctx.branch) parts.push(`**Branch:** ${ctx.branch}`);
|
|
962
|
+
if (ctx.prNumber)
|
|
963
|
+
parts.push(`**Existing PR:** #${ctx.prNumber} (may need amendment)`);
|
|
964
|
+
parts.push(`\nPlease implement the task fully, run tests, commit, and push.`);
|
|
965
|
+
const fallback = parts.join("\n");
|
|
966
|
+
return resolvePromptTemplate(
|
|
967
|
+
promptTemplate,
|
|
968
|
+
{
|
|
969
|
+
FAILURE_REASON: reason,
|
|
970
|
+
TASK_CONTEXT_BLOCK: [
|
|
971
|
+
ctx.taskTitle ? `Task: ${ctx.taskTitle}` : "",
|
|
972
|
+
ctx.taskDescription
|
|
973
|
+
? `Description: ${ctx.taskDescription.slice(0, 3000)}`
|
|
974
|
+
: "",
|
|
975
|
+
ctx.branch ? `Branch: ${ctx.branch}` : "",
|
|
976
|
+
ctx.prNumber ? `Existing PR: #${ctx.prNumber}` : "",
|
|
977
|
+
]
|
|
978
|
+
.filter(Boolean)
|
|
979
|
+
.join("\n"),
|
|
980
|
+
},
|
|
981
|
+
fallback,
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ── Merge action ────────────────────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Enable auto-merge for the PR via `gh pr merge --auto --squash`.
|
|
989
|
+
*/
|
|
990
|
+
async function executeMergeAction(decision, ctx, execOpts) {
|
|
991
|
+
const { tag, onTelegram } = execOpts;
|
|
992
|
+
|
|
993
|
+
if (!ctx.prNumber) {
|
|
994
|
+
console.warn(`[${tag}] merge_after_ci_pass but no PR number`);
|
|
995
|
+
return {
|
|
996
|
+
executed: false,
|
|
997
|
+
action: "merge_after_ci_pass",
|
|
998
|
+
success: false,
|
|
999
|
+
error: "No PR number",
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
console.log(`[${tag}] enabling auto-merge for PR #${ctx.prNumber}`);
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
const result = execSync(`gh pr merge ${ctx.prNumber} --auto --squash`, {
|
|
1007
|
+
encoding: "utf8",
|
|
1008
|
+
timeout: 30000,
|
|
1009
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
if (onTelegram) {
|
|
1013
|
+
onTelegram(
|
|
1014
|
+
`✅ Auto-merge enabled for PR #${ctx.prNumber} "${ctx.prTitle || ctx.taskTitle || ""}"`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return {
|
|
1019
|
+
executed: true,
|
|
1020
|
+
action: "merge_after_ci_pass",
|
|
1021
|
+
success: true,
|
|
1022
|
+
output: result,
|
|
1023
|
+
};
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
// Auto-merge might already be enabled, or repo doesn't support it
|
|
1026
|
+
console.warn(`[${tag}] gh pr merge --auto failed: ${err.message}`);
|
|
1027
|
+
|
|
1028
|
+
if (onTelegram) {
|
|
1029
|
+
onTelegram(
|
|
1030
|
+
`⚠️ Auto-merge failed for PR #${ctx.prNumber}: ${err.message?.slice(0, 200)}. Will retry on next cycle.`,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
executed: true,
|
|
1036
|
+
action: "merge_after_ci_pass",
|
|
1037
|
+
success: false,
|
|
1038
|
+
error: err.message,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ── Close PR action ─────────────────────────────────────────────────────────
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Close the PR via `gh pr close` with an explanatory comment.
|
|
1047
|
+
*/
|
|
1048
|
+
async function executeCloseAction(decision, ctx, execOpts) {
|
|
1049
|
+
const { tag, onTelegram } = execOpts;
|
|
1050
|
+
|
|
1051
|
+
if (!ctx.prNumber) {
|
|
1052
|
+
return {
|
|
1053
|
+
executed: false,
|
|
1054
|
+
action: "close_pr",
|
|
1055
|
+
success: false,
|
|
1056
|
+
error: "No PR number",
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
console.log(
|
|
1061
|
+
`[${tag}] closing PR #${ctx.prNumber}: ${decision.reason || "no reason"}`,
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
try {
|
|
1065
|
+
const comment = decision.reason
|
|
1066
|
+
? `Closing: ${decision.reason}`
|
|
1067
|
+
: "Closing PR based on merge strategy analysis.";
|
|
1068
|
+
|
|
1069
|
+
execSync(
|
|
1070
|
+
`gh pr close ${ctx.prNumber} --comment "${comment.replace(/"/g, '\\"')}"`,
|
|
1071
|
+
{
|
|
1072
|
+
encoding: "utf8",
|
|
1073
|
+
timeout: 30000,
|
|
1074
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1075
|
+
},
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
if (onTelegram) {
|
|
1079
|
+
onTelegram(
|
|
1080
|
+
`🚫 Closed PR #${ctx.prNumber}: ${decision.reason || "strategy decision"}`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return { executed: true, action: "close_pr", success: true };
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
console.warn(`[${tag}] close_pr failed: ${err.message}`);
|
|
1087
|
+
return {
|
|
1088
|
+
executed: true,
|
|
1089
|
+
action: "close_pr",
|
|
1090
|
+
success: false,
|
|
1091
|
+
error: err.message,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// ── Audit helper ────────────────────────────────────────────────────────────
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Write an audit log for decision execution.
|
|
1100
|
+
*/
|
|
1101
|
+
async function auditDecisionExecution(logDir, ctx, decision, result) {
|
|
1102
|
+
if (!logDir) return;
|
|
1103
|
+
try {
|
|
1104
|
+
await mkdir(resolve(logDir), { recursive: true });
|
|
1105
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1106
|
+
const auditPath = resolve(logDir, `merge-exec-${ctx.shortId}-${stamp}.log`);
|
|
1107
|
+
await writeFile(
|
|
1108
|
+
auditPath,
|
|
1109
|
+
[
|
|
1110
|
+
`# Merge Decision Execution`,
|
|
1111
|
+
`# Attempt: ${ctx.attemptId}`,
|
|
1112
|
+
`# Action: ${decision.action}`,
|
|
1113
|
+
`# Task: ${ctx.taskTitle || "unknown"}`,
|
|
1114
|
+
`# Timestamp: ${new Date().toISOString()}`,
|
|
1115
|
+
"",
|
|
1116
|
+
"## Decision:",
|
|
1117
|
+
JSON.stringify(decision, null, 2),
|
|
1118
|
+
"",
|
|
1119
|
+
"## Execution Result:",
|
|
1120
|
+
JSON.stringify(
|
|
1121
|
+
{
|
|
1122
|
+
success: result?.success,
|
|
1123
|
+
resumed: result?.resumed,
|
|
1124
|
+
attempts: result?.attempts,
|
|
1125
|
+
error: result?.error,
|
|
1126
|
+
outputLength: result?.output?.length || 0,
|
|
1127
|
+
},
|
|
1128
|
+
null,
|
|
1129
|
+
2,
|
|
1130
|
+
),
|
|
1131
|
+
"",
|
|
1132
|
+
result?.output
|
|
1133
|
+
? `## Agent Output:\n${result.output.slice(0, 5000)}`
|
|
1134
|
+
: "",
|
|
1135
|
+
].join("\n"),
|
|
1136
|
+
"utf8",
|
|
1137
|
+
);
|
|
1138
|
+
} catch {
|
|
1139
|
+
/* audit best-effort */
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// ── Convenience pipeline ────────────────────────────────────────────────────
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* One-shot convenience: analyze + execute in a single call.
|
|
1147
|
+
* Useful for callers who want the full pipeline without manual orchestration.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {MergeContext} ctx Task context
|
|
1150
|
+
* @param {object} opts Options passed to both analyze and execute
|
|
1151
|
+
* @returns {Promise<{ decision: MergeDecision, execution: ExecutionResult }>}
|
|
1152
|
+
*/
|
|
1153
|
+
export async function analyzeAndExecute(ctx, opts = {}) {
|
|
1154
|
+
const decision = await analyzeMergeStrategy(ctx, opts);
|
|
1155
|
+
|
|
1156
|
+
// Skip execution for noop
|
|
1157
|
+
if (decision.action === "noop") {
|
|
1158
|
+
return {
|
|
1159
|
+
decision,
|
|
1160
|
+
execution: { executed: false, action: "noop", success: true },
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const execution = await executeDecision(decision, ctx, opts);
|
|
1165
|
+
|
|
1166
|
+
return { decision, execution };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
1170
|
+
|
|
1171
|
+
export { VALID_ACTIONS, extractActionJson, buildMergeStrategyPrompt };
|