severally-mcp 2.3.1 → 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 +52 -17
- package/README.md +52 -16
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +15 -3
- 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 +6573 -5978
- 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 +76 -68
- package/scripts/doctor.mjs +66 -20
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +327 -90
- package/scripts/live-check.mjs +62 -23
- package/scripts/live-mcp-check.mjs +52 -16
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +62 -28
- package/src/adapters/antigravity.mjs +111 -30
- package/src/adapters/claude-code.mjs +121 -28
- package/src/adapters/codex.mjs +125 -33
- package/src/adapters/opencode-sandbox.mjs +59 -22
- package/src/adapters/opencode.mjs +144 -30
- package/src/brief.mjs +148 -47
- package/src/consultants.mjs +255 -48
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +99 -18
- package/src/expose-paths.mjs +310 -50
- package/src/failures.mjs +31 -14
- package/src/jobs.mjs +863 -249
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +172 -40
- package/src/platform.mjs +56 -8
- package/src/policy.mjs +245 -52
- package/src/redact.mjs +59 -16
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +225 -30
- package/src/schema.mjs +132 -27
- package/src/server.mjs +178 -81
- package/src/status.mjs +94 -13
- package/src/store.mjs +418 -35
package/src/jobs.mjs
CHANGED
|
@@ -4,8 +4,18 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
POLICY,
|
|
9
|
+
VERDICTS,
|
|
10
|
+
limitsSummary,
|
|
11
|
+
timeoutMs,
|
|
12
|
+
detectCaller,
|
|
13
|
+
configProblem,
|
|
14
|
+
consultantByName,
|
|
15
|
+
consultantNamed,
|
|
16
|
+
consultantAvailable,
|
|
17
|
+
} from './policy.mjs';
|
|
18
|
+
import { vendorOf, sameModel, CLI_VENDORS, CLI_IDS } from './consultants.mjs';
|
|
9
19
|
import { parseRequest, RequestError, charCount } from './schema.mjs';
|
|
10
20
|
import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
|
|
11
21
|
import { CONSULT_RESULT_SCHEMA } from './result-schema.mjs';
|
|
@@ -20,6 +30,118 @@ import * as claudeCode from './adapters/claude-code.mjs';
|
|
|
20
30
|
import * as antigravity from './adapters/antigravity.mjs';
|
|
21
31
|
import * as opencode from './adapters/opencode.mjs';
|
|
22
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>} */
|
|
23
145
|
const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
|
|
24
146
|
|
|
25
147
|
// The recovery respawn runs only while this much of the original budget is
|
|
@@ -46,6 +168,7 @@ const API_KEY_VARS = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
|
|
|
46
168
|
const CREDENTIAL_FILE_VARS = ['GOOGLE_APPLICATION_CREDENTIALS'];
|
|
47
169
|
const ALL_API_CREDENTIAL_VARS = [...API_KEY_VARS, ...CREDENTIAL_FILE_VARS];
|
|
48
170
|
|
|
171
|
+
/** @param {Record<string, string | undefined>} env */
|
|
49
172
|
function hasApiCredential(env) {
|
|
50
173
|
if (API_KEY_VARS.some((name) => env[name])) return true;
|
|
51
174
|
return CREDENTIAL_FILE_VARS.some((name) => env[name] && fs.existsSync(env[name]));
|
|
@@ -55,11 +178,12 @@ function hasApiCredential(env) {
|
|
|
55
178
|
// listed as not reported rather than silently missing from the totals.
|
|
56
179
|
const USAGE_FIGURES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'total_tokens', 'cost_usd'];
|
|
57
180
|
|
|
58
|
-
const id = (prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
|
|
181
|
+
const id = (/** @type {string} */ prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
|
|
59
182
|
|
|
60
183
|
// Jobs this process is running, across every JobManager in it. A marker owned
|
|
61
184
|
// by this pid but missing here was left by an earlier process that had the
|
|
62
185
|
// same pid, so it is as dead as one whose pid no longer exists.
|
|
186
|
+
/** @type {Set<string>} */
|
|
63
187
|
const LIVE_HERE = new Set();
|
|
64
188
|
|
|
65
189
|
// What became of the process that started a consultation: 'alive', 'dead',
|
|
@@ -67,19 +191,35 @@ const LIVE_HERE = new Set();
|
|
|
67
191
|
// (budget, retries included, plus the kill grace), so either that process is
|
|
68
192
|
// stuck or the pid now belongs to something else. Nothing is killed on the
|
|
69
193
|
// strength of this.
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} jobId
|
|
196
|
+
* @param {JobOwner | null | undefined} owner
|
|
197
|
+
* @returns {'alive' | 'dead' | 'overdue'}
|
|
198
|
+
*/
|
|
70
199
|
function ownerState(jobId, owner) {
|
|
71
200
|
if (!owner) return 'dead';
|
|
72
201
|
let pidAlive;
|
|
73
202
|
if (owner.pid === process.pid) pidAlive = LIVE_HERE.has(jobId);
|
|
74
|
-
else
|
|
203
|
+
else
|
|
204
|
+
try {
|
|
205
|
+
process.kill(owner.pid, 0);
|
|
206
|
+
pidAlive = true;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
pidAlive = err instanceof Error && 'code' in err && err.code === 'EPERM';
|
|
209
|
+
}
|
|
75
210
|
if (!pidAlive) return 'dead';
|
|
76
211
|
return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
|
|
77
212
|
}
|
|
78
213
|
|
|
79
214
|
export class JobManager {
|
|
80
215
|
constructor() {
|
|
216
|
+
/** @type {NodeJS.Timeout | null} */
|
|
217
|
+
this._cancelWatch = null;
|
|
218
|
+
/** @type {Map<string, Job>} */
|
|
81
219
|
this.jobs = new Map();
|
|
220
|
+
/** @type {Map<string, Group>} */
|
|
82
221
|
this.groups = new Map();
|
|
222
|
+
/** @type {string[]} */
|
|
83
223
|
this.order = [];
|
|
84
224
|
store.ensureDirs();
|
|
85
225
|
}
|
|
@@ -87,6 +227,10 @@ export class JobManager {
|
|
|
87
227
|
// A consultation this process does not hold: finished (its round file), or
|
|
88
228
|
// still in progress (its marker). A marker whose owner is gone becomes a
|
|
89
229
|
// failed round, `interrupted`, written once by whichever process claims it.
|
|
230
|
+
/**
|
|
231
|
+
* @param {string} jobId
|
|
232
|
+
* @returns {StoredRound | null}
|
|
233
|
+
*/
|
|
90
234
|
#fromDisk(jobId) {
|
|
91
235
|
const done = store.loadRound(jobId);
|
|
92
236
|
if (done) return done;
|
|
@@ -101,9 +245,18 @@ export class JobManager {
|
|
|
101
245
|
// something the next reader finishes: the round is written (the index
|
|
102
246
|
// takes it once), then the marker goes. A round already on disk wins -- its
|
|
103
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
|
+
*/
|
|
104
254
|
#interrupt(jobId, rec, state) {
|
|
105
255
|
const done = store.loadRound(jobId);
|
|
106
|
-
if (done) {
|
|
256
|
+
if (done) {
|
|
257
|
+
store.removeRunning(jobId);
|
|
258
|
+
return done;
|
|
259
|
+
}
|
|
107
260
|
const { owner, ...round } = rec;
|
|
108
261
|
round.status = 'failed';
|
|
109
262
|
round.finished_at = new Date().toISOString();
|
|
@@ -114,7 +267,11 @@ export class JobManager {
|
|
|
114
267
|
retriable: true,
|
|
115
268
|
delivered_advice: false,
|
|
116
269
|
};
|
|
117
|
-
try {
|
|
270
|
+
try {
|
|
271
|
+
store.persistRound(round);
|
|
272
|
+
} catch {
|
|
273
|
+
return round;
|
|
274
|
+
}
|
|
118
275
|
store.removeRunning(jobId);
|
|
119
276
|
// An overdue owner may still be running its consultant: leave its working
|
|
120
277
|
// directory to it. ponytail: if the pid was reused instead, that
|
|
@@ -128,22 +285,26 @@ export class JobManager {
|
|
|
128
285
|
for (const jobId of store.listRunning()) {
|
|
129
286
|
const rec = store.loadRunning(jobId);
|
|
130
287
|
const state = rec && ownerState(jobId, rec.owner);
|
|
131
|
-
if (rec && state !== 'alive') this.#interrupt(jobId, rec, state);
|
|
288
|
+
if (rec && state && state !== 'alive') this.#interrupt(jobId, rec, state);
|
|
132
289
|
}
|
|
133
290
|
}
|
|
134
291
|
|
|
292
|
+
/**
|
|
293
|
+
* @param {StoredRound} rec
|
|
294
|
+
* @returns {FullView}
|
|
295
|
+
*/
|
|
135
296
|
#diskView(rec) {
|
|
136
|
-
const elsewhere = Boolean(rec.owner);
|
|
137
297
|
const { owner, ...view } = rec;
|
|
138
298
|
return {
|
|
139
299
|
...view,
|
|
140
|
-
|
|
300
|
+
// consult_cancel asks the process that owns it to stop it.
|
|
301
|
+
cancellable: Boolean(owner) && (view.status === 'running' || view.status === 'queued'),
|
|
141
302
|
progress: null,
|
|
142
303
|
record: view.record ? recordSummary(view) : null,
|
|
143
|
-
brief:
|
|
144
|
-
limits:
|
|
145
|
-
next_step:
|
|
146
|
-
? `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`
|
|
147
308
|
: nextStep(view),
|
|
148
309
|
};
|
|
149
310
|
}
|
|
@@ -152,6 +313,10 @@ export class JobManager {
|
|
|
152
313
|
return [...this.jobs.values()].filter((j) => j.status === 'running');
|
|
153
314
|
}
|
|
154
315
|
|
|
316
|
+
/**
|
|
317
|
+
* @param {Job} job
|
|
318
|
+
* @returns {Round}
|
|
319
|
+
*/
|
|
155
320
|
#record(job) {
|
|
156
321
|
return {
|
|
157
322
|
job_id: job.job_id,
|
|
@@ -195,20 +360,36 @@ export class JobManager {
|
|
|
195
360
|
|
|
196
361
|
// `detail: "full"` returns the brief as stored; by default a finished
|
|
197
362
|
// consultation names it (size, sha256, round file) instead of repeating it.
|
|
363
|
+
/**
|
|
364
|
+
* @param {string} jobId
|
|
365
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
366
|
+
* @returns {JobView | null}
|
|
367
|
+
*/
|
|
198
368
|
view(jobId, { detail } = {}) {
|
|
199
369
|
const job = this.jobs.get(jobId);
|
|
200
370
|
if (!job) {
|
|
201
371
|
const rec = this.#fromDisk(jobId);
|
|
202
372
|
return rec ? compactBrief(this.#diskView(rec), detail) : null;
|
|
203
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} */
|
|
204
384
|
const rec = this.#record(job);
|
|
205
385
|
rec.cancellable = job.status === 'running' || job.status === 'queued';
|
|
206
386
|
// While it runs, say what it is doing. "running" for twenty minutes tells
|
|
207
387
|
// the lead nothing; a consultation visibly grinding through tool calls is
|
|
208
388
|
// one it can cancel now rather than wait out.
|
|
209
|
-
rec.progress =
|
|
210
|
-
|
|
211
|
-
|
|
389
|
+
rec.progress =
|
|
390
|
+
job.status === 'running' && job._handle
|
|
391
|
+
? (ADAPTERS[job.cli]?.progress?.({ stdout: job._handle.stdoutSoFar() }) ?? null)
|
|
392
|
+
: null;
|
|
212
393
|
rec.record = job.record ? recordSummary(job) : null;
|
|
213
394
|
rec.prediction = job.prediction ?? null;
|
|
214
395
|
rec.reflection = job.reflection ?? null;
|
|
@@ -222,7 +403,8 @@ export class JobManager {
|
|
|
222
403
|
const live = job.status === 'running' || job.status === 'queued' || job.status === 'cancelling';
|
|
223
404
|
if (live) {
|
|
224
405
|
rec.brief = null;
|
|
225
|
-
rec.brief_note =
|
|
406
|
+
rec.brief_note =
|
|
407
|
+
'omitted while the consultation is running; returned once it finishes, and kept in ~/.severally/history either way';
|
|
226
408
|
}
|
|
227
409
|
rec.limits = live ? null : limitsSummary();
|
|
228
410
|
rec.next_step = nextStep(job);
|
|
@@ -233,10 +415,18 @@ export class JobManager {
|
|
|
233
415
|
// job_id and group_id can still be followed up and recorded against. The
|
|
234
416
|
// index only picks and orders; each row shown is read from its round, which
|
|
235
417
|
// holds the verdicts written since. Memory wins over disk for the same job.
|
|
418
|
+
/** @param {{limit?: number}} [opts] */
|
|
236
419
|
list({ limit = 20 } = {}) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
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
|
+
}
|
|
240
430
|
// Read only: a marker whose owner is gone is shown as interrupted, and
|
|
241
431
|
// left for consult_get or the startup sweep to close out.
|
|
242
432
|
for (const jobId of store.listRunning()) {
|
|
@@ -246,41 +436,49 @@ export class JobManager {
|
|
|
246
436
|
const state = ownerState(jobId, live.owner);
|
|
247
437
|
// A round saved just before its owner died is the answer, whatever the marker says.
|
|
248
438
|
const saved = state === 'alive' ? null : store.loadRoundAt(live);
|
|
249
|
-
|
|
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 });
|
|
250
445
|
}
|
|
251
|
-
for (const row of store.indexRows())
|
|
252
|
-
|
|
446
|
+
for (const row of store.indexRows())
|
|
447
|
+
if (!seen.has(row.job_id)) {
|
|
448
|
+
seen.add(row.job_id);
|
|
449
|
+
candidates.push({ at: row.created_at ?? '', load: () => store.loadRoundAt(row) });
|
|
450
|
+
}
|
|
451
|
+
/** @type {Round[]} */
|
|
253
452
|
const picked = [];
|
|
254
|
-
//
|
|
255
|
-
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))) {
|
|
256
455
|
if (picked.length >= limit) break;
|
|
257
|
-
const j = x.
|
|
456
|
+
const j = x.load();
|
|
258
457
|
if (j) picked.push(j);
|
|
259
458
|
}
|
|
260
|
-
return picked
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
});
|
|
459
|
+
return picked.map((j) => {
|
|
460
|
+
return {
|
|
461
|
+
job_id: j.job_id,
|
|
462
|
+
group_id: j.group_id ?? null,
|
|
463
|
+
chain_id: j.chain_id,
|
|
464
|
+
round: j.round,
|
|
465
|
+
target: j.target,
|
|
466
|
+
cli: j.cli,
|
|
467
|
+
model: j.model,
|
|
468
|
+
mode: j.mode,
|
|
469
|
+
status: j.status,
|
|
470
|
+
failure_kind: j.failure?.kind ?? null,
|
|
471
|
+
created_at: j.created_at,
|
|
472
|
+
duration_ms: j.duration_ms,
|
|
473
|
+
summary: j.result ? j.result.summary.slice(0, 200) : null,
|
|
474
|
+
recorded: Boolean(j.record),
|
|
475
|
+
verdicts: j.record?.entries ? tally(j.record.entries.map((e) => e.verdict)) : null,
|
|
476
|
+
this_session: this.jobs.has(j.job_id),
|
|
477
|
+
predicted: Boolean(j.prediction),
|
|
478
|
+
reflected: Boolean(j.reflection),
|
|
479
|
+
initiator: j.initiator ?? null,
|
|
480
|
+
};
|
|
481
|
+
});
|
|
284
482
|
}
|
|
285
483
|
|
|
286
484
|
/**
|
|
@@ -290,25 +488,34 @@ export class JobManager {
|
|
|
290
488
|
* but named under `not_reported`. Nothing is estimated.
|
|
291
489
|
*/
|
|
292
490
|
usageTotals() {
|
|
491
|
+
/** @type {Record<string, Record<string, number> & {consultations: number, attempts: number}>} */
|
|
293
492
|
const by = {};
|
|
493
|
+
/** @type {Record<string, Set<string>>} */
|
|
294
494
|
const reported = {};
|
|
295
495
|
for (const j of this.jobs.values()) {
|
|
296
|
-
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);
|
|
297
497
|
if (!attempts.length) continue;
|
|
298
498
|
const t = (by[j.target] ??= { consultations: 0, attempts: 0 });
|
|
299
499
|
const seen = (reported[j.target] ??= new Set());
|
|
300
500
|
t.consultations += 1;
|
|
301
501
|
t.attempts += attempts.length;
|
|
302
502
|
for (const u of attempts) {
|
|
303
|
-
for (const [k, v] of Object.entries(u))
|
|
503
|
+
for (const [k, v] of Object.entries(u))
|
|
504
|
+
if (typeof v === 'number') {
|
|
505
|
+
t[k] = (t[k] ?? 0) + v;
|
|
506
|
+
seen.add(k);
|
|
507
|
+
}
|
|
304
508
|
}
|
|
305
509
|
}
|
|
510
|
+
/** @type {Record<string, Record<string, number | string[]>>} */
|
|
511
|
+
const out = {};
|
|
306
512
|
for (const [target, t] of Object.entries(by)) {
|
|
307
|
-
t
|
|
513
|
+
out[target] = { ...t, not_reported: USAGE_FIGURES.filter((k) => !reported[target]?.has(k)) };
|
|
308
514
|
}
|
|
309
|
-
return
|
|
515
|
+
return out;
|
|
310
516
|
}
|
|
311
517
|
|
|
518
|
+
/** @param {RawRequest} rawRequest the tool arguments, not yet validated */
|
|
312
519
|
start(rawRequest) {
|
|
313
520
|
if (process.env.SEVERALLY_ACTIVE === '1') {
|
|
314
521
|
throw new RequestError(
|
|
@@ -318,13 +525,15 @@ export class JobManager {
|
|
|
318
525
|
}
|
|
319
526
|
// A config the operator got wrong is not run on defaults: that could
|
|
320
527
|
// re-enable a CLI they turned off. Reading the history still works.
|
|
321
|
-
if (configProblem())
|
|
528
|
+
if (configProblem())
|
|
529
|
+
throw new RequestError(`the severally config file needs fixing first: ${configProblem()}`, 'config_invalid');
|
|
322
530
|
|
|
323
531
|
// Resolve the chain before validating, so follow-up rules can be applied.
|
|
324
|
-
const followupTo =
|
|
325
|
-
? rawRequest.followup_to.trim()
|
|
326
|
-
|
|
532
|
+
const followupTo =
|
|
533
|
+
typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
|
|
534
|
+
/** @type {(Round & {nextRound: number}) | null} */
|
|
327
535
|
let parent = null;
|
|
536
|
+
/** @type {GroupParents | null} */
|
|
328
537
|
let fromGroup = null;
|
|
329
538
|
if (followupTo?.startsWith('group_')) {
|
|
330
539
|
// A follow-up to a fan-out goes to every member that can take one; the
|
|
@@ -340,11 +549,11 @@ export class JobManager {
|
|
|
340
549
|
} else if (followupTo) {
|
|
341
550
|
// A job from an earlier server session is read back from the history,
|
|
342
551
|
// so a restart does not end a consultation the lead is still checking.
|
|
343
|
-
|
|
344
|
-
if (!
|
|
345
|
-
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') {
|
|
346
555
|
throw new RequestError(
|
|
347
|
-
`followup_to job "${followupTo}" is ${
|
|
556
|
+
`followup_to job "${followupTo}" is ${found.status}; only a completed consultation can be followed up`,
|
|
348
557
|
'followup_not_completed',
|
|
349
558
|
);
|
|
350
559
|
}
|
|
@@ -352,22 +561,24 @@ export class JobManager {
|
|
|
352
561
|
// round goes after every round already there: numbering it parent + 1
|
|
353
562
|
// would overwrite a later round's history file. A later round that
|
|
354
563
|
// failed or was cancelled does not block a retry from the parent.
|
|
355
|
-
const rounds = this.#chainRounds(
|
|
356
|
-
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));
|
|
357
566
|
if (later) {
|
|
358
567
|
throw new RequestError(
|
|
359
|
-
`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`,
|
|
360
569
|
'followup_not_latest',
|
|
361
570
|
);
|
|
362
571
|
}
|
|
363
|
-
parent = { ...
|
|
572
|
+
parent = { ...found, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
|
|
364
573
|
}
|
|
365
574
|
|
|
366
575
|
if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
|
|
367
576
|
rawRequest = { ...rawRequest, target: parent.target };
|
|
368
577
|
}
|
|
369
578
|
// A fan-out's members were all handed the same brief, so any one of them holds it.
|
|
370
|
-
|
|
579
|
+
const first = fromGroup?.links[0];
|
|
580
|
+
const inheritFrom = first ? (first.groupMember ?? first.parent) : parent;
|
|
581
|
+
if (inheritFrom) rawRequest = inherit(rawRequest, inheritFrom);
|
|
371
582
|
|
|
372
583
|
const req = parseRequest(rawRequest, {
|
|
373
584
|
isFollowup: Boolean(parent || fromGroup),
|
|
@@ -403,37 +614,43 @@ export class JobManager {
|
|
|
403
614
|
// A follow-up's seat also names the chain and round it opens, so that two
|
|
404
615
|
// servers continuing one consultation at once cannot both take a round
|
|
405
616
|
// number: neither sees the other's round in its history until it ends.
|
|
406
|
-
const chainOf = (i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
|
|
407
|
-
const seats = req.targets.map((target, i) =>
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
});
|
|
417
633
|
const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
|
|
418
634
|
if (taken.chainBusy) {
|
|
419
635
|
const { live, mine } = taken.chainBusy;
|
|
420
636
|
throw new RequestError(
|
|
421
|
-
`another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}`
|
|
422
|
-
|
|
637
|
+
`another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}` +
|
|
638
|
+
'; wait for it (consult_get) and follow up the latest round',
|
|
423
639
|
'followup_not_latest',
|
|
424
640
|
);
|
|
425
641
|
}
|
|
426
642
|
if (!taken.ok) {
|
|
427
643
|
throw new RequestError(
|
|
428
|
-
`this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}`
|
|
429
|
-
|
|
430
|
-
|
|
644
|
+
`this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}` +
|
|
645
|
+
(taken.elsewhere ? `, and ${taken.elsewhere} of the ${taken.live} running are in other severally processes` : '') +
|
|
646
|
+
'. Wait for one to finish (consult_get) or cancel it',
|
|
431
647
|
'concurrency_limit',
|
|
432
648
|
);
|
|
433
649
|
}
|
|
434
650
|
// A request refused from here on gives its seats back.
|
|
435
651
|
try {
|
|
436
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}>} */
|
|
437
654
|
const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
|
|
438
655
|
return this.#admitted(req, links, seats, fromGroup);
|
|
439
656
|
} catch (err) {
|
|
@@ -446,29 +663,50 @@ export class JobManager {
|
|
|
446
663
|
// round of its own chain. A member that is still running, never answered or
|
|
447
664
|
// has used its rounds is skipped and named; if that is every member, the
|
|
448
665
|
// request is refused.
|
|
666
|
+
/**
|
|
667
|
+
* @param {string} groupId
|
|
668
|
+
* @returns {GroupParents}
|
|
669
|
+
*/
|
|
449
670
|
#groupParents(groupId) {
|
|
450
671
|
const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
|
|
451
672
|
if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, 'unknown_group');
|
|
673
|
+
/** @type {GroupLink[]} */
|
|
452
674
|
const links = [];
|
|
675
|
+
/** @type {GroupParents['skipped']} */
|
|
453
676
|
const skipped = [];
|
|
454
677
|
for (const jid of group.job_ids) {
|
|
455
678
|
const member = this.jobs.get(jid) ?? store.loadRound(jid);
|
|
456
679
|
if (!member) {
|
|
457
|
-
skipped.push({
|
|
680
|
+
skipped.push({
|
|
681
|
+
job_id: jid,
|
|
682
|
+
target: null,
|
|
683
|
+
reason: 'not found: aged out of the history, or still running in another severally process',
|
|
684
|
+
});
|
|
458
685
|
continue;
|
|
459
686
|
}
|
|
460
687
|
const rounds = this.#chainRounds(member.chain_id);
|
|
461
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
|
+
}
|
|
462
697
|
const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
|
|
463
698
|
// The name is kept, not re-resolved: a consultant renamed or turned off
|
|
464
699
|
// since is skipped, never continued by whichever one answers to it now.
|
|
465
700
|
const consultant = consultantByName(member.target);
|
|
466
|
-
const reason =
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
701
|
+
const reason =
|
|
702
|
+
latest.status !== 'completed'
|
|
703
|
+
? `round ${latest.round} is still ${latest.status}`
|
|
704
|
+
: nextRound > POLICY.maxRounds
|
|
705
|
+
? `has used its ${POLICY.maxRounds} rounds`
|
|
706
|
+
: !consultant || !consultantAvailable(consultant)
|
|
707
|
+
? 'this consultant is no longer configured or available here'
|
|
470
708
|
: null;
|
|
471
|
-
if (reason) skipped.push({ job_id:
|
|
709
|
+
if (reason) skipped.push({ job_id: latest.job_id, target: member.target, reason });
|
|
472
710
|
else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
|
|
473
711
|
}
|
|
474
712
|
if (!links.length) {
|
|
@@ -480,21 +718,30 @@ export class JobManager {
|
|
|
480
718
|
return { group, links, skipped };
|
|
481
719
|
}
|
|
482
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
|
+
*/
|
|
483
727
|
#admitted(req, links, seats, fromGroup = null) {
|
|
484
728
|
// What expose_from_parent copies again: for a group, what that fan-out
|
|
485
729
|
// exposed (one manifest, shared by its members); otherwise the parent's.
|
|
486
|
-
const
|
|
730
|
+
const first = links[0];
|
|
731
|
+
const parent = first?.groupMember ?? first?.parent;
|
|
487
732
|
const groupId = id('group');
|
|
488
733
|
// Copied once for the whole request, before a round is counted or a job
|
|
489
734
|
// exists, into the group's own directory: every member is then handed the
|
|
490
735
|
// same bytes (the manifest carries their sha256), and a path that is
|
|
491
736
|
// missing or a tree that blows the cap is the lead's to fix now rather
|
|
492
737
|
// than a job that fails having already spent a round of this chain.
|
|
738
|
+
/** @type {ExposeManifest | null} */
|
|
493
739
|
let exposeManifest = null;
|
|
494
|
-
|
|
740
|
+
/** @type {BriefExposeEntry[]} */
|
|
741
|
+
const parentEntries = req.context.expose_from_parent ? (parent?.brief?.expose_paths?.entries ?? []) : [];
|
|
495
742
|
if (req.context.expose_from_parent && parentEntries.length === 0) {
|
|
496
743
|
throw new RequestError(
|
|
497
|
-
`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`,
|
|
498
745
|
'nothing_to_inherit',
|
|
499
746
|
);
|
|
500
747
|
}
|
|
@@ -503,8 +750,11 @@ export class JobManager {
|
|
|
503
750
|
try {
|
|
504
751
|
fs.mkdirSync(path.join(snapshot, 'workspace'), { recursive: true, mode: 0o700 });
|
|
505
752
|
const plan = planInheritance(parentEntries, req.context.expose_paths);
|
|
506
|
-
exposeManifest = materializeExposedPaths(snapshot, plan.paths)
|
|
507
|
-
|
|
753
|
+
exposeManifest = materializeExposedPaths(snapshot, plan.paths) ?? {
|
|
754
|
+
entries: [],
|
|
755
|
+
totals: { files: 0, bytes: 0 },
|
|
756
|
+
skipped: [],
|
|
757
|
+
};
|
|
508
758
|
exposeManifest.skipped.push(...plan.skipped);
|
|
509
759
|
const parentSources = new Set(parentEntries.map((e) => e.source));
|
|
510
760
|
for (const e of exposeManifest.entries) if (parentSources.has(e.source)) e.inherited = true;
|
|
@@ -515,37 +765,53 @@ export class JobManager {
|
|
|
515
765
|
if (req.context.expose_diff) {
|
|
516
766
|
exposeManifest.skipped.push({ source: d.source, reason: 'replaced', exposed_as: d.exposed_as });
|
|
517
767
|
} else {
|
|
518
|
-
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
|
+
}
|
|
519
772
|
}
|
|
520
773
|
}
|
|
521
774
|
if (req.context.expose_diff) {
|
|
522
775
|
const room = Math.min(POLICY.input.artifactExcerptMax, POLICY.input.totalCharsMax - charCount(req));
|
|
523
776
|
const diff = exposeDiff(snapshot, req.context.expose_diff, exposeManifest, room);
|
|
524
777
|
if (diff) {
|
|
525
|
-
req.context.artifacts = [
|
|
526
|
-
|
|
778
|
+
req.context.artifacts = [
|
|
779
|
+
...req.context.artifacts,
|
|
780
|
+
{ name: `git diff ${exposeManifest.entries.at(-1)?.base}`, kind: 'diff', language: 'diff', excerpt: diff },
|
|
781
|
+
];
|
|
527
782
|
}
|
|
528
783
|
}
|
|
529
784
|
markSinceParent(exposeManifest, parentEntries);
|
|
530
785
|
// Every earlier path gone, and nothing else to go on: refused, as a
|
|
531
786
|
// first round with no context is.
|
|
532
|
-
if (
|
|
787
|
+
if (
|
|
788
|
+
parentEntries.length &&
|
|
789
|
+
!exposeManifest.entries.length &&
|
|
790
|
+
!req.context.facts.length &&
|
|
791
|
+
!req.context.artifacts.length
|
|
792
|
+
) {
|
|
533
793
|
throw new RequestError(
|
|
534
|
-
`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`,
|
|
535
795
|
'nothing_to_inherit',
|
|
536
796
|
);
|
|
537
797
|
}
|
|
538
798
|
} catch (err) {
|
|
539
799
|
store.cleanupJobDir(groupId);
|
|
540
|
-
if (err instanceof RequestError || !err.code) throw err;
|
|
800
|
+
if (err instanceof RequestError || !(err instanceof Error) || !('code' in err) || !err.code) throw err;
|
|
541
801
|
throw new RequestError(err.message, err.code === 'too_large' ? 'expose_paths_too_large' : 'expose_path_invalid');
|
|
542
802
|
}
|
|
543
803
|
req._snapshot = path.join(snapshot, 'workspace');
|
|
544
804
|
}
|
|
545
|
-
const members = req.targets.map((target, i) => {
|
|
546
|
-
|
|
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;
|
|
547
811
|
const chainId = from ? from.chain_id : id('chain');
|
|
548
|
-
const jobId =
|
|
812
|
+
const jobId = seat.job_id;
|
|
813
|
+
const consultant = consultantNamed(target);
|
|
814
|
+
/** @type {Job} */
|
|
549
815
|
const job = {
|
|
550
816
|
job_id: jobId,
|
|
551
817
|
chain_id: chainId,
|
|
@@ -582,9 +848,9 @@ export class JobManager {
|
|
|
582
848
|
status: 'queued',
|
|
583
849
|
// What this consultant is: the CLI it runs on, the model and its
|
|
584
850
|
// lineage, as the operator defined it.
|
|
585
|
-
cli:
|
|
586
|
-
model:
|
|
587
|
-
vendor:
|
|
851
|
+
cli: consultant.cli,
|
|
852
|
+
model: consultant.model,
|
|
853
|
+
vendor: consultant.vendor,
|
|
588
854
|
created_at: new Date().toISOString(),
|
|
589
855
|
started_at: null,
|
|
590
856
|
finished_at: null,
|
|
@@ -601,21 +867,28 @@ export class JobManager {
|
|
|
601
867
|
this.order.push(jobId);
|
|
602
868
|
LIVE_HERE.add(jobId);
|
|
603
869
|
this.#watchCancels();
|
|
604
|
-
job._owner =
|
|
870
|
+
job._owner = seat.owner;
|
|
605
871
|
this.#mark(job);
|
|
606
872
|
return job;
|
|
607
873
|
});
|
|
608
874
|
this.#evict();
|
|
609
875
|
|
|
876
|
+
const [firstMember] = members;
|
|
877
|
+
if (!firstMember) throw new Error('a consultation needs a target');
|
|
878
|
+
/** @type {Group} */
|
|
610
879
|
const group = {
|
|
611
880
|
group_id: groupId,
|
|
612
881
|
created_at: new Date().toISOString(),
|
|
613
882
|
mode: req.mode,
|
|
614
|
-
question:
|
|
615
|
-
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),
|
|
616
885
|
};
|
|
617
886
|
this.groups.set(groupId, group);
|
|
618
|
-
try {
|
|
887
|
+
try {
|
|
888
|
+
store.persistGroup(group);
|
|
889
|
+
} catch {
|
|
890
|
+
/* history is best effort */
|
|
891
|
+
}
|
|
619
892
|
|
|
620
893
|
// A fan-out is only comparable if every consultant answered the same
|
|
621
894
|
// question in the same words: every member's brief is the same request,
|
|
@@ -634,28 +907,31 @@ export class JobManager {
|
|
|
634
907
|
if (req._snapshot) store.cleanupJobDir(groupId);
|
|
635
908
|
|
|
636
909
|
// The members of a follow-up to a group can be at different rounds.
|
|
637
|
-
const { round } =
|
|
638
|
-
const sameRound = members.every((m) => m.round === round);
|
|
910
|
+
const { round } = firstMember;
|
|
911
|
+
const sameRound = members.every((/** @type {Job} */ m) => m.round === round);
|
|
639
912
|
const common = {
|
|
640
913
|
group_id: groupId,
|
|
641
914
|
...(sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {}),
|
|
642
915
|
...(fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {}),
|
|
643
|
-
...(parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
|
|
916
|
+
...(parentEntries.length && exposeManifest ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
|
|
644
917
|
mode: req.mode,
|
|
645
918
|
// Counted, not refused: a brief of facts alone is a legitimate
|
|
646
919
|
// consultation, but one about code that hands over no file is one the
|
|
647
920
|
// consultant answers without having seen the repository.
|
|
648
921
|
files_exposed: exposeManifest?.totals.files ?? 0,
|
|
649
|
-
...(exposeManifest?.totals.files
|
|
650
|
-
|
|
651
|
-
|
|
922
|
+
...(exposeManifest?.totals.files
|
|
923
|
+
? {}
|
|
924
|
+
: {
|
|
925
|
+
files_note:
|
|
926
|
+
'no file was handed over, so the consultant answers from the brief alone; if this is about code in a repository, consult_cancel and start again with context.expose_paths',
|
|
927
|
+
}),
|
|
652
928
|
status: 'running',
|
|
653
929
|
accepted_at: group.created_at,
|
|
654
930
|
limits: limitsSummary(),
|
|
655
931
|
};
|
|
656
932
|
|
|
657
933
|
if (!req.fanout) {
|
|
658
|
-
const job =
|
|
934
|
+
const job = firstMember;
|
|
659
935
|
return {
|
|
660
936
|
...common,
|
|
661
937
|
job_id: job.job_id,
|
|
@@ -669,8 +945,12 @@ export class JobManager {
|
|
|
669
945
|
return {
|
|
670
946
|
...common,
|
|
671
947
|
question: group.question, // the redacted text every member was actually sent
|
|
672
|
-
jobs: members.map((m) => ({
|
|
673
|
-
job_id: m.job_id,
|
|
948
|
+
jobs: members.map((/** @type {Job} */ m) => ({
|
|
949
|
+
job_id: m.job_id,
|
|
950
|
+
chain_id: m.chain_id,
|
|
951
|
+
target: m.target,
|
|
952
|
+
cli: m.cli,
|
|
953
|
+
model: m.model,
|
|
674
954
|
...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
|
|
675
955
|
})),
|
|
676
956
|
poll_with: `consult_get({ group_id: "${groupId}" })`,
|
|
@@ -682,25 +962,42 @@ export class JobManager {
|
|
|
682
962
|
// (memory wins: it can hold a verdict written since the round was saved).
|
|
683
963
|
// Each point goes with the lead's verdict on it, so the consultant answers
|
|
684
964
|
// knowing which of its points were checked and how they came out.
|
|
965
|
+
/**
|
|
966
|
+
* @param {string} chainId
|
|
967
|
+
* @returns {Round[]}
|
|
968
|
+
*/
|
|
685
969
|
#chainRounds(chainId) {
|
|
970
|
+
/** @type {Map<number, Round>} */
|
|
686
971
|
const byRound = new Map(store.loadChain(chainId).map((j) => [j.round, j]));
|
|
687
972
|
for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
|
|
688
973
|
return [...byRound.values()];
|
|
689
974
|
}
|
|
690
975
|
|
|
976
|
+
/**
|
|
977
|
+
* @param {string} chainId
|
|
978
|
+
* @returns {PriorRound[]}
|
|
979
|
+
*/
|
|
691
980
|
#chainHistory(chainId) {
|
|
692
981
|
return this.#chainRounds(chainId)
|
|
693
|
-
.filter((j) => j.status === 'completed' && j.result)
|
|
982
|
+
.filter(/** @returns {j is Round & {result: ConsultResult}} */ (j) => j.status === 'completed' && Boolean(j.result))
|
|
694
983
|
.sort((a, b) => a.round - b.round)
|
|
695
984
|
.map((j) => {
|
|
696
985
|
const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
|
|
697
986
|
// The checked points first, then the unchecked ones, each with the
|
|
698
987
|
// lead's verdict and effect when there is one.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
988
|
+
/**
|
|
989
|
+
* @template {ResultItem} T
|
|
990
|
+
* @param {T[]} items
|
|
991
|
+
* @param {(x: T) => {point: string, grounds?: string | null}} shape
|
|
992
|
+
*/
|
|
993
|
+
const ordered = (items, shape) =>
|
|
994
|
+
[...items.filter((x) => verdicts.has(x.id)), ...items.filter((x) => !verdicts.has(x.id))].map((x) => ({
|
|
995
|
+
id: x.id,
|
|
996
|
+
...shape(x),
|
|
997
|
+
verdict: verdicts.get(x.id)?.verdict ?? null,
|
|
998
|
+
effect: verdicts.get(x.id)?.effect ?? null,
|
|
999
|
+
}));
|
|
1000
|
+
const checked = (/** @type {{verdict: unknown}} */ x) => x.verdict !== null;
|
|
704
1001
|
const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
|
|
705
1002
|
return {
|
|
706
1003
|
round: j.round,
|
|
@@ -718,6 +1015,7 @@ export class JobManager {
|
|
|
718
1015
|
#evict() {
|
|
719
1016
|
while (this.order.length > POLICY.maxJobsRetained) {
|
|
720
1017
|
const oldest = this.order.shift();
|
|
1018
|
+
if (oldest === undefined) break;
|
|
721
1019
|
const j = this.jobs.get(oldest);
|
|
722
1020
|
if (j && (j.status === 'running' || j.status === 'queued')) {
|
|
723
1021
|
this.order.push(oldest); // never evict live work
|
|
@@ -732,6 +1030,7 @@ export class JobManager {
|
|
|
732
1030
|
// last member there is nothing left to compare, so drop the group rather than
|
|
733
1031
|
// keep answering as a complete fan-out with no answers in it (and keep the
|
|
734
1032
|
// Map, which holds the question text, from growing without bound).
|
|
1033
|
+
/** @param {string} groupId */
|
|
735
1034
|
#dropGroupIfEmpty(groupId) {
|
|
736
1035
|
const group = this.groups.get(groupId);
|
|
737
1036
|
if (!group) return;
|
|
@@ -739,8 +1038,15 @@ export class JobManager {
|
|
|
739
1038
|
this.groups.delete(groupId);
|
|
740
1039
|
}
|
|
741
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
|
+
*/
|
|
742
1048
|
async #execute(job, req, chain, brief) {
|
|
743
|
-
const consultant =
|
|
1049
|
+
const consultant = consultantNamed(job.target);
|
|
744
1050
|
const cli = consultant.cli;
|
|
745
1051
|
const adapter = ADAPTERS[cli];
|
|
746
1052
|
const workdir = store.makeWorkdir(job.job_id);
|
|
@@ -752,7 +1058,7 @@ export class JobManager {
|
|
|
752
1058
|
try {
|
|
753
1059
|
fs.cpSync(req._snapshot, path.join(workdir, 'workspace'), { recursive: true });
|
|
754
1060
|
} catch (err) {
|
|
755
|
-
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}`);
|
|
756
1062
|
}
|
|
757
1063
|
}
|
|
758
1064
|
// start() renders the brief and hands it in: every member of a fan-out is
|
|
@@ -760,6 +1066,7 @@ export class JobManager {
|
|
|
760
1066
|
// added on a follow-up to the group.
|
|
761
1067
|
const text = brief ?? renderBrief(req, chain);
|
|
762
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.
|
|
763
1070
|
const sandbox = adapter.prepareSandbox ? adapter.prepareSandbox({ workdir, network: req.network }) : null;
|
|
764
1071
|
|
|
765
1072
|
try {
|
|
@@ -797,11 +1104,11 @@ export class JobManager {
|
|
|
797
1104
|
return this.#fail(
|
|
798
1105
|
job,
|
|
799
1106
|
'auth',
|
|
800
|
-
`no ${t.label} credential to hand the consultant: nothing at `
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1107
|
+
`no ${t.label} credential to hand the consultant: nothing at ` +
|
|
1108
|
+
`${sandbox.credentialsSource}, and none of ${ALL_API_CREDENTIAL_VARS.join(' / ')} names a ` +
|
|
1109
|
+
'usable credential. Log in with that CLI, point SEVERALLY_AGY_CRED_HOME at the home ' +
|
|
1110
|
+
'directory that holds the token, or set one of those variables to authenticate with an API key ' +
|
|
1111
|
+
'instead.',
|
|
805
1112
|
);
|
|
806
1113
|
}
|
|
807
1114
|
|
|
@@ -833,10 +1140,11 @@ export class JobManager {
|
|
|
833
1140
|
// What this consultant was actually launched with, read off the
|
|
834
1141
|
// invocation and the sandbox rather than restated from intent, so the
|
|
835
1142
|
// record says what held for this run.
|
|
836
|
-
job.boundary =
|
|
837
|
-
hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
1143
|
+
job.boundary =
|
|
1144
|
+
adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
1145
|
+
null;
|
|
838
1146
|
|
|
839
|
-
const attempt = (timeout) => {
|
|
1147
|
+
const attempt = (/** @type {number} */ timeout) => {
|
|
840
1148
|
const { handle, done } = runChild({
|
|
841
1149
|
command: invocation.command,
|
|
842
1150
|
args: invocation.args,
|
|
@@ -844,7 +1152,9 @@ export class JobManager {
|
|
|
844
1152
|
env,
|
|
845
1153
|
input: text,
|
|
846
1154
|
timeoutMs: timeout,
|
|
847
|
-
onCancelSignal: (fn) =>
|
|
1155
|
+
onCancelSignal: (fn) => {
|
|
1156
|
+
job._cancelFns.push(fn);
|
|
1157
|
+
},
|
|
848
1158
|
compactLine: adapter.compactLine,
|
|
849
1159
|
settleAfter: adapter.settleAfter,
|
|
850
1160
|
stopSignal: adapter.stopSignal,
|
|
@@ -862,6 +1172,7 @@ export class JobManager {
|
|
|
862
1172
|
// Same consultant, same model, same brief: a re-run never picks another
|
|
863
1173
|
// mind. Every earlier failure is kept in job.retries.
|
|
864
1174
|
let run = await attempt(budgetMs);
|
|
1175
|
+
/** @type {import('./run.mjs').Interpreted} */
|
|
865
1176
|
let interpreted;
|
|
866
1177
|
let authTried = false;
|
|
867
1178
|
let autoRetries = 0;
|
|
@@ -876,7 +1187,9 @@ export class JobManager {
|
|
|
876
1187
|
if ((run.stalled || run.timedOut || run.cancelled) && adapter.diagnostics && sandbox) {
|
|
877
1188
|
try {
|
|
878
1189
|
job.diagnostics = store.writeDiagnostics(job.chain_id, job.round, redact(adapter.diagnostics({ run, sandbox })));
|
|
879
|
-
} catch {
|
|
1190
|
+
} catch {
|
|
1191
|
+
/* history is best effort */
|
|
1192
|
+
}
|
|
880
1193
|
}
|
|
881
1194
|
if (run.spawnError) {
|
|
882
1195
|
return this.#fail(job, 'spawn_error', `could not start ${invocation.command}: ${run.spawnError}`);
|
|
@@ -903,7 +1216,7 @@ export class JobManager {
|
|
|
903
1216
|
interpreted = adapter.interpret({ ...run, lastMessageText });
|
|
904
1217
|
// An answer after an error the CLI reported is kept only if it parses;
|
|
905
1218
|
// otherwise it is that error, re-run like any other.
|
|
906
|
-
const
|
|
1219
|
+
const recoveredError = interpreted.ok ? interpreted.recoveredError : null;
|
|
907
1220
|
if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
|
|
908
1221
|
interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
|
|
909
1222
|
}
|
|
@@ -911,9 +1224,12 @@ export class JobManager {
|
|
|
911
1224
|
// No envelope at all: what stderr holds is the dump from stopping it,
|
|
912
1225
|
// not a message to classify. Not re-run, as it may have spent its quota.
|
|
913
1226
|
if (run.stalled && !interpreted.usageRaw) {
|
|
914
|
-
return this.#fail(
|
|
1227
|
+
return this.#fail(
|
|
1228
|
+
job,
|
|
1229
|
+
'cli_error',
|
|
915
1230
|
`${invocation.command} wrote its finish step but neither returned a result nor exited, and was stopped`,
|
|
916
|
-
adapter.progress?.(run) ?? null
|
|
1231
|
+
adapter.progress?.(run) ?? null,
|
|
1232
|
+
);
|
|
917
1233
|
}
|
|
918
1234
|
|
|
919
1235
|
const remaining = budgetMs - (Date.now() - t0);
|
|
@@ -921,19 +1237,35 @@ export class JobManager {
|
|
|
921
1237
|
authTried = true;
|
|
922
1238
|
const seeded = adapter.recoverAuthFailure({ interpreted, sandbox, model: job.model });
|
|
923
1239
|
if (seeded && remaining >= MIN_RETRY_MS) {
|
|
924
|
-
job.retries.push({
|
|
1240
|
+
job.retries.push({
|
|
1241
|
+
failure_kind: interpreted.failureKind,
|
|
1242
|
+
reason: 'credentials re-seeded',
|
|
1243
|
+
at: new Date().toISOString(),
|
|
1244
|
+
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1245
|
+
});
|
|
925
1246
|
run = await attempt(remaining);
|
|
926
1247
|
continue;
|
|
927
1248
|
}
|
|
928
1249
|
}
|
|
929
1250
|
const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
|
|
930
|
-
if (
|
|
931
|
-
|
|
1251
|
+
if (
|
|
1252
|
+
AUTO_RETRY_KINDS.includes(interpreted.failureKind) &&
|
|
1253
|
+
autoRetries < POLICY.maxAutoRetries &&
|
|
1254
|
+
remaining - wait >= MIN_RETRY_MS
|
|
1255
|
+
) {
|
|
932
1256
|
autoRetries += 1;
|
|
933
|
-
job.retries.push({
|
|
934
|
-
|
|
1257
|
+
job.retries.push({
|
|
1258
|
+
failure_kind: interpreted.failureKind,
|
|
1259
|
+
reason: `transient; re-run after ${wait} ms`,
|
|
1260
|
+
at: new Date().toISOString(),
|
|
1261
|
+
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1262
|
+
});
|
|
1263
|
+
await new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
|
|
935
1264
|
const timer = setTimeout(resolve, wait);
|
|
936
|
-
job._cancelFns.push(() => {
|
|
1265
|
+
job._cancelFns.push(() => {
|
|
1266
|
+
clearTimeout(timer);
|
|
1267
|
+
resolve();
|
|
1268
|
+
});
|
|
937
1269
|
});
|
|
938
1270
|
if (job.cancelRequested) return this.#fail(job, 'cancelled', 'consultation cancelled by the lead');
|
|
939
1271
|
run = await attempt(budgetMs - (Date.now() - t0));
|
|
@@ -943,8 +1275,8 @@ export class JobManager {
|
|
|
943
1275
|
}
|
|
944
1276
|
|
|
945
1277
|
// Without an answer, a process left holding the pipes is the failure to report.
|
|
946
|
-
const leftover =
|
|
947
|
-
&& `${invocation.command} exited, but a process it started kept its output open and was stopped`;
|
|
1278
|
+
const leftover =
|
|
1279
|
+
run.pipesForced && `${invocation.command} exited, but a process it started kept its output open and was stopped`;
|
|
948
1280
|
if (!interpreted.ok) {
|
|
949
1281
|
job.usage = adapter.usageRecord(interpreted.usageRaw);
|
|
950
1282
|
if (leftover) return this.#fail(job, 'cli_error', leftover, interpreted.message);
|
|
@@ -983,8 +1315,12 @@ export class JobManager {
|
|
|
983
1315
|
const notes = [
|
|
984
1316
|
adviceCaveat(normalized),
|
|
985
1317
|
sameVendorCaveat(job.caller, job.caller_model, consultant),
|
|
986
|
-
interpreted.recoveredError &&
|
|
987
|
-
|
|
1318
|
+
interpreted.recoveredError &&
|
|
1319
|
+
`the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
|
|
1320
|
+
leftover &&
|
|
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),
|
|
988
1324
|
].filter(Boolean);
|
|
989
1325
|
// No `advice_usable` verdict here. It was always true, so it carried no
|
|
990
1326
|
// information -- and worse, it read as the server vouching for advice it
|
|
@@ -1007,6 +1343,13 @@ export class JobManager {
|
|
|
1007
1343
|
}
|
|
1008
1344
|
}
|
|
1009
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* @param {Job} job
|
|
1348
|
+
* @param {string} kind
|
|
1349
|
+
* @param {unknown} message
|
|
1350
|
+
* @param {string | null} [detail]
|
|
1351
|
+
* @returns {Job}
|
|
1352
|
+
*/
|
|
1010
1353
|
#fail(job, kind, message, detail) {
|
|
1011
1354
|
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') return job;
|
|
1012
1355
|
job.status = kind === 'cancelled' ? 'cancelled' : 'failed';
|
|
@@ -1023,12 +1366,22 @@ export class JobManager {
|
|
|
1023
1366
|
}
|
|
1024
1367
|
|
|
1025
1368
|
// The in-progress marker other processes read (see store.writeRunning).
|
|
1369
|
+
/** @param {Job} job */
|
|
1026
1370
|
#mark(job) {
|
|
1027
|
-
try {
|
|
1371
|
+
try {
|
|
1372
|
+
store.writeRunning({ ...this.#record(job), owner: job._owner });
|
|
1373
|
+
} catch {
|
|
1374
|
+
/* history is best effort */
|
|
1375
|
+
}
|
|
1028
1376
|
}
|
|
1029
1377
|
|
|
1378
|
+
/** @param {Job} job */
|
|
1030
1379
|
#finish(job) {
|
|
1031
|
-
try {
|
|
1380
|
+
try {
|
|
1381
|
+
store.persistRound(this.#record(job));
|
|
1382
|
+
} catch {
|
|
1383
|
+
/* history is best effort */
|
|
1384
|
+
}
|
|
1032
1385
|
store.removeRunning(job.job_id);
|
|
1033
1386
|
store.releaseSeat(job.job_id);
|
|
1034
1387
|
LIVE_HERE.delete(job.job_id);
|
|
@@ -1043,16 +1396,17 @@ export class JobManager {
|
|
|
1043
1396
|
// consultant did not produce and a word outside VERDICTS, then stores what
|
|
1044
1397
|
// was written. It never decides that a finding was adopted, and it never
|
|
1045
1398
|
// fills in a verdict the lead did not write.
|
|
1399
|
+
/** @param {RawRecord} [input] */
|
|
1046
1400
|
record(input = {}) {
|
|
1047
1401
|
if (input.records !== undefined) return this.#recordMany(input);
|
|
1048
1402
|
const allowed = ['job_id', 'entries', 'reflection'];
|
|
1049
1403
|
const unknown = Object.keys(input).filter((k) => !allowed.includes(k));
|
|
1050
1404
|
if (unknown.length) {
|
|
1051
1405
|
throw new RequestError(
|
|
1052
|
-
`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records`
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1406
|
+
`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records` +
|
|
1407
|
+
(unknown.includes('prediction')
|
|
1408
|
+
? ". A prediction is written when the consultation starts (consult_start's `prediction`) and cannot be added or changed afterwards -- that is what makes it a prediction"
|
|
1409
|
+
: ''),
|
|
1056
1410
|
'unknown_field',
|
|
1057
1411
|
);
|
|
1058
1412
|
}
|
|
@@ -1066,24 +1420,32 @@ export class JobManager {
|
|
|
1066
1420
|
// record is checked before anything is written, so a bad id leaves the
|
|
1067
1421
|
// history as it was; then each job is written on its own, and a write that
|
|
1068
1422
|
// fails is named in the answer rather than reported as saved.
|
|
1423
|
+
/** @param {RawRecord} input */
|
|
1069
1424
|
#recordMany(input) {
|
|
1070
1425
|
const extra = Object.keys(input).filter((k) => k !== 'records');
|
|
1071
1426
|
if (extra.length) {
|
|
1072
|
-
throw new RequestError(
|
|
1427
|
+
throw new RequestError(
|
|
1428
|
+
`records cannot be combined with ${extra.map((k) => `"${k}"`).join(', ')}; put each job's verdicts inside records`,
|
|
1429
|
+
'unknown_field',
|
|
1430
|
+
);
|
|
1073
1431
|
}
|
|
1074
1432
|
const { records } = input;
|
|
1075
1433
|
if (!Array.isArray(records) || records.length === 0) {
|
|
1076
1434
|
throw new RequestError('records must be a non-empty array of { job_id, entries?, reflection? }', 'records_required');
|
|
1077
1435
|
}
|
|
1436
|
+
/** @type {Array<{index: number, job_id: string | null, error: string, message: string}>} */
|
|
1078
1437
|
const problems = [];
|
|
1438
|
+
/** @type {RecordChange[]} */
|
|
1079
1439
|
const changes = [];
|
|
1080
1440
|
const seen = new Set();
|
|
1081
|
-
records.forEach((r, i) => {
|
|
1441
|
+
records.forEach((/** @type {RawRecord} */ r, i) => {
|
|
1082
1442
|
try {
|
|
1083
|
-
if (seen.has(r?.job_id))
|
|
1443
|
+
if (seen.has(r?.job_id))
|
|
1444
|
+
throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
|
|
1084
1445
|
seen.add(r?.job_id);
|
|
1085
1446
|
const unknown = Object.keys(r ?? {}).filter((k) => !['job_id', 'entries', 'reflection'].includes(k));
|
|
1086
|
-
if (unknown.length)
|
|
1447
|
+
if (unknown.length)
|
|
1448
|
+
throw new RequestError(`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here`, 'unknown_field');
|
|
1087
1449
|
changes.push(this.#checkRecord(r ?? {}));
|
|
1088
1450
|
} catch (err) {
|
|
1089
1451
|
if (!(err instanceof RequestError)) throw err;
|
|
@@ -1104,9 +1466,16 @@ export class JobManager {
|
|
|
1104
1466
|
|
|
1105
1467
|
// Everything that can be wrong with a record, found before anything is
|
|
1106
1468
|
// written: what the job's record and reflection become, or a RequestError.
|
|
1469
|
+
/**
|
|
1470
|
+
* @param {RawRecord} rec
|
|
1471
|
+
* @returns {RecordChange}
|
|
1472
|
+
*/
|
|
1107
1473
|
#checkRecord({ job_id: jobId, entries, reflection }) {
|
|
1108
1474
|
if (entries === undefined && reflection === undefined) {
|
|
1109
|
-
throw new RequestError(
|
|
1475
|
+
throw new RequestError(
|
|
1476
|
+
'pass entries (verdicts on specific points), reflection (what the answer added), or both',
|
|
1477
|
+
'nothing_to_record',
|
|
1478
|
+
);
|
|
1110
1479
|
}
|
|
1111
1480
|
// A job this process ran is in memory; one from an earlier session is read
|
|
1112
1481
|
// back from its round file, so a verdict can still be written once the
|
|
@@ -1114,10 +1483,7 @@ export class JobManager {
|
|
|
1114
1483
|
const live = this.jobs.get(jobId);
|
|
1115
1484
|
const job = live ?? store.loadRound(jobId);
|
|
1116
1485
|
if (!job) {
|
|
1117
|
-
throw new RequestError(
|
|
1118
|
-
`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`,
|
|
1119
|
-
'unknown_job',
|
|
1120
|
-
);
|
|
1486
|
+
throw new RequestError(`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`, 'unknown_job');
|
|
1121
1487
|
}
|
|
1122
1488
|
if (!job.result) {
|
|
1123
1489
|
throw new RequestError(
|
|
@@ -1131,7 +1497,8 @@ export class JobManager {
|
|
|
1131
1497
|
|
|
1132
1498
|
const ids = recordableIds(job.result);
|
|
1133
1499
|
const now = new Date().toISOString();
|
|
1134
|
-
|
|
1500
|
+
/** @type {Map<string, RecordEntry>} */
|
|
1501
|
+
const written = new Map();
|
|
1135
1502
|
|
|
1136
1503
|
for (const entry of entries ?? []) {
|
|
1137
1504
|
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
|
|
@@ -1148,7 +1515,7 @@ export class JobManager {
|
|
|
1148
1515
|
'invalid_verdict',
|
|
1149
1516
|
);
|
|
1150
1517
|
}
|
|
1151
|
-
|
|
1518
|
+
written.set(id, {
|
|
1152
1519
|
id,
|
|
1153
1520
|
verdict,
|
|
1154
1521
|
effect: text(entry?.effect),
|
|
@@ -1162,6 +1529,7 @@ export class JobManager {
|
|
|
1162
1529
|
// lead predicted can still arrive with the evidence that settles it, and a
|
|
1163
1530
|
// surprise can still be wrong, so a match/miss verdict would rate the
|
|
1164
1531
|
// consultation on the wrong axis.
|
|
1532
|
+
/** @type {Reflection | undefined} */
|
|
1165
1533
|
let newReflection;
|
|
1166
1534
|
if (reflection !== undefined) {
|
|
1167
1535
|
const delta = text(reflection?.delta);
|
|
@@ -1180,29 +1548,40 @@ export class JobManager {
|
|
|
1180
1548
|
newReflection = { delta, related_item_ids: related, recorded_at: now };
|
|
1181
1549
|
}
|
|
1182
1550
|
|
|
1183
|
-
|
|
1184
|
-
? undefined
|
|
1185
|
-
: { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
|
|
1186
|
-
return { job, live, record: newRecord, reflection: newReflection };
|
|
1551
|
+
return { job, live, entries: entries === undefined ? undefined : [...written.values()], reflection: newReflection };
|
|
1187
1552
|
}
|
|
1188
1553
|
|
|
1189
|
-
|
|
1554
|
+
/** @param {RecordChange} change */
|
|
1555
|
+
#saveRecord({ job, live, entries, reflection }) {
|
|
1190
1556
|
const was = { record: job.record, reflection: job.reflection };
|
|
1191
|
-
|
|
1192
|
-
|
|
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;
|
|
1193
1572
|
// An update to a round that already happened, so the index line stays as it
|
|
1194
1573
|
// was: the history has one line per consultation, not one per edit.
|
|
1195
1574
|
// Unlike the round itself, a verdict exists nowhere else once this server
|
|
1196
1575
|
// exits, so a failed write is the lead's to know about, not best effort.
|
|
1197
1576
|
try {
|
|
1198
|
-
store.persistRound(live ? this.#record(
|
|
1577
|
+
store.persistRound(live ? this.#record(live) : job, { appendIndex: false });
|
|
1199
1578
|
} catch (e) {
|
|
1200
1579
|
// Not saved, so not shown as recorded either.
|
|
1201
1580
|
Object.assign(job, was);
|
|
1202
1581
|
return {
|
|
1203
1582
|
job_id: job.job_id,
|
|
1204
1583
|
error: 'record_not_saved',
|
|
1205
|
-
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`,
|
|
1206
1585
|
};
|
|
1207
1586
|
}
|
|
1208
1587
|
// Counts only: the entries are what the lead just wrote, and
|
|
@@ -1217,6 +1596,12 @@ export class JobManager {
|
|
|
1217
1596
|
};
|
|
1218
1597
|
}
|
|
1219
1598
|
|
|
1599
|
+
/**
|
|
1600
|
+
* @param {string} jobId
|
|
1601
|
+
* @param {number | undefined} waitMs
|
|
1602
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1603
|
+
* @param {AbortSignal} [signal]
|
|
1604
|
+
*/
|
|
1220
1605
|
async wait(jobId, waitMs, opts, signal) {
|
|
1221
1606
|
const job = this.jobs.get(jobId);
|
|
1222
1607
|
if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
|
|
@@ -1230,6 +1615,13 @@ export class JobManager {
|
|
|
1230
1615
|
|
|
1231
1616
|
// A consultation another process runs has no promise here, so its history
|
|
1232
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
|
+
*/
|
|
1233
1625
|
async #waitOnDisk(read, waitMs, signal) {
|
|
1234
1626
|
const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1235
1627
|
let view = read();
|
|
@@ -1254,12 +1646,14 @@ export class JobManager {
|
|
|
1254
1646
|
}, CANCEL_POLL_MS).unref();
|
|
1255
1647
|
}
|
|
1256
1648
|
|
|
1649
|
+
/** @param {string} jobId */
|
|
1257
1650
|
cancel(jobId) {
|
|
1258
1651
|
const job = this.jobs.get(jobId);
|
|
1259
1652
|
if (!job) {
|
|
1260
1653
|
const view = this.view(jobId);
|
|
1261
1654
|
if (!view) return null;
|
|
1262
|
-
if (view.status !== 'running' && view.status !== 'queued')
|
|
1655
|
+
if (view.status !== 'running' && view.status !== 'queued')
|
|
1656
|
+
return { ...view, cancel_effect: `job was already ${view.status}` };
|
|
1263
1657
|
return {
|
|
1264
1658
|
...view,
|
|
1265
1659
|
cancel_effect: store.requestCancel(jobId)
|
|
@@ -1268,17 +1662,22 @@ export class JobManager {
|
|
|
1268
1662
|
};
|
|
1269
1663
|
}
|
|
1270
1664
|
if (job.status !== 'running' && job.status !== 'queued') {
|
|
1271
|
-
return { ...this
|
|
1665
|
+
return { ...this.#jobView(job, undefined), cancel_effect: `job was already ${job.status}` };
|
|
1272
1666
|
}
|
|
1273
1667
|
job.cancelRequested = true;
|
|
1274
1668
|
for (const fn of job._cancelFns) fn();
|
|
1275
1669
|
if (job._handle) job._handle.stop();
|
|
1276
|
-
return { ...this
|
|
1670
|
+
return { ...this.#jobView(job, undefined), status: 'cancelling', cancel_effect: 'consultant process group signalled' };
|
|
1277
1671
|
}
|
|
1278
1672
|
|
|
1279
1673
|
// Every field here is derived from the one `members` array it is handed, so a
|
|
1280
1674
|
// group view can never report one status in `members` and another in
|
|
1281
1675
|
// `comparison` or `status` for the same job.
|
|
1676
|
+
/**
|
|
1677
|
+
* @param {Group} group
|
|
1678
|
+
* @param {JobView[]} members
|
|
1679
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1680
|
+
*/
|
|
1282
1681
|
#shapeGroup(group, members, { detail } = {}) {
|
|
1283
1682
|
const full = detail === 'full';
|
|
1284
1683
|
const cancelling = members.some((m) => m.status === 'cancelling');
|
|
@@ -1295,9 +1694,10 @@ export class JobManager {
|
|
|
1295
1694
|
// same thing three times. Hoist one copy to the group and strip it from
|
|
1296
1695
|
// the members, which keeps the payload readable without losing anything.
|
|
1297
1696
|
const firstBrief = members.find((m) => m.brief)?.brief ?? null;
|
|
1298
|
-
const groupBrief =
|
|
1299
|
-
|
|
1300
|
-
|
|
1697
|
+
const groupBrief =
|
|
1698
|
+
firstBrief && !full
|
|
1699
|
+
? { ...firstBrief, full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" }) or consult_export` }
|
|
1700
|
+
: firstBrief;
|
|
1301
1701
|
// The limits are the server's, not a member's: one copy, on the group.
|
|
1302
1702
|
// By default a member is only its state: what there is to compare is in
|
|
1303
1703
|
// `comparison`, once, and the full answer is one consult_get({ job_id }) away.
|
|
@@ -1305,7 +1705,8 @@ export class JobManager {
|
|
|
1305
1705
|
if (full) return rest;
|
|
1306
1706
|
const message = rest.failure && cut(rest.failure.message, FAILURE_MESSAGE_CHARS);
|
|
1307
1707
|
const progress = cut(rest.progress, FAILURE_MESSAGE_CHARS);
|
|
1308
|
-
const cutShort =
|
|
1708
|
+
const cutShort =
|
|
1709
|
+
progress !== rest.progress || Boolean(rest.failure && (message !== rest.failure.message || rest.failure.detail));
|
|
1309
1710
|
return {
|
|
1310
1711
|
job_id: rest.job_id,
|
|
1311
1712
|
target: rest.target,
|
|
@@ -1321,19 +1722,22 @@ export class JobManager {
|
|
|
1321
1722
|
const question = full ? group.question : cut(group.question, QUESTION_CHARS);
|
|
1322
1723
|
const view = {
|
|
1323
1724
|
group_id: group.group_id,
|
|
1324
|
-
status: cancelling ? 'cancelling' :
|
|
1725
|
+
status: cancelling ? 'cancelling' : live ? 'running' : 'done',
|
|
1325
1726
|
mode: group.mode,
|
|
1326
1727
|
question,
|
|
1327
|
-
...(question !== group.question
|
|
1728
|
+
...(question !== group.question
|
|
1729
|
+
? { question_full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" })` }
|
|
1730
|
+
: {}),
|
|
1328
1731
|
brief: groupBrief,
|
|
1329
1732
|
members_expected: group.job_ids.length,
|
|
1330
1733
|
members_available: members.length,
|
|
1331
1734
|
// History is capped, so an older member can already be gone. Say so:
|
|
1332
1735
|
// a fan-out that quietly reports fewer answers than it asked for is
|
|
1333
1736
|
// worse than one that admits the comparison is partial.
|
|
1334
|
-
incomplete_note:
|
|
1335
|
-
|
|
1336
|
-
|
|
1737
|
+
incomplete_note:
|
|
1738
|
+
missing > 0
|
|
1739
|
+
? `${missing} of ${group.job_ids.length} member consultation(s) have aged out of this session's history; the comparison below is partial`
|
|
1740
|
+
: null,
|
|
1337
1741
|
members: trimmed,
|
|
1338
1742
|
comparison: null,
|
|
1339
1743
|
cancellable: live,
|
|
@@ -1353,16 +1757,29 @@ export class JobManager {
|
|
|
1353
1757
|
// While no one has finished there is nothing to set side by side, and an
|
|
1354
1758
|
// empty column per consultant is only context spent on every poll.
|
|
1355
1759
|
const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
|
|
1356
|
-
|
|
1357
|
-
return view;
|
|
1760
|
+
return anyFinished ? { ...view, comparison: comparison(members, full ? Infinity : GROUP_CHARS - printed(view)) } : view;
|
|
1358
1761
|
}
|
|
1359
1762
|
|
|
1763
|
+
/**
|
|
1764
|
+
* @param {string} groupId
|
|
1765
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1766
|
+
*/
|
|
1360
1767
|
groupView(groupId, opts) {
|
|
1361
1768
|
const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
|
|
1362
1769
|
if (!group) return null;
|
|
1363
|
-
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
|
+
);
|
|
1364
1775
|
}
|
|
1365
1776
|
|
|
1777
|
+
/**
|
|
1778
|
+
* @param {string} groupId
|
|
1779
|
+
* @param {number | undefined} waitMs
|
|
1780
|
+
* @param {{detail?: string | undefined}} [opts]
|
|
1781
|
+
* @param {AbortSignal} [signal]
|
|
1782
|
+
*/
|
|
1366
1783
|
async waitGroup(groupId, waitMs, opts, signal) {
|
|
1367
1784
|
const group = this.groups.get(groupId);
|
|
1368
1785
|
// A fan-out from another process or an earlier session: nothing here to
|
|
@@ -1376,6 +1793,7 @@ export class JobManager {
|
|
|
1376
1793
|
return this.groupView(groupId, opts);
|
|
1377
1794
|
}
|
|
1378
1795
|
|
|
1796
|
+
/** @param {string} groupId */
|
|
1379
1797
|
cancelGroup(groupId) {
|
|
1380
1798
|
const group = this.groups.get(groupId);
|
|
1381
1799
|
if (!group) {
|
|
@@ -1383,10 +1801,14 @@ export class JobManager {
|
|
|
1383
1801
|
if (!view) return null;
|
|
1384
1802
|
// Started by another server: each member still running there is asked to stop.
|
|
1385
1803
|
const asked = (store.loadGroup(groupId)?.job_ids ?? [])
|
|
1386
|
-
.map((jid) => this.cancel(jid))
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1804
|
+
.map((jid) => this.cancel(jid))
|
|
1805
|
+
.filter((r) => /^asked/.test(r?.cancel_effect ?? '')).length;
|
|
1806
|
+
return {
|
|
1807
|
+
...view,
|
|
1808
|
+
cancel_effect: asked
|
|
1809
|
+
? `asked the severally process running it to stop ${asked} consultation(s); that takes up to ${CANCEL_POLL_MS / 1000} s, and consult_get shows when it has`
|
|
1810
|
+
: 'nothing to signal: no consultant in this fan-out is still running',
|
|
1811
|
+
};
|
|
1390
1812
|
}
|
|
1391
1813
|
// Count what is actually about to be signalled before signalling it:
|
|
1392
1814
|
// cancel() short-circuits on a member that has already finished, so a
|
|
@@ -1398,15 +1820,16 @@ export class JobManager {
|
|
|
1398
1820
|
// Shape the view from the cancel() results, not from a fresh read: the
|
|
1399
1821
|
// children exit asynchronously, so a signalled member reads as
|
|
1400
1822
|
// "cancelling" until it is reaped, and the whole payload must say so.
|
|
1401
|
-
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);
|
|
1402
1824
|
const total = group.job_ids.length;
|
|
1403
1825
|
return {
|
|
1404
1826
|
...this.#shapeGroup(group, members),
|
|
1405
|
-
cancel_effect:
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1827
|
+
cancel_effect:
|
|
1828
|
+
signalled === 0
|
|
1829
|
+
? 'nothing to signal: every consultant in this fan-out had already finished'
|
|
1830
|
+
: signalled === total
|
|
1831
|
+
? 'every consultant process group in this fan-out was signalled'
|
|
1832
|
+
: `${signalled} of ${total} consultant process groups in this fan-out were signalled; the rest had already finished`,
|
|
1410
1833
|
};
|
|
1411
1834
|
}
|
|
1412
1835
|
|
|
@@ -1422,7 +1845,10 @@ export class JobManager {
|
|
|
1422
1845
|
for (const job of live) {
|
|
1423
1846
|
job.cancelRequested = true;
|
|
1424
1847
|
for (const fn of job._cancelFns) fn();
|
|
1425
|
-
if (job._handle) {
|
|
1848
|
+
if (job._handle) {
|
|
1849
|
+
handles.push(job._handle);
|
|
1850
|
+
job._handle.stop();
|
|
1851
|
+
}
|
|
1426
1852
|
}
|
|
1427
1853
|
const settled = Promise.all(live.map((j) => j.promise).filter(Boolean));
|
|
1428
1854
|
await Promise.race([settled, new Promise((r) => setTimeout(r, graceMs).unref?.())]);
|
|
@@ -1452,8 +1878,13 @@ export class JobManager {
|
|
|
1452
1878
|
// files of that diff are the same entry for this purpose.
|
|
1453
1879
|
// ponytail: a diff recorded before 1.11 hashed its files in git's order, not
|
|
1454
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
|
+
*/
|
|
1455
1885
|
function markSinceParent(manifest, parentEntries) {
|
|
1456
|
-
const key = (
|
|
1886
|
+
const key = (/** @type {string} */ source, /** @type {string} */ kind) =>
|
|
1887
|
+
`${kind === 'change_files' ? 'diff' : kind}\0${source}`;
|
|
1457
1888
|
const before = new Map(parentEntries.map((e) => [key(e.source, e.kind), e.sha256]));
|
|
1458
1889
|
for (const e of manifest.entries) {
|
|
1459
1890
|
const k = key(e.source, e.kind);
|
|
@@ -1464,13 +1895,13 @@ function markSinceParent(manifest, parentEntries) {
|
|
|
1464
1895
|
// What consult_start says a follow-up copied again: the entries it shares
|
|
1465
1896
|
// with the parent round, the ones left out and why, and how many files that
|
|
1466
1897
|
// added to this round.
|
|
1898
|
+
/** @param {ExposeManifest} manifest */
|
|
1467
1899
|
function inheritedSummary(manifest) {
|
|
1468
1900
|
const carried = manifest.entries.filter((e) => e.inherited);
|
|
1469
1901
|
return {
|
|
1470
1902
|
entries: carried.map((e) => ({ exposed_as: e.exposedAs, kind: e.kind, files: e.files, changed: e.changed ?? null })),
|
|
1471
1903
|
// Only what an earlier round showed carries the name it had there.
|
|
1472
|
-
left_out: manifest.skipped.filter((x) => x.exposed_as)
|
|
1473
|
-
.map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
|
|
1904
|
+
left_out: manifest.skipped.filter((x) => x.exposed_as).map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
|
|
1474
1905
|
files: carried.reduce((n, e) => n + e.files, 0),
|
|
1475
1906
|
};
|
|
1476
1907
|
}
|
|
@@ -1480,6 +1911,10 @@ function inheritedSummary(manifest) {
|
|
|
1480
1911
|
// is the raw output stream, unparsed: material the lead may read, never an
|
|
1481
1912
|
// answer, which is why it sits beside the failure and not in `result`.
|
|
1482
1913
|
const PARTIAL_TAIL_CHARS = 8_000;
|
|
1914
|
+
/**
|
|
1915
|
+
* @param {import('./run.mjs').ChildResult} run
|
|
1916
|
+
* @returns {import('./store.mjs').PartialOutput | null}
|
|
1917
|
+
*/
|
|
1483
1918
|
function partialOutput(run) {
|
|
1484
1919
|
const out = String(run.stdout ?? '');
|
|
1485
1920
|
if (!out.trim()) return null;
|
|
@@ -1497,10 +1932,16 @@ function partialOutput(run) {
|
|
|
1497
1932
|
// Waits for `promise` (null: for nothing), at most `ms`, and no longer than
|
|
1498
1933
|
// `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
|
|
1499
1934
|
// cut short leaves nothing behind.
|
|
1935
|
+
/**
|
|
1936
|
+
* @param {Promise<unknown> | null | undefined} promise
|
|
1937
|
+
* @param {number} ms
|
|
1938
|
+
* @param {AbortSignal} [signal]
|
|
1939
|
+
*/
|
|
1500
1940
|
async function settled(promise, ms, signal) {
|
|
1501
1941
|
let timer;
|
|
1942
|
+
/** @type {(() => void) | undefined} */
|
|
1502
1943
|
let stop;
|
|
1503
|
-
const until = new Promise((resolve) => {
|
|
1944
|
+
const until = new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
|
|
1504
1945
|
stop = resolve;
|
|
1505
1946
|
timer = setTimeout(resolve, ms);
|
|
1506
1947
|
if (signal?.aborted) resolve();
|
|
@@ -1510,10 +1951,11 @@ async function settled(promise, ms, signal) {
|
|
|
1510
1951
|
await (promise ? Promise.race([promise, until]) : until);
|
|
1511
1952
|
} finally {
|
|
1512
1953
|
clearTimeout(timer);
|
|
1513
|
-
signal?.removeEventListener('abort', stop);
|
|
1954
|
+
if (stop) signal?.removeEventListener('abort', stop);
|
|
1514
1955
|
}
|
|
1515
1956
|
}
|
|
1516
1957
|
|
|
1958
|
+
/** @param {string | Record<string, unknown>} text */
|
|
1517
1959
|
function parses(text) {
|
|
1518
1960
|
try {
|
|
1519
1961
|
normalizeResult(text);
|
|
@@ -1524,6 +1966,10 @@ function parses(text) {
|
|
|
1524
1966
|
}
|
|
1525
1967
|
}
|
|
1526
1968
|
|
|
1969
|
+
/**
|
|
1970
|
+
* @param {ConsultResult} result
|
|
1971
|
+
* @returns {string[]}
|
|
1972
|
+
*/
|
|
1527
1973
|
function recordableIds(result) {
|
|
1528
1974
|
return [
|
|
1529
1975
|
...result.findings.map((f) => f.id),
|
|
@@ -1534,7 +1980,12 @@ function recordableIds(result) {
|
|
|
1534
1980
|
|
|
1535
1981
|
// How many of each word, in the order they first appear. The only arithmetic
|
|
1536
1982
|
// this server does on anyone's judgement.
|
|
1983
|
+
/**
|
|
1984
|
+
* @param {Iterable<string>} values
|
|
1985
|
+
* @returns {Record<string, number>}
|
|
1986
|
+
*/
|
|
1537
1987
|
function tally(values) {
|
|
1988
|
+
/** @type {Record<string, number>} */
|
|
1538
1989
|
const out = {};
|
|
1539
1990
|
for (const v of values) out[v] = (out[v] ?? 0) + 1;
|
|
1540
1991
|
return out;
|
|
@@ -1543,17 +1994,27 @@ function tally(values) {
|
|
|
1543
1994
|
// Counting is mechanical and stays that way: how many points the answer has,
|
|
1544
1995
|
// how many carry a verdict, which ones do not. No judgement about whether the
|
|
1545
1996
|
// consultation was worth it -- that is the lead's to draw from the entries.
|
|
1997
|
+
/**
|
|
1998
|
+
* @param {Round} job
|
|
1999
|
+
* @returns {JobRecord}
|
|
2000
|
+
*/
|
|
1546
2001
|
function recordSummary(job) {
|
|
1547
|
-
|
|
1548
|
-
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));
|
|
1549
2006
|
return {
|
|
1550
|
-
...
|
|
1551
|
-
verdicts: tally(
|
|
2007
|
+
...record,
|
|
2008
|
+
verdicts: tally(record.entries.map((e) => e.verdict)),
|
|
1552
2009
|
coverage: { recordable: ids.length, recorded: written.size },
|
|
1553
2010
|
unrecorded: ids.filter((id) => !written.has(id)),
|
|
1554
2011
|
};
|
|
1555
2012
|
}
|
|
1556
2013
|
|
|
2014
|
+
/**
|
|
2015
|
+
* @param {unknown} value
|
|
2016
|
+
* @returns {string | null}
|
|
2017
|
+
*/
|
|
1557
2018
|
function text(value) {
|
|
1558
2019
|
if (typeof value !== 'string') return null;
|
|
1559
2020
|
const t = redact(value).trim();
|
|
@@ -1566,24 +2027,37 @@ function text(value) {
|
|
|
1566
2027
|
// a key left out is inherited -- one passed, even as an empty list, replaces
|
|
1567
2028
|
// it -- and the validation that follows sees the result. Files are not:
|
|
1568
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
|
+
*/
|
|
1569
2035
|
function inherit(raw, round) {
|
|
1570
2036
|
const { brief } = round;
|
|
1571
2037
|
// A round kept off the web stays off: the material it carries over is the same.
|
|
2038
|
+
/** @type {RawRequest} */
|
|
1572
2039
|
const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
|
|
1573
2040
|
if (!brief) return out;
|
|
1574
|
-
|
|
1575
|
-
|
|
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;
|
|
1576
2046
|
}
|
|
1577
2047
|
const ctx = { ...(raw.context ?? {}) };
|
|
1578
|
-
|
|
1579
|
-
|
|
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;
|
|
1580
2053
|
}
|
|
1581
2054
|
// The diff expose_diff added is not the lead's material: it is taken again
|
|
1582
2055
|
// (or replaced) through expose_from_parent, against the files as they are now.
|
|
1583
2056
|
// It is the last artifact (appended after the lead's own), so only that one is dropped.
|
|
1584
2057
|
const artifacts = [...(brief.artifacts ?? [])];
|
|
1585
2058
|
const last = artifacts.at(-1);
|
|
1586
|
-
if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff '))
|
|
2059
|
+
if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff '))
|
|
2060
|
+
artifacts.pop();
|
|
1587
2061
|
if (ctx.artifacts === undefined && artifacts.length) {
|
|
1588
2062
|
// Stored with null for an absent language or source; the request schema takes them as optional strings.
|
|
1589
2063
|
ctx.artifacts = artifacts.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v != null)));
|
|
@@ -1592,6 +2066,7 @@ function inherit(raw, round) {
|
|
|
1592
2066
|
return out;
|
|
1593
2067
|
}
|
|
1594
2068
|
|
|
2069
|
+
/** @param {ReturnType<typeof normalizeResult>} normalized */
|
|
1595
2070
|
function adviceCaveat(normalized) {
|
|
1596
2071
|
const { result, quality } = normalized;
|
|
1597
2072
|
const notes = [];
|
|
@@ -1603,16 +2078,51 @@ function adviceCaveat(normalized) {
|
|
|
1603
2078
|
notes.push('the consultant did not say how well its evidence supports the answer (evidence_basis)');
|
|
1604
2079
|
}
|
|
1605
2080
|
// Read first in a comparison; a missing one is shown as null there, and named here.
|
|
1606
|
-
if (!result.stance)
|
|
2081
|
+
if (!result.stance)
|
|
2082
|
+
notes.push('the answer gives no bottom line (stance): read the summary and findings for one before relying on it');
|
|
1607
2083
|
if (quality.findings_without_grounds > 0) {
|
|
1608
2084
|
notes.push(`${quality.findings_without_grounds} finding(s) arrived without grounds: do not adopt them without checking`);
|
|
1609
2085
|
}
|
|
1610
2086
|
if (quality.unsourced && result.findings.length > 0) {
|
|
1611
|
-
notes.push(
|
|
2087
|
+
notes.push("no sources were cited; the findings rest on the consultant's own reasoning over the brief");
|
|
1612
2088
|
}
|
|
1613
2089
|
return notes.length ? notes.join('; ') : null;
|
|
1614
2090
|
}
|
|
1615
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
|
+
|
|
1616
2126
|
// A lead may legitimately name its own CLI as the consultant: a fresh child
|
|
1617
2127
|
// session with no shared context is still a useful clean-context re-read.
|
|
1618
2128
|
// But it is not an independent opinion -- the answer comes from the same
|
|
@@ -1620,10 +2130,17 @@ function adviceCaveat(normalized) {
|
|
|
1620
2130
|
// lineage is the consultant's model's, not its CLI's: OpenCode can run GLM
|
|
1621
2131
|
// for a GLM lead and DeepSeek for anyone.
|
|
1622
2132
|
|
|
2133
|
+
/**
|
|
2134
|
+
* @param {string | null | undefined} caller
|
|
2135
|
+
* @param {string | null | undefined} callerModel
|
|
2136
|
+
* @param {Consultant} consultant
|
|
2137
|
+
* @returns {string | null}
|
|
2138
|
+
*/
|
|
1623
2139
|
function sameVendorCaveat(caller, callerModel, consultant) {
|
|
1624
2140
|
// The lead's lineage: its declared model if that names one, else its CLI's
|
|
1625
2141
|
// own. An unknown lineage on either side is no caveat -- not a guess.
|
|
1626
|
-
const
|
|
2142
|
+
const callerCli = CLI_IDS.find((c) => c === caller);
|
|
2143
|
+
const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (callerCli ? (CLI_VENDORS[callerCli] ?? null) : null);
|
|
1627
2144
|
const vendor = consultant.vendor;
|
|
1628
2145
|
if (!callerVendor || !vendor || callerVendor !== vendor) return null;
|
|
1629
2146
|
// Say what a fresh session of the same lineage removes and what it keeps.
|
|
@@ -1653,21 +2170,26 @@ function sameVendorCaveat(caller, callerModel, consultant) {
|
|
|
1653
2170
|
// A mechanical side-by-side. The server deliberately does not decide whether
|
|
1654
2171
|
// the consultants agree: inventing agreement that is not there is exactly the
|
|
1655
2172
|
// failure a second opinion is supposed to prevent.
|
|
2173
|
+
/**
|
|
2174
|
+
* @param {JobView[]} members
|
|
2175
|
+
* @param {number} budget
|
|
2176
|
+
*/
|
|
1656
2177
|
function comparison(members, budget) {
|
|
1657
2178
|
// Each column first pays for its fixed fields; the rest is its budget.
|
|
1658
2179
|
const each = (budget - COMPARISON_NOTE_CHARS) / Math.max(1, members.length) - COLUMN_FIXED_CHARS;
|
|
1659
|
-
|
|
2180
|
+
const firstPass = members.map((m) => ({ m, column: byTarget(m, each) }));
|
|
2181
|
+
let columns = firstPass.map((x) => x.column);
|
|
1660
2182
|
// What the short answers did not spend goes to the ones that were cut.
|
|
1661
2183
|
const cutShort = columns.filter((c) => c.truncated).length;
|
|
1662
2184
|
if (each !== Infinity && cutShort) {
|
|
1663
2185
|
const spare = columns.reduce((n, c) => n + (c.truncated ? 0 : Math.max(0, each + COLUMN_FIXED_CHARS - printed(c))), 0);
|
|
1664
|
-
columns =
|
|
2186
|
+
columns = firstPass.map(({ m, column }) => (column.truncated ? byTarget(m, each + spare / cutShort) : column));
|
|
1665
2187
|
}
|
|
1666
2188
|
return {
|
|
1667
2189
|
by_target: columns,
|
|
1668
2190
|
note:
|
|
1669
2191
|
'This server does not judge whether the consultants agree: similar summaries are not evidence of agreement. ' +
|
|
1670
|
-
|
|
2192
|
+
"Each stance is the consultant's own declaration, relayed as given -- consultants that share a finding can still " +
|
|
1671
2193
|
'declare opposite stances, and that is a divergence. Compare the grounds behind each point yourself, and spend a ' +
|
|
1672
2194
|
'follow-up only where a divergence would change your decision.',
|
|
1673
2195
|
};
|
|
@@ -1680,17 +2202,32 @@ function comparison(members, budget) {
|
|
|
1680
2202
|
// shortened to fit the share before any is left out. A cut is flagged, never hidden: `truncated` says so,
|
|
1681
2203
|
// `omitted` counts the items a list could not hold, and `full_with` names the
|
|
1682
2204
|
// call that has everything.
|
|
2205
|
+
/**
|
|
2206
|
+
* @param {JobView} m
|
|
2207
|
+
* @param {number} budget
|
|
2208
|
+
* @returns {ComparisonColumn}
|
|
2209
|
+
*/
|
|
1683
2210
|
function byTarget(m, budget) {
|
|
1684
2211
|
const full = budget === Infinity;
|
|
1685
2212
|
let left = budget;
|
|
1686
2213
|
let truncated = false;
|
|
2214
|
+
/** @type {Record<string, number>} */
|
|
1687
2215
|
const omitted = {};
|
|
1688
|
-
const clip = (s, max) => {
|
|
2216
|
+
const clip = (/** @type {string | null | undefined} */ s, /** @type {number} */ max) => {
|
|
1689
2217
|
const out = full ? s : cut(s, max);
|
|
1690
2218
|
if (out !== s) truncated = true;
|
|
1691
2219
|
return out;
|
|
1692
2220
|
};
|
|
1693
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
|
+
*/
|
|
1694
2231
|
const list = (name, items = [], fields, build, part, floor = MIN_TEXT_CHARS) => {
|
|
1695
2232
|
if (full) return items.map((x) => build(x, Infinity));
|
|
1696
2233
|
let share = left * part;
|
|
@@ -1710,25 +2247,46 @@ function byTarget(m, budget) {
|
|
|
1710
2247
|
left -= n;
|
|
1711
2248
|
out.push(v);
|
|
1712
2249
|
}
|
|
1713
|
-
if (out.length < items.length) {
|
|
2250
|
+
if (out.length < items.length) {
|
|
2251
|
+
omitted[name] = items.length - out.length;
|
|
2252
|
+
truncated = true;
|
|
2253
|
+
}
|
|
1714
2254
|
return out;
|
|
1715
2255
|
};
|
|
1716
2256
|
const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
|
|
1717
2257
|
const caveat = clip(m.quality?.caveat ?? null, Math.min(CAVEAT_CHARS, budget / 3));
|
|
1718
2258
|
if (!full) left -= printed(summary) + printed(caveat);
|
|
2259
|
+
/** @type {Partial<ConsultResult>} */
|
|
1719
2260
|
const r = m.result ?? {};
|
|
1720
2261
|
// The findings first, with up to 60% of what is left and their points cut
|
|
1721
2262
|
// as short as it takes: they are what gets recorded against, so their ids
|
|
1722
2263
|
// matter most. The other lists then share the rest evenly.
|
|
1723
|
-
const findingPoints = list(
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
2264
|
+
const findingPoints = list(
|
|
2265
|
+
'finding_points',
|
|
2266
|
+
r.findings,
|
|
2267
|
+
1,
|
|
2268
|
+
(f, n) => ({ id: f.id, severity: f.severity ?? null, point: clip(f.point, n) }),
|
|
2269
|
+
0.6,
|
|
2270
|
+
20,
|
|
2271
|
+
);
|
|
2272
|
+
const decisionChangers = list(
|
|
2273
|
+
'decision_changers',
|
|
2274
|
+
r.decision_changers,
|
|
2275
|
+
2,
|
|
2276
|
+
(d, n) => ({ condition: clip(d.condition, n), changes_to: clip(d.changes_to, n) }),
|
|
2277
|
+
1 / 4,
|
|
2278
|
+
);
|
|
1727
2279
|
const alternatives = list('alternative_options', r.alternatives, 1, (a, n) => clip(a.option, n), 1 / 3);
|
|
1728
2280
|
const unknowns = list('unknowns', r.unknowns, 1, (u, n) => ({ id: u.id, item: clip(u.item, n) }), 1 / 2);
|
|
1729
2281
|
const nextChecks = list('next_checks', r.next_checks, 1, (c, n) => ({ id: c.id, check: clip(c.check, n) }), 1 / 2);
|
|
1730
|
-
const disagreements = list(
|
|
1731
|
-
|
|
2282
|
+
const disagreements = list(
|
|
2283
|
+
'remaining_disagreements',
|
|
2284
|
+
r.remaining_disagreements,
|
|
2285
|
+
3,
|
|
2286
|
+
(d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
|
|
2287
|
+
1,
|
|
2288
|
+
);
|
|
2289
|
+
/** @type {ComparisonColumn} */
|
|
1732
2290
|
const entry = {
|
|
1733
2291
|
job_id: m.job_id,
|
|
1734
2292
|
target: m.target,
|
|
@@ -1763,7 +2321,11 @@ function byTarget(m, budget) {
|
|
|
1763
2321
|
points_without_verdict: m.result ? (m.record?.unrecorded ?? recordableIds(m.result)).length : null,
|
|
1764
2322
|
};
|
|
1765
2323
|
// A member's status and failure are in `members`; say them once.
|
|
1766
|
-
if (!full) {
|
|
2324
|
+
if (!full) {
|
|
2325
|
+
delete entry.cli;
|
|
2326
|
+
delete entry.status;
|
|
2327
|
+
delete entry.failure_kind;
|
|
2328
|
+
}
|
|
1767
2329
|
if (Object.keys(omitted).length) entry.omitted = omitted;
|
|
1768
2330
|
if (truncated) {
|
|
1769
2331
|
entry.truncated = true;
|
|
@@ -1794,6 +2356,10 @@ const MIN_TEXT_CHARS = 40;
|
|
|
1794
2356
|
* points being checked, without the rest of the answer. A projection, not a
|
|
1795
2357
|
* summary. An id the answer does not have is refused with the ones it has.
|
|
1796
2358
|
*/
|
|
2359
|
+
/**
|
|
2360
|
+
* @param {JobView} view
|
|
2361
|
+
* @param {string[]} ids
|
|
2362
|
+
*/
|
|
1797
2363
|
export function pointsOf(view, ids) {
|
|
1798
2364
|
if (!view.result) {
|
|
1799
2365
|
throw new RequestError(`consultation "${view.job_id}" is ${view.status} and has no answer to take points from`, 'no_result');
|
|
@@ -1802,13 +2368,21 @@ export function pointsOf(view, ids) {
|
|
|
1802
2368
|
const known = recordableIds(r);
|
|
1803
2369
|
const unknown = ids.filter((id) => !known.includes(id));
|
|
1804
2370
|
if (unknown.length) {
|
|
1805
|
-
throw new RequestError(
|
|
2371
|
+
throw new RequestError(
|
|
2372
|
+
`${unknown.map((id) => `"${id}"`).join(', ')} not in this answer; it has: ${known.join(', ')}`,
|
|
2373
|
+
'unknown_entry_id',
|
|
2374
|
+
);
|
|
1806
2375
|
}
|
|
1807
2376
|
const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
|
|
1808
2377
|
const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
|
|
1809
2378
|
const points = [...new Set(ids)].map((id) => {
|
|
1810
|
-
|
|
1811
|
-
|
|
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 };
|
|
1812
2386
|
});
|
|
1813
2387
|
return {
|
|
1814
2388
|
job_id: view.job_id,
|
|
@@ -1837,6 +2411,7 @@ export function pointsOf(view, ids) {
|
|
|
1837
2411
|
|
|
1838
2412
|
// What a value takes as the server prints it, nested four levels into the
|
|
1839
2413
|
// response: two-space JSON, every line indented eight more.
|
|
2414
|
+
/** @param {unknown} v */
|
|
1840
2415
|
function printed(v) {
|
|
1841
2416
|
const j = JSON.stringify(v, null, 2) ?? '';
|
|
1842
2417
|
return j.length + 8 * j.split('\n').length;
|
|
@@ -1844,10 +2419,15 @@ function printed(v) {
|
|
|
1844
2419
|
|
|
1845
2420
|
// A text cut to `max` characters as printed in JSON, so a run of characters
|
|
1846
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
|
+
*/
|
|
1847
2427
|
function cut(s, max) {
|
|
1848
2428
|
if (typeof s !== 'string') return s;
|
|
1849
2429
|
const room = Math.max(0, Math.floor(max));
|
|
1850
|
-
const width = (t) => JSON.stringify(t).length - 2;
|
|
2430
|
+
const width = (/** @type {string} */ t) => JSON.stringify(t).length - 2;
|
|
1851
2431
|
if (width(s) <= room) return s;
|
|
1852
2432
|
// One character of the room goes to the ellipsis, and a surrogate pair is
|
|
1853
2433
|
// never split.
|
|
@@ -1861,6 +2441,11 @@ function cut(s, max) {
|
|
|
1861
2441
|
// is named rather than repeated: its size and sha256 (of the JSON stored in
|
|
1862
2442
|
// the round file, so both can be checked against it), where it is kept, and
|
|
1863
2443
|
// what was copied -- skipped files counted by reason, not listed.
|
|
2444
|
+
/**
|
|
2445
|
+
* @param {FullView} view
|
|
2446
|
+
* @param {string} [detail]
|
|
2447
|
+
* @returns {JobView}
|
|
2448
|
+
*/
|
|
1864
2449
|
function compactBrief(view, detail) {
|
|
1865
2450
|
if (detail === 'full' || !view.brief) return view;
|
|
1866
2451
|
const json = JSON.stringify(view.brief);
|
|
@@ -1872,12 +2457,18 @@ function compactBrief(view, detail) {
|
|
|
1872
2457
|
sha256: crypto.createHash('sha256').update(json).digest('hex'),
|
|
1873
2458
|
// Under the severally home (~/.severally unless the operator moved it).
|
|
1874
2459
|
history_file: path.relative(POLICY.home, store.roundPath(view.chain_id, view.round)),
|
|
1875
|
-
exposed: ex
|
|
2460
|
+
exposed: ex
|
|
2461
|
+
? { ...ex.totals, skipped: tally((ex.skipped ?? []).map((/** @type {{reason: string}} */ x) => x.reason)) }
|
|
2462
|
+
: null,
|
|
1876
2463
|
full_with: `consult_get({ job_id: "${view.job_id}", detail: "full" }) or consult_export`,
|
|
1877
2464
|
},
|
|
1878
2465
|
};
|
|
1879
2466
|
}
|
|
1880
2467
|
|
|
2468
|
+
/**
|
|
2469
|
+
* @param {Round} job
|
|
2470
|
+
* @returns {string}
|
|
2471
|
+
*/
|
|
1881
2472
|
function nextStep(job) {
|
|
1882
2473
|
if (job.status === 'running' || job.status === 'queued') return 'poll consult_get again, or consult_cancel to stop it';
|
|
1883
2474
|
if (job.status === 'completed') {
|
|
@@ -1886,27 +2477,35 @@ function nextStep(job) {
|
|
|
1886
2477
|
// point showed, not when the answer arrives. Name the open ones until then.
|
|
1887
2478
|
const written = new Set((job.record?.entries ?? []).map((e) => e.id));
|
|
1888
2479
|
const open = job.result ? recordableIds(job.result).filter((id) => !written.has(id)).length : 0;
|
|
1889
|
-
const close =
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2480
|
+
const close =
|
|
2481
|
+
open > 0
|
|
2482
|
+
? ` Then write what checking showed: consult_record({ job_id: "${job.job_id}", entries: [{ id, verdict, effect }] }) -- ${open} point(s) still carry no verdict.`
|
|
2483
|
+
: '';
|
|
2484
|
+
return (
|
|
2485
|
+
(left > 0
|
|
2486
|
+
? `check the grounds behind the points that matter, run the one check that would change the decision, then either decide, or spend one of your ${left} remaining round(s) on the specific divergences (followup_to: "${job.job_id}").`
|
|
2487
|
+
: 'rounds exhausted: decide with what you have.') + close
|
|
2488
|
+
);
|
|
1895
2489
|
}
|
|
1896
|
-
if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error')
|
|
1897
|
-
|
|
1898
|
-
if (job.failure?.kind === '
|
|
1899
|
-
|
|
1900
|
-
if (job.failure?.kind === '
|
|
2490
|
+
if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error')
|
|
2491
|
+
return 'retriable: narrow the brief and start a new consultation';
|
|
2492
|
+
if (job.failure?.kind === 'interrupted')
|
|
2493
|
+
return 'the severally process running it exited before the consultant answered; start the consultation again if you still need it';
|
|
2494
|
+
if (job.failure?.kind === 'rate_limited')
|
|
2495
|
+
return 'the consultant was rate-limited and the server already re-ran it (see retries); try again in a few minutes, or proceed on your own judgement';
|
|
2496
|
+
if (job.failure?.kind === 'usage_limit' || job.failure?.kind === 'auth')
|
|
2497
|
+
return 'not a consultation outcome: the consultant never answered. Proceed on your own judgement, or fix the credentials/quota first';
|
|
2498
|
+
if (job.failure?.kind === 'invalid_output')
|
|
2499
|
+
return 'the consultant answered but not in the required shape; retry once with a shorter brief before giving up';
|
|
1901
2500
|
return 'proceed on your own judgement and note that no peer input was obtained';
|
|
1902
2501
|
}
|
|
1903
2502
|
|
|
1904
2503
|
// Every spelling a token could name a flag by: `--name[=v]` as written, and for
|
|
1905
2504
|
// a single dash both a short flag with its value attached (`-sdanger-full-access`
|
|
1906
2505
|
// in clap) and a Go-style long flag (`-dangerously-skip-permissions`).
|
|
1907
|
-
const flagNames = (a) => {
|
|
2506
|
+
const flagNames = (/** @type {string} */ a) => {
|
|
1908
2507
|
if (!a.startsWith('-') || a === '-') return [];
|
|
1909
|
-
const name = a.split('=')
|
|
2508
|
+
const [name = ''] = a.split('=');
|
|
1910
2509
|
return a.startsWith('--') ? [name] : [name, a.slice(0, 2), `-${name}`];
|
|
1911
2510
|
};
|
|
1912
2511
|
|
|
@@ -1917,6 +2516,12 @@ const flagNames = (a) => {
|
|
|
1917
2516
|
* adapter's FORBIDDEN_FLAGS and RESERVED_FLAGS (aliases and other flags that
|
|
1918
2517
|
* undo it). Values (tokens without a leading dash) are not inspected.
|
|
1919
2518
|
*/
|
|
2519
|
+
/**
|
|
2520
|
+
* @param {CliId} target
|
|
2521
|
+
* @param {readonly string[]} adapterArgs
|
|
2522
|
+
* @param {unknown} extra
|
|
2523
|
+
* @returns {string | null}
|
|
2524
|
+
*/
|
|
1920
2525
|
export function extraArgsProblem(target, adapterArgs, extra) {
|
|
1921
2526
|
if (!Array.isArray(extra) || !extra.every((a) => typeof a === 'string')) return 'must be an array of strings';
|
|
1922
2527
|
const adapter = ADAPTERS[target];
|
|
@@ -1930,11 +2535,20 @@ export function extraArgsProblem(target, adapterArgs, extra) {
|
|
|
1930
2535
|
}
|
|
1931
2536
|
|
|
1932
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
|
+
*/
|
|
1933
2543
|
export function withExtraArgs(args, extra) {
|
|
1934
2544
|
if (extra.length === 0) return args;
|
|
1935
2545
|
return args.at(-1) === '-' ? [...args.slice(0, -1), ...extra, '-'] : [...args, ...extra];
|
|
1936
2546
|
}
|
|
1937
2547
|
|
|
2548
|
+
/**
|
|
2549
|
+
* @param {CliId} target
|
|
2550
|
+
* @param {readonly string[]} args
|
|
2551
|
+
*/
|
|
1938
2552
|
export function assertNoForbiddenFlags(target, args) {
|
|
1939
2553
|
const forbidden = ADAPTERS[target].FORBIDDEN_FLAGS;
|
|
1940
2554
|
const hit = args.find((a) => forbidden.includes(a));
|