omp-conductor 0.3.22 → 0.3.24

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/src/unblock.ts CHANGED
@@ -43,19 +43,38 @@ export interface UnblockOutcome {
43
43
  }
44
44
 
45
45
  /**
46
- * Drop both terminal state labels, whichever the issue is actually carrying.
46
+ * Drop the state labels the issue could be carrying, as far as the store can
47
+ * prove they are droppable.
47
48
  *
48
- * Both unconditionally, because the tracker is the only source of truth for
49
- * which one is set and this process cannot read that back through the Tracker
50
- * port — inferring it from the newest run row would be a guess that goes wrong
51
- * exactly when a human has relabelled something by hand. Removing a label an
52
- * issue does not carry is a no-op: `gh issue edit --remove-label` exits 0 on an
53
- * absent label (verified against gh 2.97.0), and the adapter swallows the 404
54
- * older paths return for one.
49
+ * The two terminal labels come off unconditionally, because the tracker is the
50
+ * only source of truth for which one is set and this process cannot read that
51
+ * back through the Tracker port — inferring it from the newest run row would be
52
+ * a guess that goes wrong exactly when a human has relabelled something by
53
+ * hand. Removing a label an issue does not carry is a no-op: `gh issue edit
54
+ * --remove-label` exits 0 on an absent label (verified against gh 2.97.0), and
55
+ * the adapter swallows the 404 older paths return for one.
55
56
  *
56
- * `agent:in-progress` is deliberately not in the set. It means a worker process
57
- * exists, which is not something an operator can answer away, and clearing it
58
- * from under a live run is how two workers end up on one issue.
57
+ * `agent:in-progress` comes off only when the newest run row is terminal, and
58
+ * that condition is the whole safety argument. The label means a worker process
59
+ * exists, and clearing it from under a live run is how two workers end up on
60
+ * one issue, so it used to be excluded outright. What that cost showed up on
61
+ * 2026-08-09: veltro#331's newest row had already settled to `failed` (its PR
62
+ * #332 was closed unmerged), `unblock 331` reported `cleared agent:blocked,
63
+ * agent:failed` and `next tick eligible again`, and an immediate authoritative
64
+ * `gh issue view 331` still showed `agent:in-progress` — the one label that
65
+ * actually decides eligibility, with no verb anywhere able to remove it (#18).
66
+ * A terminal newest row is proof the process is gone rather than an opinion
67
+ * about it: it is written when the worker exits, by `reconcileOrphanedRuns` at
68
+ * startup for a worker that died with its daemon, or by settlement once the PR
69
+ * resolved. Once it is there, the interlock has nothing left to guard.
70
+ *
71
+ * No run row at all is deliberately *not* that proof, so that case still leaves
72
+ * the label alone. A missing row means the store never saw the run, which is
73
+ * indistinguishable from here to a claim that raced a store write — and being
74
+ * wrong in that direction is the two-workers bug, where being wrong in the
75
+ * other direction is a stuck issue an operator is already looking at. On the
76
+ * paths the daemon does own it releases the label itself, next to the store
77
+ * transition that proves the run ended (`releaseInProgress` in `daemon.ts`).
59
78
  */
60
79
  export async function unblockIssue(
61
80
  project: ProjectConfig,
@@ -63,13 +82,21 @@ export async function unblockIssue(
63
82
  store: Store,
64
83
  issue: number,
65
84
  ): Promise<UnblockOutcome> {
85
+ // Read before any label is touched: terminality is the whole of the argument
86
+ // for clearing in-progress, so the row that carries it decides the set.
87
+ const latest = store.latestRun(project.name, issue);
88
+ const terminal = latest !== undefined && !LIVE_STATES.includes(latest.state);
89
+
66
90
  const cleared: string[] = [];
67
- for (const label of new Set([project.stateLabels.blocked, project.stateLabels.failed])) {
91
+ for (const label of new Set([
92
+ project.stateLabels.blocked,
93
+ project.stateLabels.failed,
94
+ ...(terminal ? [project.stateLabels.inProgress] : []),
95
+ ])) {
68
96
  await tracker.removeLabel(issue, label);
69
97
  cleared.push(label);
70
98
  }
71
99
 
72
- const latest = store.latestRun(project.name, issue);
73
100
  return {
74
101
  cleared,
75
102
  attemptsUsed: store.attemptsFor(project.name, issue),
@@ -81,9 +108,14 @@ export async function unblockIssue(
81
108
 
82
109
  /**
83
110
  * What the operator reads back. It promises a re-claim only when one can
84
- * actually happen: a live run still owns the issue through `agent:in-progress`,
85
- * and a spent attempt budget makes the next tick escalate rather than dispatch.
86
- * Either promised blindly would send someone away believing work had resumed.
111
+ * actually happen, because a promise made blindly sends someone away believing
112
+ * work had resumed and they find out by waiting for it. Three things withhold
113
+ * it: a live run still owns the issue through `agent:in-progress`, a spent
114
+ * attempt budget makes the next tick escalate rather than dispatch, and — since
115
+ * the in-progress label is only released for a terminal run — an issue with no
116
+ * run row at all, where that label may still be sitting there unread. #18 was
117
+ * filed against this function saying `next tick eligible again` in a case where
118
+ * it was not, so the wording is a contract rather than prose.
87
119
  */
88
120
  export function formatUnblock(
89
121
  issue: number,
@@ -95,7 +127,7 @@ export function formatUnblock(
95
127
  const lines = [`#${issue}: cleared ${o.cleared.join(", ")}`];
96
128
 
97
129
  if (latest === undefined) {
98
- lines.push(" runs none recorded — the labels were cleared anyway; eligibility is read off the tracker");
130
+ lines.push(" runs none recorded — the terminal labels were cleared anyway; eligibility is read off the tracker");
99
131
  } else {
100
132
  lines.push(` runs ${o.attemptsUsed}, newest ${latest.state}`);
101
133
  lines.push(` failures ${o.failuresUsed} of ${caps.maxAttemptsPerIssue}`);
@@ -117,6 +149,12 @@ export function formatUnblock(
117
149
  ` next tick not eligible: the ${caps.maxContinuationsPerIssue}-continuation budget was exceeded. ` +
118
150
  "Inspect progress or raise maxContinuationsPerIssue.",
119
151
  );
152
+ } else if (latest === undefined) {
153
+ lines.push(
154
+ ` next tick eligible once the issue carries "${project.queueLabel}" and no state label — with no ` +
155
+ `run row to prove the worker is gone, "${project.stateLabels.inProgress}" was left in place, and ` +
156
+ "on its own it keeps the issue ineligible",
157
+ );
120
158
  } else {
121
159
  lines.push(` next tick eligible again, as long as the issue still carries "${project.queueLabel}"`);
122
160
  }