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 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(stateName, byTypes) {
2278
+ async assigned(filter) {
2272
2279
  const field = this.cfg.assignField ?? "assignee";
2273
- const stateFilter = byTypes?.length ? { type: { in: byTypes } } : { name: { eq: stateName } };
2274
- const data = await this.gql(`query($filter: IssueFilter!) {
2275
- issues(filter: $filter, first: 50) {
2276
- nodes { id identifier title description state { name type } team { key id } }
2277
- }
2278
- }`, { filter: { [field]: { isMe: { eq: true } }, state: stateFilter } });
2279
- return data.issues.nodes;
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 issues = await this.assigned(this.cfg.startState, this.cfg.startStateTypes);
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(this.cfg.claimedState);
2356
- return issues.map((i) => ({ key: `linear:${i.identifier}`, open: true }));
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
- if (state.get(item.key))
2446
- continue;
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, { uuid: id, kind: item.kind, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
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
  }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstah",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",