@dev-loops/core 0.2.7 → 0.4.0
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/package.json +7 -1
- package/src/config/config.mjs +245 -4
- package/src/config/extension-defaults.yaml +12 -0
- package/src/github/copilot-helpers.mjs +65 -0
- package/src/loop/async-start-contract.mjs +8 -27
- package/src/loop/gate-fanin.mjs +222 -0
- package/src/loop/handoff-envelope.mjs +50 -8
- package/src/loop/lifecycle-state.mjs +13 -1
- package/src/loop/pr-gate-coordination.mjs +183 -3
- package/src/loop/queue-board-ordering.mjs +5 -1
- package/src/loop/queue-board-sync.mjs +4 -1
- package/src/loop/queue-driver.mjs +25 -3
- package/src/loop/queue-membership.mjs +145 -0
- package/src/loop/queue-state.mjs +56 -1
- package/src/loop/run-context.mjs +9 -16
- package/src/loop/worktree-guard.mjs +6 -16
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue membership reconciliation (issue #864).
|
|
3
|
+
*
|
|
4
|
+
* A configured GitHub Projects board is the authoritative source of queue
|
|
5
|
+
* MEMBERSHIP (which issues to work) and ordering — not just status. Before a
|
|
6
|
+
* queue run, this module folds the board's "Next Up" items into the local
|
|
7
|
+
* `.pi/dev-loop-queue.json` entries so the board drives membership, and reports
|
|
8
|
+
* a clear, non-misleading emptiness verdict.
|
|
9
|
+
*
|
|
10
|
+
* This is the testable orchestration seam used by `scripts/loop/run-queue.mjs`.
|
|
11
|
+
* Board config loading, Next Up resolution, and queue persistence are all
|
|
12
|
+
* injectable so the policy can be exercised without GitHub or the filesystem.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadBoardConfig } from "./queue-board-sync.mjs";
|
|
16
|
+
import { resolveNextUpOrder } from "./queue-board-ordering.mjs";
|
|
17
|
+
import { reconcileEntriesFromBoard, writeQueue, pendingEntries } from "./queue-state.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reconcile a configured board's Next Up membership into the queue, then
|
|
21
|
+
* classify emptiness.
|
|
22
|
+
*
|
|
23
|
+
* Behavior:
|
|
24
|
+
* - Board NOT configured: no reconcile. Emptiness is judged purely on the
|
|
25
|
+
* local queue (preserving the legacy "Queue is empty" behavior).
|
|
26
|
+
* - Board configured: resolve Next Up targets and reconcile them in. If the
|
|
27
|
+
* resolver fails-open (returns no targets) the board membership simply
|
|
28
|
+
* contributes nothing; we never crash. Newly added targets are persisted
|
|
29
|
+
* via `writeQueue`.
|
|
30
|
+
*
|
|
31
|
+
* Emptiness verdict (`emptiness`):
|
|
32
|
+
* - `null` — there is pending work; the caller should run the queue.
|
|
33
|
+
* - "queue_empty" — board not configured and the local queue is empty
|
|
34
|
+
* (legacy "Queue is empty" message).
|
|
35
|
+
* - "board_empty" — board IS configured, Next Up resolved successfully
|
|
36
|
+
* (reason == null) but is genuinely empty, and there is
|
|
37
|
+
* no pending local work. Distinct from the misleading
|
|
38
|
+
* generic empty case.
|
|
39
|
+
* - "board_unavailable" — board IS configured but Next Up resolution failed
|
|
40
|
+
* (fail-open: empty order WITH a non-null reason) and
|
|
41
|
+
* the local queue had no pending work to fall back to.
|
|
42
|
+
* This must NOT be reported as "board_empty" — the
|
|
43
|
+
* board may well have items we simply could not read.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} repoRoot
|
|
46
|
+
* @param {string} repo - "owner/name"
|
|
47
|
+
* @param {{version:number, entries:Array}} queue
|
|
48
|
+
* @param {object} [deps]
|
|
49
|
+
* @param {(repoRoot:string)=>{enabled:boolean}} [deps.loadBoardConfig]
|
|
50
|
+
* @param {(repo:string, repoRoot:string, env:object, d:object)=>Promise<{ok:boolean, order:number[], reason:?string}>} [deps.resolveNextUpOrder]
|
|
51
|
+
* @param {(repoRoot:string, queue:object)=>Promise<void>} [deps.writeQueue]
|
|
52
|
+
* @param {(msg:string)=>void} [deps.log]
|
|
53
|
+
* @returns {Promise<{queue:object, added:number[], boardConfigured:boolean, emptiness:(null|"queue_empty"|"board_empty"|"board_unavailable"), reason:(string|null)}>}
|
|
54
|
+
*/
|
|
55
|
+
export async function reconcileBoardMembership(repoRoot, repo, queue, deps = {}) {
|
|
56
|
+
const loadConfig = deps.loadBoardConfig ?? loadBoardConfig;
|
|
57
|
+
const resolveOrder = deps.resolveNextUpOrder ?? resolveNextUpOrder;
|
|
58
|
+
const persist = deps.writeQueue ?? writeQueue;
|
|
59
|
+
const log = typeof deps.log === "function" ? deps.log : (msg) => console.error(msg);
|
|
60
|
+
|
|
61
|
+
let boardConfigured = false;
|
|
62
|
+
try {
|
|
63
|
+
const config = loadConfig(repoRoot);
|
|
64
|
+
boardConfigured = Boolean(config?.enabled);
|
|
65
|
+
// loadBoardConfig does not throw on read/parse failures; it reports them
|
|
66
|
+
// via `{ enabled: false, reason: ... }`. Surface that reason so a genuine
|
|
67
|
+
// config read/parse error is visible instead of being silently treated as
|
|
68
|
+
// "board not configured". The ordinary "board not configured" case carries
|
|
69
|
+
// no reason and must stay quiet.
|
|
70
|
+
if (!boardConfigured && config?.reason) {
|
|
71
|
+
log(`[queue-membership] board config unavailable (fail-open): ${config.reason}`);
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Config read errors must not crash the queue; treat as unconfigured.
|
|
75
|
+
log(`[queue-membership] board config read failed (fail-open): ${err?.message ?? err}`);
|
|
76
|
+
boardConfigured = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let added = [];
|
|
80
|
+
let reason = null;
|
|
81
|
+
// True when the board is configured but Next Up could not be resolved: the
|
|
82
|
+
// resolver is fail-open, so this surfaces as an empty order WITH a non-null
|
|
83
|
+
// reason (or a thrown error caught below). We must not confuse this with a
|
|
84
|
+
// genuinely empty Next Up (empty order AND reason == null).
|
|
85
|
+
let resolutionFailed = false;
|
|
86
|
+
|
|
87
|
+
if (boardConfigured) {
|
|
88
|
+
let nextUp = [];
|
|
89
|
+
try {
|
|
90
|
+
// env/board dependencies use resolveNextUpOrder's own defaults
|
|
91
|
+
// (process.env / {}); the production caller never overrides them, and
|
|
92
|
+
// tests inject a stubbed resolveNextUpOrder.
|
|
93
|
+
const result = await resolveOrder(repo, repoRoot, process.env, {});
|
|
94
|
+
reason = result?.reason ?? null;
|
|
95
|
+
nextUp = Array.isArray(result?.order) ? result.order : [];
|
|
96
|
+
// Fail-open contract: an empty order paired with a non-null reason means
|
|
97
|
+
// resolution failed (API error, board lookup failure, etc.) — NOT that the
|
|
98
|
+
// board's Next Up is genuinely empty.
|
|
99
|
+
if (nextUp.length === 0 && reason != null) {
|
|
100
|
+
resolutionFailed = true;
|
|
101
|
+
log(`[queue-membership] Next Up resolution failed (fail-open), using local queue: ${reason}`);
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// resolveNextUpOrder is itself fail-open, but guard anyway: a board
|
|
105
|
+
// resolution error falls back to the local queue without crashing.
|
|
106
|
+
reason = err?.message ?? "board resolution failed";
|
|
107
|
+
resolutionFailed = true;
|
|
108
|
+
log(`[queue-membership] Next Up resolution failed (fail-open), using local queue: ${reason}`);
|
|
109
|
+
nextUp = [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (nextUp.length > 0) {
|
|
113
|
+
const outcome = reconcileEntriesFromBoard(queue, nextUp);
|
|
114
|
+
added = outcome.added;
|
|
115
|
+
if (added.length > 0) {
|
|
116
|
+
log(`[queue-membership] added ${added.length} entr${added.length === 1 ? "y" : "ies"} from board Next Up: ${added.join(", ")}`);
|
|
117
|
+
try {
|
|
118
|
+
await persist(repoRoot, queue);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// A write failure should not crash the run; entries still drive this
|
|
121
|
+
// in-memory run, they just are not persisted for the next invocation.
|
|
122
|
+
log(`[queue-membership] failed to persist reconciled queue (continuing in-memory): ${err?.message ?? err}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pending = pendingEntries(queue);
|
|
129
|
+
let emptiness = null;
|
|
130
|
+
if (pending.length === 0) {
|
|
131
|
+
if (!boardConfigured) {
|
|
132
|
+
emptiness = "queue_empty";
|
|
133
|
+
} else if (resolutionFailed) {
|
|
134
|
+
// Board configured but we could not read Next Up and the local queue is
|
|
135
|
+
// empty: the board may have items we simply failed to fetch. Report a
|
|
136
|
+
// distinct verdict instead of the misleading "board_empty".
|
|
137
|
+
emptiness = "board_unavailable";
|
|
138
|
+
} else {
|
|
139
|
+
// Board configured, Next Up resolved cleanly (reason == null) and empty.
|
|
140
|
+
emptiness = "board_empty";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { queue, added, boardConfigured, emptiness, reason };
|
|
145
|
+
}
|
package/src/loop/queue-state.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export async function writeQueue(repoRoot, queue) {
|
|
|
81
81
|
export function createEntry(target, kind, dependsOn = []) {
|
|
82
82
|
return {
|
|
83
83
|
target,
|
|
84
|
-
kind, // "issue" | "pr"
|
|
84
|
+
kind, // "issue" | "pr" | "board" (board = issue-or-PR, resolved by number at run time)
|
|
85
85
|
status: "queued",
|
|
86
86
|
dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
|
|
87
87
|
pr: null,
|
|
@@ -211,6 +211,61 @@ export function pendingEntries(queue) {
|
|
|
211
211
|
);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// ── Board membership reconciliation ──────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reconcile board-driven membership into the local queue (issue #864).
|
|
218
|
+
*
|
|
219
|
+
* A configured GitHub Projects board is the authoritative source of queue
|
|
220
|
+
* MEMBERSHIP (which issues should be worked), not just ordering/status. This
|
|
221
|
+
* pure helper folds the board's "Next Up" targets into the queue: for each
|
|
222
|
+
* target not already present (by `findEntry`), it appends a fresh queued
|
|
223
|
+
* `createEntry(target, "board")`. Existing entries — and their status, order,
|
|
224
|
+
* and metadata — are preserved untouched, and targets already present are
|
|
225
|
+
* skipped (dedup).
|
|
226
|
+
*
|
|
227
|
+
* Entry kind is `"board"` rather than `"issue"`: the board's Next Up can hold
|
|
228
|
+
* issues OR PRs, and `resolveNextUpOrder` collapses each item to a bare number
|
|
229
|
+
* (`issueNumber ?? prNumber`), discarding the artifact kind. The queue driver
|
|
230
|
+
* never branches on `entry.kind`; it dispatches purely by `entry.target` and
|
|
231
|
+
* the per-entry startup resolver resolves the actual artifact (issue or PR) by
|
|
232
|
+
* number at run time. So a neutral `"board"` kind correctly records the
|
|
233
|
+
* provenance without falsely asserting "issue" for PR-backed board items.
|
|
234
|
+
*
|
|
235
|
+
* Pure: performs no I/O. Mutates and returns the passed `queue` object (its
|
|
236
|
+
* `entries` array is appended to in place) plus the list of newly added
|
|
237
|
+
* targets so the caller can log/report.
|
|
238
|
+
*
|
|
239
|
+
* @param {{version:number, entries:Array}} queue - the current queue.
|
|
240
|
+
* @param {Array<number>} nextUpTargets - board "Next Up" issue/PR numbers.
|
|
241
|
+
* @returns {{queue:{version:number, entries:Array}, added:number[]}}
|
|
242
|
+
*/
|
|
243
|
+
export function reconcileEntriesFromBoard(queue, nextUpTargets) {
|
|
244
|
+
const added = [];
|
|
245
|
+
if (!queue || !Array.isArray(queue.entries)) {
|
|
246
|
+
return { queue, added };
|
|
247
|
+
}
|
|
248
|
+
if (!Array.isArray(nextUpTargets) || nextUpTargets.length === 0) {
|
|
249
|
+
return { queue, added };
|
|
250
|
+
}
|
|
251
|
+
for (const target of nextUpTargets) {
|
|
252
|
+
// Guard against malformed/duplicate board input: only positive-integer
|
|
253
|
+
// targets, and never add the same target twice (even if it repeats in the
|
|
254
|
+
// board list and was not yet in the queue at the start of this loop).
|
|
255
|
+
//
|
|
256
|
+
// The integer guard is critical for NaN: a NaN target never dedups via
|
|
257
|
+
// findEntry (NaN !== NaN), so accepting it would re-append a fresh NaN
|
|
258
|
+
// entry on every reconcile. Infinity, non-integers, negatives, and 0 are
|
|
259
|
+
// never valid issue/PR numbers either, so reject anything that is not a
|
|
260
|
+
// positive integer.
|
|
261
|
+
if (!Number.isInteger(target) || target <= 0) continue;
|
|
262
|
+
if (findEntry(queue, target)) continue;
|
|
263
|
+
queue.entries.push(createEntry(target, "board"));
|
|
264
|
+
added.push(target);
|
|
265
|
+
}
|
|
266
|
+
return { queue, added };
|
|
267
|
+
}
|
|
268
|
+
|
|
214
269
|
// ── Bug injection ────────────────────────────────────────────────────
|
|
215
270
|
|
|
216
271
|
export function appendBugIssue(queue, issueNumber, dependsOn = null) {
|
package/src/loop/run-context.mjs
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Neutral run-id / async-context contract.
|
|
3
3
|
*
|
|
4
|
-
* The dev-loop async path
|
|
4
|
+
* The dev-loop async path keys off the harness-neutral `DEVLOOPS_RUN_ID` env var to
|
|
5
5
|
* identify an inspectable per-subagent run (runner ownership, async-start enforcement,
|
|
6
|
-
* human-comment gating)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* native per-subagent run id.
|
|
10
|
-
*
|
|
11
|
-
* Marker precedence is neutral-first: a present `DEVLOOPS_RUN_ID` wins; otherwise the Pi
|
|
12
|
-
* alias is honored. Existing Pi runs that set only `PI_SUBAGENT_RUN_ID` behave identically.
|
|
6
|
+
* human-comment gating), and provides a mint-and-propagate path for harnesses (e.g. Claude
|
|
7
|
+
* Code) that inject no native per-subagent run id. The harness sets `DEVLOOPS_RUN_ID` when
|
|
8
|
+
* dispatching an async subagent.
|
|
13
9
|
*
|
|
14
10
|
* This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
|
|
15
11
|
* which take an injectable `fs` and `root` for testability.
|
|
@@ -21,16 +17,13 @@ import path from "node:path";
|
|
|
21
17
|
|
|
22
18
|
/**
|
|
23
19
|
* Env var names that carry the async-context run id, in resolution precedence order.
|
|
24
|
-
*
|
|
20
|
+
* The neutral `DEVLOOPS_RUN_ID` is the sole marker.
|
|
25
21
|
*/
|
|
26
|
-
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"
|
|
22
|
+
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"]);
|
|
27
23
|
|
|
28
24
|
/** Neutral env var name used when minting/propagating a run id. */
|
|
29
25
|
export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
|
|
30
26
|
|
|
31
|
-
/** Pi-compatibility alias env var name. */
|
|
32
|
-
export const PI_RUN_ID_ALIAS_VAR = "PI_SUBAGENT_RUN_ID";
|
|
33
|
-
|
|
34
27
|
/** State-file name (under `.pi/`, consistent with existing dev-loop checkpoint files). */
|
|
35
28
|
export const RUN_CONTEXT_FILENAME = "dev-loop-run-context.json";
|
|
36
29
|
|
|
@@ -56,7 +49,7 @@ export function isClaudeHarness(env = process.env) {
|
|
|
56
49
|
}
|
|
57
50
|
|
|
58
51
|
/**
|
|
59
|
-
* Resolve the active run id from the environment
|
|
52
|
+
* Resolve the active run id from the environment.
|
|
60
53
|
*
|
|
61
54
|
* @param {Record<string, string|undefined>} [env]
|
|
62
55
|
* @returns {string|null} The trimmed run id, or null when none is set.
|
|
@@ -154,8 +147,8 @@ export function readRunContext({ root, fs = fsDefault }) {
|
|
|
154
147
|
* Resolve the active run id, or mint one and persist a run-context state file.
|
|
155
148
|
*
|
|
156
149
|
* This is the "mint at startup and propagate" primitive a Claude dev-loop agent (or a
|
|
157
|
-
* headless entry) calls before dispatching child work. When the env already carries a
|
|
158
|
-
*
|
|
150
|
+
* headless entry) calls before dispatching child work. When the env already carries a
|
|
151
|
+
* `DEVLOOPS_RUN_ID`, it is reused and no new id is minted.
|
|
159
152
|
*
|
|
160
153
|
* @param {object} [params]
|
|
161
154
|
* @param {Record<string, string|undefined>} [params.env]
|
|
@@ -121,32 +121,22 @@ export function isListedWorktree(cwd, worktreePaths) {
|
|
|
121
121
|
* Neutral environment variable name checked by `detectSubagentAvailability`.
|
|
122
122
|
*
|
|
123
123
|
* Set `DEVLOOPS_SUBAGENT_AVAILABLE=1` when the runtime supports subagent dispatch.
|
|
124
|
-
* This is consistent with the `
|
|
124
|
+
* This is consistent with the `DEVLOOPS_WORKTREE_BYPASS` pattern and other repo-local
|
|
125
125
|
* runtime configuration gates already present in the repo.
|
|
126
126
|
*/
|
|
127
127
|
export const DEVLOOPS_SUBAGENT_AVAILABLE_VAR = "DEVLOOPS_SUBAGENT_AVAILABLE";
|
|
128
128
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
* neutral var is unset so existing Pi runtimes keep working unchanged.
|
|
132
|
-
*/
|
|
133
|
-
export const PI_SUBAGENT_AVAILABLE_VAR = "PI_SUBAGENT_AVAILABLE";
|
|
134
|
-
|
|
135
|
-
/** Availability env var names, neutral-first. */
|
|
136
|
-
export const SUBAGENT_AVAILABLE_VARS = Object.freeze([
|
|
137
|
-
DEVLOOPS_SUBAGENT_AVAILABLE_VAR,
|
|
138
|
-
PI_SUBAGENT_AVAILABLE_VAR,
|
|
139
|
-
]);
|
|
129
|
+
/** Availability env var names. */
|
|
130
|
+
export const SUBAGENT_AVAILABLE_VARS = Object.freeze([DEVLOOPS_SUBAGENT_AVAILABLE_VAR]);
|
|
140
131
|
|
|
141
132
|
/**
|
|
142
133
|
* Detect whether subagent dispatch is available in the current runtime.
|
|
143
134
|
*
|
|
144
135
|
* This is an env-var-based heuristic, consistent with other bypass/availability
|
|
145
136
|
* patterns in the repo. It is intentionally simple — the gate's subagent check
|
|
146
|
-
* is advisory (fails-open) and never hard-blocks on subagent absence.
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* alias is only consulted when the neutral var is unset/blank.
|
|
137
|
+
* is advisory (fails-open) and never hard-blocks on subagent absence. The var that is
|
|
138
|
+
* *set* (non-blank) is authoritative — so an explicit `DEVLOOPS_SUBAGENT_AVAILABLE=0` is
|
|
139
|
+
* respected as a hard "not available".
|
|
150
140
|
*
|
|
151
141
|
* @param {{ env?: Record<string, string | undefined> }} [options]
|
|
152
142
|
* @returns {boolean}
|