lobstah 0.5.5 → 0.5.7
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 +1282 -736
- package/dist/runner.js +308 -72
- package/docs/configuration.md +1 -1
- package/docs/design.md +14 -0
- package/docs/lobsterman.md +19 -83
- package/docs/vocabulary.md +24 -6
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// packages/runner/src/index.ts
|
|
2
|
-
import * as
|
|
3
|
-
import * as
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
+
import * as fs13 from "node:fs";
|
|
3
|
+
import * as path11 from "node:path";
|
|
5
4
|
|
|
6
5
|
// packages/core/dist/types.js
|
|
7
6
|
var VERBS = ["working", "needs-decision", "blocked", "paused", "done", "failed"];
|
|
@@ -26,6 +25,9 @@ function laneDirs(lane) {
|
|
|
26
25
|
inbox: path.join(root, "inbox")
|
|
27
26
|
};
|
|
28
27
|
}
|
|
28
|
+
function soakingDir() {
|
|
29
|
+
return path.join(lobstahHome(), "soaking");
|
|
30
|
+
}
|
|
29
31
|
function statusPath(id, lane) {
|
|
30
32
|
return path.join(laneDirs(lane).state, `${id}.status`);
|
|
31
33
|
}
|
|
@@ -80,6 +82,20 @@ function readStatusLog(id, lane) {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
// packages/core/dist/queue.js
|
|
85
|
+
function storedDescriptor(id, lane) {
|
|
86
|
+
const dirs = laneDirs(lane);
|
|
87
|
+
for (const file of [
|
|
88
|
+
path2.join(dirs.queue, `${id}.json`),
|
|
89
|
+
path2.join(dirs.active, id, "descriptor.json"),
|
|
90
|
+
path2.join(dirs.done, id, "descriptor.json")
|
|
91
|
+
]) {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
94
|
+
} catch {
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
83
99
|
function complete(id, lane) {
|
|
84
100
|
const dirs = laneDirs(lane);
|
|
85
101
|
fs2.renameSync(path2.join(dirs.active, id), path2.join(dirs.done, id));
|
|
@@ -794,8 +810,8 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
|
|
|
794
810
|
}
|
|
795
811
|
|
|
796
812
|
// packages/core/dist/config.js
|
|
797
|
-
var ATTENTION_KINDS = ["question", "landed", "pr:draft", "pr:review", "pr:checks", "pr:ready"];
|
|
798
|
-
var DEFAULT_ATTENTION_KINDS = ["question", "pr:draft", "pr:review", "pr:checks", "pr:ready"];
|
|
813
|
+
var ATTENTION_KINDS = ["question", "landed", "pr:draft", "pr:review", "pr:checks", "pr:conflict", "pr:ready"];
|
|
814
|
+
var DEFAULT_ATTENTION_KINDS = ["question", "pr:draft", "pr:review", "pr:checks", "pr:conflict", "pr:ready"];
|
|
799
815
|
function parseAttentionKinds(raw) {
|
|
800
816
|
if (raw === void 0)
|
|
801
817
|
return [...DEFAULT_ATTENTION_KINDS];
|
|
@@ -928,12 +944,127 @@ function codexInvocation(argv) {
|
|
|
928
944
|
return void 0;
|
|
929
945
|
}
|
|
930
946
|
|
|
947
|
+
// packages/core/dist/soak.js
|
|
948
|
+
import * as fs8 from "node:fs";
|
|
949
|
+
import * as path6 from "node:path";
|
|
950
|
+
function regPath(trapId) {
|
|
951
|
+
return path6.join(soakingDir(), `${trapId}.json`);
|
|
952
|
+
}
|
|
953
|
+
function readTrap(trapId) {
|
|
954
|
+
try {
|
|
955
|
+
return JSON.parse(fs8.readFileSync(regPath(trapId), "utf8"));
|
|
956
|
+
} catch {
|
|
957
|
+
return void 0;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
931
961
|
// packages/core/dist/compiled.js
|
|
932
962
|
var COMPILED_BINARY = (() => {
|
|
933
963
|
const bun = globalThis.Bun;
|
|
934
964
|
return bun !== void 0 && String(bun.main ?? "").includes("$bunfs");
|
|
935
965
|
})();
|
|
936
966
|
|
|
967
|
+
// packages/core/dist/resume.js
|
|
968
|
+
import * as fs9 from "node:fs";
|
|
969
|
+
import * as path7 from "node:path";
|
|
970
|
+
import { spawnSync } from "node:child_process";
|
|
971
|
+
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;
|
|
972
|
+
function harnessFromSessionId(sessionId) {
|
|
973
|
+
const version = sessionId ? UUID.exec(sessionId)?.[1] : void 0;
|
|
974
|
+
return version === "7" ? "codex" : version === "4" ? "claude" : void 0;
|
|
975
|
+
}
|
|
976
|
+
function claimOf(id, lane) {
|
|
977
|
+
const dirs = laneDirs(lane);
|
|
978
|
+
for (const dir of [dirs.active, dirs.done]) {
|
|
979
|
+
try {
|
|
980
|
+
return JSON.parse(fs9.readFileSync(path7.join(dir, id, "claim.json"), "utf8"));
|
|
981
|
+
} catch {
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return void 0;
|
|
985
|
+
}
|
|
986
|
+
function laneOf(id) {
|
|
987
|
+
for (const lane of ["work", "chore"]) {
|
|
988
|
+
if (storedDescriptor(id, lane) || readEvidence(id, lane).sessionId)
|
|
989
|
+
return lane;
|
|
990
|
+
}
|
|
991
|
+
return void 0;
|
|
992
|
+
}
|
|
993
|
+
function resolveSessionHarness(id, cfg, laneHint) {
|
|
994
|
+
const lane = laneHint ?? laneOf(id);
|
|
995
|
+
if (!lane)
|
|
996
|
+
return {};
|
|
997
|
+
const ev = readEvidence(id, lane);
|
|
998
|
+
const sessionId = ev.sessionId;
|
|
999
|
+
const out = (harness, source) => ({
|
|
1000
|
+
sessionId,
|
|
1001
|
+
harness,
|
|
1002
|
+
source,
|
|
1003
|
+
lane
|
|
1004
|
+
});
|
|
1005
|
+
if (ev.harness)
|
|
1006
|
+
return out(ev.harness, "evidence");
|
|
1007
|
+
const claim = claimOf(id, lane);
|
|
1008
|
+
if (claim?.harness)
|
|
1009
|
+
return out(claim.harness, "trap");
|
|
1010
|
+
if (ev.deliveredTo?.startsWith("wt:")) {
|
|
1011
|
+
const reg = readTrap(ev.deliveredTo.slice("wt:".length));
|
|
1012
|
+
if (reg?.harness)
|
|
1013
|
+
return out(reg.harness, "trap");
|
|
1014
|
+
}
|
|
1015
|
+
const byId = harnessFromSessionId(sessionId);
|
|
1016
|
+
if (byId)
|
|
1017
|
+
return out(byId, "session-id");
|
|
1018
|
+
const d = storedDescriptor(id, lane);
|
|
1019
|
+
const repo = d && cfg?.repos[d.repo];
|
|
1020
|
+
const fromDescriptor = d?.harness ?? repo?.harness?.default ?? cfg?.harness.default;
|
|
1021
|
+
if (fromDescriptor)
|
|
1022
|
+
return out(fromDescriptor, "descriptor");
|
|
1023
|
+
return { sessionId, lane };
|
|
1024
|
+
}
|
|
1025
|
+
var UNRESUMABLE = /no conversation found|no rollout found|(session|thread|conversation)( id)?[^.\n]{0,80}\b(not found|does not exist|unknown|invalid)|(could not|cannot|failed to|unable to) (find|load|resume)[^.\n]{0,40}\b(session|thread|conversation)/i;
|
|
1026
|
+
function isUnresumable(error) {
|
|
1027
|
+
return error !== void 0 && UNRESUMABLE.test(error);
|
|
1028
|
+
}
|
|
1029
|
+
function worktreeProgress(worktree, trunk) {
|
|
1030
|
+
if (!worktree || !fs9.existsSync(worktree))
|
|
1031
|
+
return "No worktree progress recorded.";
|
|
1032
|
+
const git2 = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
1033
|
+
const commits = trunk ? git2("log", "--oneline", `origin/${trunk}..HEAD`) : git2("log", "--oneline", "-15");
|
|
1034
|
+
const status = git2("status", "--short");
|
|
1035
|
+
return `Commits so far:
|
|
1036
|
+
${commits || "(none)"}
|
|
1037
|
+
|
|
1038
|
+
Uncommitted changes:
|
|
1039
|
+
${status || "(clean)"}`;
|
|
1040
|
+
}
|
|
1041
|
+
function originProgress(originId, lane) {
|
|
1042
|
+
const ev = readEvidence(originId, lane);
|
|
1043
|
+
const last = readStatusLog(originId, lane).at(-1);
|
|
1044
|
+
const lines = [`The earlier dispatch ${originId} left:`];
|
|
1045
|
+
if (ev.branch)
|
|
1046
|
+
lines.push(`- branch: ${ev.branch}`);
|
|
1047
|
+
if (ev.prUrl)
|
|
1048
|
+
lines.push(`- PR: ${ev.prUrl}`);
|
|
1049
|
+
if (last)
|
|
1050
|
+
lines.push(`- last status: ${last.verb}${last.note ? ` \u2014 ${last.note}` : ""}`);
|
|
1051
|
+
if (ev.commits?.length)
|
|
1052
|
+
lines.push("- commits:", ...ev.commits.map((c) => ` ${c}`));
|
|
1053
|
+
if (lines.length === 1)
|
|
1054
|
+
lines.push("- (no recorded branch, PR, or commits)");
|
|
1055
|
+
return lines.join("\n");
|
|
1056
|
+
}
|
|
1057
|
+
function handoffNote(fromHarness, progress, why) {
|
|
1058
|
+
return `You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available${why ? ` (${why})` : ""} \u2014 the state below is the ground truth. Review it, then continue the brief from where it stops.
|
|
1059
|
+
|
|
1060
|
+
${progress}`;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// packages/runner/src/run.ts
|
|
1064
|
+
import * as fs12 from "node:fs";
|
|
1065
|
+
import * as path10 from "node:path";
|
|
1066
|
+
import { fileURLToPath } from "node:url";
|
|
1067
|
+
|
|
937
1068
|
// packages/adapters/dist/claude.js
|
|
938
1069
|
import { spawn } from "node:child_process";
|
|
939
1070
|
|
|
@@ -1212,25 +1343,25 @@ function createClaudeAdapter() {
|
|
|
1212
1343
|
}
|
|
1213
1344
|
|
|
1214
1345
|
// packages/adapters/dist/codex.js
|
|
1215
|
-
import * as
|
|
1346
|
+
import * as fs10 from "node:fs";
|
|
1216
1347
|
import * as os3 from "node:os";
|
|
1217
|
-
import * as
|
|
1348
|
+
import * as path8 from "node:path";
|
|
1218
1349
|
import { spawn as spawn2 } from "node:child_process";
|
|
1219
1350
|
function isolatedCodexHome() {
|
|
1220
|
-
const dir =
|
|
1221
|
-
|
|
1222
|
-
const auth =
|
|
1223
|
-
const realAuth =
|
|
1224
|
-
if (!
|
|
1351
|
+
const dir = path8.join(lobstahHome(), "codex-home");
|
|
1352
|
+
fs10.mkdirSync(dir, { recursive: true });
|
|
1353
|
+
const auth = path8.join(dir, "auth.json");
|
|
1354
|
+
const realAuth = path8.join(os3.homedir(), ".codex", "auth.json");
|
|
1355
|
+
if (!fs10.existsSync(auth) && fs10.existsSync(realAuth)) {
|
|
1225
1356
|
try {
|
|
1226
|
-
|
|
1357
|
+
fs10.symlinkSync(realAuth, auth);
|
|
1227
1358
|
} catch {
|
|
1228
|
-
|
|
1359
|
+
fs10.copyFileSync(realAuth, auth);
|
|
1229
1360
|
}
|
|
1230
1361
|
}
|
|
1231
|
-
const cfg =
|
|
1232
|
-
if (!
|
|
1233
|
-
|
|
1362
|
+
const cfg = path8.join(dir, "config.toml");
|
|
1363
|
+
if (!fs10.existsSync(cfg)) {
|
|
1364
|
+
fs10.writeFileSync(cfg, "# lobstah-managed: auth inherited from ~/.codex, interactive config not\n");
|
|
1234
1365
|
}
|
|
1235
1366
|
return dir;
|
|
1236
1367
|
}
|
|
@@ -1427,14 +1558,14 @@ function loadAdapter(name) {
|
|
|
1427
1558
|
}
|
|
1428
1559
|
|
|
1429
1560
|
// packages/worktree/dist/index.js
|
|
1430
|
-
import * as
|
|
1431
|
-
import * as
|
|
1561
|
+
import * as fs11 from "node:fs";
|
|
1562
|
+
import * as path9 from "node:path";
|
|
1432
1563
|
import { exec, execFile } from "node:child_process";
|
|
1433
1564
|
import { promisify } from "node:util";
|
|
1434
1565
|
var run = promisify(execFile);
|
|
1435
1566
|
var shell = promisify(exec);
|
|
1436
1567
|
function worktreePath(id) {
|
|
1437
|
-
return
|
|
1568
|
+
return path9.join(lobstahHome(), "worktrees", id);
|
|
1438
1569
|
}
|
|
1439
1570
|
async function git(cwd, ...args) {
|
|
1440
1571
|
const { stdout } = await run("git", args, { cwd, env: process.env });
|
|
@@ -1442,10 +1573,10 @@ async function git(cwd, ...args) {
|
|
|
1442
1573
|
}
|
|
1443
1574
|
async function allocate(repo, id) {
|
|
1444
1575
|
const dir = worktreePath(id);
|
|
1445
|
-
if (
|
|
1576
|
+
if (fs11.existsSync(dir)) {
|
|
1446
1577
|
throw new Error(`worktree for ${id} already exists at ${dir} \u2014 never allocate a second`);
|
|
1447
1578
|
}
|
|
1448
|
-
if (!
|
|
1579
|
+
if (!fs11.existsSync(repo.path)) {
|
|
1449
1580
|
if (!repo.origin)
|
|
1450
1581
|
throw new Error(`repo path ${repo.path} missing and no origin configured`);
|
|
1451
1582
|
await run("git", ["clone", repo.origin, repo.path], { env: process.env });
|
|
@@ -1495,6 +1626,7 @@ function deliver(run2, id, lane) {
|
|
|
1495
1626
|
async function drive(run2, opts) {
|
|
1496
1627
|
const { id, lane, pollMs = 3e3, stopped = () => false } = opts;
|
|
1497
1628
|
let cancelled = false;
|
|
1629
|
+
let activity = 0;
|
|
1498
1630
|
let finished = false;
|
|
1499
1631
|
void run2.done.then(() => finished = true);
|
|
1500
1632
|
const cancel = () => {
|
|
@@ -1517,6 +1649,7 @@ async function drive(run2, opts) {
|
|
|
1517
1649
|
};
|
|
1518
1650
|
for await (const ev of run2.events) {
|
|
1519
1651
|
appendEvent(id, lane, ev);
|
|
1652
|
+
if (ev.type === "tool-start" || ev.type === "text") activity++;
|
|
1520
1653
|
if (ev.type === "session" && ev.data?.sessionId) {
|
|
1521
1654
|
mergeEvidence(id, lane, { sessionId: String(ev.data.sessionId) });
|
|
1522
1655
|
}
|
|
@@ -1537,7 +1670,7 @@ async function drive(run2, opts) {
|
|
|
1537
1670
|
}
|
|
1538
1671
|
run2.end();
|
|
1539
1672
|
}
|
|
1540
|
-
return { cancelled };
|
|
1673
|
+
return { cancelled, activity };
|
|
1541
1674
|
}
|
|
1542
1675
|
function settle(id, lane, r) {
|
|
1543
1676
|
const lastVerb = readStatusLog(id, lane).at(-1)?.verb;
|
|
@@ -1547,74 +1680,174 @@ function settle(id, lane, r) {
|
|
|
1547
1680
|
else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb)) appendStatus(id, lane, "done");
|
|
1548
1681
|
}
|
|
1549
1682
|
|
|
1550
|
-
// packages/runner/src/
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1683
|
+
// packages/runner/src/plan.ts
|
|
1684
|
+
var short = (s) => s.slice(0, 8);
|
|
1685
|
+
function askedHarness(id, lane, cfg) {
|
|
1686
|
+
const d = storedDescriptor(id, lane);
|
|
1687
|
+
return d?.harness ?? (d ? cfg.repos[d.repo]?.harness?.default : void 0) ?? cfg.harness.default ?? "claude";
|
|
1688
|
+
}
|
|
1689
|
+
function planStart(input) {
|
|
1690
|
+
const { id, lane, descriptor, cfg, resolvedHarness, envResume } = input;
|
|
1691
|
+
if (envResume) {
|
|
1692
|
+
const own = resolveSessionHarness(id, cfg, lane);
|
|
1693
|
+
const harness = (own.sessionId === envResume ? own.harness : harnessFromSessionId(envResume)) ?? resolvedHarness;
|
|
1694
|
+
return {
|
|
1695
|
+
harness,
|
|
1696
|
+
resume: { sessionId: envResume, own: true },
|
|
1697
|
+
note: harness !== resolvedHarness ? `resuming own ${harness} session ${short(envResume)}` : void 0
|
|
1698
|
+
};
|
|
1555
1699
|
}
|
|
1556
|
-
|
|
1700
|
+
const prior = readEvidence(id, lane).sessionId;
|
|
1701
|
+
if (prior) {
|
|
1702
|
+
const from = resolveSessionHarness(id, cfg, lane).harness;
|
|
1703
|
+
return {
|
|
1704
|
+
harness: resolvedHarness,
|
|
1705
|
+
cold: { why: `session ${short(prior)} is not resumed on a respawn`, fromHarness: from },
|
|
1706
|
+
note: `respawn: starting cold on ${resolvedHarness}${from ? ` (previous session: ${from})` : ""}`
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
if (!descriptor.followUp) return { harness: resolvedHarness };
|
|
1710
|
+
const originId = descriptor.followUp;
|
|
1711
|
+
const origin = resolveSessionHarness(originId, cfg);
|
|
1712
|
+
const originLane = origin.lane ?? lane;
|
|
1713
|
+
const originRef = { id: originId, lane: originLane };
|
|
1714
|
+
if (!origin.sessionId) {
|
|
1715
|
+
return {
|
|
1716
|
+
harness: resolvedHarness,
|
|
1717
|
+
cold: { why: `follow-up origin ${short(originId)} has no recorded session`, origin: originRef },
|
|
1718
|
+
note: `follow-up of ${short(originId)}: no session recorded \u2014 starting cold on ${resolvedHarness} with a progress note`
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
if (!origin.harness) {
|
|
1722
|
+
return {
|
|
1723
|
+
harness: resolvedHarness,
|
|
1724
|
+
resume: { sessionId: origin.sessionId, own: false, origin: originRef },
|
|
1725
|
+
note: `follow-up of ${short(originId)}: resuming session ${short(origin.sessionId)} on ${resolvedHarness} (origin harness unknown)`
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
const requested = descriptor.harness;
|
|
1729
|
+
const swap = requested !== void 0 && requested !== origin.harness && requested !== askedHarness(originId, originLane, cfg);
|
|
1730
|
+
if (swap) {
|
|
1731
|
+
return {
|
|
1732
|
+
harness: requested,
|
|
1733
|
+
cold: { why: `swap from ${origin.harness} to ${requested}`, fromHarness: origin.harness, origin: originRef },
|
|
1734
|
+
note: `follow-up of ${short(originId)}: swap \u2014 origin session is ${origin.harness}, ${requested} requested; starting cold on ${requested} with a progress note`
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
const ignored = requested !== void 0 && requested !== origin.harness ? ` (--harness ${requested} ignored for the resume)` : "";
|
|
1738
|
+
return {
|
|
1739
|
+
harness: origin.harness,
|
|
1740
|
+
resume: { sessionId: origin.sessionId, own: false, origin: originRef },
|
|
1741
|
+
note: `follow-up of ${short(originId)}: resuming ${origin.harness} session ${short(origin.sessionId)} (harness from ${origin.source})${ignored}`
|
|
1742
|
+
};
|
|
1557
1743
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1744
|
+
|
|
1745
|
+
// packages/runner/src/run.ts
|
|
1746
|
+
var defaultDeps = { loadAdapter, allocate, collectEvidence };
|
|
1747
|
+
function coldNote(cold, cwd, trunk) {
|
|
1748
|
+
const parts = [worktreeProgress(cwd, trunk)];
|
|
1749
|
+
if (cold.origin) parts.push(originProgress(cold.origin.id, cold.origin.lane));
|
|
1750
|
+
return handoffNote(cold.fromHarness ?? "unknown", parts.join("\n\n"), cold.why);
|
|
1751
|
+
}
|
|
1752
|
+
async function main(activeDir, lane, deps = defaultDeps) {
|
|
1753
|
+
const id = path10.basename(activeDir);
|
|
1560
1754
|
const status = (verb, note) => appendStatus(id, lane, verb, note);
|
|
1561
1755
|
const cfg = loadConfig();
|
|
1562
|
-
const descriptor = JSON.parse(
|
|
1756
|
+
const descriptor = JSON.parse(fs12.readFileSync(path10.join(activeDir, "descriptor.json"), "utf8"));
|
|
1563
1757
|
const resolved = resolveDispatch(descriptor, cfg);
|
|
1564
1758
|
const repo = cfg.repos[descriptor.repo];
|
|
1565
|
-
const briefFile =
|
|
1566
|
-
if (!
|
|
1567
|
-
const brief =
|
|
1759
|
+
const briefFile = path10.join(activeDir, "brief.md");
|
|
1760
|
+
if (!fs12.existsSync(briefFile)) fs12.writeFileSync(briefFile, descriptor.brief);
|
|
1761
|
+
const brief = fs12.readFileSync(briefFile, "utf8");
|
|
1568
1762
|
const attempts = Number(process.env.LOBSTAH_ATTEMPTS ?? "1");
|
|
1569
1763
|
const runnerInfo = {
|
|
1570
1764
|
pid: process.pid,
|
|
1571
1765
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1572
1766
|
attempts
|
|
1573
1767
|
};
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1768
|
+
fs12.writeFileSync(path10.join(activeDir, "runner.json"), JSON.stringify(runnerInfo, null, 2));
|
|
1769
|
+
const plan = planStart({
|
|
1770
|
+
id,
|
|
1771
|
+
lane,
|
|
1772
|
+
descriptor,
|
|
1773
|
+
cfg,
|
|
1774
|
+
resolvedHarness: resolved.harness,
|
|
1775
|
+
envResume: process.env.LOBSTAH_RESUME
|
|
1776
|
+
});
|
|
1777
|
+
const firstNote = [attempts > 1 ? `attempt ${attempts}` : void 0, plan.note].filter(Boolean).join("; ");
|
|
1778
|
+
status("working", firstNote || void 0);
|
|
1779
|
+
appendEvent(id, lane, {
|
|
1780
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1781
|
+
type: "runner",
|
|
1782
|
+
data: { pid: process.pid, attempts, harness: plan.harness, ...plan.resume ? { resume: plan.resume.sessionId } : {} }
|
|
1783
|
+
});
|
|
1784
|
+
const own = readEvidence(id, lane).sessionId;
|
|
1785
|
+
mergeEvidence(id, lane, { harness: plan.harness, sessionId: plan.resume?.own ? own : void 0 });
|
|
1786
|
+
const wtFile = path10.join(activeDir, "worktree.json");
|
|
1578
1787
|
let cwd;
|
|
1579
|
-
if (
|
|
1580
|
-
cwd = JSON.parse(
|
|
1581
|
-
if (!
|
|
1582
|
-
} else if (
|
|
1788
|
+
if (fs12.existsSync(wtFile)) {
|
|
1789
|
+
cwd = JSON.parse(fs12.readFileSync(wtFile, "utf8")).path;
|
|
1790
|
+
if (!fs12.existsSync(cwd)) throw new Error(`recorded worktree ${cwd} is gone`);
|
|
1791
|
+
} else if (fs12.existsSync(worktreePath(id))) {
|
|
1583
1792
|
throw new Error(`unrecorded worktree already exists for ${id} \u2014 refusing to proceed`);
|
|
1584
1793
|
} else {
|
|
1585
|
-
cwd = await allocate(repo, id);
|
|
1586
|
-
|
|
1587
|
-
}
|
|
1588
|
-
const
|
|
1589
|
-
const
|
|
1590
|
-
const
|
|
1591
|
-
const
|
|
1592
|
-
const
|
|
1593
|
-
const
|
|
1594
|
-
id,
|
|
1595
|
-
cwd,
|
|
1596
|
-
prompt,
|
|
1597
|
-
model: resolved.model,
|
|
1598
|
-
effort: resolved.effort,
|
|
1599
|
-
limits: resolved.limits,
|
|
1600
|
-
env: {
|
|
1601
|
-
...resolved.env,
|
|
1602
|
-
LOBSTAH_HOME: lobstahHome(),
|
|
1603
|
-
...workerPath ? { PATH: workerPath } : {}
|
|
1604
|
-
},
|
|
1605
|
-
flags: resolved.flags,
|
|
1606
|
-
resumeSession
|
|
1607
|
-
});
|
|
1794
|
+
cwd = await deps.allocate(repo, id);
|
|
1795
|
+
fs12.writeFileSync(wtFile, JSON.stringify({ path: cwd }, null, 2));
|
|
1796
|
+
}
|
|
1797
|
+
const adapter = deps.loadAdapter(plan.harness);
|
|
1798
|
+
const envNudge = process.env.LOBSTAH_NUDGE;
|
|
1799
|
+
const planNudge = plan.cold && !envNudge ? coldNote(plan.cold, cwd, repo.trunk) : void 0;
|
|
1800
|
+
const promptWith = (nudge) => buildPrompt(brief, { id, nudge, attachments: descriptor.attachments });
|
|
1801
|
+
const binDir = path10.resolve(path10.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "bin");
|
|
1802
|
+
const workerPath = fs12.existsSync(path10.join(binDir, "lobstah")) ? `${binDir}${path10.delimiter}${process.env.PATH ?? ""}` : void 0;
|
|
1608
1803
|
let wallClockHit = false;
|
|
1804
|
+
let current;
|
|
1609
1805
|
const wallTimer = resolved.limits.wallClockSecs ? setTimeout(() => {
|
|
1610
1806
|
wallClockHit = true;
|
|
1611
|
-
|
|
1807
|
+
current?.kill();
|
|
1612
1808
|
}, resolved.limits.wallClockSecs * 1e3) : void 0;
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1809
|
+
const runOnce = async (prompt, resumeSession) => {
|
|
1810
|
+
const run2 = await adapter.start({
|
|
1811
|
+
id,
|
|
1812
|
+
cwd,
|
|
1813
|
+
prompt,
|
|
1814
|
+
model: resolved.model,
|
|
1815
|
+
effort: resolved.effort,
|
|
1816
|
+
limits: resolved.limits,
|
|
1817
|
+
env: {
|
|
1818
|
+
...resolved.env,
|
|
1819
|
+
LOBSTAH_HOME: lobstahHome(),
|
|
1820
|
+
...workerPath ? { PATH: workerPath } : {}
|
|
1821
|
+
},
|
|
1822
|
+
flags: resolved.flags,
|
|
1823
|
+
resumeSession
|
|
1824
|
+
});
|
|
1825
|
+
current = run2;
|
|
1826
|
+
const { cancelled: cancelled2, activity } = await drive(run2, { id, lane, stopped: () => wallClockHit });
|
|
1827
|
+
const result2 = await run2.done;
|
|
1828
|
+
return { cancelled: cancelled2, activity, result: result2 };
|
|
1829
|
+
};
|
|
1830
|
+
let outcome = await runOnce(promptWith(envNudge ?? planNudge), plan.resume?.sessionId);
|
|
1831
|
+
if (plan.resume && !outcome.cancelled && !wallClockHit && outcome.activity === 0 && isUnresumable(outcome.result.error)) {
|
|
1832
|
+
const reason = outcome.result.error.replace(/\s+/g, " ").slice(0, 200);
|
|
1833
|
+
status("working", `resume-fallback: ${reason} \u2014 starting cold on ${plan.harness} with a progress note`);
|
|
1834
|
+
appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data: { resumeFallback: reason } });
|
|
1835
|
+
mergeEvidence(id, lane, { resumeFallback: reason, sessionId: void 0 });
|
|
1836
|
+
const note = coldNote(
|
|
1837
|
+
{
|
|
1838
|
+
why: `resume of session ${plan.resume.sessionId} failed`,
|
|
1839
|
+
fromHarness: plan.harness,
|
|
1840
|
+
...plan.resume.origin ? { origin: plan.resume.origin } : {}
|
|
1841
|
+
},
|
|
1842
|
+
cwd,
|
|
1843
|
+
repo.trunk
|
|
1844
|
+
);
|
|
1845
|
+
outcome = await runOnce(promptWith([envNudge, note].filter(Boolean).join("\n\n")), void 0);
|
|
1846
|
+
}
|
|
1615
1847
|
if (wallTimer) clearTimeout(wallTimer);
|
|
1848
|
+
const { cancelled, result } = outcome;
|
|
1616
1849
|
try {
|
|
1617
|
-
const gitEvidence = await collectEvidence(repo, id);
|
|
1850
|
+
const gitEvidence = await deps.collectEvidence(repo, id);
|
|
1618
1851
|
console.log(`[runner] git evidence: ${JSON.stringify(gitEvidence)}`);
|
|
1619
1852
|
mergeEvidence(id, lane, { ...gitEvidence, sessionId: result.sessionId ?? readEvidence(id, lane).sessionId });
|
|
1620
1853
|
console.log(`[runner] evidence after merge: ${JSON.stringify(readEvidence(id, lane))}`);
|
|
@@ -1626,13 +1859,15 @@ async function main(activeDir, lane) {
|
|
|
1626
1859
|
settle(id, lane, { cancelled, wallClockHit, error: result.error });
|
|
1627
1860
|
complete(id, lane);
|
|
1628
1861
|
}
|
|
1862
|
+
|
|
1863
|
+
// packages/runner/src/index.ts
|
|
1629
1864
|
function runRunner(activeDirArg2, laneArg2) {
|
|
1630
1865
|
const lane = laneArg2 === "chore" ? "chore" : "work";
|
|
1631
1866
|
main(activeDirArg2, lane).catch((err) => {
|
|
1632
|
-
const id =
|
|
1867
|
+
const id = path11.basename(activeDirArg2);
|
|
1633
1868
|
try {
|
|
1634
1869
|
appendStatus(id, lane, "failed", String(err instanceof Error ? err.message : err).slice(0, 500));
|
|
1635
|
-
|
|
1870
|
+
fs13.renameSync(activeDirArg2, path11.join(laneDirs(lane).done, id));
|
|
1636
1871
|
} catch {
|
|
1637
1872
|
}
|
|
1638
1873
|
process.exitCode = 1;
|
|
@@ -1642,6 +1877,7 @@ var [activeDirArg, laneArg] = process.argv.slice(2);
|
|
|
1642
1877
|
if (activeDirArg && activeDirArg !== "__runner") runRunner(activeDirArg, laneArg);
|
|
1643
1878
|
export {
|
|
1644
1879
|
main,
|
|
1880
|
+
planStart,
|
|
1645
1881
|
runRunner
|
|
1646
1882
|
};
|
|
1647
1883
|
/*! Bundled license information:
|
package/docs/configuration.md
CHANGED
|
@@ -14,7 +14,7 @@ keys of that section.
|
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `notifyCommand` | — | Exec'd by the daemon on wake-worthy status transitions with `LOBSTAH_ID`, `LOBSTAH_LANE`, `LOBSTAH_VERB`, `LOBSTAH_NOTE`, `LOBSTAH_AT` in the environment. Fire-and-forget; point it at ntfy, a Slack helper, anything. |
|
|
16
16
|
| `notifyVerbs` | `["needs-decision", "blocked", "done", "failed"]` | Which verbs fire `notifyCommand`. |
|
|
17
|
-
| `attentionKinds` | `["question", "pr:draft", "pr:review", "pr:checks", "pr:ready"]` | Which attention kinds `man tend` lists — and so what the desktop pet and the glass walk across the screen. Valid kinds: `question`, `landed` (opt-in), `pr:draft`, `pr:review`, `pr:checks`, `pr:ready`; an unknown kind is a config error naming the valid set. Notify is edge-triggered and fires once per transition; attention is level-triggered and stands until its clear condition ([vocabulary.md](vocabulary.md#attention-contract)). |
|
|
17
|
+
| `attentionKinds` | `["question", "pr:draft", "pr:review", "pr:checks", "pr:conflict", "pr:ready"]` | Which attention kinds `man tend` lists — and so what the desktop pet and the glass walk across the screen. Valid kinds: `question`, `landed` (opt-in), `pr:draft`, `pr:review`, `pr:checks`, `pr:conflict`, `pr:ready`; an unknown kind is a config error naming the valid set. Notify is edge-triggered and fires once per transition; attention is level-triggered and stands until its clear condition ([vocabulary.md](vocabulary.md#attention-contract)). |
|
|
18
18
|
| `remindSecs` | `900` | An unanswered `needs-decision`/`blocked` re-fires to `man wait`/`man haul` on this interval until answered. `0` = report once only. |
|
|
19
19
|
|
|
20
20
|
## `[repos.<key>]` — workspace definitions
|
package/docs/design.md
CHANGED
|
@@ -452,6 +452,20 @@ event stream each poll so the wedge detector sees a live wait, delivers the
|
|
|
452
452
|
answer into the next turn, and stamps `working`. Only a turn ending on `working`
|
|
453
453
|
(or no report) with an empty inbox ends the session and is stamped `done`.
|
|
454
454
|
|
|
455
|
+
**The session's own harness resumes it.** A session only resumes under the
|
|
456
|
+
harness that wrote it: a Codex thread id handed to Claude Code is "No
|
|
457
|
+
conversation found". The descriptor's `harness` records what was asked, not
|
|
458
|
+
what ran (a trap claims unaddressed work with whatever harness it is), so every
|
|
459
|
+
run stamps `harness` into evidence beside `sessionId` (the adapter's for a
|
|
460
|
+
headless run, the trap's for a claim), and one resolver reads it back for a
|
|
461
|
+
follow-up's fork, a daemon restart, `lobstah swap`, and `attach`. It falls back
|
|
462
|
+
to the claiming trap, then the session id's UUID version, then the descriptor.
|
|
463
|
+
A follow-up whose `--harness` names something the chain never asked for is a
|
|
464
|
+
swap: cold on that harness with the brief plus a progress note. A resume the
|
|
465
|
+
harness refuses before doing any work falls back the same way and records
|
|
466
|
+
`resume-fallback: <reason>` in the status note and evidence. It never fails
|
|
467
|
+
the dispatch. The first status note says which of these happened.
|
|
468
|
+
|
|
455
469
|
```ts
|
|
456
470
|
// inside the runner
|
|
457
471
|
const thread = await adapter.start({
|