lobstah 0.5.9 → 0.5.10
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 +168 -54
- package/dist/runner.js +117 -17
- package/docs/configuration.md +7 -0
- package/docs/design.md +14 -2
- package/docs/pickup.md +7 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1395,6 +1395,7 @@ var init_config = __esm({
|
|
|
1395
1395
|
wedgeThresholdSecs: 600,
|
|
1396
1396
|
maxRestartAttempts: 2,
|
|
1397
1397
|
wallClockSecs: 3600,
|
|
1398
|
+
backgroundWaitSecs: 1800,
|
|
1398
1399
|
choreRetentionDays: 7,
|
|
1399
1400
|
attachmentMaxBytes: 25 * 1024 * 1024
|
|
1400
1401
|
};
|
|
@@ -2809,6 +2810,9 @@ function pumpClaudeMessage(msg, push, onSession) {
|
|
|
2809
2810
|
if (msg.session_id)
|
|
2810
2811
|
onSession(msg.session_id);
|
|
2811
2812
|
push({ at, type: "session", data: { sessionId: msg.session_id } });
|
|
2813
|
+
} else if (msg.type === "system" && msg.subtype === "background_tasks_changed") {
|
|
2814
|
+
const live = (msg.tasks ?? []).filter((t) => !t.ambient).length;
|
|
2815
|
+
push({ at, type: "background", data: { live } });
|
|
2812
2816
|
} else if (msg.type === "assistant") {
|
|
2813
2817
|
for (const block of msg.message?.content ?? []) {
|
|
2814
2818
|
if (block.type === "tool_use") {
|
|
@@ -2829,6 +2833,19 @@ function pumpClaudeMessage(msg, push, onSession) {
|
|
|
2829
2833
|
push({ at, type: "turn-end", data: { subtype: msg.subtype, costUsd: msg.total_cost_usd } });
|
|
2830
2834
|
}
|
|
2831
2835
|
}
|
|
2836
|
+
function claudeSdkOptions(opts) {
|
|
2837
|
+
return {
|
|
2838
|
+
cwd: opts.cwd,
|
|
2839
|
+
model: opts.model,
|
|
2840
|
+
...opts.effort ? { effort: opts.effort } : {},
|
|
2841
|
+
maxTurns: opts.limits.maxTurns,
|
|
2842
|
+
resume: opts.resumeSession,
|
|
2843
|
+
forkSession: opts.resumeSession ? true : void 0,
|
|
2844
|
+
permissionMode: "bypassPermissions",
|
|
2845
|
+
env: { ...process.env, ...opts.env },
|
|
2846
|
+
extraArgs: flagsToExtraArgs(opts.flags)
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2832
2849
|
function claudeCliArgs(opts) {
|
|
2833
2850
|
const args = [
|
|
2834
2851
|
"-p",
|
|
@@ -2842,6 +2859,8 @@ function claudeCliArgs(opts) {
|
|
|
2842
2859
|
];
|
|
2843
2860
|
if (opts.model)
|
|
2844
2861
|
args.push("--model", opts.model);
|
|
2862
|
+
if (opts.effort)
|
|
2863
|
+
args.push("--effort", opts.effort);
|
|
2845
2864
|
if (opts.limits.maxTurns)
|
|
2846
2865
|
args.push("--max-turns", String(opts.limits.maxTurns));
|
|
2847
2866
|
if (opts.resumeSession)
|
|
@@ -2949,16 +2968,7 @@ function createClaudeAdapter() {
|
|
|
2949
2968
|
}
|
|
2950
2969
|
const q = sdk.query({
|
|
2951
2970
|
prompt: userMessages(),
|
|
2952
|
-
options:
|
|
2953
|
-
cwd: opts.cwd,
|
|
2954
|
-
model: opts.model,
|
|
2955
|
-
maxTurns: opts.limits.maxTurns,
|
|
2956
|
-
resume: opts.resumeSession,
|
|
2957
|
-
forkSession: opts.resumeSession ? true : void 0,
|
|
2958
|
-
permissionMode: "bypassPermissions",
|
|
2959
|
-
env: { ...process.env, ...opts.env },
|
|
2960
|
-
extraArgs: flagsToExtraArgs(opts.flags)
|
|
2961
|
-
}
|
|
2971
|
+
options: claudeSdkOptions(opts)
|
|
2962
2972
|
});
|
|
2963
2973
|
const events = new AsyncQueue();
|
|
2964
2974
|
let sessionId;
|
|
@@ -3251,6 +3261,22 @@ async function git4(cwd, ...args) {
|
|
|
3251
3261
|
const { stdout } = await run3("git", args, { cwd, env: process.env });
|
|
3252
3262
|
return stdout.trim();
|
|
3253
3263
|
}
|
|
3264
|
+
function isLockContention(err) {
|
|
3265
|
+
const text = `${err.stderr ?? ""}
|
|
3266
|
+
${err instanceof Error ? err.message : ""}`;
|
|
3267
|
+
return LOCK_CONTENTION.some((re) => re.test(text));
|
|
3268
|
+
}
|
|
3269
|
+
async function gitShared(cwd, ...args) {
|
|
3270
|
+
for (let attempt = 1; ; attempt++) {
|
|
3271
|
+
try {
|
|
3272
|
+
return await git4(cwd, ...args);
|
|
3273
|
+
} catch (err) {
|
|
3274
|
+
if (attempt >= 6 || !isLockContention(err))
|
|
3275
|
+
throw err;
|
|
3276
|
+
await new Promise((r) => setTimeout(r, 100 * 2 ** (attempt - 1) * (0.5 + Math.random())));
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3254
3280
|
async function allocate(repo, id) {
|
|
3255
3281
|
const dir = worktreePath(id);
|
|
3256
3282
|
if (fs43.existsSync(dir)) {
|
|
@@ -3261,8 +3287,8 @@ async function allocate(repo, id) {
|
|
|
3261
3287
|
throw new Error(`repo path ${repo.path} missing and no origin configured`);
|
|
3262
3288
|
await run3("git", ["clone", repo.origin, repo.path], { env: process.env });
|
|
3263
3289
|
}
|
|
3264
|
-
await
|
|
3265
|
-
await
|
|
3290
|
+
await gitShared(repo.path, "fetch", "origin", repo.trunk);
|
|
3291
|
+
await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${repo.trunk}`);
|
|
3266
3292
|
for (const cmd of repo.setup ?? []) {
|
|
3267
3293
|
await shell(cmd, { cwd: dir, env: { ...process.env, ...repo.env ?? {} } });
|
|
3268
3294
|
}
|
|
@@ -3274,19 +3300,20 @@ async function collectEvidence(repo, id) {
|
|
|
3274
3300
|
const log = await git4(dir, "log", "--oneline", `origin/${repo.trunk}..HEAD`);
|
|
3275
3301
|
return { branch, commits: log ? log.split("\n") : [] };
|
|
3276
3302
|
}
|
|
3277
|
-
var run3, shell;
|
|
3303
|
+
var run3, shell, LOCK_CONTENTION;
|
|
3278
3304
|
var init_dist4 = __esm({
|
|
3279
3305
|
"packages/worktree/dist/index.js"() {
|
|
3280
3306
|
"use strict";
|
|
3281
3307
|
init_dist2();
|
|
3282
3308
|
run3 = promisify(execFile);
|
|
3283
3309
|
shell = promisify(exec);
|
|
3310
|
+
LOCK_CONTENTION = [/cannot lock ref/, /unable to update local ref/, /Unable to create '[^']*\.lock': File exists/];
|
|
3284
3311
|
}
|
|
3285
3312
|
});
|
|
3286
3313
|
|
|
3287
3314
|
// packages/runner/dist/contract.js
|
|
3288
3315
|
function buildPrompt(brief, opts) {
|
|
3289
|
-
const reporting = `Report status by running \`lobstah report ${opts.id} <verb> [note]\` (verbs: ${VERBS.join(", ")}). Attach a PR URL to your final report with \`--pr <url>\`. At natural checkpoints, check for operator messages with \`lobstah inbox ${opts.id}\` \u2014 messages also arrive automatically between your turns.`;
|
|
3316
|
+
const reporting = `Report status by running \`lobstah report ${opts.id} <verb> [note]\` (verbs: ${VERBS.join(", ")}). Attach a PR URL to your final report with \`--pr <url>\`. Only your report finishes this dispatch: ending a turn without \`done\` or \`failed\` does not. If you start background work and end your turn to wait for it, you are woken when it finishes. At natural checkpoints, check for operator messages with \`lobstah inbox ${opts.id}\` \u2014 messages also arrive automatically between your turns.`;
|
|
3290
3317
|
const parts = [
|
|
3291
3318
|
`You are a dispatched coding agent supervised by lobstah. Dispatch id: ${opts.id}.`,
|
|
3292
3319
|
`Work only inside the current directory \u2014 it is an isolated git worktree allocated for this dispatch.`,
|
|
@@ -3310,6 +3337,9 @@ var init_contract = __esm({
|
|
|
3310
3337
|
});
|
|
3311
3338
|
|
|
3312
3339
|
// packages/runner/dist/drive.js
|
|
3340
|
+
function unreportedNudge(id) {
|
|
3341
|
+
return `Your turn ended without a status report, so this dispatch is not finished. If the work is complete, run \`lobstah report ${id} done "<what you did>"\`, adding \`--pr <url>\` if you opened a PR. If no change was needed, report \`done\` with a note saying why. If a human has to answer a question, or you are blocked, report \`needs-decision\` or \`blocked\` with it. If you are waiting on something, run it in the background: you are woken when it finishes.`;
|
|
3342
|
+
}
|
|
3313
3343
|
function deliver(run4, id, lane) {
|
|
3314
3344
|
const msgs = unhandled(id, lane);
|
|
3315
3345
|
for (const m of msgs) {
|
|
@@ -3319,7 +3349,7 @@ function deliver(run4, id, lane) {
|
|
|
3319
3349
|
return msgs.length;
|
|
3320
3350
|
}
|
|
3321
3351
|
async function drive(run4, opts) {
|
|
3322
|
-
const { id, lane, pollMs = 3e3, stopped = () => false } = opts;
|
|
3352
|
+
const { id, lane, pollMs = 3e3, stopped = () => false, backgroundWaitMs = 30 * 6e4, settleGraceMs = 12e4 } = opts;
|
|
3323
3353
|
let cancelled = false;
|
|
3324
3354
|
let activity = 0;
|
|
3325
3355
|
let finished = false;
|
|
@@ -3345,8 +3375,53 @@ async function drive(run4, opts) {
|
|
|
3345
3375
|
await sleep(pollMs);
|
|
3346
3376
|
}
|
|
3347
3377
|
};
|
|
3378
|
+
let liveBackground = 0;
|
|
3379
|
+
let nudged = false;
|
|
3380
|
+
let hold;
|
|
3381
|
+
const runnerEvent = (data) => appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data });
|
|
3382
|
+
const releaseHold = () => {
|
|
3383
|
+
if (!hold)
|
|
3384
|
+
return;
|
|
3385
|
+
clearInterval(hold.heartbeat);
|
|
3386
|
+
clearTimeout(hold.expiry);
|
|
3387
|
+
hold = void 0;
|
|
3388
|
+
};
|
|
3389
|
+
const unreported = () => {
|
|
3390
|
+
if (!nudged) {
|
|
3391
|
+
nudged = true;
|
|
3392
|
+
runnerEvent({ nudged: "unreported" });
|
|
3393
|
+
run4.send(unreportedNudge(id));
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
appendStatus(id, lane, "failed", "ended without reporting a result, after being asked to");
|
|
3397
|
+
run4.end();
|
|
3398
|
+
};
|
|
3399
|
+
const holdOpen = (ms) => {
|
|
3400
|
+
releaseHold();
|
|
3401
|
+
const heartbeat = setInterval(() => {
|
|
3402
|
+
if (cancelRequested(id, lane)) {
|
|
3403
|
+
releaseHold();
|
|
3404
|
+
cancel();
|
|
3405
|
+
return;
|
|
3406
|
+
}
|
|
3407
|
+
touchEvents(id, lane);
|
|
3408
|
+
}, pollMs);
|
|
3409
|
+
const expiry = setTimeout(() => {
|
|
3410
|
+
releaseHold();
|
|
3411
|
+
runnerEvent({ backgroundWait: "expired", live: liveBackground });
|
|
3412
|
+
unreported();
|
|
3413
|
+
}, ms);
|
|
3414
|
+
hold = { heartbeat, expiry };
|
|
3415
|
+
};
|
|
3348
3416
|
for await (const ev of run4.events) {
|
|
3349
3417
|
appendEvent(id, lane, ev);
|
|
3418
|
+
if (ev.type === "background") {
|
|
3419
|
+
liveBackground = Number(ev.data?.live ?? 0);
|
|
3420
|
+
if (hold && liveBackground === 0)
|
|
3421
|
+
holdOpen(settleGraceMs);
|
|
3422
|
+
continue;
|
|
3423
|
+
}
|
|
3424
|
+
releaseHold();
|
|
3350
3425
|
if (ev.type === "tool-start" || ev.type === "text")
|
|
3351
3426
|
activity++;
|
|
3352
3427
|
if (ev.type === "session" && ev.data?.sessionId) {
|
|
@@ -3369,8 +3444,23 @@ async function drive(run4, opts) {
|
|
|
3369
3444
|
await awaitAnswer(lastVerb);
|
|
3370
3445
|
continue;
|
|
3371
3446
|
}
|
|
3372
|
-
|
|
3447
|
+
if (lastVerb !== void 0 && TERMINAL_VERBS.includes(lastVerb)) {
|
|
3448
|
+
run4.end();
|
|
3449
|
+
continue;
|
|
3450
|
+
}
|
|
3451
|
+
const subtype = ev.data?.subtype;
|
|
3452
|
+
if (subtype !== void 0 && subtype !== "success") {
|
|
3453
|
+
run4.end();
|
|
3454
|
+
continue;
|
|
3455
|
+
}
|
|
3456
|
+
if (liveBackground > 0) {
|
|
3457
|
+
runnerEvent({ holding: "background", live: liveBackground, forSecs: Math.round(backgroundWaitMs / 1e3) });
|
|
3458
|
+
holdOpen(backgroundWaitMs);
|
|
3459
|
+
continue;
|
|
3460
|
+
}
|
|
3461
|
+
unreported();
|
|
3373
3462
|
}
|
|
3463
|
+
releaseHold();
|
|
3374
3464
|
return { cancelled, activity };
|
|
3375
3465
|
}
|
|
3376
3466
|
function settle(id, lane, r) {
|
|
@@ -3382,7 +3472,7 @@ function settle(id, lane, r) {
|
|
|
3382
3472
|
else if (r.error)
|
|
3383
3473
|
appendStatus(id, lane, "failed", r.error.slice(0, 500));
|
|
3384
3474
|
else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb))
|
|
3385
|
-
appendStatus(id, lane, "
|
|
3475
|
+
appendStatus(id, lane, "failed", "stopped without reporting a result");
|
|
3386
3476
|
}
|
|
3387
3477
|
var WAITING_VERBS, sleep;
|
|
3388
3478
|
var init_drive = __esm({
|
|
@@ -3568,7 +3658,12 @@ async function main(activeDir, lane, deps = defaultDeps) {
|
|
|
3568
3658
|
resumeSession
|
|
3569
3659
|
});
|
|
3570
3660
|
current = run4;
|
|
3571
|
-
const { cancelled: cancelled2, activity } = await drive(run4, {
|
|
3661
|
+
const { cancelled: cancelled2, activity } = await drive(run4, {
|
|
3662
|
+
id,
|
|
3663
|
+
lane,
|
|
3664
|
+
stopped: () => wallClockHit,
|
|
3665
|
+
backgroundWaitMs: cfg.limits.backgroundWaitSecs * 1e3
|
|
3666
|
+
});
|
|
3572
3667
|
const result2 = await run4.done;
|
|
3573
3668
|
return { cancelled: cancelled2, activity, result: result2 };
|
|
3574
3669
|
};
|
|
@@ -4387,6 +4482,9 @@ var GithubSource = class {
|
|
|
4387
4482
|
this.cfg = cfg;
|
|
4388
4483
|
this.name = `gh:${cfg.repo}`;
|
|
4389
4484
|
}
|
|
4485
|
+
owns(key) {
|
|
4486
|
+
return key.startsWith(`${this.name}#`);
|
|
4487
|
+
}
|
|
4390
4488
|
async api(method, url, body) {
|
|
4391
4489
|
const res = await fetch(`${API}${url}`, {
|
|
4392
4490
|
method,
|
|
@@ -4619,6 +4717,9 @@ var LinearSource = class {
|
|
|
4619
4717
|
constructor(cfg) {
|
|
4620
4718
|
this.cfg = cfg;
|
|
4621
4719
|
}
|
|
4720
|
+
owns(key) {
|
|
4721
|
+
return key.startsWith("linear:");
|
|
4722
|
+
}
|
|
4622
4723
|
async gql(query, variables = {}) {
|
|
4623
4724
|
const res = await fetch(API2, {
|
|
4624
4725
|
method: "POST",
|
|
@@ -4766,42 +4867,51 @@ function dispatchLane(uuid) {
|
|
|
4766
4867
|
async function reportLoop(source, state, log = () => {
|
|
4767
4868
|
}, notify = () => {
|
|
4768
4869
|
}) {
|
|
4870
|
+
const failures = [];
|
|
4769
4871
|
for (const [key, entry] of state.entries()) {
|
|
4770
|
-
if (
|
|
4872
|
+
if (!source.owns(key))
|
|
4771
4873
|
continue;
|
|
4772
|
-
|
|
4773
|
-
if (!lane)
|
|
4874
|
+
if (entry.released)
|
|
4774
4875
|
continue;
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
state.update(key, { lastReported: verb });
|
|
4783
|
-
log(`${key}: reported ${verb}`);
|
|
4784
|
-
notify({
|
|
4785
|
-
key,
|
|
4786
|
-
uuid: entry.uuid,
|
|
4787
|
-
verb,
|
|
4788
|
-
note: readStatusLog(entry.uuid, lane).at(-1)?.note,
|
|
4789
|
-
prUrl: evidence.prUrl
|
|
4876
|
+
try {
|
|
4877
|
+
const lane = dispatchLane(entry.uuid);
|
|
4878
|
+
if (!lane)
|
|
4879
|
+
continue;
|
|
4880
|
+
const verb = reconcile({
|
|
4881
|
+
log: readStatusLog(entry.uuid, lane),
|
|
4882
|
+
lastEventAt: lastEventAt(entry.uuid, lane)
|
|
4790
4883
|
});
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4884
|
+
if (verb !== "unknown" && verb !== entry.lastReported) {
|
|
4885
|
+
const evidence = readEvidence(entry.uuid, lane);
|
|
4886
|
+
await source.report(key, verb, { ...evidence, uuid: entry.uuid });
|
|
4887
|
+
state.update(key, { lastReported: verb });
|
|
4888
|
+
log(`${key}: reported ${verb}`);
|
|
4889
|
+
notify({
|
|
4890
|
+
key,
|
|
4891
|
+
uuid: entry.uuid,
|
|
4892
|
+
verb,
|
|
4893
|
+
note: readStatusLog(entry.uuid, lane).at(-1)?.note,
|
|
4894
|
+
prUrl: evidence.prUrl
|
|
4895
|
+
});
|
|
4896
|
+
}
|
|
4897
|
+
if (entry.kind === "issue" && verb === "failed" && fs22.existsSync(path17.join(laneDirs(lane).done, entry.uuid))) {
|
|
4898
|
+
state.releaseIssue(key);
|
|
4899
|
+
log(`${key}: finalized failure released for a bounded retry`);
|
|
4900
|
+
continue;
|
|
4901
|
+
}
|
|
4902
|
+
const msgs = await source.inbound(key, entry.lastInboundAt);
|
|
4903
|
+
if (msgs.length > 0) {
|
|
4904
|
+
for (const m of msgs)
|
|
4905
|
+
sendMessage(entry.uuid, lane, m, `tracker:${source.name.startsWith("gh:") ? "github" : source.name}`);
|
|
4906
|
+
state.update(key, { lastInboundAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4907
|
+
log(`${key}: forwarded ${msgs.length} comment(s) to inbox`);
|
|
4908
|
+
}
|
|
4909
|
+
} catch (err) {
|
|
4910
|
+
failures.push(`${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
4803
4911
|
}
|
|
4804
4912
|
}
|
|
4913
|
+
if (failures.length > 0)
|
|
4914
|
+
throw new Error(failures.join("; "));
|
|
4805
4915
|
}
|
|
4806
4916
|
|
|
4807
4917
|
// apps/pick/dist/loops/dispatch.js
|
|
@@ -5273,12 +5383,16 @@ function syncStreams(children, flight, log, notify) {
|
|
|
5273
5383
|
}
|
|
5274
5384
|
async function cycle(sources, merges, state, pollSecs2, log, notify) {
|
|
5275
5385
|
for (const source of sources) {
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5386
|
+
for (const loop of [
|
|
5387
|
+
() => dispatchLoop(source, state, log),
|
|
5388
|
+
() => reportLoop(source, state, log, notify),
|
|
5389
|
+
() => reconcileLoop(source, state, log)
|
|
5390
|
+
]) {
|
|
5391
|
+
try {
|
|
5392
|
+
await loop();
|
|
5393
|
+
} catch (err) {
|
|
5394
|
+
log(`${source.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
5395
|
+
}
|
|
5282
5396
|
}
|
|
5283
5397
|
}
|
|
5284
5398
|
for (const { source, policy } of merges) {
|
package/dist/runner.js
CHANGED
|
@@ -841,6 +841,7 @@ var DEFAULT_LIMITS = {
|
|
|
841
841
|
wedgeThresholdSecs: 600,
|
|
842
842
|
maxRestartAttempts: 2,
|
|
843
843
|
wallClockSecs: 3600,
|
|
844
|
+
backgroundWaitSecs: 1800,
|
|
844
845
|
choreRetentionDays: 7,
|
|
845
846
|
attachmentMaxBytes: 25 * 1024 * 1024
|
|
846
847
|
};
|
|
@@ -1223,6 +1224,9 @@ function pumpClaudeMessage(msg, push, onSession) {
|
|
|
1223
1224
|
if (msg.session_id)
|
|
1224
1225
|
onSession(msg.session_id);
|
|
1225
1226
|
push({ at, type: "session", data: { sessionId: msg.session_id } });
|
|
1227
|
+
} else if (msg.type === "system" && msg.subtype === "background_tasks_changed") {
|
|
1228
|
+
const live = (msg.tasks ?? []).filter((t) => !t.ambient).length;
|
|
1229
|
+
push({ at, type: "background", data: { live } });
|
|
1226
1230
|
} else if (msg.type === "assistant") {
|
|
1227
1231
|
for (const block of msg.message?.content ?? []) {
|
|
1228
1232
|
if (block.type === "tool_use") {
|
|
@@ -1249,6 +1253,19 @@ var userMessage = (content) => ({
|
|
|
1249
1253
|
parent_tool_use_id: null,
|
|
1250
1254
|
session_id: ""
|
|
1251
1255
|
});
|
|
1256
|
+
function claudeSdkOptions(opts) {
|
|
1257
|
+
return {
|
|
1258
|
+
cwd: opts.cwd,
|
|
1259
|
+
model: opts.model,
|
|
1260
|
+
...opts.effort ? { effort: opts.effort } : {},
|
|
1261
|
+
maxTurns: opts.limits.maxTurns,
|
|
1262
|
+
resume: opts.resumeSession,
|
|
1263
|
+
forkSession: opts.resumeSession ? true : void 0,
|
|
1264
|
+
permissionMode: "bypassPermissions",
|
|
1265
|
+
env: { ...process.env, ...opts.env },
|
|
1266
|
+
extraArgs: flagsToExtraArgs(opts.flags)
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1252
1269
|
function claudeCliArgs(opts) {
|
|
1253
1270
|
const args = [
|
|
1254
1271
|
"-p",
|
|
@@ -1262,6 +1279,8 @@ function claudeCliArgs(opts) {
|
|
|
1262
1279
|
];
|
|
1263
1280
|
if (opts.model)
|
|
1264
1281
|
args.push("--model", opts.model);
|
|
1282
|
+
if (opts.effort)
|
|
1283
|
+
args.push("--effort", opts.effort);
|
|
1265
1284
|
if (opts.limits.maxTurns)
|
|
1266
1285
|
args.push("--max-turns", String(opts.limits.maxTurns));
|
|
1267
1286
|
if (opts.resumeSession)
|
|
@@ -1369,16 +1388,7 @@ function createClaudeAdapter() {
|
|
|
1369
1388
|
}
|
|
1370
1389
|
const q = sdk.query({
|
|
1371
1390
|
prompt: userMessages(),
|
|
1372
|
-
options:
|
|
1373
|
-
cwd: opts.cwd,
|
|
1374
|
-
model: opts.model,
|
|
1375
|
-
maxTurns: opts.limits.maxTurns,
|
|
1376
|
-
resume: opts.resumeSession,
|
|
1377
|
-
forkSession: opts.resumeSession ? true : void 0,
|
|
1378
|
-
permissionMode: "bypassPermissions",
|
|
1379
|
-
env: { ...process.env, ...opts.env },
|
|
1380
|
-
extraArgs: flagsToExtraArgs(opts.flags)
|
|
1381
|
-
}
|
|
1391
|
+
options: claudeSdkOptions(opts)
|
|
1382
1392
|
});
|
|
1383
1393
|
const events = new AsyncQueue();
|
|
1384
1394
|
let sessionId;
|
|
@@ -1642,6 +1652,23 @@ async function git(cwd, ...args) {
|
|
|
1642
1652
|
const { stdout } = await run("git", args, { cwd, env: process.env });
|
|
1643
1653
|
return stdout.trim();
|
|
1644
1654
|
}
|
|
1655
|
+
var LOCK_CONTENTION = [/cannot lock ref/, /unable to update local ref/, /Unable to create '[^']*\.lock': File exists/];
|
|
1656
|
+
function isLockContention(err) {
|
|
1657
|
+
const text = `${err.stderr ?? ""}
|
|
1658
|
+
${err instanceof Error ? err.message : ""}`;
|
|
1659
|
+
return LOCK_CONTENTION.some((re) => re.test(text));
|
|
1660
|
+
}
|
|
1661
|
+
async function gitShared(cwd, ...args) {
|
|
1662
|
+
for (let attempt = 1; ; attempt++) {
|
|
1663
|
+
try {
|
|
1664
|
+
return await git(cwd, ...args);
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
if (attempt >= 6 || !isLockContention(err))
|
|
1667
|
+
throw err;
|
|
1668
|
+
await new Promise((r) => setTimeout(r, 100 * 2 ** (attempt - 1) * (0.5 + Math.random())));
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1645
1672
|
async function allocate(repo, id) {
|
|
1646
1673
|
const dir = worktreePath(id);
|
|
1647
1674
|
if (fs11.existsSync(dir)) {
|
|
@@ -1652,8 +1679,8 @@ async function allocate(repo, id) {
|
|
|
1652
1679
|
throw new Error(`repo path ${repo.path} missing and no origin configured`);
|
|
1653
1680
|
await run("git", ["clone", repo.origin, repo.path], { env: process.env });
|
|
1654
1681
|
}
|
|
1655
|
-
await
|
|
1656
|
-
await
|
|
1682
|
+
await gitShared(repo.path, "fetch", "origin", repo.trunk);
|
|
1683
|
+
await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${repo.trunk}`);
|
|
1657
1684
|
for (const cmd of repo.setup ?? []) {
|
|
1658
1685
|
await shell(cmd, { cwd: dir, env: { ...process.env, ...repo.env ?? {} } });
|
|
1659
1686
|
}
|
|
@@ -1668,7 +1695,7 @@ async function collectEvidence(repo, id) {
|
|
|
1668
1695
|
|
|
1669
1696
|
// packages/runner/src/contract.ts
|
|
1670
1697
|
function buildPrompt(brief, opts) {
|
|
1671
|
-
const reporting = `Report status by running \`lobstah report ${opts.id} <verb> [note]\` (verbs: ${VERBS.join(", ")}). Attach a PR URL to your final report with \`--pr <url>\`. At natural checkpoints, check for operator messages with \`lobstah inbox ${opts.id}\` \u2014 messages also arrive automatically between your turns.`;
|
|
1698
|
+
const reporting = `Report status by running \`lobstah report ${opts.id} <verb> [note]\` (verbs: ${VERBS.join(", ")}). Attach a PR URL to your final report with \`--pr <url>\`. Only your report finishes this dispatch: ending a turn without \`done\` or \`failed\` does not. If you start background work and end your turn to wait for it, you are woken when it finishes. At natural checkpoints, check for operator messages with \`lobstah inbox ${opts.id}\` \u2014 messages also arrive automatically between your turns.`;
|
|
1672
1699
|
const parts = [
|
|
1673
1700
|
`You are a dispatched coding agent supervised by lobstah. Dispatch id: ${opts.id}.`,
|
|
1674
1701
|
`Work only inside the current directory \u2014 it is an isolated git worktree allocated for this dispatch.`,
|
|
@@ -1685,6 +1712,9 @@ function buildPrompt(brief, opts) {
|
|
|
1685
1712
|
|
|
1686
1713
|
// packages/runner/src/drive.ts
|
|
1687
1714
|
var WAITING_VERBS = ["needs-decision", "blocked", "paused"];
|
|
1715
|
+
function unreportedNudge(id) {
|
|
1716
|
+
return `Your turn ended without a status report, so this dispatch is not finished. If the work is complete, run \`lobstah report ${id} done "<what you did>"\`, adding \`--pr <url>\` if you opened a PR. If no change was needed, report \`done\` with a note saying why. If a human has to answer a question, or you are blocked, report \`needs-decision\` or \`blocked\` with it. If you are waiting on something, run it in the background: you are woken when it finishes.`;
|
|
1717
|
+
}
|
|
1688
1718
|
var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1689
1719
|
function deliver(run2, id, lane) {
|
|
1690
1720
|
const msgs = unhandled(id, lane);
|
|
@@ -1695,7 +1725,14 @@ function deliver(run2, id, lane) {
|
|
|
1695
1725
|
return msgs.length;
|
|
1696
1726
|
}
|
|
1697
1727
|
async function drive(run2, opts) {
|
|
1698
|
-
const {
|
|
1728
|
+
const {
|
|
1729
|
+
id,
|
|
1730
|
+
lane,
|
|
1731
|
+
pollMs = 3e3,
|
|
1732
|
+
stopped = () => false,
|
|
1733
|
+
backgroundWaitMs = 30 * 6e4,
|
|
1734
|
+
settleGraceMs = 12e4
|
|
1735
|
+
} = opts;
|
|
1699
1736
|
let cancelled = false;
|
|
1700
1737
|
let activity = 0;
|
|
1701
1738
|
let finished = false;
|
|
@@ -1718,8 +1755,51 @@ async function drive(run2, opts) {
|
|
|
1718
1755
|
await sleep(pollMs);
|
|
1719
1756
|
}
|
|
1720
1757
|
};
|
|
1758
|
+
let liveBackground = 0;
|
|
1759
|
+
let nudged = false;
|
|
1760
|
+
let hold;
|
|
1761
|
+
const runnerEvent = (data) => appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data });
|
|
1762
|
+
const releaseHold = () => {
|
|
1763
|
+
if (!hold) return;
|
|
1764
|
+
clearInterval(hold.heartbeat);
|
|
1765
|
+
clearTimeout(hold.expiry);
|
|
1766
|
+
hold = void 0;
|
|
1767
|
+
};
|
|
1768
|
+
const unreported = () => {
|
|
1769
|
+
if (!nudged) {
|
|
1770
|
+
nudged = true;
|
|
1771
|
+
runnerEvent({ nudged: "unreported" });
|
|
1772
|
+
run2.send(unreportedNudge(id));
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
appendStatus(id, lane, "failed", "ended without reporting a result, after being asked to");
|
|
1776
|
+
run2.end();
|
|
1777
|
+
};
|
|
1778
|
+
const holdOpen = (ms) => {
|
|
1779
|
+
releaseHold();
|
|
1780
|
+
const heartbeat = setInterval(() => {
|
|
1781
|
+
if (cancelRequested(id, lane)) {
|
|
1782
|
+
releaseHold();
|
|
1783
|
+
cancel();
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
touchEvents(id, lane);
|
|
1787
|
+
}, pollMs);
|
|
1788
|
+
const expiry = setTimeout(() => {
|
|
1789
|
+
releaseHold();
|
|
1790
|
+
runnerEvent({ backgroundWait: "expired", live: liveBackground });
|
|
1791
|
+
unreported();
|
|
1792
|
+
}, ms);
|
|
1793
|
+
hold = { heartbeat, expiry };
|
|
1794
|
+
};
|
|
1721
1795
|
for await (const ev of run2.events) {
|
|
1722
1796
|
appendEvent(id, lane, ev);
|
|
1797
|
+
if (ev.type === "background") {
|
|
1798
|
+
liveBackground = Number(ev.data?.live ?? 0);
|
|
1799
|
+
if (hold && liveBackground === 0) holdOpen(settleGraceMs);
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
releaseHold();
|
|
1723
1803
|
if (ev.type === "tool-start" || ev.type === "text") activity++;
|
|
1724
1804
|
if (ev.type === "session" && ev.data?.sessionId) {
|
|
1725
1805
|
mergeEvidence(id, lane, { sessionId: String(ev.data.sessionId) });
|
|
@@ -1739,8 +1819,23 @@ async function drive(run2, opts) {
|
|
|
1739
1819
|
await awaitAnswer(lastVerb);
|
|
1740
1820
|
continue;
|
|
1741
1821
|
}
|
|
1742
|
-
|
|
1822
|
+
if (lastVerb !== void 0 && TERMINAL_VERBS.includes(lastVerb)) {
|
|
1823
|
+
run2.end();
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
const subtype = ev.data?.subtype;
|
|
1827
|
+
if (subtype !== void 0 && subtype !== "success") {
|
|
1828
|
+
run2.end();
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
if (liveBackground > 0) {
|
|
1832
|
+
runnerEvent({ holding: "background", live: liveBackground, forSecs: Math.round(backgroundWaitMs / 1e3) });
|
|
1833
|
+
holdOpen(backgroundWaitMs);
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1836
|
+
unreported();
|
|
1743
1837
|
}
|
|
1838
|
+
releaseHold();
|
|
1744
1839
|
return { cancelled, activity };
|
|
1745
1840
|
}
|
|
1746
1841
|
function settle(id, lane, r) {
|
|
@@ -1748,7 +1843,7 @@ function settle(id, lane, r) {
|
|
|
1748
1843
|
if (r.cancelled) appendStatus(id, lane, "failed", "cancelled by operator");
|
|
1749
1844
|
else if (r.wallClockHit) appendStatus(id, lane, "failed", "wall-clock limit exceeded");
|
|
1750
1845
|
else if (r.error) appendStatus(id, lane, "failed", r.error.slice(0, 500));
|
|
1751
|
-
else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb)) appendStatus(id, lane, "
|
|
1846
|
+
else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb)) appendStatus(id, lane, "failed", "stopped without reporting a result");
|
|
1752
1847
|
}
|
|
1753
1848
|
|
|
1754
1849
|
// packages/runner/src/plan.ts
|
|
@@ -1912,7 +2007,12 @@ async function main(activeDir, lane, deps = defaultDeps) {
|
|
|
1912
2007
|
resumeSession
|
|
1913
2008
|
});
|
|
1914
2009
|
current = run2;
|
|
1915
|
-
const { cancelled: cancelled2, activity } = await drive(run2, {
|
|
2010
|
+
const { cancelled: cancelled2, activity } = await drive(run2, {
|
|
2011
|
+
id,
|
|
2012
|
+
lane,
|
|
2013
|
+
stopped: () => wallClockHit,
|
|
2014
|
+
backgroundWaitMs: cfg.limits.backgroundWaitSecs * 1e3
|
|
2015
|
+
});
|
|
1916
2016
|
const result2 = await run2.done;
|
|
1917
2017
|
return { cancelled: cancelled2, activity, result: result2 };
|
|
1918
2018
|
};
|
package/docs/configuration.md
CHANGED
|
@@ -33,6 +33,12 @@ The descriptor's `repo` field resolves here; the key is what dispatchers name.
|
|
|
33
33
|
`[repos.<key>.harness]` — per-repo harness defaults: `default` (`claude` \|
|
|
34
34
|
`codex`), `model`, `effort`.
|
|
35
35
|
|
|
36
|
+
`effort` reaches both harnesses: Claude as its effort level (`low`, `medium`,
|
|
37
|
+
`high`, `xhigh`, `max`), Codex as `model_reasoning_effort`. Set it for Claude
|
|
38
|
+
rather than relying on the default — left unset, a Claude session takes
|
|
39
|
+
`effortLevel` from the host's settings files, including the user's own
|
|
40
|
+
`~/.claude/settings.json`.
|
|
41
|
+
|
|
36
42
|
`lobstah repos add <path> [--pickup]` detects and appends a block (origin,
|
|
37
43
|
default branch from `origin/HEAD`, setup from the lockfile); `lobstah init
|
|
38
44
|
--scan <dir>...` does the same for every git repo found under the given
|
|
@@ -52,6 +58,7 @@ Same three keys as the per-repo block. Precedence for every harness setting:
|
|
|
52
58
|
| `wedgeThresholdSecs` | `600` | No tool activity for this long while alive = wedged → killed and forked with a nudge. |
|
|
53
59
|
| `maxRestartAttempts` | `2` | Bounded restart ladder for dead and wedged runners. |
|
|
54
60
|
| `wallClockSecs` | `3600` | Hard per-dispatch ceiling, enforced by the runner. |
|
|
61
|
+
| `backgroundWaitSecs` | `1800` | A turn that ends without a report is held open this long while background work the worker started is still running (a push behind a slow pre-push gate); the harness wakes the worker when it settles. Heartbeats keep the wedge detector off the wait. Keep it below `wallClockSecs`, which still ends the run. |
|
|
55
62
|
| `choreRetentionDays` | `7` | Completed chores age out of `chores/done/`. |
|
|
56
63
|
| `attachmentMaxBytes` | `26214400` (25 MiB) | Maximum size of each file supplied with repeatable `dispatch --attach` or `send --attach`. |
|
|
57
64
|
|
package/docs/design.md
CHANGED
|
@@ -449,8 +449,20 @@ worker's last report on `needs-decision`, `blocked`, or `paused` does not end
|
|
|
449
449
|
the session: a headless worker waiting on a question stays alive until
|
|
450
450
|
answered, cancelled, or the wall clock — the runner polls the inbox, touches the
|
|
451
451
|
event stream each poll so the wedge detector sees a live wait, delivers the
|
|
452
|
-
answer into the next turn, and stamps `working`.
|
|
453
|
-
|
|
452
|
+
answer into the next turn, and stamps `working`. A turn ending on `done` or
|
|
453
|
+
`failed` ends the session.
|
|
454
|
+
|
|
455
|
+
**Only the worker's report finishes a dispatch.** A turn can end with no report
|
|
456
|
+
while the worker is still mid-task: it started a push behind a slow pre-push gate
|
|
457
|
+
and ended the turn to be woken when it lands, which is how the harness asks a
|
|
458
|
+
model to wait. Ending the session there kills the push. So a quiet turn is held
|
|
459
|
+
open while the harness reports live background work (ambient watchers excluded),
|
|
460
|
+
up to `[limits].backgroundWaitSecs`, with the same heartbeats as a question wait;
|
|
461
|
+
the harness wakes the worker when the work settles. With nothing live, or once
|
|
462
|
+
the window passes, the worker is asked once to report. Silence after that is
|
|
463
|
+
`failed`, as is a run that stops on its own without a report. Stamping `done` on
|
|
464
|
+
silence turned a question never asked, two in-flight pushes, and a conclusion
|
|
465
|
+
nobody saw into finished work.
|
|
454
466
|
|
|
455
467
|
**The session's own harness resumes it.** A session only resumes under the
|
|
456
468
|
harness that wrote it: a Codex thread id handed to Claude Code is "No
|
package/docs/pickup.md
CHANGED
|
@@ -356,6 +356,13 @@ design rather than by trust:
|
|
|
356
356
|
In Progress item because the table lacks an entry for it. A missing mapping
|
|
357
357
|
triggers a rebuild, and only a rebuilt table may declare an orphan. Losing a
|
|
358
358
|
file must never cancel live work.
|
|
359
|
+
- **Shared, so owned.** Every source writes into one table. A loop that walks
|
|
360
|
+
it acts only on the keys its source `owns()` — a Linear key handed to the
|
|
361
|
+
GitHub source, or the reverse, is not an item that source can read. And no
|
|
362
|
+
single entry may fail the walk: each is isolated, the rest still report, and
|
|
363
|
+
the loop rejects afterwards so the cycle logs it. Dispatch, report, and
|
|
364
|
+
reconcile are guarded separately, so a report that cannot land never takes
|
|
365
|
+
reconciliation down with it.
|
|
359
366
|
|
|
360
367
|
---
|
|
361
368
|
|