@nodii/dev 0.6.34 → 0.6.36
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/README.md +68 -8
- package/dist/api.js +6 -0
- package/dist/cli.js +57 -9
- package/dist/core.js +1 -1
- package/dist/devops-openapi.json +1 -1
- package/dist/freeze.js +38 -15
- package/dist/gather.js +105 -9
- package/dist/hooks.js +6 -2
- package/dist/lanes.js +73 -11
- package/dist/remote.js +281 -0
- package/dist/runs.js +41 -7
- package/dist/starting.js +24 -11
- package/dist/upload.js +15 -2
- package/package.json +1 -1
package/dist/freeze.js
CHANGED
|
@@ -29,9 +29,43 @@ export function shouldReground(argv) {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* The only Gather → Freeze shape adapter. Gather may add display metadata,
|
|
32
|
-
* but Freeze
|
|
32
|
+
* but Freeze pins only the ⚑ clauses, in full (hub-58): every other clause
|
|
33
|
+
* stays in the Clause map, which the Freeze references.
|
|
33
34
|
*/
|
|
34
35
|
export function pinnedClausesFromGather(clauses) {
|
|
36
|
+
return checkedGatherClauses(clauses)
|
|
37
|
+
.filter((clause) => clause.counterInstinct)
|
|
38
|
+
.map((clause) => ({
|
|
39
|
+
documentCode: clause.documentCode,
|
|
40
|
+
documentVersion: clause.documentVersion,
|
|
41
|
+
clauseId: clause.clauseId,
|
|
42
|
+
...(clause.breadcrumb || clause.sectionId
|
|
43
|
+
? { section: clause.breadcrumb ?? clause.sectionId }
|
|
44
|
+
: {}),
|
|
45
|
+
gist: clause.gist,
|
|
46
|
+
text: clause.text,
|
|
47
|
+
counterInstinct: true,
|
|
48
|
+
repos: clause.repos,
|
|
49
|
+
steps: clause.steps,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The Freeze's reference to the Clause map: its Gather id and the non-flagged
|
|
55
|
+
* clauses' one-line gists in rank order (Dev cuts them at the token cap).
|
|
56
|
+
*/
|
|
57
|
+
export function clauseMapFromGather(gather) {
|
|
58
|
+
if (typeof gather?.gatherId !== "string" || !gather.gatherId)
|
|
59
|
+
throw new CliError("GATHER_CLAUSE_MAP_INVALID", "Gather returned no gather id", 1);
|
|
60
|
+
const clauses = checkedGatherClauses(gather.clauses)
|
|
61
|
+
.map((clause, index) => ({ clause, index }))
|
|
62
|
+
.filter(({ clause }) => !clause.counterInstinct)
|
|
63
|
+
.sort((a, b) => (a.clause.rank ?? Infinity) - (b.clause.rank ?? Infinity) || a.index - b.index)
|
|
64
|
+
.map(({ clause }) => ({ clauseId: clause.clauseId, gist: clause.gist }));
|
|
65
|
+
return { gatherId: gather.gatherId, clauses };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkedGatherClauses(clauses) {
|
|
35
69
|
if (!Array.isArray(clauses))
|
|
36
70
|
throw new CliError("GATHER_CLAUSE_MAP_INVALID", "Gather returned no clause map", 1);
|
|
37
71
|
return clauses.map((clause) => {
|
|
@@ -66,25 +100,13 @@ export function pinnedClausesFromGather(clauses) {
|
|
|
66
100
|
].join(", ")}`,
|
|
67
101
|
1,
|
|
68
102
|
);
|
|
69
|
-
return
|
|
70
|
-
documentCode: clause.documentCode,
|
|
71
|
-
documentVersion: clause.documentVersion,
|
|
72
|
-
clauseId: clause.clauseId,
|
|
73
|
-
...(clause.breadcrumb || clause.sectionId
|
|
74
|
-
? { section: clause.breadcrumb ?? clause.sectionId }
|
|
75
|
-
: {}),
|
|
76
|
-
gist: clause.gist,
|
|
77
|
-
...(clause.counterInstinct ? { text: clause.text } : {}),
|
|
78
|
-
counterInstinct: clause.counterInstinct,
|
|
79
|
-
repos: clause.repos,
|
|
80
|
-
steps: clause.steps,
|
|
81
|
-
};
|
|
103
|
+
return clause;
|
|
82
104
|
});
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
export const GATHER_LOOKUP_IDS = ["figma-guidelines"];
|
|
86
108
|
|
|
87
|
-
/** Build the
|
|
109
|
+
/** Build the stored contract; Delta Gather keeps lane-authored sections. */
|
|
88
110
|
export function freezeFromGather({
|
|
89
111
|
gather,
|
|
90
112
|
boundaries,
|
|
@@ -119,6 +141,7 @@ export function freezeFromGather({
|
|
|
119
141
|
},
|
|
120
142
|
boundaries,
|
|
121
143
|
pinnedClauses: pinnedClausesFromGather(gather.clauses),
|
|
144
|
+
clauseMap: clauseMapFromGather(gather),
|
|
122
145
|
// Gather owns the lookups it derives (the Figma scope's guidelines): each
|
|
123
146
|
// Gather replaces them, or drops them when the scope no longer yields any.
|
|
124
147
|
// Lookups the lane added itself are kept.
|
package/dist/gather.js
CHANGED
|
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { homedir, platform } from "node:os";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { link, lstat, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
-
import { join } from "node:path";
|
|
5
|
+
import { appendFile, link, lstat, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
7
|
import { CliError } from "./core.js";
|
|
8
8
|
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
@@ -21,6 +21,10 @@ export const PICKER_CONTEXT = 8192;
|
|
|
21
21
|
// A hard per-pick deadline: a pick that has not answered by then is stopped
|
|
22
22
|
// (PICKER_TIMEOUT) so the next one never queues behind it.
|
|
23
23
|
export const PICKER_TIMEOUT_MS = 30_000;
|
|
24
|
+
// A lane Gather's first pick, timed only once the model is warm: real lanes
|
|
25
|
+
// hit the 60 s Gather cap on a cold 12B (GATHER_TIMEOUT, 30 Oct).
|
|
26
|
+
export const PICKER_FIRST_PICK_TIMEOUT_MS = 120_000;
|
|
27
|
+
// Dev's requests in a Gather; the local warm-up and pick run on their own budgets.
|
|
24
28
|
export const GATHER_TIMEOUT_MS = 60_000;
|
|
25
29
|
export const PICKER_WARM_TIMEOUT_MS = 180_000;
|
|
26
30
|
export const OLLAMA_URL = "http://127.0.0.1:11434";
|
|
@@ -79,9 +83,8 @@ async function jsonRequest(path, { baseUrl = ollamaUrl(), fetchImpl = fetch, tim
|
|
|
79
83
|
}), requestSignal);
|
|
80
84
|
} catch (error) {
|
|
81
85
|
const timedOut = requestSignal.aborted || error?.name === "TimeoutError" || error?.name === "AbortError";
|
|
82
|
-
throw new CliError("PICKER_UNAVAILABLE",
|
|
83
|
-
|
|
84
|
-
: `Local Gather picker is unavailable at ${baseUrl}: ${error.message}. ${SETUP_FIX}`, 1);
|
|
86
|
+
if (timedOut) throw new CliError("PICKER_UNAVAILABLE", `Local Gather picker timed out after ${timeoutMs} ms.`, 1);
|
|
87
|
+
throw new CliError("PICKER_UNAVAILABLE", `Local Gather picker is unavailable at ${baseUrl}: ${error.message}. ${SETUP_FIX}`, 1, { unreachable: true, url: baseUrl });
|
|
85
88
|
}
|
|
86
89
|
let raw;
|
|
87
90
|
try {
|
|
@@ -139,8 +142,41 @@ export async function requirePicker(options = {}) {
|
|
|
139
142
|
*/
|
|
140
143
|
export async function warmPicker(options = {}) {
|
|
141
144
|
const status = await requirePicker(options);
|
|
142
|
-
|
|
143
|
-
|
|
145
|
+
const started = Date.now();
|
|
146
|
+
const payload = await jsonRequest("/api/generate", { ...options, timeoutMs: options.timeoutMs ?? PICKER_WARM_TIMEOUT_MS, body: { model: (options.model ?? PINNED).name, prompt: "", stream: false } });
|
|
147
|
+
return { ...status, latency: pickLatency(payload, Date.now() - started) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Gather's deadline for Dev's requests. It stops while `hold` runs the local
|
|
152
|
+
* pick, which has its own budgets (warm-up, then the first pick), so a cold
|
|
153
|
+
* model never spends the requests' time.
|
|
154
|
+
*/
|
|
155
|
+
export function gatherDeadline(ms) {
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
let remaining = ms;
|
|
158
|
+
let since = Date.now();
|
|
159
|
+
let timer = setTimeout(() => controller.abort(), remaining);
|
|
160
|
+
let holds = 0;
|
|
161
|
+
return {
|
|
162
|
+
ms,
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
async hold(task) {
|
|
165
|
+
if (holds++ === 0) {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
remaining -= Date.now() - since;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return await task();
|
|
171
|
+
} finally {
|
|
172
|
+
if (--holds === 0 && !controller.signal.aborted) {
|
|
173
|
+
since = Date.now();
|
|
174
|
+
timer = setTimeout(() => controller.abort(), Math.max(0, remaining));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
clear: () => clearTimeout(timer),
|
|
179
|
+
};
|
|
144
180
|
}
|
|
145
181
|
|
|
146
182
|
function pickerPrompt({ objective, files = [], triggers }) {
|
|
@@ -156,6 +192,16 @@ function pickerPrompt({ objective, files = [], triggers }) {
|
|
|
156
192
|
|
|
157
193
|
const forced = (trigger) => Array.isArray(trigger?.forcedBy) ? trigger.forcedBy.length > 0 : Boolean(trigger?.forcedBy);
|
|
158
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Why a lane Gather hands the pick to the agent instead (hub-58, 30 Oct): this
|
|
197
|
+
* Mac has no picker model, or no Ollama at all. Null for every other failure.
|
|
198
|
+
*/
|
|
199
|
+
export function agentFallbackReason(error) {
|
|
200
|
+
if (error?.code === "PICKER_MODEL_MISSING") return `${error.details?.model ?? PICKER_MODEL} not installed; run nodii-dev setup`;
|
|
201
|
+
if (error?.code === "PICKER_UNAVAILABLE" && error.details?.unreachable) return `no local Ollama at ${error.details.url}; run nodii-dev setup`;
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
159
205
|
export async function pickDocuments(input, options = {}) {
|
|
160
206
|
if (!input || typeof input.objective !== "string" || !input.objective.trim())
|
|
161
207
|
throw new CliError("PICKER_INPUT_INVALID", "Gather needs a non-empty objective.", 2);
|
|
@@ -287,7 +333,7 @@ async function removeIfStale(lock, token, { alive, now, staleMs }) {
|
|
|
287
333
|
* long). Takeovers are serialized, so the lock path is never emptied under a
|
|
288
334
|
* live pick.
|
|
289
335
|
*/
|
|
290
|
-
export async function withPickLock(lane, task, { dir = pickLockDir(), pid = process.pid, alive = processAlive, now = Date.now, staleMs = 2 *
|
|
336
|
+
export async function withPickLock(lane, task, { dir = pickLockDir(), pid = process.pid, alive = processAlive, now = Date.now, staleMs = 2 * PICKER_FIRST_PICK_TIMEOUT_MS, attempts = 40, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
291
337
|
await privateDir(dir);
|
|
292
338
|
const name = encodeURIComponent(lane);
|
|
293
339
|
const lock = join(dir, `${name}.lock`);
|
|
@@ -306,7 +352,7 @@ export async function withPickLock(lane, task, { dir = pickLockDir(), pid = proc
|
|
|
306
352
|
}
|
|
307
353
|
const verdict = await removeIfStale(lock, token, { alive, now, staleMs });
|
|
308
354
|
if (!verdict.retry)
|
|
309
|
-
throw new CliError("PICKER_BUSY", `A Gather pick for lane ${lane} is already running (pid ${verdict.holder ?? "unknown"}); it ends within ${Math.round(
|
|
355
|
+
throw new CliError("PICKER_BUSY", `A Gather pick for lane ${lane} is already running (pid ${verdict.holder ?? "unknown"}); it ends within ${Math.round(PICKER_FIRST_PICK_TIMEOUT_MS / 1000)} s. Rerun after it finishes (lock: ${lock}).`, 1, { pid: verdict.holder, lock });
|
|
310
356
|
await sleep(5 + Math.random() * 20);
|
|
311
357
|
}
|
|
312
358
|
} finally {
|
|
@@ -325,6 +371,56 @@ const processAlive = (pid) => {
|
|
|
325
371
|
catch (error) { return error?.code === "EPERM"; }
|
|
326
372
|
};
|
|
327
373
|
|
|
374
|
+
/** This Mac's Gather pick timings, one JSON line per lane pick (the p50/p95 evidence). */
|
|
375
|
+
export const pickTimingsPath = (env = process.env) => env.NODII_DEV_GATHER_TIMINGS || join(homedir(), ".cache", "nodii-dev", "gather-timings.jsonl");
|
|
376
|
+
|
|
377
|
+
export async function recordPickTiming(entry, { path = pickTimingsPath() } = {}) {
|
|
378
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
379
|
+
await appendFile(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The newest `last` timings, optionally for one model; unreadable lines are skipped. */
|
|
383
|
+
export async function readPickTimings({ path = pickTimingsPath(), model, last = 50 } = {}) {
|
|
384
|
+
const raw = await readFile(path, "utf8").catch((error) => { if (error?.code === "ENOENT") return ""; throw error; });
|
|
385
|
+
const entries = raw.split("\n").flatMap((line) => { try { return line ? [JSON.parse(line)] : []; } catch { return []; } });
|
|
386
|
+
return entries.filter((x) => !model || x.model === model).slice(-last);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Nearest-rank percentile; null for no values. */
|
|
390
|
+
export function percentile(values, p) {
|
|
391
|
+
const sorted = values.filter((x) => typeof x === "number").sort((a, b) => a - b);
|
|
392
|
+
if (!sorted.length) return null;
|
|
393
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function pickTimingStats(entries) {
|
|
397
|
+
const ok = entries.filter((x) => x.outcome === "ok");
|
|
398
|
+
const spread = (key) => ({ p50: percentile(ok.map((x) => x[key]), 50), p95: percentile(ok.map((x) => x[key]), 95) });
|
|
399
|
+
const failed = {};
|
|
400
|
+
for (const x of entries) if (x.outcome !== "ok") failed[x.outcome] = (failed[x.outcome] ?? 0) + 1;
|
|
401
|
+
return { picks: entries.length, ok: ok.length, warmMs: spread("warmMs"), pickMs: spread("pickMs"), gatherPickMs: spread("totalMs"), failed };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const seconds = (value) => value === null || value === undefined ? "?" : `${(value / 1000).toFixed(1)} s`;
|
|
405
|
+
|
|
406
|
+
export function pickTimingText(stats, { model, path } = {}) {
|
|
407
|
+
if (!stats.picks) return `${model ?? "Gather picker"}: no lane picks recorded on this Mac yet${path ? ` (${path})` : ""}.`;
|
|
408
|
+
const failed = Object.entries(stats.failed).map(([code, n]) => `${n} ${code}`).join(", ");
|
|
409
|
+
return [
|
|
410
|
+
`${model ?? "Gather picker"} · ${stats.picks} lane pick${stats.picks === 1 ? "" : "s"} on this Mac${path ? ` (${path})` : ""}`,
|
|
411
|
+
` warm-up p50 ${seconds(stats.warmMs.p50)} p95 ${seconds(stats.warmMs.p95)}`,
|
|
412
|
+
` first pick p50 ${seconds(stats.pickMs.p50)} p95 ${seconds(stats.pickMs.p95)} (budget ${seconds(PICKER_FIRST_PICK_TIMEOUT_MS)} once warm)`,
|
|
413
|
+
` warm-up + pick p50 ${seconds(stats.gatherPickMs.p50)} p95 ${seconds(stats.gatherPickMs.p95)}`,
|
|
414
|
+
...(failed ? [` failed ${failed}`] : []),
|
|
415
|
+
].join("\n");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** One line for a lane Gather's output: this pick, then this Mac's spread. */
|
|
419
|
+
export function pickTimingLine(timing, stats) {
|
|
420
|
+
const spread = stats?.ok ? `; this Mac p50 ${seconds(stats.pickMs.p50)}, p95 ${seconds(stats.pickMs.p95)} over ${stats.ok} pick${stats.ok === 1 ? "" : "s"} (nodii-dev gather timings)` : "";
|
|
421
|
+
return `Picker ${timing.model}: warm-up ${seconds(timing.warmMs)}, pick ${seconds(timing.pickMs)} of its ${seconds(timing.budgetMs)} budget${spread}.`;
|
|
422
|
+
}
|
|
423
|
+
|
|
328
424
|
const LATENCY_PROBE = {
|
|
329
425
|
objective: "Add a contacts list endpoint with cursor paging.",
|
|
330
426
|
triggers: [
|
package/dist/hooks.js
CHANGED
|
@@ -254,9 +254,13 @@ export async function sessionStartContext(payload, { request = devRequest, confi
|
|
|
254
254
|
},
|
|
255
255
|
{ request, config },
|
|
256
256
|
);
|
|
257
|
+
// A Freeze written before it referenced the Clause map has no pointer to it.
|
|
258
|
+
const pointer = result.shown && !context.includes("lane gather show")
|
|
259
|
+
? `\nFull Clause map: \`nodii-dev lane gather show ${result.lane.key}\`; one clause in full: \`nodii-dev rules show <ID>\`.`
|
|
260
|
+
: "";
|
|
257
261
|
return result.shown
|
|
258
|
-
? context.trimEnd()
|
|
259
|
-
: "Freeze re-grounding: no active Dev lane with initialized Freeze was available for this worktree; run `nodii-dev lane freeze show <key>` before continuing.";
|
|
262
|
+
? `${context.trimEnd()}${pointer}`
|
|
263
|
+
: "Freeze re-grounding: no active Dev lane with initialized Freeze was available for this worktree; run `nodii-dev lane freeze show <key>` (and `nodii-dev lane gather show <key>` for its Clause map) before continuing.";
|
|
260
264
|
}
|
|
261
265
|
|
|
262
266
|
export function workflowSuggestion(payload) {
|
package/dist/lanes.js
CHANGED
|
@@ -4,7 +4,7 @@ import { promisify } from "node:util";
|
|
|
4
4
|
import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
|
|
5
5
|
import { launchRecovery } from "./runtime-launcher.js";
|
|
6
6
|
import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
|
|
7
|
-
import { GATHER_TIMEOUT_MS, PICKER_MODEL, gatherText, pickDocuments, withPickLock } from "./gather.js";
|
|
7
|
+
import { GATHER_TIMEOUT_MS, PICKER_FIRST_PICK_TIMEOUT_MS, PICKER_MODEL, agentFallbackReason, gatherDeadline, gatherText, pickDocuments, pickTimingLine, pickTimingStats, readPickTimings, recordPickTiming, warmPicker, withPickLock } from "./gather.js";
|
|
8
8
|
import { storeGatherFreeze } from "./freeze.js";
|
|
9
9
|
import { SWAP_BOUNDARIES, componentsForScope, guidelineLookup, laneFigmaScope, loadDsCatalog } from "./figma-scope.js";
|
|
10
10
|
|
|
@@ -207,7 +207,41 @@ async function currentGatherLane(request, config, gitContext) {
|
|
|
207
207
|
return matches[0].key;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
const timedWarm = async (warm, signal) => {
|
|
211
|
+
const started = Date.now();
|
|
212
|
+
const status = await warm({ signal });
|
|
213
|
+
return { wallMs: Date.now() - started, loadMs: status?.latency?.loadMs ?? null };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The lane's local pick: wait for the warm-up begun at Gather start, then the
|
|
218
|
+
* first pick on its own budget. Every pick that reached the model is logged
|
|
219
|
+
* for this Mac's p50/p95, failures included.
|
|
220
|
+
*/
|
|
221
|
+
async function localPick(lane, warming, pick, { budgetMs, timings }) {
|
|
222
|
+
const started = Date.now();
|
|
223
|
+
const warmed = await warming;
|
|
224
|
+
const pickStarted = Date.now();
|
|
225
|
+
const entry = (outcome, picked) => ({
|
|
226
|
+
at: new Date().toISOString(), lane, model: picked?.model ?? PICKER_MODEL, outcome,
|
|
227
|
+
warmMs: pickStarted - started, warmLoadMs: warmed?.loadMs ?? null,
|
|
228
|
+
pickMs: picked?.latency?.wallMs ?? Date.now() - pickStarted, loadMs: picked?.latency?.loadMs ?? null,
|
|
229
|
+
totalMs: Date.now() - started, budgetMs,
|
|
230
|
+
});
|
|
231
|
+
let picked;
|
|
232
|
+
try {
|
|
233
|
+
picked = await pick();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof CliError && ["PICKER_TIMEOUT", "PICKER_RESPONSE_INVALID"].includes(error.code)) await timings.record(entry(error.code)).catch(() => {});
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
const timing = entry("ok", picked);
|
|
239
|
+
await timings.record(timing).catch(() => {});
|
|
240
|
+
const stats = pickTimingStats(await timings.read({ model: timing.model }).catch(() => []));
|
|
241
|
+
return { ...picked, timing, stats };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, picker = pickDocuments, warm = warmPicker, firstPickTimeoutMs = PICKER_FIRST_PICK_TIMEOUT_MS, timings = { record: recordPickTiming, read: readPickTimings }, changedPaths = laneChangedPaths, gatherTimeoutMs = GATHER_TIMEOUT_MS, deadline: outerDeadline, storeFreeze = storeGatherFreeze, launch = launchRecovery, catalog = loadDsCatalog, pickLock = withPickLock } = {}) {
|
|
211
245
|
if (action === "list") {
|
|
212
246
|
const c = flags.mine ? await config() : null;
|
|
213
247
|
const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
|
|
@@ -231,6 +265,16 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
231
265
|
const payload = await request("POST", `/api/v1/lanes/${encode(key)}/start`, via);
|
|
232
266
|
return { payload, human: (p) => `Started ${laneLine(p.data)}` };
|
|
233
267
|
}
|
|
268
|
+
// The Freeze references the Clause map rather than holding it (hub-58).
|
|
269
|
+
if (action === "gather" && args[0] === "show") {
|
|
270
|
+
// A Gather flag here means a Gather was meant: never show silently instead.
|
|
271
|
+
const gatherFlags = ["picker", "shortlist", "documents", "delta", "objective", "done-check", "paths", "components", "limit", "section-limit-per-document"].filter((flag) => flags[flag] !== undefined);
|
|
272
|
+
if (gatherFlags.length)
|
|
273
|
+
throw new CliError("USAGE", `lane gather show takes only [<key>] [--gather ID]; --${gatherFlags.join(", --")} belong to lane gather.`, 2);
|
|
274
|
+
const key = need(args[1] ?? await currentGatherLane(request, config, gitContext), "lane gather show needs <key>");
|
|
275
|
+
const payload = await request("GET", `/api/v1/lanes/${encode(key)}/gather${query({ gatherId: flags.gather })}`);
|
|
276
|
+
return { payload, human: (p) => `Gather ${p.data.gatherId}\n${gatherText(p, p.data.objective ?? "")}` };
|
|
277
|
+
}
|
|
234
278
|
const ref = need(args[0] ?? (action === "gather" ? await currentGatherLane(request, config, gitContext) : undefined), `lane ${action} needs <key>`);
|
|
235
279
|
const id = ref;
|
|
236
280
|
if (action === "show") {
|
|
@@ -238,11 +282,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
238
282
|
return { payload, human: (p) => detailText(p.data) };
|
|
239
283
|
}
|
|
240
284
|
if (action === "gather") {
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
285
|
+
const deadline = outerDeadline ?? gatherDeadline(gatherTimeoutMs);
|
|
286
|
+
const signal = deadline.signal;
|
|
287
|
+
// Stops a warm-up still loading when Gather fails before its pick.
|
|
288
|
+
const warmStop = new AbortController();
|
|
244
289
|
try {
|
|
245
290
|
const byAgent = agentPickerFlags(flags);
|
|
291
|
+
// Warm the local picker at Gather start, alongside Dev's requests (GATHER_TIMEOUT, 30 Oct).
|
|
292
|
+
const warming = byAgent ? null : timedWarm(warm, warmStop.signal);
|
|
293
|
+
warming?.catch(() => {});
|
|
246
294
|
const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`, undefined, { signal })).data;
|
|
247
295
|
if (flags.objective !== undefined && (typeof flags.objective !== "string" || !flags.objective.trim()))
|
|
248
296
|
throw new CliError("USAGE", "lane gather --objective needs non-empty text", 2);
|
|
@@ -343,7 +391,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
343
391
|
return { payload: { data: { recorded: false, picker: "agent", shortlistId, objective, triggers } }, human: (p) => agentShortlistText(ref, p.data, flags) };
|
|
344
392
|
// Only the local model pick takes the per-lane pick lock; an agent pick
|
|
345
393
|
// records a choice already made (AGENT-HARNESS-19 + GATHER-NA).
|
|
346
|
-
const
|
|
394
|
+
const pickInput = {
|
|
347
395
|
objective,
|
|
348
396
|
files: paths,
|
|
349
397
|
triggers: triggers.map((trigger) => ({
|
|
@@ -351,7 +399,19 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
351
399
|
trigger: trigger.sentence,
|
|
352
400
|
...(trigger.forcedBy ? { forcedBy: trigger.forcedBy } : {}),
|
|
353
401
|
})),
|
|
354
|
-
}
|
|
402
|
+
};
|
|
403
|
+
let picked = byAgent && { documents: byAgent.documents };
|
|
404
|
+
if (!picked) {
|
|
405
|
+
try {
|
|
406
|
+
// Dev's deadline stops while the pick runs on its own budgets.
|
|
407
|
+
picked = await deadline.hold(() => localPick(ref, warming, () => pickLock(lane.id ?? id, () => picker(pickInput, { signal, timeoutMs: firstPickTimeoutMs })), { budgetMs: firstPickTimeoutMs, timings }));
|
|
408
|
+
} catch (error) {
|
|
409
|
+
// No picker on this Mac: the agent picks from the same shortlist (hub-58).
|
|
410
|
+
const fallback = agentFallbackReason(error);
|
|
411
|
+
if (!fallback) throw error;
|
|
412
|
+
return { payload: { data: { recorded: false, picker: "agent", fallback, shortlistId, objective, triggers } }, human: (p) => `picker: agent (${p.data.fallback})\n${agentShortlistText(ref, p.data, flags)}` };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
355
415
|
const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
|
|
356
416
|
objective,
|
|
357
417
|
documentCodes: picked.documents,
|
|
@@ -391,13 +451,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
391
451
|
...(invalidScope ? [`Notice: the launch Figma scope is not published keys (${invalidScope.replace(/\.$/, "")}), so the Freeze was stored with no Figma scope and every Figma lock and write is refused. Set the published keys with ${SWAP_BOUNDARIES(id)}.`] : []),
|
|
392
452
|
].map((line) => `\n${line}`).join("");
|
|
393
453
|
// The pick's model and latency ride along in --json (morning check records them).
|
|
394
|
-
const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null } };
|
|
395
|
-
|
|
454
|
+
const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null, ...(picked.timing ? { timing: picked.timing, stats: picked.stats } : {}) } };
|
|
455
|
+
const timingLine = picked.timing ? `\n${pickTimingLine(picked.timing, picked.stats)}` : "";
|
|
456
|
+
return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}${timingLine}` };
|
|
396
457
|
} catch (error) {
|
|
397
|
-
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded
|
|
458
|
+
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather's Dev requests exceeded their ${Math.round(deadline.ms / 1000)}-second deadline (the local pick runs on its own budget); nothing was retried.`, 1);
|
|
398
459
|
throw error;
|
|
399
460
|
} finally {
|
|
400
|
-
|
|
461
|
+
warmStop.abort();
|
|
462
|
+
if (!outerDeadline) deadline.clear();
|
|
401
463
|
}
|
|
402
464
|
}
|
|
403
465
|
if (action === "step") {
|