fifony 0.1.42 → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/dist/assets/{CommandPalette-DNR5umI1.js → CommandPalette-M4VAMxCU.js} +1 -1
- package/app/dist/assets/{KeyboardShortcutsHelp-Dpl19F20.js → KeyboardShortcutsHelp-DkvPUXQq.js} +1 -1
- package/app/dist/assets/OnboardingWizard-B7V9hoCR.js +1 -0
- package/app/dist/assets/analytics.lazy-zVJdF880.js +1 -0
- package/app/dist/assets/{api-ChEctgc5.js → api-CkVfYg_m.js} +1 -1
- package/app/dist/assets/{createLucideIcon-R47sXufx.js → createLucideIcon-Dfk_Hxud.js} +1 -1
- package/app/dist/assets/index-BpiCi7Ew.css +1 -0
- package/app/dist/assets/index-D2INW0zc.js +47 -0
- package/app/dist/assets/vendor-BEoYbFV1.js +9 -0
- package/app/dist/index.html +5 -5
- package/app/dist/service-worker.js +9 -4
- package/bin/fifony.js +3 -0
- package/dist/agent/pty-daemon.js +177 -0
- package/dist/agent/run-local.js +177 -43
- package/dist/{agent-NNGZEKZH.js → agent-RMQTTUEC.js} +37 -16
- package/dist/analytics-broadcaster-O6YBP66L.js +145 -0
- package/dist/chunk-3NE23NYW.js +82 -0
- package/dist/chunk-42AMQAJG.js +404 -0
- package/dist/{chunk-H5N7O5NP.js → chunk-AILXZ2TD.js} +79 -147
- package/dist/{chunk-I2UHVKHS.js → chunk-BRSR26VK.js} +2 -2
- package/dist/chunk-E2EWEYA4.js +1302 -0
- package/dist/chunk-ESWHDHH6.js +102 -0
- package/dist/{chunk-NB44PCD2.js → chunk-FJNH3G2Z.js} +1061 -1138
- package/dist/chunk-MVTGAKQK.js +493 -0
- package/dist/chunk-QQQLP3PL.js +155 -0
- package/dist/chunk-SOBLO4YZ.js +2016 -0
- package/dist/chunk-YRSH2CLW.js +13784 -0
- package/dist/cli.js +335 -44
- package/dist/{issue-state-machine-GPQNZYUZ.js → fsm-issue-YGGF7SIL.js} +9 -5
- package/dist/helpers-L7NYO5XS.js +53 -0
- package/dist/issue-log-broadcaster-WZAHISYB.js +84 -0
- package/dist/{issues-MZLRSXD6.js → issues-3QRR7KM6.js} +10 -8
- package/dist/log-analyzer-K7MXQB4T.js +287 -0
- package/dist/mcp/server.js +109 -137
- package/dist/parallel-executor-6INE6NDO.js +118 -0
- package/dist/pid-manager-UBWXVSMD.js +21 -0
- package/dist/queue-workers-XFZK3TT5.js +32 -0
- package/dist/replan-issue.command-4UCWYHGZ.js +15 -0
- package/dist/scheduler-ZP7GOZDW.js +26 -0
- package/dist/{settings-NGY33WQE.js → settings-ZAWDCFP2.js} +32 -8
- package/dist/settings.resource-5CW456AZ.js +24 -0
- package/dist/store-M6NCKMZY.js +97 -0
- package/dist/{web-push-CRVDJKWR.js → web-push-AX5IIK3P.js} +2 -2
- package/dist/{workspace-D3F3XGSI.js → workspace-CJTWFWTJ.js} +5 -4
- package/package.json +8 -7
- package/app/dist/assets/OnboardingWizard-CijMhJDW.js +0 -1
- package/app/dist/assets/analytics.lazy-Dq90a756.js +0 -1
- package/app/dist/assets/index-Dy_fM427.js +0 -54
- package/app/dist/assets/index-Q9jBP0Pz.css +0 -1
- package/app/dist/assets/vendor-DkWeBvNl.js +0 -9
- package/dist/chunk-2CVTK5F2.js +0 -288
- package/dist/chunk-37N5OFHM.js +0 -125
- package/dist/chunk-JTKUWIQD.js +0 -8406
- package/dist/chunk-RBDBGU2C.js +0 -303
- package/dist/issue-runner-CMZPSVC7.js +0 -16
- package/dist/queue-workers-XZ6DGH4W.js +0 -23
- package/dist/scheduler-NVE6L3P7.js +0 -22
- package/dist/store-4HCGBN4L.js +0 -65
|
@@ -0,0 +1,2016 @@
|
|
|
1
|
+
import {
|
|
2
|
+
markIssueDirty,
|
|
3
|
+
normalizeAgentProvider
|
|
4
|
+
} from "./chunk-FJNH3G2Z.js";
|
|
5
|
+
import {
|
|
6
|
+
logger
|
|
7
|
+
} from "./chunk-DVU3CXWA.js";
|
|
8
|
+
import {
|
|
9
|
+
renderPrompt
|
|
10
|
+
} from "./chunk-ESWHDHH6.js";
|
|
11
|
+
import {
|
|
12
|
+
SOURCE_MARKER,
|
|
13
|
+
SOURCE_ROOT,
|
|
14
|
+
TARGET_ROOT,
|
|
15
|
+
WORKSPACE_ROOT,
|
|
16
|
+
appendFileTail,
|
|
17
|
+
idToSafePath,
|
|
18
|
+
now
|
|
19
|
+
} from "./chunk-42AMQAJG.js";
|
|
20
|
+
|
|
21
|
+
// src/domains/workspace.ts
|
|
22
|
+
import {
|
|
23
|
+
existsSync as existsSync4,
|
|
24
|
+
mkdirSync as mkdirSync2,
|
|
25
|
+
readdirSync as readdirSync2,
|
|
26
|
+
readFileSync as readFileSync3,
|
|
27
|
+
rmSync as rmSync2,
|
|
28
|
+
statSync,
|
|
29
|
+
writeFileSync as writeFileSync3
|
|
30
|
+
} from "fs";
|
|
31
|
+
import { copyFile, mkdir, readdir, stat, writeFile } from "fs/promises";
|
|
32
|
+
import { extname, join as join3, resolve } from "path";
|
|
33
|
+
import { execSync } from "child_process";
|
|
34
|
+
|
|
35
|
+
// src/agents/command-executor.ts
|
|
36
|
+
import {
|
|
37
|
+
appendFileSync,
|
|
38
|
+
existsSync as existsSync2,
|
|
39
|
+
readFileSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
writeFileSync
|
|
42
|
+
} from "fs";
|
|
43
|
+
import { join } from "path";
|
|
44
|
+
import { env, execPath } from "process";
|
|
45
|
+
import { spawn } from "child_process";
|
|
46
|
+
import { createConnection } from "net";
|
|
47
|
+
import { createRequire } from "module";
|
|
48
|
+
import { fileURLToPath } from "url";
|
|
49
|
+
|
|
50
|
+
// src/agents/docker-runner.ts
|
|
51
|
+
import { existsSync } from "fs";
|
|
52
|
+
import { homedir, userInfo } from "os";
|
|
53
|
+
var CONTAINER_WORKSPACE = "/workspace";
|
|
54
|
+
var CONTAINER_PLANNING = "/planning";
|
|
55
|
+
function translatePaths(value, workspacePath) {
|
|
56
|
+
return value.replaceAll(workspacePath, CONTAINER_WORKSPACE);
|
|
57
|
+
}
|
|
58
|
+
function authMounts(home) {
|
|
59
|
+
const mounts = [];
|
|
60
|
+
for (const dir of [".claude", ".codex", ".gemini"]) {
|
|
61
|
+
const p = `${home}/${dir}`;
|
|
62
|
+
if (existsSync(p)) mounts.push(`-v "${p}:${home}/${dir}:ro,z"`);
|
|
63
|
+
}
|
|
64
|
+
const gitconfig = `${home}/.gitconfig`;
|
|
65
|
+
if (existsSync(gitconfig)) mounts.push(`-v "${gitconfig}:${home}/.gitconfig:ro"`);
|
|
66
|
+
const sshDir = `${home}/.ssh`;
|
|
67
|
+
if (existsSync(sshDir)) mounts.push(`-v "${sshDir}:${home}/.ssh:ro"`);
|
|
68
|
+
return mounts;
|
|
69
|
+
}
|
|
70
|
+
function buildDockerRunCommand(innerCommand, workspacePath, worktreePath, targetRoot, image) {
|
|
71
|
+
const home = homedir();
|
|
72
|
+
const ui = userInfo();
|
|
73
|
+
const cwd = worktreePath ? `${CONTAINER_WORKSPACE}/worktree` : CONTAINER_WORKSPACE;
|
|
74
|
+
const mounts = [
|
|
75
|
+
`-v "${workspacePath}:${CONTAINER_WORKSPACE}:z"`,
|
|
76
|
+
`-v "${targetRoot}/.git:${targetRoot}/.git:z"`,
|
|
77
|
+
...authMounts(home)
|
|
78
|
+
];
|
|
79
|
+
const escapedInner = innerCommand.replace(/'/g, "'\\''");
|
|
80
|
+
return [
|
|
81
|
+
"docker run --rm --init",
|
|
82
|
+
`--user ${ui.uid}:${ui.gid}`,
|
|
83
|
+
"--network host",
|
|
84
|
+
"--cap-drop ALL",
|
|
85
|
+
"--security-opt no-new-privileges",
|
|
86
|
+
...mounts,
|
|
87
|
+
`-w "${cwd}"`,
|
|
88
|
+
image,
|
|
89
|
+
"sh",
|
|
90
|
+
"-c",
|
|
91
|
+
`'. ${CONTAINER_WORKSPACE}/.env.sh && ${escapedInner}'`
|
|
92
|
+
].join(" ");
|
|
93
|
+
}
|
|
94
|
+
function buildDockerPlanCommand(innerCommand, tempDir, image) {
|
|
95
|
+
const home = homedir();
|
|
96
|
+
const ui = userInfo();
|
|
97
|
+
const translatedCommand = innerCommand.replaceAll(tempDir, CONTAINER_PLANNING);
|
|
98
|
+
const escapedInner = translatedCommand.replace(/'/g, "'\\''");
|
|
99
|
+
const mounts = [
|
|
100
|
+
`-v "${tempDir}:${CONTAINER_PLANNING}:z"`,
|
|
101
|
+
...authMounts(home)
|
|
102
|
+
];
|
|
103
|
+
return [
|
|
104
|
+
"docker run --rm --init",
|
|
105
|
+
`--user ${ui.uid}:${ui.gid}`,
|
|
106
|
+
"--network host",
|
|
107
|
+
"--cap-drop ALL",
|
|
108
|
+
"--security-opt no-new-privileges",
|
|
109
|
+
...mounts,
|
|
110
|
+
`-w "${CONTAINER_PLANNING}"`,
|
|
111
|
+
image,
|
|
112
|
+
"sh",
|
|
113
|
+
"-c",
|
|
114
|
+
`'. ${CONTAINER_PLANNING}/.env.sh && ${escapedInner}'`
|
|
115
|
+
].join(" ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/agents/command-executor.ts
|
|
119
|
+
function resolveDaemonScript() {
|
|
120
|
+
const pkgRoot = process.env.FIFONY_PKG_ROOT;
|
|
121
|
+
if (!pkgRoot) return null;
|
|
122
|
+
const compiled = join(pkgRoot, "dist", "agent", "pty-daemon.js");
|
|
123
|
+
if (existsSync2(compiled)) {
|
|
124
|
+
return { command: execPath, args: [compiled] };
|
|
125
|
+
}
|
|
126
|
+
const source = join(pkgRoot, "src", "agents", "pty-daemon.ts");
|
|
127
|
+
if (existsSync2(source)) {
|
|
128
|
+
try {
|
|
129
|
+
const require2 = createRequire(fileURLToPath(import.meta.url));
|
|
130
|
+
const tsxCli = require2.resolve("tsx/cli");
|
|
131
|
+
return { command: execPath, args: [tsxCli, source] };
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
var DAEMON_SCRIPT = resolveDaemonScript();
|
|
139
|
+
var HOOK_RUNTIME_CONFIG = {
|
|
140
|
+
pollIntervalMs: 0,
|
|
141
|
+
workerConcurrency: 1,
|
|
142
|
+
maxConcurrentByState: {},
|
|
143
|
+
commandTimeoutMs: 18e5,
|
|
144
|
+
maxAttemptsDefault: 1,
|
|
145
|
+
maxTurns: 1,
|
|
146
|
+
retryDelayMs: 0,
|
|
147
|
+
staleInProgressTimeoutMs: 0,
|
|
148
|
+
logLinesTail: 12e3,
|
|
149
|
+
maxPreviousOutputChars: 12e3,
|
|
150
|
+
agentProvider: "codex",
|
|
151
|
+
agentCommand: "",
|
|
152
|
+
defaultEffort: { default: "medium" },
|
|
153
|
+
runMode: "filesystem",
|
|
154
|
+
autoReviewApproval: true,
|
|
155
|
+
dockerExecution: false,
|
|
156
|
+
dockerImage: "fifony-agent:latest",
|
|
157
|
+
afterCreateHook: "",
|
|
158
|
+
beforeRunHook: "",
|
|
159
|
+
afterRunHook: "",
|
|
160
|
+
beforeRemoveHook: ""
|
|
161
|
+
};
|
|
162
|
+
async function waitForSocket(socketPath, timeoutMs) {
|
|
163
|
+
const deadline = Date.now() + timeoutMs;
|
|
164
|
+
while (Date.now() < deadline) {
|
|
165
|
+
if (existsSync2(socketPath)) return true;
|
|
166
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
async function runCommandWithTimeout(command, workspacePath, issue, config, promptFile, extraEnv = {}, outputFile) {
|
|
171
|
+
const started = Date.now();
|
|
172
|
+
const resultFile = extraEnv.FIFONY_RESULT_FILE;
|
|
173
|
+
const allVars = {
|
|
174
|
+
FIFONY_ISSUE_ID: issue.id,
|
|
175
|
+
FIFONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
176
|
+
FIFONY_ISSUE_TITLE: issue.title,
|
|
177
|
+
FIFONY_WORKSPACE_PATH: issue.worktreePath ?? workspacePath,
|
|
178
|
+
FIFONY_PROMPT_FILE: promptFile
|
|
179
|
+
};
|
|
180
|
+
for (const [key, value] of Object.entries(extraEnv)) {
|
|
181
|
+
if (value.length > 4e3) {
|
|
182
|
+
const valFile = join(workspacePath, `${key.toLowerCase()}.txt`);
|
|
183
|
+
writeFileSync(valFile, value, "utf8");
|
|
184
|
+
allVars[`${key}_FILE`] = valFile;
|
|
185
|
+
} else {
|
|
186
|
+
allVars[key] = value;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (config.dockerExecution) {
|
|
190
|
+
for (const key of Object.keys(allVars)) {
|
|
191
|
+
allVars[key] = translatePaths(allVars[key], workspacePath);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const envFilePath = join(workspacePath, ".env.sh");
|
|
195
|
+
const envFileLines = Object.entries(allVars).map(([k, v]) => `export ${k}='${String(v).replace(/'/g, "'\\''")}'`).join("\n");
|
|
196
|
+
writeFileSync(envFilePath, envFileLines, "utf8");
|
|
197
|
+
let effectiveCommand;
|
|
198
|
+
if (config.dockerExecution && config.dockerImage) {
|
|
199
|
+
const translatedCmd = translatePaths(command, workspacePath);
|
|
200
|
+
effectiveCommand = buildDockerRunCommand(
|
|
201
|
+
translatedCmd,
|
|
202
|
+
workspacePath,
|
|
203
|
+
issue.worktreePath,
|
|
204
|
+
TARGET_ROOT,
|
|
205
|
+
config.dockerImage
|
|
206
|
+
);
|
|
207
|
+
} else {
|
|
208
|
+
effectiveCommand = `. "${envFilePath}" && ${command}`;
|
|
209
|
+
}
|
|
210
|
+
const liveLogFile = join(workspacePath, "live-output.log");
|
|
211
|
+
if (outputFile) {
|
|
212
|
+
try {
|
|
213
|
+
const header = `# fifony stdout capture
|
|
214
|
+
# turn: ${extraEnv.FIFONY_TURN_INDEX ?? "?"}
|
|
215
|
+
# provider: ${extraEnv.FIFONY_AGENT_PROVIDER ?? "?"}
|
|
216
|
+
# role: ${extraEnv.FIFONY_AGENT_ROLE ?? "?"}
|
|
217
|
+
# timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
218
|
+
---
|
|
219
|
+
`;
|
|
220
|
+
writeFileSync(outputFile, header, "utf8");
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!config.dockerExecution && DAEMON_SCRIPT) {
|
|
225
|
+
const socketPath = join(workspacePath, "agent.sock");
|
|
226
|
+
if (existsSync2(socketPath)) {
|
|
227
|
+
const { isDaemonAlive } = await import("./pid-manager-UBWXVSMD.js");
|
|
228
|
+
if (isDaemonAlive(workspacePath)) {
|
|
229
|
+
logger.info({ issueId: issue.id }, "[Agent] Live PTY daemon detected \u2014 reattaching to existing session");
|
|
230
|
+
return attachToDaemon(socketPath, workspacePath, issue, config, started, outputFile, resultFile);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
rmSync(socketPath, { force: true });
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (resultFile && extraEnv.FIFONY_PRESERVE_RESULT_FILE !== "1") {
|
|
238
|
+
rmSync(resultFile, { force: true });
|
|
239
|
+
}
|
|
240
|
+
writeFileSync(liveLogFile, "", "utf8");
|
|
241
|
+
const daemonArgs = JSON.stringify({
|
|
242
|
+
command: effectiveCommand,
|
|
243
|
+
workspacePath,
|
|
244
|
+
issueId: issue.id,
|
|
245
|
+
startedAt: new Date(started).toISOString(),
|
|
246
|
+
commandSlice: command.slice(0, 200)
|
|
247
|
+
});
|
|
248
|
+
const daemonProcess = spawn(DAEMON_SCRIPT.command, [...DAEMON_SCRIPT.args, daemonArgs], {
|
|
249
|
+
detached: true,
|
|
250
|
+
stdio: "ignore",
|
|
251
|
+
cwd: workspacePath
|
|
252
|
+
});
|
|
253
|
+
daemonProcess.unref();
|
|
254
|
+
logger.debug({ issueId: issue.id, daemonPid: daemonProcess.pid, command: command.slice(0, 120), cwd: workspacePath }, "[Agent] PTY daemon spawned");
|
|
255
|
+
const socketReady = await waitForSocket(socketPath, 1e4);
|
|
256
|
+
if (!socketReady) {
|
|
257
|
+
logger.warn({ issueId: issue.id }, "[Agent] PTY daemon socket not ready \u2014 falling back to inline PTY");
|
|
258
|
+
} else {
|
|
259
|
+
return attachToDaemon(socketPath, workspacePath, issue, config, started, outputFile, resultFile);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!config.dockerExecution) {
|
|
263
|
+
let nodePty = null;
|
|
264
|
+
try {
|
|
265
|
+
const mod = await import("node-pty");
|
|
266
|
+
if (typeof mod.spawn === "function") nodePty = mod;
|
|
267
|
+
} catch {
|
|
268
|
+
}
|
|
269
|
+
if (nodePty) {
|
|
270
|
+
if (resultFile && extraEnv.FIFONY_PRESERVE_RESULT_FILE !== "1") {
|
|
271
|
+
rmSync(resultFile, { force: true });
|
|
272
|
+
}
|
|
273
|
+
writeFileSync(liveLogFile, "", "utf8");
|
|
274
|
+
return new Promise((resolve2) => {
|
|
275
|
+
const ptyProcess = nodePty.spawn("sh", ["-c", effectiveCommand], {
|
|
276
|
+
name: "xterm-256color",
|
|
277
|
+
cols: 220,
|
|
278
|
+
rows: 50,
|
|
279
|
+
cwd: workspacePath,
|
|
280
|
+
env: process.env
|
|
281
|
+
});
|
|
282
|
+
const pid = ptyProcess.pid;
|
|
283
|
+
const pidFile = join(workspacePath, "agent.pid");
|
|
284
|
+
if (pid) {
|
|
285
|
+
logger.debug({ issueId: issue.id, pid, command: command.slice(0, 120), cwd: workspacePath }, "[Agent] Process spawned (PTY)");
|
|
286
|
+
writeFileSync(pidFile, JSON.stringify({
|
|
287
|
+
pid,
|
|
288
|
+
issueId: issue.id,
|
|
289
|
+
startedAt: new Date(started).toISOString(),
|
|
290
|
+
command: command.slice(0, 200)
|
|
291
|
+
}), "utf8");
|
|
292
|
+
}
|
|
293
|
+
let output = "";
|
|
294
|
+
let timedOut = false;
|
|
295
|
+
let outputBytes = 0;
|
|
296
|
+
let outputHeader = "";
|
|
297
|
+
const onChunk = (chunk) => {
|
|
298
|
+
const text = String(chunk);
|
|
299
|
+
if (outputHeader.length < 2e3) outputHeader = (outputHeader + text).slice(0, 2e3);
|
|
300
|
+
output = appendFileTail(output, text, config.logLinesTail);
|
|
301
|
+
outputBytes += text.length;
|
|
302
|
+
try {
|
|
303
|
+
appendFileSync(liveLogFile, text);
|
|
304
|
+
} catch {
|
|
305
|
+
}
|
|
306
|
+
if (outputFile) {
|
|
307
|
+
try {
|
|
308
|
+
appendFileSync(outputFile, text);
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
issue.commandOutputTail = output;
|
|
313
|
+
};
|
|
314
|
+
ptyProcess.onData(onChunk);
|
|
315
|
+
const AGENT_STALE_OUTPUT_MS = 18e5;
|
|
316
|
+
const killPty = () => {
|
|
317
|
+
try {
|
|
318
|
+
ptyProcess.kill();
|
|
319
|
+
} catch {
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
const timer = setTimeout(() => {
|
|
323
|
+
timedOut = true;
|
|
324
|
+
killPty();
|
|
325
|
+
}, config.commandTimeoutMs);
|
|
326
|
+
let lastWatchdogBytes = 0;
|
|
327
|
+
let lastOutputGrowthAt = Date.now();
|
|
328
|
+
let watchdogKilled = false;
|
|
329
|
+
const watchdog = setInterval(() => {
|
|
330
|
+
if (pid) {
|
|
331
|
+
try {
|
|
332
|
+
process.kill(pid, 0);
|
|
333
|
+
} catch {
|
|
334
|
+
clearInterval(watchdog);
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
watchdogKilled = true;
|
|
337
|
+
try {
|
|
338
|
+
rmSync(pidFile, { force: true });
|
|
339
|
+
} catch {
|
|
340
|
+
}
|
|
341
|
+
resolve2({ success: false, code: null, output: appendFileTail(output, `
|
|
342
|
+
Agent process died unexpectedly (PID ${pid}).`, config.logLinesTail) });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (outputBytes > lastWatchdogBytes) {
|
|
347
|
+
lastWatchdogBytes = outputBytes;
|
|
348
|
+
lastOutputGrowthAt = Date.now();
|
|
349
|
+
} else if (Date.now() - lastOutputGrowthAt > AGENT_STALE_OUTPUT_MS) {
|
|
350
|
+
clearInterval(watchdog);
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
timedOut = true;
|
|
353
|
+
watchdogKilled = true;
|
|
354
|
+
killPty();
|
|
355
|
+
try {
|
|
356
|
+
rmSync(pidFile, { force: true });
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
resolve2({ success: false, code: null, output: appendFileTail(output, `
|
|
360
|
+
Agent process stuck \u2014 no output for ${Math.round(AGENT_STALE_OUTPUT_MS / 6e4)} minutes.`, config.logLinesTail) });
|
|
361
|
+
}
|
|
362
|
+
}, 3e4);
|
|
363
|
+
const cleanup = () => {
|
|
364
|
+
clearInterval(watchdog);
|
|
365
|
+
try {
|
|
366
|
+
rmSync(pidFile, { force: true });
|
|
367
|
+
} catch {
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
371
|
+
clearTimeout(timer);
|
|
372
|
+
cleanup();
|
|
373
|
+
if (watchdogKilled) return;
|
|
374
|
+
const buildOutput = (suffix) => {
|
|
375
|
+
const tail = appendFileTail(output, suffix, config.logLinesTail);
|
|
376
|
+
return outputHeader.length > 0 && !tail.startsWith(outputHeader.slice(0, 80)) ? `${outputHeader}
|
|
377
|
+
${tail}` : tail;
|
|
378
|
+
};
|
|
379
|
+
if (timedOut) {
|
|
380
|
+
resolve2({ success: false, code: null, output: buildOutput(`
|
|
381
|
+
Execution timeout after ${config.commandTimeoutMs}ms.`) });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const duration = Math.max(0, Date.now() - started);
|
|
385
|
+
if (exitCode === 0) {
|
|
386
|
+
resolve2({ success: true, code: exitCode, output: buildOutput(`
|
|
387
|
+
Execution succeeded in ${duration}ms.`) });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
resolve2({ success: false, code: exitCode ?? null, output: buildOutput(`
|
|
391
|
+
Command exit code ${exitCode ?? "unknown"} after ${duration}ms.`) });
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (resultFile && extraEnv.FIFONY_PRESERVE_RESULT_FILE !== "1") {
|
|
397
|
+
rmSync(resultFile, { force: true });
|
|
398
|
+
}
|
|
399
|
+
writeFileSync(liveLogFile, "", "utf8");
|
|
400
|
+
return new Promise((resolve2) => {
|
|
401
|
+
const child = spawn(effectiveCommand, {
|
|
402
|
+
shell: true,
|
|
403
|
+
cwd: workspacePath,
|
|
404
|
+
detached: !config.dockerExecution,
|
|
405
|
+
// Docker containers don't need detached mode
|
|
406
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
407
|
+
});
|
|
408
|
+
if (!config.dockerExecution) child.unref();
|
|
409
|
+
if (child.stdin) {
|
|
410
|
+
child.stdin.end();
|
|
411
|
+
}
|
|
412
|
+
const pidFile = join(workspacePath, "agent.pid");
|
|
413
|
+
const pid = child.pid;
|
|
414
|
+
if (pid) {
|
|
415
|
+
logger.debug({ issueId: issue.id, pid, command: command.slice(0, 120), cwd: workspacePath }, "[Agent] Process spawned");
|
|
416
|
+
writeFileSync(pidFile, JSON.stringify({
|
|
417
|
+
pid,
|
|
418
|
+
issueId: issue.id,
|
|
419
|
+
startedAt: new Date(started).toISOString(),
|
|
420
|
+
command: command.slice(0, 200)
|
|
421
|
+
}), "utf8");
|
|
422
|
+
}
|
|
423
|
+
let output = "";
|
|
424
|
+
let timedOut = false;
|
|
425
|
+
let outputBytes = 0;
|
|
426
|
+
let outputHeader = "";
|
|
427
|
+
const onChunk = (chunk) => {
|
|
428
|
+
const text = String(chunk);
|
|
429
|
+
if (outputHeader.length < 2e3) outputHeader = (outputHeader + text).slice(0, 2e3);
|
|
430
|
+
output = appendFileTail(output, text, config.logLinesTail);
|
|
431
|
+
outputBytes += text.length;
|
|
432
|
+
try {
|
|
433
|
+
appendFileSync(liveLogFile, text);
|
|
434
|
+
} catch {
|
|
435
|
+
}
|
|
436
|
+
if (outputFile) {
|
|
437
|
+
try {
|
|
438
|
+
appendFileSync(outputFile, text);
|
|
439
|
+
} catch {
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
issue.commandOutputTail = output;
|
|
443
|
+
};
|
|
444
|
+
child.stdout?.on("data", onChunk);
|
|
445
|
+
child.stderr?.on("data", onChunk);
|
|
446
|
+
const AGENT_STALE_OUTPUT_MS = 18e5;
|
|
447
|
+
const timer = setTimeout(() => {
|
|
448
|
+
timedOut = true;
|
|
449
|
+
if (pid) {
|
|
450
|
+
try {
|
|
451
|
+
process.kill(-pid, "SIGTERM");
|
|
452
|
+
} catch {
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
child.kill("SIGTERM");
|
|
456
|
+
}
|
|
457
|
+
}, config.commandTimeoutMs);
|
|
458
|
+
let lastWatchdogBytes = 0;
|
|
459
|
+
let lastOutputGrowthAt = Date.now();
|
|
460
|
+
let watchdogKilled = false;
|
|
461
|
+
const watchdog = setInterval(() => {
|
|
462
|
+
if (pid) {
|
|
463
|
+
try {
|
|
464
|
+
process.kill(pid, 0);
|
|
465
|
+
} catch {
|
|
466
|
+
clearInterval(watchdog);
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
watchdogKilled = true;
|
|
469
|
+
try {
|
|
470
|
+
rmSync(pidFile, { force: true });
|
|
471
|
+
} catch {
|
|
472
|
+
}
|
|
473
|
+
resolve2({ success: false, code: null, output: appendFileTail(output, `
|
|
474
|
+
Agent process died unexpectedly (PID ${pid}).`, config.logLinesTail) });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (outputBytes > lastWatchdogBytes) {
|
|
479
|
+
lastWatchdogBytes = outputBytes;
|
|
480
|
+
lastOutputGrowthAt = Date.now();
|
|
481
|
+
} else if (Date.now() - lastOutputGrowthAt > AGENT_STALE_OUTPUT_MS) {
|
|
482
|
+
clearInterval(watchdog);
|
|
483
|
+
clearTimeout(timer);
|
|
484
|
+
timedOut = true;
|
|
485
|
+
watchdogKilled = true;
|
|
486
|
+
if (pid) {
|
|
487
|
+
try {
|
|
488
|
+
process.kill(-pid, "SIGTERM");
|
|
489
|
+
} catch {
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
child.kill("SIGTERM");
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
rmSync(pidFile, { force: true });
|
|
496
|
+
} catch {
|
|
497
|
+
}
|
|
498
|
+
resolve2({ success: false, code: null, output: appendFileTail(output, `
|
|
499
|
+
Agent process stuck \u2014 no output for ${Math.round(AGENT_STALE_OUTPUT_MS / 6e4)} minutes.`, config.logLinesTail) });
|
|
500
|
+
}
|
|
501
|
+
}, 3e4);
|
|
502
|
+
const cleanup = () => {
|
|
503
|
+
clearInterval(watchdog);
|
|
504
|
+
try {
|
|
505
|
+
rmSync(pidFile, { force: true });
|
|
506
|
+
} catch {
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
child.on("error", () => {
|
|
510
|
+
clearTimeout(timer);
|
|
511
|
+
cleanup();
|
|
512
|
+
if (watchdogKilled) return;
|
|
513
|
+
resolve2({ success: false, code: null, output: `Command execution failed for issue ${issue.id}.` });
|
|
514
|
+
});
|
|
515
|
+
child.on("close", (code) => {
|
|
516
|
+
clearTimeout(timer);
|
|
517
|
+
cleanup();
|
|
518
|
+
if (watchdogKilled) return;
|
|
519
|
+
const buildOutput = (suffix) => {
|
|
520
|
+
const tail = appendFileTail(output, suffix, config.logLinesTail);
|
|
521
|
+
return outputHeader.length > 0 && !tail.startsWith(outputHeader.slice(0, 80)) ? `${outputHeader}
|
|
522
|
+
${tail}` : tail;
|
|
523
|
+
};
|
|
524
|
+
if (timedOut) {
|
|
525
|
+
resolve2({ success: false, code: null, output: buildOutput(`
|
|
526
|
+
Execution timeout after ${config.commandTimeoutMs}ms.`) });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const duration = Math.max(0, Date.now() - started);
|
|
530
|
+
if (code === 0) {
|
|
531
|
+
resolve2({ success: true, code, output: buildOutput(`
|
|
532
|
+
Execution succeeded in ${duration}ms.`) });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
resolve2({ success: false, code, output: buildOutput(`
|
|
536
|
+
Command exit code ${code ?? "unknown"} after ${duration}ms.`) });
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function attachToDaemon(socketPath, workspacePath, issue, config, started, outputFile, resultFile) {
|
|
541
|
+
return new Promise((resolve2) => {
|
|
542
|
+
const daemonExitFile = join(workspacePath, "daemon.exit.json");
|
|
543
|
+
let output = "";
|
|
544
|
+
let outputHeader = "";
|
|
545
|
+
let outputBytes = 0;
|
|
546
|
+
let timedOut = false;
|
|
547
|
+
let resolved = false;
|
|
548
|
+
let sockBuf = "";
|
|
549
|
+
const onChunk = (text) => {
|
|
550
|
+
if (outputHeader.length < 2e3) outputHeader = (outputHeader + text).slice(0, 2e3);
|
|
551
|
+
output = appendFileTail(output, text, config.logLinesTail);
|
|
552
|
+
outputBytes += text.length;
|
|
553
|
+
if (outputFile) {
|
|
554
|
+
try {
|
|
555
|
+
appendFileSync(outputFile, text);
|
|
556
|
+
} catch {
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
issue.commandOutputTail = output;
|
|
560
|
+
};
|
|
561
|
+
const buildOutput = (suffix) => {
|
|
562
|
+
const tail = appendFileTail(output, suffix, config.logLinesTail);
|
|
563
|
+
return outputHeader.length > 0 && !tail.startsWith(outputHeader.slice(0, 80)) ? `${outputHeader}
|
|
564
|
+
${tail}` : tail;
|
|
565
|
+
};
|
|
566
|
+
const finish = (success, code, suffix) => {
|
|
567
|
+
if (resolved) return;
|
|
568
|
+
resolved = true;
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
clearInterval(watchdog);
|
|
571
|
+
sock.destroy();
|
|
572
|
+
if (resultFile) {
|
|
573
|
+
try {
|
|
574
|
+
rmSync(daemonExitFile, { force: true });
|
|
575
|
+
} catch {
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
resolve2({ success, code, output: buildOutput(suffix) });
|
|
579
|
+
};
|
|
580
|
+
const sock = createConnection(socketPath);
|
|
581
|
+
sock.on("connect", () => {
|
|
582
|
+
sock.write(JSON.stringify({ t: "tail" }) + "\n");
|
|
583
|
+
});
|
|
584
|
+
sock.on("data", (chunk) => {
|
|
585
|
+
sockBuf += chunk.toString();
|
|
586
|
+
const lines = sockBuf.split("\n");
|
|
587
|
+
sockBuf = lines.pop() ?? "";
|
|
588
|
+
for (const line of lines) {
|
|
589
|
+
if (!line.trim()) continue;
|
|
590
|
+
try {
|
|
591
|
+
const msg = JSON.parse(line);
|
|
592
|
+
if (msg.t === "d" && typeof msg.v === "string") {
|
|
593
|
+
onChunk(msg.v);
|
|
594
|
+
} else if (msg.t === "tail" && typeof msg.v === "string") {
|
|
595
|
+
output = msg.v.slice(-config.logLinesTail * 4);
|
|
596
|
+
if (outputHeader.length === 0) outputHeader = output.slice(0, 2e3);
|
|
597
|
+
outputBytes = output.length;
|
|
598
|
+
issue.commandOutputTail = output;
|
|
599
|
+
} else if (msg.t === "x") {
|
|
600
|
+
const exitCode = msg.c ?? null;
|
|
601
|
+
const success = exitCode === 0;
|
|
602
|
+
const duration = Math.max(0, Date.now() - started);
|
|
603
|
+
const suffix = success ? `
|
|
604
|
+
Execution succeeded in ${duration}ms.` : `
|
|
605
|
+
Command exit code ${exitCode ?? "unknown"} after ${duration}ms.`;
|
|
606
|
+
finish(success, exitCode, suffix);
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
sock.on("error", (err) => {
|
|
613
|
+
logger.warn({ issueId: issue.id, err: String(err) }, "[Agent] Daemon socket error");
|
|
614
|
+
tryRecoverFromExitFile();
|
|
615
|
+
});
|
|
616
|
+
sock.on("close", () => {
|
|
617
|
+
if (!resolved) tryRecoverFromExitFile();
|
|
618
|
+
});
|
|
619
|
+
const tryRecoverFromExitFile = () => {
|
|
620
|
+
if (resolved) return;
|
|
621
|
+
try {
|
|
622
|
+
const raw = readFileSync(daemonExitFile, "utf8");
|
|
623
|
+
const rec = JSON.parse(raw);
|
|
624
|
+
const duration = Math.max(0, Date.now() - started);
|
|
625
|
+
finish(rec.success, rec.code, `
|
|
626
|
+
Recovered from daemon exit record after ${duration}ms.`);
|
|
627
|
+
} catch {
|
|
628
|
+
finish(false, null, "\nDaemon socket closed unexpectedly and no exit record found.");
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
const AGENT_STALE_OUTPUT_MS = 18e5;
|
|
632
|
+
let lastWatchdogBytes = 0;
|
|
633
|
+
let lastOutputGrowthAt = Date.now();
|
|
634
|
+
const timer = setTimeout(() => {
|
|
635
|
+
timedOut = true;
|
|
636
|
+
sock.write(JSON.stringify({ t: "cancel" }) + "\n");
|
|
637
|
+
finish(false, null, `
|
|
638
|
+
Execution timeout after ${config.commandTimeoutMs}ms.`);
|
|
639
|
+
}, config.commandTimeoutMs);
|
|
640
|
+
const watchdog = setInterval(() => {
|
|
641
|
+
if (outputBytes > lastWatchdogBytes) {
|
|
642
|
+
lastWatchdogBytes = outputBytes;
|
|
643
|
+
lastOutputGrowthAt = Date.now();
|
|
644
|
+
} else if (Date.now() - lastOutputGrowthAt > AGENT_STALE_OUTPUT_MS) {
|
|
645
|
+
sock.write(JSON.stringify({ t: "cancel" }) + "\n");
|
|
646
|
+
finish(false, null, `
|
|
647
|
+
Agent process stuck \u2014 no output for ${Math.round(AGENT_STALE_OUTPUT_MS / 6e4)} minutes.`);
|
|
648
|
+
}
|
|
649
|
+
void timedOut;
|
|
650
|
+
}, 3e4);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
async function writeToDaemon(workspacePath, text) {
|
|
654
|
+
const socketPath = join(workspacePath, "agent.sock");
|
|
655
|
+
if (!existsSync2(socketPath)) return;
|
|
656
|
+
return new Promise((resolve2) => {
|
|
657
|
+
const sock = createConnection(socketPath);
|
|
658
|
+
const cleanup = () => {
|
|
659
|
+
try {
|
|
660
|
+
sock.destroy();
|
|
661
|
+
} catch {
|
|
662
|
+
}
|
|
663
|
+
resolve2();
|
|
664
|
+
};
|
|
665
|
+
sock.on("connect", () => {
|
|
666
|
+
try {
|
|
667
|
+
sock.write(JSON.stringify({ t: "write", v: text }) + "\n");
|
|
668
|
+
} catch {
|
|
669
|
+
}
|
|
670
|
+
setImmediate(cleanup);
|
|
671
|
+
});
|
|
672
|
+
sock.on("error", cleanup);
|
|
673
|
+
sock.on("timeout", cleanup);
|
|
674
|
+
sock.setTimeout(3e3);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
async function runHook(command, workspacePath, issue, hookName, extraEnv = {}) {
|
|
678
|
+
if (!command.trim()) return;
|
|
679
|
+
const result = await runCommandWithTimeout(command, workspacePath, issue, {
|
|
680
|
+
...HOOK_RUNTIME_CONFIG,
|
|
681
|
+
agentProvider: normalizeAgentProvider(env.FIFONY_AGENT_PROVIDER ?? "codex"),
|
|
682
|
+
agentCommand: command
|
|
683
|
+
}, "", { FIFONY_HOOK_NAME: hookName, ...extraEnv });
|
|
684
|
+
if (!result.success) {
|
|
685
|
+
throw new Error(`${hookName} hook failed: ${result.output}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// src/agents/review-failure-history.ts
|
|
690
|
+
function sortFailureHistory(history) {
|
|
691
|
+
return [...history].sort((left, right) => {
|
|
692
|
+
const leftAt = Date.parse(left.recordedAt);
|
|
693
|
+
const rightAt = Date.parse(right.recordedAt);
|
|
694
|
+
if (!Number.isNaN(leftAt) && !Number.isNaN(rightAt) && leftAt !== rightAt) return rightAt - leftAt;
|
|
695
|
+
return left.id.localeCompare(right.id);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
function recordReviewFailures(issue, reviewRun, gradingReport, recordedAt) {
|
|
699
|
+
if (!reviewRun || !gradingReport) return issue.reviewFailureHistory ?? [];
|
|
700
|
+
const failedCriteria = gradingReport.criteria.filter((criterion) => criterion.result === "FAIL");
|
|
701
|
+
if (failedCriteria.length === 0) return issue.reviewFailureHistory ?? [];
|
|
702
|
+
const existing = Array.isArray(issue.reviewFailureHistory) ? issue.reviewFailureHistory : [];
|
|
703
|
+
const next = [...existing];
|
|
704
|
+
for (const criterion of failedCriteria) {
|
|
705
|
+
const record = {
|
|
706
|
+
id: `${reviewRun.id}:${criterion.id}`,
|
|
707
|
+
runId: reviewRun.id,
|
|
708
|
+
scope: reviewRun.scope,
|
|
709
|
+
planVersion: reviewRun.planVersion ?? (issue.planVersion ?? 1),
|
|
710
|
+
attempt: reviewRun.attempt ?? gradingReport.reviewAttempt ?? 1,
|
|
711
|
+
criterionId: criterion.id,
|
|
712
|
+
description: criterion.description,
|
|
713
|
+
category: criterion.category,
|
|
714
|
+
verificationMethod: criterion.verificationMethod,
|
|
715
|
+
blocking: criterion.blocking,
|
|
716
|
+
weight: criterion.weight,
|
|
717
|
+
evidence: criterion.evidence,
|
|
718
|
+
recordedAt,
|
|
719
|
+
reviewProfile: reviewRun.reviewProfile?.primary,
|
|
720
|
+
routing: reviewRun.routing
|
|
721
|
+
};
|
|
722
|
+
const existingIndex = next.findIndex((entry) => entry.id === record.id);
|
|
723
|
+
if (existingIndex >= 0) {
|
|
724
|
+
next[existingIndex] = record;
|
|
725
|
+
} else {
|
|
726
|
+
next.push(record);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
issue.reviewFailureHistory = sortFailureHistory(next);
|
|
730
|
+
return issue.reviewFailureHistory;
|
|
731
|
+
}
|
|
732
|
+
function collectRecurringFailurePatterns(issue, options = {}) {
|
|
733
|
+
const {
|
|
734
|
+
scope,
|
|
735
|
+
blockingOnly = false,
|
|
736
|
+
currentPlanVersionOnly = false,
|
|
737
|
+
minOccurrences = 2,
|
|
738
|
+
limit = 6
|
|
739
|
+
} = options;
|
|
740
|
+
const currentPlanVersion = issue.planVersion ?? 1;
|
|
741
|
+
const history = (issue.reviewFailureHistory ?? []).filter((entry) => {
|
|
742
|
+
if (scope && entry.scope !== scope) return false;
|
|
743
|
+
if (blockingOnly && !entry.blocking) return false;
|
|
744
|
+
if (currentPlanVersionOnly && entry.planVersion !== currentPlanVersion) return false;
|
|
745
|
+
return true;
|
|
746
|
+
});
|
|
747
|
+
const patterns = /* @__PURE__ */ new Map();
|
|
748
|
+
for (const entry of history) {
|
|
749
|
+
const key = `${entry.scope}:${entry.criterionId}`;
|
|
750
|
+
const existing = patterns.get(key);
|
|
751
|
+
if (!existing) {
|
|
752
|
+
patterns.set(key, {
|
|
753
|
+
criterionId: entry.criterionId,
|
|
754
|
+
description: entry.description,
|
|
755
|
+
category: entry.category,
|
|
756
|
+
blocking: entry.blocking,
|
|
757
|
+
count: 1,
|
|
758
|
+
attempts: [entry.attempt],
|
|
759
|
+
scopes: [entry.scope],
|
|
760
|
+
latestEvidence: entry.evidence,
|
|
761
|
+
latestRecordedAt: entry.recordedAt
|
|
762
|
+
});
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
existing.count += 1;
|
|
766
|
+
if (!existing.attempts.includes(entry.attempt)) existing.attempts.push(entry.attempt);
|
|
767
|
+
if (!existing.scopes.includes(entry.scope)) existing.scopes.push(entry.scope);
|
|
768
|
+
if (Date.parse(entry.recordedAt) >= Date.parse(existing.latestRecordedAt)) {
|
|
769
|
+
existing.latestEvidence = entry.evidence;
|
|
770
|
+
existing.latestRecordedAt = entry.recordedAt;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return [...patterns.values()].filter((pattern) => pattern.count >= minOccurrences).sort((left, right) => {
|
|
774
|
+
if (right.count !== left.count) return right.count - left.count;
|
|
775
|
+
return right.latestRecordedAt.localeCompare(left.latestRecordedAt);
|
|
776
|
+
}).slice(0, limit);
|
|
777
|
+
}
|
|
778
|
+
function findRecurringBlockingFailures(issue, gradingReport, scope, minOccurrences = 2) {
|
|
779
|
+
const currentBlockingIds = new Set(
|
|
780
|
+
gradingReport.criteria.filter((criterion) => criterion.result === "FAIL" && criterion.blocking).map((criterion) => criterion.id)
|
|
781
|
+
);
|
|
782
|
+
if (currentBlockingIds.size === 0) return [];
|
|
783
|
+
return collectRecurringFailurePatterns(issue, {
|
|
784
|
+
scope,
|
|
785
|
+
blockingOnly: true,
|
|
786
|
+
currentPlanVersionOnly: true,
|
|
787
|
+
minOccurrences,
|
|
788
|
+
limit: currentBlockingIds.size
|
|
789
|
+
}).filter((pattern) => currentBlockingIds.has(pattern.criterionId));
|
|
790
|
+
}
|
|
791
|
+
function buildRecurringFailureContext(issue, scope) {
|
|
792
|
+
const patterns = collectRecurringFailurePatterns(issue, {
|
|
793
|
+
scope,
|
|
794
|
+
currentPlanVersionOnly: true,
|
|
795
|
+
minOccurrences: 2,
|
|
796
|
+
limit: 5
|
|
797
|
+
});
|
|
798
|
+
if (patterns.length === 0) return "";
|
|
799
|
+
const lines = ["## Recurring Reviewer Failures", ""];
|
|
800
|
+
lines.push("The reviewer has already flagged these patterns more than once. Treat them as the highest-priority risks before asking for another review.", "");
|
|
801
|
+
for (const pattern of patterns) {
|
|
802
|
+
const attemptLabel = pattern.attempts.length > 0 ? `attempts ${pattern.attempts.sort((a, b) => a - b).join(", ")}` : "multiple attempts";
|
|
803
|
+
lines.push(`- **${pattern.criterionId}** [${pattern.category}] failed ${pattern.count} time(s) across ${attemptLabel}: ${pattern.description}`);
|
|
804
|
+
if (pattern.latestEvidence) {
|
|
805
|
+
lines.push(` Latest evidence: ${pattern.latestEvidence}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
lines.push("");
|
|
809
|
+
return lines.join("\n");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/agents/prompt-builder.ts
|
|
813
|
+
function buildRetryContext(issue) {
|
|
814
|
+
const summaries = issue.previousAttemptSummaries;
|
|
815
|
+
const recurringFailureContext = buildRecurringFailureContext(issue);
|
|
816
|
+
if ((!summaries || summaries.length === 0) && !recurringFailureContext) return "";
|
|
817
|
+
const lines = [];
|
|
818
|
+
if (summaries && summaries.length > 0) {
|
|
819
|
+
lines.push("## Previous Attempts\n");
|
|
820
|
+
lines.push("The following previous attempts FAILED. Do NOT repeat the same approach. Try a fundamentally different strategy.\n");
|
|
821
|
+
}
|
|
822
|
+
for (let i = 0; i < (summaries?.length ?? 0); i++) {
|
|
823
|
+
const s = summaries[i];
|
|
824
|
+
const phaseLabel = s.phase === "review" ? "review" : s.phase === "crash" ? "crash" : s.phase === "plan" ? "plan" : "execution";
|
|
825
|
+
lines.push(`### Attempt ${i + 1} \u2014 ${phaseLabel} failure (plan v${s.planVersion}, exec #${s.executeAttempt})`);
|
|
826
|
+
if (s.phase === "review") {
|
|
827
|
+
lines.push("*The reviewer identified issues with the previous implementation. Focus on addressing the reviewer's feedback \u2014 do not redo work that was already approved.*");
|
|
828
|
+
} else if (s.phase === "crash") {
|
|
829
|
+
lines.push("*The agent process crashed or timed out. Simplify the approach \u2014 break the work into smaller steps.*");
|
|
830
|
+
}
|
|
831
|
+
if (s.insight) {
|
|
832
|
+
lines.push(`**Failure type:** ${s.insight.errorType}`);
|
|
833
|
+
lines.push(`**Root cause:** ${s.insight.rootCause}`);
|
|
834
|
+
if (s.insight.failedCommand) lines.push(`**Failed command:** \`${s.insight.failedCommand}\``);
|
|
835
|
+
if (s.insight.filesInvolved.length > 0) {
|
|
836
|
+
lines.push(`**Files involved:** ${s.insight.filesInvolved.map((f) => `\`${f}\``).join(", ")}`);
|
|
837
|
+
}
|
|
838
|
+
lines.push(`**What to do differently:** ${s.insight.suggestion}`);
|
|
839
|
+
} else {
|
|
840
|
+
lines.push(`**Error:** ${s.error}`);
|
|
841
|
+
}
|
|
842
|
+
if (s.outputTail) {
|
|
843
|
+
lines.push(`
|
|
844
|
+
<details><summary>Output tail</summary>
|
|
845
|
+
|
|
846
|
+
\`\`\`
|
|
847
|
+
${s.outputTail}
|
|
848
|
+
\`\`\`
|
|
849
|
+
</details>`);
|
|
850
|
+
}
|
|
851
|
+
if (s.outputFile) {
|
|
852
|
+
lines.push(`*Full output saved in: outputs/${s.outputFile}*`);
|
|
853
|
+
}
|
|
854
|
+
lines.push("");
|
|
855
|
+
}
|
|
856
|
+
if (issue.lastFailedPhase === "review" && issue.gradingReport) {
|
|
857
|
+
const failedCriteria = issue.gradingReport.criteria.filter(
|
|
858
|
+
(c) => c.result === "FAIL" && ((issue.gradingReport?.blockingVerdict ?? "FAIL") === "FAIL" ? c.blocking : true)
|
|
859
|
+
);
|
|
860
|
+
if (failedCriteria.length > 0) {
|
|
861
|
+
lines.push("## Previous Review Grade: FAIL\n");
|
|
862
|
+
lines.push("The automated reviewer graded your last submission and found these specific failures:");
|
|
863
|
+
for (const c of failedCriteria) {
|
|
864
|
+
lines.push(`- **${c.id}** [${c.category}] FAILED: ${c.description} \u2014 ${c.evidence}`);
|
|
865
|
+
}
|
|
866
|
+
lines.push("\nYou MUST address ALL of these before submitting. The reviewer will check each one again.\n");
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (recurringFailureContext) {
|
|
870
|
+
lines.push(recurringFailureContext);
|
|
871
|
+
}
|
|
872
|
+
const full = lines.join("\n");
|
|
873
|
+
return full.length > 8e3 ? full.slice(0, 8e3) + "\n[...truncated]" : full;
|
|
874
|
+
}
|
|
875
|
+
async function buildPrompt(issue, _workflowDefinition) {
|
|
876
|
+
const rendered = await renderPrompt("workflow-default", { issue, attempt: issue.attempts || 0 });
|
|
877
|
+
if (!issue.plan?.steps?.length) {
|
|
878
|
+
return rendered;
|
|
879
|
+
}
|
|
880
|
+
const planSection = await renderPrompt("workflow-plan-section", {
|
|
881
|
+
estimatedComplexity: issue.plan.estimatedComplexity,
|
|
882
|
+
summary: issue.plan.summary,
|
|
883
|
+
steps: issue.plan.steps.map((step) => ({
|
|
884
|
+
step: step.step,
|
|
885
|
+
action: step.action,
|
|
886
|
+
files: step.files ?? [],
|
|
887
|
+
details: step.details ?? ""
|
|
888
|
+
}))
|
|
889
|
+
});
|
|
890
|
+
return `${rendered}
|
|
891
|
+
|
|
892
|
+
${planSection}`;
|
|
893
|
+
}
|
|
894
|
+
var CONTEXT_WINDOW_BY_MODEL = [
|
|
895
|
+
["claude-3-5", 2e5],
|
|
896
|
+
["claude-3-7", 2e5],
|
|
897
|
+
["claude-opus-4", 2e5],
|
|
898
|
+
["claude-sonnet-4", 2e5],
|
|
899
|
+
["claude-haiku-4", 2e5],
|
|
900
|
+
["claude", 2e5],
|
|
901
|
+
["gemini-2.5", 1e6],
|
|
902
|
+
["gemini-2.0", 1e6],
|
|
903
|
+
["gemini-1.5", 1e6],
|
|
904
|
+
["gemini", 128e3],
|
|
905
|
+
["gpt-4o", 128e3],
|
|
906
|
+
["gpt-4", 128e3],
|
|
907
|
+
["o1", 2e5],
|
|
908
|
+
["o3", 2e5],
|
|
909
|
+
["codex", 128e3]
|
|
910
|
+
];
|
|
911
|
+
function resolveContextWindow(model) {
|
|
912
|
+
if (!model) return null;
|
|
913
|
+
const lc = model.toLowerCase();
|
|
914
|
+
for (const [fragment, size] of CONTEXT_WINDOW_BY_MODEL) {
|
|
915
|
+
if (lc.includes(fragment)) return size;
|
|
916
|
+
}
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
async function buildTurnPrompt(issue, basePrompt, previousOutput, turnIndex, maxTurns, nextPrompt) {
|
|
920
|
+
if (turnIndex === 1) return basePrompt;
|
|
921
|
+
const turnsRemaining = maxTurns - turnIndex + 1;
|
|
922
|
+
const isFinalTurns = turnsRemaining <= 2;
|
|
923
|
+
const cumulativeTokens = issue.tokenUsage?.totalTokens ?? 0;
|
|
924
|
+
const contextWindow = resolveContextWindow(issue.tokenUsage?.model);
|
|
925
|
+
const contextWindowPct = contextWindow && cumulativeTokens > 0 ? Math.round(cumulativeTokens / contextWindow * 100) : null;
|
|
926
|
+
const isContextPressure = contextWindowPct !== null && contextWindowPct >= 70;
|
|
927
|
+
return renderPrompt("agent-turn", {
|
|
928
|
+
issueIdentifier: issue.identifier,
|
|
929
|
+
turnIndex,
|
|
930
|
+
maxTurns,
|
|
931
|
+
turnsRemaining,
|
|
932
|
+
isFinalTurns,
|
|
933
|
+
isContextPressure,
|
|
934
|
+
contextWindowPct: contextWindowPct ?? 0,
|
|
935
|
+
basePrompt,
|
|
936
|
+
continuation: nextPrompt.trim() || "Continue the work, inspect the workspace, and move the issue toward completion.",
|
|
937
|
+
outputTail: previousOutput.trim() || "No previous output captured."
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
async function buildProviderBasePrompt(provider, issue, basePrompt, workspacePath, skillContext, capabilitiesManifest) {
|
|
941
|
+
return renderPrompt("agent-provider-base", {
|
|
942
|
+
isPlanner: provider.role === "planner",
|
|
943
|
+
isReviewer: provider.role === "reviewer",
|
|
944
|
+
hasImpeccableOverlay: provider.overlays?.includes("impeccable") ?? false,
|
|
945
|
+
hasFrontendDesignOverlay: provider.overlays?.includes("frontend-design") ?? false,
|
|
946
|
+
profileInstructions: provider.profileInstructions || "",
|
|
947
|
+
skillContext,
|
|
948
|
+
capabilitiesManifest: capabilitiesManifest || "",
|
|
949
|
+
capabilityCategory: "",
|
|
950
|
+
selectionReason: provider.selectionReason ?? "",
|
|
951
|
+
overlays: provider.overlays ?? [],
|
|
952
|
+
targetPaths: issue.paths ?? [],
|
|
953
|
+
workspacePath,
|
|
954
|
+
basePrompt
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// src/agents/memory-engine.ts
|
|
959
|
+
import {
|
|
960
|
+
existsSync as existsSync3,
|
|
961
|
+
mkdirSync,
|
|
962
|
+
readdirSync,
|
|
963
|
+
readFileSync as readFileSync2,
|
|
964
|
+
writeFileSync as writeFileSync2
|
|
965
|
+
} from "fs";
|
|
966
|
+
import { join as join2 } from "path";
|
|
967
|
+
var MEMORY_DIRNAME = "memory";
|
|
968
|
+
var WORKFLOW_FILE = "WORKFLOW.md";
|
|
969
|
+
var MEMORY_FILE = "MEMORY.md";
|
|
970
|
+
var HEARTBEAT_FILE = "HEARTBEAT.md";
|
|
971
|
+
function resolveTodayDate(value = now()) {
|
|
972
|
+
return value.slice(0, 10);
|
|
973
|
+
}
|
|
974
|
+
function resolvePaths(workspacePath, date = resolveTodayDate()) {
|
|
975
|
+
const memoryDir = join2(workspacePath, MEMORY_DIRNAME);
|
|
976
|
+
return {
|
|
977
|
+
root: workspacePath,
|
|
978
|
+
memoryDir,
|
|
979
|
+
workflowFile: join2(workspacePath, WORKFLOW_FILE),
|
|
980
|
+
memoryFile: join2(workspacePath, MEMORY_FILE),
|
|
981
|
+
heartbeatFile: join2(workspacePath, HEARTBEAT_FILE),
|
|
982
|
+
dailyFile: join2(memoryDir, `${date}.md`)
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
function readText(filePath) {
|
|
986
|
+
try {
|
|
987
|
+
return existsSync3(filePath) ? readFileSync2(filePath, "utf8") : "";
|
|
988
|
+
} catch {
|
|
989
|
+
return "";
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
function writeIfChanged(filePath, next) {
|
|
993
|
+
const current = readText(filePath);
|
|
994
|
+
if (current === next) return false;
|
|
995
|
+
writeFileSync2(filePath, next, "utf8");
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
function ensureFile(filePath, initial) {
|
|
999
|
+
if (existsSync3(filePath)) return false;
|
|
1000
|
+
writeFileSync2(filePath, initial, "utf8");
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
function renderWorkflowDocument(issue) {
|
|
1004
|
+
const plan = issue.plan;
|
|
1005
|
+
const lines = [
|
|
1006
|
+
"# Fifony Workflow Context",
|
|
1007
|
+
"",
|
|
1008
|
+
`Updated: ${now()}`,
|
|
1009
|
+
`Issue: ${issue.identifier} - ${issue.title}`,
|
|
1010
|
+
`State: ${issue.state}`,
|
|
1011
|
+
`Plan version: ${issue.planVersion ?? 0}`,
|
|
1012
|
+
`Execute attempt: ${issue.executeAttempt ?? 0}`,
|
|
1013
|
+
`Review attempt: ${issue.reviewAttempt ?? 0}`,
|
|
1014
|
+
`Harness mode: ${plan?.harnessMode ?? "unknown"}`,
|
|
1015
|
+
""
|
|
1016
|
+
];
|
|
1017
|
+
if (plan?.summary) {
|
|
1018
|
+
lines.push("## Current Plan", "", plan.summary, "");
|
|
1019
|
+
}
|
|
1020
|
+
if (plan?.executionContract) {
|
|
1021
|
+
lines.push("## Execution Contract", "");
|
|
1022
|
+
lines.push(`Summary: ${plan.executionContract.summary}`);
|
|
1023
|
+
lines.push(`Checkpoint policy: ${plan.executionContract.checkpointPolicy}`);
|
|
1024
|
+
if (plan.executionContract.focusAreas.length > 0) {
|
|
1025
|
+
lines.push("", "Focus areas:");
|
|
1026
|
+
for (const focusArea of plan.executionContract.focusAreas) lines.push(`- ${focusArea}`);
|
|
1027
|
+
}
|
|
1028
|
+
if (plan.executionContract.requiredChecks.length > 0) {
|
|
1029
|
+
lines.push("", "Required checks:");
|
|
1030
|
+
for (const check of plan.executionContract.requiredChecks) lines.push(`- ${check}`);
|
|
1031
|
+
}
|
|
1032
|
+
lines.push("");
|
|
1033
|
+
}
|
|
1034
|
+
if (plan?.acceptanceCriteria?.length) {
|
|
1035
|
+
lines.push("## Acceptance Criteria", "");
|
|
1036
|
+
for (const criterion of plan.acceptanceCriteria) {
|
|
1037
|
+
lines.push(`- ${criterion.id} [${criterion.category}] ${criterion.blocking ? "blocking" : "advisory"}: ${criterion.description}`);
|
|
1038
|
+
}
|
|
1039
|
+
lines.push("");
|
|
1040
|
+
}
|
|
1041
|
+
const recurringFailures = collectRecurringFailurePatterns(issue, {
|
|
1042
|
+
currentPlanVersionOnly: true,
|
|
1043
|
+
minOccurrences: 2,
|
|
1044
|
+
limit: 5
|
|
1045
|
+
});
|
|
1046
|
+
if (recurringFailures.length > 0) {
|
|
1047
|
+
lines.push("## Recurring Failure Patterns", "");
|
|
1048
|
+
for (const failure of recurringFailures) {
|
|
1049
|
+
lines.push(`- ${failure.criterionId} failed ${failure.count}x: ${failure.description}`);
|
|
1050
|
+
}
|
|
1051
|
+
lines.push("");
|
|
1052
|
+
}
|
|
1053
|
+
return `${lines.join("\n").trim()}
|
|
1054
|
+
`;
|
|
1055
|
+
}
|
|
1056
|
+
function renderHeartbeatDocument(issue) {
|
|
1057
|
+
const lines = [
|
|
1058
|
+
"# Fifony Heartbeat",
|
|
1059
|
+
"",
|
|
1060
|
+
"Use this file as the short operational checklist for the current issue workspace.",
|
|
1061
|
+
"",
|
|
1062
|
+
"## Current Checks",
|
|
1063
|
+
"",
|
|
1064
|
+
`- Current state: ${issue.state}`,
|
|
1065
|
+
`- Review attempts: ${issue.reviewAttempt ?? 0}`,
|
|
1066
|
+
`- Checkpoint status: ${issue.checkpointStatus ?? "n/a"}`,
|
|
1067
|
+
`- Last error: ${issue.lastError ?? "none"}`
|
|
1068
|
+
];
|
|
1069
|
+
if (issue.plan?.executionContract.focusAreas?.length) {
|
|
1070
|
+
lines.push("", "## Focus Next", "");
|
|
1071
|
+
for (const focusArea of issue.plan.executionContract.focusAreas) {
|
|
1072
|
+
lines.push(`- Inspect ${focusArea}`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
const recentPolicyDecisions = (issue.policyDecisions ?? []).slice(0, 3);
|
|
1076
|
+
if (recentPolicyDecisions.length > 0) {
|
|
1077
|
+
lines.push("", "## Recent Policy Decisions", "");
|
|
1078
|
+
for (const decision of recentPolicyDecisions) {
|
|
1079
|
+
lines.push(`- ${decision.kind}: ${decision.rationale}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return `${lines.join("\n").trim()}
|
|
1083
|
+
`;
|
|
1084
|
+
}
|
|
1085
|
+
function renderMemoryHeader(issue) {
|
|
1086
|
+
return [
|
|
1087
|
+
"# Durable Workspace Memory",
|
|
1088
|
+
"",
|
|
1089
|
+
"This file keeps high-value lessons for the current issue workspace.",
|
|
1090
|
+
"",
|
|
1091
|
+
`Issue: ${issue.identifier} - ${issue.title}`,
|
|
1092
|
+
"",
|
|
1093
|
+
"## Durable Learnings",
|
|
1094
|
+
""
|
|
1095
|
+
].join("\n");
|
|
1096
|
+
}
|
|
1097
|
+
function buildDurableEntries(issue) {
|
|
1098
|
+
const entries = [];
|
|
1099
|
+
for (const failure of collectRecurringFailurePatterns(issue, {
|
|
1100
|
+
currentPlanVersionOnly: true,
|
|
1101
|
+
minOccurrences: 2,
|
|
1102
|
+
limit: 10
|
|
1103
|
+
})) {
|
|
1104
|
+
entries.push({
|
|
1105
|
+
id: `failure-${failure.criterionId}`,
|
|
1106
|
+
kind: "review-failure",
|
|
1107
|
+
issueId: issue.id,
|
|
1108
|
+
issueIdentifier: issue.identifier,
|
|
1109
|
+
title: `Recurring blocking failure: ${failure.criterionId}`,
|
|
1110
|
+
summary: `${failure.description} Failed ${failure.count} times.`,
|
|
1111
|
+
details: failure.latestEvidence ? [failure.latestEvidence] : void 0,
|
|
1112
|
+
source: "review",
|
|
1113
|
+
createdAt: failure.latestRecordedAt,
|
|
1114
|
+
planVersion: issue.planVersion,
|
|
1115
|
+
reviewAttempt: issue.reviewAttempt,
|
|
1116
|
+
persistLongTerm: true,
|
|
1117
|
+
tags: [failure.category, "recurring-failure"]
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
for (const decision of issue.policyDecisions ?? []) {
|
|
1121
|
+
entries.push({
|
|
1122
|
+
id: `policy-${decision.id}`,
|
|
1123
|
+
kind: "policy-decision",
|
|
1124
|
+
issueId: issue.id,
|
|
1125
|
+
issueIdentifier: issue.identifier,
|
|
1126
|
+
title: `Policy decision: ${decision.kind}`,
|
|
1127
|
+
summary: decision.rationale,
|
|
1128
|
+
source: "runtime",
|
|
1129
|
+
createdAt: decision.recordedAt,
|
|
1130
|
+
planVersion: decision.planVersion,
|
|
1131
|
+
reviewScope: decision.reviewScope,
|
|
1132
|
+
persistLongTerm: true,
|
|
1133
|
+
tags: [decision.kind, decision.basis]
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
const latestNegotiation = [...issue.contractNegotiationRuns ?? []].filter((entry) => entry.status === "completed" && entry.blockingConcernsCount && entry.blockingConcernsCount > 0).sort((left, right) => Date.parse(right.completedAt ?? right.startedAt) - Date.parse(left.completedAt ?? left.startedAt))[0];
|
|
1137
|
+
if (latestNegotiation) {
|
|
1138
|
+
entries.push({
|
|
1139
|
+
id: `contract-${latestNegotiation.id}`,
|
|
1140
|
+
kind: "contract-negotiation",
|
|
1141
|
+
issueId: issue.id,
|
|
1142
|
+
issueIdentifier: issue.identifier,
|
|
1143
|
+
title: "Contract negotiation concern",
|
|
1144
|
+
summary: latestNegotiation.summary || "Execution contract required revision before code could be written.",
|
|
1145
|
+
details: (latestNegotiation.concerns ?? []).slice(0, 3).map((concern) => `${concern.id}: ${concern.requiredChange}`),
|
|
1146
|
+
source: "planning",
|
|
1147
|
+
createdAt: latestNegotiation.completedAt ?? latestNegotiation.startedAt,
|
|
1148
|
+
planVersion: latestNegotiation.planVersion,
|
|
1149
|
+
persistLongTerm: true,
|
|
1150
|
+
tags: ["contractual", "planning"]
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
return entries;
|
|
1154
|
+
}
|
|
1155
|
+
function renderEntry(entry) {
|
|
1156
|
+
const lines = [
|
|
1157
|
+
`<!-- fifony-memory:${entry.id} -->`,
|
|
1158
|
+
`### ${entry.title}`,
|
|
1159
|
+
"",
|
|
1160
|
+
`- kind: ${entry.kind}`,
|
|
1161
|
+
`- source: ${entry.source}`,
|
|
1162
|
+
`- createdAt: ${entry.createdAt}`,
|
|
1163
|
+
`- planVersion: ${entry.planVersion ?? 0}`,
|
|
1164
|
+
entry.reviewAttempt ? `- reviewAttempt: ${entry.reviewAttempt}` : "",
|
|
1165
|
+
entry.reviewScope ? `- reviewScope: ${entry.reviewScope}` : "",
|
|
1166
|
+
entry.tags?.length ? `- tags: ${entry.tags.join(", ")}` : "",
|
|
1167
|
+
"",
|
|
1168
|
+
entry.summary,
|
|
1169
|
+
""
|
|
1170
|
+
].filter(Boolean);
|
|
1171
|
+
if (entry.details?.length) {
|
|
1172
|
+
lines.push("Details:");
|
|
1173
|
+
for (const detail of entry.details) lines.push(`- ${detail}`);
|
|
1174
|
+
lines.push("");
|
|
1175
|
+
}
|
|
1176
|
+
return `${lines.join("\n")}
|
|
1177
|
+
`;
|
|
1178
|
+
}
|
|
1179
|
+
function appendUniqueEntry(filePath, entry) {
|
|
1180
|
+
const marker = `<!-- fifony-memory:${entry.id} -->`;
|
|
1181
|
+
const current = readText(filePath);
|
|
1182
|
+
if (current.includes(marker)) return false;
|
|
1183
|
+
const prefix = current && !current.endsWith("\n") ? "\n\n" : current ? "\n" : "";
|
|
1184
|
+
writeFileSync2(filePath, `${current}${prefix}${renderEntry(entry)}`, "utf8");
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1187
|
+
function listRecentDailyFiles(memoryDir) {
|
|
1188
|
+
if (!existsSync3(memoryDir)) return [];
|
|
1189
|
+
return readdirSync(memoryDir).filter((entry) => entry.endsWith(".md")).sort((left, right) => right.localeCompare(left)).slice(0, 3).map((entry) => join2(memoryDir, entry));
|
|
1190
|
+
}
|
|
1191
|
+
function ensureWorkspaceMemoryFiles(issue, workspacePath) {
|
|
1192
|
+
const paths = resolvePaths(workspacePath);
|
|
1193
|
+
mkdirSync(paths.root, { recursive: true });
|
|
1194
|
+
mkdirSync(paths.memoryDir, { recursive: true });
|
|
1195
|
+
ensureFile(paths.workflowFile, renderWorkflowDocument(issue));
|
|
1196
|
+
ensureFile(paths.memoryFile, renderMemoryHeader(issue));
|
|
1197
|
+
ensureFile(paths.heartbeatFile, renderHeartbeatDocument(issue));
|
|
1198
|
+
ensureFile(paths.dailyFile, `# Daily Memory - ${resolveTodayDate()}
|
|
1199
|
+
|
|
1200
|
+
`);
|
|
1201
|
+
return paths;
|
|
1202
|
+
}
|
|
1203
|
+
function flushWorkspaceMemory(issue, workspacePath, reason) {
|
|
1204
|
+
if (!workspacePath) return null;
|
|
1205
|
+
const paths = ensureWorkspaceMemoryFiles(issue, workspacePath);
|
|
1206
|
+
const changedFiles = [];
|
|
1207
|
+
let promotedEntries = 0;
|
|
1208
|
+
if (writeIfChanged(paths.workflowFile, renderWorkflowDocument(issue))) changedFiles.push(paths.workflowFile);
|
|
1209
|
+
if (writeIfChanged(paths.heartbeatFile, renderHeartbeatDocument(issue))) changedFiles.push(paths.heartbeatFile);
|
|
1210
|
+
if (!existsSync3(paths.memoryFile)) {
|
|
1211
|
+
writeFileSync2(paths.memoryFile, renderMemoryHeader(issue), "utf8");
|
|
1212
|
+
changedFiles.push(paths.memoryFile);
|
|
1213
|
+
}
|
|
1214
|
+
for (const entry of buildDurableEntries(issue)) {
|
|
1215
|
+
if (appendUniqueEntry(paths.memoryFile, entry)) promotedEntries += 1;
|
|
1216
|
+
}
|
|
1217
|
+
if (promotedEntries > 0 && !changedFiles.includes(paths.memoryFile)) changedFiles.push(paths.memoryFile);
|
|
1218
|
+
if (changedFiles.length === 0 && issue.memoryFlushAt) return null;
|
|
1219
|
+
issue.memoryFlushAt = now();
|
|
1220
|
+
issue.memoryFlushCount = (issue.memoryFlushCount ?? 0) + 1;
|
|
1221
|
+
markIssueDirty(issue.id);
|
|
1222
|
+
return {
|
|
1223
|
+
flushedAt: issue.memoryFlushAt,
|
|
1224
|
+
reason,
|
|
1225
|
+
changedFiles,
|
|
1226
|
+
entriesWritten: changedFiles.length,
|
|
1227
|
+
promotedEntries
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function recordWorkspaceMemoryEvent(issue, workspacePath, entry) {
|
|
1231
|
+
if (!workspacePath) return false;
|
|
1232
|
+
const paths = ensureWorkspaceMemoryFiles(issue, workspacePath);
|
|
1233
|
+
const wroteDaily = appendUniqueEntry(paths.dailyFile, entry);
|
|
1234
|
+
const wroteLongTerm = entry.persistLongTerm ? appendUniqueEntry(paths.memoryFile, entry) : false;
|
|
1235
|
+
if (!wroteDaily && !wroteLongTerm) return false;
|
|
1236
|
+
issue.memoryFlushAt = now();
|
|
1237
|
+
issue.memoryFlushCount = (issue.memoryFlushCount ?? 0) + 1;
|
|
1238
|
+
markIssueDirty(issue.id);
|
|
1239
|
+
return true;
|
|
1240
|
+
}
|
|
1241
|
+
function listWorkspaceMemoryContextDocuments(workspacePath) {
|
|
1242
|
+
const paths = resolvePaths(workspacePath);
|
|
1243
|
+
const docs = [];
|
|
1244
|
+
const bootstrapFiles = [
|
|
1245
|
+
{ path: paths.workflowFile, layer: "bootstrap", kind: "doc" },
|
|
1246
|
+
{ path: paths.heartbeatFile, layer: "bootstrap", kind: "doc" },
|
|
1247
|
+
{ path: paths.memoryFile, layer: "workspace-memory", kind: "issue-memory" }
|
|
1248
|
+
];
|
|
1249
|
+
for (const file of bootstrapFiles) {
|
|
1250
|
+
const text = readText(file.path);
|
|
1251
|
+
if (!text.trim()) continue;
|
|
1252
|
+
docs.push({
|
|
1253
|
+
layer: file.layer,
|
|
1254
|
+
kind: file.kind,
|
|
1255
|
+
path: file.path.slice(workspacePath.length + 1).replace(/\\/g, "/"),
|
|
1256
|
+
sourceId: `workspace:${file.path}`,
|
|
1257
|
+
text
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
for (const dailyFile of listRecentDailyFiles(paths.memoryDir)) {
|
|
1261
|
+
const text = readText(dailyFile);
|
|
1262
|
+
if (!text.trim()) continue;
|
|
1263
|
+
docs.push({
|
|
1264
|
+
layer: "workspace-memory",
|
|
1265
|
+
kind: "issue-memory",
|
|
1266
|
+
path: dailyFile.slice(workspacePath.length + 1).replace(/\\/g, "/"),
|
|
1267
|
+
sourceId: `workspace:${dailyFile}`,
|
|
1268
|
+
text
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
return docs;
|
|
1272
|
+
}
|
|
1273
|
+
var DEFAULT_MEMORY_ENGINE = {
|
|
1274
|
+
ensureWorkspaceArtifacts: ensureWorkspaceMemoryFiles,
|
|
1275
|
+
flushIssueMemory: flushWorkspaceMemory,
|
|
1276
|
+
recordIssueEvent: recordWorkspaceMemoryEvent,
|
|
1277
|
+
listContextDocuments: listWorkspaceMemoryContextDocuments
|
|
1278
|
+
};
|
|
1279
|
+
function getMemoryEngine() {
|
|
1280
|
+
return DEFAULT_MEMORY_ENGINE;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/domains/workspace.ts
|
|
1284
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
1285
|
+
".git",
|
|
1286
|
+
".fifony",
|
|
1287
|
+
"node_modules",
|
|
1288
|
+
".venv",
|
|
1289
|
+
"data",
|
|
1290
|
+
"dist",
|
|
1291
|
+
"build",
|
|
1292
|
+
".turbo",
|
|
1293
|
+
".next",
|
|
1294
|
+
".nuxt",
|
|
1295
|
+
".tanstack",
|
|
1296
|
+
"coverage",
|
|
1297
|
+
"artifacts",
|
|
1298
|
+
"captures",
|
|
1299
|
+
"tmp",
|
|
1300
|
+
"temp"
|
|
1301
|
+
]);
|
|
1302
|
+
function shouldSkipPath(relativePath) {
|
|
1303
|
+
const parts = relativePath.split("/");
|
|
1304
|
+
if (parts.some((segment) => SKIP_DIRS.has(segment))) return true;
|
|
1305
|
+
const base = parts.at(-1) ?? "";
|
|
1306
|
+
if (base.startsWith("map_scan_") && extname(base) === ".json") return true;
|
|
1307
|
+
if (extname(base) === ".xlsx") return true;
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
function bootstrapSource() {
|
|
1311
|
+
if (existsSync4(SOURCE_MARKER)) return;
|
|
1312
|
+
logger.info("Creating local source snapshot for Fifony (local-only runtime)...");
|
|
1313
|
+
const copyRecursive = (source, target, rel = "") => {
|
|
1314
|
+
mkdirSync2(target, { recursive: true });
|
|
1315
|
+
const items = readdirSync2(source, { withFileTypes: true });
|
|
1316
|
+
for (const item of items) {
|
|
1317
|
+
const nextRel = rel ? `${rel}/${item.name}` : item.name;
|
|
1318
|
+
if (shouldSkipPath(nextRel)) continue;
|
|
1319
|
+
const sourcePath = `${source}/${item.name}`;
|
|
1320
|
+
const targetPath = `${target}/${item.name}`;
|
|
1321
|
+
const itemStat = statSync(sourcePath);
|
|
1322
|
+
if (item.isDirectory()) {
|
|
1323
|
+
copyRecursive(sourcePath, targetPath, nextRel);
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
if (item.isSymbolicLink() || itemStat.isSymbolicLink()) continue;
|
|
1327
|
+
if (itemStat.isFile() || itemStat.isFIFO()) {
|
|
1328
|
+
try {
|
|
1329
|
+
const file = readFileSync3(sourcePath);
|
|
1330
|
+
writeFileSync3(targetPath, file);
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
if (error.code === "ENOENT") {
|
|
1333
|
+
logger.debug(`Skipped missing source file: ${sourcePath}`);
|
|
1334
|
+
} else {
|
|
1335
|
+
throw error;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1341
|
+
mkdirSync2(SOURCE_ROOT, { recursive: true });
|
|
1342
|
+
copyRecursive(TARGET_ROOT, SOURCE_ROOT);
|
|
1343
|
+
writeFileSync3(SOURCE_MARKER, `${now()}
|
|
1344
|
+
`, "utf8");
|
|
1345
|
+
}
|
|
1346
|
+
var sourceReadyPromise = null;
|
|
1347
|
+
var skipSourceFlag = false;
|
|
1348
|
+
function setSkipSource(skip) {
|
|
1349
|
+
skipSourceFlag = skip;
|
|
1350
|
+
}
|
|
1351
|
+
async function ensureSourceReady(onProgress) {
|
|
1352
|
+
if (skipSourceFlag) {
|
|
1353
|
+
onProgress?.("ready");
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (existsSync4(SOURCE_MARKER)) {
|
|
1357
|
+
onProgress?.("ready");
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (sourceReadyPromise) return sourceReadyPromise;
|
|
1361
|
+
sourceReadyPromise = (async () => {
|
|
1362
|
+
onProgress?.("copying");
|
|
1363
|
+
logger.info("Creating local source snapshot (async) for Fifony...");
|
|
1364
|
+
const copyRecursiveAsync = async (source, target, rel = "") => {
|
|
1365
|
+
await mkdir(target, { recursive: true });
|
|
1366
|
+
const items = await readdir(source, { withFileTypes: true });
|
|
1367
|
+
for (const item of items) {
|
|
1368
|
+
const nextRel = rel ? `${rel}/${item.name}` : item.name;
|
|
1369
|
+
if (shouldSkipPath(nextRel)) continue;
|
|
1370
|
+
const sourcePath = `${source}/${item.name}`;
|
|
1371
|
+
const targetPath = `${target}/${item.name}`;
|
|
1372
|
+
const itemStat = await stat(sourcePath);
|
|
1373
|
+
if (item.isDirectory()) {
|
|
1374
|
+
await copyRecursiveAsync(sourcePath, targetPath, nextRel);
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
if (item.isSymbolicLink() || itemStat.isSymbolicLink()) continue;
|
|
1378
|
+
if (itemStat.isFile() || itemStat.isFIFO()) {
|
|
1379
|
+
try {
|
|
1380
|
+
await copyFile(sourcePath, targetPath);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
if (error.code === "ENOENT") {
|
|
1383
|
+
logger.debug(`Skipped missing source file: ${sourcePath}`);
|
|
1384
|
+
} else {
|
|
1385
|
+
throw error;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
await mkdir(SOURCE_ROOT, { recursive: true });
|
|
1392
|
+
await copyRecursiveAsync(TARGET_ROOT, SOURCE_ROOT);
|
|
1393
|
+
await writeFile(SOURCE_MARKER, `${now()}
|
|
1394
|
+
`, "utf8");
|
|
1395
|
+
onProgress?.("ready");
|
|
1396
|
+
logger.info("Source snapshot ready (async).");
|
|
1397
|
+
})();
|
|
1398
|
+
return sourceReadyPromise;
|
|
1399
|
+
}
|
|
1400
|
+
function getGitRepoStatus(dir) {
|
|
1401
|
+
const isGit = (() => {
|
|
1402
|
+
try {
|
|
1403
|
+
execSync("git rev-parse --git-dir", { cwd: dir, stdio: "pipe" });
|
|
1404
|
+
return true;
|
|
1405
|
+
} catch {
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
})();
|
|
1409
|
+
if (!isGit) {
|
|
1410
|
+
return { isGit: false, hasCommits: false, branch: null };
|
|
1411
|
+
}
|
|
1412
|
+
const branch = (() => {
|
|
1413
|
+
try {
|
|
1414
|
+
return execSync("git symbolic-ref --short HEAD", { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim() || null;
|
|
1415
|
+
} catch {
|
|
1416
|
+
try {
|
|
1417
|
+
return execSync("git rev-parse --abbrev-ref HEAD", { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim() || null;
|
|
1418
|
+
} catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
})();
|
|
1423
|
+
const hasCommits = (() => {
|
|
1424
|
+
try {
|
|
1425
|
+
execSync("git rev-parse --verify HEAD", { cwd: dir, stdio: "pipe" });
|
|
1426
|
+
return true;
|
|
1427
|
+
} catch {
|
|
1428
|
+
return false;
|
|
1429
|
+
}
|
|
1430
|
+
})();
|
|
1431
|
+
let isClean = true;
|
|
1432
|
+
let untrackedCount = 0;
|
|
1433
|
+
if (hasCommits) {
|
|
1434
|
+
try {
|
|
1435
|
+
const porcelain = execSync("git status --porcelain", { cwd: dir, encoding: "utf8", timeout: 5e3 }).trim();
|
|
1436
|
+
isClean = porcelain.length === 0;
|
|
1437
|
+
untrackedCount = porcelain.split("\n").filter((l) => l.startsWith("??")).length;
|
|
1438
|
+
} catch {
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return { isGit: true, hasCommits, branch, isClean, untrackedCount };
|
|
1442
|
+
}
|
|
1443
|
+
function gitRequirementMessage(action) {
|
|
1444
|
+
return `fifony requires a git repository with at least one commit to ${action}. Initialize git in this project and create an initial commit, or use the onboarding Setup step.`;
|
|
1445
|
+
}
|
|
1446
|
+
function ensureGitRepoReadyForWorktrees(dir, action = "run issue worktrees") {
|
|
1447
|
+
const status = getGitRepoStatus(dir);
|
|
1448
|
+
if (!status.isGit) {
|
|
1449
|
+
throw new Error(gitRequirementMessage(action));
|
|
1450
|
+
}
|
|
1451
|
+
if (!status.hasCommits) {
|
|
1452
|
+
throw new Error(`fifony requires at least one commit to ${action} because git worktree needs a base commit. Create an initial commit, then retry.`);
|
|
1453
|
+
}
|
|
1454
|
+
return status;
|
|
1455
|
+
}
|
|
1456
|
+
function initializeGitRepoForWorktrees(dir) {
|
|
1457
|
+
let status = getGitRepoStatus(dir);
|
|
1458
|
+
if (!status.isGit) {
|
|
1459
|
+
try {
|
|
1460
|
+
execSync("git init -b main", { cwd: dir, stdio: "pipe" });
|
|
1461
|
+
} catch {
|
|
1462
|
+
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
1463
|
+
}
|
|
1464
|
+
status = getGitRepoStatus(dir);
|
|
1465
|
+
}
|
|
1466
|
+
if (!status.hasCommits) {
|
|
1467
|
+
execSync(
|
|
1468
|
+
'git -c user.name="fifony" -c user.email="fifony@local.invalid" commit --allow-empty -m "Initial commit"',
|
|
1469
|
+
{ cwd: dir, stdio: "pipe" }
|
|
1470
|
+
);
|
|
1471
|
+
status = getGitRepoStatus(dir);
|
|
1472
|
+
}
|
|
1473
|
+
return status;
|
|
1474
|
+
}
|
|
1475
|
+
function assertIssueHasGitWorktree(issue, action) {
|
|
1476
|
+
if (!issue.branchName || !issue.baseBranch || !issue.worktreePath) {
|
|
1477
|
+
throw new Error(
|
|
1478
|
+
`Issue ${issue.identifier} has no git worktree \u2014 cannot ${action}. This usually means the issue was executed before git was initialized for the project. Initialize git, then re-run the issue.`
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
function detectDefaultBranch(dir) {
|
|
1483
|
+
try {
|
|
1484
|
+
const current = execSync("git rev-parse --abbrev-ref HEAD", { cwd: dir, encoding: "utf8" }).trim();
|
|
1485
|
+
if (current && current !== "HEAD") return current;
|
|
1486
|
+
const remote = execSync("git symbolic-ref refs/remotes/origin/HEAD", { cwd: dir, encoding: "utf8" }).trim();
|
|
1487
|
+
return remote.replace("refs/remotes/origin/", "");
|
|
1488
|
+
} catch {
|
|
1489
|
+
return "main";
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
var CLI_CONFIG_DIRS = [".claude", ".codex", ".gemini"];
|
|
1493
|
+
var CLI_CONFIG_FILES = ["CLAUDE.md"];
|
|
1494
|
+
function copyCliConfigDirs(sourceRoot, worktreePath) {
|
|
1495
|
+
for (const dir of CLI_CONFIG_DIRS) {
|
|
1496
|
+
const src = join3(sourceRoot, dir);
|
|
1497
|
+
const dst = join3(worktreePath, dir);
|
|
1498
|
+
if (existsSync4(src) && statSync(src).isDirectory() && !existsSync4(dst)) {
|
|
1499
|
+
try {
|
|
1500
|
+
execSync(`cp -R "${src}" "${dst}"`, { stdio: "pipe", timeout: 1e4 });
|
|
1501
|
+
logger.debug({ dir, worktreePath }, "[Workspace] Copied CLI config dir to worktree");
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
logger.warn({ err: String(err), dir }, "[Workspace] Failed to copy CLI config dir");
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
for (const file of CLI_CONFIG_FILES) {
|
|
1508
|
+
const src = join3(sourceRoot, file);
|
|
1509
|
+
const dst = join3(worktreePath, file);
|
|
1510
|
+
if (existsSync4(src) && !existsSync4(dst)) {
|
|
1511
|
+
try {
|
|
1512
|
+
execSync(`cp "${src}" "${dst}"`, { stdio: "pipe", timeout: 5e3 });
|
|
1513
|
+
logger.debug({ file, worktreePath }, "[Workspace] Copied CLI config file to worktree");
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
logger.warn({ err: String(err), file }, "[Workspace] Failed to copy CLI config file");
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
function isGitWorkingTree(dir) {
|
|
1521
|
+
try {
|
|
1522
|
+
execSync("git rev-parse --git-dir", { cwd: dir, stdio: "pipe", timeout: 5e3 });
|
|
1523
|
+
return true;
|
|
1524
|
+
} catch {
|
|
1525
|
+
return false;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
function resolveTestWorkspacePath(issue) {
|
|
1529
|
+
const workspaceRoot = issue.workspacePath ?? join3(WORKSPACE_ROOT, idToSafePath(issue.id));
|
|
1530
|
+
return join3(workspaceRoot, "test-worktree");
|
|
1531
|
+
}
|
|
1532
|
+
function createTestWorkspace(issue) {
|
|
1533
|
+
ensureGitRepoReadyForWorktrees(TARGET_ROOT, "create isolated test workspaces");
|
|
1534
|
+
assertIssueHasGitWorktree(issue, "create a test workspace");
|
|
1535
|
+
const workspaceRoot = issue.workspacePath ?? join3(WORKSPACE_ROOT, idToSafePath(issue.id));
|
|
1536
|
+
const testWorkspacePath = issue.testWorkspacePath ?? resolveTestWorkspacePath(issue);
|
|
1537
|
+
mkdirSync2(workspaceRoot, { recursive: true });
|
|
1538
|
+
if (existsSync4(testWorkspacePath)) {
|
|
1539
|
+
if (isGitWorkingTree(testWorkspacePath)) {
|
|
1540
|
+
issue.testWorkspacePath = testWorkspacePath;
|
|
1541
|
+
issue.testApplied = true;
|
|
1542
|
+
return testWorkspacePath;
|
|
1543
|
+
}
|
|
1544
|
+
rmSync2(testWorkspacePath, { recursive: true, force: true });
|
|
1545
|
+
}
|
|
1546
|
+
try {
|
|
1547
|
+
execSync(`git worktree add --detach "${testWorkspacePath}" "${issue.branchName}"`, {
|
|
1548
|
+
cwd: TARGET_ROOT,
|
|
1549
|
+
stdio: "pipe",
|
|
1550
|
+
timeout: 3e4
|
|
1551
|
+
});
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
const msg = err.stderr || err.stdout || String(err);
|
|
1554
|
+
throw new Error(`Failed to create isolated test workspace: ${msg}`);
|
|
1555
|
+
}
|
|
1556
|
+
copyCliConfigDirs(TARGET_ROOT, testWorkspacePath);
|
|
1557
|
+
issue.testWorkspacePath = testWorkspacePath;
|
|
1558
|
+
issue.testApplied = true;
|
|
1559
|
+
return testWorkspacePath;
|
|
1560
|
+
}
|
|
1561
|
+
function removeTestWorkspace(issue) {
|
|
1562
|
+
const testWorkspacePath = issue.testWorkspacePath;
|
|
1563
|
+
issue.testApplied = false;
|
|
1564
|
+
issue.testWorkspacePath = void 0;
|
|
1565
|
+
if (!testWorkspacePath) return;
|
|
1566
|
+
try {
|
|
1567
|
+
execSync(`git worktree remove --force "${testWorkspacePath}"`, {
|
|
1568
|
+
cwd: TARGET_ROOT,
|
|
1569
|
+
stdio: "pipe",
|
|
1570
|
+
timeout: 3e4
|
|
1571
|
+
});
|
|
1572
|
+
logger.info({ issueId: issue.id, testWorkspacePath }, "[Workspace] Removed isolated test workspace");
|
|
1573
|
+
return;
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
logger.warn({ issueId: issue.id, testWorkspacePath, err: String(error) }, "[Workspace] Failed to remove isolated test workspace via git worktree");
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
rmSync2(testWorkspacePath, { recursive: true, force: true });
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
logger.warn({ issueId: issue.id, testWorkspacePath, err: String(error) }, "[Workspace] Failed to remove isolated test workspace directory");
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
async function createGitWorktree(issue, worktreePath, baseBranch) {
|
|
1584
|
+
let headCommitAtStart = "";
|
|
1585
|
+
const resolvedBaseBranch = baseBranch ?? detectDefaultBranch(TARGET_ROOT);
|
|
1586
|
+
try {
|
|
1587
|
+
headCommitAtStart = execSync("git rev-parse HEAD", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
|
|
1588
|
+
} catch {
|
|
1589
|
+
}
|
|
1590
|
+
const branchName = `fifony/${issue.id}`;
|
|
1591
|
+
execSync(`git worktree add "${worktreePath}" -B "${branchName}"`, {
|
|
1592
|
+
cwd: TARGET_ROOT,
|
|
1593
|
+
stdio: "pipe"
|
|
1594
|
+
});
|
|
1595
|
+
try {
|
|
1596
|
+
const gitFileContent = readFileSync3(join3(worktreePath, ".git"), "utf8").trim();
|
|
1597
|
+
const gitDirRel = gitFileContent.replace("gitdir: ", "").trim();
|
|
1598
|
+
const gitDirPath = resolve(worktreePath, gitDirRel);
|
|
1599
|
+
mkdirSync2(join3(gitDirPath, "info"), { recursive: true });
|
|
1600
|
+
writeFileSync3(join3(gitDirPath, "info", "exclude"), "fifony-*\n.fifony-*\nfifony_*\n", "utf8");
|
|
1601
|
+
} catch (err) {
|
|
1602
|
+
logger.warn({ err: String(err) }, "[Agent] Failed to write worktree excludes");
|
|
1603
|
+
}
|
|
1604
|
+
issue.branchName = branchName;
|
|
1605
|
+
issue.baseBranch = resolvedBaseBranch;
|
|
1606
|
+
issue.headCommitAtStart = headCommitAtStart;
|
|
1607
|
+
issue.worktreePath = worktreePath;
|
|
1608
|
+
copyCliConfigDirs(TARGET_ROOT, worktreePath);
|
|
1609
|
+
logger.debug({ issueId: issue.id, branchName, baseBranch: resolvedBaseBranch, worktreePath }, "[Agent] Git worktree created");
|
|
1610
|
+
}
|
|
1611
|
+
async function prepareWorkspace(issue, state, defaultBranch) {
|
|
1612
|
+
const safeId = idToSafePath(issue.id);
|
|
1613
|
+
const workspaceRoot = join3(WORKSPACE_ROOT, safeId);
|
|
1614
|
+
const worktreePath = join3(workspaceRoot, "worktree");
|
|
1615
|
+
const createdNow = !existsSync4(worktreePath);
|
|
1616
|
+
if (createdNow) {
|
|
1617
|
+
mkdirSync2(workspaceRoot, { recursive: true });
|
|
1618
|
+
logger.debug({ issueId: issue.id, identifier: issue.identifier, workspacePath: workspaceRoot }, "[Agent] Creating workspace");
|
|
1619
|
+
ensureGitRepoReadyForWorktrees(TARGET_ROOT, "execute issues");
|
|
1620
|
+
if (state.config.afterCreateHook) {
|
|
1621
|
+
mkdirSync2(worktreePath, { recursive: true });
|
|
1622
|
+
await runHook(state.config.afterCreateHook, worktreePath, issue, "after_create");
|
|
1623
|
+
} else {
|
|
1624
|
+
await createGitWorktree(issue, worktreePath, defaultBranch);
|
|
1625
|
+
}
|
|
1626
|
+
logger.debug({ issueId: issue.id, workspacePath: workspaceRoot, worktreePath }, "[Agent] Workspace created");
|
|
1627
|
+
} else {
|
|
1628
|
+
logger.debug({ issueId: issue.id, workspacePath: workspaceRoot }, "[Agent] Reusing existing workspace");
|
|
1629
|
+
}
|
|
1630
|
+
const metaPath = join3(workspaceRoot, "issue.json");
|
|
1631
|
+
const promptText = await buildPrompt(issue, null);
|
|
1632
|
+
const promptFile = join3(workspaceRoot, "prompt.md");
|
|
1633
|
+
ensureWorkspaceMemoryFiles(issue, workspaceRoot);
|
|
1634
|
+
if (createdNow) {
|
|
1635
|
+
recordWorkspaceMemoryEvent(issue, workspaceRoot, {
|
|
1636
|
+
id: `bootstrap-v${issue.planVersion ?? 0}`,
|
|
1637
|
+
kind: "bootstrap",
|
|
1638
|
+
issueId: issue.id,
|
|
1639
|
+
issueIdentifier: issue.identifier,
|
|
1640
|
+
title: "Workspace bootstrap",
|
|
1641
|
+
summary: "Issue workspace prepared with worktree, prompt scaffold, and durable memory files.",
|
|
1642
|
+
source: "runtime",
|
|
1643
|
+
createdAt: now(),
|
|
1644
|
+
planVersion: issue.planVersion,
|
|
1645
|
+
persistLongTerm: false,
|
|
1646
|
+
tags: ["workspace", "bootstrap"]
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
writeFileSync3(metaPath, JSON.stringify({ ...issue, runtimeSource: SOURCE_ROOT, bootstrapAt: now() }, null, 2), "utf8");
|
|
1650
|
+
writeFileSync3(promptFile, `${promptText}
|
|
1651
|
+
`, "utf8");
|
|
1652
|
+
issue.workspacePath = workspaceRoot;
|
|
1653
|
+
issue.worktreePath = worktreePath;
|
|
1654
|
+
issue.workspacePreparedAt = now();
|
|
1655
|
+
return { workspacePath: workspaceRoot, promptText, promptFile };
|
|
1656
|
+
}
|
|
1657
|
+
async function cleanWorkspace(issueId, issue, state) {
|
|
1658
|
+
const safeId = idToSafePath(issueId);
|
|
1659
|
+
const workspacePath = issue?.workspacePath ?? join3(WORKSPACE_ROOT, safeId);
|
|
1660
|
+
if (!existsSync4(workspacePath)) return;
|
|
1661
|
+
if (state.config.beforeRemoveHook) {
|
|
1662
|
+
try {
|
|
1663
|
+
const dummyIssue = issue ?? { id: issueId, identifier: issueId };
|
|
1664
|
+
await runHook(state.config.beforeRemoveHook, workspacePath, dummyIssue, "before_remove");
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
logger.warn(`before_remove hook failed for ${issueId}: ${String(error)}`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (issue?.testWorkspacePath) {
|
|
1670
|
+
removeTestWorkspace(issue);
|
|
1671
|
+
}
|
|
1672
|
+
if (issue?.branchName && issue.worktreePath) {
|
|
1673
|
+
try {
|
|
1674
|
+
execSync(`git worktree remove --force "${issue.worktreePath}"`, { cwd: TARGET_ROOT, stdio: "pipe" });
|
|
1675
|
+
logger.info(`Removed worktree for ${issueId}: ${issue.worktreePath}`);
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
logger.warn(`Failed to remove worktree for ${issueId}: ${String(error)}`);
|
|
1678
|
+
try {
|
|
1679
|
+
rmSync2(issue.worktreePath, { recursive: true, force: true });
|
|
1680
|
+
} catch {
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
execSync(`git branch -D "${issue.branchName}"`, { cwd: TARGET_ROOT, stdio: "pipe" });
|
|
1685
|
+
} catch {
|
|
1686
|
+
}
|
|
1687
|
+
try {
|
|
1688
|
+
rmSync2(workspacePath, { recursive: true, force: true });
|
|
1689
|
+
} catch {
|
|
1690
|
+
}
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
try {
|
|
1694
|
+
rmSync2(workspacePath, { recursive: true, force: true });
|
|
1695
|
+
logger.info(`Cleaned workspace for ${issueId}: ${workspacePath}`);
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
logger.warn(`Failed to clean workspace for ${issueId}: ${String(error)}`);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
function inferChangedWorkspacePaths(_workspacePath, limit = 32, issue) {
|
|
1701
|
+
if (!issue?.baseBranch || !issue.branchName) return [];
|
|
1702
|
+
try {
|
|
1703
|
+
const output = execSync(
|
|
1704
|
+
`git diff --name-only "${issue.baseBranch}"..."${issue.branchName}"`,
|
|
1705
|
+
{ cwd: TARGET_ROOT, encoding: "utf8", timeout: 1e4, stdio: "pipe" }
|
|
1706
|
+
);
|
|
1707
|
+
return output.trim().split("\n").filter(Boolean).slice(0, limit);
|
|
1708
|
+
} catch {
|
|
1709
|
+
return [];
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
function computeDiffStats(issue) {
|
|
1713
|
+
if (!issue.baseBranch || !issue.branchName) return;
|
|
1714
|
+
try {
|
|
1715
|
+
let raw = "";
|
|
1716
|
+
try {
|
|
1717
|
+
raw = execSync(
|
|
1718
|
+
`git diff --stat "${issue.baseBranch}"..."${issue.branchName}"`,
|
|
1719
|
+
{ cwd: TARGET_ROOT, encoding: "utf8", maxBuffer: 512e3, timeout: 1e4, stdio: "pipe" }
|
|
1720
|
+
);
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
raw = err.stdout || "";
|
|
1723
|
+
}
|
|
1724
|
+
if (raw) parseDiffStats(issue, raw);
|
|
1725
|
+
} catch {
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
function parseDiffStats(issue, raw) {
|
|
1729
|
+
const lines = raw.trim().split("\n");
|
|
1730
|
+
const summary = lines[lines.length - 1] || "";
|
|
1731
|
+
const filesMatch = summary.match(/(\d+)\s+files?\s+changed/);
|
|
1732
|
+
const addMatch = summary.match(/(\d+)\s+insertions?\(\+\)/);
|
|
1733
|
+
const delMatch = summary.match(/(\d+)\s+deletions?\(-\)/);
|
|
1734
|
+
const internalRe = /fifony[-_]|\.fifony-|WORKFLOW\.local/;
|
|
1735
|
+
const fileLines = lines.slice(0, -1).filter((l) => {
|
|
1736
|
+
const name = l.trim().split("|")[0]?.trim().split("/").pop() || "";
|
|
1737
|
+
return !internalRe.test(name);
|
|
1738
|
+
});
|
|
1739
|
+
const regexFiles = filesMatch ? parseInt(filesMatch[1], 10) : 0;
|
|
1740
|
+
issue.filesChanged = fileLines.length > 0 ? fileLines.length : regexFiles;
|
|
1741
|
+
issue.linesAdded = addMatch ? parseInt(addMatch[1], 10) : 0;
|
|
1742
|
+
issue.linesRemoved = delMatch ? parseInt(delMatch[1], 10) : 0;
|
|
1743
|
+
}
|
|
1744
|
+
async function syncIssueDiffStatsToStore(issue) {
|
|
1745
|
+
if (!issue?.id) return;
|
|
1746
|
+
const { getIssueStateResource } = await import("./store-M6NCKMZY.js");
|
|
1747
|
+
const issueResource = getIssueStateResource();
|
|
1748
|
+
if (!issueResource) return;
|
|
1749
|
+
const toNumber = (value) => {
|
|
1750
|
+
const parsed = typeof value === "number" ? value : Number(value ?? 0);
|
|
1751
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
1752
|
+
};
|
|
1753
|
+
const nextLinesAdded = toNumber(issue.linesAdded);
|
|
1754
|
+
const nextLinesRemoved = toNumber(issue.linesRemoved);
|
|
1755
|
+
const nextFilesChanged = toNumber(issue.filesChanged);
|
|
1756
|
+
if (nextLinesAdded === 0 && nextLinesRemoved === 0 && nextFilesChanged === 0 && !issue.branchName) {
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const current = await issueResource.get?.(issue.id).catch(() => null);
|
|
1760
|
+
const previousLinesAdded = toNumber(current?.linesAdded);
|
|
1761
|
+
const previousLinesRemoved = toNumber(current?.linesRemoved);
|
|
1762
|
+
const previousFilesChanged = toNumber(current?.filesChanged);
|
|
1763
|
+
await issueResource.patch(issue.id, {
|
|
1764
|
+
linesAdded: nextLinesAdded,
|
|
1765
|
+
linesRemoved: nextLinesRemoved,
|
|
1766
|
+
filesChanged: nextFilesChanged,
|
|
1767
|
+
branchName: issue.branchName
|
|
1768
|
+
});
|
|
1769
|
+
const add = issueResource.add;
|
|
1770
|
+
const sub = issueResource.sub;
|
|
1771
|
+
if (typeof add !== "function" || typeof sub !== "function") {
|
|
1772
|
+
logger.debug({ issueId: issue.id }, "[DiffStats] resource.add/sub not available \u2014 EC plugin may not be installed");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
const deltaAdded = nextLinesAdded - previousLinesAdded;
|
|
1776
|
+
const deltaRemoved = nextLinesRemoved - previousLinesRemoved;
|
|
1777
|
+
const deltaFiles = nextFilesChanged - previousFilesChanged;
|
|
1778
|
+
if (deltaAdded === 0 && deltaRemoved === 0 && deltaFiles === 0) {
|
|
1779
|
+
logger.debug({ issueId: issue.id, nextLinesAdded, previousLinesAdded }, "[DiffStats] No delta to send to EC (values already synced)");
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
logger.debug({ issueId: issue.id, deltaAdded, deltaRemoved, deltaFiles }, "[DiffStats] Sending deltas to EC");
|
|
1783
|
+
const applyDelta = async (field, delta) => {
|
|
1784
|
+
if (delta > 0) {
|
|
1785
|
+
await add.call(issueResource, issue.id, field, delta);
|
|
1786
|
+
} else if (delta < 0) {
|
|
1787
|
+
await sub.call(issueResource, issue.id, field, Math.abs(delta));
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
await Promise.all([
|
|
1791
|
+
applyDelta("linesAdded", deltaAdded),
|
|
1792
|
+
applyDelta("linesRemoved", deltaRemoved),
|
|
1793
|
+
applyDelta("filesChanged", deltaFiles)
|
|
1794
|
+
]);
|
|
1795
|
+
}
|
|
1796
|
+
function ensureWorktreeCommitted(issue) {
|
|
1797
|
+
const worktreePath = issue.worktreePath;
|
|
1798
|
+
if (!worktreePath || !issue.branchName) return;
|
|
1799
|
+
execSync("git add -A", { cwd: worktreePath, stdio: "pipe" });
|
|
1800
|
+
const statusBeforeCommit = execSync("git status --porcelain", { cwd: worktreePath, encoding: "utf8" }).trim();
|
|
1801
|
+
if (!statusBeforeCommit) return;
|
|
1802
|
+
try {
|
|
1803
|
+
execSync(`git commit -m "fifony: agent changes for ${issue.identifier}"`, { cwd: worktreePath, stdio: "pipe" });
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
const remaining = execSync("git status --porcelain", { cwd: worktreePath, encoding: "utf8" }).trim();
|
|
1806
|
+
if (remaining) {
|
|
1807
|
+
throw new Error(`Failed to commit agent changes for ${issue.identifier}: ${String(error)}`);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
const statusAfterCommit = execSync("git status --porcelain", { cwd: worktreePath, encoding: "utf8" }).trim();
|
|
1811
|
+
if (statusAfterCommit) {
|
|
1812
|
+
throw new Error(`Worktree for ${issue.identifier} still has uncommitted changes after commit.`);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
function mergeWorktree(issue, abortOnConflict = true) {
|
|
1816
|
+
const result = { copied: [], deleted: [], skipped: [], conflicts: [] };
|
|
1817
|
+
ensureWorktreeCommitted(issue);
|
|
1818
|
+
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
|
|
1819
|
+
if (currentBranch !== issue.baseBranch) {
|
|
1820
|
+
throw new Error(`Cannot merge ${issue.identifier}: current branch is ${currentBranch}, expected ${issue.baseBranch}.`);
|
|
1821
|
+
}
|
|
1822
|
+
const targetStatus = execSync("git status --porcelain", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
|
|
1823
|
+
if (targetStatus) {
|
|
1824
|
+
throw new Error(`Cannot merge ${issue.identifier}: target repository has uncommitted changes.`);
|
|
1825
|
+
}
|
|
1826
|
+
try {
|
|
1827
|
+
const diffOut = execSync(
|
|
1828
|
+
`git diff --name-status "${issue.baseBranch}"..."${issue.branchName}"`,
|
|
1829
|
+
{ cwd: TARGET_ROOT, encoding: "utf8" }
|
|
1830
|
+
);
|
|
1831
|
+
for (const line of diffOut.trim().split("\n").filter(Boolean)) {
|
|
1832
|
+
const [statusChar, ...parts] = line.split(" ");
|
|
1833
|
+
const filePath = parts.join(" ");
|
|
1834
|
+
if (statusChar === "D") result.deleted.push(filePath);
|
|
1835
|
+
else result.copied.push(filePath);
|
|
1836
|
+
}
|
|
1837
|
+
} catch {
|
|
1838
|
+
}
|
|
1839
|
+
try {
|
|
1840
|
+
execSync(
|
|
1841
|
+
`git merge --no-ff "${issue.branchName}" -m "fifony: merge ${issue.identifier}"`,
|
|
1842
|
+
{ cwd: TARGET_ROOT, stdio: "pipe" }
|
|
1843
|
+
);
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
try {
|
|
1846
|
+
const conflictOut = execSync(
|
|
1847
|
+
"git diff --name-only --diff-filter=U",
|
|
1848
|
+
{ cwd: TARGET_ROOT, encoding: "utf8" }
|
|
1849
|
+
);
|
|
1850
|
+
result.conflicts.push(...conflictOut.trim().split("\n").filter(Boolean));
|
|
1851
|
+
} catch {
|
|
1852
|
+
}
|
|
1853
|
+
if (abortOnConflict) {
|
|
1854
|
+
try {
|
|
1855
|
+
execSync("git merge --abort", { cwd: TARGET_ROOT, stdio: "pipe" });
|
|
1856
|
+
} catch {
|
|
1857
|
+
}
|
|
1858
|
+
logger.warn({ issueId: issue.id, err: String(err) }, "[Agent] Git merge failed, aborted");
|
|
1859
|
+
} else {
|
|
1860
|
+
logger.info({ issueId: issue.id, conflicts: result.conflicts }, "[Agent] Git merge has conflicts \u2014 leaving markers for agent resolution");
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return result;
|
|
1864
|
+
}
|
|
1865
|
+
function shouldSkipMergePath(relativePath) {
|
|
1866
|
+
const parts = relativePath.split("/");
|
|
1867
|
+
if (parts.some((s) => s === ".git" || s === "node_modules" || s === ".fifony" || s === "dist" || s === ".tanstack")) {
|
|
1868
|
+
return true;
|
|
1869
|
+
}
|
|
1870
|
+
const base = parts.at(-1) ?? "";
|
|
1871
|
+
return base === "WORKFLOW.local.md" || base === ".fifony-env.sh" || base === ".fifony-compiled-env.sh" || base === ".fifony-local-source-ready" || base.startsWith("fifony-") || base.startsWith("fifony_");
|
|
1872
|
+
}
|
|
1873
|
+
function mergeWorkspace(issue, abortOnConflict = true) {
|
|
1874
|
+
ensureGitRepoReadyForWorktrees(TARGET_ROOT, "merge issues");
|
|
1875
|
+
assertIssueHasGitWorktree(issue, "merge");
|
|
1876
|
+
return mergeWorktree(issue, abortOnConflict);
|
|
1877
|
+
}
|
|
1878
|
+
function dryMerge(issue) {
|
|
1879
|
+
ensureGitRepoReadyForWorktrees(TARGET_ROOT, "preview merges");
|
|
1880
|
+
assertIssueHasGitWorktree(issue, "preview merge");
|
|
1881
|
+
ensureWorktreeCommitted(issue);
|
|
1882
|
+
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
|
|
1883
|
+
if (currentBranch !== issue.baseBranch) {
|
|
1884
|
+
throw new Error(`Cannot preview merge: current branch is ${currentBranch}, expected ${issue.baseBranch}.`);
|
|
1885
|
+
}
|
|
1886
|
+
const targetStatus = execSync("git status --porcelain", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
|
|
1887
|
+
if (targetStatus) {
|
|
1888
|
+
throw new Error(`Cannot preview merge: target repository has uncommitted changes.`);
|
|
1889
|
+
}
|
|
1890
|
+
let conflictFiles = [];
|
|
1891
|
+
let willConflict = false;
|
|
1892
|
+
try {
|
|
1893
|
+
execSync(
|
|
1894
|
+
`git merge --no-commit --no-ff "${issue.branchName}"`,
|
|
1895
|
+
{ cwd: TARGET_ROOT, stdio: "pipe" }
|
|
1896
|
+
);
|
|
1897
|
+
} catch {
|
|
1898
|
+
willConflict = true;
|
|
1899
|
+
try {
|
|
1900
|
+
const conflictOut = execSync(
|
|
1901
|
+
"git diff --name-only --diff-filter=U",
|
|
1902
|
+
{ cwd: TARGET_ROOT, encoding: "utf8" }
|
|
1903
|
+
);
|
|
1904
|
+
conflictFiles = conflictOut.trim().split("\n").filter(Boolean);
|
|
1905
|
+
} catch {
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
try {
|
|
1909
|
+
execSync("git merge --abort", { cwd: TARGET_ROOT, stdio: "pipe" });
|
|
1910
|
+
} catch {
|
|
1911
|
+
try {
|
|
1912
|
+
execSync("git reset --merge ORIG_HEAD", { cwd: TARGET_ROOT, stdio: "pipe" });
|
|
1913
|
+
} catch {
|
|
1914
|
+
try {
|
|
1915
|
+
execSync("git reset --merge", { cwd: TARGET_ROOT, stdio: "pipe" });
|
|
1916
|
+
} catch (error) {
|
|
1917
|
+
logger.warn({ issueId: issue.id, err: String(error) }, "[Workspace] Failed to safely clean dry-merge state");
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
let changedFiles = 0;
|
|
1922
|
+
try {
|
|
1923
|
+
const diffOut = execSync(
|
|
1924
|
+
`git diff --name-only "${issue.baseBranch}"..."${issue.branchName}"`,
|
|
1925
|
+
{ cwd: TARGET_ROOT, encoding: "utf8" }
|
|
1926
|
+
);
|
|
1927
|
+
changedFiles = diffOut.trim().split("\n").filter(Boolean).length;
|
|
1928
|
+
} catch {
|
|
1929
|
+
}
|
|
1930
|
+
return { willConflict, conflictFiles, canMerge: !willConflict, changedFiles };
|
|
1931
|
+
}
|
|
1932
|
+
function rebaseWorktree(issue) {
|
|
1933
|
+
ensureGitRepoReadyForWorktrees(TARGET_ROOT, "rebase worktrees");
|
|
1934
|
+
assertIssueHasGitWorktree(issue, "rebase");
|
|
1935
|
+
ensureWorktreeCommitted(issue);
|
|
1936
|
+
try {
|
|
1937
|
+
execSync(
|
|
1938
|
+
`git rebase "${issue.baseBranch}"`,
|
|
1939
|
+
{ cwd: issue.worktreePath, stdio: "pipe" }
|
|
1940
|
+
);
|
|
1941
|
+
return { success: true, conflictFiles: [] };
|
|
1942
|
+
} catch {
|
|
1943
|
+
let conflictFiles = [];
|
|
1944
|
+
try {
|
|
1945
|
+
const conflictOut = execSync(
|
|
1946
|
+
"git diff --name-only --diff-filter=U",
|
|
1947
|
+
{ cwd: issue.worktreePath, encoding: "utf8" }
|
|
1948
|
+
);
|
|
1949
|
+
conflictFiles = conflictOut.trim().split("\n").filter(Boolean);
|
|
1950
|
+
} catch {
|
|
1951
|
+
}
|
|
1952
|
+
try {
|
|
1953
|
+
execSync("git rebase --abort", { cwd: issue.worktreePath, stdio: "pipe" });
|
|
1954
|
+
} catch {
|
|
1955
|
+
}
|
|
1956
|
+
return { success: false, conflictFiles };
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
function hydrateIssuePathsFromWorkspace(issue) {
|
|
1960
|
+
const inferredPaths = inferChangedWorkspacePaths(issue.workspacePath ?? "", 32, issue);
|
|
1961
|
+
if (inferredPaths.length === 0) return [];
|
|
1962
|
+
issue.paths = [.../* @__PURE__ */ new Set([...issue.paths ?? [], ...inferredPaths])];
|
|
1963
|
+
return inferredPaths;
|
|
1964
|
+
}
|
|
1965
|
+
function writeVersionedArtifacts(workspacePath, prefix, planVersion, attempt, sources) {
|
|
1966
|
+
const { writeFileSync: _wfs, readFileSync: _rfs, existsSync: _es } = { writeFileSync: writeFileSync3, readFileSync: readFileSync3, existsSync: existsSync4 };
|
|
1967
|
+
for (const { srcFile, destSuffix } of sources) {
|
|
1968
|
+
const src = join3(workspacePath, srcFile);
|
|
1969
|
+
if (_es(src)) {
|
|
1970
|
+
_wfs(join3(workspacePath, `${prefix}.v${planVersion}a${attempt}.${destSuffix}`), _rfs(src, "utf8"), "utf8");
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
export {
|
|
1976
|
+
CONTAINER_PLANNING,
|
|
1977
|
+
buildDockerPlanCommand,
|
|
1978
|
+
runCommandWithTimeout,
|
|
1979
|
+
attachToDaemon,
|
|
1980
|
+
writeToDaemon,
|
|
1981
|
+
runHook,
|
|
1982
|
+
recordReviewFailures,
|
|
1983
|
+
findRecurringBlockingFailures,
|
|
1984
|
+
buildRetryContext,
|
|
1985
|
+
buildPrompt,
|
|
1986
|
+
resolveContextWindow,
|
|
1987
|
+
buildTurnPrompt,
|
|
1988
|
+
buildProviderBasePrompt,
|
|
1989
|
+
recordWorkspaceMemoryEvent,
|
|
1990
|
+
getMemoryEngine,
|
|
1991
|
+
bootstrapSource,
|
|
1992
|
+
setSkipSource,
|
|
1993
|
+
ensureSourceReady,
|
|
1994
|
+
getGitRepoStatus,
|
|
1995
|
+
ensureGitRepoReadyForWorktrees,
|
|
1996
|
+
initializeGitRepoForWorktrees,
|
|
1997
|
+
assertIssueHasGitWorktree,
|
|
1998
|
+
detectDefaultBranch,
|
|
1999
|
+
createTestWorkspace,
|
|
2000
|
+
removeTestWorkspace,
|
|
2001
|
+
createGitWorktree,
|
|
2002
|
+
prepareWorkspace,
|
|
2003
|
+
cleanWorkspace,
|
|
2004
|
+
inferChangedWorkspacePaths,
|
|
2005
|
+
computeDiffStats,
|
|
2006
|
+
parseDiffStats,
|
|
2007
|
+
syncIssueDiffStatsToStore,
|
|
2008
|
+
ensureWorktreeCommitted,
|
|
2009
|
+
shouldSkipMergePath,
|
|
2010
|
+
mergeWorkspace,
|
|
2011
|
+
dryMerge,
|
|
2012
|
+
rebaseWorktree,
|
|
2013
|
+
hydrateIssuePathsFromWorkspace,
|
|
2014
|
+
writeVersionedArtifacts
|
|
2015
|
+
};
|
|
2016
|
+
//# sourceMappingURL=chunk-SOBLO4YZ.js.map
|