automata-cli 0.5.0-develop.181 → 0.5.0-develop.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/dist/ConfigWizard-5B5AOGUU.js +544 -0
- package/dist/{chunk-OLM47VIK.js → chunk-LTCVLM4I.js} +18 -0
- package/dist/index.js +2037 -58
- package/package.json +1 -1
- package/dist/ConfigWizard-N473Z6LV.js +0 -432
package/dist/index.js
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_CHECK_ISSUE_PROMPT,
|
|
4
4
|
DEFAULT_CLAUDE_SYSTEM_PROMPT,
|
|
5
|
+
DEFAULT_DO_WORK,
|
|
6
|
+
DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT,
|
|
7
|
+
DEFAULT_DO_WORK_PR_WORK_PROMPT,
|
|
5
8
|
DEFAULT_FIX_COMMENTS_PROMPT,
|
|
6
9
|
DEFAULT_SONAR_PROMPT,
|
|
7
10
|
readConfig,
|
|
8
11
|
readRawConfig,
|
|
9
12
|
writeConfig
|
|
10
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-LTCVLM4I.js";
|
|
11
14
|
|
|
12
15
|
// src/index.ts
|
|
13
|
-
import { Command as
|
|
16
|
+
import { Command as Command7 } from "commander";
|
|
14
17
|
|
|
15
18
|
// src/version.ts
|
|
16
19
|
import { readFileSync } from "fs";
|
|
@@ -24,6 +27,22 @@ var version = packageJson.version;
|
|
|
24
27
|
import { Command } from "commander";
|
|
25
28
|
var VALID_TYPES = ["gh", "azdo"];
|
|
26
29
|
var VALID_TECHNIQUES = ["label", "assignee", "title-contains"];
|
|
30
|
+
var VALID_EXECUTORS = ["claude", "codex"];
|
|
31
|
+
var VALID_TURN_KINDS = ["issue-discuss", "pr-work"];
|
|
32
|
+
function writeDoWork(patch) {
|
|
33
|
+
const current = readRawConfig();
|
|
34
|
+
writeConfig({ ...current, doWork: { ...current.doWork, ...patch } });
|
|
35
|
+
}
|
|
36
|
+
function parseNonNegativeInt(value, label) {
|
|
37
|
+
const trimmed = value.trim();
|
|
38
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
39
|
+
if (Number.isNaN(parsed) || parsed < 0 || String(parsed) !== trimmed || !Number.isSafeInteger(parsed)) {
|
|
40
|
+
process.stderr.write(`Error: ${label} must be a non-negative integer (got "${value}").
|
|
41
|
+
`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
27
46
|
var configSetType = new Command("type").description("Set the remote environment type").argument("<value>", "Remote type: gh (GitHub) or azdo (Azure DevOps)").action((value) => {
|
|
28
47
|
if (!VALID_TYPES.includes(value)) {
|
|
29
48
|
process.stderr.write(`Error: invalid type "${value}". Must be one of: ${VALID_TYPES.join(", ")}
|
|
@@ -80,12 +99,102 @@ var configSetAgentUser = new Command("agent-user").description("Set the login th
|
|
|
80
99
|
process.stdout.write(`Agent user set to: ${user}
|
|
81
100
|
`);
|
|
82
101
|
});
|
|
83
|
-
var
|
|
102
|
+
var configSetDoWorkBaseBranch = new Command("do-work-base-branch").description("Set the branch `do-work` returns to for discussion turns").argument("<value>", `Branch name (default: ${DEFAULT_DO_WORK.baseBranch})`).action((value) => {
|
|
103
|
+
const branch = value.trim();
|
|
104
|
+
if (branch.length === 0) {
|
|
105
|
+
process.stderr.write("Error: do-work-base-branch requires a non-empty branch name.\n");
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
writeDoWork({ baseBranch: branch });
|
|
109
|
+
process.stdout.write(`do-work base branch set to: ${branch}
|
|
110
|
+
`);
|
|
111
|
+
});
|
|
112
|
+
var configSetDoWorkProtectedBranches = new Command("do-work-protected-branches").description("Set the branches a `do-work` build turn must never check out and push to").argument("<value>", `Comma-separated branch names (default: ${DEFAULT_DO_WORK.protectedBranches.join(",")})`).action((value) => {
|
|
113
|
+
const branches = value.split(",").map((branch) => branch.trim()).filter((branch) => branch.length > 0);
|
|
114
|
+
if (branches.length === 0) {
|
|
115
|
+
process.stderr.write("Error: do-work-protected-branches requires at least one branch name.\n");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
writeDoWork({ protectedBranches: branches });
|
|
119
|
+
process.stdout.write(`do-work protected branches set to: ${branches.join(", ")}
|
|
120
|
+
`);
|
|
121
|
+
});
|
|
122
|
+
var configSetDoWorkExecutor = new Command("do-work-executor").description("Set the default executor `do-work` invokes").argument("<value>", `Executor: ${VALID_EXECUTORS.join(", ")}`).action((value) => {
|
|
123
|
+
if (!VALID_EXECUTORS.includes(value)) {
|
|
124
|
+
process.stderr.write(`Error: invalid executor "${value}". Must be one of: ${VALID_EXECUTORS.join(", ")}
|
|
125
|
+
`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
writeDoWork({ executor: value });
|
|
129
|
+
process.stdout.write(`do-work executor set to: ${value}
|
|
130
|
+
`);
|
|
131
|
+
});
|
|
132
|
+
var configSetDoWorkModel = new Command("do-work-model").description("Set the default model `do-work` passes to one executor").argument("<executor>", `Executor: ${VALID_EXECUTORS.join(", ")}`).argument("<value>", "Model identifier").action((executor, value) => {
|
|
133
|
+
if (!VALID_EXECUTORS.includes(executor)) {
|
|
134
|
+
process.stderr.write(
|
|
135
|
+
`Error: invalid executor "${executor}". Must be one of: ${VALID_EXECUTORS.join(", ")}
|
|
136
|
+
`
|
|
137
|
+
);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const model = value.trim();
|
|
141
|
+
if (model.length === 0) {
|
|
142
|
+
process.stderr.write("Error: do-work-model requires a non-empty model identifier.\n");
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
const current = readRawConfig();
|
|
146
|
+
writeDoWork({ models: { ...current.doWork?.models, [executor]: model } });
|
|
147
|
+
process.stdout.write(`do-work ${executor} model set to: ${model}
|
|
148
|
+
`);
|
|
149
|
+
});
|
|
150
|
+
var configSetDoWorkMaxRuns = new Command("do-work-max-runs").description("Set the maximum number of model runs `do-work` performs per tick (0 = unlimited)").argument("<value>", "Non-negative integer").action((value) => {
|
|
151
|
+
const maxRunsPerTick = parseNonNegativeInt(value, "do-work-max-runs");
|
|
152
|
+
writeDoWork({ maxRunsPerTick });
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
`do-work max runs per tick set to: ${String(maxRunsPerTick)}${maxRunsPerTick === 0 ? " (unlimited)" : ""}
|
|
155
|
+
`
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
var configSetDoWorkLockStaleMinutes = new Command("do-work-lock-stale-minutes").description("Set how long a run lock may be held before it is treated as stale").argument("<value>", `Positive integer (default: ${String(DEFAULT_DO_WORK.lockStaleMinutes)})`).action((value) => {
|
|
159
|
+
const minutes = parseNonNegativeInt(value, "do-work-lock-stale-minutes");
|
|
160
|
+
if (minutes === 0) {
|
|
161
|
+
process.stderr.write("Error: do-work-lock-stale-minutes must be greater than zero.\n");
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
writeDoWork({ lockStaleMinutes: minutes });
|
|
165
|
+
process.stdout.write(`do-work lock staleness set to: ${String(minutes)} minutes
|
|
166
|
+
`);
|
|
167
|
+
});
|
|
168
|
+
var configSetDoWorkPrompt = new Command("do-work-prompt").description("Set the turn instructions for a `do-work` turn kind (prompt text or a .md filename)").argument("<turn-kind>", `Turn kind: ${VALID_TURN_KINDS.join(", ")}`).argument("<value>", "Prompt text, or a plain .md filename inside .automata/").action((turnKind, value) => {
|
|
169
|
+
if (!VALID_TURN_KINDS.includes(turnKind)) {
|
|
170
|
+
process.stderr.write(
|
|
171
|
+
`Error: invalid turn kind "${turnKind}". Must be one of: ${VALID_TURN_KINDS.join(", ")}
|
|
172
|
+
`
|
|
173
|
+
);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
const prompt = value.trim();
|
|
177
|
+
if (prompt.length === 0) {
|
|
178
|
+
process.stderr.write("Error: do-work-prompt requires a non-empty value.\n");
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
const current = readRawConfig();
|
|
182
|
+
const prompts = { ...current.doWork?.prompts };
|
|
183
|
+
if (turnKind === "issue-discuss") {
|
|
184
|
+
prompts.issueDiscuss = prompt;
|
|
185
|
+
} else {
|
|
186
|
+
prompts.prWork = prompt;
|
|
187
|
+
}
|
|
188
|
+
writeDoWork({ prompts });
|
|
189
|
+
process.stdout.write(`do-work ${turnKind} prompt set.
|
|
190
|
+
`);
|
|
191
|
+
});
|
|
192
|
+
var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt).addCommand(configSetAllowedUsers).addCommand(configSetAgentUser).addCommand(configSetDoWorkBaseBranch).addCommand(configSetDoWorkProtectedBranches).addCommand(configSetDoWorkExecutor).addCommand(configSetDoWorkModel).addCommand(configSetDoWorkMaxRuns).addCommand(configSetDoWorkLockStaleMinutes).addCommand(configSetDoWorkPrompt);
|
|
84
193
|
var configCommand = new Command("config").description("Configure automata settings").addCommand(configSet).action(async () => {
|
|
85
194
|
const [{ render }, React, { ConfigWizard }] = await Promise.all([
|
|
86
195
|
import("ink"),
|
|
87
196
|
import("react"),
|
|
88
|
-
import("./ConfigWizard-
|
|
197
|
+
import("./ConfigWizard-5B5AOGUU.js")
|
|
89
198
|
]);
|
|
90
199
|
const { waitUntilExit } = render(React.createElement(ConfigWizard));
|
|
91
200
|
await waitUntilExit();
|
|
@@ -218,8 +327,8 @@ function resolveSonarPullRequestUrl(url, prNumber) {
|
|
|
218
327
|
}
|
|
219
328
|
function isSonarUrl(url) {
|
|
220
329
|
try {
|
|
221
|
-
const
|
|
222
|
-
return
|
|
330
|
+
const hostname2 = new URL(url).hostname;
|
|
331
|
+
return hostname2 === "sonarcloud.io" || hostname2.endsWith(".sonarcloud.io");
|
|
223
332
|
} catch {
|
|
224
333
|
return false;
|
|
225
334
|
}
|
|
@@ -595,8 +704,12 @@ function isUpstreamGone(branch) {
|
|
|
595
704
|
const { status } = run2("git", ["ls-remote", "--exit-code", "--heads", "origin", branch]);
|
|
596
705
|
return status !== 0;
|
|
597
706
|
}
|
|
598
|
-
function hasUncommittedChanges() {
|
|
599
|
-
const
|
|
707
|
+
function hasUncommittedChanges(excludePaths = []) {
|
|
708
|
+
const args = ["status", "--porcelain"];
|
|
709
|
+
if (excludePaths.length > 0) {
|
|
710
|
+
args.push("--", ".", ...excludePaths.map((path) => `:(exclude)${path}`));
|
|
711
|
+
}
|
|
712
|
+
const { stdout } = run2("git", args);
|
|
600
713
|
return stdout.trim().length > 0;
|
|
601
714
|
}
|
|
602
715
|
function checkoutAndPull(targetBranch) {
|
|
@@ -609,6 +722,22 @@ function checkoutAndPull(targetBranch) {
|
|
|
609
722
|
throw new Error(`Failed to pull ${targetBranch}: ${pull.stderr.trim()}`);
|
|
610
723
|
}
|
|
611
724
|
}
|
|
725
|
+
function gitCommand(args) {
|
|
726
|
+
const { stderr, status } = run2("git", args);
|
|
727
|
+
return { ok: status === 0, stderr: stderr.trim() };
|
|
728
|
+
}
|
|
729
|
+
function checkoutBranch(branch) {
|
|
730
|
+
return gitCommand(["checkout", branch]);
|
|
731
|
+
}
|
|
732
|
+
function createTrackingBranch(branch) {
|
|
733
|
+
return gitCommand(["checkout", "-b", branch, `origin/${branch}`]);
|
|
734
|
+
}
|
|
735
|
+
function fetchBranch(branch) {
|
|
736
|
+
return gitCommand(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
|
|
737
|
+
}
|
|
738
|
+
function pullFastForwardOnly(branch) {
|
|
739
|
+
return gitCommand(branch === void 0 ? ["pull", "--ff-only"] : ["pull", "--ff-only", "origin", branch]);
|
|
740
|
+
}
|
|
612
741
|
function fetchPrune() {
|
|
613
742
|
const result = run2("git", ["fetch", "--prune"]);
|
|
614
743
|
if (result.status !== 0) {
|
|
@@ -795,8 +924,8 @@ function sleep(ms) {
|
|
|
795
924
|
}
|
|
796
925
|
function isSonarCheck(check) {
|
|
797
926
|
try {
|
|
798
|
-
const
|
|
799
|
-
return
|
|
927
|
+
const hostname2 = new URL(check.detailsUrl).hostname;
|
|
928
|
+
return hostname2 === "sonarcloud.io" || hostname2.endsWith(".sonarcloud.io");
|
|
800
929
|
} catch {
|
|
801
930
|
return false;
|
|
802
931
|
}
|
|
@@ -1207,7 +1336,7 @@ minor segment is incremented (e.g. 1.2.0 \u2192 1.3.0).`
|
|
|
1207
1336
|
`);
|
|
1208
1337
|
}
|
|
1209
1338
|
});
|
|
1210
|
-
var
|
|
1339
|
+
var gitCommand2 = new Command2("git").description("Git workflow commands (some require gh CLI)").addCommand(getPrInfoCmd).addCommand(getPrCommentsCmd).addCommand(finishFeatureCmd).addCommand(publishReleaseCmd);
|
|
1211
1340
|
|
|
1212
1341
|
// src/commands/getReady.ts
|
|
1213
1342
|
import { createInterface as createInterface2 } from "readline";
|
|
@@ -1369,6 +1498,12 @@ import { existsSync } from "fs";
|
|
|
1369
1498
|
import { delimiter, join } from "path";
|
|
1370
1499
|
|
|
1371
1500
|
// src/cli/spawnUtils.ts
|
|
1501
|
+
var SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
1502
|
+
var ESCAPED_QUOTE = String.raw`'\''`;
|
|
1503
|
+
function shellQuote(arg) {
|
|
1504
|
+
if (SHELL_SAFE.test(arg)) return arg;
|
|
1505
|
+
return "'" + arg.replaceAll("'", ESCAPED_QUOTE) + "'";
|
|
1506
|
+
}
|
|
1372
1507
|
function truncate(str, max) {
|
|
1373
1508
|
return str.length > max ? str.slice(0, max) + "..." : str;
|
|
1374
1509
|
}
|
|
@@ -1397,6 +1532,48 @@ function handleExitCode(status, toolName) {
|
|
|
1397
1532
|
}
|
|
1398
1533
|
}
|
|
1399
1534
|
|
|
1535
|
+
// src/cli/childRegistry.ts
|
|
1536
|
+
var active = /* @__PURE__ */ new Set();
|
|
1537
|
+
function trackChild(child) {
|
|
1538
|
+
active.add(child);
|
|
1539
|
+
}
|
|
1540
|
+
function untrackChild(child) {
|
|
1541
|
+
active.delete(child);
|
|
1542
|
+
}
|
|
1543
|
+
function waitForExit(child) {
|
|
1544
|
+
return new Promise((resolve2) => {
|
|
1545
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
1546
|
+
resolve2();
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
child.once("exit", () => resolve2());
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
function afterDelay(ms) {
|
|
1553
|
+
return new Promise((resolve2) => {
|
|
1554
|
+
const timer = setTimeout(() => resolve2("timeout"), ms);
|
|
1555
|
+
timer.unref();
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
async function terminateTrackedChildren(timeoutMs = 1e4, killGraceMs = 5e3) {
|
|
1559
|
+
const children = [...active];
|
|
1560
|
+
if (children.length === 0) return true;
|
|
1561
|
+
const exits = children.map((child) => waitForExit(child));
|
|
1562
|
+
for (const child of children) {
|
|
1563
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
|
|
1564
|
+
}
|
|
1565
|
+
const settled = await Promise.race([Promise.all(exits).then(() => "exited"), afterDelay(timeoutMs)]);
|
|
1566
|
+
if (settled === "exited") return true;
|
|
1567
|
+
for (const child of children) {
|
|
1568
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
1569
|
+
}
|
|
1570
|
+
const escalated = await Promise.race([
|
|
1571
|
+
Promise.all(exits).then(() => "exited"),
|
|
1572
|
+
afterDelay(killGraceMs)
|
|
1573
|
+
]);
|
|
1574
|
+
return escalated === "exited";
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1400
1577
|
// src/claude/claudeService.ts
|
|
1401
1578
|
function resolveCommand(name) {
|
|
1402
1579
|
const pathDirs = (process.env["PATH"] ?? "").split(delimiter);
|
|
@@ -1406,6 +1583,52 @@ function resolveCommand(name) {
|
|
|
1406
1583
|
}
|
|
1407
1584
|
return name;
|
|
1408
1585
|
}
|
|
1586
|
+
function buildClaudeArgs(prompt, options = {}) {
|
|
1587
|
+
const args = [];
|
|
1588
|
+
if (options.yolo) args.push("--dangerously-skip-permissions");
|
|
1589
|
+
if (options.model) args.push("--model", options.model);
|
|
1590
|
+
if (options.verbose) args.push("--verbose", "--output-format", "stream-json");
|
|
1591
|
+
args.push("-p", prompt);
|
|
1592
|
+
return args;
|
|
1593
|
+
}
|
|
1594
|
+
function runClaude(prompt, options = {}) {
|
|
1595
|
+
return new Promise((resolve2, reject) => {
|
|
1596
|
+
const claudeBin = resolveCommand("claude");
|
|
1597
|
+
const args = buildClaudeArgs(prompt, { yolo: true, model: options.model, verbose: true });
|
|
1598
|
+
const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
|
|
1599
|
+
trackChild(child);
|
|
1600
|
+
const rl = createInterface({ input: child.stdout });
|
|
1601
|
+
let turnCount = 0;
|
|
1602
|
+
rl.on("line", (line) => {
|
|
1603
|
+
if (options.printSteps !== true) return;
|
|
1604
|
+
try {
|
|
1605
|
+
const event = JSON.parse(line);
|
|
1606
|
+
formatEvent(event, turnCount);
|
|
1607
|
+
if (event["type"] === "assistant") turnCount++;
|
|
1608
|
+
} catch {
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
child.on("error", (err) => {
|
|
1612
|
+
untrackChild(child);
|
|
1613
|
+
const nodeErr = err;
|
|
1614
|
+
reject(
|
|
1615
|
+
nodeErr.code === "ENOENT" ? new Error("`claude` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
|
|
1616
|
+
);
|
|
1617
|
+
});
|
|
1618
|
+
child.on("close", (code, signal) => {
|
|
1619
|
+
untrackChild(child);
|
|
1620
|
+
if (code === 0) {
|
|
1621
|
+
resolve2();
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
reject(
|
|
1625
|
+
new Error(
|
|
1626
|
+
signal === null ? `Claude Code exited with code ${String(code)}.` : `Claude Code terminated on ${signal}.`
|
|
1627
|
+
)
|
|
1628
|
+
);
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1409
1632
|
function invokeClaudeCode(prompt, options = {}) {
|
|
1410
1633
|
if (options.verbose) {
|
|
1411
1634
|
return invokeClaudeCodeVerbose(prompt, options.yolo ?? false, options.model);
|
|
@@ -1414,10 +1637,7 @@ function invokeClaudeCode(prompt, options = {}) {
|
|
|
1414
1637
|
}
|
|
1415
1638
|
function invokeClaudeCodeSync(prompt, yolo, model) {
|
|
1416
1639
|
const claudeBin = resolveCommand("claude");
|
|
1417
|
-
const args =
|
|
1418
|
-
if (yolo) args.push("--dangerously-skip-permissions");
|
|
1419
|
-
if (model) args.push("--model", model);
|
|
1420
|
-
args.push("-p", prompt);
|
|
1640
|
+
const args = buildClaudeArgs(prompt, { yolo, model, verbose: false });
|
|
1421
1641
|
const result = spawnSync4(claudeBin, args, { encoding: "utf8", stdio: "inherit" });
|
|
1422
1642
|
handleSpawnError(result.error, "claude");
|
|
1423
1643
|
handleExitCode(result.status, "Claude Code");
|
|
@@ -1425,10 +1645,7 @@ function invokeClaudeCodeSync(prompt, yolo, model) {
|
|
|
1425
1645
|
function invokeClaudeCodeVerbose(prompt, yolo, model) {
|
|
1426
1646
|
return new Promise((resolve2) => {
|
|
1427
1647
|
const claudeBin = resolveCommand("claude");
|
|
1428
|
-
const args =
|
|
1429
|
-
if (yolo) args.push("--dangerously-skip-permissions");
|
|
1430
|
-
if (model) args.push("--model", model);
|
|
1431
|
-
args.push("--verbose", "--output-format", "stream-json", "-p", prompt);
|
|
1648
|
+
const args = buildClaudeArgs(prompt, { yolo, model, verbose: true });
|
|
1432
1649
|
const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
|
|
1433
1650
|
const rl = createInterface({ input: child.stdout });
|
|
1434
1651
|
let turnCount = 0;
|
|
@@ -1514,7 +1731,14 @@ function summarizeTool(name, input) {
|
|
|
1514
1731
|
}
|
|
1515
1732
|
|
|
1516
1733
|
// src/codex/codexService.ts
|
|
1517
|
-
import { spawnSync as spawnSync5 } from "child_process";
|
|
1734
|
+
import { spawn as spawn2, spawnSync as spawnSync5 } from "child_process";
|
|
1735
|
+
function buildCodexArgs(prompt, options = {}) {
|
|
1736
|
+
const args = ["exec"];
|
|
1737
|
+
if (options.yolo) args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
1738
|
+
if (options.model) args.push("--model", options.model);
|
|
1739
|
+
args.push(prompt);
|
|
1740
|
+
return args;
|
|
1741
|
+
}
|
|
1518
1742
|
function invokeCodexCode(prompt, options = {}) {
|
|
1519
1743
|
if (options.verbose) {
|
|
1520
1744
|
process.stderr.write("Warning: --verbose is not supported for Codex and will be ignored.\n");
|
|
@@ -1523,14 +1747,38 @@ function invokeCodexCode(prompt, options = {}) {
|
|
|
1523
1747
|
}
|
|
1524
1748
|
function invokeCodexCodeSync(prompt, yolo, model) {
|
|
1525
1749
|
const codexBin = resolveCommand("codex");
|
|
1526
|
-
const args =
|
|
1527
|
-
if (yolo) args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
1528
|
-
if (model) args.push("--model", model);
|
|
1529
|
-
args.push(prompt);
|
|
1750
|
+
const args = buildCodexArgs(prompt, { yolo, model });
|
|
1530
1751
|
const result = spawnSync5(codexBin, args, { encoding: "utf8", stdio: "inherit" });
|
|
1531
1752
|
handleSpawnError(result.error, "codex");
|
|
1532
1753
|
handleExitCode(result.status, "Codex");
|
|
1533
1754
|
}
|
|
1755
|
+
function runCodex(prompt, options = {}) {
|
|
1756
|
+
return new Promise((resolve2, reject) => {
|
|
1757
|
+
const codexBin = resolveCommand("codex");
|
|
1758
|
+
const args = buildCodexArgs(prompt, { yolo: true, model: options.model });
|
|
1759
|
+
const child = spawn2(codexBin, args, { stdio: "inherit" });
|
|
1760
|
+
trackChild(child);
|
|
1761
|
+
child.on("error", (err) => {
|
|
1762
|
+
untrackChild(child);
|
|
1763
|
+
const nodeErr = err;
|
|
1764
|
+
reject(
|
|
1765
|
+
nodeErr.code === "ENOENT" ? new Error("`codex` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
|
|
1766
|
+
);
|
|
1767
|
+
});
|
|
1768
|
+
child.on("close", (code, signal) => {
|
|
1769
|
+
untrackChild(child);
|
|
1770
|
+
if (code === 0) {
|
|
1771
|
+
resolve2();
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
reject(
|
|
1775
|
+
new Error(
|
|
1776
|
+
signal === null ? `Codex exited with code ${String(code)}.` : `Codex terminated on ${signal}.`
|
|
1777
|
+
)
|
|
1778
|
+
);
|
|
1779
|
+
});
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1534
1782
|
|
|
1535
1783
|
// src/commands/getReady.ts
|
|
1536
1784
|
function writeOverflowHint(output, issues, limit) {
|
|
@@ -1767,49 +2015,105 @@ var executeCommand = new Command4("execute").description("Delegate work to an AI
|
|
|
1767
2015
|
// src/commands/executePrompt.ts
|
|
1768
2016
|
import { Command as Command5 } from "commander";
|
|
1769
2017
|
|
|
1770
|
-
// src/github/
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
2018
|
+
// src/github/conversation.ts
|
|
2019
|
+
var KIND_LABELS = {
|
|
2020
|
+
"issue-body": "issue description",
|
|
2021
|
+
"issue-comment": "comment",
|
|
2022
|
+
"pr-comment": "pull request comment",
|
|
2023
|
+
"pr-review": "pull request review",
|
|
2024
|
+
"thread-comment": "review thread comment"
|
|
2025
|
+
};
|
|
2026
|
+
function byCreatedAt(a, b) {
|
|
2027
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
2028
|
+
}
|
|
2029
|
+
function classify(author, p) {
|
|
2030
|
+
const login2 = author.toLowerCase();
|
|
2031
|
+
if (login2 === p.agentUser.toLowerCase()) return "agent";
|
|
2032
|
+
return p.allowedUsers.some((user) => user.toLowerCase() === login2) ? "authorized" : "other";
|
|
2033
|
+
}
|
|
2034
|
+
function analyzeSurface(messages, p) {
|
|
2035
|
+
const ordered = [...messages].sort(byCreatedAt);
|
|
1774
2036
|
let lastAgentAt = null;
|
|
1775
|
-
for (const
|
|
1776
|
-
if (
|
|
1777
|
-
if (
|
|
1778
|
-
|
|
2037
|
+
for (const message of ordered) {
|
|
2038
|
+
if (message.kind === "issue-body") continue;
|
|
2039
|
+
if (classify(message.author, p) !== "agent") continue;
|
|
2040
|
+
if (lastAgentAt === null || message.createdAt > lastAgentAt) {
|
|
2041
|
+
lastAgentAt = message.createdAt;
|
|
1779
2042
|
}
|
|
1780
2043
|
}
|
|
1781
|
-
const
|
|
2044
|
+
const kept = [];
|
|
2045
|
+
for (const message of ordered) {
|
|
2046
|
+
const authorClass = classify(message.author, p);
|
|
2047
|
+
if (authorClass === "other") continue;
|
|
2048
|
+
const isNew = authorClass === "authorized" && (lastAgentAt === null || message.createdAt > lastAgentAt);
|
|
2049
|
+
kept.push({ ...message, isNew });
|
|
2050
|
+
}
|
|
2051
|
+
const newMessages = kept.filter((message) => message.isNew);
|
|
2052
|
+
return {
|
|
2053
|
+
messages: kept,
|
|
2054
|
+
newMessages,
|
|
2055
|
+
newMessageCount: newMessages.length,
|
|
2056
|
+
hasNewMessage: newMessages.length > 0,
|
|
2057
|
+
lastAgentAt
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
function lastAuthorClass(messages, p) {
|
|
2061
|
+
if (messages.length === 0) return "none";
|
|
2062
|
+
const newest = [...messages].sort(byCreatedAt).at(-1);
|
|
2063
|
+
return newest === void 0 ? "none" : classify(newest.author, p);
|
|
2064
|
+
}
|
|
2065
|
+
function formatMessages(messages) {
|
|
2066
|
+
return messages.map((message) => {
|
|
2067
|
+
const marker = message.isNew ? " \xB7 NEW since last agent run" : "";
|
|
2068
|
+
return `[${message.author}] ${KIND_LABELS[message.kind]} \xB7 ${message.createdAt}${marker}
|
|
2069
|
+
${message.body}`;
|
|
2070
|
+
}).join("\n\n");
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// src/github/issueConversation.ts
|
|
2074
|
+
function analyzeConversation(conversation, allowedUsers, agentUser) {
|
|
2075
|
+
const participants = { allowedUsers, agentUser };
|
|
2076
|
+
const messages = [
|
|
1782
2077
|
{
|
|
1783
|
-
kind: "issue",
|
|
2078
|
+
kind: "issue-body",
|
|
1784
2079
|
author: conversation.author,
|
|
1785
2080
|
body: conversation.body,
|
|
1786
2081
|
createdAt: conversation.createdAt
|
|
1787
2082
|
},
|
|
1788
|
-
...conversation.comments.map((
|
|
1789
|
-
kind: "comment",
|
|
1790
|
-
author:
|
|
1791
|
-
body:
|
|
1792
|
-
createdAt:
|
|
2083
|
+
...conversation.comments.map((comment) => ({
|
|
2084
|
+
kind: "issue-comment",
|
|
2085
|
+
author: comment.author,
|
|
2086
|
+
body: comment.body,
|
|
2087
|
+
createdAt: comment.createdAt
|
|
1793
2088
|
}))
|
|
1794
|
-
]
|
|
1795
|
-
const
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
return {
|
|
2089
|
+
];
|
|
2090
|
+
const analysis = analyzeSurface(messages, participants);
|
|
2091
|
+
return {
|
|
2092
|
+
messages: analysis.messages.map(toConversationMessage),
|
|
2093
|
+
newMessageCount: analysis.newMessageCount,
|
|
2094
|
+
hasNewMessage: analysis.hasNewMessage,
|
|
2095
|
+
lastAgentAt: analysis.lastAgentAt
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
function toConversationMessage(message) {
|
|
2099
|
+
return {
|
|
2100
|
+
kind: message.kind === "issue-body" ? "issue" : "comment",
|
|
2101
|
+
author: message.author,
|
|
2102
|
+
body: message.body,
|
|
2103
|
+
createdAt: message.createdAt,
|
|
2104
|
+
isNew: message.isNew
|
|
2105
|
+
};
|
|
1805
2106
|
}
|
|
1806
2107
|
function formatConversation(messages) {
|
|
1807
|
-
return
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
2108
|
+
return formatMessages(
|
|
2109
|
+
messages.map((message) => ({
|
|
2110
|
+
kind: message.kind === "issue" ? "issue-body" : "issue-comment",
|
|
2111
|
+
author: message.author,
|
|
2112
|
+
body: message.body,
|
|
2113
|
+
createdAt: message.createdAt,
|
|
2114
|
+
isNew: message.isNew
|
|
2115
|
+
}))
|
|
2116
|
+
);
|
|
1813
2117
|
}
|
|
1814
2118
|
|
|
1815
2119
|
// src/commands/executePrompt.ts
|
|
@@ -2027,14 +2331,1689 @@ ${formatConversation(analysis.messages)}`,
|
|
|
2027
2331
|
});
|
|
2028
2332
|
var executePromptCommand = new Command5("execute-prompt").description("Execute a configured custom prompt using an AI assistant").addCommand(executeSonarCmd).addCommand(executeFixCommentsCmd).addCommand(executeCheckIssueCmd);
|
|
2029
2333
|
|
|
2334
|
+
// src/commands/doWork.ts
|
|
2335
|
+
import { Command as Command6 } from "commander";
|
|
2336
|
+
|
|
2337
|
+
// src/github/ghWorkService.ts
|
|
2338
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
2339
|
+
function run4(cmd, args) {
|
|
2340
|
+
const result = spawnSync6(cmd, args, { encoding: "utf8" });
|
|
2341
|
+
if (result.error) {
|
|
2342
|
+
const err = result.error;
|
|
2343
|
+
if (err.code === "ENOENT") {
|
|
2344
|
+
throw new Error(`\`${cmd}\` CLI is not installed or not on PATH.`);
|
|
2345
|
+
}
|
|
2346
|
+
throw new Error(err.message);
|
|
2347
|
+
}
|
|
2348
|
+
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status ?? 1 };
|
|
2349
|
+
}
|
|
2350
|
+
function ghJson(args, what) {
|
|
2351
|
+
const { stdout, stderr, status } = run4("gh", args);
|
|
2352
|
+
if (status !== 0) {
|
|
2353
|
+
throw new Error(stderr.trim() || `Failed to ${what}. Is \`gh\` installed and authenticated?`);
|
|
2354
|
+
}
|
|
2355
|
+
return JSON.parse(stdout);
|
|
2356
|
+
}
|
|
2357
|
+
function login(author) {
|
|
2358
|
+
return author?.login ?? "";
|
|
2359
|
+
}
|
|
2360
|
+
function byCreatedAt2(a, b) {
|
|
2361
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
2362
|
+
}
|
|
2363
|
+
function visibleAt(createdAt, submittedAt) {
|
|
2364
|
+
if (submittedAt === null || submittedAt === void 0) return createdAt;
|
|
2365
|
+
return submittedAt > createdAt ? submittedAt : createdAt;
|
|
2366
|
+
}
|
|
2367
|
+
function getRepoSlug() {
|
|
2368
|
+
const { stdout, status } = run4("git", ["remote", "get-url", "origin"]);
|
|
2369
|
+
if (status !== 0) {
|
|
2370
|
+
throw new Error("Could not read the `origin` remote. Is this a git repository with a remote?");
|
|
2371
|
+
}
|
|
2372
|
+
const url = stdout.trim();
|
|
2373
|
+
const match = /github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) ?? /github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
|
|
2374
|
+
if (!match) {
|
|
2375
|
+
throw new Error(`Could not determine the GitHub owner/repo from the origin remote: ${url}`);
|
|
2376
|
+
}
|
|
2377
|
+
return { owner: match[1], repo: match[2] };
|
|
2378
|
+
}
|
|
2379
|
+
function getAuthenticatedLogin() {
|
|
2380
|
+
const { stdout, status } = run4("gh", ["api", "user", "--jq", ".login"]);
|
|
2381
|
+
if (status !== 0) return null;
|
|
2382
|
+
const login2 = stdout.trim();
|
|
2383
|
+
return login2.length > 0 ? login2 : null;
|
|
2384
|
+
}
|
|
2385
|
+
function listCandidateIssues(technique, value, limit) {
|
|
2386
|
+
const args = [
|
|
2387
|
+
"issue",
|
|
2388
|
+
"list",
|
|
2389
|
+
"--state",
|
|
2390
|
+
"open",
|
|
2391
|
+
"--limit",
|
|
2392
|
+
String(limit),
|
|
2393
|
+
"--json",
|
|
2394
|
+
"number,title,body,url"
|
|
2395
|
+
];
|
|
2396
|
+
switch (technique) {
|
|
2397
|
+
case "label":
|
|
2398
|
+
args.push("--label", value);
|
|
2399
|
+
break;
|
|
2400
|
+
case "assignee":
|
|
2401
|
+
args.push("--assignee", value);
|
|
2402
|
+
break;
|
|
2403
|
+
case "title-contains":
|
|
2404
|
+
args.push("--search", `${value} in:title`);
|
|
2405
|
+
break;
|
|
2406
|
+
}
|
|
2407
|
+
return ghJson(args, "query GitHub issues");
|
|
2408
|
+
}
|
|
2409
|
+
function getIssueSurface(issueNumber) {
|
|
2410
|
+
const raw = ghJson(
|
|
2411
|
+
[
|
|
2412
|
+
"issue",
|
|
2413
|
+
"view",
|
|
2414
|
+
String(issueNumber),
|
|
2415
|
+
"--json",
|
|
2416
|
+
"number,title,body,url,state,author,createdAt,assignees,labels,comments"
|
|
2417
|
+
],
|
|
2418
|
+
`read issue #${String(issueNumber)}`
|
|
2419
|
+
);
|
|
2420
|
+
const messages = [
|
|
2421
|
+
{
|
|
2422
|
+
kind: "issue-body",
|
|
2423
|
+
author: login(raw.author),
|
|
2424
|
+
body: raw.body,
|
|
2425
|
+
createdAt: raw.createdAt
|
|
2426
|
+
},
|
|
2427
|
+
...(raw.comments ?? []).map((comment) => ({
|
|
2428
|
+
kind: "issue-comment",
|
|
2429
|
+
author: login(comment.author),
|
|
2430
|
+
body: comment.body,
|
|
2431
|
+
createdAt: comment.createdAt
|
|
2432
|
+
}))
|
|
2433
|
+
].sort(byCreatedAt2);
|
|
2434
|
+
return {
|
|
2435
|
+
issue: { number: raw.number, title: raw.title, body: raw.body, url: raw.url },
|
|
2436
|
+
state: raw.state === "CLOSED" ? "CLOSED" : "OPEN",
|
|
2437
|
+
assignees: (raw.assignees ?? []).map(login).filter((name) => name.length > 0),
|
|
2438
|
+
labels: (raw.labels ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
|
|
2439
|
+
messages
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
var LINK_MAP_QUERY = `
|
|
2443
|
+
query($owner:String!,$repo:String!,$cursor:String){
|
|
2444
|
+
repository(owner:$owner,name:$repo){
|
|
2445
|
+
defaultBranchRef{ name }
|
|
2446
|
+
pullRequests(states:OPEN, first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){
|
|
2447
|
+
pageInfo{ hasNextPage endCursor }
|
|
2448
|
+
nodes{
|
|
2449
|
+
number url title headRefName baseRefName isCrossRepository isDraft updatedAt
|
|
2450
|
+
closingIssuesReferences(first:50){
|
|
2451
|
+
pageInfo{ hasNextPage }
|
|
2452
|
+
nodes{ number repository{ nameWithOwner } }
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
}`.trim();
|
|
2458
|
+
var MAX_LINK_MAP_PAGES = 50;
|
|
2459
|
+
function indexPullRequest(map, node, nameWithOwner) {
|
|
2460
|
+
const ref = {
|
|
2461
|
+
number: node.number,
|
|
2462
|
+
url: node.url,
|
|
2463
|
+
title: node.title,
|
|
2464
|
+
headRefName: node.headRefName,
|
|
2465
|
+
baseRefName: node.baseRefName,
|
|
2466
|
+
isCrossRepository: node.isCrossRepository,
|
|
2467
|
+
state: "OPEN",
|
|
2468
|
+
isDraft: node.isDraft,
|
|
2469
|
+
updatedAt: node.updatedAt
|
|
2470
|
+
};
|
|
2471
|
+
if (node.closingIssuesReferences.pageInfo?.hasNextPage) {
|
|
2472
|
+
throw new Error(
|
|
2473
|
+
`Pull request #${String(node.number)} closes more than 50 issues, so the issue-to-pull-request map cannot be read completely. Refusing the tick rather than risk starting work on an issue that already has a pull request.`
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2476
|
+
for (const issue of node.closingIssuesReferences.nodes) {
|
|
2477
|
+
if (issue.repository.nameWithOwner !== nameWithOwner) continue;
|
|
2478
|
+
const existing = map.get(issue.number);
|
|
2479
|
+
if (existing) {
|
|
2480
|
+
existing.push(ref);
|
|
2481
|
+
} else {
|
|
2482
|
+
map.set(issue.number, [ref]);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
function getOpenPrLinkMap() {
|
|
2487
|
+
const { owner, repo } = getRepoSlug();
|
|
2488
|
+
const map = /* @__PURE__ */ new Map();
|
|
2489
|
+
let defaultBranch = null;
|
|
2490
|
+
let cursor = null;
|
|
2491
|
+
for (let page = 0; page < MAX_LINK_MAP_PAGES; page++) {
|
|
2492
|
+
const args = ["api", "graphql", "-f", `query=${LINK_MAP_QUERY}`, "-f", `owner=${owner}`, "-f", `repo=${repo}`];
|
|
2493
|
+
if (cursor !== null) args.push("-f", `cursor=${cursor}`);
|
|
2494
|
+
const response = ghJson(args, "query open pull requests");
|
|
2495
|
+
defaultBranch = response.data.repository.defaultBranchRef?.name ?? defaultBranch;
|
|
2496
|
+
const connection = response.data.repository.pullRequests;
|
|
2497
|
+
for (const node of connection.nodes) {
|
|
2498
|
+
indexPullRequest(map, node, `${owner}/${repo}`);
|
|
2499
|
+
}
|
|
2500
|
+
if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
|
|
2501
|
+
return { byIssue: map, defaultBranch };
|
|
2502
|
+
}
|
|
2503
|
+
cursor = connection.pageInfo.endCursor;
|
|
2504
|
+
}
|
|
2505
|
+
throw new Error(
|
|
2506
|
+
`Stopped paginating open pull requests after ${String(MAX_LINK_MAP_PAGES)} pages, so the issue-to-pull-request map cannot be trusted. Refusing the tick rather than risk starting work on an issue that already has a pull request.`
|
|
2507
|
+
);
|
|
2508
|
+
}
|
|
2509
|
+
var REVIEW_THREADS_QUERY2 = `
|
|
2510
|
+
query($owner:String!,$repo:String!,$prNumber:Int!,$cursor:String){
|
|
2511
|
+
repository(owner:$owner,name:$repo){
|
|
2512
|
+
pullRequest(number:$prNumber){
|
|
2513
|
+
reviewThreads(first:100, after:$cursor){
|
|
2514
|
+
pageInfo{ hasNextPage endCursor }
|
|
2515
|
+
nodes{
|
|
2516
|
+
isResolved isOutdated path line
|
|
2517
|
+
comments(last:100){
|
|
2518
|
+
pageInfo{ hasPreviousPage }
|
|
2519
|
+
nodes{ author{login} body createdAt url pullRequestReview{ submittedAt } }
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
}`.trim();
|
|
2526
|
+
var MAX_THREAD_PAGES = 50;
|
|
2527
|
+
function normalizePrState(state) {
|
|
2528
|
+
if (state === "MERGED") return "MERGED";
|
|
2529
|
+
if (state === "CLOSED") return "CLOSED";
|
|
2530
|
+
return "OPEN";
|
|
2531
|
+
}
|
|
2532
|
+
function getReviewThreads(prNumber) {
|
|
2533
|
+
const { owner, repo } = getRepoSlug();
|
|
2534
|
+
const threads = [];
|
|
2535
|
+
let cursor = null;
|
|
2536
|
+
for (let page = 0; page < MAX_THREAD_PAGES; page++) {
|
|
2537
|
+
const args = [
|
|
2538
|
+
"api",
|
|
2539
|
+
"graphql",
|
|
2540
|
+
"-f",
|
|
2541
|
+
`query=${REVIEW_THREADS_QUERY2}`,
|
|
2542
|
+
"-f",
|
|
2543
|
+
`owner=${owner}`,
|
|
2544
|
+
"-f",
|
|
2545
|
+
`repo=${repo}`,
|
|
2546
|
+
"-F",
|
|
2547
|
+
`prNumber=${String(prNumber)}`
|
|
2548
|
+
];
|
|
2549
|
+
if (cursor !== null) args.push("-f", `cursor=${cursor}`);
|
|
2550
|
+
const response = ghJson(
|
|
2551
|
+
args,
|
|
2552
|
+
`query review threads for pull request #${String(prNumber)}`
|
|
2553
|
+
);
|
|
2554
|
+
const connection = response.data.repository.pullRequest.reviewThreads;
|
|
2555
|
+
for (const node of connection.nodes) {
|
|
2556
|
+
if (node.comments.pageInfo?.hasPreviousPage) {
|
|
2557
|
+
throw new Error(
|
|
2558
|
+
`Review thread on ${node.path} in pull request #${String(prNumber)} has more than 100 comments, so the earliest ones were not read. Refusing rather than risk suppressing a maintainer's request.`
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
threads.push({
|
|
2562
|
+
path: node.path,
|
|
2563
|
+
line: node.line ?? null,
|
|
2564
|
+
isResolved: node.isResolved,
|
|
2565
|
+
url: node.comments.nodes.at(-1)?.url ?? null,
|
|
2566
|
+
comments: node.comments.nodes.map((comment) => ({
|
|
2567
|
+
kind: "thread-comment",
|
|
2568
|
+
author: login(comment.author),
|
|
2569
|
+
body: comment.body,
|
|
2570
|
+
// When the comment became *visible*, not when it was drafted.
|
|
2571
|
+
// GitHub stamps `createdAt` the moment a comment is added to a
|
|
2572
|
+
// pending review, and it only becomes visible when the review is
|
|
2573
|
+
// submitted — minutes later for a human working through a diff.
|
|
2574
|
+
// Using `createdAt` let an agent answer posted in between look newer
|
|
2575
|
+
// than the review, which marked every one of its threads answered
|
|
2576
|
+
// and discarded the whole review silently.
|
|
2577
|
+
createdAt: visibleAt(comment.createdAt, comment.pullRequestReview?.submittedAt)
|
|
2578
|
+
})).sort(byCreatedAt2)
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
|
|
2582
|
+
return threads;
|
|
2583
|
+
}
|
|
2584
|
+
cursor = connection.pageInfo.endCursor;
|
|
2585
|
+
}
|
|
2586
|
+
throw new Error(
|
|
2587
|
+
`Stopped paginating review threads for pull request #${String(prNumber)} after ${String(MAX_THREAD_PAGES)} pages; refusing rather than answering only part of the feedback.`
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
function getPrSurface(prNumber) {
|
|
2591
|
+
const raw = ghJson(
|
|
2592
|
+
[
|
|
2593
|
+
"pr",
|
|
2594
|
+
"view",
|
|
2595
|
+
String(prNumber),
|
|
2596
|
+
"--json",
|
|
2597
|
+
"number,title,url,headRefName,baseRefName,isCrossRepository,state,isDraft,body,author,createdAt,updatedAt,comments,reviews"
|
|
2598
|
+
],
|
|
2599
|
+
`read pull request #${String(prNumber)}`
|
|
2600
|
+
);
|
|
2601
|
+
const messages = [
|
|
2602
|
+
...(raw.comments ?? []).map((comment) => ({
|
|
2603
|
+
kind: "pr-comment",
|
|
2604
|
+
author: login(comment.author),
|
|
2605
|
+
body: comment.body,
|
|
2606
|
+
createdAt: comment.createdAt
|
|
2607
|
+
})),
|
|
2608
|
+
// A review with no body carries no message — only its inline comments do,
|
|
2609
|
+
// and those arrive through the review-thread query.
|
|
2610
|
+
...(raw.reviews ?? []).filter((review) => review.body.trim().length > 0).map((review) => ({
|
|
2611
|
+
kind: "pr-review",
|
|
2612
|
+
author: login(review.author),
|
|
2613
|
+
body: review.body,
|
|
2614
|
+
createdAt: review.submittedAt ?? review.createdAt ?? ""
|
|
2615
|
+
}))
|
|
2616
|
+
].sort(byCreatedAt2);
|
|
2617
|
+
const threads = getReviewThreads(prNumber);
|
|
2618
|
+
const state = normalizePrState(raw.state);
|
|
2619
|
+
return {
|
|
2620
|
+
pr: {
|
|
2621
|
+
number: raw.number,
|
|
2622
|
+
url: raw.url,
|
|
2623
|
+
title: raw.title,
|
|
2624
|
+
headRefName: raw.headRefName,
|
|
2625
|
+
baseRefName: raw.baseRefName ?? "",
|
|
2626
|
+
isCrossRepository: raw.isCrossRepository ?? false,
|
|
2627
|
+
state,
|
|
2628
|
+
isDraft: raw.isDraft ?? false,
|
|
2629
|
+
updatedAt: raw.updatedAt ?? raw.createdAt
|
|
2630
|
+
},
|
|
2631
|
+
messages,
|
|
2632
|
+
threads
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
function assignIssueToAgent(issueNumber, agentUser) {
|
|
2636
|
+
const { stderr, status } = run4("gh", [
|
|
2637
|
+
"issue",
|
|
2638
|
+
"edit",
|
|
2639
|
+
String(issueNumber),
|
|
2640
|
+
"--add-assignee",
|
|
2641
|
+
agentUser
|
|
2642
|
+
]);
|
|
2643
|
+
if (status !== 0) {
|
|
2644
|
+
throw new Error(stderr.trim() || `Failed to assign issue #${String(issueNumber)} to ${agentUser}.`);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
function postMarker(_surface, number, body) {
|
|
2648
|
+
const { owner, repo } = getRepoSlug();
|
|
2649
|
+
const raw = ghJson(
|
|
2650
|
+
[
|
|
2651
|
+
"api",
|
|
2652
|
+
"--method",
|
|
2653
|
+
"POST",
|
|
2654
|
+
`repos/${owner}/${repo}/issues/${String(number)}/comments`,
|
|
2655
|
+
"-f",
|
|
2656
|
+
`body=${body}`
|
|
2657
|
+
],
|
|
2658
|
+
`post a comment on #${String(number)}`
|
|
2659
|
+
);
|
|
2660
|
+
return { commentId: String(raw.id), createdAt: raw.created_at };
|
|
2661
|
+
}
|
|
2662
|
+
function updateMarker(marker, body) {
|
|
2663
|
+
const { owner, repo } = getRepoSlug();
|
|
2664
|
+
const { stderr, status } = run4("gh", [
|
|
2665
|
+
"api",
|
|
2666
|
+
"--method",
|
|
2667
|
+
"PATCH",
|
|
2668
|
+
`repos/${owner}/${repo}/issues/comments/${marker.commentId}`,
|
|
2669
|
+
"-f",
|
|
2670
|
+
`body=${body}`
|
|
2671
|
+
]);
|
|
2672
|
+
if (status !== 0) {
|
|
2673
|
+
throw new Error(stderr.trim() || `Failed to update comment ${marker.commentId}.`);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
function deleteMarker(marker) {
|
|
2677
|
+
const { owner, repo } = getRepoSlug();
|
|
2678
|
+
const { stderr, status } = run4("gh", [
|
|
2679
|
+
"api",
|
|
2680
|
+
"--method",
|
|
2681
|
+
"DELETE",
|
|
2682
|
+
`repos/${owner}/${repo}/issues/comments/${marker.commentId}`
|
|
2683
|
+
]);
|
|
2684
|
+
if (status !== 0) {
|
|
2685
|
+
if (/not found/i.test(stderr) || /HTTP 404/i.test(stderr)) return;
|
|
2686
|
+
throw new Error(stderr.trim() || `Failed to delete comment ${marker.commentId}.`);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
// src/github/workDetection.ts
|
|
2691
|
+
function isAssignedToAgent(assignees, agentUser) {
|
|
2692
|
+
const agent = agentUser.toLowerCase();
|
|
2693
|
+
return assignees.some((name) => name.toLowerCase() === agent);
|
|
2694
|
+
}
|
|
2695
|
+
function findActionableThreads(threads, p, prLastAgentAt) {
|
|
2696
|
+
const actionable = [];
|
|
2697
|
+
for (const thread of threads) {
|
|
2698
|
+
if (thread.isResolved) continue;
|
|
2699
|
+
const comments = thread.comments.filter((comment) => classifyForThread(comment.author, p) !== "other");
|
|
2700
|
+
if (lastAuthorClass(comments, p) !== "authorized") continue;
|
|
2701
|
+
const newestAuthorized = comments.at(-1)?.createdAt;
|
|
2702
|
+
if (newestAuthorized !== void 0 && prLastAgentAt !== null && prLastAgentAt > newestAuthorized) {
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
2705
|
+
actionable.push({ ...thread, comments });
|
|
2706
|
+
}
|
|
2707
|
+
return actionable;
|
|
2708
|
+
}
|
|
2709
|
+
function classifyForThread(author, p) {
|
|
2710
|
+
const login2 = author.toLowerCase();
|
|
2711
|
+
if (login2 === p.agentUser.toLowerCase()) return "agent";
|
|
2712
|
+
return p.allowedUsers.some((user) => user.toLowerCase() === login2) ? "authorized" : "other";
|
|
2713
|
+
}
|
|
2714
|
+
function newestPr(prs) {
|
|
2715
|
+
return [...prs].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
2716
|
+
}
|
|
2717
|
+
function plural(count, noun) {
|
|
2718
|
+
return `${String(count)} ${noun}${count === 1 ? "" : "s"}`;
|
|
2719
|
+
}
|
|
2720
|
+
function protectedHeads(policy) {
|
|
2721
|
+
return [policy.baseBranch, policy.defaultBranch ?? "", ...policy.protectedBranches ?? []].filter(
|
|
2722
|
+
(branch) => branch.length > 0
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2725
|
+
function unsafeBranchSkip(pr, issue, policy) {
|
|
2726
|
+
if (pr.isCrossRepository) {
|
|
2727
|
+
return {
|
|
2728
|
+
kind: "skip",
|
|
2729
|
+
issue,
|
|
2730
|
+
reason: "unsafe-pr-branch",
|
|
2731
|
+
detail: `pull request #${String(pr.number)} comes from a fork; its head branch is not in this repository`
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
if (protectedHeads(policy).includes(pr.headRefName)) {
|
|
2735
|
+
return {
|
|
2736
|
+
kind: "skip",
|
|
2737
|
+
issue,
|
|
2738
|
+
reason: "unsafe-pr-branch",
|
|
2739
|
+
detail: `pull request #${String(pr.number)} has a protected branch (${pr.headRefName}) as its head, so a turn would have to push to it`
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
return null;
|
|
2743
|
+
}
|
|
2744
|
+
function decideWork(state, p, policy) {
|
|
2745
|
+
const baseBranch = policy.baseBranch;
|
|
2746
|
+
const { issueSurface, prSurface } = state;
|
|
2747
|
+
const issue = issueSurface.issue;
|
|
2748
|
+
if (issueSurface.state === "CLOSED") {
|
|
2749
|
+
return { kind: "skip", issue, reason: "issue-closed", detail: "the issue is closed" };
|
|
2750
|
+
}
|
|
2751
|
+
const issueAnalysis = analyzeSurface(issueSurface.messages, p);
|
|
2752
|
+
const needsAssignment = !isAssignedToAgent(issueSurface.assignees, p.agentUser);
|
|
2753
|
+
const openPrs = state.linkedPrs.filter((pr) => pr.state === "OPEN");
|
|
2754
|
+
const hasOpenPr = openPrs.length > 0 && prSurface !== null && prSurface.pr.state === "OPEN";
|
|
2755
|
+
if (!hasOpenPr) {
|
|
2756
|
+
if (!issueAnalysis.hasNewMessage) {
|
|
2757
|
+
return {
|
|
2758
|
+
kind: "skip",
|
|
2759
|
+
issue,
|
|
2760
|
+
reason: "no-new-messages",
|
|
2761
|
+
detail: issueAnalysis.lastAgentAt === null ? "no messages from authorized accounts" : `nothing new since the agent's message at ${issueAnalysis.lastAgentAt}`
|
|
2762
|
+
};
|
|
2763
|
+
}
|
|
2764
|
+
return {
|
|
2765
|
+
kind: "work",
|
|
2766
|
+
item: {
|
|
2767
|
+
issue,
|
|
2768
|
+
turn: "issue-discuss",
|
|
2769
|
+
pr: null,
|
|
2770
|
+
branch: baseBranch,
|
|
2771
|
+
needsAssignment,
|
|
2772
|
+
issueAnalysis,
|
|
2773
|
+
prAnalysis: null,
|
|
2774
|
+
actionableThreads: [],
|
|
2775
|
+
reason: `${plural(issueAnalysis.newMessageCount, "new issue message")}, no open pull request`,
|
|
2776
|
+
ambiguousPrs: []
|
|
2777
|
+
}
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
const surface = prSurface;
|
|
2781
|
+
const unsafe = unsafeBranchSkip(surface.pr, issue, policy);
|
|
2782
|
+
if (unsafe !== null) return unsafe;
|
|
2783
|
+
const agentThreadMessages = surface.threads.flatMap((thread) => thread.comments).filter((comment) => comment.author.toLowerCase() === p.agentUser.toLowerCase());
|
|
2784
|
+
const prAnalysis = analyzeSurface([...surface.messages, ...agentThreadMessages], p);
|
|
2785
|
+
const actionableThreads = findActionableThreads(surface.threads, p, prAnalysis.lastAgentAt);
|
|
2786
|
+
const hasPrWork = prAnalysis.hasNewMessage || actionableThreads.length > 0;
|
|
2787
|
+
if (!hasPrWork && !issueAnalysis.hasNewMessage) {
|
|
2788
|
+
return {
|
|
2789
|
+
kind: "skip",
|
|
2790
|
+
issue,
|
|
2791
|
+
reason: "no-new-messages",
|
|
2792
|
+
detail: `nothing new on issue or pull request #${String(surface.pr.number)}`
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
const reasons = [];
|
|
2796
|
+
if (prAnalysis.hasNewMessage) {
|
|
2797
|
+
reasons.push(plural(prAnalysis.newMessageCount, "new pull request message"));
|
|
2798
|
+
}
|
|
2799
|
+
if (actionableThreads.length > 0) {
|
|
2800
|
+
reasons.push(plural(actionableThreads.length, "unresolved review thread"));
|
|
2801
|
+
}
|
|
2802
|
+
if (issueAnalysis.hasNewMessage) {
|
|
2803
|
+
reasons.push(plural(issueAnalysis.newMessageCount, "new issue message"));
|
|
2804
|
+
}
|
|
2805
|
+
return {
|
|
2806
|
+
kind: "work",
|
|
2807
|
+
item: {
|
|
2808
|
+
issue,
|
|
2809
|
+
turn: "pr-work",
|
|
2810
|
+
pr: surface.pr,
|
|
2811
|
+
branch: surface.pr.headRefName,
|
|
2812
|
+
needsAssignment,
|
|
2813
|
+
issueAnalysis,
|
|
2814
|
+
prAnalysis,
|
|
2815
|
+
actionableThreads,
|
|
2816
|
+
reason: `${reasons.join(", ")} on pull request #${String(surface.pr.number)}`,
|
|
2817
|
+
// Several open PRs closing one issue is ambiguous rather than wrong; the
|
|
2818
|
+
// most recently updated one is used and the rest are reported.
|
|
2819
|
+
ambiguousPrs: openPrs.length > 1 ? openPrs.filter((pr) => pr.number !== surface.pr.number) : []
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
function selectLinkedPr(linkedPrs) {
|
|
2824
|
+
const open = linkedPrs.filter((pr) => pr.state === "OPEN");
|
|
2825
|
+
return open.length === 0 ? null : newestPr(open);
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// src/github/workPrompt.ts
|
|
2829
|
+
function formatThreads(threads) {
|
|
2830
|
+
return threads.map((thread) => {
|
|
2831
|
+
const location = thread.line === null ? `${thread.path}:(file)` : `${thread.path}:${String(thread.line)}`;
|
|
2832
|
+
const newest = thread.comments.at(-1);
|
|
2833
|
+
const author = newest?.author ?? "unknown";
|
|
2834
|
+
const body = newest?.body ?? "";
|
|
2835
|
+
const link = thread.url === null ? "" : `
|
|
2836
|
+
${thread.url}`;
|
|
2837
|
+
return `[${author}] ${location}${link}
|
|
2838
|
+
${body}`;
|
|
2839
|
+
}).join("\n\n");
|
|
2840
|
+
}
|
|
2841
|
+
function composePrompt(input) {
|
|
2842
|
+
const { item, repo, agentUser, baseBranch, frame } = input;
|
|
2843
|
+
const lines = [
|
|
2844
|
+
frame.trimEnd(),
|
|
2845
|
+
"",
|
|
2846
|
+
"--- Context assembled by automata ---",
|
|
2847
|
+
`Repository: ${repo.owner}/${repo.repo}`,
|
|
2848
|
+
`You are: ${agentUser}`,
|
|
2849
|
+
`Turn: ${item.turn}`,
|
|
2850
|
+
`Base branch: ${baseBranch}`,
|
|
2851
|
+
`Issue #${String(item.issue.number)}: ${item.issue.title}`,
|
|
2852
|
+
`Issue URL: ${item.issue.url}`
|
|
2853
|
+
];
|
|
2854
|
+
if (item.pr) {
|
|
2855
|
+
lines.push(
|
|
2856
|
+
`Pull request #${String(item.pr.number)}: ${item.pr.title}`,
|
|
2857
|
+
`Pull request URL: ${item.pr.url}`,
|
|
2858
|
+
`Branch: ${item.pr.headRefName} (checked out and up to date)`
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
const newMessages = [
|
|
2862
|
+
...item.issueAnalysis.newMessages,
|
|
2863
|
+
...item.prAnalysis?.newMessages ?? []
|
|
2864
|
+
].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
2865
|
+
if (newMessages.length > 0) {
|
|
2866
|
+
lines.push("", "New since your last message \u2014 this is what you must answer:", "", formatMessages(newMessages));
|
|
2867
|
+
}
|
|
2868
|
+
lines.push(
|
|
2869
|
+
"",
|
|
2870
|
+
"Full conversation on the issue (authorized accounts and you only, oldest first):",
|
|
2871
|
+
"",
|
|
2872
|
+
formatMessages(item.issueAnalysis.messages)
|
|
2873
|
+
);
|
|
2874
|
+
if (item.prAnalysis && item.prAnalysis.messages.length > 0) {
|
|
2875
|
+
lines.push(
|
|
2876
|
+
"",
|
|
2877
|
+
"Conversation on the pull request (authorized accounts and you only, oldest first):",
|
|
2878
|
+
"",
|
|
2879
|
+
formatMessages(item.prAnalysis.messages)
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2882
|
+
if (item.actionableThreads.length > 0) {
|
|
2883
|
+
lines.push("", "Unresolved review threads needing an answer:", "", formatThreads(item.actionableThreads));
|
|
2884
|
+
}
|
|
2885
|
+
if (item.ambiguousPrs.length > 0) {
|
|
2886
|
+
const others = item.ambiguousPrs.map((pr) => `#${String(pr.number)}`).join(", ");
|
|
2887
|
+
lines.push(
|
|
2888
|
+
"",
|
|
2889
|
+
`Note: this issue is also closed by ${others}. You are working on #${String(item.pr?.number ?? 0)}, the most recently updated one.`
|
|
2890
|
+
);
|
|
2891
|
+
}
|
|
2892
|
+
lines.push(
|
|
2893
|
+
"",
|
|
2894
|
+
"Only the messages above exist. Anything from other accounts has been withheld",
|
|
2895
|
+
"deliberately \u2014 do not ask about it."
|
|
2896
|
+
);
|
|
2897
|
+
return lines.join("\n");
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
// src/github/markerReconciliation.ts
|
|
2901
|
+
function isAuthorized(author, p) {
|
|
2902
|
+
const login2 = author.toLowerCase();
|
|
2903
|
+
return login2 !== p.agentUser.toLowerCase() && p.allowedUsers.some((user) => user.toLowerCase() === login2);
|
|
2904
|
+
}
|
|
2905
|
+
function promptWatermark(messageSets) {
|
|
2906
|
+
let watermark = null;
|
|
2907
|
+
for (const messages of messageSets) {
|
|
2908
|
+
for (const message of messages) {
|
|
2909
|
+
if (watermark === null || message.createdAt > watermark) watermark = message.createdAt;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
return watermark;
|
|
2913
|
+
}
|
|
2914
|
+
function messagesBetween(messages, p, watermark, until) {
|
|
2915
|
+
const since = watermark ?? "";
|
|
2916
|
+
return messages.filter(
|
|
2917
|
+
(message) => isAuthorized(message.author, p) && message.createdAt > since && message.createdAt <= until
|
|
2918
|
+
);
|
|
2919
|
+
}
|
|
2920
|
+
function analyseAnswer(messages, p, marker, watermark) {
|
|
2921
|
+
let answeredAt = null;
|
|
2922
|
+
for (const message of messages) {
|
|
2923
|
+
if (message.kind === "issue-body") continue;
|
|
2924
|
+
if (message.author.toLowerCase() !== p.agentUser.toLowerCase()) continue;
|
|
2925
|
+
if (message.createdAt <= marker.createdAt) continue;
|
|
2926
|
+
if (answeredAt === null || message.createdAt > answeredAt) answeredAt = message.createdAt;
|
|
2927
|
+
}
|
|
2928
|
+
const since = watermark ?? marker.createdAt;
|
|
2929
|
+
const toReport = messages.filter(
|
|
2930
|
+
(message) => isAuthorized(message.author, p) && message.createdAt > since
|
|
2931
|
+
);
|
|
2932
|
+
const missed = answeredAt === null ? [] : toReport.filter((message) => message.createdAt <= answeredAt);
|
|
2933
|
+
return { answeredAt, missed, toReport };
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
// src/run/runLock.ts
|
|
2937
|
+
import { writeFileSync, readFileSync as readFileSync3, unlinkSync, mkdirSync, renameSync, linkSync, statSync } from "fs";
|
|
2938
|
+
import { randomUUID } from "crypto";
|
|
2939
|
+
import { hostname } from "os";
|
|
2940
|
+
import { join as join2 } from "path";
|
|
2941
|
+
var LOCK_DIR = ".automata";
|
|
2942
|
+
var LOCK_FILE = "automata.lock";
|
|
2943
|
+
var RUN_LOCK_RELATIVE_PATH = `${LOCK_DIR}/${LOCK_FILE}`;
|
|
2944
|
+
function lockPath() {
|
|
2945
|
+
return join2(process.cwd(), LOCK_DIR, LOCK_FILE);
|
|
2946
|
+
}
|
|
2947
|
+
function processStartedAt(pid) {
|
|
2948
|
+
try {
|
|
2949
|
+
const stat = readFileSync3(`/proc/${String(pid)}/stat`, "utf8");
|
|
2950
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
|
|
2951
|
+
const field = afterComm.split(" ")[19];
|
|
2952
|
+
return field === void 0 || field.length === 0 ? null : field;
|
|
2953
|
+
} catch {
|
|
2954
|
+
return null;
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
function isAlive(pid) {
|
|
2958
|
+
try {
|
|
2959
|
+
process.kill(pid, 0);
|
|
2960
|
+
return true;
|
|
2961
|
+
} catch (err) {
|
|
2962
|
+
return err.code === "EPERM";
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
function readOwner(path) {
|
|
2966
|
+
try {
|
|
2967
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
2968
|
+
if (typeof parsed.pid !== "number" || typeof parsed.startedAt !== "string") return null;
|
|
2969
|
+
return {
|
|
2970
|
+
pid: parsed.pid,
|
|
2971
|
+
startedAt: parsed.startedAt,
|
|
2972
|
+
host: parsed.host ?? "unknown",
|
|
2973
|
+
command: parsed.command ?? "unknown",
|
|
2974
|
+
token: parsed.token ?? "",
|
|
2975
|
+
pidStartedAt: parsed.pidStartedAt
|
|
2976
|
+
};
|
|
2977
|
+
} catch {
|
|
2978
|
+
return null;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
function isStale(owner, staleMinutes) {
|
|
2982
|
+
if (owner === null) return true;
|
|
2983
|
+
if (owner.host === hostname()) {
|
|
2984
|
+
if (!isAlive(owner.pid)) return true;
|
|
2985
|
+
const startedAt2 = processStartedAt(owner.pid);
|
|
2986
|
+
if (owner.pidStartedAt !== void 0 && startedAt2 !== null && startedAt2 !== owner.pidStartedAt) {
|
|
2987
|
+
return true;
|
|
2988
|
+
}
|
|
2989
|
+
return false;
|
|
2990
|
+
}
|
|
2991
|
+
const startedAt = Date.parse(owner.startedAt);
|
|
2992
|
+
if (Number.isNaN(startedAt)) return true;
|
|
2993
|
+
return Date.now() - startedAt > staleMinutes * 60 * 1e3;
|
|
2994
|
+
}
|
|
2995
|
+
function heldTooLong(owner, staleMinutes) {
|
|
2996
|
+
if (owner.host !== hostname()) return false;
|
|
2997
|
+
if (owner.pidStartedAt !== void 0 && processStartedAt(owner.pid) !== null) return false;
|
|
2998
|
+
const startedAt = Date.parse(owner.startedAt);
|
|
2999
|
+
if (Number.isNaN(startedAt)) return true;
|
|
3000
|
+
return Date.now() - startedAt > staleMinutes * 60 * 1e3;
|
|
3001
|
+
}
|
|
3002
|
+
function makeHandle(path, token) {
|
|
3003
|
+
let released = false;
|
|
3004
|
+
return {
|
|
3005
|
+
release() {
|
|
3006
|
+
if (released) return;
|
|
3007
|
+
released = true;
|
|
3008
|
+
const current = readOwner(path);
|
|
3009
|
+
if (current !== null && current.token !== token) {
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
const takenAway = `${path}.releasing.${token}`;
|
|
3013
|
+
try {
|
|
3014
|
+
renameSync(path, takenAway);
|
|
3015
|
+
} catch {
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
const owner = readOwner(takenAway);
|
|
3019
|
+
if (owner === null || owner.token === token) {
|
|
3020
|
+
try {
|
|
3021
|
+
unlinkSync(takenAway);
|
|
3022
|
+
} catch {
|
|
3023
|
+
}
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
try {
|
|
3027
|
+
linkSync(takenAway, path);
|
|
3028
|
+
} catch {
|
|
3029
|
+
}
|
|
3030
|
+
try {
|
|
3031
|
+
unlinkSync(takenAway);
|
|
3032
|
+
} catch {
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
function publishLock(path, command, token) {
|
|
3038
|
+
const owner = {
|
|
3039
|
+
pid: process.pid,
|
|
3040
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3041
|
+
host: hostname(),
|
|
3042
|
+
command,
|
|
3043
|
+
token,
|
|
3044
|
+
pidStartedAt: processStartedAt(process.pid) ?? void 0
|
|
3045
|
+
};
|
|
3046
|
+
const staging = `${path}.staging.${token}`;
|
|
3047
|
+
try {
|
|
3048
|
+
writeFileSync(staging, JSON.stringify(owner, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
|
|
3049
|
+
} catch {
|
|
3050
|
+
return false;
|
|
3051
|
+
}
|
|
3052
|
+
try {
|
|
3053
|
+
linkSync(staging, path);
|
|
3054
|
+
return true;
|
|
3055
|
+
} catch (err) {
|
|
3056
|
+
if (err.code !== "EEXIST") throw err;
|
|
3057
|
+
return false;
|
|
3058
|
+
} finally {
|
|
3059
|
+
try {
|
|
3060
|
+
unlinkSync(staging);
|
|
3061
|
+
} catch {
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
function acquireRunLock(command, staleMinutes) {
|
|
3066
|
+
const path = lockPath();
|
|
3067
|
+
const token = randomUUID();
|
|
3068
|
+
mkdirSync(join2(process.cwd(), LOCK_DIR), { recursive: true });
|
|
3069
|
+
if (publishLock(path, command, token)) {
|
|
3070
|
+
return { ok: true, handle: makeHandle(path, token) };
|
|
3071
|
+
}
|
|
3072
|
+
const owner = readOwner(path);
|
|
3073
|
+
if (!isStale(owner, staleMinutes)) {
|
|
3074
|
+
const held = owner;
|
|
3075
|
+
return { ok: false, heldBy: held, suspect: heldTooLong(held, staleMinutes) };
|
|
3076
|
+
}
|
|
3077
|
+
return reclaim(path, command, token, owner);
|
|
3078
|
+
}
|
|
3079
|
+
var UNKNOWN_OWNER = {
|
|
3080
|
+
pid: 0,
|
|
3081
|
+
startedAt: "unknown",
|
|
3082
|
+
host: "unknown",
|
|
3083
|
+
command: "unknown",
|
|
3084
|
+
token: ""
|
|
3085
|
+
};
|
|
3086
|
+
function claimStaleLock(path, token, expected) {
|
|
3087
|
+
const claimed = `${path}.stale.${token}`;
|
|
3088
|
+
try {
|
|
3089
|
+
renameSync(path, claimed);
|
|
3090
|
+
} catch (err) {
|
|
3091
|
+
if (err.code === "ENOENT") return false;
|
|
3092
|
+
throw err;
|
|
3093
|
+
}
|
|
3094
|
+
if (expected !== void 0) {
|
|
3095
|
+
const taken = readOwner(claimed);
|
|
3096
|
+
const sameFile = expected === null && taken === null || expected !== null && taken !== null && taken.token === expected.token;
|
|
3097
|
+
if (!sameFile) {
|
|
3098
|
+
try {
|
|
3099
|
+
linkSync(claimed, path);
|
|
3100
|
+
} catch {
|
|
3101
|
+
}
|
|
3102
|
+
try {
|
|
3103
|
+
unlinkSync(claimed);
|
|
3104
|
+
} catch {
|
|
3105
|
+
}
|
|
3106
|
+
return false;
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
return true;
|
|
3110
|
+
}
|
|
3111
|
+
var CLAIM_STALE_MS = 6e4;
|
|
3112
|
+
function acquireClaim(path) {
|
|
3113
|
+
const claimPath = `${path}.claim`;
|
|
3114
|
+
try {
|
|
3115
|
+
writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
|
|
3116
|
+
encoding: "utf8",
|
|
3117
|
+
flag: "wx"
|
|
3118
|
+
});
|
|
3119
|
+
return claimPath;
|
|
3120
|
+
} catch (err) {
|
|
3121
|
+
if (err.code !== "EEXIST") throw err;
|
|
3122
|
+
}
|
|
3123
|
+
if (!claimIsAbandoned(claimPath)) return null;
|
|
3124
|
+
try {
|
|
3125
|
+
renameSync(claimPath, `${claimPath}.abandoned.${String(process.pid)}`);
|
|
3126
|
+
} catch {
|
|
3127
|
+
return null;
|
|
3128
|
+
}
|
|
3129
|
+
try {
|
|
3130
|
+
unlinkSync(`${claimPath}.abandoned.${String(process.pid)}`);
|
|
3131
|
+
} catch {
|
|
3132
|
+
}
|
|
3133
|
+
try {
|
|
3134
|
+
writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
|
|
3135
|
+
encoding: "utf8",
|
|
3136
|
+
flag: "wx"
|
|
3137
|
+
});
|
|
3138
|
+
return claimPath;
|
|
3139
|
+
} catch {
|
|
3140
|
+
return null;
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
function claimIsAbandoned(claimPath) {
|
|
3144
|
+
let at = Number.NaN;
|
|
3145
|
+
try {
|
|
3146
|
+
const raw = JSON.parse(readFileSync3(claimPath, "utf8"));
|
|
3147
|
+
if (raw.at !== void 0) at = Date.parse(raw.at);
|
|
3148
|
+
} catch {
|
|
3149
|
+
}
|
|
3150
|
+
if (Number.isNaN(at)) {
|
|
3151
|
+
try {
|
|
3152
|
+
at = statSync(claimPath).mtimeMs;
|
|
3153
|
+
} catch {
|
|
3154
|
+
return true;
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
return Date.now() - at >= CLAIM_STALE_MS;
|
|
3158
|
+
}
|
|
3159
|
+
function reclaim(path, command, token, expected) {
|
|
3160
|
+
const claimPath = acquireClaim(path);
|
|
3161
|
+
if (claimPath === null) {
|
|
3162
|
+
const owner = readOwner(path);
|
|
3163
|
+
const ownerDead = owner !== null && owner.host === hostname() && !isAlive(owner.pid);
|
|
3164
|
+
return { ok: false, heldBy: owner ?? UNKNOWN_OWNER, suspect: ownerDead };
|
|
3165
|
+
}
|
|
3166
|
+
try {
|
|
3167
|
+
if (!claimStaleLock(path, token, expected)) {
|
|
3168
|
+
return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
|
|
3169
|
+
}
|
|
3170
|
+
const claimed = `${path}.stale.${token}`;
|
|
3171
|
+
try {
|
|
3172
|
+
if (publishLock(path, command, token)) {
|
|
3173
|
+
return { ok: true, handle: makeHandle(path, token) };
|
|
3174
|
+
}
|
|
3175
|
+
return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
|
|
3176
|
+
} finally {
|
|
3177
|
+
try {
|
|
3178
|
+
unlinkSync(claimed);
|
|
3179
|
+
} catch {
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
} finally {
|
|
3183
|
+
try {
|
|
3184
|
+
unlinkSync(claimPath);
|
|
3185
|
+
} catch {
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
// src/git/workspaceService.ts
|
|
3191
|
+
function dirtyTree() {
|
|
3192
|
+
return {
|
|
3193
|
+
ok: false,
|
|
3194
|
+
reason: "dirty-tree",
|
|
3195
|
+
detail: "the working tree has uncommitted changes; commit or stash them yourself and re-run"
|
|
3196
|
+
};
|
|
3197
|
+
}
|
|
3198
|
+
function prepareBaseBranch(baseBranch) {
|
|
3199
|
+
if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
|
|
3200
|
+
const checkout = checkoutBranch(baseBranch);
|
|
3201
|
+
if (!checkout.ok) {
|
|
3202
|
+
return { ok: false, reason: "checkout-failed", detail: checkout.stderr };
|
|
3203
|
+
}
|
|
3204
|
+
const pull = pullFastForwardOnly();
|
|
3205
|
+
if (!pull.ok) {
|
|
3206
|
+
return { ok: false, reason: "pull-failed", detail: pull.stderr };
|
|
3207
|
+
}
|
|
3208
|
+
return { ok: true, branch: baseBranch };
|
|
3209
|
+
}
|
|
3210
|
+
function preparePrBranch(headRefName) {
|
|
3211
|
+
if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
|
|
3212
|
+
const fetched = fetchBranch(headRefName);
|
|
3213
|
+
if (!fetched.ok) {
|
|
3214
|
+
return { ok: false, reason: "checkout-failed", detail: fetched.stderr };
|
|
3215
|
+
}
|
|
3216
|
+
const checkout = checkoutBranch(headRefName);
|
|
3217
|
+
if (!checkout.ok) {
|
|
3218
|
+
const created = createTrackingBranch(headRefName);
|
|
3219
|
+
if (!created.ok) {
|
|
3220
|
+
return { ok: false, reason: "checkout-failed", detail: created.stderr };
|
|
3221
|
+
}
|
|
3222
|
+
return { ok: true, branch: headRefName };
|
|
3223
|
+
}
|
|
3224
|
+
const pull = pullFastForwardOnly(headRefName);
|
|
3225
|
+
if (!pull.ok) {
|
|
3226
|
+
return { ok: false, reason: "pull-failed", detail: pull.stderr };
|
|
3227
|
+
}
|
|
3228
|
+
return { ok: true, branch: headRefName };
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
// src/commands/doWork.ts
|
|
3232
|
+
var inFlightMarker = null;
|
|
3233
|
+
function out(message) {
|
|
3234
|
+
process.stdout.write(message);
|
|
3235
|
+
}
|
|
3236
|
+
function progress(message) {
|
|
3237
|
+
process.stderr.write(message);
|
|
3238
|
+
}
|
|
3239
|
+
function fail(message) {
|
|
3240
|
+
process.stderr.write(`Error: ${message}
|
|
3241
|
+
`);
|
|
3242
|
+
process.exit(1);
|
|
3243
|
+
}
|
|
3244
|
+
function parsePositiveInt(value, label) {
|
|
3245
|
+
const trimmed = value.trim();
|
|
3246
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
3247
|
+
fail(`${label} must be a positive integer (got "${value}").`);
|
|
3248
|
+
}
|
|
3249
|
+
const parsed = Number(trimmed);
|
|
3250
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
3251
|
+
fail(`${label} must be a positive integer within the safe range (got "${value}").`);
|
|
3252
|
+
}
|
|
3253
|
+
return parsed;
|
|
3254
|
+
}
|
|
3255
|
+
function resolveSettings(options) {
|
|
3256
|
+
let config;
|
|
3257
|
+
try {
|
|
3258
|
+
config = readConfig();
|
|
3259
|
+
} catch (err) {
|
|
3260
|
+
fail(err.message);
|
|
3261
|
+
}
|
|
3262
|
+
if (config.remoteType !== "gh") {
|
|
3263
|
+
fail(
|
|
3264
|
+
"do-work is only supported for GitHub remotes. Set it with `automata config set type gh`. Azure DevOps lacks the issue conversation APIs this needs \u2014 see docs/azdo-gap.md."
|
|
3265
|
+
);
|
|
3266
|
+
}
|
|
3267
|
+
if (!config.issueDiscoveryTechnique) {
|
|
3268
|
+
fail("No issue discovery technique configured. Run `automata config set issue-discovery-technique <value>`.");
|
|
3269
|
+
}
|
|
3270
|
+
if (!config.issueDiscoveryValue) {
|
|
3271
|
+
fail("No issue discovery value configured. Run `automata config set issue-discovery-value <value>`.");
|
|
3272
|
+
}
|
|
3273
|
+
const allowedUsers = (config.allowedUsers ?? []).filter((user) => user.trim().length > 0);
|
|
3274
|
+
if (allowedUsers.length === 0) {
|
|
3275
|
+
fail("No allowed users configured. Run `automata config set allowed-users <user1,user2>`.");
|
|
3276
|
+
}
|
|
3277
|
+
const agentUser = (config.agentUser ?? "").trim();
|
|
3278
|
+
if (agentUser.length === 0) {
|
|
3279
|
+
fail("No agent user configured. Run `automata config set agent-user <login>`.");
|
|
3280
|
+
}
|
|
3281
|
+
validateDoWorkConfig(config.doWork);
|
|
3282
|
+
const doWork = config.doWork ?? {};
|
|
3283
|
+
let executor = doWork.executor ?? DEFAULT_DO_WORK.executor;
|
|
3284
|
+
if (options.with !== void 0) {
|
|
3285
|
+
const requested = options.with.toLowerCase();
|
|
3286
|
+
if (requested !== "claude" && requested !== "codex") {
|
|
3287
|
+
fail(`--with must be 'claude' or 'codex', got '${options.with}'.`);
|
|
3288
|
+
}
|
|
3289
|
+
executor = requested;
|
|
3290
|
+
}
|
|
3291
|
+
if (options.dryRun !== true) {
|
|
3292
|
+
checkAuthenticatedIdentity(agentUser, allowedUsers);
|
|
3293
|
+
}
|
|
3294
|
+
return {
|
|
3295
|
+
baseBranch: doWork.baseBranch ?? DEFAULT_DO_WORK.baseBranch,
|
|
3296
|
+
protectedBranches: doWork.protectedBranches ?? DEFAULT_DO_WORK.protectedBranches,
|
|
3297
|
+
executor,
|
|
3298
|
+
// --model wins; otherwise take the default for the executor in use.
|
|
3299
|
+
model: options.model ?? doWork.models?.[executor],
|
|
3300
|
+
maxRuns: options.maxRuns !== void 0 ? parsePositiveInt(options.maxRuns, "--max-runs") : doWork.maxRunsPerTick ?? DEFAULT_DO_WORK.maxRunsPerTick,
|
|
3301
|
+
lockStaleMinutes: doWork.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes,
|
|
3302
|
+
limit: parsePositiveInt(options.limit, "--limit"),
|
|
3303
|
+
participants: { allowedUsers, agentUser },
|
|
3304
|
+
prompts: {
|
|
3305
|
+
"issue-discuss": doWork.prompts?.issueDiscuss ?? DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT,
|
|
3306
|
+
"pr-work": doWork.prompts?.prWork ?? DEFAULT_DO_WORK_PR_WORK_PROMPT
|
|
3307
|
+
},
|
|
3308
|
+
technique: config.issueDiscoveryTechnique,
|
|
3309
|
+
discoveryValue: config.issueDiscoveryValue,
|
|
3310
|
+
onlyIssue: options.issue === void 0 ? void 0 : parsePositiveInt(options.issue, "--issue")
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
function checkAuthenticatedIdentity(agentUser, allowedUsers) {
|
|
3314
|
+
const login2 = getAuthenticatedLogin();
|
|
3315
|
+
if (login2 === null) {
|
|
3316
|
+
progress(
|
|
3317
|
+
`Warning: could not determine which account \`gh\` is authenticated as; assuming it is the agent (${agentUser}).
|
|
3318
|
+
`
|
|
3319
|
+
);
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
if (login2.toLowerCase() === agentUser.toLowerCase()) return;
|
|
3323
|
+
if (allowedUsers.some((user) => user.toLowerCase() === login2.toLowerCase())) {
|
|
3324
|
+
fail(
|
|
3325
|
+
`\`gh\` is authenticated as "${login2}", which is listed in allowedUsers. Everything do-work posts would be attributed to an account that is allowed to instruct the agent, so its own marker comment would look like a new instruction and each tick would answer the previous tick forever. Authenticate \`gh\` as the agent account (${agentUser}) in this environment, or correct \`agentUser\`.`
|
|
3326
|
+
);
|
|
3327
|
+
}
|
|
3328
|
+
fail(
|
|
3329
|
+
`\`gh\` is authenticated as "${login2}" but agentUser is "${agentUser}". Comments posted under that identity are neither the agent's nor an authorized user's, so they are filtered out of the conversation: the answer boundary would never advance and the same message would start a run on every tick. Authenticate \`gh\` as the agent account (${agentUser}) in this environment, or correct \`agentUser\`.`
|
|
3330
|
+
);
|
|
3331
|
+
}
|
|
3332
|
+
function planRun(item, settings) {
|
|
3333
|
+
const prompt = composePrompt({
|
|
3334
|
+
item,
|
|
3335
|
+
repo: getRepoSlug(),
|
|
3336
|
+
agentUser: settings.participants.agentUser,
|
|
3337
|
+
baseBranch: settings.baseBranch,
|
|
3338
|
+
frame: settings.prompts[item.turn]
|
|
3339
|
+
});
|
|
3340
|
+
const bin = resolveCommand(settings.executor === "codex" ? "codex" : "claude");
|
|
3341
|
+
const args = settings.executor === "codex" ? buildCodexArgs(prompt, { yolo: true, model: settings.model }) : buildClaudeArgs(prompt, { yolo: true, verbose: true, model: settings.model });
|
|
3342
|
+
return { prompt, bin, args, command: [bin, ...args].map(shellQuote).join(" ") };
|
|
3343
|
+
}
|
|
3344
|
+
function describePlannedRun(item, settings, run5) {
|
|
3345
|
+
const rule = "\u2500".repeat(72);
|
|
3346
|
+
const branchAction = item.turn === "pr-work" ? " and fast-forward" : " and pull";
|
|
3347
|
+
const assignment = item.needsAssignment ? `would assign to ${settings.participants.agentUser}` : "already assigned";
|
|
3348
|
+
const markerTarget = item.turn === "pr-work" && item.pr ? `pull request #${String(item.pr.number)}` : `issue #${String(item.issue.number)}`;
|
|
3349
|
+
const modelNote = settings.model === void 0 ? " (no model override)" : ` \xB7 model ${settings.model}`;
|
|
3350
|
+
const lines = [
|
|
3351
|
+
rule,
|
|
3352
|
+
`Issue #${String(item.issue.number)} \u2014 ${item.issue.title}`,
|
|
3353
|
+
rule,
|
|
3354
|
+
` Turn ${item.turn}`,
|
|
3355
|
+
` Why ${item.reason}`,
|
|
3356
|
+
` Branch ${item.branch} (would check out${branchAction})`,
|
|
3357
|
+
` Assign ${assignment}`,
|
|
3358
|
+
` Marker would post on ${markerTarget}`,
|
|
3359
|
+
` Executor ${settings.executor}${modelNote}`,
|
|
3360
|
+
" Permissions bypassed (do-work always runs unattended)",
|
|
3361
|
+
` Prompt ${String(run5.prompt.length)} chars \u2014 frame + assembled context`,
|
|
3362
|
+
"",
|
|
3363
|
+
" Command that would be launched:",
|
|
3364
|
+
// Printed flush-left and unindented on purpose: the prompt is a multi-line
|
|
3365
|
+
// quoted argument, so indenting the continuation lines would inject leading
|
|
3366
|
+
// whitespace into the prompt itself and the command would no longer be the
|
|
3367
|
+
// one that runs.
|
|
3368
|
+
rule,
|
|
3369
|
+
run5.command,
|
|
3370
|
+
rule,
|
|
3371
|
+
""
|
|
3372
|
+
];
|
|
3373
|
+
return lines.join("\n") + "\n";
|
|
3374
|
+
}
|
|
3375
|
+
function isPlainObject(value) {
|
|
3376
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3377
|
+
}
|
|
3378
|
+
function validateOptionalString(container, key, path) {
|
|
3379
|
+
const value = container[key];
|
|
3380
|
+
if (value === void 0 || value === null) return;
|
|
3381
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
3382
|
+
fail(`${path} must be a non-empty string.`);
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
function validateOptionalInt(container, key, path, min, hint) {
|
|
3386
|
+
const value = container[key];
|
|
3387
|
+
if (value === void 0 || value === null) return;
|
|
3388
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
|
|
3389
|
+
fail(`${path} must be ${hint}, got ${JSON.stringify(value)}.`);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
function validateDoWorkConfig(section) {
|
|
3393
|
+
if (section === void 0 || section === null) return;
|
|
3394
|
+
if (!isPlainObject(section)) {
|
|
3395
|
+
fail(`doWork must be an object, got ${JSON.stringify(section)}.`);
|
|
3396
|
+
}
|
|
3397
|
+
const executor = section["executor"];
|
|
3398
|
+
if (executor !== void 0 && executor !== "claude" && executor !== "codex") {
|
|
3399
|
+
fail(`doWork.executor must be 'claude' or 'codex', got ${JSON.stringify(executor)}.`);
|
|
3400
|
+
}
|
|
3401
|
+
validateOptionalString(section, "baseBranch", "doWork.baseBranch");
|
|
3402
|
+
validateOptionalInt(section, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
|
|
3403
|
+
validateOptionalInt(section, "lockStaleMinutes", "doWork.lockStaleMinutes", 1, "a positive integer");
|
|
3404
|
+
const protectedBranches = section["protectedBranches"];
|
|
3405
|
+
if (protectedBranches !== void 0 && protectedBranches !== null) {
|
|
3406
|
+
if (!Array.isArray(protectedBranches) || protectedBranches.some((b) => typeof b !== "string" || b.trim().length === 0)) {
|
|
3407
|
+
fail("doWork.protectedBranches must be an array of non-empty strings.");
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
for (const container of ["models", "prompts"]) {
|
|
3411
|
+
const value = section[container];
|
|
3412
|
+
if (value === void 0 || value === null) continue;
|
|
3413
|
+
if (!isPlainObject(value)) {
|
|
3414
|
+
fail(`doWork.${container} must be an object, got ${JSON.stringify(value)}.`);
|
|
3415
|
+
}
|
|
3416
|
+
const keys = container === "models" ? ["claude", "codex"] : ["issueDiscuss", "prWork"];
|
|
3417
|
+
for (const key of keys) {
|
|
3418
|
+
validateOptionalString(value, key, `doWork.${container}.${key}`);
|
|
3419
|
+
}
|
|
3420
|
+
for (const key of Object.keys(value)) {
|
|
3421
|
+
if (!keys.includes(key)) {
|
|
3422
|
+
fail(`doWork.${container}.${key} is not a recognised setting; expected one of: ${keys.join(", ")}.`);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
function discoverIssues(settings) {
|
|
3428
|
+
const candidates = listCandidateIssues(settings.technique, settings.discoveryValue, settings.limit);
|
|
3429
|
+
if (settings.onlyIssue === void 0) {
|
|
3430
|
+
if (candidates.length === settings.limit) {
|
|
3431
|
+
progress(
|
|
3432
|
+
`Note: fetched the maximum of ${String(settings.limit)} issues \u2014 there may be more. Use --limit to raise it.
|
|
3433
|
+
`
|
|
3434
|
+
);
|
|
3435
|
+
}
|
|
3436
|
+
return candidates;
|
|
3437
|
+
}
|
|
3438
|
+
const match = candidates.find((issue) => issue.number === settings.onlyIssue);
|
|
3439
|
+
if (match) return [match];
|
|
3440
|
+
const surface = getIssueSurface(settings.onlyIssue);
|
|
3441
|
+
if (!issueMatchesFilter(surface, settings)) {
|
|
3442
|
+
progress(
|
|
3443
|
+
`Note: issue #${String(settings.onlyIssue)} does not match the configured discovery filter (${settings.technique} = ${settings.discoveryValue}); processing it anyway because --issue was given.
|
|
3444
|
+
`
|
|
3445
|
+
);
|
|
3446
|
+
}
|
|
3447
|
+
return [surface.issue];
|
|
3448
|
+
}
|
|
3449
|
+
function issueMatchesFilter(surface, settings) {
|
|
3450
|
+
const value = settings.discoveryValue.toLowerCase();
|
|
3451
|
+
switch (settings.technique) {
|
|
3452
|
+
case "label":
|
|
3453
|
+
return surface.labels.some((label) => label.toLowerCase() === value);
|
|
3454
|
+
case "assignee":
|
|
3455
|
+
return surface.assignees.some((assignee) => assignee.toLowerCase() === value);
|
|
3456
|
+
case "title-contains":
|
|
3457
|
+
return surface.issue.title.toLowerCase().includes(value);
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
function buildIssueState(issue, linkMap) {
|
|
3461
|
+
const issueSurface = getIssueSurface(issue.number);
|
|
3462
|
+
const linkedPrs = linkMap.byIssue.get(issue.number) ?? [];
|
|
3463
|
+
const selected = selectLinkedPr(linkedPrs);
|
|
3464
|
+
return {
|
|
3465
|
+
issueSurface,
|
|
3466
|
+
linkedPrs,
|
|
3467
|
+
prSurface: selected === null ? null : getPrSurface(selected.number)
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3470
|
+
function describePlan(decisions) {
|
|
3471
|
+
const lines = decisions.map((decision) => {
|
|
3472
|
+
if (decision.kind === "skip") {
|
|
3473
|
+
return ` #${String(decision.issue.number)} nothing to do \u2014 ${decision.detail}`;
|
|
3474
|
+
}
|
|
3475
|
+
const item = decision.item;
|
|
3476
|
+
const claim = item.needsAssignment ? ", will assign to the agent" : "";
|
|
3477
|
+
return ` #${String(item.issue.number)} ${item.turn} on ${item.branch} \u2014 ${item.reason}${claim}`;
|
|
3478
|
+
});
|
|
3479
|
+
return lines.length === 0 ? " (no issues matched the discovery filter)\n" : lines.join("\n") + "\n";
|
|
3480
|
+
}
|
|
3481
|
+
function claimIssue(item, settings) {
|
|
3482
|
+
if (!item.needsAssignment) return;
|
|
3483
|
+
try {
|
|
3484
|
+
assignIssueToAgent(item.issue.number, settings.participants.agentUser);
|
|
3485
|
+
progress(` assigned issue #${String(item.issue.number)} to ${settings.participants.agentUser}.
|
|
3486
|
+
`);
|
|
3487
|
+
} catch (err) {
|
|
3488
|
+
progress(` warning: could not assign issue #${String(item.issue.number)}: ${err.message}
|
|
3489
|
+
`);
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
function notePickupOnIssue(item, marker) {
|
|
3493
|
+
if (item.turn !== "pr-work" || item.pr === null || !item.issueAnalysis.hasNewMessage) return null;
|
|
3494
|
+
try {
|
|
3495
|
+
return postMarker(
|
|
3496
|
+
"issue",
|
|
3497
|
+
item.issue.number,
|
|
3498
|
+
`automata do-work: picked this up on pull request #${String(item.pr.number)} \u2014 ${item.pr.url}`
|
|
3499
|
+
);
|
|
3500
|
+
} catch (err) {
|
|
3501
|
+
progress(` skipped: could not note the pickup on issue #${String(item.issue.number)} \u2014 ${err.message}
|
|
3502
|
+
`);
|
|
3503
|
+
try {
|
|
3504
|
+
deleteMarker(marker);
|
|
3505
|
+
} catch (deleteErr) {
|
|
3506
|
+
progress(` warning: could not withdraw the working marker: ${deleteErr.message}
|
|
3507
|
+
`);
|
|
3508
|
+
}
|
|
3509
|
+
return false;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
function reportIssueMessagesBuriedByNote(item, note, participants) {
|
|
3513
|
+
const watermark = promptWatermark([item.issueAnalysis.messages]);
|
|
3514
|
+
let buried;
|
|
3515
|
+
try {
|
|
3516
|
+
const messages = getIssueSurface(item.issue.number).messages;
|
|
3517
|
+
buried = messagesBetween(messages, participants, watermark, note.createdAt);
|
|
3518
|
+
} catch (err) {
|
|
3519
|
+
progress(` warning: could not re-read issue #${String(item.issue.number)}: ${err.message}
|
|
3520
|
+
`);
|
|
3521
|
+
return 0;
|
|
3522
|
+
}
|
|
3523
|
+
if (buried.length === 0) return 0;
|
|
3524
|
+
const authors = [...new Set(buried.map((message) => message.author))].join(", ");
|
|
3525
|
+
try {
|
|
3526
|
+
postMarker(
|
|
3527
|
+
"issue",
|
|
3528
|
+
item.issue.number,
|
|
3529
|
+
`automata do-work: ${authors} posted here while this issue was being picked up, so ${buried.length === 1 ? "that message was" : "those messages were"} not included in the run. Please post again to have them acted on.`
|
|
3530
|
+
);
|
|
3531
|
+
} catch (err) {
|
|
3532
|
+
progress(` warning: could not report the buried issue message(s): ${err.message}
|
|
3533
|
+
`);
|
|
3534
|
+
}
|
|
3535
|
+
return buried.length;
|
|
3536
|
+
}
|
|
3537
|
+
function refreshItem(item, settings) {
|
|
3538
|
+
const linkMap = getOpenPrLinkMap();
|
|
3539
|
+
return decideWork(buildIssueState(item.issue, linkMap), settings.participants, {
|
|
3540
|
+
baseBranch: settings.baseBranch,
|
|
3541
|
+
defaultBranch: linkMap.defaultBranch,
|
|
3542
|
+
protectedBranches: settings.protectedBranches
|
|
3543
|
+
});
|
|
3544
|
+
}
|
|
3545
|
+
function readAnsweringSurface(item) {
|
|
3546
|
+
if (item.turn === "issue-discuss" || item.pr === null) {
|
|
3547
|
+
return getIssueSurface(item.issue.number).messages;
|
|
3548
|
+
}
|
|
3549
|
+
const surface = getPrSurface(item.pr.number);
|
|
3550
|
+
return [...surface.messages, ...surface.threads.flatMap((thread) => thread.comments)];
|
|
3551
|
+
}
|
|
3552
|
+
function markerSurfaceLabel(item) {
|
|
3553
|
+
return item.turn === "pr-work" && item.pr ? `pull request #${String(item.pr.number)}` : `issue #${String(item.issue.number)}`;
|
|
3554
|
+
}
|
|
3555
|
+
function markerSurfaceTarget(item) {
|
|
3556
|
+
return item.turn === "pr-work" && item.pr ? { surface: "pr", number: item.pr.number } : { surface: "issue", number: item.issue.number };
|
|
3557
|
+
}
|
|
3558
|
+
function reportOvertakenMessages(item, analysis) {
|
|
3559
|
+
if (analysis.missed.length === 0) return;
|
|
3560
|
+
const authors = [...new Set(analysis.toReport.map((message) => message.author))].join(", ");
|
|
3561
|
+
const count = analysis.toReport.length;
|
|
3562
|
+
const target = markerSurfaceTarget(item);
|
|
3563
|
+
try {
|
|
3564
|
+
postMarker(
|
|
3565
|
+
target.surface,
|
|
3566
|
+
target.number,
|
|
3567
|
+
`automata do-work: ${authors} posted here while this run was already in progress, so ${count === 1 ? "that message was" : "those messages were"} not included in it. Please post again to have them acted on.`
|
|
3568
|
+
);
|
|
3569
|
+
} catch (err) {
|
|
3570
|
+
progress(` warning: could not report the overtaken message(s): ${err.message}
|
|
3571
|
+
`);
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
function reconcileMarker(item, marker, participants, watermark, runError) {
|
|
3575
|
+
let analysis;
|
|
3576
|
+
try {
|
|
3577
|
+
analysis = analyseAnswer(readAnsweringSurface(item), participants, marker, watermark);
|
|
3578
|
+
} catch (err) {
|
|
3579
|
+
progress(
|
|
3580
|
+
` warning: could not re-read issue #${String(item.issue.number)} to check for an answer: ${err.message}
|
|
3581
|
+
`
|
|
3582
|
+
);
|
|
3583
|
+
return reportUnverified(item, marker);
|
|
3584
|
+
}
|
|
3585
|
+
if (analysis.answeredAt !== null) {
|
|
3586
|
+
reportOvertakenMessages(item, analysis);
|
|
3587
|
+
try {
|
|
3588
|
+
deleteMarker(marker);
|
|
3589
|
+
} catch (err) {
|
|
3590
|
+
progress(` warning: could not delete the marker comment: ${err.message}
|
|
3591
|
+
`);
|
|
3592
|
+
}
|
|
3593
|
+
if (analysis.missed.length > 0) {
|
|
3594
|
+
return {
|
|
3595
|
+
outcome: "answered-no-reply",
|
|
3596
|
+
detail: `answered (${String(analysis.toReport.length)} message(s) arrived mid-run and were flagged)`,
|
|
3597
|
+
reason: "flagged"
|
|
3598
|
+
};
|
|
3599
|
+
}
|
|
3600
|
+
return runError === null ? { outcome: "answered", detail: "answered", reason: "answered" } : { outcome: "answered", detail: `answered, but the run reported: ${runError.message}`, reason: "answered" };
|
|
3601
|
+
}
|
|
3602
|
+
return reportNoAnswer(item, marker, runError);
|
|
3603
|
+
}
|
|
3604
|
+
function reportUnverified(item, marker) {
|
|
3605
|
+
const surface = markerSurfaceLabel(item);
|
|
3606
|
+
try {
|
|
3607
|
+
updateMarker(
|
|
3608
|
+
marker,
|
|
3609
|
+
`automata do-work: the agent run finished, but automata could not read ${surface} afterwards to confirm whether an answer was posted. Check this thread and the branch before assuming either. Reply here to have another attempt made.`
|
|
3610
|
+
);
|
|
3611
|
+
} catch (err) {
|
|
3612
|
+
progress(` warning: could not update the marker comment: ${err.message}
|
|
3613
|
+
`);
|
|
3614
|
+
}
|
|
3615
|
+
return {
|
|
3616
|
+
outcome: "answered-no-reply",
|
|
3617
|
+
detail: "could not verify whether an answer was posted",
|
|
3618
|
+
reason: "unverified"
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
3621
|
+
function reportNoAnswer(item, marker, runError) {
|
|
3622
|
+
const surface = markerSurfaceLabel(item);
|
|
3623
|
+
const sideEffects = item.turn === "issue-discuss" ? "It may still have created a branch or opened a pull request \u2014 check before assuming otherwise." : `It may still have changed the branch \`${item.branch}\` \u2014 check it before assuming otherwise.`;
|
|
3624
|
+
const explanation = runError === null ? `automata do-work: the agent run finished without posting an answer on ${surface}. ${sideEffects} Reply on ${surface} to have another attempt made.` : `automata do-work: the agent run failed before posting an answer (${runError.message}). ${sideEffects} Reply on ${surface} to have another attempt made.`;
|
|
3625
|
+
try {
|
|
3626
|
+
updateMarker(marker, explanation);
|
|
3627
|
+
} catch (err) {
|
|
3628
|
+
progress(
|
|
3629
|
+
` warning: could not update the marker comment on issue #${String(item.issue.number)}: ${err.message}
|
|
3630
|
+
the humans have not been told that this run produced no answer.
|
|
3631
|
+
`
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3634
|
+
return runError === null ? { outcome: "answered-no-reply", detail: "run finished but posted no answer", reason: "no-answer" } : { outcome: "failed", detail: `run failed: ${runError.message}`, reason: "no-answer" };
|
|
3635
|
+
}
|
|
3636
|
+
async function invokeExecutor(prompt, settings, silent) {
|
|
3637
|
+
if (settings.executor === "codex") {
|
|
3638
|
+
await runCodex(prompt, { model: settings.model });
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
await runClaude(prompt, { model: settings.model, printSteps: !silent });
|
|
3642
|
+
}
|
|
3643
|
+
function repairIssueLink(item, baseBranch) {
|
|
3644
|
+
try {
|
|
3645
|
+
const branch = getCurrentBranch();
|
|
3646
|
+
if (branch === baseBranch) {
|
|
3647
|
+
progress(` issue #${String(item.issue.number)} is still in discussion (no branch was created).
|
|
3648
|
+
`);
|
|
3649
|
+
return false;
|
|
3650
|
+
}
|
|
3651
|
+
const pr = getCurrentBranchPr();
|
|
3652
|
+
if (!pr) {
|
|
3653
|
+
progress(` issue #${String(item.issue.number)} is still in discussion (no pull request).
|
|
3654
|
+
`);
|
|
3655
|
+
return false;
|
|
3656
|
+
}
|
|
3657
|
+
const closesRef = new RegExp(String.raw`\bcloses\s+#` + String(item.issue.number) + String.raw`\b`, "i");
|
|
3658
|
+
if (closesRef.test(pr.body)) {
|
|
3659
|
+
progress(` pull request #${String(pr.number)} already closes issue #${String(item.issue.number)}.
|
|
3660
|
+
`);
|
|
3661
|
+
return true;
|
|
3662
|
+
}
|
|
3663
|
+
addClosesRefToPr(pr.number, item.issue.number);
|
|
3664
|
+
progress(` linked pull request #${String(pr.number)} to issue #${String(item.issue.number)}.
|
|
3665
|
+
`);
|
|
3666
|
+
return true;
|
|
3667
|
+
} catch (err) {
|
|
3668
|
+
progress(` warning: could not link a pull request to issue #${String(item.issue.number)}: ${err.message}
|
|
3669
|
+
`);
|
|
3670
|
+
return false;
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
async function processItem(planned, settings, silent) {
|
|
3674
|
+
progress(`
|
|
3675
|
+
#${String(planned.issue.number)} ${planned.turn}: ${planned.reason}
|
|
3676
|
+
`);
|
|
3677
|
+
const refreshed = refreshItem(planned, settings);
|
|
3678
|
+
if (refreshed.kind === "skip") {
|
|
3679
|
+
progress(` skipped: ${refreshed.detail}
|
|
3680
|
+
`);
|
|
3681
|
+
return {
|
|
3682
|
+
issue: planned.issue.number,
|
|
3683
|
+
title: planned.issue.title,
|
|
3684
|
+
turn: planned.turn,
|
|
3685
|
+
outcome: "skipped",
|
|
3686
|
+
detail: `no longer actionable: ${refreshed.detail}`
|
|
3687
|
+
};
|
|
3688
|
+
}
|
|
3689
|
+
const item = refreshed.item;
|
|
3690
|
+
if (item.turn !== planned.turn) {
|
|
3691
|
+
progress(` turn changed to ${item.turn} since the plan was built; using the current state.
|
|
3692
|
+
`);
|
|
3693
|
+
}
|
|
3694
|
+
const base = {
|
|
3695
|
+
issue: item.issue.number,
|
|
3696
|
+
title: item.issue.title,
|
|
3697
|
+
turn: item.turn
|
|
3698
|
+
};
|
|
3699
|
+
const prepared = item.turn === "issue-discuss" ? prepareBaseBranch(item.branch) : preparePrBranch(item.branch);
|
|
3700
|
+
if (!prepared.ok) {
|
|
3701
|
+
progress(` skipped: ${prepared.reason} \u2014 ${prepared.detail}
|
|
3702
|
+
`);
|
|
3703
|
+
return { ...base, outcome: "skipped", detail: `${prepared.reason}: ${prepared.detail}` };
|
|
3704
|
+
}
|
|
3705
|
+
claimIssue(item, settings);
|
|
3706
|
+
let marker;
|
|
3707
|
+
const markerSurface = item.turn === "pr-work" && item.pr ? item.pr.number : item.issue.number;
|
|
3708
|
+
try {
|
|
3709
|
+
marker = postMarker(item.turn === "pr-work" ? "pr" : "issue", markerSurface, "automata do-work: working\u2026");
|
|
3710
|
+
} catch (err) {
|
|
3711
|
+
progress(` skipped: could not post the working marker \u2014 ${err.message}
|
|
3712
|
+
`);
|
|
3713
|
+
return { ...base, outcome: "skipped", detail: `marker failed: ${err.message}` };
|
|
3714
|
+
}
|
|
3715
|
+
inFlightMarker = { marker, item };
|
|
3716
|
+
const note = notePickupOnIssue(item, marker);
|
|
3717
|
+
if (note === false) {
|
|
3718
|
+
inFlightMarker = null;
|
|
3719
|
+
return { ...base, outcome: "skipped", detail: "issue pickup note failed" };
|
|
3720
|
+
}
|
|
3721
|
+
let buriedByNote = 0;
|
|
3722
|
+
if (note !== null) {
|
|
3723
|
+
progress(` noted on issue #${String(item.issue.number)} that the work is on pull request #${String(item.pr?.number ?? 0)}.
|
|
3724
|
+
`);
|
|
3725
|
+
buriedByNote = reportIssueMessagesBuriedByNote(item, note, settings.participants);
|
|
3726
|
+
}
|
|
3727
|
+
const watermark = promptWatermark([
|
|
3728
|
+
item.issueAnalysis.messages,
|
|
3729
|
+
item.prAnalysis?.messages ?? [],
|
|
3730
|
+
item.actionableThreads.flatMap((thread) => thread.comments)
|
|
3731
|
+
]);
|
|
3732
|
+
const prompt = composePrompt({
|
|
3733
|
+
item,
|
|
3734
|
+
repo: getRepoSlug(),
|
|
3735
|
+
agentUser: settings.participants.agentUser,
|
|
3736
|
+
baseBranch: settings.baseBranch,
|
|
3737
|
+
frame: settings.prompts[item.turn]
|
|
3738
|
+
});
|
|
3739
|
+
const MAX_PROMPT_BYTES = 96 * 1024;
|
|
3740
|
+
const promptBytes = Buffer.byteLength(prompt, "utf8");
|
|
3741
|
+
if (promptBytes > MAX_PROMPT_BYTES) {
|
|
3742
|
+
const detail = `the composed prompt is ${String(Math.round(promptBytes / 1024))} KiB, over the ${String(MAX_PROMPT_BYTES / 1024)} KiB limit for a single command-line argument. The conversation is too long to hand to the executor this way.`;
|
|
3743
|
+
progress(` failed: ${detail}
|
|
3744
|
+
`);
|
|
3745
|
+
inFlightMarker = null;
|
|
3746
|
+
try {
|
|
3747
|
+
updateMarker(
|
|
3748
|
+
marker,
|
|
3749
|
+
`automata do-work: could not start a run because ${detail} Summarise the discussion in a new issue, or shorten the thread, and try again.`
|
|
3750
|
+
);
|
|
3751
|
+
} catch (err) {
|
|
3752
|
+
progress(` warning: could not update the marker comment: ${err.message}
|
|
3753
|
+
`);
|
|
3754
|
+
}
|
|
3755
|
+
return { ...base, outcome: "failed", detail };
|
|
3756
|
+
}
|
|
3757
|
+
let runError = null;
|
|
3758
|
+
try {
|
|
3759
|
+
await invokeExecutor(prompt, settings, silent);
|
|
3760
|
+
} catch (err) {
|
|
3761
|
+
runError = err;
|
|
3762
|
+
}
|
|
3763
|
+
const ranExecutor = true;
|
|
3764
|
+
inFlightMarker = null;
|
|
3765
|
+
const reconciled = reconcileMarker(item, marker, settings.participants, watermark, runError);
|
|
3766
|
+
progress(` ${reconciled.detail}
|
|
3767
|
+
`);
|
|
3768
|
+
let outcome = reconciled;
|
|
3769
|
+
if (buriedByNote > 0 && outcome.outcome === "answered") {
|
|
3770
|
+
outcome = {
|
|
3771
|
+
outcome: "answered-no-reply",
|
|
3772
|
+
detail: `${outcome.detail} (${String(buriedByNote)} issue message(s) were buried by the pickup note and flagged)`,
|
|
3773
|
+
reason: "flagged"
|
|
3774
|
+
};
|
|
3775
|
+
}
|
|
3776
|
+
if (item.turn === "issue-discuss") {
|
|
3777
|
+
const linked = repairIssueLink(item, settings.baseBranch);
|
|
3778
|
+
if (linked && outcome.reason === "no-answer") {
|
|
3779
|
+
outcome = {
|
|
3780
|
+
outcome: "answered",
|
|
3781
|
+
detail: "opened a pull request (no issue comment)",
|
|
3782
|
+
reason: "answered"
|
|
3783
|
+
};
|
|
3784
|
+
progress(" a pull request was opened, so the turn is counted as answered.\n");
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
return { ...base, outcome: outcome.outcome, detail: outcome.detail, ranExecutor };
|
|
3788
|
+
}
|
|
3789
|
+
function summarize(reports) {
|
|
3790
|
+
out("\nTick summary:\n");
|
|
3791
|
+
if (reports.length === 0) {
|
|
3792
|
+
out(" nothing to do\n");
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
for (const report of reports) {
|
|
3796
|
+
out(` #${String(report.issue)} ${report.turn ?? "-"} ${report.outcome} \u2014 ${report.detail}
|
|
3797
|
+
`);
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
var doWorkCommand = new Command6("do-work").description(
|
|
3801
|
+
"Run one tick of the autonomous loop: find the issues whose newest authorized message the agent has not answered, and answer them"
|
|
3802
|
+
).option("--with <executor>", "Executor to use: claude or codex (default: from config, else claude)").option("--model <string>", "Model identifier to pass to the executor, overriding the configured default for it").option("--issue <number>", "Restrict the tick to a single issue").option("--limit <n>", "Maximum number of issues to fetch", "10").option("--max-runs <n>", "Maximum number of model runs this tick").option(
|
|
3803
|
+
"--dry-run",
|
|
3804
|
+
"Print the work plan, plus a summary and the exact command that would be launched for each item, and exit without changing anything"
|
|
3805
|
+
).option("--json", "Emit the work plan and outcomes as JSON on stdout").option("--silent", "Suppress step-by-step Claude output; show only the final summary").action(async (options) => {
|
|
3806
|
+
const settings = resolveSettings(options);
|
|
3807
|
+
if (options.dryRun === true) {
|
|
3808
|
+
const exitCode2 = await runTick(settings, options);
|
|
3809
|
+
if (exitCode2 !== 0) process.exit(exitCode2);
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
const lock = acquireRunLock("do-work", settings.lockStaleMinutes);
|
|
3813
|
+
if (!lock.ok) {
|
|
3814
|
+
const held = lock.heldBy;
|
|
3815
|
+
const sentence = `Another automata instance is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}, command ${held.command}). Doing nothing.
|
|
3816
|
+
`;
|
|
3817
|
+
const suspectSentence = lock.suspect ? `Warning: that lock has been held longer than ${String(settings.lockStaleMinutes)} minutes. If no tick is really running, its process id was probably reused; remove ${RUN_LOCK_RELATIVE_PATH} once you have confirmed that.
|
|
3818
|
+
` : "";
|
|
3819
|
+
const exitCode2 = lock.suspect ? 2 : 0;
|
|
3820
|
+
if (options.json === true) {
|
|
3821
|
+
progress(sentence + suspectSentence);
|
|
3822
|
+
out(
|
|
3823
|
+
JSON.stringify(
|
|
3824
|
+
{ lockHeld: true, suspect: lock.suspect, heldBy: held, plan: [], items: [], exitCode: exitCode2 },
|
|
3825
|
+
null,
|
|
3826
|
+
2
|
|
3827
|
+
) + "\n"
|
|
3828
|
+
);
|
|
3829
|
+
} else {
|
|
3830
|
+
out(sentence);
|
|
3831
|
+
if (suspectSentence) progress(suspectSentence);
|
|
3832
|
+
}
|
|
3833
|
+
if (exitCode2 !== 0) process.exit(exitCode2);
|
|
3834
|
+
return;
|
|
3835
|
+
}
|
|
3836
|
+
const handle = lock.handle;
|
|
3837
|
+
let shuttingDown = false;
|
|
3838
|
+
const onSignal = () => {
|
|
3839
|
+
if (shuttingDown) return;
|
|
3840
|
+
shuttingDown = true;
|
|
3841
|
+
progress("\nInterrupted: stopping the executor before releasing the run lock\u2026\n");
|
|
3842
|
+
const pending = inFlightMarker;
|
|
3843
|
+
if (pending !== null) {
|
|
3844
|
+
try {
|
|
3845
|
+
updateMarker(
|
|
3846
|
+
pending.marker,
|
|
3847
|
+
`automata do-work: this run was interrupted before it finished, so no answer was produced. The branch \`${pending.item.branch}\` may have been changed. Reply here to have another attempt made.`
|
|
3848
|
+
);
|
|
3849
|
+
} catch (err) {
|
|
3850
|
+
progress(`Warning: could not update the in-flight marker: ${err.message}
|
|
3851
|
+
`);
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
void terminateTrackedChildren().then((allExited) => {
|
|
3855
|
+
if (allExited) {
|
|
3856
|
+
handle.release();
|
|
3857
|
+
} else {
|
|
3858
|
+
progress(
|
|
3859
|
+
"Warning: could not confirm the executor exited; leaving the run lock in place. Check for a stray executor process before the next tick.\n"
|
|
3860
|
+
);
|
|
3861
|
+
}
|
|
3862
|
+
process.exit(130);
|
|
3863
|
+
});
|
|
3864
|
+
};
|
|
3865
|
+
process.once("SIGINT", onSignal);
|
|
3866
|
+
process.once("SIGTERM", onSignal);
|
|
3867
|
+
let exitCode;
|
|
3868
|
+
try {
|
|
3869
|
+
exitCode = await runTick(settings, options);
|
|
3870
|
+
} catch (err) {
|
|
3871
|
+
process.stderr.write(`Error: ${err.message}
|
|
3872
|
+
`);
|
|
3873
|
+
exitCode = 1;
|
|
3874
|
+
} finally {
|
|
3875
|
+
handle.release();
|
|
3876
|
+
process.removeListener("SIGINT", onSignal);
|
|
3877
|
+
process.removeListener("SIGTERM", onSignal);
|
|
3878
|
+
}
|
|
3879
|
+
if (exitCode !== 0) process.exit(exitCode);
|
|
3880
|
+
});
|
|
3881
|
+
async function runTick(settings, options) {
|
|
3882
|
+
const issues = discoverIssues(settings);
|
|
3883
|
+
const linkMap = getOpenPrLinkMap();
|
|
3884
|
+
const decisions = issues.map(
|
|
3885
|
+
(issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, {
|
|
3886
|
+
baseBranch: settings.baseBranch,
|
|
3887
|
+
defaultBranch: linkMap.defaultBranch,
|
|
3888
|
+
protectedBranches: settings.protectedBranches
|
|
3889
|
+
})
|
|
3890
|
+
);
|
|
3891
|
+
const items = decisions.flatMap((decision) => decision.kind === "work" ? [decision.item] : []);
|
|
3892
|
+
const planText = `Work plan (${String(items.length)} of ${String(issues.length)} issues need an answer):
|
|
3893
|
+
${describePlan(decisions)}`;
|
|
3894
|
+
if (options.json) {
|
|
3895
|
+
progress(planText);
|
|
3896
|
+
} else {
|
|
3897
|
+
out(planText);
|
|
3898
|
+
}
|
|
3899
|
+
if (options.dryRun) {
|
|
3900
|
+
reportDryRun(items, decisions, settings, options);
|
|
3901
|
+
return 0;
|
|
3902
|
+
}
|
|
3903
|
+
const reports = [];
|
|
3904
|
+
const deferred = [];
|
|
3905
|
+
let runsUsed = 0;
|
|
3906
|
+
for (const item of items) {
|
|
3907
|
+
if (settings.maxRuns > 0 && runsUsed >= settings.maxRuns) {
|
|
3908
|
+
deferred.push(item);
|
|
3909
|
+
continue;
|
|
3910
|
+
}
|
|
3911
|
+
let report;
|
|
3912
|
+
try {
|
|
3913
|
+
report = await processItem(item, settings, options.silent === true);
|
|
3914
|
+
} catch (err) {
|
|
3915
|
+
progress(` failed: ${err.message}
|
|
3916
|
+
`);
|
|
3917
|
+
report = {
|
|
3918
|
+
issue: item.issue.number,
|
|
3919
|
+
title: item.issue.title,
|
|
3920
|
+
turn: item.turn,
|
|
3921
|
+
outcome: "failed",
|
|
3922
|
+
detail: err.message
|
|
3923
|
+
};
|
|
3924
|
+
}
|
|
3925
|
+
if (report.ranExecutor === true) runsUsed++;
|
|
3926
|
+
reports.push(report);
|
|
3927
|
+
}
|
|
3928
|
+
for (const item of deferred) {
|
|
3929
|
+
progress(`
|
|
3930
|
+
#${String(item.issue.number)} deferred: --max-runs / maxRunsPerTick reached.
|
|
3931
|
+
`);
|
|
3932
|
+
reports.push({
|
|
3933
|
+
issue: item.issue.number,
|
|
3934
|
+
title: item.issue.title,
|
|
3935
|
+
turn: item.turn,
|
|
3936
|
+
outcome: "deferred",
|
|
3937
|
+
detail: `run cap of ${String(settings.maxRuns)} reached`
|
|
3938
|
+
});
|
|
3939
|
+
}
|
|
3940
|
+
const degraded = reports.some((report) => report.outcome !== "answered");
|
|
3941
|
+
const exitCode = degraded ? 2 : 0;
|
|
3942
|
+
if (options.json) {
|
|
3943
|
+
out(JSON.stringify({ dryRun: false, plan: decisions.map(toPlanJson), items: reports, exitCode }, null, 2) + "\n");
|
|
3944
|
+
} else {
|
|
3945
|
+
summarize(reports);
|
|
3946
|
+
}
|
|
3947
|
+
return exitCode;
|
|
3948
|
+
}
|
|
3949
|
+
function reportDryRun(items, decisions, settings, options) {
|
|
3950
|
+
const describable = settings.maxRuns > 0 ? items.slice(0, settings.maxRuns) : items;
|
|
3951
|
+
const planned = describable.map((item) => planRun(item, settings));
|
|
3952
|
+
if (options.json) {
|
|
3953
|
+
out(
|
|
3954
|
+
JSON.stringify(
|
|
3955
|
+
{
|
|
3956
|
+
dryRun: true,
|
|
3957
|
+
plan: decisions.map(toPlanJson),
|
|
3958
|
+
runs: planned.map((run5, index) => ({
|
|
3959
|
+
issue: describable[index].issue.number,
|
|
3960
|
+
turn: describable[index].turn,
|
|
3961
|
+
executor: settings.executor,
|
|
3962
|
+
model: settings.model ?? null,
|
|
3963
|
+
bin: run5.bin,
|
|
3964
|
+
args: run5.args,
|
|
3965
|
+
command: run5.command,
|
|
3966
|
+
prompt: run5.prompt
|
|
3967
|
+
}))
|
|
3968
|
+
},
|
|
3969
|
+
null,
|
|
3970
|
+
2
|
|
3971
|
+
) + "\n"
|
|
3972
|
+
);
|
|
3973
|
+
return;
|
|
3974
|
+
}
|
|
3975
|
+
for (const [index, run5] of planned.entries()) {
|
|
3976
|
+
out("\n" + describePlannedRun(describable[index], settings, run5));
|
|
3977
|
+
}
|
|
3978
|
+
const deferred = items.length - describable.length;
|
|
3979
|
+
if (deferred > 0) {
|
|
3980
|
+
out(`
|
|
3981
|
+
(${String(deferred)} further item(s) deferred by the run cap.)
|
|
3982
|
+
`);
|
|
3983
|
+
}
|
|
3984
|
+
out("\nDry run: nothing was assigned, posted, checked out or executed.\n");
|
|
3985
|
+
}
|
|
3986
|
+
function toPlanJson(decision) {
|
|
3987
|
+
if (decision.kind === "skip") {
|
|
3988
|
+
return {
|
|
3989
|
+
issue: decision.issue.number,
|
|
3990
|
+
title: decision.issue.title,
|
|
3991
|
+
turn: null,
|
|
3992
|
+
skipReason: decision.reason,
|
|
3993
|
+
reason: decision.detail
|
|
3994
|
+
};
|
|
3995
|
+
}
|
|
3996
|
+
const item = decision.item;
|
|
3997
|
+
return {
|
|
3998
|
+
issue: item.issue.number,
|
|
3999
|
+
title: item.issue.title,
|
|
4000
|
+
turn: item.turn,
|
|
4001
|
+
branch: item.branch,
|
|
4002
|
+
pr: item.pr?.number ?? null,
|
|
4003
|
+
needsAssignment: item.needsAssignment,
|
|
4004
|
+
reason: item.reason
|
|
4005
|
+
};
|
|
4006
|
+
}
|
|
4007
|
+
|
|
2030
4008
|
// src/index.ts
|
|
2031
|
-
var program = new
|
|
4009
|
+
var program = new Command7();
|
|
2032
4010
|
program.name("automata").description("Automata CLI tool").version(version, "-v, --version");
|
|
2033
4011
|
program.addCommand(configCommand);
|
|
2034
|
-
program.addCommand(
|
|
4012
|
+
program.addCommand(gitCommand2);
|
|
2035
4013
|
program.addCommand(implementNextCommand);
|
|
2036
4014
|
program.addCommand(executeCommand);
|
|
2037
4015
|
program.addCommand(executePromptCommand);
|
|
4016
|
+
program.addCommand(doWorkCommand);
|
|
2038
4017
|
program.showHelpAfterError();
|
|
2039
4018
|
program.parse();
|
|
2040
4019
|
if (process.argv.length <= 2) {
|