severally-mcp 2.4.0 → 2.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +44 -19
- package/README.md +44 -18
- 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 +585 -255
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +57 -54
- package/scripts/doctor.mjs +15 -4
- 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 +184 -25
- 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 +578 -132
- 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 {
|
|
@@ -907,7 +1144,7 @@ export class JobManager {
|
|
|
907
1144
|
adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
908
1145
|
null;
|
|
909
1146
|
|
|
910
|
-
const attempt = (timeout) => {
|
|
1147
|
+
const attempt = (/** @type {number} */ timeout) => {
|
|
911
1148
|
const { handle, done } = runChild({
|
|
912
1149
|
command: invocation.command,
|
|
913
1150
|
args: invocation.args,
|
|
@@ -915,7 +1152,9 @@ export class JobManager {
|
|
|
915
1152
|
env,
|
|
916
1153
|
input: text,
|
|
917
1154
|
timeoutMs: timeout,
|
|
918
|
-
onCancelSignal: (fn) =>
|
|
1155
|
+
onCancelSignal: (fn) => {
|
|
1156
|
+
job._cancelFns.push(fn);
|
|
1157
|
+
},
|
|
919
1158
|
compactLine: adapter.compactLine,
|
|
920
1159
|
settleAfter: adapter.settleAfter,
|
|
921
1160
|
stopSignal: adapter.stopSignal,
|
|
@@ -933,6 +1172,7 @@ export class JobManager {
|
|
|
933
1172
|
// Same consultant, same model, same brief: a re-run never picks another
|
|
934
1173
|
// mind. Every earlier failure is kept in job.retries.
|
|
935
1174
|
let run = await attempt(budgetMs);
|
|
1175
|
+
/** @type {import('./run.mjs').Interpreted} */
|
|
936
1176
|
let interpreted;
|
|
937
1177
|
let authTried = false;
|
|
938
1178
|
let autoRetries = 0;
|
|
@@ -976,7 +1216,7 @@ export class JobManager {
|
|
|
976
1216
|
interpreted = adapter.interpret({ ...run, lastMessageText });
|
|
977
1217
|
// An answer after an error the CLI reported is kept only if it parses;
|
|
978
1218
|
// otherwise it is that error, re-run like any other.
|
|
979
|
-
const
|
|
1219
|
+
const recoveredError = interpreted.ok ? interpreted.recoveredError : null;
|
|
980
1220
|
if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
|
|
981
1221
|
interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
|
|
982
1222
|
}
|
|
@@ -1020,7 +1260,7 @@ export class JobManager {
|
|
|
1020
1260
|
at: new Date().toISOString(),
|
|
1021
1261
|
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1022
1262
|
});
|
|
1023
|
-
await new Promise((resolve) => {
|
|
1263
|
+
await new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
|
|
1024
1264
|
const timer = setTimeout(resolve, wait);
|
|
1025
1265
|
job._cancelFns.push(() => {
|
|
1026
1266
|
clearTimeout(timer);
|
|
@@ -1079,6 +1319,8 @@ export class JobManager {
|
|
|
1079
1319
|
`the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
|
|
1080
1320
|
leftover &&
|
|
1081
1321
|
'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)',
|
|
1322
|
+
req._snapshot && unreadCaveat(job.tool_calls, adapter.FILE_READ_TOOLS),
|
|
1323
|
+
refusedToolCaveat(job.tool_calls, adapter.ALLOWED_TOOLS),
|
|
1082
1324
|
].filter(Boolean);
|
|
1083
1325
|
// No `advice_usable` verdict here. It was always true, so it carried no
|
|
1084
1326
|
// information -- and worse, it read as the server vouching for advice it
|
|
@@ -1101,6 +1343,13 @@ export class JobManager {
|
|
|
1101
1343
|
}
|
|
1102
1344
|
}
|
|
1103
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* @param {Job} job
|
|
1348
|
+
* @param {string} kind
|
|
1349
|
+
* @param {unknown} message
|
|
1350
|
+
* @param {string | null} [detail]
|
|
1351
|
+
* @returns {Job}
|
|
1352
|
+
*/
|
|
1104
1353
|
#fail(job, kind, message, detail) {
|
|
1105
1354
|
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') return job;
|
|
1106
1355
|
job.status = kind === 'cancelled' ? 'cancelled' : 'failed';
|
|
@@ -1117,6 +1366,7 @@ export class JobManager {
|
|
|
1117
1366
|
}
|
|
1118
1367
|
|
|
1119
1368
|
// The in-progress marker other processes read (see store.writeRunning).
|
|
1369
|
+
/** @param {Job} job */
|
|
1120
1370
|
#mark(job) {
|
|
1121
1371
|
try {
|
|
1122
1372
|
store.writeRunning({ ...this.#record(job), owner: job._owner });
|
|
@@ -1125,6 +1375,7 @@ export class JobManager {
|
|
|
1125
1375
|
}
|
|
1126
1376
|
}
|
|
1127
1377
|
|
|
1378
|
+
/** @param {Job} job */
|
|
1128
1379
|
#finish(job) {
|
|
1129
1380
|
try {
|
|
1130
1381
|
store.persistRound(this.#record(job));
|
|
@@ -1145,7 +1396,7 @@ export class JobManager {
|
|
|
1145
1396
|
// consultant did not produce and a word outside VERDICTS, then stores what
|
|
1146
1397
|
// was written. It never decides that a finding was adopted, and it never
|
|
1147
1398
|
// fills in a verdict the lead did not write.
|
|
1148
|
-
/** @param {
|
|
1399
|
+
/** @param {RawRecord} [input] */
|
|
1149
1400
|
record(input = {}) {
|
|
1150
1401
|
if (input.records !== undefined) return this.#recordMany(input);
|
|
1151
1402
|
const allowed = ['job_id', 'entries', 'reflection'];
|
|
@@ -1169,6 +1420,7 @@ export class JobManager {
|
|
|
1169
1420
|
// record is checked before anything is written, so a bad id leaves the
|
|
1170
1421
|
// history as it was; then each job is written on its own, and a write that
|
|
1171
1422
|
// fails is named in the answer rather than reported as saved.
|
|
1423
|
+
/** @param {RawRecord} input */
|
|
1172
1424
|
#recordMany(input) {
|
|
1173
1425
|
const extra = Object.keys(input).filter((k) => k !== 'records');
|
|
1174
1426
|
if (extra.length) {
|
|
@@ -1181,10 +1433,12 @@ export class JobManager {
|
|
|
1181
1433
|
if (!Array.isArray(records) || records.length === 0) {
|
|
1182
1434
|
throw new RequestError('records must be a non-empty array of { job_id, entries?, reflection? }', 'records_required');
|
|
1183
1435
|
}
|
|
1436
|
+
/** @type {Array<{index: number, job_id: string | null, error: string, message: string}>} */
|
|
1184
1437
|
const problems = [];
|
|
1438
|
+
/** @type {RecordChange[]} */
|
|
1185
1439
|
const changes = [];
|
|
1186
1440
|
const seen = new Set();
|
|
1187
|
-
records.forEach((r, i) => {
|
|
1441
|
+
records.forEach((/** @type {RawRecord} */ r, i) => {
|
|
1188
1442
|
try {
|
|
1189
1443
|
if (seen.has(r?.job_id))
|
|
1190
1444
|
throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
|
|
@@ -1212,7 +1466,10 @@ export class JobManager {
|
|
|
1212
1466
|
|
|
1213
1467
|
// Everything that can be wrong with a record, found before anything is
|
|
1214
1468
|
// written: what the job's record and reflection become, or a RequestError.
|
|
1215
|
-
/**
|
|
1469
|
+
/**
|
|
1470
|
+
* @param {RawRecord} rec
|
|
1471
|
+
* @returns {RecordChange}
|
|
1472
|
+
*/
|
|
1216
1473
|
#checkRecord({ job_id: jobId, entries, reflection }) {
|
|
1217
1474
|
if (entries === undefined && reflection === undefined) {
|
|
1218
1475
|
throw new RequestError(
|
|
@@ -1240,7 +1497,8 @@ export class JobManager {
|
|
|
1240
1497
|
|
|
1241
1498
|
const ids = recordableIds(job.result);
|
|
1242
1499
|
const now = new Date().toISOString();
|
|
1243
|
-
|
|
1500
|
+
/** @type {Map<string, RecordEntry>} */
|
|
1501
|
+
const written = new Map();
|
|
1244
1502
|
|
|
1245
1503
|
for (const entry of entries ?? []) {
|
|
1246
1504
|
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
|
|
@@ -1257,7 +1515,7 @@ export class JobManager {
|
|
|
1257
1515
|
'invalid_verdict',
|
|
1258
1516
|
);
|
|
1259
1517
|
}
|
|
1260
|
-
|
|
1518
|
+
written.set(id, {
|
|
1261
1519
|
id,
|
|
1262
1520
|
verdict,
|
|
1263
1521
|
effect: text(entry?.effect),
|
|
@@ -1271,6 +1529,7 @@ export class JobManager {
|
|
|
1271
1529
|
// lead predicted can still arrive with the evidence that settles it, and a
|
|
1272
1530
|
// surprise can still be wrong, so a match/miss verdict would rate the
|
|
1273
1531
|
// consultation on the wrong axis.
|
|
1532
|
+
/** @type {Reflection | undefined} */
|
|
1274
1533
|
let newReflection;
|
|
1275
1534
|
if (reflection !== undefined) {
|
|
1276
1535
|
const delta = text(reflection?.delta);
|
|
@@ -1289,30 +1548,40 @@ export class JobManager {
|
|
|
1289
1548
|
newReflection = { delta, related_item_ids: related, recorded_at: now };
|
|
1290
1549
|
}
|
|
1291
1550
|
|
|
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 };
|
|
1551
|
+
return { job, live, entries: entries === undefined ? undefined : [...written.values()], reflection: newReflection };
|
|
1297
1552
|
}
|
|
1298
1553
|
|
|
1299
|
-
|
|
1554
|
+
/** @param {RecordChange} change */
|
|
1555
|
+
#saveRecord({ job, live, entries, reflection }) {
|
|
1300
1556
|
const was = { record: job.record, reflection: job.reflection };
|
|
1301
|
-
|
|
1302
|
-
|
|
1557
|
+
// Another server may have written verdicts or a reflection to this round
|
|
1558
|
+
// since this one loaded it. Start from the file, so this call replaces
|
|
1559
|
+
// only the ids it names (and the reflection, if it passes one) and keeps
|
|
1560
|
+
// the rest; the merge goes back into memory too, so consult_get and a
|
|
1561
|
+
// follow-up's brief show the same record.
|
|
1562
|
+
// ponytail: read-then-write, not locked; two writes in the same instant can still lose one.
|
|
1563
|
+
const disk = store.loadRoundAt(job);
|
|
1564
|
+
const base = disk?.result ? disk : job;
|
|
1565
|
+
job.reflection = reflection ?? base.reflection;
|
|
1566
|
+
if (entries !== undefined) {
|
|
1567
|
+
const merged = new Map((base.record?.entries ?? []).map((e) => [e.id, e]));
|
|
1568
|
+
for (const e of entries) merged.set(e.id, e);
|
|
1569
|
+
const ids = job.result ? recordableIds(job.result) : [];
|
|
1570
|
+
job.record = { updated_at: new Date().toISOString(), entries: ids.flatMap((id) => merged.get(id) ?? []) };
|
|
1571
|
+
} else if (base.record) job.record = base.record;
|
|
1303
1572
|
// An update to a round that already happened, so the index line stays as it
|
|
1304
1573
|
// was: the history has one line per consultation, not one per edit.
|
|
1305
1574
|
// Unlike the round itself, a verdict exists nowhere else once this server
|
|
1306
1575
|
// exits, so a failed write is the lead's to know about, not best effort.
|
|
1307
1576
|
try {
|
|
1308
|
-
store.persistRound(live ? this.#record(
|
|
1577
|
+
store.persistRound(live ? this.#record(live) : job, { appendIndex: false });
|
|
1309
1578
|
} catch (e) {
|
|
1310
1579
|
// Not saved, so not shown as recorded either.
|
|
1311
1580
|
Object.assign(job, was);
|
|
1312
1581
|
return {
|
|
1313
1582
|
job_id: job.job_id,
|
|
1314
1583
|
error: 'record_not_saved',
|
|
1315
|
-
message: `the verdict for "${job.job_id}" could not be written to the history (${e.message}); record it again`,
|
|
1584
|
+
message: `the verdict for "${job.job_id}" could not be written to the history (${e instanceof Error ? e.message : e}); record it again`,
|
|
1316
1585
|
};
|
|
1317
1586
|
}
|
|
1318
1587
|
// Counts only: the entries are what the lead just wrote, and
|
|
@@ -1327,6 +1596,12 @@ export class JobManager {
|
|
|
1327
1596
|
};
|
|
1328
1597
|
}
|
|
1329
1598
|
|
|
1599
|
+
/**
|
|
1600
|
+
* @param {string} jobId
|
|
1601
|
+
* @param {number | undefined} waitMs
|
|
1602
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1603
|
+
* @param {AbortSignal} [signal]
|
|
1604
|
+
*/
|
|
1330
1605
|
async wait(jobId, waitMs, opts, signal) {
|
|
1331
1606
|
const job = this.jobs.get(jobId);
|
|
1332
1607
|
if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
|
|
@@ -1340,6 +1615,13 @@ export class JobManager {
|
|
|
1340
1615
|
|
|
1341
1616
|
// A consultation another process runs has no promise here, so its history
|
|
1342
1617
|
// is re-read once a second until it is no longer running or the wait is up.
|
|
1618
|
+
/**
|
|
1619
|
+
* @template {{status: string}} T
|
|
1620
|
+
* @param {() => T | null} read
|
|
1621
|
+
* @param {number | undefined} waitMs
|
|
1622
|
+
* @param {AbortSignal} [signal]
|
|
1623
|
+
* @returns {Promise<T | null>}
|
|
1624
|
+
*/
|
|
1343
1625
|
async #waitOnDisk(read, waitMs, signal) {
|
|
1344
1626
|
const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1345
1627
|
let view = read();
|
|
@@ -1364,6 +1646,7 @@ export class JobManager {
|
|
|
1364
1646
|
}, CANCEL_POLL_MS).unref();
|
|
1365
1647
|
}
|
|
1366
1648
|
|
|
1649
|
+
/** @param {string} jobId */
|
|
1367
1650
|
cancel(jobId) {
|
|
1368
1651
|
const job = this.jobs.get(jobId);
|
|
1369
1652
|
if (!job) {
|
|
@@ -1379,18 +1662,22 @@ export class JobManager {
|
|
|
1379
1662
|
};
|
|
1380
1663
|
}
|
|
1381
1664
|
if (job.status !== 'running' && job.status !== 'queued') {
|
|
1382
|
-
return { ...this
|
|
1665
|
+
return { ...this.#jobView(job, undefined), cancel_effect: `job was already ${job.status}` };
|
|
1383
1666
|
}
|
|
1384
1667
|
job.cancelRequested = true;
|
|
1385
1668
|
for (const fn of job._cancelFns) fn();
|
|
1386
1669
|
if (job._handle) job._handle.stop();
|
|
1387
|
-
return { ...this
|
|
1670
|
+
return { ...this.#jobView(job, undefined), status: 'cancelling', cancel_effect: 'consultant process group signalled' };
|
|
1388
1671
|
}
|
|
1389
1672
|
|
|
1390
1673
|
// Every field here is derived from the one `members` array it is handed, so a
|
|
1391
1674
|
// group view can never report one status in `members` and another in
|
|
1392
1675
|
// `comparison` or `status` for the same job.
|
|
1393
|
-
/**
|
|
1676
|
+
/**
|
|
1677
|
+
* @param {Group} group
|
|
1678
|
+
* @param {JobView[]} members
|
|
1679
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1680
|
+
*/
|
|
1394
1681
|
#shapeGroup(group, members, { detail } = {}) {
|
|
1395
1682
|
const full = detail === 'full';
|
|
1396
1683
|
const cancelling = members.some((m) => m.status === 'cancelling');
|
|
@@ -1470,16 +1757,29 @@ export class JobManager {
|
|
|
1470
1757
|
// While no one has finished there is nothing to set side by side, and an
|
|
1471
1758
|
// empty column per consultant is only context spent on every poll.
|
|
1472
1759
|
const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
|
|
1473
|
-
|
|
1474
|
-
return view;
|
|
1760
|
+
return anyFinished ? { ...view, comparison: comparison(members, full ? Infinity : GROUP_CHARS - printed(view)) } : view;
|
|
1475
1761
|
}
|
|
1476
1762
|
|
|
1763
|
+
/**
|
|
1764
|
+
* @param {string} groupId
|
|
1765
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1766
|
+
*/
|
|
1477
1767
|
groupView(groupId, opts) {
|
|
1478
1768
|
const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
|
|
1479
1769
|
if (!group) return null;
|
|
1480
|
-
return this.#shapeGroup(
|
|
1770
|
+
return this.#shapeGroup(
|
|
1771
|
+
group,
|
|
1772
|
+
group.job_ids.map((jid) => this.view(jid, opts)).filter((v) => v != null),
|
|
1773
|
+
opts,
|
|
1774
|
+
);
|
|
1481
1775
|
}
|
|
1482
1776
|
|
|
1777
|
+
/**
|
|
1778
|
+
* @param {string} groupId
|
|
1779
|
+
* @param {number | undefined} waitMs
|
|
1780
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1781
|
+
* @param {AbortSignal} [signal]
|
|
1782
|
+
*/
|
|
1483
1783
|
async waitGroup(groupId, waitMs, opts, signal) {
|
|
1484
1784
|
const group = this.groups.get(groupId);
|
|
1485
1785
|
// A fan-out from another process or an earlier session: nothing here to
|
|
@@ -1493,6 +1793,7 @@ export class JobManager {
|
|
|
1493
1793
|
return this.groupView(groupId, opts);
|
|
1494
1794
|
}
|
|
1495
1795
|
|
|
1796
|
+
/** @param {string} groupId */
|
|
1496
1797
|
cancelGroup(groupId) {
|
|
1497
1798
|
const group = this.groups.get(groupId);
|
|
1498
1799
|
if (!group) {
|
|
@@ -1519,7 +1820,7 @@ export class JobManager {
|
|
|
1519
1820
|
// Shape the view from the cancel() results, not from a fresh read: the
|
|
1520
1821
|
// children exit asynchronously, so a signalled member reads as
|
|
1521
1822
|
// "cancelling" until it is reaped, and the whole payload must say so.
|
|
1522
|
-
const members = group.job_ids.map((jid) => this.cancel(jid)).filter(
|
|
1823
|
+
const members = group.job_ids.map((jid) => this.cancel(jid)).filter((v) => v != null);
|
|
1523
1824
|
const total = group.job_ids.length;
|
|
1524
1825
|
return {
|
|
1525
1826
|
...this.#shapeGroup(group, members),
|
|
@@ -1577,8 +1878,13 @@ export class JobManager {
|
|
|
1577
1878
|
// files of that diff are the same entry for this purpose.
|
|
1578
1879
|
// ponytail: a diff recorded before 1.11 hashed its files in git's order, not
|
|
1579
1880
|
// path order, so its re-copy can read as changed when it is not.
|
|
1881
|
+
/**
|
|
1882
|
+
* @param {ExposeManifest} manifest
|
|
1883
|
+
* @param {BriefExposeEntry[]} parentEntries
|
|
1884
|
+
*/
|
|
1580
1885
|
function markSinceParent(manifest, parentEntries) {
|
|
1581
|
-
const key = (
|
|
1886
|
+
const key = (/** @type {string} */ source, /** @type {string} */ kind) =>
|
|
1887
|
+
`${kind === 'change_files' ? 'diff' : kind}\0${source}`;
|
|
1582
1888
|
const before = new Map(parentEntries.map((e) => [key(e.source, e.kind), e.sha256]));
|
|
1583
1889
|
for (const e of manifest.entries) {
|
|
1584
1890
|
const k = key(e.source, e.kind);
|
|
@@ -1589,6 +1895,7 @@ function markSinceParent(manifest, parentEntries) {
|
|
|
1589
1895
|
// What consult_start says a follow-up copied again: the entries it shares
|
|
1590
1896
|
// with the parent round, the ones left out and why, and how many files that
|
|
1591
1897
|
// added to this round.
|
|
1898
|
+
/** @param {ExposeManifest} manifest */
|
|
1592
1899
|
function inheritedSummary(manifest) {
|
|
1593
1900
|
const carried = manifest.entries.filter((e) => e.inherited);
|
|
1594
1901
|
return {
|
|
@@ -1604,6 +1911,10 @@ function inheritedSummary(manifest) {
|
|
|
1604
1911
|
// is the raw output stream, unparsed: material the lead may read, never an
|
|
1605
1912
|
// answer, which is why it sits beside the failure and not in `result`.
|
|
1606
1913
|
const PARTIAL_TAIL_CHARS = 8_000;
|
|
1914
|
+
/**
|
|
1915
|
+
* @param {import('./run.mjs').ChildResult} run
|
|
1916
|
+
* @returns {import('./store.mjs').PartialOutput | null}
|
|
1917
|
+
*/
|
|
1607
1918
|
function partialOutput(run) {
|
|
1608
1919
|
const out = String(run.stdout ?? '');
|
|
1609
1920
|
if (!out.trim()) return null;
|
|
@@ -1621,10 +1932,16 @@ function partialOutput(run) {
|
|
|
1621
1932
|
// Waits for `promise` (null: for nothing), at most `ms`, and no longer than
|
|
1622
1933
|
// `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
|
|
1623
1934
|
// cut short leaves nothing behind.
|
|
1935
|
+
/**
|
|
1936
|
+
* @param {Promise<unknown> | null | undefined} promise
|
|
1937
|
+
* @param {number} ms
|
|
1938
|
+
* @param {AbortSignal} [signal]
|
|
1939
|
+
*/
|
|
1624
1940
|
async function settled(promise, ms, signal) {
|
|
1625
1941
|
let timer;
|
|
1942
|
+
/** @type {(() => void) | undefined} */
|
|
1626
1943
|
let stop;
|
|
1627
|
-
const until = new Promise((resolve) => {
|
|
1944
|
+
const until = new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
|
|
1628
1945
|
stop = resolve;
|
|
1629
1946
|
timer = setTimeout(resolve, ms);
|
|
1630
1947
|
if (signal?.aborted) resolve();
|
|
@@ -1634,10 +1951,11 @@ async function settled(promise, ms, signal) {
|
|
|
1634
1951
|
await (promise ? Promise.race([promise, until]) : until);
|
|
1635
1952
|
} finally {
|
|
1636
1953
|
clearTimeout(timer);
|
|
1637
|
-
signal?.removeEventListener('abort', stop);
|
|
1954
|
+
if (stop) signal?.removeEventListener('abort', stop);
|
|
1638
1955
|
}
|
|
1639
1956
|
}
|
|
1640
1957
|
|
|
1958
|
+
/** @param {string | Record<string, unknown>} text */
|
|
1641
1959
|
function parses(text) {
|
|
1642
1960
|
try {
|
|
1643
1961
|
normalizeResult(text);
|
|
@@ -1648,6 +1966,10 @@ function parses(text) {
|
|
|
1648
1966
|
}
|
|
1649
1967
|
}
|
|
1650
1968
|
|
|
1969
|
+
/**
|
|
1970
|
+
* @param {ConsultResult} result
|
|
1971
|
+
* @returns {string[]}
|
|
1972
|
+
*/
|
|
1651
1973
|
function recordableIds(result) {
|
|
1652
1974
|
return [
|
|
1653
1975
|
...result.findings.map((f) => f.id),
|
|
@@ -1658,7 +1980,12 @@ function recordableIds(result) {
|
|
|
1658
1980
|
|
|
1659
1981
|
// How many of each word, in the order they first appear. The only arithmetic
|
|
1660
1982
|
// this server does on anyone's judgement.
|
|
1983
|
+
/**
|
|
1984
|
+
* @param {Iterable<string>} values
|
|
1985
|
+
* @returns {Record<string, number>}
|
|
1986
|
+
*/
|
|
1661
1987
|
function tally(values) {
|
|
1988
|
+
/** @type {Record<string, number>} */
|
|
1662
1989
|
const out = {};
|
|
1663
1990
|
for (const v of values) out[v] = (out[v] ?? 0) + 1;
|
|
1664
1991
|
return out;
|
|
@@ -1667,17 +1994,27 @@ function tally(values) {
|
|
|
1667
1994
|
// Counting is mechanical and stays that way: how many points the answer has,
|
|
1668
1995
|
// how many carry a verdict, which ones do not. No judgement about whether the
|
|
1669
1996
|
// consultation was worth it -- that is the lead's to draw from the entries.
|
|
1997
|
+
/**
|
|
1998
|
+
* @param {Round} job
|
|
1999
|
+
* @returns {JobRecord}
|
|
2000
|
+
*/
|
|
1670
2001
|
function recordSummary(job) {
|
|
1671
|
-
|
|
1672
|
-
const
|
|
2002
|
+
// Callers pass a round that has a record, and a record is only written against an answer (#checkRecord).
|
|
2003
|
+
const record = /** @type {JobRecord} */ (job.record);
|
|
2004
|
+
const ids = recordableIds(/** @type {ConsultResult} */ (job.result));
|
|
2005
|
+
const written = new Set(record.entries.map((e) => e.id));
|
|
1673
2006
|
return {
|
|
1674
|
-
...
|
|
1675
|
-
verdicts: tally(
|
|
2007
|
+
...record,
|
|
2008
|
+
verdicts: tally(record.entries.map((e) => e.verdict)),
|
|
1676
2009
|
coverage: { recordable: ids.length, recorded: written.size },
|
|
1677
2010
|
unrecorded: ids.filter((id) => !written.has(id)),
|
|
1678
2011
|
};
|
|
1679
2012
|
}
|
|
1680
2013
|
|
|
2014
|
+
/**
|
|
2015
|
+
* @param {unknown} value
|
|
2016
|
+
* @returns {string | null}
|
|
2017
|
+
*/
|
|
1681
2018
|
function text(value) {
|
|
1682
2019
|
if (typeof value !== 'string') return null;
|
|
1683
2020
|
const t = redact(value).trim();
|
|
@@ -1690,23 +2027,29 @@ function text(value) {
|
|
|
1690
2027
|
// a key left out is inherited -- one passed, even as an empty list, replaces
|
|
1691
2028
|
// it -- and the validation that follows sees the result. Files are not:
|
|
1692
2029
|
// copying them again stays expose_from_parent, named in the call (schema.mjs).
|
|
2030
|
+
/**
|
|
2031
|
+
* @param {RawRequest} raw
|
|
2032
|
+
* @param {Round} round
|
|
2033
|
+
* @returns {RawRequest}
|
|
2034
|
+
*/
|
|
1693
2035
|
function inherit(raw, round) {
|
|
1694
2036
|
const { brief } = round;
|
|
1695
2037
|
// A round kept off the web stays off: the material it carries over is the same.
|
|
2038
|
+
/** @type {RawRequest} */
|
|
1696
2039
|
const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
|
|
1697
2040
|
if (!brief) return out;
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
!(Array.isArray(brief[k]) && !brief[k].length)
|
|
1704
|
-
)
|
|
1705
|
-
out[k] = brief[k];
|
|
2041
|
+
/** @type {Array<'objective' | 'success_criteria' | 'constraints'>} */
|
|
2042
|
+
const topKeys = ['objective', 'success_criteria', 'constraints'];
|
|
2043
|
+
for (const k of topKeys) {
|
|
2044
|
+
const v = brief[k];
|
|
2045
|
+
if (out[k] === undefined && raw.context?.[k] === undefined && v != null && !(Array.isArray(v) && !v.length)) out[k] = v;
|
|
1706
2046
|
}
|
|
1707
2047
|
const ctx = { ...(raw.context ?? {}) };
|
|
1708
|
-
|
|
1709
|
-
|
|
2048
|
+
/** @type {Array<'facts' | 'proposal' | 'counterpoints'>} */
|
|
2049
|
+
const contextKeys = ['facts', 'proposal', 'counterpoints'];
|
|
2050
|
+
for (const k of contextKeys) {
|
|
2051
|
+
const v = brief[k];
|
|
2052
|
+
if (ctx[k] === undefined && v != null && !(Array.isArray(v) && !v.length)) ctx[k] = v;
|
|
1710
2053
|
}
|
|
1711
2054
|
// The diff expose_diff added is not the lead's material: it is taken again
|
|
1712
2055
|
// (or replaced) through expose_from_parent, against the files as they are now.
|
|
@@ -1723,6 +2066,7 @@ function inherit(raw, round) {
|
|
|
1723
2066
|
return out;
|
|
1724
2067
|
}
|
|
1725
2068
|
|
|
2069
|
+
/** @param {ReturnType<typeof normalizeResult>} normalized */
|
|
1726
2070
|
function adviceCaveat(normalized) {
|
|
1727
2071
|
const { result, quality } = normalized;
|
|
1728
2072
|
const notes = [];
|
|
@@ -1745,6 +2089,40 @@ function adviceCaveat(normalized) {
|
|
|
1745
2089
|
return notes.length ? notes.join('; ') : null;
|
|
1746
2090
|
}
|
|
1747
2091
|
|
|
2092
|
+
// Files were handed over, but no call to a file-reading tool succeeded. On
|
|
2093
|
+
// 2026-10-20 an agy release renamed its read tool, every read was refused,
|
|
2094
|
+
// and the answer still cited line numbers. Only for a CLI that reports its
|
|
2095
|
+
// tool calls and names its reading tools; the others say nothing here.
|
|
2096
|
+
/**
|
|
2097
|
+
* @param {Record<string, number> | null | undefined} calls
|
|
2098
|
+
* @param {readonly string[] | undefined} readTools
|
|
2099
|
+
* @returns {string | null}
|
|
2100
|
+
*/
|
|
2101
|
+
function unreadCaveat(calls, readTools) {
|
|
2102
|
+
if (!calls || !readTools) return null;
|
|
2103
|
+
if (readTools.some((t) => calls[t])) return null;
|
|
2104
|
+
const failed = readTools.reduce((n, t) => n + (calls[`${t}:error`] ?? 0), 0);
|
|
2105
|
+
const how = failed ? `${failed} failed or were refused` : 'none was made';
|
|
2106
|
+
return `files were exposed, but no call to read one succeeded (${how})`;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// A refused call to a tool outside the allowlist is most often a tool the CLI
|
|
2110
|
+
// renamed in a new release; naming it lets one consultation show the cause.
|
|
2111
|
+
/**
|
|
2112
|
+
* @param {Record<string, number> | null | undefined} calls
|
|
2113
|
+
* @param {readonly string[] | undefined} allowed
|
|
2114
|
+
* @returns {string | null}
|
|
2115
|
+
*/
|
|
2116
|
+
function refusedToolCaveat(calls, allowed) {
|
|
2117
|
+
if (!calls || !allowed) return null;
|
|
2118
|
+
const names = Object.keys(calls)
|
|
2119
|
+
.filter((k) => k.endsWith(':error'))
|
|
2120
|
+
.map((k) => k.slice(0, -':error'.length))
|
|
2121
|
+
.filter((t) => !allowed.includes(t));
|
|
2122
|
+
if (!names.length) return null;
|
|
2123
|
+
return `the consultant's CLI called ${names.join(', ')}, which severally does not allow, and was refused; a new CLI release may have renamed a tool`;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
1748
2126
|
// A lead may legitimately name its own CLI as the consultant: a fresh child
|
|
1749
2127
|
// session with no shared context is still a useful clean-context re-read.
|
|
1750
2128
|
// But it is not an independent opinion -- the answer comes from the same
|
|
@@ -1752,10 +2130,17 @@ function adviceCaveat(normalized) {
|
|
|
1752
2130
|
// lineage is the consultant's model's, not its CLI's: OpenCode can run GLM
|
|
1753
2131
|
// for a GLM lead and DeepSeek for anyone.
|
|
1754
2132
|
|
|
2133
|
+
/**
|
|
2134
|
+
* @param {string | null | undefined} caller
|
|
2135
|
+
* @param {string | null | undefined} callerModel
|
|
2136
|
+
* @param {Consultant} consultant
|
|
2137
|
+
* @returns {string | null}
|
|
2138
|
+
*/
|
|
1755
2139
|
function sameVendorCaveat(caller, callerModel, consultant) {
|
|
1756
2140
|
// The lead's lineage: its declared model if that names one, else its CLI's
|
|
1757
2141
|
// own. An unknown lineage on either side is no caveat -- not a guess.
|
|
1758
|
-
const
|
|
2142
|
+
const callerCli = CLI_IDS.find((c) => c === caller);
|
|
2143
|
+
const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (callerCli ? (CLI_VENDORS[callerCli] ?? null) : null);
|
|
1759
2144
|
const vendor = consultant.vendor;
|
|
1760
2145
|
if (!callerVendor || !vendor || callerVendor !== vendor) return null;
|
|
1761
2146
|
// Say what a fresh session of the same lineage removes and what it keeps.
|
|
@@ -1785,15 +2170,20 @@ function sameVendorCaveat(caller, callerModel, consultant) {
|
|
|
1785
2170
|
// A mechanical side-by-side. The server deliberately does not decide whether
|
|
1786
2171
|
// the consultants agree: inventing agreement that is not there is exactly the
|
|
1787
2172
|
// failure a second opinion is supposed to prevent.
|
|
2173
|
+
/**
|
|
2174
|
+
* @param {JobView[]} members
|
|
2175
|
+
* @param {number} budget
|
|
2176
|
+
*/
|
|
1788
2177
|
function comparison(members, budget) {
|
|
1789
2178
|
// Each column first pays for its fixed fields; the rest is its budget.
|
|
1790
2179
|
const each = (budget - COMPARISON_NOTE_CHARS) / Math.max(1, members.length) - COLUMN_FIXED_CHARS;
|
|
1791
|
-
|
|
2180
|
+
const firstPass = members.map((m) => ({ m, column: byTarget(m, each) }));
|
|
2181
|
+
let columns = firstPass.map((x) => x.column);
|
|
1792
2182
|
// What the short answers did not spend goes to the ones that were cut.
|
|
1793
2183
|
const cutShort = columns.filter((c) => c.truncated).length;
|
|
1794
2184
|
if (each !== Infinity && cutShort) {
|
|
1795
2185
|
const spare = columns.reduce((n, c) => n + (c.truncated ? 0 : Math.max(0, each + COLUMN_FIXED_CHARS - printed(c))), 0);
|
|
1796
|
-
columns =
|
|
2186
|
+
columns = firstPass.map(({ m, column }) => (column.truncated ? byTarget(m, each + spare / cutShort) : column));
|
|
1797
2187
|
}
|
|
1798
2188
|
return {
|
|
1799
2189
|
by_target: columns,
|
|
@@ -1812,17 +2202,32 @@ function comparison(members, budget) {
|
|
|
1812
2202
|
// shortened to fit the share before any is left out. A cut is flagged, never hidden: `truncated` says so,
|
|
1813
2203
|
// `omitted` counts the items a list could not hold, and `full_with` names the
|
|
1814
2204
|
// call that has everything.
|
|
2205
|
+
/**
|
|
2206
|
+
* @param {JobView} m
|
|
2207
|
+
* @param {number} budget
|
|
2208
|
+
* @returns {ComparisonColumn}
|
|
2209
|
+
*/
|
|
1815
2210
|
function byTarget(m, budget) {
|
|
1816
2211
|
const full = budget === Infinity;
|
|
1817
2212
|
let left = budget;
|
|
1818
2213
|
let truncated = false;
|
|
2214
|
+
/** @type {Record<string, number>} */
|
|
1819
2215
|
const omitted = {};
|
|
1820
|
-
const clip = (s, max) => {
|
|
2216
|
+
const clip = (/** @type {string | null | undefined} */ s, /** @type {number} */ max) => {
|
|
1821
2217
|
const out = full ? s : cut(s, max);
|
|
1822
2218
|
if (out !== s) truncated = true;
|
|
1823
2219
|
return out;
|
|
1824
2220
|
};
|
|
1825
2221
|
const item = Math.min(ITEM_CHARS, budget / 10);
|
|
2222
|
+
/**
|
|
2223
|
+
* @template T
|
|
2224
|
+
* @param {string} name
|
|
2225
|
+
* @param {T[] | undefined} items
|
|
2226
|
+
* @param {number} fields
|
|
2227
|
+
* @param {(x: T, n: number) => unknown} build
|
|
2228
|
+
* @param {number} part
|
|
2229
|
+
* @param {number} [floor]
|
|
2230
|
+
*/
|
|
1826
2231
|
const list = (name, items = [], fields, build, part, floor = MIN_TEXT_CHARS) => {
|
|
1827
2232
|
if (full) return items.map((x) => build(x, Infinity));
|
|
1828
2233
|
let share = left * part;
|
|
@@ -1851,6 +2256,7 @@ function byTarget(m, budget) {
|
|
|
1851
2256
|
const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
|
|
1852
2257
|
const caveat = clip(m.quality?.caveat ?? null, Math.min(CAVEAT_CHARS, budget / 3));
|
|
1853
2258
|
if (!full) left -= printed(summary) + printed(caveat);
|
|
2259
|
+
/** @type {Partial<ConsultResult>} */
|
|
1854
2260
|
const r = m.result ?? {};
|
|
1855
2261
|
// The findings first, with up to 60% of what is left and their points cut
|
|
1856
2262
|
// as short as it takes: they are what gets recorded against, so their ids
|
|
@@ -1880,6 +2286,7 @@ function byTarget(m, budget) {
|
|
|
1880
2286
|
(d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
|
|
1881
2287
|
1,
|
|
1882
2288
|
);
|
|
2289
|
+
/** @type {ComparisonColumn} */
|
|
1883
2290
|
const entry = {
|
|
1884
2291
|
job_id: m.job_id,
|
|
1885
2292
|
target: m.target,
|
|
@@ -1949,6 +2356,10 @@ const MIN_TEXT_CHARS = 40;
|
|
|
1949
2356
|
* points being checked, without the rest of the answer. A projection, not a
|
|
1950
2357
|
* summary. An id the answer does not have is refused with the ones it has.
|
|
1951
2358
|
*/
|
|
2359
|
+
/**
|
|
2360
|
+
* @param {JobView} view
|
|
2361
|
+
* @param {string[]} ids
|
|
2362
|
+
*/
|
|
1952
2363
|
export function pointsOf(view, ids) {
|
|
1953
2364
|
if (!view.result) {
|
|
1954
2365
|
throw new RequestError(`consultation "${view.job_id}" is ${view.status} and has no answer to take points from`, 'no_result');
|
|
@@ -1965,10 +2376,13 @@ export function pointsOf(view, ids) {
|
|
|
1965
2376
|
const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
|
|
1966
2377
|
const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
|
|
1967
2378
|
const points = [...new Set(ids)].map((id) => {
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
.find((
|
|
1971
|
-
|
|
2379
|
+
// Every id was checked against recordableIds above, so one list has it.
|
|
2380
|
+
const [point] = Object.entries(lists).flatMap(([kind, l]) => {
|
|
2381
|
+
const item = l.find((x) => x.id === id);
|
|
2382
|
+
return item ? [{ kind, ...item }] : [];
|
|
2383
|
+
});
|
|
2384
|
+
if (!point) throw new RequestError(`"${id}" is not a point in this answer`, 'unknown_entry_id');
|
|
2385
|
+
return { ...point, recorded: verdicts.get(id) ?? null };
|
|
1972
2386
|
});
|
|
1973
2387
|
return {
|
|
1974
2388
|
job_id: view.job_id,
|
|
@@ -1997,6 +2411,7 @@ export function pointsOf(view, ids) {
|
|
|
1997
2411
|
|
|
1998
2412
|
// What a value takes as the server prints it, nested four levels into the
|
|
1999
2413
|
// response: two-space JSON, every line indented eight more.
|
|
2414
|
+
/** @param {unknown} v */
|
|
2000
2415
|
function printed(v) {
|
|
2001
2416
|
const j = JSON.stringify(v, null, 2) ?? '';
|
|
2002
2417
|
return j.length + 8 * j.split('\n').length;
|
|
@@ -2004,10 +2419,15 @@ function printed(v) {
|
|
|
2004
2419
|
|
|
2005
2420
|
// A text cut to `max` characters as printed in JSON, so a run of characters
|
|
2006
2421
|
// that JSON escapes (a control character takes six) cannot outgrow the cut.
|
|
2422
|
+
/**
|
|
2423
|
+
* @param {string | null | undefined} s
|
|
2424
|
+
* @param {number} max
|
|
2425
|
+
* @returns {string | null | undefined}
|
|
2426
|
+
*/
|
|
2007
2427
|
function cut(s, max) {
|
|
2008
2428
|
if (typeof s !== 'string') return s;
|
|
2009
2429
|
const room = Math.max(0, Math.floor(max));
|
|
2010
|
-
const width = (t) => JSON.stringify(t).length - 2;
|
|
2430
|
+
const width = (/** @type {string} */ t) => JSON.stringify(t).length - 2;
|
|
2011
2431
|
if (width(s) <= room) return s;
|
|
2012
2432
|
// One character of the room goes to the ellipsis, and a surrogate pair is
|
|
2013
2433
|
// never split.
|
|
@@ -2021,6 +2441,11 @@ function cut(s, max) {
|
|
|
2021
2441
|
// is named rather than repeated: its size and sha256 (of the JSON stored in
|
|
2022
2442
|
// the round file, so both can be checked against it), where it is kept, and
|
|
2023
2443
|
// what was copied -- skipped files counted by reason, not listed.
|
|
2444
|
+
/**
|
|
2445
|
+
* @param {FullView} view
|
|
2446
|
+
* @param {string} [detail]
|
|
2447
|
+
* @returns {JobView}
|
|
2448
|
+
*/
|
|
2024
2449
|
function compactBrief(view, detail) {
|
|
2025
2450
|
if (detail === 'full' || !view.brief) return view;
|
|
2026
2451
|
const json = JSON.stringify(view.brief);
|
|
@@ -2032,12 +2457,18 @@ function compactBrief(view, detail) {
|
|
|
2032
2457
|
sha256: crypto.createHash('sha256').update(json).digest('hex'),
|
|
2033
2458
|
// Under the severally home (~/.severally unless the operator moved it).
|
|
2034
2459
|
history_file: path.relative(POLICY.home, store.roundPath(view.chain_id, view.round)),
|
|
2035
|
-
exposed: ex
|
|
2460
|
+
exposed: ex
|
|
2461
|
+
? { ...ex.totals, skipped: tally((ex.skipped ?? []).map((/** @type {{reason: string}} */ x) => x.reason)) }
|
|
2462
|
+
: null,
|
|
2036
2463
|
full_with: `consult_get({ job_id: "${view.job_id}", detail: "full" }) or consult_export`,
|
|
2037
2464
|
},
|
|
2038
2465
|
};
|
|
2039
2466
|
}
|
|
2040
2467
|
|
|
2468
|
+
/**
|
|
2469
|
+
* @param {Round} job
|
|
2470
|
+
* @returns {string}
|
|
2471
|
+
*/
|
|
2041
2472
|
function nextStep(job) {
|
|
2042
2473
|
if (job.status === 'running' || job.status === 'queued') return 'poll consult_get again, or consult_cancel to stop it';
|
|
2043
2474
|
if (job.status === 'completed') {
|
|
@@ -2072,9 +2503,9 @@ function nextStep(job) {
|
|
|
2072
2503
|
// Every spelling a token could name a flag by: `--name[=v]` as written, and for
|
|
2073
2504
|
// a single dash both a short flag with its value attached (`-sdanger-full-access`
|
|
2074
2505
|
// in clap) and a Go-style long flag (`-dangerously-skip-permissions`).
|
|
2075
|
-
const flagNames = (a) => {
|
|
2506
|
+
const flagNames = (/** @type {string} */ a) => {
|
|
2076
2507
|
if (!a.startsWith('-') || a === '-') return [];
|
|
2077
|
-
const name = a.split('=')
|
|
2508
|
+
const [name = ''] = a.split('=');
|
|
2078
2509
|
return a.startsWith('--') ? [name] : [name, a.slice(0, 2), `-${name}`];
|
|
2079
2510
|
};
|
|
2080
2511
|
|
|
@@ -2085,6 +2516,12 @@ const flagNames = (a) => {
|
|
|
2085
2516
|
* adapter's FORBIDDEN_FLAGS and RESERVED_FLAGS (aliases and other flags that
|
|
2086
2517
|
* undo it). Values (tokens without a leading dash) are not inspected.
|
|
2087
2518
|
*/
|
|
2519
|
+
/**
|
|
2520
|
+
* @param {CliId} target
|
|
2521
|
+
* @param {readonly string[]} adapterArgs
|
|
2522
|
+
* @param {unknown} extra
|
|
2523
|
+
* @returns {string | null}
|
|
2524
|
+
*/
|
|
2088
2525
|
export function extraArgsProblem(target, adapterArgs, extra) {
|
|
2089
2526
|
if (!Array.isArray(extra) || !extra.every((a) => typeof a === 'string')) return 'must be an array of strings';
|
|
2090
2527
|
const adapter = ADAPTERS[target];
|
|
@@ -2098,11 +2535,20 @@ export function extraArgsProblem(target, adapterArgs, extra) {
|
|
|
2098
2535
|
}
|
|
2099
2536
|
|
|
2100
2537
|
/** Extra args go before a trailing `-` (Codex's read-the-prompt-from-stdin marker), else at the end. */
|
|
2538
|
+
/**
|
|
2539
|
+
* @param {string[]} args
|
|
2540
|
+
* @param {readonly string[]} extra
|
|
2541
|
+
* @returns {string[]}
|
|
2542
|
+
*/
|
|
2101
2543
|
export function withExtraArgs(args, extra) {
|
|
2102
2544
|
if (extra.length === 0) return args;
|
|
2103
2545
|
return args.at(-1) === '-' ? [...args.slice(0, -1), ...extra, '-'] : [...args, ...extra];
|
|
2104
2546
|
}
|
|
2105
2547
|
|
|
2548
|
+
/**
|
|
2549
|
+
* @param {CliId} target
|
|
2550
|
+
* @param {readonly string[]} args
|
|
2551
|
+
*/
|
|
2106
2552
|
export function assertNoForbiddenFlags(target, args) {
|
|
2107
2553
|
const forbidden = ADAPTERS[target].FORBIDDEN_FLAGS;
|
|
2108
2554
|
const hit = args.find((a) => forbidden.includes(a));
|