severally-mcp 2.4.0 → 2.5.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/CONFIG.md +1 -0
- package/README.ja.md +44 -19
- package/README.md +44 -18
- package/config.example.json +1 -1
- package/docs/how-it-works.ja.md +2 -0
- package/docs/how-it-works.md +2 -0
- package/package.json +3 -1
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +630 -271
- package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +57 -54
- package/scripts/doctor.mjs +15 -4
- package/scripts/init-config.mjs +2 -1
- package/scripts/install.mjs +73 -9
- package/scripts/live-check.mjs +11 -5
- package/scripts/live-mcp-check.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +24 -6
- package/src/adapters/antigravity.mjs +80 -17
- package/src/adapters/claude-code.mjs +49 -8
- package/src/adapters/codex.mjs +56 -6
- package/src/adapters/opencode-sandbox.mjs +20 -2
- package/src/adapters/opencode.mjs +115 -19
- package/src/brief.mjs +84 -7
- package/src/consultants.mjs +193 -27
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +50 -6
- package/src/expose-paths.mjs +149 -7
- package/src/failures.mjs +8 -1
- package/src/jobs.mjs +632 -152
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +91 -14
- package/src/platform.mjs +52 -2
- package/src/policy.mjs +151 -28
- package/src/redact.mjs +22 -3
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +158 -12
- package/src/schema.mjs +84 -11
- package/src/server.mjs +49 -14
- package/src/status.mjs +16 -4
- package/src/store.mjs +287 -6
package/src/jobs.mjs
CHANGED
|
@@ -12,9 +12,10 @@ import {
|
|
|
12
12
|
detectCaller,
|
|
13
13
|
configProblem,
|
|
14
14
|
consultantByName,
|
|
15
|
+
consultantNamed,
|
|
15
16
|
consultantAvailable,
|
|
16
17
|
} from './policy.mjs';
|
|
17
|
-
import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
|
|
18
|
+
import { vendorOf, sameModel, CLI_VENDORS, CLI_IDS } from './consultants.mjs';
|
|
18
19
|
import { parseRequest, RequestError, charCount } from './schema.mjs';
|
|
19
20
|
import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
|
|
20
21
|
import { CONSULT_RESULT_SCHEMA } from './result-schema.mjs';
|
|
@@ -29,6 +30,118 @@ import * as claudeCode from './adapters/claude-code.mjs';
|
|
|
29
30
|
import * as antigravity from './adapters/antigravity.mjs';
|
|
30
31
|
import * as opencode from './adapters/opencode.mjs';
|
|
31
32
|
|
|
33
|
+
/** @typedef {import('./run.mjs').Adapter} Adapter */
|
|
34
|
+
/** @typedef {import('./run.mjs').ProcHandle} ProcHandle */
|
|
35
|
+
/** @typedef {import('./consultants.mjs').Consultant} Consultant */
|
|
36
|
+
/** @typedef {import('./consultants.mjs').CliId} CliId */
|
|
37
|
+
/** @typedef {import('./store.mjs').Group} Group */
|
|
38
|
+
/** @typedef {import('./store.mjs').Seat} Seat */
|
|
39
|
+
/** @typedef {NonNullable<Seat['owner']>} JobOwner Who runs a consultation: the severally process, and when it counts as overdue. */
|
|
40
|
+
/** @typedef {import('./brief.mjs').PriorRound} PriorRound */
|
|
41
|
+
/** @typedef {import('./schema.mjs').ParsedRequest & {_snapshot?: string}} ParsedRequest A validated request (see parseRequest), with the copy of the exposed files the job manager adds. */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The tool arguments, not yet validated; only the keys inherit() and start() look at are named.
|
|
45
|
+
* @typedef {{target?: unknown, targets?: unknown[] | null | undefined, network?: unknown, context?: Record<string, unknown> | null | undefined, [key: string]: unknown}} RawRequest
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** @typedef {import('./store.mjs').JobFailure} JobFailure */
|
|
49
|
+
/** @typedef {import('./store.mjs').RecordEntry} RecordEntry */
|
|
50
|
+
/** @typedef {import('./store.mjs').JobRecord} JobRecord */
|
|
51
|
+
/** @typedef {import('./store.mjs').Reflection} Reflection */
|
|
52
|
+
/** @typedef {import('./store.mjs').Prediction} Prediction */
|
|
53
|
+
/** @typedef {import('./store.mjs').ResultItem} ResultItem */
|
|
54
|
+
/** @typedef {import('./store.mjs').ConsultResult} ConsultResult */
|
|
55
|
+
/** @typedef {import('./store.mjs').Round} Round */
|
|
56
|
+
/** @typedef {import('./store.mjs').BriefRecord} BriefRecord */
|
|
57
|
+
/** @typedef {import('./store.mjs').RetryRecord} RetryRecord */
|
|
58
|
+
/** @typedef {import('./store.mjs').IndexRow} IndexRow */
|
|
59
|
+
/** @typedef {import('./store.mjs').BriefExposeEntry} BriefExposeEntry */
|
|
60
|
+
|
|
61
|
+
/** @typedef {import('./store.mjs').RunningRecord} StoredRound A round, or the marker of one still running elsewhere. */
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A consultation this process runs: the round, plus what only a live process has.
|
|
65
|
+
* @typedef {Round & {group_id: string, caller: string | null, retries: RetryRecord[], cancelRequested: boolean, _cancelFns: Array<() => void>, _request: ParsedRequest, _owner?: JobOwner | undefined, _handle?: ProcHandle | undefined, promise?: Promise<unknown>}} Job
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a view adds to a round.
|
|
70
|
+
* @typedef {object} ViewExtras
|
|
71
|
+
* @property {boolean} [cancellable]
|
|
72
|
+
* @property {string | null} [progress]
|
|
73
|
+
* @property {string} [brief_note]
|
|
74
|
+
* @property {ReturnType<typeof limitsSummary> | null} [limits]
|
|
75
|
+
* @property {string} [next_step]
|
|
76
|
+
* @property {string} [cancel_effect]
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The brief as consult_get names it by default: its size and sha256, where it is kept, what was copied.
|
|
81
|
+
* @typedef {object} BriefSummary
|
|
82
|
+
* @property {number} chars
|
|
83
|
+
* @property {string} sha256
|
|
84
|
+
* @property {string} history_file
|
|
85
|
+
* @property {({skipped: Record<string, number>} & import('./expose-paths.mjs').ExposeTotals) | null} exposed
|
|
86
|
+
* @property {string} full_with
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/** @typedef {Round & ViewExtras} FullView A round as consult_get shows it with `detail: "full"`. */
|
|
90
|
+
|
|
91
|
+
/** @typedef {Omit<Round, 'brief'> & ViewExtras & {brief: BriefRecord | BriefSummary | null}} JobView A round as consult_get shows it. */
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A consult_record call, or one of its `records`, as sent: not yet validated. The job_id is only ever looked up, so it is not narrowed.
|
|
95
|
+
* @typedef {{job_id?: any, entries?: Array<{id?: unknown, verdict?: unknown, effect?: unknown, note?: unknown} | null>, reflection?: {delta?: unknown, related_item_ids?: unknown} | null, [key: string]: unknown}} RawRecord
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/** @typedef {{job: Round, live: Job | undefined, entries?: RecordEntry[] | undefined, reflection?: Reflection | undefined}} RecordChange What a record call would write, checked and not yet written: only the entries this call names. */
|
|
99
|
+
|
|
100
|
+
/** @typedef {import('./expose-paths.mjs').ExposeManifest} ExposeManifest */
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {object} GroupLink
|
|
104
|
+
* @property {Round & {nextRound: number}} parent
|
|
105
|
+
* @property {number} round
|
|
106
|
+
* @property {Round} [groupMember]
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A follow-up to a fan-out: the group, where each member goes on from, and
|
|
111
|
+
* the members that cannot take one.
|
|
112
|
+
* @typedef {object} GroupParents
|
|
113
|
+
* @property {Group} group
|
|
114
|
+
* @property {GroupLink[]} links
|
|
115
|
+
* @property {Array<{job_id: string, target: string | null, reason: string}>} skipped
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One consultant's column of a comparison.
|
|
120
|
+
* @typedef {object} ComparisonColumn
|
|
121
|
+
* @property {string} job_id
|
|
122
|
+
* @property {string} target
|
|
123
|
+
* @property {string} [cli]
|
|
124
|
+
* @property {string} [status]
|
|
125
|
+
* @property {string | null} [failure_kind]
|
|
126
|
+
* @property {string | null} stance
|
|
127
|
+
* @property {string | null} confidence
|
|
128
|
+
* @property {string | null} evidence_basis
|
|
129
|
+
* @property {string | null | undefined} summary
|
|
130
|
+
* @property {unknown[]} finding_points
|
|
131
|
+
* @property {Record<string, number>} severity_counts
|
|
132
|
+
* @property {unknown[]} decision_changers
|
|
133
|
+
* @property {unknown[]} alternative_options
|
|
134
|
+
* @property {unknown[]} unknowns
|
|
135
|
+
* @property {unknown[]} next_checks
|
|
136
|
+
* @property {unknown[]} remaining_disagreements
|
|
137
|
+
* @property {string | null | undefined} caveat
|
|
138
|
+
* @property {number | null} points_without_verdict
|
|
139
|
+
* @property {Record<string, number>} [omitted]
|
|
140
|
+
* @property {boolean} [truncated]
|
|
141
|
+
* @property {string} [full_with]
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/** @type {Record<CliId, Adapter> & Record<string, Adapter | undefined>} */
|
|
32
145
|
const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
|
|
33
146
|
|
|
34
147
|
// The recovery respawn runs only while this much of the original budget is
|
|
@@ -55,6 +168,7 @@ const API_KEY_VARS = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
|
|
|
55
168
|
const CREDENTIAL_FILE_VARS = ['GOOGLE_APPLICATION_CREDENTIALS'];
|
|
56
169
|
const ALL_API_CREDENTIAL_VARS = [...API_KEY_VARS, ...CREDENTIAL_FILE_VARS];
|
|
57
170
|
|
|
171
|
+
/** @param {Record<string, string | undefined>} env */
|
|
58
172
|
function hasApiCredential(env) {
|
|
59
173
|
if (API_KEY_VARS.some((name) => env[name])) return true;
|
|
60
174
|
return CREDENTIAL_FILE_VARS.some((name) => env[name] && fs.existsSync(env[name]));
|
|
@@ -64,11 +178,12 @@ function hasApiCredential(env) {
|
|
|
64
178
|
// listed as not reported rather than silently missing from the totals.
|
|
65
179
|
const USAGE_FIGURES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'total_tokens', 'cost_usd'];
|
|
66
180
|
|
|
67
|
-
const id = (prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
|
|
181
|
+
const id = (/** @type {string} */ prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
|
|
68
182
|
|
|
69
183
|
// Jobs this process is running, across every JobManager in it. A marker owned
|
|
70
184
|
// by this pid but missing here was left by an earlier process that had the
|
|
71
185
|
// same pid, so it is as dead as one whose pid no longer exists.
|
|
186
|
+
/** @type {Set<string>} */
|
|
72
187
|
const LIVE_HERE = new Set();
|
|
73
188
|
|
|
74
189
|
// What became of the process that started a consultation: 'alive', 'dead',
|
|
@@ -76,6 +191,11 @@ const LIVE_HERE = new Set();
|
|
|
76
191
|
// (budget, retries included, plus the kill grace), so either that process is
|
|
77
192
|
// stuck or the pid now belongs to something else. Nothing is killed on the
|
|
78
193
|
// strength of this.
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} jobId
|
|
196
|
+
* @param {JobOwner | null | undefined} owner
|
|
197
|
+
* @returns {'alive' | 'dead' | 'overdue'}
|
|
198
|
+
*/
|
|
79
199
|
function ownerState(jobId, owner) {
|
|
80
200
|
if (!owner) return 'dead';
|
|
81
201
|
let pidAlive;
|
|
@@ -85,7 +205,7 @@ function ownerState(jobId, owner) {
|
|
|
85
205
|
process.kill(owner.pid, 0);
|
|
86
206
|
pidAlive = true;
|
|
87
207
|
} catch (err) {
|
|
88
|
-
pidAlive = err.code === 'EPERM';
|
|
208
|
+
pidAlive = err instanceof Error && 'code' in err && err.code === 'EPERM';
|
|
89
209
|
}
|
|
90
210
|
if (!pidAlive) return 'dead';
|
|
91
211
|
return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
|
|
@@ -93,9 +213,13 @@ function ownerState(jobId, owner) {
|
|
|
93
213
|
|
|
94
214
|
export class JobManager {
|
|
95
215
|
constructor() {
|
|
216
|
+
/** @type {NodeJS.Timeout | null} */
|
|
96
217
|
this._cancelWatch = null;
|
|
218
|
+
/** @type {Map<string, Job>} */
|
|
97
219
|
this.jobs = new Map();
|
|
220
|
+
/** @type {Map<string, Group>} */
|
|
98
221
|
this.groups = new Map();
|
|
222
|
+
/** @type {string[]} */
|
|
99
223
|
this.order = [];
|
|
100
224
|
store.ensureDirs();
|
|
101
225
|
}
|
|
@@ -103,6 +227,10 @@ export class JobManager {
|
|
|
103
227
|
// A consultation this process does not hold: finished (its round file), or
|
|
104
228
|
// still in progress (its marker). A marker whose owner is gone becomes a
|
|
105
229
|
// failed round, `interrupted`, written once by whichever process claims it.
|
|
230
|
+
/**
|
|
231
|
+
* @param {string} jobId
|
|
232
|
+
* @returns {StoredRound | null}
|
|
233
|
+
*/
|
|
106
234
|
#fromDisk(jobId) {
|
|
107
235
|
const done = store.loadRound(jobId);
|
|
108
236
|
if (done) return done;
|
|
@@ -117,6 +245,12 @@ export class JobManager {
|
|
|
117
245
|
// something the next reader finishes: the round is written (the index
|
|
118
246
|
// takes it once), then the marker goes. A round already on disk wins -- its
|
|
119
247
|
// owner finished and died before removing the marker.
|
|
248
|
+
/**
|
|
249
|
+
* @param {string} jobId
|
|
250
|
+
* @param {StoredRound} rec
|
|
251
|
+
* @param {'alive' | 'dead' | 'overdue'} state
|
|
252
|
+
* @returns {Round}
|
|
253
|
+
*/
|
|
120
254
|
#interrupt(jobId, rec, state) {
|
|
121
255
|
const done = store.loadRound(jobId);
|
|
122
256
|
if (done) {
|
|
@@ -151,22 +285,26 @@ export class JobManager {
|
|
|
151
285
|
for (const jobId of store.listRunning()) {
|
|
152
286
|
const rec = store.loadRunning(jobId);
|
|
153
287
|
const state = rec && ownerState(jobId, rec.owner);
|
|
154
|
-
if (rec && state !== 'alive') this.#interrupt(jobId, rec, state);
|
|
288
|
+
if (rec && state && state !== 'alive') this.#interrupt(jobId, rec, state);
|
|
155
289
|
}
|
|
156
290
|
}
|
|
157
291
|
|
|
292
|
+
/**
|
|
293
|
+
* @param {StoredRound} rec
|
|
294
|
+
* @returns {FullView}
|
|
295
|
+
*/
|
|
158
296
|
#diskView(rec) {
|
|
159
|
-
const elsewhere = Boolean(rec.owner);
|
|
160
297
|
const { owner, ...view } = rec;
|
|
161
298
|
return {
|
|
162
299
|
...view,
|
|
163
|
-
|
|
300
|
+
// consult_cancel asks the process that owns it to stop it.
|
|
301
|
+
cancellable: Boolean(owner) && (view.status === 'running' || view.status === 'queued'),
|
|
164
302
|
progress: null,
|
|
165
303
|
record: view.record ? recordSummary(view) : null,
|
|
166
|
-
brief:
|
|
167
|
-
limits:
|
|
168
|
-
next_step:
|
|
169
|
-
? `running in another severally process (pid ${owner.pid}); poll again later
|
|
304
|
+
brief: owner ? null : view.brief,
|
|
305
|
+
limits: owner ? null : limitsSummary(),
|
|
306
|
+
next_step: owner
|
|
307
|
+
? `running in another severally process (pid ${owner.pid}); poll again later, or stop it with consult_cancel`
|
|
170
308
|
: nextStep(view),
|
|
171
309
|
};
|
|
172
310
|
}
|
|
@@ -175,6 +313,10 @@ export class JobManager {
|
|
|
175
313
|
return [...this.jobs.values()].filter((j) => j.status === 'running');
|
|
176
314
|
}
|
|
177
315
|
|
|
316
|
+
/**
|
|
317
|
+
* @param {Job} job
|
|
318
|
+
* @returns {Round}
|
|
319
|
+
*/
|
|
178
320
|
#record(job) {
|
|
179
321
|
return {
|
|
180
322
|
job_id: job.job_id,
|
|
@@ -218,13 +360,27 @@ export class JobManager {
|
|
|
218
360
|
|
|
219
361
|
// `detail: "full"` returns the brief as stored; by default a finished
|
|
220
362
|
// consultation names it (size, sha256, round file) instead of repeating it.
|
|
221
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* @param {string} jobId
|
|
365
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
366
|
+
* @returns {JobView | null}
|
|
367
|
+
*/
|
|
222
368
|
view(jobId, { detail } = {}) {
|
|
223
369
|
const job = this.jobs.get(jobId);
|
|
224
370
|
if (!job) {
|
|
225
371
|
const rec = this.#fromDisk(jobId);
|
|
226
372
|
return rec ? compactBrief(this.#diskView(rec), detail) : null;
|
|
227
373
|
}
|
|
374
|
+
return this.#jobView(job, detail);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param {Job} job
|
|
379
|
+
* @param {string | undefined} detail
|
|
380
|
+
* @returns {JobView}
|
|
381
|
+
*/
|
|
382
|
+
#jobView(job, detail) {
|
|
383
|
+
/** @type {FullView} */
|
|
228
384
|
const rec = this.#record(job);
|
|
229
385
|
rec.cancellable = job.status === 'running' || job.status === 'queued';
|
|
230
386
|
// While it runs, say what it is doing. "running" for twenty minutes tells
|
|
@@ -259,10 +415,18 @@ export class JobManager {
|
|
|
259
415
|
// job_id and group_id can still be followed up and recorded against. The
|
|
260
416
|
// index only picks and orders; each row shown is read from its round, which
|
|
261
417
|
// holds the verdicts written since. Memory wins over disk for the same job.
|
|
418
|
+
/** @param {{limit?: number}} [opts] */
|
|
262
419
|
list({ limit = 20 } = {}) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
420
|
+
// Sorted by when each was created; a round file is read only for the rows shown.
|
|
421
|
+
/** @type {Array<{at: string, load: () => Round | null}>} */
|
|
422
|
+
const candidates = [];
|
|
423
|
+
const seen = new Set();
|
|
424
|
+
for (const jid of this.order) {
|
|
425
|
+
const job = this.jobs.get(jid);
|
|
426
|
+
if (!job) continue;
|
|
427
|
+
seen.add(job.job_id);
|
|
428
|
+
candidates.push({ at: job.created_at ?? '', load: () => job });
|
|
429
|
+
}
|
|
266
430
|
// Read only: a marker whose owner is gone is shown as interrupted, and
|
|
267
431
|
// left for consult_get or the startup sweep to close out.
|
|
268
432
|
for (const jobId of store.listRunning()) {
|
|
@@ -272,21 +436,24 @@ export class JobManager {
|
|
|
272
436
|
const state = ownerState(jobId, live.owner);
|
|
273
437
|
// A round saved just before its owner died is the answer, whatever the marker says.
|
|
274
438
|
const saved = state === 'alive' ? null : store.loadRoundAt(live);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
439
|
+
// The marker of a round whose owner is gone: shown as interrupted, with no failure detail to give.
|
|
440
|
+
const rec =
|
|
441
|
+
state === 'alive'
|
|
442
|
+
? live
|
|
443
|
+
: (saved ?? /** @type {Round} */ ({ ...live, status: 'failed', failure: { kind: 'interrupted' } }));
|
|
444
|
+
candidates.push({ at: rec.created_at ?? '', load: () => rec });
|
|
278
445
|
}
|
|
279
446
|
for (const row of store.indexRows())
|
|
280
447
|
if (!seen.has(row.job_id)) {
|
|
281
448
|
seen.add(row.job_id);
|
|
282
|
-
|
|
449
|
+
candidates.push({ at: row.created_at ?? '', load: () => store.loadRoundAt(row) });
|
|
283
450
|
}
|
|
284
|
-
|
|
451
|
+
/** @type {Round[]} */
|
|
285
452
|
const picked = [];
|
|
286
|
-
//
|
|
287
|
-
for (const x of
|
|
453
|
+
// One that cannot be read is passed over.
|
|
454
|
+
for (const x of candidates.sort((p, q) => (q.at < p.at ? -1 : q.at > p.at ? 1 : 0))) {
|
|
288
455
|
if (picked.length >= limit) break;
|
|
289
|
-
const j = x.
|
|
456
|
+
const j = x.load();
|
|
290
457
|
if (j) picked.push(j);
|
|
291
458
|
}
|
|
292
459
|
return picked.map((j) => {
|
|
@@ -321,10 +488,12 @@ export class JobManager {
|
|
|
321
488
|
* but named under `not_reported`. Nothing is estimated.
|
|
322
489
|
*/
|
|
323
490
|
usageTotals() {
|
|
491
|
+
/** @type {Record<string, Record<string, number> & {consultations: number, attempts: number}>} */
|
|
324
492
|
const by = {};
|
|
493
|
+
/** @type {Record<string, Set<string>>} */
|
|
325
494
|
const reported = {};
|
|
326
495
|
for (const j of this.jobs.values()) {
|
|
327
|
-
const attempts = [...(j.retries ?? []).map((r) => r.usage), j.usage].filter(
|
|
496
|
+
const attempts = [...(j.retries ?? []).map((r) => r.usage), j.usage].filter((u) => u != null);
|
|
328
497
|
if (!attempts.length) continue;
|
|
329
498
|
const t = (by[j.target] ??= { consultations: 0, attempts: 0 });
|
|
330
499
|
const seen = (reported[j.target] ??= new Set());
|
|
@@ -338,12 +507,15 @@ export class JobManager {
|
|
|
338
507
|
}
|
|
339
508
|
}
|
|
340
509
|
}
|
|
510
|
+
/** @type {Record<string, Record<string, number | string[]>>} */
|
|
511
|
+
const out = {};
|
|
341
512
|
for (const [target, t] of Object.entries(by)) {
|
|
342
|
-
t
|
|
513
|
+
out[target] = { ...t, not_reported: USAGE_FIGURES.filter((k) => !reported[target]?.has(k)) };
|
|
343
514
|
}
|
|
344
|
-
return
|
|
515
|
+
return out;
|
|
345
516
|
}
|
|
346
517
|
|
|
518
|
+
/** @param {RawRequest} rawRequest the tool arguments, not yet validated */
|
|
347
519
|
start(rawRequest) {
|
|
348
520
|
if (process.env.SEVERALLY_ACTIVE === '1') {
|
|
349
521
|
throw new RequestError(
|
|
@@ -359,7 +531,9 @@ export class JobManager {
|
|
|
359
531
|
// Resolve the chain before validating, so follow-up rules can be applied.
|
|
360
532
|
const followupTo =
|
|
361
533
|
typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
|
|
534
|
+
/** @type {(Round & {nextRound: number}) | null} */
|
|
362
535
|
let parent = null;
|
|
536
|
+
/** @type {GroupParents | null} */
|
|
363
537
|
let fromGroup = null;
|
|
364
538
|
if (followupTo?.startsWith('group_')) {
|
|
365
539
|
// A follow-up to a fan-out goes to every member that can take one; the
|
|
@@ -375,11 +549,11 @@ export class JobManager {
|
|
|
375
549
|
} else if (followupTo) {
|
|
376
550
|
// A job from an earlier server session is read back from the history,
|
|
377
551
|
// so a restart does not end a consultation the lead is still checking.
|
|
378
|
-
|
|
379
|
-
if (!
|
|
380
|
-
if (
|
|
552
|
+
const found = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
|
|
553
|
+
if (!found) throw new RequestError(`followup_to references unknown job "${followupTo}"`, 'unknown_job');
|
|
554
|
+
if (found.status !== 'completed') {
|
|
381
555
|
throw new RequestError(
|
|
382
|
-
`followup_to job "${followupTo}" is ${
|
|
556
|
+
`followup_to job "${followupTo}" is ${found.status}; only a completed consultation can be followed up`,
|
|
383
557
|
'followup_not_completed',
|
|
384
558
|
);
|
|
385
559
|
}
|
|
@@ -387,23 +561,24 @@ export class JobManager {
|
|
|
387
561
|
// round goes after every round already there: numbering it parent + 1
|
|
388
562
|
// would overwrite a later round's history file. A later round that
|
|
389
563
|
// failed or was cancelled does not block a retry from the parent.
|
|
390
|
-
const rounds = this.#chainRounds(
|
|
391
|
-
const later = rounds.find((j) => j.round >
|
|
564
|
+
const rounds = this.#chainRounds(found.chain_id);
|
|
565
|
+
const later = rounds.find((j) => j.round > found.round && !['failed', 'cancelled'].includes(j.status));
|
|
392
566
|
if (later) {
|
|
393
567
|
throw new RequestError(
|
|
394
|
-
`job "${followupTo}" is round ${
|
|
568
|
+
`job "${followupTo}" is round ${found.round}, and this consultation already has round ${later.round} (job "${later.job_id}", ${later.status}); follow up the latest round`,
|
|
395
569
|
'followup_not_latest',
|
|
396
570
|
);
|
|
397
571
|
}
|
|
398
|
-
parent = { ...
|
|
572
|
+
parent = { ...found, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
|
|
399
573
|
}
|
|
400
574
|
|
|
401
575
|
if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
|
|
402
576
|
rawRequest = { ...rawRequest, target: parent.target };
|
|
403
577
|
}
|
|
404
578
|
// A fan-out's members were all handed the same brief, so any one of them holds it.
|
|
405
|
-
|
|
406
|
-
|
|
579
|
+
const first = fromGroup?.links[0];
|
|
580
|
+
const inheritFrom = first ? (first.groupMember ?? first.parent) : parent;
|
|
581
|
+
if (inheritFrom) rawRequest = inherit(rawRequest, inheritFrom);
|
|
407
582
|
|
|
408
583
|
const req = parseRequest(rawRequest, {
|
|
409
584
|
isFollowup: Boolean(parent || fromGroup),
|
|
@@ -439,19 +614,22 @@ export class JobManager {
|
|
|
439
614
|
// A follow-up's seat also names the chain and round it opens, so that two
|
|
440
615
|
// servers continuing one consultation at once cannot both take a round
|
|
441
616
|
// number: neither sees the other's round in its history until it ends.
|
|
442
|
-
const chainOf = (i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
|
|
443
|
-
const seats = req.targets.map((target, i) =>
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
617
|
+
const chainOf = (/** @type {number} */ i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
|
|
618
|
+
const seats = req.targets.map((/** @type {string} */ target, /** @type {number} */ i) => {
|
|
619
|
+
const link = chainOf(i);
|
|
620
|
+
return {
|
|
621
|
+
job_id: id('job'),
|
|
622
|
+
...(link ? { chain_id: link.parent.chain_id, round: link.round } : {}),
|
|
623
|
+
// Twice the budget plus an hour: the deadline is wall-clock, the budget is
|
|
624
|
+
// not, so a suspended laptop must not make a live owner look overdue.
|
|
625
|
+
owner: {
|
|
626
|
+
pid: process.pid,
|
|
627
|
+
deadline_at: new Date(
|
|
628
|
+
Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000,
|
|
629
|
+
).toISOString(),
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
});
|
|
455
633
|
const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
|
|
456
634
|
if (taken.chainBusy) {
|
|
457
635
|
const { live, mine } = taken.chainBusy;
|
|
@@ -472,6 +650,7 @@ export class JobManager {
|
|
|
472
650
|
// A request refused from here on gives its seats back.
|
|
473
651
|
try {
|
|
474
652
|
// Each target's parent round and the round it opens, in req.targets order.
|
|
653
|
+
/** @type {Array<{parent: Round | null, round: number, groupMember?: Round}>} */
|
|
475
654
|
const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
|
|
476
655
|
return this.#admitted(req, links, seats, fromGroup);
|
|
477
656
|
} catch (err) {
|
|
@@ -484,10 +663,16 @@ export class JobManager {
|
|
|
484
663
|
// round of its own chain. A member that is still running, never answered or
|
|
485
664
|
// has used its rounds is skipped and named; if that is every member, the
|
|
486
665
|
// request is refused.
|
|
666
|
+
/**
|
|
667
|
+
* @param {string} groupId
|
|
668
|
+
* @returns {GroupParents}
|
|
669
|
+
*/
|
|
487
670
|
#groupParents(groupId) {
|
|
488
671
|
const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
|
|
489
672
|
if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, 'unknown_group');
|
|
673
|
+
/** @type {GroupLink[]} */
|
|
490
674
|
const links = [];
|
|
675
|
+
/** @type {GroupParents['skipped']} */
|
|
491
676
|
const skipped = [];
|
|
492
677
|
for (const jid of group.job_ids) {
|
|
493
678
|
const member = this.jobs.get(jid) ?? store.loadRound(jid);
|
|
@@ -501,20 +686,27 @@ export class JobManager {
|
|
|
501
686
|
}
|
|
502
687
|
const rounds = this.#chainRounds(member.chain_id);
|
|
503
688
|
const latest = rounds.filter((j) => !['failed', 'cancelled'].includes(j.status)).sort((a, b) => b.round - a.round)[0];
|
|
689
|
+
if (!latest) {
|
|
690
|
+
skipped.push({
|
|
691
|
+
job_id: member.job_id,
|
|
692
|
+
target: member.target,
|
|
693
|
+
reason: `${member.status}, so there is no answer to follow up`,
|
|
694
|
+
});
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
504
697
|
const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
|
|
505
698
|
// The name is kept, not re-resolved: a consultant renamed or turned off
|
|
506
699
|
// since is skipped, never continued by whichever one answers to it now.
|
|
507
700
|
const consultant = consultantByName(member.target);
|
|
508
|
-
const reason =
|
|
509
|
-
|
|
510
|
-
: latest.status !== 'completed'
|
|
701
|
+
const reason =
|
|
702
|
+
latest.status !== 'completed'
|
|
511
703
|
? `round ${latest.round} is still ${latest.status}`
|
|
512
704
|
: nextRound > POLICY.maxRounds
|
|
513
705
|
? `has used its ${POLICY.maxRounds} rounds`
|
|
514
706
|
: !consultant || !consultantAvailable(consultant)
|
|
515
707
|
? 'this consultant is no longer configured or available here'
|
|
516
708
|
: null;
|
|
517
|
-
if (reason) skipped.push({ job_id:
|
|
709
|
+
if (reason) skipped.push({ job_id: latest.job_id, target: member.target, reason });
|
|
518
710
|
else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
|
|
519
711
|
}
|
|
520
712
|
if (!links.length) {
|
|
@@ -526,21 +718,30 @@ export class JobManager {
|
|
|
526
718
|
return { group, links, skipped };
|
|
527
719
|
}
|
|
528
720
|
|
|
721
|
+
/**
|
|
722
|
+
* @param {ParsedRequest} req
|
|
723
|
+
* @param {Array<{parent: Round | null, round: number, groupMember?: Round}>} links
|
|
724
|
+
* @param {Seat[]} seats
|
|
725
|
+
* @param {GroupParents | null} [fromGroup]
|
|
726
|
+
*/
|
|
529
727
|
#admitted(req, links, seats, fromGroup = null) {
|
|
530
728
|
// What expose_from_parent copies again: for a group, what that fan-out
|
|
531
729
|
// exposed (one manifest, shared by its members); otherwise the parent's.
|
|
532
|
-
const
|
|
730
|
+
const first = links[0];
|
|
731
|
+
const parent = first?.groupMember ?? first?.parent;
|
|
533
732
|
const groupId = id('group');
|
|
534
733
|
// Copied once for the whole request, before a round is counted or a job
|
|
535
734
|
// exists, into the group's own directory: every member is then handed the
|
|
536
735
|
// same bytes (the manifest carries their sha256), and a path that is
|
|
537
736
|
// missing or a tree that blows the cap is the lead's to fix now rather
|
|
538
737
|
// than a job that fails having already spent a round of this chain.
|
|
738
|
+
/** @type {ExposeManifest | null} */
|
|
539
739
|
let exposeManifest = null;
|
|
540
|
-
|
|
740
|
+
/** @type {BriefExposeEntry[]} */
|
|
741
|
+
const parentEntries = req.context.expose_from_parent ? (parent?.brief?.expose_paths?.entries ?? []) : [];
|
|
541
742
|
if (req.context.expose_from_parent && parentEntries.length === 0) {
|
|
542
743
|
throw new RequestError(
|
|
543
|
-
`context.expose_from_parent: job "${parent
|
|
744
|
+
`context.expose_from_parent: job "${parent?.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
|
|
544
745
|
'nothing_to_inherit',
|
|
545
746
|
);
|
|
546
747
|
}
|
|
@@ -564,7 +765,10 @@ export class JobManager {
|
|
|
564
765
|
if (req.context.expose_diff) {
|
|
565
766
|
exposeManifest.skipped.push({ source: d.source, reason: 'replaced', exposed_as: d.exposed_as });
|
|
566
767
|
} else {
|
|
567
|
-
if (reexposeChange(snapshot, d, exposeManifest))
|
|
768
|
+
if (reexposeChange(snapshot, d, exposeManifest)) {
|
|
769
|
+
const added = exposeManifest.entries.at(-1);
|
|
770
|
+
if (added) added.inherited = true;
|
|
771
|
+
}
|
|
568
772
|
}
|
|
569
773
|
}
|
|
570
774
|
if (req.context.expose_diff) {
|
|
@@ -573,7 +777,7 @@ export class JobManager {
|
|
|
573
777
|
if (diff) {
|
|
574
778
|
req.context.artifacts = [
|
|
575
779
|
...req.context.artifacts,
|
|
576
|
-
{ name: `git diff ${exposeManifest.entries.at(-1)
|
|
780
|
+
{ name: `git diff ${exposeManifest.entries.at(-1)?.base}`, kind: 'diff', language: 'diff', excerpt: diff },
|
|
577
781
|
];
|
|
578
782
|
}
|
|
579
783
|
}
|
|
@@ -587,21 +791,27 @@ export class JobManager {
|
|
|
587
791
|
!req.context.artifacts.length
|
|
588
792
|
) {
|
|
589
793
|
throw new RequestError(
|
|
590
|
-
`context.expose_from_parent: nothing job "${parent
|
|
794
|
+
`context.expose_from_parent: nothing job "${parent?.job_id}" exposed is there any more, and this request has no facts or artifacts`,
|
|
591
795
|
'nothing_to_inherit',
|
|
592
796
|
);
|
|
593
797
|
}
|
|
594
798
|
} catch (err) {
|
|
595
799
|
store.cleanupJobDir(groupId);
|
|
596
|
-
if (err instanceof RequestError || !err.code) throw err;
|
|
800
|
+
if (err instanceof RequestError || !(err instanceof Error) || !('code' in err) || !err.code) throw err;
|
|
597
801
|
throw new RequestError(err.message, err.code === 'too_large' ? 'expose_paths_too_large' : 'expose_path_invalid');
|
|
598
802
|
}
|
|
599
803
|
req._snapshot = path.join(snapshot, 'workspace');
|
|
600
804
|
}
|
|
601
|
-
const members = req.targets.map((target, i) => {
|
|
602
|
-
|
|
805
|
+
const members = req.targets.map((/** @type {string} */ target, /** @type {number} */ i) => {
|
|
806
|
+
// One link and one seat per target: start() builds them from req.targets.
|
|
807
|
+
const link = links[i];
|
|
808
|
+
const seat = seats[i];
|
|
809
|
+
if (!link || !seat) throw new Error(`no follow-up link or seat for target "${target}"`);
|
|
810
|
+
const { parent: from, round } = link;
|
|
603
811
|
const chainId = from ? from.chain_id : id('chain');
|
|
604
|
-
const jobId =
|
|
812
|
+
const jobId = seat.job_id;
|
|
813
|
+
const consultant = consultantNamed(target);
|
|
814
|
+
/** @type {Job} */
|
|
605
815
|
const job = {
|
|
606
816
|
job_id: jobId,
|
|
607
817
|
chain_id: chainId,
|
|
@@ -638,9 +848,9 @@ export class JobManager {
|
|
|
638
848
|
status: 'queued',
|
|
639
849
|
// What this consultant is: the CLI it runs on, the model and its
|
|
640
850
|
// lineage, as the operator defined it.
|
|
641
|
-
cli:
|
|
642
|
-
model:
|
|
643
|
-
vendor:
|
|
851
|
+
cli: consultant.cli,
|
|
852
|
+
model: consultant.model,
|
|
853
|
+
vendor: consultant.vendor,
|
|
644
854
|
created_at: new Date().toISOString(),
|
|
645
855
|
started_at: null,
|
|
646
856
|
finished_at: null,
|
|
@@ -657,18 +867,21 @@ export class JobManager {
|
|
|
657
867
|
this.order.push(jobId);
|
|
658
868
|
LIVE_HERE.add(jobId);
|
|
659
869
|
this.#watchCancels();
|
|
660
|
-
job._owner =
|
|
870
|
+
job._owner = seat.owner;
|
|
661
871
|
this.#mark(job);
|
|
662
872
|
return job;
|
|
663
873
|
});
|
|
664
874
|
this.#evict();
|
|
665
875
|
|
|
876
|
+
const [firstMember] = members;
|
|
877
|
+
if (!firstMember) throw new Error('a consultation needs a target');
|
|
878
|
+
/** @type {Group} */
|
|
666
879
|
const group = {
|
|
667
880
|
group_id: groupId,
|
|
668
881
|
created_at: new Date().toISOString(),
|
|
669
882
|
mode: req.mode,
|
|
670
|
-
question:
|
|
671
|
-
job_ids: members.map((m) => m.job_id),
|
|
883
|
+
question: firstMember.question, // already redacted, and byte-identical across the group
|
|
884
|
+
job_ids: members.map((/** @type {Job} */ m) => m.job_id),
|
|
672
885
|
};
|
|
673
886
|
this.groups.set(groupId, group);
|
|
674
887
|
try {
|
|
@@ -694,13 +907,13 @@ export class JobManager {
|
|
|
694
907
|
if (req._snapshot) store.cleanupJobDir(groupId);
|
|
695
908
|
|
|
696
909
|
// The members of a follow-up to a group can be at different rounds.
|
|
697
|
-
const { round } =
|
|
698
|
-
const sameRound = members.every((m) => m.round === round);
|
|
910
|
+
const { round } = firstMember;
|
|
911
|
+
const sameRound = members.every((/** @type {Job} */ m) => m.round === round);
|
|
699
912
|
const common = {
|
|
700
913
|
group_id: groupId,
|
|
701
914
|
...(sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {}),
|
|
702
915
|
...(fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {}),
|
|
703
|
-
...(parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
|
|
916
|
+
...(parentEntries.length && exposeManifest ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
|
|
704
917
|
mode: req.mode,
|
|
705
918
|
// Counted, not refused: a brief of facts alone is a legitimate
|
|
706
919
|
// consultation, but one about code that hands over no file is one the
|
|
@@ -718,7 +931,7 @@ export class JobManager {
|
|
|
718
931
|
};
|
|
719
932
|
|
|
720
933
|
if (!req.fanout) {
|
|
721
|
-
const job =
|
|
934
|
+
const job = firstMember;
|
|
722
935
|
return {
|
|
723
936
|
...common,
|
|
724
937
|
job_id: job.job_id,
|
|
@@ -732,7 +945,7 @@ export class JobManager {
|
|
|
732
945
|
return {
|
|
733
946
|
...common,
|
|
734
947
|
question: group.question, // the redacted text every member was actually sent
|
|
735
|
-
jobs: members.map((m) => ({
|
|
948
|
+
jobs: members.map((/** @type {Job} */ m) => ({
|
|
736
949
|
job_id: m.job_id,
|
|
737
950
|
chain_id: m.chain_id,
|
|
738
951
|
target: m.target,
|
|
@@ -749,20 +962,34 @@ export class JobManager {
|
|
|
749
962
|
// (memory wins: it can hold a verdict written since the round was saved).
|
|
750
963
|
// Each point goes with the lead's verdict on it, so the consultant answers
|
|
751
964
|
// knowing which of its points were checked and how they came out.
|
|
965
|
+
/**
|
|
966
|
+
* @param {string} chainId
|
|
967
|
+
* @returns {Round[]}
|
|
968
|
+
*/
|
|
752
969
|
#chainRounds(chainId) {
|
|
970
|
+
/** @type {Map<number, Round>} */
|
|
753
971
|
const byRound = new Map(store.loadChain(chainId).map((j) => [j.round, j]));
|
|
754
972
|
for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
|
|
755
973
|
return [...byRound.values()];
|
|
756
974
|
}
|
|
757
975
|
|
|
976
|
+
/**
|
|
977
|
+
* @param {string} chainId
|
|
978
|
+
* @returns {PriorRound[]}
|
|
979
|
+
*/
|
|
758
980
|
#chainHistory(chainId) {
|
|
759
981
|
return this.#chainRounds(chainId)
|
|
760
|
-
.filter((j) => j.status === 'completed' && j.result)
|
|
982
|
+
.filter(/** @returns {j is Round & {result: ConsultResult}} */ (j) => j.status === 'completed' && Boolean(j.result))
|
|
761
983
|
.sort((a, b) => a.round - b.round)
|
|
762
984
|
.map((j) => {
|
|
763
985
|
const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
|
|
764
986
|
// The checked points first, then the unchecked ones, each with the
|
|
765
987
|
// lead's verdict and effect when there is one.
|
|
988
|
+
/**
|
|
989
|
+
* @template {ResultItem} T
|
|
990
|
+
* @param {T[]} items
|
|
991
|
+
* @param {(x: T) => {point: string, grounds?: string | null}} shape
|
|
992
|
+
*/
|
|
766
993
|
const ordered = (items, shape) =>
|
|
767
994
|
[...items.filter((x) => verdicts.has(x.id)), ...items.filter((x) => !verdicts.has(x.id))].map((x) => ({
|
|
768
995
|
id: x.id,
|
|
@@ -770,7 +997,7 @@ export class JobManager {
|
|
|
770
997
|
verdict: verdicts.get(x.id)?.verdict ?? null,
|
|
771
998
|
effect: verdicts.get(x.id)?.effect ?? null,
|
|
772
999
|
}));
|
|
773
|
-
const checked = (x) => x.verdict !== null;
|
|
1000
|
+
const checked = (/** @type {{verdict: unknown}} */ x) => x.verdict !== null;
|
|
774
1001
|
const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
|
|
775
1002
|
return {
|
|
776
1003
|
round: j.round,
|
|
@@ -788,6 +1015,7 @@ export class JobManager {
|
|
|
788
1015
|
#evict() {
|
|
789
1016
|
while (this.order.length > POLICY.maxJobsRetained) {
|
|
790
1017
|
const oldest = this.order.shift();
|
|
1018
|
+
if (oldest === undefined) break;
|
|
791
1019
|
const j = this.jobs.get(oldest);
|
|
792
1020
|
if (j && (j.status === 'running' || j.status === 'queued')) {
|
|
793
1021
|
this.order.push(oldest); // never evict live work
|
|
@@ -802,6 +1030,7 @@ export class JobManager {
|
|
|
802
1030
|
// last member there is nothing left to compare, so drop the group rather than
|
|
803
1031
|
// keep answering as a complete fan-out with no answers in it (and keep the
|
|
804
1032
|
// Map, which holds the question text, from growing without bound).
|
|
1033
|
+
/** @param {string} groupId */
|
|
805
1034
|
#dropGroupIfEmpty(groupId) {
|
|
806
1035
|
const group = this.groups.get(groupId);
|
|
807
1036
|
if (!group) return;
|
|
@@ -809,8 +1038,15 @@ export class JobManager {
|
|
|
809
1038
|
this.groups.delete(groupId);
|
|
810
1039
|
}
|
|
811
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* @param {Job} job
|
|
1043
|
+
* @param {ParsedRequest} req
|
|
1044
|
+
* @param {import('./brief.mjs').BriefChain} chain
|
|
1045
|
+
* @param {string} [brief]
|
|
1046
|
+
* @returns {Promise<Job>}
|
|
1047
|
+
*/
|
|
812
1048
|
async #execute(job, req, chain, brief) {
|
|
813
|
-
const consultant =
|
|
1049
|
+
const consultant = consultantNamed(job.target);
|
|
814
1050
|
const cli = consultant.cli;
|
|
815
1051
|
const adapter = ADAPTERS[cli];
|
|
816
1052
|
const workdir = store.makeWorkdir(job.job_id);
|
|
@@ -822,7 +1058,7 @@ export class JobManager {
|
|
|
822
1058
|
try {
|
|
823
1059
|
fs.cpSync(req._snapshot, path.join(workdir, 'workspace'), { recursive: true });
|
|
824
1060
|
} catch (err) {
|
|
825
|
-
return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err.message}`);
|
|
1061
|
+
return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err instanceof Error ? err.message : err}`);
|
|
826
1062
|
}
|
|
827
1063
|
}
|
|
828
1064
|
// start() renders the brief and hands it in: every member of a fan-out is
|
|
@@ -830,6 +1066,7 @@ export class JobManager {
|
|
|
830
1066
|
// added on a follow-up to the group.
|
|
831
1067
|
const text = brief ?? renderBrief(req, chain);
|
|
832
1068
|
const schemaPath = store.writeJobArtifact(job.job_id, 'response-schema.json', JSON.stringify(CONSULT_RESULT_SCHEMA, null, 2));
|
|
1069
|
+
// Left out, not undefined, when the request names none.
|
|
833
1070
|
const sandbox = adapter.prepareSandbox ? adapter.prepareSandbox({ workdir, network: req.network }) : null;
|
|
834
1071
|
|
|
835
1072
|
try {
|
|
@@ -886,28 +1123,37 @@ export class JobManager {
|
|
|
886
1123
|
// its latency is part of this job's duration and budget, not free time
|
|
887
1124
|
// before the meter starts.
|
|
888
1125
|
if (adapter.prepare) await adapter.prepare();
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1126
|
+
// Built again for the fallback model, so the operator's extra args and
|
|
1127
|
+
// the forbidden-flag check apply to that run exactly as to the first.
|
|
1128
|
+
// Returns the invocation, or the problem with the operator's args.
|
|
1129
|
+
/** @param {import('./consultants.mjs').Consultant} c @returns {import('./run.mjs').Invocation | string} */
|
|
1130
|
+
const launch = (c) => {
|
|
1131
|
+
const invocation = adapter.buildInvocation({
|
|
1132
|
+
workdir,
|
|
1133
|
+
schemaPath,
|
|
1134
|
+
guardrails: renderGuardrails(Boolean(req._snapshot), req.network),
|
|
1135
|
+
sandbox,
|
|
1136
|
+
consultant: c,
|
|
1137
|
+
hasExposedPaths: Boolean(req._snapshot),
|
|
1138
|
+
network: req.network,
|
|
1139
|
+
});
|
|
1140
|
+
const problem = extraArgsProblem(cli, invocation.args, t.extraArgs);
|
|
1141
|
+
if (problem) return problem;
|
|
1142
|
+
invocation.args = withExtraArgs(invocation.args, t.extraArgs);
|
|
1143
|
+
assertNoForbiddenFlags(cli, invocation.args);
|
|
1144
|
+
// What this consultant was actually launched with, read off the
|
|
1145
|
+
// invocation and the sandbox rather than restated from intent, so the
|
|
1146
|
+
// record says what held for this run.
|
|
1147
|
+
job.boundary =
|
|
1148
|
+
adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
1149
|
+
null;
|
|
1150
|
+
return invocation;
|
|
1151
|
+
};
|
|
1152
|
+
const first = launch(consultant);
|
|
1153
|
+
if (typeof first === 'string') return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${first}`);
|
|
1154
|
+
let invocation = first;
|
|
898
1155
|
|
|
899
|
-
const
|
|
900
|
-
if (argsProblem) return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${argsProblem}`);
|
|
901
|
-
invocation.args = withExtraArgs(invocation.args, t.extraArgs);
|
|
902
|
-
assertNoForbiddenFlags(cli, invocation.args);
|
|
903
|
-
// What this consultant was actually launched with, read off the
|
|
904
|
-
// invocation and the sandbox rather than restated from intent, so the
|
|
905
|
-
// record says what held for this run.
|
|
906
|
-
job.boundary =
|
|
907
|
-
adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
908
|
-
null;
|
|
909
|
-
|
|
910
|
-
const attempt = (timeout) => {
|
|
1156
|
+
const attempt = (/** @type {number} */ timeout) => {
|
|
911
1157
|
const { handle, done } = runChild({
|
|
912
1158
|
command: invocation.command,
|
|
913
1159
|
args: invocation.args,
|
|
@@ -915,7 +1161,9 @@ export class JobManager {
|
|
|
915
1161
|
env,
|
|
916
1162
|
input: text,
|
|
917
1163
|
timeoutMs: timeout,
|
|
918
|
-
onCancelSignal: (fn) =>
|
|
1164
|
+
onCancelSignal: (fn) => {
|
|
1165
|
+
job._cancelFns.push(fn);
|
|
1166
|
+
},
|
|
919
1167
|
compactLine: adapter.compactLine,
|
|
920
1168
|
settleAfter: adapter.settleAfter,
|
|
921
1169
|
stopSignal: adapter.stopSignal,
|
|
@@ -933,9 +1181,11 @@ export class JobManager {
|
|
|
933
1181
|
// Same consultant, same model, same brief: a re-run never picks another
|
|
934
1182
|
// mind. Every earlier failure is kept in job.retries.
|
|
935
1183
|
let run = await attempt(budgetMs);
|
|
1184
|
+
/** @type {import('./run.mjs').Interpreted} */
|
|
936
1185
|
let interpreted;
|
|
937
1186
|
let authTried = false;
|
|
938
1187
|
let autoRetries = 0;
|
|
1188
|
+
let fallenBack = false;
|
|
939
1189
|
for (;;) {
|
|
940
1190
|
// Read off the consultant's own event stream, last attempt only; null
|
|
941
1191
|
// where the CLI does not stream its tool calls (Claude Code).
|
|
@@ -976,7 +1226,7 @@ export class JobManager {
|
|
|
976
1226
|
interpreted = adapter.interpret({ ...run, lastMessageText });
|
|
977
1227
|
// An answer after an error the CLI reported is kept only if it parses;
|
|
978
1228
|
// otherwise it is that error, re-run like any other.
|
|
979
|
-
const
|
|
1229
|
+
const recoveredError = interpreted.ok ? interpreted.recoveredError : null;
|
|
980
1230
|
if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
|
|
981
1231
|
interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
|
|
982
1232
|
}
|
|
@@ -1007,6 +1257,25 @@ export class JobManager {
|
|
|
1007
1257
|
continue;
|
|
1008
1258
|
}
|
|
1009
1259
|
}
|
|
1260
|
+
// The operator's fallback for a model whose account quota is used up
|
|
1261
|
+
// (a re-run on the same model would fail the same way). It is a
|
|
1262
|
+
// different mind from the one asked, so the answer says so below.
|
|
1263
|
+
if (interpreted.failureKind === 'usage_limit' && consultant.fallbackModel && !fallenBack && remaining >= MIN_RETRY_MS) {
|
|
1264
|
+
fallenBack = true;
|
|
1265
|
+
const fallback = consultant.fallbackModel;
|
|
1266
|
+
const next = launch({ ...consultant, model: fallback });
|
|
1267
|
+
if (typeof next === 'string') return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${next}`);
|
|
1268
|
+
invocation = next;
|
|
1269
|
+
job.retries.push({
|
|
1270
|
+
failure_kind: interpreted.failureKind,
|
|
1271
|
+
reason: `${job.model} quota used up; re-run on ${fallback}`,
|
|
1272
|
+
at: new Date().toISOString(),
|
|
1273
|
+
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1274
|
+
});
|
|
1275
|
+
job.model = fallback;
|
|
1276
|
+
run = await attempt(remaining);
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1010
1279
|
const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
|
|
1011
1280
|
if (
|
|
1012
1281
|
AUTO_RETRY_KINDS.includes(interpreted.failureKind) &&
|
|
@@ -1020,7 +1289,7 @@ export class JobManager {
|
|
|
1020
1289
|
at: new Date().toISOString(),
|
|
1021
1290
|
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1022
1291
|
});
|
|
1023
|
-
await new Promise((resolve) => {
|
|
1292
|
+
await new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
|
|
1024
1293
|
const timer = setTimeout(resolve, wait);
|
|
1025
1294
|
job._cancelFns.push(() => {
|
|
1026
1295
|
clearTimeout(timer);
|
|
@@ -1079,6 +1348,9 @@ export class JobManager {
|
|
|
1079
1348
|
`the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
|
|
1080
1349
|
leftover &&
|
|
1081
1350
|
'the answer parsed, but a process the consultant started kept its output open after it exited and was stopped (one that left its process group may still be running)',
|
|
1351
|
+
fallenBack && `answered by ${job.model}: ${consultant.model}'s quota was used up`,
|
|
1352
|
+
req._snapshot && unreadCaveat(job.tool_calls, adapter.FILE_READ_TOOLS),
|
|
1353
|
+
refusedToolCaveat(job.tool_calls, adapter.ALLOWED_TOOLS),
|
|
1082
1354
|
].filter(Boolean);
|
|
1083
1355
|
// No `advice_usable` verdict here. It was always true, so it carried no
|
|
1084
1356
|
// information -- and worse, it read as the server vouching for advice it
|
|
@@ -1101,6 +1373,13 @@ export class JobManager {
|
|
|
1101
1373
|
}
|
|
1102
1374
|
}
|
|
1103
1375
|
|
|
1376
|
+
/**
|
|
1377
|
+
* @param {Job} job
|
|
1378
|
+
* @param {string} kind
|
|
1379
|
+
* @param {unknown} message
|
|
1380
|
+
* @param {string | null} [detail]
|
|
1381
|
+
* @returns {Job}
|
|
1382
|
+
*/
|
|
1104
1383
|
#fail(job, kind, message, detail) {
|
|
1105
1384
|
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') return job;
|
|
1106
1385
|
job.status = kind === 'cancelled' ? 'cancelled' : 'failed';
|
|
@@ -1117,6 +1396,7 @@ export class JobManager {
|
|
|
1117
1396
|
}
|
|
1118
1397
|
|
|
1119
1398
|
// The in-progress marker other processes read (see store.writeRunning).
|
|
1399
|
+
/** @param {Job} job */
|
|
1120
1400
|
#mark(job) {
|
|
1121
1401
|
try {
|
|
1122
1402
|
store.writeRunning({ ...this.#record(job), owner: job._owner });
|
|
@@ -1125,6 +1405,7 @@ export class JobManager {
|
|
|
1125
1405
|
}
|
|
1126
1406
|
}
|
|
1127
1407
|
|
|
1408
|
+
/** @param {Job} job */
|
|
1128
1409
|
#finish(job) {
|
|
1129
1410
|
try {
|
|
1130
1411
|
store.persistRound(this.#record(job));
|
|
@@ -1145,7 +1426,7 @@ export class JobManager {
|
|
|
1145
1426
|
// consultant did not produce and a word outside VERDICTS, then stores what
|
|
1146
1427
|
// was written. It never decides that a finding was adopted, and it never
|
|
1147
1428
|
// fills in a verdict the lead did not write.
|
|
1148
|
-
/** @param {
|
|
1429
|
+
/** @param {RawRecord} [input] */
|
|
1149
1430
|
record(input = {}) {
|
|
1150
1431
|
if (input.records !== undefined) return this.#recordMany(input);
|
|
1151
1432
|
const allowed = ['job_id', 'entries', 'reflection'];
|
|
@@ -1169,6 +1450,7 @@ export class JobManager {
|
|
|
1169
1450
|
// record is checked before anything is written, so a bad id leaves the
|
|
1170
1451
|
// history as it was; then each job is written on its own, and a write that
|
|
1171
1452
|
// fails is named in the answer rather than reported as saved.
|
|
1453
|
+
/** @param {RawRecord} input */
|
|
1172
1454
|
#recordMany(input) {
|
|
1173
1455
|
const extra = Object.keys(input).filter((k) => k !== 'records');
|
|
1174
1456
|
if (extra.length) {
|
|
@@ -1181,10 +1463,12 @@ export class JobManager {
|
|
|
1181
1463
|
if (!Array.isArray(records) || records.length === 0) {
|
|
1182
1464
|
throw new RequestError('records must be a non-empty array of { job_id, entries?, reflection? }', 'records_required');
|
|
1183
1465
|
}
|
|
1466
|
+
/** @type {Array<{index: number, job_id: string | null, error: string, message: string}>} */
|
|
1184
1467
|
const problems = [];
|
|
1468
|
+
/** @type {RecordChange[]} */
|
|
1185
1469
|
const changes = [];
|
|
1186
1470
|
const seen = new Set();
|
|
1187
|
-
records.forEach((r, i) => {
|
|
1471
|
+
records.forEach((/** @type {RawRecord} */ r, i) => {
|
|
1188
1472
|
try {
|
|
1189
1473
|
if (seen.has(r?.job_id))
|
|
1190
1474
|
throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
|
|
@@ -1212,7 +1496,10 @@ export class JobManager {
|
|
|
1212
1496
|
|
|
1213
1497
|
// Everything that can be wrong with a record, found before anything is
|
|
1214
1498
|
// written: what the job's record and reflection become, or a RequestError.
|
|
1215
|
-
/**
|
|
1499
|
+
/**
|
|
1500
|
+
* @param {RawRecord} rec
|
|
1501
|
+
* @returns {RecordChange}
|
|
1502
|
+
*/
|
|
1216
1503
|
#checkRecord({ job_id: jobId, entries, reflection }) {
|
|
1217
1504
|
if (entries === undefined && reflection === undefined) {
|
|
1218
1505
|
throw new RequestError(
|
|
@@ -1240,7 +1527,8 @@ export class JobManager {
|
|
|
1240
1527
|
|
|
1241
1528
|
const ids = recordableIds(job.result);
|
|
1242
1529
|
const now = new Date().toISOString();
|
|
1243
|
-
|
|
1530
|
+
/** @type {Map<string, RecordEntry>} */
|
|
1531
|
+
const written = new Map();
|
|
1244
1532
|
|
|
1245
1533
|
for (const entry of entries ?? []) {
|
|
1246
1534
|
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
|
|
@@ -1257,7 +1545,7 @@ export class JobManager {
|
|
|
1257
1545
|
'invalid_verdict',
|
|
1258
1546
|
);
|
|
1259
1547
|
}
|
|
1260
|
-
|
|
1548
|
+
written.set(id, {
|
|
1261
1549
|
id,
|
|
1262
1550
|
verdict,
|
|
1263
1551
|
effect: text(entry?.effect),
|
|
@@ -1271,6 +1559,7 @@ export class JobManager {
|
|
|
1271
1559
|
// lead predicted can still arrive with the evidence that settles it, and a
|
|
1272
1560
|
// surprise can still be wrong, so a match/miss verdict would rate the
|
|
1273
1561
|
// consultation on the wrong axis.
|
|
1562
|
+
/** @type {Reflection | undefined} */
|
|
1274
1563
|
let newReflection;
|
|
1275
1564
|
if (reflection !== undefined) {
|
|
1276
1565
|
const delta = text(reflection?.delta);
|
|
@@ -1289,30 +1578,40 @@ export class JobManager {
|
|
|
1289
1578
|
newReflection = { delta, related_item_ids: related, recorded_at: now };
|
|
1290
1579
|
}
|
|
1291
1580
|
|
|
1292
|
-
|
|
1293
|
-
entries === undefined
|
|
1294
|
-
? undefined
|
|
1295
|
-
: { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
|
|
1296
|
-
return { job, live, record: newRecord, reflection: newReflection };
|
|
1581
|
+
return { job, live, entries: entries === undefined ? undefined : [...written.values()], reflection: newReflection };
|
|
1297
1582
|
}
|
|
1298
1583
|
|
|
1299
|
-
|
|
1584
|
+
/** @param {RecordChange} change */
|
|
1585
|
+
#saveRecord({ job, live, entries, reflection }) {
|
|
1300
1586
|
const was = { record: job.record, reflection: job.reflection };
|
|
1301
|
-
|
|
1302
|
-
|
|
1587
|
+
// Another server may have written verdicts or a reflection to this round
|
|
1588
|
+
// since this one loaded it. Start from the file, so this call replaces
|
|
1589
|
+
// only the ids it names (and the reflection, if it passes one) and keeps
|
|
1590
|
+
// the rest; the merge goes back into memory too, so consult_get and a
|
|
1591
|
+
// follow-up's brief show the same record.
|
|
1592
|
+
// ponytail: read-then-write, not locked; two writes in the same instant can still lose one.
|
|
1593
|
+
const disk = store.loadRoundAt(job);
|
|
1594
|
+
const base = disk?.result ? disk : job;
|
|
1595
|
+
job.reflection = reflection ?? base.reflection;
|
|
1596
|
+
if (entries !== undefined) {
|
|
1597
|
+
const merged = new Map((base.record?.entries ?? []).map((e) => [e.id, e]));
|
|
1598
|
+
for (const e of entries) merged.set(e.id, e);
|
|
1599
|
+
const ids = job.result ? recordableIds(job.result) : [];
|
|
1600
|
+
job.record = { updated_at: new Date().toISOString(), entries: ids.flatMap((id) => merged.get(id) ?? []) };
|
|
1601
|
+
} else if (base.record) job.record = base.record;
|
|
1303
1602
|
// An update to a round that already happened, so the index line stays as it
|
|
1304
1603
|
// was: the history has one line per consultation, not one per edit.
|
|
1305
1604
|
// Unlike the round itself, a verdict exists nowhere else once this server
|
|
1306
1605
|
// exits, so a failed write is the lead's to know about, not best effort.
|
|
1307
1606
|
try {
|
|
1308
|
-
store.persistRound(live ? this.#record(
|
|
1607
|
+
store.persistRound(live ? this.#record(live) : job, { appendIndex: false });
|
|
1309
1608
|
} catch (e) {
|
|
1310
1609
|
// Not saved, so not shown as recorded either.
|
|
1311
1610
|
Object.assign(job, was);
|
|
1312
1611
|
return {
|
|
1313
1612
|
job_id: job.job_id,
|
|
1314
1613
|
error: 'record_not_saved',
|
|
1315
|
-
message: `the verdict for "${job.job_id}" could not be written to the history (${e.message}); record it again`,
|
|
1614
|
+
message: `the verdict for "${job.job_id}" could not be written to the history (${e instanceof Error ? e.message : e}); record it again`,
|
|
1316
1615
|
};
|
|
1317
1616
|
}
|
|
1318
1617
|
// Counts only: the entries are what the lead just wrote, and
|
|
@@ -1327,6 +1626,12 @@ export class JobManager {
|
|
|
1327
1626
|
};
|
|
1328
1627
|
}
|
|
1329
1628
|
|
|
1629
|
+
/**
|
|
1630
|
+
* @param {string} jobId
|
|
1631
|
+
* @param {number | undefined} waitMs
|
|
1632
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1633
|
+
* @param {AbortSignal} [signal]
|
|
1634
|
+
*/
|
|
1330
1635
|
async wait(jobId, waitMs, opts, signal) {
|
|
1331
1636
|
const job = this.jobs.get(jobId);
|
|
1332
1637
|
if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
|
|
@@ -1340,6 +1645,13 @@ export class JobManager {
|
|
|
1340
1645
|
|
|
1341
1646
|
// A consultation another process runs has no promise here, so its history
|
|
1342
1647
|
// is re-read once a second until it is no longer running or the wait is up.
|
|
1648
|
+
/**
|
|
1649
|
+
* @template {{status: string}} T
|
|
1650
|
+
* @param {() => T | null} read
|
|
1651
|
+
* @param {number | undefined} waitMs
|
|
1652
|
+
* @param {AbortSignal} [signal]
|
|
1653
|
+
* @returns {Promise<T | null>}
|
|
1654
|
+
*/
|
|
1343
1655
|
async #waitOnDisk(read, waitMs, signal) {
|
|
1344
1656
|
const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1345
1657
|
let view = read();
|
|
@@ -1364,6 +1676,7 @@ export class JobManager {
|
|
|
1364
1676
|
}, CANCEL_POLL_MS).unref();
|
|
1365
1677
|
}
|
|
1366
1678
|
|
|
1679
|
+
/** @param {string} jobId */
|
|
1367
1680
|
cancel(jobId) {
|
|
1368
1681
|
const job = this.jobs.get(jobId);
|
|
1369
1682
|
if (!job) {
|
|
@@ -1379,18 +1692,22 @@ export class JobManager {
|
|
|
1379
1692
|
};
|
|
1380
1693
|
}
|
|
1381
1694
|
if (job.status !== 'running' && job.status !== 'queued') {
|
|
1382
|
-
return { ...this
|
|
1695
|
+
return { ...this.#jobView(job, undefined), cancel_effect: `job was already ${job.status}` };
|
|
1383
1696
|
}
|
|
1384
1697
|
job.cancelRequested = true;
|
|
1385
1698
|
for (const fn of job._cancelFns) fn();
|
|
1386
1699
|
if (job._handle) job._handle.stop();
|
|
1387
|
-
return { ...this
|
|
1700
|
+
return { ...this.#jobView(job, undefined), status: 'cancelling', cancel_effect: 'consultant process group signalled' };
|
|
1388
1701
|
}
|
|
1389
1702
|
|
|
1390
1703
|
// Every field here is derived from the one `members` array it is handed, so a
|
|
1391
1704
|
// group view can never report one status in `members` and another in
|
|
1392
1705
|
// `comparison` or `status` for the same job.
|
|
1393
|
-
/**
|
|
1706
|
+
/**
|
|
1707
|
+
* @param {Group} group
|
|
1708
|
+
* @param {JobView[]} members
|
|
1709
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1710
|
+
*/
|
|
1394
1711
|
#shapeGroup(group, members, { detail } = {}) {
|
|
1395
1712
|
const full = detail === 'full';
|
|
1396
1713
|
const cancelling = members.some((m) => m.status === 'cancelling');
|
|
@@ -1470,16 +1787,29 @@ export class JobManager {
|
|
|
1470
1787
|
// While no one has finished there is nothing to set side by side, and an
|
|
1471
1788
|
// empty column per consultant is only context spent on every poll.
|
|
1472
1789
|
const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
|
|
1473
|
-
|
|
1474
|
-
return view;
|
|
1790
|
+
return anyFinished ? { ...view, comparison: comparison(members, full ? Infinity : GROUP_CHARS - printed(view)) } : view;
|
|
1475
1791
|
}
|
|
1476
1792
|
|
|
1793
|
+
/**
|
|
1794
|
+
* @param {string} groupId
|
|
1795
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1796
|
+
*/
|
|
1477
1797
|
groupView(groupId, opts) {
|
|
1478
1798
|
const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
|
|
1479
1799
|
if (!group) return null;
|
|
1480
|
-
return this.#shapeGroup(
|
|
1800
|
+
return this.#shapeGroup(
|
|
1801
|
+
group,
|
|
1802
|
+
group.job_ids.map((jid) => this.view(jid, opts)).filter((v) => v != null),
|
|
1803
|
+
opts,
|
|
1804
|
+
);
|
|
1481
1805
|
}
|
|
1482
1806
|
|
|
1807
|
+
/**
|
|
1808
|
+
* @param {string} groupId
|
|
1809
|
+
* @param {number | undefined} waitMs
|
|
1810
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1811
|
+
* @param {AbortSignal} [signal]
|
|
1812
|
+
*/
|
|
1483
1813
|
async waitGroup(groupId, waitMs, opts, signal) {
|
|
1484
1814
|
const group = this.groups.get(groupId);
|
|
1485
1815
|
// A fan-out from another process or an earlier session: nothing here to
|
|
@@ -1493,6 +1823,7 @@ export class JobManager {
|
|
|
1493
1823
|
return this.groupView(groupId, opts);
|
|
1494
1824
|
}
|
|
1495
1825
|
|
|
1826
|
+
/** @param {string} groupId */
|
|
1496
1827
|
cancelGroup(groupId) {
|
|
1497
1828
|
const group = this.groups.get(groupId);
|
|
1498
1829
|
if (!group) {
|
|
@@ -1519,7 +1850,7 @@ export class JobManager {
|
|
|
1519
1850
|
// Shape the view from the cancel() results, not from a fresh read: the
|
|
1520
1851
|
// children exit asynchronously, so a signalled member reads as
|
|
1521
1852
|
// "cancelling" until it is reaped, and the whole payload must say so.
|
|
1522
|
-
const members = group.job_ids.map((jid) => this.cancel(jid)).filter(
|
|
1853
|
+
const members = group.job_ids.map((jid) => this.cancel(jid)).filter((v) => v != null);
|
|
1523
1854
|
const total = group.job_ids.length;
|
|
1524
1855
|
return {
|
|
1525
1856
|
...this.#shapeGroup(group, members),
|
|
@@ -1577,8 +1908,13 @@ export class JobManager {
|
|
|
1577
1908
|
// files of that diff are the same entry for this purpose.
|
|
1578
1909
|
// ponytail: a diff recorded before 1.11 hashed its files in git's order, not
|
|
1579
1910
|
// path order, so its re-copy can read as changed when it is not.
|
|
1911
|
+
/**
|
|
1912
|
+
* @param {ExposeManifest} manifest
|
|
1913
|
+
* @param {BriefExposeEntry[]} parentEntries
|
|
1914
|
+
*/
|
|
1580
1915
|
function markSinceParent(manifest, parentEntries) {
|
|
1581
|
-
const key = (
|
|
1916
|
+
const key = (/** @type {string} */ source, /** @type {string} */ kind) =>
|
|
1917
|
+
`${kind === 'change_files' ? 'diff' : kind}\0${source}`;
|
|
1582
1918
|
const before = new Map(parentEntries.map((e) => [key(e.source, e.kind), e.sha256]));
|
|
1583
1919
|
for (const e of manifest.entries) {
|
|
1584
1920
|
const k = key(e.source, e.kind);
|
|
@@ -1589,6 +1925,7 @@ function markSinceParent(manifest, parentEntries) {
|
|
|
1589
1925
|
// What consult_start says a follow-up copied again: the entries it shares
|
|
1590
1926
|
// with the parent round, the ones left out and why, and how many files that
|
|
1591
1927
|
// added to this round.
|
|
1928
|
+
/** @param {ExposeManifest} manifest */
|
|
1592
1929
|
function inheritedSummary(manifest) {
|
|
1593
1930
|
const carried = manifest.entries.filter((e) => e.inherited);
|
|
1594
1931
|
return {
|
|
@@ -1604,6 +1941,10 @@ function inheritedSummary(manifest) {
|
|
|
1604
1941
|
// is the raw output stream, unparsed: material the lead may read, never an
|
|
1605
1942
|
// answer, which is why it sits beside the failure and not in `result`.
|
|
1606
1943
|
const PARTIAL_TAIL_CHARS = 8_000;
|
|
1944
|
+
/**
|
|
1945
|
+
* @param {import('./run.mjs').ChildResult} run
|
|
1946
|
+
* @returns {import('./store.mjs').PartialOutput | null}
|
|
1947
|
+
*/
|
|
1607
1948
|
function partialOutput(run) {
|
|
1608
1949
|
const out = String(run.stdout ?? '');
|
|
1609
1950
|
if (!out.trim()) return null;
|
|
@@ -1621,10 +1962,16 @@ function partialOutput(run) {
|
|
|
1621
1962
|
// Waits for `promise` (null: for nothing), at most `ms`, and no longer than
|
|
1622
1963
|
// `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
|
|
1623
1964
|
// cut short leaves nothing behind.
|
|
1965
|
+
/**
|
|
1966
|
+
* @param {Promise<unknown> | null | undefined} promise
|
|
1967
|
+
* @param {number} ms
|
|
1968
|
+
* @param {AbortSignal} [signal]
|
|
1969
|
+
*/
|
|
1624
1970
|
async function settled(promise, ms, signal) {
|
|
1625
1971
|
let timer;
|
|
1972
|
+
/** @type {(() => void) | undefined} */
|
|
1626
1973
|
let stop;
|
|
1627
|
-
const until = new Promise((resolve) => {
|
|
1974
|
+
const until = new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
|
|
1628
1975
|
stop = resolve;
|
|
1629
1976
|
timer = setTimeout(resolve, ms);
|
|
1630
1977
|
if (signal?.aborted) resolve();
|
|
@@ -1634,10 +1981,11 @@ async function settled(promise, ms, signal) {
|
|
|
1634
1981
|
await (promise ? Promise.race([promise, until]) : until);
|
|
1635
1982
|
} finally {
|
|
1636
1983
|
clearTimeout(timer);
|
|
1637
|
-
signal?.removeEventListener('abort', stop);
|
|
1984
|
+
if (stop) signal?.removeEventListener('abort', stop);
|
|
1638
1985
|
}
|
|
1639
1986
|
}
|
|
1640
1987
|
|
|
1988
|
+
/** @param {string | Record<string, unknown>} text */
|
|
1641
1989
|
function parses(text) {
|
|
1642
1990
|
try {
|
|
1643
1991
|
normalizeResult(text);
|
|
@@ -1648,6 +1996,10 @@ function parses(text) {
|
|
|
1648
1996
|
}
|
|
1649
1997
|
}
|
|
1650
1998
|
|
|
1999
|
+
/**
|
|
2000
|
+
* @param {ConsultResult} result
|
|
2001
|
+
* @returns {string[]}
|
|
2002
|
+
*/
|
|
1651
2003
|
function recordableIds(result) {
|
|
1652
2004
|
return [
|
|
1653
2005
|
...result.findings.map((f) => f.id),
|
|
@@ -1658,7 +2010,12 @@ function recordableIds(result) {
|
|
|
1658
2010
|
|
|
1659
2011
|
// How many of each word, in the order they first appear. The only arithmetic
|
|
1660
2012
|
// this server does on anyone's judgement.
|
|
2013
|
+
/**
|
|
2014
|
+
* @param {Iterable<string>} values
|
|
2015
|
+
* @returns {Record<string, number>}
|
|
2016
|
+
*/
|
|
1661
2017
|
function tally(values) {
|
|
2018
|
+
/** @type {Record<string, number>} */
|
|
1662
2019
|
const out = {};
|
|
1663
2020
|
for (const v of values) out[v] = (out[v] ?? 0) + 1;
|
|
1664
2021
|
return out;
|
|
@@ -1667,17 +2024,27 @@ function tally(values) {
|
|
|
1667
2024
|
// Counting is mechanical and stays that way: how many points the answer has,
|
|
1668
2025
|
// how many carry a verdict, which ones do not. No judgement about whether the
|
|
1669
2026
|
// consultation was worth it -- that is the lead's to draw from the entries.
|
|
2027
|
+
/**
|
|
2028
|
+
* @param {Round} job
|
|
2029
|
+
* @returns {JobRecord}
|
|
2030
|
+
*/
|
|
1670
2031
|
function recordSummary(job) {
|
|
1671
|
-
|
|
1672
|
-
const
|
|
2032
|
+
// Callers pass a round that has a record, and a record is only written against an answer (#checkRecord).
|
|
2033
|
+
const record = /** @type {JobRecord} */ (job.record);
|
|
2034
|
+
const ids = recordableIds(/** @type {ConsultResult} */ (job.result));
|
|
2035
|
+
const written = new Set(record.entries.map((e) => e.id));
|
|
1673
2036
|
return {
|
|
1674
|
-
...
|
|
1675
|
-
verdicts: tally(
|
|
2037
|
+
...record,
|
|
2038
|
+
verdicts: tally(record.entries.map((e) => e.verdict)),
|
|
1676
2039
|
coverage: { recordable: ids.length, recorded: written.size },
|
|
1677
2040
|
unrecorded: ids.filter((id) => !written.has(id)),
|
|
1678
2041
|
};
|
|
1679
2042
|
}
|
|
1680
2043
|
|
|
2044
|
+
/**
|
|
2045
|
+
* @param {unknown} value
|
|
2046
|
+
* @returns {string | null}
|
|
2047
|
+
*/
|
|
1681
2048
|
function text(value) {
|
|
1682
2049
|
if (typeof value !== 'string') return null;
|
|
1683
2050
|
const t = redact(value).trim();
|
|
@@ -1690,23 +2057,29 @@ function text(value) {
|
|
|
1690
2057
|
// a key left out is inherited -- one passed, even as an empty list, replaces
|
|
1691
2058
|
// it -- and the validation that follows sees the result. Files are not:
|
|
1692
2059
|
// copying them again stays expose_from_parent, named in the call (schema.mjs).
|
|
2060
|
+
/**
|
|
2061
|
+
* @param {RawRequest} raw
|
|
2062
|
+
* @param {Round} round
|
|
2063
|
+
* @returns {RawRequest}
|
|
2064
|
+
*/
|
|
1693
2065
|
function inherit(raw, round) {
|
|
1694
2066
|
const { brief } = round;
|
|
1695
2067
|
// A round kept off the web stays off: the material it carries over is the same.
|
|
2068
|
+
/** @type {RawRequest} */
|
|
1696
2069
|
const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
|
|
1697
2070
|
if (!brief) return out;
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
!(Array.isArray(brief[k]) && !brief[k].length)
|
|
1704
|
-
)
|
|
1705
|
-
out[k] = brief[k];
|
|
2071
|
+
/** @type {Array<'objective' | 'success_criteria' | 'constraints'>} */
|
|
2072
|
+
const topKeys = ['objective', 'success_criteria', 'constraints'];
|
|
2073
|
+
for (const k of topKeys) {
|
|
2074
|
+
const v = brief[k];
|
|
2075
|
+
if (out[k] === undefined && raw.context?.[k] === undefined && v != null && !(Array.isArray(v) && !v.length)) out[k] = v;
|
|
1706
2076
|
}
|
|
1707
2077
|
const ctx = { ...(raw.context ?? {}) };
|
|
1708
|
-
|
|
1709
|
-
|
|
2078
|
+
/** @type {Array<'facts' | 'proposal' | 'counterpoints'>} */
|
|
2079
|
+
const contextKeys = ['facts', 'proposal', 'counterpoints'];
|
|
2080
|
+
for (const k of contextKeys) {
|
|
2081
|
+
const v = brief[k];
|
|
2082
|
+
if (ctx[k] === undefined && v != null && !(Array.isArray(v) && !v.length)) ctx[k] = v;
|
|
1710
2083
|
}
|
|
1711
2084
|
// The diff expose_diff added is not the lead's material: it is taken again
|
|
1712
2085
|
// (or replaced) through expose_from_parent, against the files as they are now.
|
|
@@ -1723,6 +2096,7 @@ function inherit(raw, round) {
|
|
|
1723
2096
|
return out;
|
|
1724
2097
|
}
|
|
1725
2098
|
|
|
2099
|
+
/** @param {ReturnType<typeof normalizeResult>} normalized */
|
|
1726
2100
|
function adviceCaveat(normalized) {
|
|
1727
2101
|
const { result, quality } = normalized;
|
|
1728
2102
|
const notes = [];
|
|
@@ -1745,6 +2119,44 @@ function adviceCaveat(normalized) {
|
|
|
1745
2119
|
return notes.length ? notes.join('; ') : null;
|
|
1746
2120
|
}
|
|
1747
2121
|
|
|
2122
|
+
// Files were handed over, but no call to a file-reading tool succeeded. On
|
|
2123
|
+
// 2026-10-20 an agy release renamed its read tool, every read was refused,
|
|
2124
|
+
// and the answer still cited line numbers. Only for a CLI that reports its
|
|
2125
|
+
// tool calls and names its reading tools; the others say nothing here.
|
|
2126
|
+
/**
|
|
2127
|
+
* @param {Record<string, number> | null | undefined} calls
|
|
2128
|
+
* @param {readonly string[] | undefined} readTools
|
|
2129
|
+
* @returns {string | null}
|
|
2130
|
+
*/
|
|
2131
|
+
function unreadCaveat(calls, readTools) {
|
|
2132
|
+
if (!calls || !readTools) return null;
|
|
2133
|
+
if (readTools.some((t) => calls[t])) return null;
|
|
2134
|
+
const failed = readTools.reduce((n, t) => n + (calls[`${t}:error`] ?? 0), 0);
|
|
2135
|
+
const how = failed ? `${failed} failed or were refused` : 'none was made';
|
|
2136
|
+
return `files were exposed, but no call to read one succeeded (${how})`;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// A refused call to a tool outside the allowlist that looks like a file
|
|
2140
|
+
// reader is most often the CLI's read tool renamed in a new release; naming
|
|
2141
|
+
// it lets one consultation show the cause. The tools severally refuses on
|
|
2142
|
+
// purpose (agy tries run_command even for a trivial question) are left out:
|
|
2143
|
+
// reporting them would be noise, and they are not renamed readers.
|
|
2144
|
+
// ponytail: a name heuristic; a reader named otherwise goes unreported until the unread note shows it.
|
|
2145
|
+
/**
|
|
2146
|
+
* @param {Record<string, number> | null | undefined} calls
|
|
2147
|
+
* @param {readonly string[] | undefined} allowed
|
|
2148
|
+
* @returns {string | null}
|
|
2149
|
+
*/
|
|
2150
|
+
function refusedToolCaveat(calls, allowed) {
|
|
2151
|
+
if (!calls || !allowed) return null;
|
|
2152
|
+
const names = Object.keys(calls)
|
|
2153
|
+
.filter((k) => k.endsWith(':error'))
|
|
2154
|
+
.map((k) => k.slice(0, -':error'.length))
|
|
2155
|
+
.filter((t) => !allowed.includes(t) && /read|view|open/i.test(t));
|
|
2156
|
+
if (!names.length) return null;
|
|
2157
|
+
return `the consultant's CLI called ${names.join(', ')}, which severally does not allow, and was refused; a new CLI release may have renamed its file-reading tool`;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
1748
2160
|
// A lead may legitimately name its own CLI as the consultant: a fresh child
|
|
1749
2161
|
// session with no shared context is still a useful clean-context re-read.
|
|
1750
2162
|
// But it is not an independent opinion -- the answer comes from the same
|
|
@@ -1752,10 +2164,17 @@ function adviceCaveat(normalized) {
|
|
|
1752
2164
|
// lineage is the consultant's model's, not its CLI's: OpenCode can run GLM
|
|
1753
2165
|
// for a GLM lead and DeepSeek for anyone.
|
|
1754
2166
|
|
|
2167
|
+
/**
|
|
2168
|
+
* @param {string | null | undefined} caller
|
|
2169
|
+
* @param {string | null | undefined} callerModel
|
|
2170
|
+
* @param {Consultant} consultant
|
|
2171
|
+
* @returns {string | null}
|
|
2172
|
+
*/
|
|
1755
2173
|
function sameVendorCaveat(caller, callerModel, consultant) {
|
|
1756
2174
|
// The lead's lineage: its declared model if that names one, else its CLI's
|
|
1757
2175
|
// own. An unknown lineage on either side is no caveat -- not a guess.
|
|
1758
|
-
const
|
|
2176
|
+
const callerCli = CLI_IDS.find((c) => c === caller);
|
|
2177
|
+
const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (callerCli ? (CLI_VENDORS[callerCli] ?? null) : null);
|
|
1759
2178
|
const vendor = consultant.vendor;
|
|
1760
2179
|
if (!callerVendor || !vendor || callerVendor !== vendor) return null;
|
|
1761
2180
|
// Say what a fresh session of the same lineage removes and what it keeps.
|
|
@@ -1785,15 +2204,20 @@ function sameVendorCaveat(caller, callerModel, consultant) {
|
|
|
1785
2204
|
// A mechanical side-by-side. The server deliberately does not decide whether
|
|
1786
2205
|
// the consultants agree: inventing agreement that is not there is exactly the
|
|
1787
2206
|
// failure a second opinion is supposed to prevent.
|
|
2207
|
+
/**
|
|
2208
|
+
* @param {JobView[]} members
|
|
2209
|
+
* @param {number} budget
|
|
2210
|
+
*/
|
|
1788
2211
|
function comparison(members, budget) {
|
|
1789
2212
|
// Each column first pays for its fixed fields; the rest is its budget.
|
|
1790
2213
|
const each = (budget - COMPARISON_NOTE_CHARS) / Math.max(1, members.length) - COLUMN_FIXED_CHARS;
|
|
1791
|
-
|
|
2214
|
+
const firstPass = members.map((m) => ({ m, column: byTarget(m, each) }));
|
|
2215
|
+
let columns = firstPass.map((x) => x.column);
|
|
1792
2216
|
// What the short answers did not spend goes to the ones that were cut.
|
|
1793
2217
|
const cutShort = columns.filter((c) => c.truncated).length;
|
|
1794
2218
|
if (each !== Infinity && cutShort) {
|
|
1795
2219
|
const spare = columns.reduce((n, c) => n + (c.truncated ? 0 : Math.max(0, each + COLUMN_FIXED_CHARS - printed(c))), 0);
|
|
1796
|
-
columns =
|
|
2220
|
+
columns = firstPass.map(({ m, column }) => (column.truncated ? byTarget(m, each + spare / cutShort) : column));
|
|
1797
2221
|
}
|
|
1798
2222
|
return {
|
|
1799
2223
|
by_target: columns,
|
|
@@ -1812,17 +2236,32 @@ function comparison(members, budget) {
|
|
|
1812
2236
|
// shortened to fit the share before any is left out. A cut is flagged, never hidden: `truncated` says so,
|
|
1813
2237
|
// `omitted` counts the items a list could not hold, and `full_with` names the
|
|
1814
2238
|
// call that has everything.
|
|
2239
|
+
/**
|
|
2240
|
+
* @param {JobView} m
|
|
2241
|
+
* @param {number} budget
|
|
2242
|
+
* @returns {ComparisonColumn}
|
|
2243
|
+
*/
|
|
1815
2244
|
function byTarget(m, budget) {
|
|
1816
2245
|
const full = budget === Infinity;
|
|
1817
2246
|
let left = budget;
|
|
1818
2247
|
let truncated = false;
|
|
2248
|
+
/** @type {Record<string, number>} */
|
|
1819
2249
|
const omitted = {};
|
|
1820
|
-
const clip = (s, max) => {
|
|
2250
|
+
const clip = (/** @type {string | null | undefined} */ s, /** @type {number} */ max) => {
|
|
1821
2251
|
const out = full ? s : cut(s, max);
|
|
1822
2252
|
if (out !== s) truncated = true;
|
|
1823
2253
|
return out;
|
|
1824
2254
|
};
|
|
1825
2255
|
const item = Math.min(ITEM_CHARS, budget / 10);
|
|
2256
|
+
/**
|
|
2257
|
+
* @template T
|
|
2258
|
+
* @param {string} name
|
|
2259
|
+
* @param {T[] | undefined} items
|
|
2260
|
+
* @param {number} fields
|
|
2261
|
+
* @param {(x: T, n: number) => unknown} build
|
|
2262
|
+
* @param {number} part
|
|
2263
|
+
* @param {number} [floor]
|
|
2264
|
+
*/
|
|
1826
2265
|
const list = (name, items = [], fields, build, part, floor = MIN_TEXT_CHARS) => {
|
|
1827
2266
|
if (full) return items.map((x) => build(x, Infinity));
|
|
1828
2267
|
let share = left * part;
|
|
@@ -1851,6 +2290,7 @@ function byTarget(m, budget) {
|
|
|
1851
2290
|
const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
|
|
1852
2291
|
const caveat = clip(m.quality?.caveat ?? null, Math.min(CAVEAT_CHARS, budget / 3));
|
|
1853
2292
|
if (!full) left -= printed(summary) + printed(caveat);
|
|
2293
|
+
/** @type {Partial<ConsultResult>} */
|
|
1854
2294
|
const r = m.result ?? {};
|
|
1855
2295
|
// The findings first, with up to 60% of what is left and their points cut
|
|
1856
2296
|
// as short as it takes: they are what gets recorded against, so their ids
|
|
@@ -1880,6 +2320,7 @@ function byTarget(m, budget) {
|
|
|
1880
2320
|
(d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
|
|
1881
2321
|
1,
|
|
1882
2322
|
);
|
|
2323
|
+
/** @type {ComparisonColumn} */
|
|
1883
2324
|
const entry = {
|
|
1884
2325
|
job_id: m.job_id,
|
|
1885
2326
|
target: m.target,
|
|
@@ -1949,6 +2390,10 @@ const MIN_TEXT_CHARS = 40;
|
|
|
1949
2390
|
* points being checked, without the rest of the answer. A projection, not a
|
|
1950
2391
|
* summary. An id the answer does not have is refused with the ones it has.
|
|
1951
2392
|
*/
|
|
2393
|
+
/**
|
|
2394
|
+
* @param {JobView} view
|
|
2395
|
+
* @param {string[]} ids
|
|
2396
|
+
*/
|
|
1952
2397
|
export function pointsOf(view, ids) {
|
|
1953
2398
|
if (!view.result) {
|
|
1954
2399
|
throw new RequestError(`consultation "${view.job_id}" is ${view.status} and has no answer to take points from`, 'no_result');
|
|
@@ -1965,10 +2410,13 @@ export function pointsOf(view, ids) {
|
|
|
1965
2410
|
const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
|
|
1966
2411
|
const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
|
|
1967
2412
|
const points = [...new Set(ids)].map((id) => {
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
.find((
|
|
1971
|
-
|
|
2413
|
+
// Every id was checked against recordableIds above, so one list has it.
|
|
2414
|
+
const [point] = Object.entries(lists).flatMap(([kind, l]) => {
|
|
2415
|
+
const item = l.find((x) => x.id === id);
|
|
2416
|
+
return item ? [{ kind, ...item }] : [];
|
|
2417
|
+
});
|
|
2418
|
+
if (!point) throw new RequestError(`"${id}" is not a point in this answer`, 'unknown_entry_id');
|
|
2419
|
+
return { ...point, recorded: verdicts.get(id) ?? null };
|
|
1972
2420
|
});
|
|
1973
2421
|
return {
|
|
1974
2422
|
job_id: view.job_id,
|
|
@@ -1997,6 +2445,7 @@ export function pointsOf(view, ids) {
|
|
|
1997
2445
|
|
|
1998
2446
|
// What a value takes as the server prints it, nested four levels into the
|
|
1999
2447
|
// response: two-space JSON, every line indented eight more.
|
|
2448
|
+
/** @param {unknown} v */
|
|
2000
2449
|
function printed(v) {
|
|
2001
2450
|
const j = JSON.stringify(v, null, 2) ?? '';
|
|
2002
2451
|
return j.length + 8 * j.split('\n').length;
|
|
@@ -2004,10 +2453,15 @@ function printed(v) {
|
|
|
2004
2453
|
|
|
2005
2454
|
// A text cut to `max` characters as printed in JSON, so a run of characters
|
|
2006
2455
|
// that JSON escapes (a control character takes six) cannot outgrow the cut.
|
|
2456
|
+
/**
|
|
2457
|
+
* @param {string | null | undefined} s
|
|
2458
|
+
* @param {number} max
|
|
2459
|
+
* @returns {string | null | undefined}
|
|
2460
|
+
*/
|
|
2007
2461
|
function cut(s, max) {
|
|
2008
2462
|
if (typeof s !== 'string') return s;
|
|
2009
2463
|
const room = Math.max(0, Math.floor(max));
|
|
2010
|
-
const width = (t) => JSON.stringify(t).length - 2;
|
|
2464
|
+
const width = (/** @type {string} */ t) => JSON.stringify(t).length - 2;
|
|
2011
2465
|
if (width(s) <= room) return s;
|
|
2012
2466
|
// One character of the room goes to the ellipsis, and a surrogate pair is
|
|
2013
2467
|
// never split.
|
|
@@ -2021,6 +2475,11 @@ function cut(s, max) {
|
|
|
2021
2475
|
// is named rather than repeated: its size and sha256 (of the JSON stored in
|
|
2022
2476
|
// the round file, so both can be checked against it), where it is kept, and
|
|
2023
2477
|
// what was copied -- skipped files counted by reason, not listed.
|
|
2478
|
+
/**
|
|
2479
|
+
* @param {FullView} view
|
|
2480
|
+
* @param {string} [detail]
|
|
2481
|
+
* @returns {JobView}
|
|
2482
|
+
*/
|
|
2024
2483
|
function compactBrief(view, detail) {
|
|
2025
2484
|
if (detail === 'full' || !view.brief) return view;
|
|
2026
2485
|
const json = JSON.stringify(view.brief);
|
|
@@ -2032,12 +2491,18 @@ function compactBrief(view, detail) {
|
|
|
2032
2491
|
sha256: crypto.createHash('sha256').update(json).digest('hex'),
|
|
2033
2492
|
// Under the severally home (~/.severally unless the operator moved it).
|
|
2034
2493
|
history_file: path.relative(POLICY.home, store.roundPath(view.chain_id, view.round)),
|
|
2035
|
-
exposed: ex
|
|
2494
|
+
exposed: ex
|
|
2495
|
+
? { ...ex.totals, skipped: tally((ex.skipped ?? []).map((/** @type {{reason: string}} */ x) => x.reason)) }
|
|
2496
|
+
: null,
|
|
2036
2497
|
full_with: `consult_get({ job_id: "${view.job_id}", detail: "full" }) or consult_export`,
|
|
2037
2498
|
},
|
|
2038
2499
|
};
|
|
2039
2500
|
}
|
|
2040
2501
|
|
|
2502
|
+
/**
|
|
2503
|
+
* @param {Round} job
|
|
2504
|
+
* @returns {string}
|
|
2505
|
+
*/
|
|
2041
2506
|
function nextStep(job) {
|
|
2042
2507
|
if (job.status === 'running' || job.status === 'queued') return 'poll consult_get again, or consult_cancel to stop it';
|
|
2043
2508
|
if (job.status === 'completed') {
|
|
@@ -2072,9 +2537,9 @@ function nextStep(job) {
|
|
|
2072
2537
|
// Every spelling a token could name a flag by: `--name[=v]` as written, and for
|
|
2073
2538
|
// a single dash both a short flag with its value attached (`-sdanger-full-access`
|
|
2074
2539
|
// in clap) and a Go-style long flag (`-dangerously-skip-permissions`).
|
|
2075
|
-
const flagNames = (a) => {
|
|
2540
|
+
const flagNames = (/** @type {string} */ a) => {
|
|
2076
2541
|
if (!a.startsWith('-') || a === '-') return [];
|
|
2077
|
-
const name = a.split('=')
|
|
2542
|
+
const [name = ''] = a.split('=');
|
|
2078
2543
|
return a.startsWith('--') ? [name] : [name, a.slice(0, 2), `-${name}`];
|
|
2079
2544
|
};
|
|
2080
2545
|
|
|
@@ -2085,6 +2550,12 @@ const flagNames = (a) => {
|
|
|
2085
2550
|
* adapter's FORBIDDEN_FLAGS and RESERVED_FLAGS (aliases and other flags that
|
|
2086
2551
|
* undo it). Values (tokens without a leading dash) are not inspected.
|
|
2087
2552
|
*/
|
|
2553
|
+
/**
|
|
2554
|
+
* @param {CliId} target
|
|
2555
|
+
* @param {readonly string[]} adapterArgs
|
|
2556
|
+
* @param {unknown} extra
|
|
2557
|
+
* @returns {string | null}
|
|
2558
|
+
*/
|
|
2088
2559
|
export function extraArgsProblem(target, adapterArgs, extra) {
|
|
2089
2560
|
if (!Array.isArray(extra) || !extra.every((a) => typeof a === 'string')) return 'must be an array of strings';
|
|
2090
2561
|
const adapter = ADAPTERS[target];
|
|
@@ -2098,11 +2569,20 @@ export function extraArgsProblem(target, adapterArgs, extra) {
|
|
|
2098
2569
|
}
|
|
2099
2570
|
|
|
2100
2571
|
/** Extra args go before a trailing `-` (Codex's read-the-prompt-from-stdin marker), else at the end. */
|
|
2572
|
+
/**
|
|
2573
|
+
* @param {string[]} args
|
|
2574
|
+
* @param {readonly string[]} extra
|
|
2575
|
+
* @returns {string[]}
|
|
2576
|
+
*/
|
|
2101
2577
|
export function withExtraArgs(args, extra) {
|
|
2102
2578
|
if (extra.length === 0) return args;
|
|
2103
2579
|
return args.at(-1) === '-' ? [...args.slice(0, -1), ...extra, '-'] : [...args, ...extra];
|
|
2104
2580
|
}
|
|
2105
2581
|
|
|
2582
|
+
/**
|
|
2583
|
+
* @param {CliId} target
|
|
2584
|
+
* @param {readonly string[]} args
|
|
2585
|
+
*/
|
|
2106
2586
|
export function assertNoForbiddenFlags(target, args) {
|
|
2107
2587
|
const forbidden = ADAPTERS[target].FORBIDDEN_FLAGS;
|
|
2108
2588
|
const hit = args.find((a) => forbidden.includes(a));
|