lobstah 0.6.2 → 0.6.4
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/dist/main.js +854 -272
- package/dist/runner.js +253 -19
- package/docs/configuration.md +21 -1
- package/docs/man.md +72 -20
- package/docs/vocabulary.md +15 -6
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -739,6 +739,18 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
|
|
|
739
739
|
}
|
|
740
740
|
|
|
741
741
|
// packages/core/dist/config.js
|
|
742
|
+
var BRIEF_KINDS = ["conflict", "checks", "review", "ciFix", "rebase"];
|
|
743
|
+
function parseBriefHooks(raw) {
|
|
744
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
745
|
+
return void 0;
|
|
746
|
+
const out = {};
|
|
747
|
+
for (const kind of [...BRIEF_KINDS, "all"]) {
|
|
748
|
+
const text = raw[kind];
|
|
749
|
+
if (typeof text === "string" && text.trim())
|
|
750
|
+
out[kind] = text.trim();
|
|
751
|
+
}
|
|
752
|
+
return Object.keys(out).length ? out : void 0;
|
|
753
|
+
}
|
|
742
754
|
var ATTENTION_KINDS = ["question", "decision", "landed", "pr:draft", "pr:review", "pr:checks", "pr:conflict", "pr:ready", "report"];
|
|
743
755
|
var DEFAULT_ATTENTION_KINDS = ["question", "decision", "pr:ready", "pr:review", "pr:conflict", "pr:checks"];
|
|
744
756
|
function parseAttentionKinds(raw) {
|
|
@@ -766,7 +778,7 @@ var DEFAULT_HELM = {
|
|
|
766
778
|
armGraceSecs: 5
|
|
767
779
|
};
|
|
768
780
|
var DEFAULT_GLASS = { port: 4949 };
|
|
769
|
-
var DEFAULT_WATCH = { maxForksPerCycle: 3, autoRepair: true, conflicts: true, checks: true, maxRepairsPerPr: 2, repairSettleSecs: 600, repairTrapWaitSecs: 600 };
|
|
781
|
+
var DEFAULT_WATCH = { maxForksPerCycle: 3, autoRepair: true, conflicts: true, checks: true, maxRepairsPerPr: 2, maxRepairsWithoutProgress: 2, repairSettleSecs: 600, repairTrapWaitSecs: 600 };
|
|
770
782
|
var DEFAULT_LIMITS = {
|
|
771
783
|
maxConcurrent: 2,
|
|
772
784
|
choreConcurrent: 1,
|
|
@@ -809,7 +821,8 @@ function loadConfig() {
|
|
|
809
821
|
pushEarly: r.pushEarly === void 0 ? void 0 : Boolean(r.pushEarly),
|
|
810
822
|
draftPr: r.draftPr === void 0 ? void 0 : Boolean(r.draftPr),
|
|
811
823
|
checkpointOnStop: r.checkpointOnStop === void 0 ? void 0 : Boolean(r.checkpointOnStop),
|
|
812
|
-
humanGateChecks: Array.isArray(r.humanGateChecks) ? r.humanGateChecks.map(String).filter(Boolean) : void 0
|
|
824
|
+
humanGateChecks: Array.isArray(r.humanGateChecks) ? r.humanGateChecks.map(String).filter(Boolean) : void 0,
|
|
825
|
+
briefHooks: parseBriefHooks(r.briefHooks)
|
|
813
826
|
};
|
|
814
827
|
}
|
|
815
828
|
const groundsRaw = raw.grounds ?? {};
|
|
@@ -857,6 +870,26 @@ var GB = 1024 ** 3;
|
|
|
857
870
|
// packages/core/dist/watch.js
|
|
858
871
|
import * as fs3 from "node:fs";
|
|
859
872
|
import * as path3 from "node:path";
|
|
873
|
+
|
|
874
|
+
// packages/core/dist/gh-errors.js
|
|
875
|
+
function firstMeaningfulLine(...outputs) {
|
|
876
|
+
for (const out of outputs) {
|
|
877
|
+
for (const raw of (out ?? "").split("\n")) {
|
|
878
|
+
let line = raw.trim();
|
|
879
|
+
if (!line || /^(warning:|hint:|\(node:|\{|\})/i.test(line))
|
|
880
|
+
continue;
|
|
881
|
+
const m = /^error:\s*(.*)$/.exec(line);
|
|
882
|
+
if (m)
|
|
883
|
+
line = m[1].replace(/^"(.*)"$/, "$1").trim();
|
|
884
|
+
line = line.replace(/^(gh|GraphQL):\s*/i, "");
|
|
885
|
+
if (line)
|
|
886
|
+
return line;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return void 0;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// packages/core/dist/watch.js
|
|
860
893
|
function watchesDir() {
|
|
861
894
|
return path3.join(lobstahHome(), "watches");
|
|
862
895
|
}
|
|
@@ -1577,6 +1610,7 @@ var COMPILED_BINARY = (() => {
|
|
|
1577
1610
|
})();
|
|
1578
1611
|
|
|
1579
1612
|
// packages/core/dist/pr.js
|
|
1613
|
+
import { spawnSync } from "node:child_process";
|
|
1580
1614
|
function parsePrRef(s) {
|
|
1581
1615
|
const m = /^(?:pr:)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)$/.exec(s.trim()) ?? /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)(?:[/?#].*)?$/.exec(s.trim());
|
|
1582
1616
|
if (!m)
|
|
@@ -1587,6 +1621,59 @@ function parsePrRef(s) {
|
|
|
1587
1621
|
}
|
|
1588
1622
|
var PR_VIEW_FIELDS = "title,state,isDraft,headRefOid,baseRefName,baseRefOid,headRefName,mergeStateStatus,reviewDecision,statusCheckRollup,mergedAt,closedAt,updatedAt,reviews";
|
|
1589
1623
|
var PR_VIEW_FIELDS_NO_CHECKS = PR_VIEW_FIELDS.split(",").filter((f) => f !== "statusCheckRollup").join(",");
|
|
1624
|
+
var FORBIDDEN = /resource not accessible by (integration|personal access token)/i;
|
|
1625
|
+
function ghPrView(ref) {
|
|
1626
|
+
const view1 = (fields2) => spawnSync("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", fields2], {
|
|
1627
|
+
encoding: "utf8",
|
|
1628
|
+
timeout: 6e4
|
|
1629
|
+
});
|
|
1630
|
+
const reason = (r) => firstMeaningfulLine(r.stderr) ?? `gh exited ${r.status}`;
|
|
1631
|
+
const unknownBase = (r) => r.status !== 0 && /unknown json field.*baseRefOid/i.test(r.stderr ?? "");
|
|
1632
|
+
const withoutBase = (fields2) => fields2.split(",").filter((f) => f !== "baseRefOid").join(",");
|
|
1633
|
+
let fields = PR_VIEW_FIELDS;
|
|
1634
|
+
let res = view1(fields);
|
|
1635
|
+
if (unknownBase(res))
|
|
1636
|
+
res = view1(fields = withoutBase(fields));
|
|
1637
|
+
if (res.error)
|
|
1638
|
+
throw new Error(`gh: ${res.error.message}`);
|
|
1639
|
+
let checksError;
|
|
1640
|
+
if (res.status !== 0 && FORBIDDEN.test(res.stderr ?? "")) {
|
|
1641
|
+
checksError = reason(res);
|
|
1642
|
+
const retry = view1(fields === PR_VIEW_FIELDS ? PR_VIEW_FIELDS_NO_CHECKS : withoutBase(PR_VIEW_FIELDS_NO_CHECKS));
|
|
1643
|
+
if (retry.error)
|
|
1644
|
+
throw new Error(`gh: ${retry.error.message}`);
|
|
1645
|
+
if (retry.status !== 0) {
|
|
1646
|
+
throw new Error(FORBIDDEN.test(retry.stderr ?? "") ? `${reason(retry)} (fails even without check results)` : reason(retry));
|
|
1647
|
+
}
|
|
1648
|
+
res = retry;
|
|
1649
|
+
}
|
|
1650
|
+
if (res.status !== 0)
|
|
1651
|
+
throw new Error(reason(res));
|
|
1652
|
+
const view = JSON.parse(res.stdout);
|
|
1653
|
+
if (checksError)
|
|
1654
|
+
view.checksError = checksError;
|
|
1655
|
+
if (view.state === "OPEN") {
|
|
1656
|
+
const threads = ghUnresolvedThreads(ref);
|
|
1657
|
+
if (threads !== void 0)
|
|
1658
|
+
view.unresolvedThreads = threads;
|
|
1659
|
+
}
|
|
1660
|
+
return view;
|
|
1661
|
+
}
|
|
1662
|
+
var THREADS_QUERY = "query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$n){reviewThreads(first:100){nodes{isResolved}}}}}";
|
|
1663
|
+
function ghUnresolvedThreads(ref) {
|
|
1664
|
+
const res = spawnSync("gh", ["api", "graphql", "-f", `query=${THREADS_QUERY}`, "-f", `owner=${ref.owner}`, "-f", `repo=${ref.repo}`, "-F", `n=${ref.number}`], { encoding: "utf8", timeout: 6e4 });
|
|
1665
|
+
if (res.error || res.status !== 0)
|
|
1666
|
+
return void 0;
|
|
1667
|
+
return parseUnresolvedThreads(res.stdout);
|
|
1668
|
+
}
|
|
1669
|
+
function parseUnresolvedThreads(stdout) {
|
|
1670
|
+
try {
|
|
1671
|
+
const nodes = JSON.parse(stdout).data?.repository?.pullRequest?.reviewThreads?.nodes;
|
|
1672
|
+
return Array.isArray(nodes) ? nodes.filter((t) => t.isResolved === false).length : void 0;
|
|
1673
|
+
} catch {
|
|
1674
|
+
return void 0;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1590
1677
|
|
|
1591
1678
|
// packages/core/dist/prs.js
|
|
1592
1679
|
import * as fs14 from "node:fs";
|
|
@@ -1594,6 +1681,20 @@ import * as path11 from "node:path";
|
|
|
1594
1681
|
function prsDir() {
|
|
1595
1682
|
return path11.join(lobstahHome(), "prs");
|
|
1596
1683
|
}
|
|
1684
|
+
function prRecordFile(key) {
|
|
1685
|
+
const ref = parsePrRef(key);
|
|
1686
|
+
if (!ref)
|
|
1687
|
+
throw new Error(`not a PR key: ${key}`);
|
|
1688
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1689
|
+
return path11.join(prsDir(), `${safe(ref.owner)}__${safe(ref.repo)}__${ref.number}.json`);
|
|
1690
|
+
}
|
|
1691
|
+
function readPr(key) {
|
|
1692
|
+
try {
|
|
1693
|
+
return JSON.parse(fs14.readFileSync(prRecordFile(key), "utf8"));
|
|
1694
|
+
} catch {
|
|
1695
|
+
return void 0;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1597
1698
|
function readPrs() {
|
|
1598
1699
|
let files;
|
|
1599
1700
|
try {
|
|
@@ -1640,11 +1741,11 @@ function chainPr(id, lane) {
|
|
|
1640
1741
|
// packages/core/dist/pr-repair.js
|
|
1641
1742
|
var PUSH_RETRIES = 3;
|
|
1642
1743
|
var PUSH_REJECTED = "push rejected:";
|
|
1643
|
-
function pushRule(branch, id = "<dispatch id>") {
|
|
1744
|
+
function pushRule(branch, id = "<dispatch id>", mode = "rebase") {
|
|
1644
1745
|
const b = branch ?? "the PR's head branch";
|
|
1645
1746
|
return [
|
|
1646
1747
|
`Push only to the existing branch ${b}.`,
|
|
1647
|
-
`If the push is rejected as non-fast-forward because ${b} moved, fetch ${b}, rebase your commits onto the moved head again, and push with \`--force-with-lease=${b}:<the head you just fetched>\`. Retry at most ${PUSH_RETRIES} times.`,
|
|
1748
|
+
mode === "merge" ? `If the push is rejected as non-fast-forward because ${b} moved, fetch ${b}, merge the moved head into your branch, and push again with a normal push. Retry at most ${PUSH_RETRIES} times. Never force-push.` : `If the push is rejected as non-fast-forward because ${b} moved, fetch ${b}, rebase your commits onto the moved head again, and push with \`--force-with-lease=${b}:<the head you just fetched>\`. Retry at most ${PUSH_RETRIES} times.`,
|
|
1648
1749
|
"If a push hook fails with a real test or type error, do not retry the push: fix the error, commit, and push again.",
|
|
1649
1750
|
`If you still cannot push, report \`lobstah report ${id} failed "${PUSH_REJECTED} <rejection text>; moved head <full sha of the head you fetched>"\` and leave the PR as it was.`,
|
|
1650
1751
|
"Never push to another branch. Never open a new PR."
|
|
@@ -1655,7 +1756,7 @@ function pushRule(branch, id = "<dispatch id>") {
|
|
|
1655
1756
|
import * as fs15 from "node:fs";
|
|
1656
1757
|
import * as os3 from "node:os";
|
|
1657
1758
|
import * as path12 from "node:path";
|
|
1658
|
-
import { spawnSync } from "node:child_process";
|
|
1759
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
1659
1760
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-([0-9a-f])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1660
1761
|
function harnessFromSessionId(sessionId) {
|
|
1661
1762
|
const version = sessionId ? UUID.exec(sessionId)?.[1] : void 0;
|
|
@@ -1717,7 +1818,7 @@ function isUnresumable(error) {
|
|
|
1717
1818
|
function worktreeProgress(worktree, trunk) {
|
|
1718
1819
|
if (!worktree || !fs15.existsSync(worktree))
|
|
1719
1820
|
return "No worktree progress recorded.";
|
|
1720
|
-
const git2 = (...a) =>
|
|
1821
|
+
const git2 = (...a) => spawnSync2("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
1721
1822
|
const commits = trunk ? git2("log", "--oneline", `origin/${trunk}..HEAD`) : git2("log", "--oneline", "-15");
|
|
1722
1823
|
const status = git2("status", "--short");
|
|
1723
1824
|
return `Commits so far:
|
|
@@ -1827,7 +1928,7 @@ function isFinished(id, lane) {
|
|
|
1827
1928
|
// packages/runner/src/run.ts
|
|
1828
1929
|
import * as fs19 from "node:fs";
|
|
1829
1930
|
import * as path16 from "node:path";
|
|
1830
|
-
import { execFileSync } from "node:child_process";
|
|
1931
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1831
1932
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1832
1933
|
|
|
1833
1934
|
// packages/adapters/dist/claude.js
|
|
@@ -2346,7 +2447,7 @@ function loadAdapter(name) {
|
|
|
2346
2447
|
import * as fs17 from "node:fs";
|
|
2347
2448
|
import * as path14 from "node:path";
|
|
2348
2449
|
import { createHash } from "node:crypto";
|
|
2349
|
-
import { exec, execFile } from "node:child_process";
|
|
2450
|
+
import { exec, execFile, execFileSync } from "node:child_process";
|
|
2350
2451
|
import { promisify } from "node:util";
|
|
2351
2452
|
var run = promisify(execFile);
|
|
2352
2453
|
var shell = promisify(exec);
|
|
@@ -2372,7 +2473,12 @@ var LOCK_CONTENTION = [
|
|
|
2372
2473
|
// held by another process ("fetching ref <ref> failed: reference already
|
|
2373
2474
|
// exists"). The files backend maps the lock's EEXIST to this reason; older
|
|
2374
2475
|
// git printed "Unable to create '<ref>.lock': File exists" instead.
|
|
2375
|
-
/reference already exists
|
|
2476
|
+
/reference already exists/,
|
|
2477
|
+
// Windows: two fetches unpack the same loose object, and one cannot write
|
|
2478
|
+
// the file the other holds open ("unable to write file
|
|
2479
|
+
// .git/objects/<xx>/<rest>: Permission denied", then "unpack-objects
|
|
2480
|
+
// failed"). Only an object path matches, not any denied write.
|
|
2481
|
+
/unable to write file \S*objects[\\/][0-9a-f]{2}[\\/][0-9a-f]{38,}: Permission denied/
|
|
2376
2482
|
];
|
|
2377
2483
|
function gitStderr(err) {
|
|
2378
2484
|
const stderr = err.stderr;
|
|
@@ -2399,6 +2505,112 @@ ${gitStderr(err)}`, { cause: err });
|
|
|
2399
2505
|
}
|
|
2400
2506
|
}
|
|
2401
2507
|
}
|
|
2508
|
+
var REPO_LOCK_WAIT_MS = 5 * 6e4;
|
|
2509
|
+
var repoQueues = /* @__PURE__ */ new Map();
|
|
2510
|
+
function pidAlive(pid) {
|
|
2511
|
+
try {
|
|
2512
|
+
process.kill(pid, 0);
|
|
2513
|
+
return true;
|
|
2514
|
+
} catch (err) {
|
|
2515
|
+
return err.code === "EPERM";
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
var BUSY = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
2519
|
+
function takeRepoLock(file, mine) {
|
|
2520
|
+
try {
|
|
2521
|
+
fs17.writeFileSync(file, mine, { flag: "wx" });
|
|
2522
|
+
return { state: "held" };
|
|
2523
|
+
} catch (err) {
|
|
2524
|
+
const code = err.code ?? "";
|
|
2525
|
+
if (code !== "EEXIST" && !BUSY.has(code))
|
|
2526
|
+
throw err;
|
|
2527
|
+
}
|
|
2528
|
+
let raw = "";
|
|
2529
|
+
let age = 0;
|
|
2530
|
+
try {
|
|
2531
|
+
raw = fs17.readFileSync(file, "utf8");
|
|
2532
|
+
age = Date.now() - fs17.statSync(file).mtimeMs;
|
|
2533
|
+
} catch {
|
|
2534
|
+
return { state: "retry" };
|
|
2535
|
+
}
|
|
2536
|
+
let holder;
|
|
2537
|
+
try {
|
|
2538
|
+
holder = JSON.parse(raw);
|
|
2539
|
+
} catch {
|
|
2540
|
+
holder = void 0;
|
|
2541
|
+
}
|
|
2542
|
+
const stale = age > REPO_LOCK_WAIT_MS || (holder ? !pidAlive(holder.pid) : age > 5e3);
|
|
2543
|
+
if (stale) {
|
|
2544
|
+
try {
|
|
2545
|
+
if (fs17.readFileSync(file, "utf8") === raw)
|
|
2546
|
+
fs17.rmSync(file, { force: true });
|
|
2547
|
+
} catch {
|
|
2548
|
+
}
|
|
2549
|
+
return { state: "retry" };
|
|
2550
|
+
}
|
|
2551
|
+
return { state: "wait", ...holder ? { holder: holder.pid } : {} };
|
|
2552
|
+
}
|
|
2553
|
+
function releaseRepoLock(file, mine) {
|
|
2554
|
+
try {
|
|
2555
|
+
if (fs17.readFileSync(file, "utf8") === mine)
|
|
2556
|
+
fs17.rmSync(file, { force: true, recursive: true, maxRetries: 5 });
|
|
2557
|
+
} catch {
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
async function withRepoLock(common, fn) {
|
|
2561
|
+
const file = path14.join(common, "lobstah-git.lock");
|
|
2562
|
+
const mine = JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2563
|
+
const deadline = Date.now() + REPO_LOCK_WAIT_MS;
|
|
2564
|
+
let held = false;
|
|
2565
|
+
while (Date.now() < deadline) {
|
|
2566
|
+
const took = takeRepoLock(file, mine);
|
|
2567
|
+
if (took.state === "held") {
|
|
2568
|
+
held = true;
|
|
2569
|
+
break;
|
|
2570
|
+
}
|
|
2571
|
+
if (took.state === "wait")
|
|
2572
|
+
await new Promise((r) => setTimeout(r, 50 + Math.random() * 50));
|
|
2573
|
+
}
|
|
2574
|
+
try {
|
|
2575
|
+
await fn();
|
|
2576
|
+
} finally {
|
|
2577
|
+
if (held)
|
|
2578
|
+
releaseRepoLock(file, mine);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
async function oneAtATime(cwd, fn) {
|
|
2582
|
+
const common = await git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
2583
|
+
const key = realpath(common);
|
|
2584
|
+
const run2 = (repoQueues.get(key) ?? Promise.resolve()).then(() => withRepoLock(common, () => fn(common)));
|
|
2585
|
+
const tail = run2.catch(() => {
|
|
2586
|
+
});
|
|
2587
|
+
repoQueues.set(key, tail);
|
|
2588
|
+
try {
|
|
2589
|
+
await run2;
|
|
2590
|
+
} finally {
|
|
2591
|
+
if (repoQueues.get(key) === tail)
|
|
2592
|
+
repoQueues.delete(key);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
function fetchStamps(common) {
|
|
2596
|
+
try {
|
|
2597
|
+
return JSON.parse(fs17.readFileSync(path14.join(common, "lobstah-fetch.json"), "utf8"));
|
|
2598
|
+
} catch {
|
|
2599
|
+
return {};
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
async function fetchShared(cwd, remote, ref) {
|
|
2603
|
+
const asked = Date.now();
|
|
2604
|
+
const key = `${remote} ${ref}`;
|
|
2605
|
+
await oneAtATime(cwd, async (common) => {
|
|
2606
|
+
if ((fetchStamps(common)[key] ?? -Infinity) >= asked)
|
|
2607
|
+
return;
|
|
2608
|
+
const started = Date.now();
|
|
2609
|
+
await gitShared(cwd, "fetch", remote, ref);
|
|
2610
|
+
const stamps = { ...fetchStamps(common), [key]: started };
|
|
2611
|
+
fs17.writeFileSync(path14.join(common, "lobstah-fetch.json"), JSON.stringify(stamps, null, 2));
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2402
2614
|
async function allocate(repo, id, fromRemoteBranch = repo.trunk) {
|
|
2403
2615
|
const dir = worktreePath(id);
|
|
2404
2616
|
if (fs17.existsSync(dir)) {
|
|
@@ -2409,8 +2621,10 @@ async function allocate(repo, id, fromRemoteBranch = repo.trunk) {
|
|
|
2409
2621
|
throw new Error(`repo path ${repo.path} missing and no origin configured`);
|
|
2410
2622
|
await run("git", ["clone", repo.origin, repo.path], { env: process.env });
|
|
2411
2623
|
}
|
|
2412
|
-
await
|
|
2413
|
-
await
|
|
2624
|
+
await fetchShared(repo.path, "origin", fromRemoteBranch);
|
|
2625
|
+
await oneAtATime(repo.path, async () => {
|
|
2626
|
+
await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${fromRemoteBranch}`);
|
|
2627
|
+
});
|
|
2414
2628
|
await runSetup(repo, dir);
|
|
2415
2629
|
return dir;
|
|
2416
2630
|
}
|
|
@@ -2544,7 +2758,7 @@ async function chooseWorktree(input) {
|
|
|
2544
2758
|
return { reuse: true, path: found.path, owner: found.owner, from: found.from };
|
|
2545
2759
|
}
|
|
2546
2760
|
async function prepareReuse(repo, dir) {
|
|
2547
|
-
await
|
|
2761
|
+
await fetchShared(dir, "origin", repo.trunk);
|
|
2548
2762
|
if (!repo.setup?.length)
|
|
2549
2763
|
return { setupRan: false };
|
|
2550
2764
|
if (recordedSetupHash(dir) === setupHash(repo, dir))
|
|
@@ -2874,6 +3088,7 @@ function planStart(input) {
|
|
|
2874
3088
|
function startWallClock(opts) {
|
|
2875
3089
|
const now2 = opts.now ?? Date.now;
|
|
2876
3090
|
const tickMs = Math.max(1, Math.min(opts.tickMs ?? 5e3, opts.limitMs));
|
|
3091
|
+
const sleepGapMs = opts.sleepGapMs ?? Math.max(6e4, tickMs * 6);
|
|
2877
3092
|
let elapsed = opts.initialElapsedMs ?? 0;
|
|
2878
3093
|
const maxMs = Math.max(opts.limitMs, opts.maxMs ?? opts.limitMs);
|
|
2879
3094
|
let windowMs = Math.min(maxMs, Math.max(opts.limitMs, opts.initialWindowMs ?? opts.limitMs));
|
|
@@ -2887,7 +3102,8 @@ function startWallClock(opts) {
|
|
|
2887
3102
|
} catch {
|
|
2888
3103
|
paused = false;
|
|
2889
3104
|
}
|
|
2890
|
-
|
|
3105
|
+
const step = Math.max(0, t - last);
|
|
3106
|
+
if (!paused) elapsed += step > sleepGapMs ? Math.min(step, tickMs) : step;
|
|
2891
3107
|
last = t;
|
|
2892
3108
|
if (!done && elapsed >= windowMs && elapsed < maxMs) {
|
|
2893
3109
|
let advanced = false;
|
|
@@ -3094,13 +3310,23 @@ function keepRemote(opts) {
|
|
|
3094
3310
|
}
|
|
3095
3311
|
|
|
3096
3312
|
// packages/runner/src/run.ts
|
|
3313
|
+
function livePrState(url) {
|
|
3314
|
+
const ref = parsePrRef(url);
|
|
3315
|
+
if (!ref) return void 0;
|
|
3316
|
+
try {
|
|
3317
|
+
return ghPrView(ref).state;
|
|
3318
|
+
} catch {
|
|
3319
|
+
return void 0;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3097
3322
|
var defaultDeps = {
|
|
3098
3323
|
loadAdapter,
|
|
3099
3324
|
allocate,
|
|
3100
3325
|
chooseWorktree,
|
|
3101
3326
|
prepareReuse,
|
|
3102
3327
|
collectEvidence,
|
|
3103
|
-
reap: async () => 0
|
|
3328
|
+
reap: async () => 0,
|
|
3329
|
+
prState: livePrState
|
|
3104
3330
|
};
|
|
3105
3331
|
var DONE_WAIT_MS = 2e3;
|
|
3106
3332
|
function within(p, ms, fallback) {
|
|
@@ -3141,6 +3367,14 @@ async function main(activeDir, lane, seams = {}) {
|
|
|
3141
3367
|
attempts
|
|
3142
3368
|
};
|
|
3143
3369
|
fs19.writeFileSync(path16.join(activeDir, "runner.json"), JSON.stringify(runnerInfo, null, 2));
|
|
3370
|
+
if (descriptor.systemRepair && descriptor.pr?.url) {
|
|
3371
|
+
const state = deps.prState(descriptor.pr.url) ?? readPr(parsePrRef(descriptor.pr.url)?.key ?? "")?.state;
|
|
3372
|
+
if (state === "MERGED" || state === "CLOSED") {
|
|
3373
|
+
status("failed", `cancelled: ${descriptor.pr.url} is ${state.toLowerCase()}; nothing to repair`);
|
|
3374
|
+
complete(id, lane);
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3144
3378
|
const plan = planStart({
|
|
3145
3379
|
id,
|
|
3146
3380
|
lane,
|
|
@@ -3240,7 +3474,7 @@ async function main(activeDir, lane, seams = {}) {
|
|
|
3240
3474
|
})();
|
|
3241
3475
|
const head = () => {
|
|
3242
3476
|
try {
|
|
3243
|
-
return
|
|
3477
|
+
return execFileSync2("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
3244
3478
|
} catch {
|
|
3245
3479
|
return void 0;
|
|
3246
3480
|
}
|
|
@@ -3412,10 +3646,10 @@ async function main(activeDir, lane, seams = {}) {
|
|
|
3412
3646
|
}
|
|
3413
3647
|
|
|
3414
3648
|
// packages/runner/src/reap.ts
|
|
3415
|
-
import { execFileSync as
|
|
3649
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
3416
3650
|
function listProcesses() {
|
|
3417
3651
|
if (process.platform === "win32") {
|
|
3418
|
-
const out2 =
|
|
3652
|
+
const out2 = execFileSync3(
|
|
3419
3653
|
"powershell.exe",
|
|
3420
3654
|
[
|
|
3421
3655
|
"-NoProfile",
|
|
@@ -3427,7 +3661,7 @@ function listProcesses() {
|
|
|
3427
3661
|
);
|
|
3428
3662
|
return out2.split(/\r?\n/).map((l) => l.trim().split(/\s+/).map(Number)).filter(([pid, ppid]) => Number.isInteger(pid) && Number.isInteger(ppid)).map(([pid, ppid]) => ({ pid, ppid }));
|
|
3429
3663
|
}
|
|
3430
|
-
const out =
|
|
3664
|
+
const out = execFileSync3("ps", ["-A", "-o", "pid=,ppid=,pgid="], {
|
|
3431
3665
|
encoding: "utf8",
|
|
3432
3666
|
timeout: 3e4,
|
|
3433
3667
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -3470,7 +3704,7 @@ async function stopPids(pids, graceMs = 2e3) {
|
|
|
3470
3704
|
if (process.platform === "win32") {
|
|
3471
3705
|
for (const pid of pids) {
|
|
3472
3706
|
try {
|
|
3473
|
-
|
|
3707
|
+
execFileSync3("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 3e4 });
|
|
3474
3708
|
} catch {
|
|
3475
3709
|
}
|
|
3476
3710
|
}
|
package/docs/configuration.md
CHANGED
|
@@ -42,6 +42,25 @@ rather than relying on the default — left unset, a Claude session takes
|
|
|
42
42
|
`effortLevel` from the host's settings files, including the user's own
|
|
43
43
|
`~/.claude/settings.json`.
|
|
44
44
|
|
|
45
|
+
`[repos.<key>.briefHooks]` — markdown appended to the briefs lobstah
|
|
46
|
+
writes for this repo, one key per kind. Each value is a string; lobstah
|
|
47
|
+
appends it as written and does not read it.
|
|
48
|
+
|
|
49
|
+
| Key | Appended to |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `conflict` | A daemon repair of a PR with a merge conflict. |
|
|
52
|
+
| `checks` | A daemon repair of a PR with failing checks. |
|
|
53
|
+
| `review` | A daemon repair of a PR with requested changes, and a pickup review-feedback round. |
|
|
54
|
+
| `ciFix` | A CI-fix continuation a PR watch forks. |
|
|
55
|
+
| `rebase` | A rebase chore from pickup's merge loop. |
|
|
56
|
+
| `all` | Every kind above, after that kind's own text. |
|
|
57
|
+
|
|
58
|
+
```toml
|
|
59
|
+
[repos.web.briefHooks]
|
|
60
|
+
all = "Before you report done, run the repo's PR refresh (`/pr-refresh`)."
|
|
61
|
+
checks = "Run `pnpm test:ci` before you push."
|
|
62
|
+
```
|
|
63
|
+
|
|
45
64
|
`lobstah repos add <path> [--pickup]` detects and appends a block (origin,
|
|
46
65
|
default branch from `origin/HEAD`, setup from the lockfile); `lobstah init
|
|
47
66
|
--scan <dir>...` does the same for every git repo found under the given
|
|
@@ -113,7 +132,8 @@ send a continuation.
|
|
|
113
132
|
| `conflicts` | `true` | Repair conflicts when `autoRepair` is on. Set `false` to show conflict attention without a repair. |
|
|
114
133
|
| `checks` | `true` | Repair failed current checks when `autoRepair` is on. Set `false` to show check attention without a repair. |
|
|
115
134
|
| `maxRepairsPerPr` | `2` | Maximum repair follow-ups for one PR head SHA. When the limit is reached and the issue remains, `pr:conflict`, `pr:checks`, or `pr:review` attention names the limit. A repair that waits is not an attempt. Below this limit, each failing check gets at most one repair round per PR and head SHA: a check that had its round and still fails on the same head waits for a new commit, with `repair.status: waiting` and `heldBy: repaired`, and raises `pr:checks` attention. A CI-fix continuation from `lobstah pick` (`autoRepair = false`) follows the same rule. Human gates (`[repos.<key>].humanGateChecks`, `report --human-gate`) get no round. |
|
|
116
|
-
| `repairSettleSecs` | `600` | A repair is queued only after the PR's head, its base branch's head, and its failing checks have been unchanged for this many seconds. Until then the PR record shows `repair.status: waiting` with `heldBy: settle` and `until`. |
|
|
135
|
+
| `repairSettleSecs` | `600` | A repair is queued only after the PR's head, its base branch's head, and its failing checks have been unchanged for this many seconds. A conflict repair also waits this many seconds after the PR's latest approval. Until then the PR record shows `repair.status: waiting` with `heldBy: settle` and `until`. |
|
|
136
|
+
| `maxRepairsWithoutProgress` | `2` | Maximum repairs in a row on one PR that end `done` while the PR stays at the head the repair pushed and does not merge. Past the cap, lobstah stops repairing that PR: the PR record shows `repair.status: gave-up`, `pr:conflict` (or `pr:checks`, `pr:review`) attention names the cap, and one `repair-stopped` notice is posted. The count resets when the PR merges or closes, when a push that is not the repair's moves the head, and on `lobstah watch release <key>` (or `--all`). |
|
|
117
137
|
| `repairTrapWaitSecs` | `600` | A daemon repair chore addressed to the PR-owning trap waits this many seconds for that trap. If still queued, it becomes headless. A person's addressed work never falls back. |
|
|
118
138
|
|
|
119
139
|
## `[grounds.*]` — helm territories
|
package/docs/man.md
CHANGED
|
@@ -192,20 +192,47 @@ with `paused --waiting-on pr` or `--waiting-on review` merges, the daemon
|
|
|
192
192
|
finishes that dispatch `done` with the note `the PR merged: <url>`. When
|
|
193
193
|
the PR closes without merge, it finishes it `failed` with `the PR closed
|
|
194
194
|
without merge: <url>`. The PR waited on is the `--link` when it names a
|
|
195
|
-
GitHub PR
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
`
|
|
200
|
-
|
|
195
|
+
GitHub PR that is not one of the dispatch's own. Otherwise the dispatch
|
|
196
|
+
waits on all of its own PRs (its evidence, which `lobstah catch` shows),
|
|
197
|
+
else its chain's PR, else the PRs of the `pr:` watches it owns. A
|
|
198
|
+
dispatch with several PRs finishes only when every one of them has merged
|
|
199
|
+
or closed: `done` when at least one merged (`the PRs merged: <urls>`, or
|
|
200
|
+
`the PRs ended — merged: <urls>; closed without merge: <urls>`), `failed`
|
|
201
|
+
when all closed without merge. Every paused dispatch waiting on those PRs
|
|
202
|
+
is finished, in the whole chain. The daemon does this after it observes PR watches and before its
|
|
203
|
+
cull pass, so `[limits].releaseOnMerge` releases the chain's worktrees in
|
|
204
|
+
the same pass. `report paused --waiting-on pr|review` registers the watch
|
|
205
|
+
of the dispatch's own PR when it has none. The daemon also reads, at
|
|
206
|
+
`[pickup].pollSecs`, each waited-on PR that no live dispatch-owned watch
|
|
207
|
+
observes (a linked PR with no watch, or a helm-owned watch), so its merge
|
|
208
|
+
or close is seen without `lobstah pick`. When lobstah knows no PR for the
|
|
209
|
+
wait, the report prints a `warning` and a merge will not finish the
|
|
210
|
+
dispatch: report again with `--link <PR url>`.
|
|
201
211
|
|
|
202
212
|
### PR state after done
|
|
203
213
|
|
|
204
214
|
A dispatch reports its PR with `report <id> <verb> --pr <url>`, which
|
|
205
215
|
registers a `pr:` watch for the chain so the PR stays observed (see the
|
|
206
216
|
PR preset in [vocabulary.md](vocabulary.md#the-pr-preset); `--no-watch`
|
|
207
|
-
opts out). A trap's PR is tracked from its first push.
|
|
208
|
-
|
|
217
|
+
opts out). A trap's PR is tracked from its first push.
|
|
218
|
+
|
|
219
|
+
A dispatch can own several PRs. `--pr` is repeatable: each PR is recorded
|
|
220
|
+
and gets its own `pr:` watch. The first is the dispatch's `prUrl`; all of
|
|
221
|
+
them are its `prUrls`. When a reported PR is part of a stack, the other PRs
|
|
222
|
+
of the stack are recorded too:
|
|
223
|
+
|
|
224
|
+
- a gh stack: the `gh-stack` state in the dispatch's checkout (from
|
|
225
|
+
`gh stack init` or `gh stack submit`) lists a stack that holds the PR;
|
|
226
|
+
- a base chain: the dispatch pushed two or more branches, and
|
|
227
|
+
`gh pr view <branch>` links their PRs to the reported one by base
|
|
228
|
+
branch.
|
|
229
|
+
|
|
230
|
+
The report prints `prs` with every PR it recorded. `lobstah catch` and
|
|
231
|
+
`lobstah status <id>` list each PR (`prs`), `man tend` lists each with its
|
|
232
|
+
state in the story's `pr` column, and the glass links each by number. Each
|
|
233
|
+
PR has its own record, so `pr:*` attention and the merged/closed notice are
|
|
234
|
+
per PR. `[limits].releaseOnMerge` keeps the worktree while another PR of
|
|
235
|
+
the dispatch is open. What you get depends on what runs:
|
|
209
236
|
|
|
210
237
|
- **Only the helm park or `man wait`** (no service): PR state badges in
|
|
211
238
|
`man tend`, `lobstah catch`, and the glass (`merged`, `draft`, `review`,
|
|
@@ -223,16 +250,21 @@ succeeds and the watch's check records `lastError`.
|
|
|
223
250
|
|
|
224
251
|
Which commands register a watch. Only these write points register one:
|
|
225
252
|
|
|
226
|
-
- `lobstah report <id> <verb> --pr <url
|
|
227
|
-
|
|
228
|
-
|
|
253
|
+
- `lobstah report <id> <verb> --pr <url> [--pr <url> ...]` registers the
|
|
254
|
+
watch for each PR the worker opened, and for the other PRs of a stack
|
|
255
|
+
that holds one of them. Any verb but `failed` does this. A PR already
|
|
256
|
+
watched is not registered again.
|
|
229
257
|
- `lobstah report <id> paused --waiting-on pr|review` registers the watch
|
|
230
|
-
for the dispatch's own PR. A `--link` to another PR registers nothing
|
|
258
|
+
for the dispatch's own PR. A `--link` to another PR registers nothing; the
|
|
259
|
+
daemon reads that PR while the dispatch waits on it.
|
|
231
260
|
- `lobstah soak beat` registers the watch for a trap's PR from its first
|
|
232
261
|
push. At most once a minute, it reads the trap's branch. When the branch
|
|
233
|
-
is not trunk
|
|
234
|
-
|
|
235
|
-
|
|
262
|
+
is not trunk, has an upstream, and its head commit is newer than the
|
|
263
|
+
dispatch's claim, it asks `gh pr view <branch>` for the PR. A trap that
|
|
264
|
+
takes new work on the branch of its last dispatch does not give that PR
|
|
265
|
+
to the new dispatch until the new dispatch commits. It records a new PR in
|
|
266
|
+
the dispatch's evidence, where `lobstah catch` and `lobstah status <id>`
|
|
267
|
+
show it.
|
|
236
268
|
- `lobstah watch add <key>` registers the watch you name.
|
|
237
269
|
- `lobstah watch backfill --apply` registers watches for PRs in old dispatch
|
|
238
270
|
history, and fetches the title of each PR record that has none (one
|
|
@@ -304,6 +336,8 @@ and the glass show the repair's PR, chore lane, worker, and trap wait.
|
|
|
304
336
|
PR whose head branch is its base branch.
|
|
305
337
|
- The PR's head, its base branch's head, or its failing checks changed less
|
|
306
338
|
than `[watch].repairSettleSecs` ago (default 600).
|
|
339
|
+
- For a conflict repair: the PR was approved less than
|
|
340
|
+
`[watch].repairSettleSecs` ago.
|
|
307
341
|
- For a checks repair: a fresh read of the latest run of each failing check
|
|
308
342
|
shows that run in progress or passed.
|
|
309
343
|
- The PR's watch is held. `lobstah cancel` on a repair dispatch holds its
|
|
@@ -322,13 +356,29 @@ in the `repairs waiting` table, the PR badge ends in `repair waits: <heldBy>`,
|
|
|
322
356
|
the glass PR modal shows the reason, and `lobstah doctor` lists it. When the
|
|
323
357
|
wait ends, the normal rules apply again.
|
|
324
358
|
|
|
359
|
+
**Repairs stop** on a PR after `[watch].maxRepairsWithoutProgress`
|
|
360
|
+
(default 2) repairs in a row that each ended `done` while the PR stayed at
|
|
361
|
+
the head the repair pushed and did not merge. lobstah then queues no repair
|
|
362
|
+
for that PR, marks its repair `gave-up`, raises `pr:conflict` (or
|
|
363
|
+
`pr:checks`, `pr:review`) attention that names the cap, and posts one
|
|
364
|
+
`repair-stopped` notice. The count resets when the PR merges or closes,
|
|
365
|
+
when a push that is not the repair's moves the head, and on
|
|
366
|
+
`lobstah watch release <key>`, which prints `repairsResumed` with the PRs it
|
|
367
|
+
resumed.
|
|
368
|
+
|
|
325
369
|
**A repair pushes** to its PR's head branch, and so does a rebase chore
|
|
326
370
|
from pickup's merge loop. The descriptor of each names its PR (`pr`), so
|
|
327
371
|
the runner pushes no branch and opens no PR for it. The brief gives the
|
|
328
|
-
worker the push rule: push only to the PR's head branch.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
372
|
+
worker the push rule: push only to the PR's head branch. A conflict
|
|
373
|
+
repair of a **standalone** PR (its base is the repo's `trunk`) merges the
|
|
374
|
+
base into the PR branch with a merge commit and pushes normally; on a
|
|
375
|
+
non-fast-forward rejection it fetches the moved head, merges it, and
|
|
376
|
+
pushes again, never with force. A conflict repair of a **stacked** PR (its
|
|
377
|
+
base is another PR's branch) rebases onto that base and pushes with
|
|
378
|
+
`--force-with-lease`; on a rejection it rebases onto the moved head again
|
|
379
|
+
and pushes with a lease on the head just fetched. A checks or review
|
|
380
|
+
repair follows the rebase rule on a rejection. Each retries at most three
|
|
381
|
+
times. A push hook that fails with a real test or
|
|
332
382
|
type error is not retried: the worker fixes the error and pushes again.
|
|
333
383
|
For code already on main, the repair keeps main's version and only this
|
|
334
384
|
PR's own changes. It does not change behavior. If a conflict resolution
|
|
@@ -509,7 +559,9 @@ the same rule.
|
|
|
509
559
|
`attachments/`. The claimed helm alone may ask.
|
|
510
560
|
- **Standing.** A decision stands until it is answered or the helm withdraws
|
|
511
561
|
it with `lobstah man ask --withdraw <key>`, which removes its directory. A
|
|
512
|
-
newer `man ask` on the same dispatch
|
|
562
|
+
newer `man ask` on the same dispatch keeps the older decisions: each shows
|
|
563
|
+
as its own card. `lobstah man ask ... --replace <key>` replaces that one
|
|
564
|
+
standing decision; a key that is not standing is refused.
|
|
513
565
|
- **Attention.** A standing decision is attention of kind `decision`, in
|
|
514
566
|
the default `attentionKinds`. It makes the verdict `needs-attention`. A
|
|
515
567
|
worker's raw `needs-decision` or `blocked` stays a `question` until the
|