lobstah 0.3.1 → 0.3.2
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 +52 -15
- package/docs/lobsterman.md +12 -0
- package/docs/pickup.md +13 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1957,6 +1957,12 @@ var PickupState = class {
|
|
|
1957
1957
|
this.data.map[key] = { ...cur, ...patch };
|
|
1958
1958
|
this.save();
|
|
1959
1959
|
}
|
|
1960
|
+
releaseIssue(key) {
|
|
1961
|
+
const entry = this.get(key);
|
|
1962
|
+
if (entry?.kind !== "issue" || entry.lastReported !== "failed")
|
|
1963
|
+
return;
|
|
1964
|
+
this.update(key, { released: true, attempts: entry.attempts ?? 1 });
|
|
1965
|
+
}
|
|
1960
1966
|
approvalConsumed(dedupKey) {
|
|
1961
1967
|
return dedupKey in this.data.consumedApprovals;
|
|
1962
1968
|
}
|
|
@@ -2242,6 +2248,7 @@ When the change is complete, commit it, push the branch, and open a PR against $
|
|
|
2242
2248
|
|
|
2243
2249
|
// apps/pick/dist/sources/linear.js
|
|
2244
2250
|
var API2 = "https://api.linear.app/graphql";
|
|
2251
|
+
var CLOSED_TYPES = ["completed", "canceled"];
|
|
2245
2252
|
function stateFor(cfg, verb) {
|
|
2246
2253
|
if (verb === "done")
|
|
2247
2254
|
return cfg.doneState;
|
|
@@ -2268,15 +2275,22 @@ var LinearSource = class {
|
|
|
2268
2275
|
}
|
|
2269
2276
|
return json.data;
|
|
2270
2277
|
}
|
|
2271
|
-
async assigned(
|
|
2278
|
+
async assigned(filter) {
|
|
2272
2279
|
const field = this.cfg.assignField ?? "assignee";
|
|
2273
|
-
const
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
+
const issues = [];
|
|
2281
|
+
let after = null;
|
|
2282
|
+
for (; ; ) {
|
|
2283
|
+
const data = await this.gql(`query($filter: IssueFilter!, $after: String) {
|
|
2284
|
+
issues(filter: $filter, first: 50, after: $after) {
|
|
2285
|
+
nodes { id identifier title description state { name type } team { key id } }
|
|
2286
|
+
pageInfo { hasNextPage endCursor }
|
|
2287
|
+
}
|
|
2288
|
+
}`, { filter: { [field]: { isMe: { eq: true } }, ...filter }, after });
|
|
2289
|
+
issues.push(...data.issues.nodes);
|
|
2290
|
+
if (!data.issues.pageInfo.hasNextPage)
|
|
2291
|
+
return issues;
|
|
2292
|
+
after = data.issues.pageInfo.endCursor;
|
|
2293
|
+
}
|
|
2280
2294
|
}
|
|
2281
2295
|
async stateId(teamId, name) {
|
|
2282
2296
|
const cacheKey = `${teamId}:${name}`;
|
|
@@ -2304,7 +2318,8 @@ var LinearSource = class {
|
|
|
2304
2318
|
await this.gql(`mutation($id: String!, $body: String!) { commentCreate(input: { issueId: $id, body: $body }) { success } }`, { id: issue.id, body });
|
|
2305
2319
|
}
|
|
2306
2320
|
async poll() {
|
|
2307
|
-
const
|
|
2321
|
+
const state = this.cfg.startStateTypes?.length ? { type: { in: this.cfg.startStateTypes } } : { name: { eq: this.cfg.startState } };
|
|
2322
|
+
const issues = await this.assigned({ state });
|
|
2308
2323
|
return issues.flatMap((issue) => {
|
|
2309
2324
|
const repoKey = this.cfg.route[issue.team.key];
|
|
2310
2325
|
if (!repoKey)
|
|
@@ -2343,7 +2358,7 @@ When the change is complete, commit it, push the branch, and open a PR whose des
|
|
|
2343
2358
|
lines.push(evidence.note);
|
|
2344
2359
|
await this.commentOn(issue, lines.join("\n"));
|
|
2345
2360
|
const target = stateFor(this.cfg, verb);
|
|
2346
|
-
if (target)
|
|
2361
|
+
if (target && !CLOSED_TYPES.includes(issue.state.type))
|
|
2347
2362
|
await this.moveTo(issue, target);
|
|
2348
2363
|
}
|
|
2349
2364
|
async inbound(key, since) {
|
|
@@ -2352,8 +2367,10 @@ When the change is complete, commit it, push the branch, and open a PR whose des
|
|
|
2352
2367
|
return data.issue.comments.nodes.filter((c) => !c.user?.isMe && !MARKER.test(c.body)).filter((c) => !since || c.createdAt > since).map((c) => c.body);
|
|
2353
2368
|
}
|
|
2354
2369
|
async inProgress() {
|
|
2355
|
-
const issues = await this.assigned(
|
|
2356
|
-
|
|
2370
|
+
const issues = await this.assigned({
|
|
2371
|
+
or: [{ state: { name: { eq: this.cfg.claimedState } } }, { state: { type: { in: CLOSED_TYPES } } }]
|
|
2372
|
+
});
|
|
2373
|
+
return issues.map((i) => ({ key: `linear:${i.identifier}`, open: !CLOSED_TYPES.includes(i.state.type) }));
|
|
2357
2374
|
}
|
|
2358
2375
|
async recoverUuid(key) {
|
|
2359
2376
|
const identifier = key.replace(/^linear:/, "");
|
|
@@ -2391,6 +2408,8 @@ async function reportLoop(source, state, log = () => {
|
|
|
2391
2408
|
}, notify = () => {
|
|
2392
2409
|
}) {
|
|
2393
2410
|
for (const [key, entry] of state.entries()) {
|
|
2411
|
+
if (entry.released)
|
|
2412
|
+
continue;
|
|
2394
2413
|
const lane = dispatchLane(entry.uuid);
|
|
2395
2414
|
if (!lane)
|
|
2396
2415
|
continue;
|
|
@@ -2411,6 +2430,11 @@ async function reportLoop(source, state, log = () => {
|
|
|
2411
2430
|
prUrl: evidence.prUrl
|
|
2412
2431
|
});
|
|
2413
2432
|
}
|
|
2433
|
+
if (entry.kind === "issue" && verb === "failed" && fs17.existsSync(path12.join(laneDirs(lane).done, entry.uuid))) {
|
|
2434
|
+
state.releaseIssue(key);
|
|
2435
|
+
log(`${key}: finalized failure released for a bounded retry`);
|
|
2436
|
+
continue;
|
|
2437
|
+
}
|
|
2414
2438
|
const msgs = await source.inbound(key, entry.lastInboundAt);
|
|
2415
2439
|
if (msgs.length > 0) {
|
|
2416
2440
|
for (const m of msgs)
|
|
@@ -2441,9 +2465,17 @@ function roundPlan(item, state) {
|
|
|
2441
2465
|
async function dispatchLoop(source, state, log = () => {
|
|
2442
2466
|
}) {
|
|
2443
2467
|
const items = await source.poll();
|
|
2468
|
+
const maxAttempts = 1 + loadConfig().limits.maxRestartAttempts;
|
|
2444
2469
|
for (const item of items) {
|
|
2445
|
-
|
|
2446
|
-
|
|
2470
|
+
const previous = state.get(item.key);
|
|
2471
|
+
if (previous) {
|
|
2472
|
+
if (item.kind !== "issue" || previous.kind !== "issue" || !previous.released)
|
|
2473
|
+
continue;
|
|
2474
|
+
if ((previous.attempts ?? 1) >= maxAttempts) {
|
|
2475
|
+
log(`${item.key}: issue attempt limit (${maxAttempts}) reached \u2014 holding for a human`);
|
|
2476
|
+
continue;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2447
2479
|
const plan = item.kind === "review" ? roundPlan(item, state) : { hold: false, followUp: void 0 };
|
|
2448
2480
|
if (plan.hold) {
|
|
2449
2481
|
log(`${item.key}: a prior round is still in flight \u2014 holding`);
|
|
@@ -2461,7 +2493,12 @@ async function dispatchLoop(source, state, log = () => {
|
|
|
2461
2493
|
// A review dispatch forks the latest session in its chain; issues start cold.
|
|
2462
2494
|
...plan.followUp ? { followUp: plan.followUp } : {}
|
|
2463
2495
|
}, "work");
|
|
2464
|
-
state.set(item.key, {
|
|
2496
|
+
state.set(item.key, {
|
|
2497
|
+
uuid: id,
|
|
2498
|
+
kind: item.kind,
|
|
2499
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2500
|
+
...item.kind === "issue" ? { attempts: previous ? (previous.attempts ?? 1) + 1 : 1 } : {}
|
|
2501
|
+
});
|
|
2465
2502
|
log(`${item.key}: dispatched as ${id}`);
|
|
2466
2503
|
}
|
|
2467
2504
|
}
|
package/docs/lobsterman.md
CHANGED
|
@@ -125,6 +125,18 @@ After dispatching work, run `lobstah man wait` as a background task. When it
|
|
|
125
125
|
completes, follow its `next:` instruction, then re-arm it.
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
**Arm it long-lived.** The default (`--timeout 0`) waits forever and returns
|
|
129
|
+
only when something happens — that is the right idiom, not a compromise.
|
|
130
|
+
Every wake costs the session a turn, so a short timeout used as a "check in
|
|
131
|
+
periodically" mechanism buys nothing but noise: attention is level-triggered
|
|
132
|
+
and stands on disk until consumed, so a wait that dies mid-shift drops zero
|
|
133
|
+
events — the next wait delivers everything that accumulated. There is no
|
|
134
|
+
report to miss either; a quiet fleet has nothing to say. Pass `--timeout`
|
|
135
|
+
only as a dead-man's check on the channel itself (an hour, not minutes): a
|
|
136
|
+
quiet timeout prints one line, exits 3, and the re-arm proves the line was
|
|
137
|
+
alive. Liveness reassurance for the human belongs on a status surface
|
|
138
|
+
(`lobstah man tend`), never in the transcript.
|
|
139
|
+
|
|
128
140
|
**Tier 3 — park the session on a Stop hook (Claude Code only).** A Stop hook
|
|
129
141
|
that blocks on `lobstah man wait`, so the session never
|
|
130
142
|
really idles — it parks for free and continues the moment something needs it.
|
package/docs/pickup.md
CHANGED
|
@@ -300,6 +300,19 @@ An orphan verdict requires a trustworthy mapping — see
|
|
|
300
300
|
[Pickup state](#pickup-state): a missing table entry is `unknown` and triggers
|
|
301
301
|
a rebuild, never a reset.
|
|
302
302
|
|
|
303
|
+
Linear reconciliation includes assigned/delegated issues in the claimed state
|
|
304
|
+
and terminal (`completed`/`canceled`) states, following every results page.
|
|
305
|
+
A failure report leaves an already closed Linear issue closed.
|
|
306
|
+
|
|
307
|
+
After an issue dispatch finalizes as `failed` and its tracker report succeeds,
|
|
308
|
+
pickup releases its key for another claim when the tracker offers it in the
|
|
309
|
+
start state. The ledger retains the last UUID and an attempt count across
|
|
310
|
+
restarts: one initial attempt plus `[limits].maxRestartAttempts` retries
|
|
311
|
+
(default two). Legacy entries count as one attempt. At the cap, the issue stays
|
|
312
|
+
in the start state for human attention; polling does not claim it again.
|
|
313
|
+
Successful dispatches and review-round keys remain deduplicated. A `done`
|
|
314
|
+
report alone is not a failure verdict, regardless of commit count.
|
|
315
|
+
|
|
303
316
|
**Residual, by honesty:** same-machine reconciliation cannot detect
|
|
304
317
|
whole-machine death — the reconciler dies with the laptop. The stale
|
|
305
318
|
`executor.json` heartbeat covers that case, but only for a remote reader — a
|