severally-mcp 2.1.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CODE_OF_CONDUCT.md +83 -0
- package/CONFIG.md +10 -6
- package/README.ja.md +146 -69
- package/README.md +150 -73
- package/bin/severally-mcp.mjs +17 -2
- package/config.example.json +26 -0
- package/docs/how-it-works.ja.md +4 -2
- package/docs/how-it-works.md +5 -3
- package/docs/install.ja.md +59 -24
- package/docs/install.md +60 -24
- package/package.json +16 -4
- 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 +29220 -27764
- package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
- package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
- package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
- package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
- package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
- package/plugins/severally/skills/claude/severally/reference.md +11 -5
- package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
- package/plugins/severally/skills/codex/severally/reference.md +11 -5
- package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
- package/plugins/severally/skills/opencode/severally/reference.md +11 -5
- package/scripts/build.mjs +31 -26
- package/scripts/doctor.mjs +52 -17
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +342 -84
- package/scripts/live-check.mjs +52 -19
- package/scripts/live-mcp-check.mjs +42 -12
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +39 -23
- package/src/adapters/antigravity.mjs +34 -16
- package/src/adapters/claude-code.mjs +73 -21
- package/src/adapters/codex.mjs +69 -27
- package/src/adapters/opencode-sandbox.mjs +40 -21
- package/src/adapters/opencode.mjs +31 -13
- package/src/brief.mjs +65 -41
- package/src/consultants.mjs +99 -30
- package/src/export.mjs +49 -12
- package/src/expose-paths.mjs +162 -44
- package/src/failures.mjs +23 -13
- package/src/jobs.mjs +420 -140
- package/src/parse-result.mjs +85 -30
- package/src/platform.mjs +5 -7
- package/src/policy.mjs +139 -32
- package/src/redact.mjs +38 -14
- package/src/run.mjs +104 -22
- package/src/schema.mjs +76 -22
- package/src/server.mjs +176 -166
- package/src/status.mjs +132 -0
- package/src/store.mjs +164 -19
package/src/jobs.mjs
CHANGED
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
POLICY,
|
|
9
|
+
VERDICTS,
|
|
10
|
+
limitsSummary,
|
|
11
|
+
timeoutMs,
|
|
12
|
+
detectCaller,
|
|
13
|
+
configProblem,
|
|
14
|
+
consultantByName,
|
|
15
|
+
consultantAvailable,
|
|
16
|
+
} from './policy.mjs';
|
|
8
17
|
import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
|
|
9
18
|
import { parseRequest, RequestError, charCount } from './schema.mjs';
|
|
10
19
|
import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
|
|
@@ -27,6 +36,9 @@ const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
|
|
|
27
36
|
// anyway would silently extend the job's advertised wall-clock deadline.
|
|
28
37
|
const MIN_RETRY_MS = 5_000;
|
|
29
38
|
|
|
39
|
+
// How often a server looks for a cancel another server asked for.
|
|
40
|
+
const CANCEL_POLL_MS = 1_000;
|
|
41
|
+
|
|
30
42
|
// The second way a consultant can be authenticated. A linked token file and an
|
|
31
43
|
// API key / ADC file are alternatives, not both required, so the presence of
|
|
32
44
|
// either has to satisfy the credential check below. These are the names
|
|
@@ -68,13 +80,20 @@ function ownerState(jobId, owner) {
|
|
|
68
80
|
if (!owner) return 'dead';
|
|
69
81
|
let pidAlive;
|
|
70
82
|
if (owner.pid === process.pid) pidAlive = LIVE_HERE.has(jobId);
|
|
71
|
-
else
|
|
83
|
+
else
|
|
84
|
+
try {
|
|
85
|
+
process.kill(owner.pid, 0);
|
|
86
|
+
pidAlive = true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
pidAlive = err.code === 'EPERM';
|
|
89
|
+
}
|
|
72
90
|
if (!pidAlive) return 'dead';
|
|
73
91
|
return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
export class JobManager {
|
|
77
95
|
constructor() {
|
|
96
|
+
this._cancelWatch = null;
|
|
78
97
|
this.jobs = new Map();
|
|
79
98
|
this.groups = new Map();
|
|
80
99
|
this.order = [];
|
|
@@ -100,7 +119,10 @@ export class JobManager {
|
|
|
100
119
|
// owner finished and died before removing the marker.
|
|
101
120
|
#interrupt(jobId, rec, state) {
|
|
102
121
|
const done = store.loadRound(jobId);
|
|
103
|
-
if (done) {
|
|
122
|
+
if (done) {
|
|
123
|
+
store.removeRunning(jobId);
|
|
124
|
+
return done;
|
|
125
|
+
}
|
|
104
126
|
const { owner, ...round } = rec;
|
|
105
127
|
round.status = 'failed';
|
|
106
128
|
round.finished_at = new Date().toISOString();
|
|
@@ -111,7 +133,11 @@ export class JobManager {
|
|
|
111
133
|
retriable: true,
|
|
112
134
|
delivered_advice: false,
|
|
113
135
|
};
|
|
114
|
-
try {
|
|
136
|
+
try {
|
|
137
|
+
store.persistRound(round);
|
|
138
|
+
} catch {
|
|
139
|
+
return round;
|
|
140
|
+
}
|
|
115
141
|
store.removeRunning(jobId);
|
|
116
142
|
// An overdue owner may still be running its consultant: leave its working
|
|
117
143
|
// directory to it. ponytail: if the pid was reused instead, that
|
|
@@ -192,6 +218,7 @@ export class JobManager {
|
|
|
192
218
|
|
|
193
219
|
// `detail: "full"` returns the brief as stored; by default a finished
|
|
194
220
|
// consultation names it (size, sha256, round file) instead of repeating it.
|
|
221
|
+
/** @param {string} jobId @param {{detail?: string}} [opts] */
|
|
195
222
|
view(jobId, { detail } = {}) {
|
|
196
223
|
const job = this.jobs.get(jobId);
|
|
197
224
|
if (!job) {
|
|
@@ -203,9 +230,10 @@ export class JobManager {
|
|
|
203
230
|
// While it runs, say what it is doing. "running" for twenty minutes tells
|
|
204
231
|
// the lead nothing; a consultation visibly grinding through tool calls is
|
|
205
232
|
// one it can cancel now rather than wait out.
|
|
206
|
-
rec.progress =
|
|
207
|
-
|
|
208
|
-
|
|
233
|
+
rec.progress =
|
|
234
|
+
job.status === 'running' && job._handle
|
|
235
|
+
? (ADAPTERS[job.cli]?.progress?.({ stdout: job._handle.stdoutSoFar() }) ?? null)
|
|
236
|
+
: null;
|
|
209
237
|
rec.record = job.record ? recordSummary(job) : null;
|
|
210
238
|
rec.prediction = job.prediction ?? null;
|
|
211
239
|
rec.reflection = job.reflection ?? null;
|
|
@@ -219,39 +247,71 @@ export class JobManager {
|
|
|
219
247
|
const live = job.status === 'running' || job.status === 'queued' || job.status === 'cancelling';
|
|
220
248
|
if (live) {
|
|
221
249
|
rec.brief = null;
|
|
222
|
-
rec.brief_note =
|
|
250
|
+
rec.brief_note =
|
|
251
|
+
'omitted while the consultation is running; returned once it finishes, and kept in ~/.severally/history either way';
|
|
223
252
|
}
|
|
224
253
|
rec.limits = live ? null : limitsSummary();
|
|
225
254
|
rec.next_step = nextStep(job);
|
|
226
255
|
return compactBrief(rec, detail);
|
|
227
256
|
}
|
|
228
257
|
|
|
258
|
+
// This session's consultations and the history's, so an earlier session's
|
|
259
|
+
// job_id and group_id can still be followed up and recorded against. The
|
|
260
|
+
// index only picks and orders; each row shown is read from its round, which
|
|
261
|
+
// holds the verdicts written since. Memory wins over disk for the same job.
|
|
229
262
|
list({ limit = 20 } = {}) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
failure_kind: j.failure?.kind ?? null,
|
|
245
|
-
created_at: j.created_at,
|
|
246
|
-
duration_ms: j.duration_ms,
|
|
247
|
-
summary: j.result ? j.result.summary.slice(0, 200) : null,
|
|
248
|
-
recorded: Boolean(j.record),
|
|
249
|
-
verdicts: j.record ? tally(j.record.entries.map((e) => e.verdict)) : null,
|
|
250
|
-
predicted: Boolean(j.prediction),
|
|
251
|
-
reflected: Boolean(j.reflection),
|
|
252
|
-
initiator: j.initiator ?? null,
|
|
253
|
-
};
|
|
263
|
+
const here = this.order.map((jid) => this.jobs.get(jid));
|
|
264
|
+
const seen = new Set(here.map((j) => j.job_id));
|
|
265
|
+
const elsewhere = [];
|
|
266
|
+
// Read only: a marker whose owner is gone is shown as interrupted, and
|
|
267
|
+
// left for consult_get or the startup sweep to close out.
|
|
268
|
+
for (const jobId of store.listRunning()) {
|
|
269
|
+
const live = seen.has(jobId) ? null : store.loadRunning(jobId);
|
|
270
|
+
if (!live) continue;
|
|
271
|
+
seen.add(jobId);
|
|
272
|
+
const state = ownerState(jobId, live.owner);
|
|
273
|
+
// A round saved just before its owner died is the answer, whatever the marker says.
|
|
274
|
+
const saved = state === 'alive' ? null : store.loadRoundAt(live);
|
|
275
|
+
elsewhere.push({
|
|
276
|
+
rec: state === 'alive' ? live : (saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } }),
|
|
254
277
|
});
|
|
278
|
+
}
|
|
279
|
+
for (const row of store.indexRows())
|
|
280
|
+
if (!seen.has(row.job_id)) {
|
|
281
|
+
seen.add(row.job_id);
|
|
282
|
+
elsewhere.push({ row });
|
|
283
|
+
}
|
|
284
|
+
const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
|
|
285
|
+
const picked = [];
|
|
286
|
+
// Round files are read only for the rows shown; one that cannot be read is passed over.
|
|
287
|
+
for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
|
|
288
|
+
if (picked.length >= limit) break;
|
|
289
|
+
const j = x.row ? store.loadRoundAt(x.row) : (x.rec ?? x);
|
|
290
|
+
if (j) picked.push(j);
|
|
291
|
+
}
|
|
292
|
+
return picked.map((j) => {
|
|
293
|
+
return {
|
|
294
|
+
job_id: j.job_id,
|
|
295
|
+
group_id: j.group_id ?? null,
|
|
296
|
+
chain_id: j.chain_id,
|
|
297
|
+
round: j.round,
|
|
298
|
+
target: j.target,
|
|
299
|
+
cli: j.cli,
|
|
300
|
+
model: j.model,
|
|
301
|
+
mode: j.mode,
|
|
302
|
+
status: j.status,
|
|
303
|
+
failure_kind: j.failure?.kind ?? null,
|
|
304
|
+
created_at: j.created_at,
|
|
305
|
+
duration_ms: j.duration_ms,
|
|
306
|
+
summary: j.result ? j.result.summary.slice(0, 200) : null,
|
|
307
|
+
recorded: Boolean(j.record),
|
|
308
|
+
verdicts: j.record?.entries ? tally(j.record.entries.map((e) => e.verdict)) : null,
|
|
309
|
+
this_session: this.jobs.has(j.job_id),
|
|
310
|
+
predicted: Boolean(j.prediction),
|
|
311
|
+
reflected: Boolean(j.reflection),
|
|
312
|
+
initiator: j.initiator ?? null,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
255
315
|
}
|
|
256
316
|
|
|
257
317
|
/**
|
|
@@ -271,7 +331,11 @@ export class JobManager {
|
|
|
271
331
|
t.consultations += 1;
|
|
272
332
|
t.attempts += attempts.length;
|
|
273
333
|
for (const u of attempts) {
|
|
274
|
-
for (const [k, v] of Object.entries(u))
|
|
334
|
+
for (const [k, v] of Object.entries(u))
|
|
335
|
+
if (typeof v === 'number') {
|
|
336
|
+
t[k] = (t[k] ?? 0) + v;
|
|
337
|
+
seen.add(k);
|
|
338
|
+
}
|
|
275
339
|
}
|
|
276
340
|
}
|
|
277
341
|
for (const [target, t] of Object.entries(by)) {
|
|
@@ -289,12 +353,12 @@ export class JobManager {
|
|
|
289
353
|
}
|
|
290
354
|
// A config the operator got wrong is not run on defaults: that could
|
|
291
355
|
// re-enable a CLI they turned off. Reading the history still works.
|
|
292
|
-
if (configProblem())
|
|
356
|
+
if (configProblem())
|
|
357
|
+
throw new RequestError(`the severally config file needs fixing first: ${configProblem()}`, 'config_invalid');
|
|
293
358
|
|
|
294
359
|
// Resolve the chain before validating, so follow-up rules can be applied.
|
|
295
|
-
const followupTo =
|
|
296
|
-
? rawRequest.followup_to.trim()
|
|
297
|
-
: null;
|
|
360
|
+
const followupTo =
|
|
361
|
+
typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
|
|
298
362
|
let parent = null;
|
|
299
363
|
let fromGroup = null;
|
|
300
364
|
if (followupTo?.startsWith('group_')) {
|
|
@@ -334,11 +398,20 @@ export class JobManager {
|
|
|
334
398
|
parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
|
|
335
399
|
}
|
|
336
400
|
|
|
401
|
+
if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
|
|
402
|
+
rawRequest = { ...rawRequest, target: parent.target };
|
|
403
|
+
}
|
|
404
|
+
// A fan-out's members were all handed the same brief, so any one of them holds it.
|
|
405
|
+
if (parent || fromGroup)
|
|
406
|
+
rawRequest = inherit(rawRequest, fromGroup ? (fromGroup.links[0].groupMember ?? fromGroup.links[0].parent) : parent);
|
|
407
|
+
|
|
337
408
|
const req = parseRequest(rawRequest, {
|
|
338
409
|
isFollowup: Boolean(parent || fromGroup),
|
|
339
410
|
parentMode: parent?.mode ?? fromGroup?.group.mode ?? null,
|
|
340
411
|
groupFollowup: Boolean(fromGroup),
|
|
341
412
|
});
|
|
413
|
+
// The operator's setting only ever closes: a request cannot reopen the web.
|
|
414
|
+
if (POLICY.network === 'off') req.network = 'off';
|
|
342
415
|
|
|
343
416
|
if (fromGroup && fromGroup.links.some((l, i) => l.parent.target !== req.targets[i])) {
|
|
344
417
|
throw new RequestError(
|
|
@@ -363,21 +436,36 @@ export class JobManager {
|
|
|
363
436
|
// Admit or refuse the whole fan-out: a half-started group would leave the
|
|
364
437
|
// lead comparing answers to a question only some consultants were asked.
|
|
365
438
|
// The cap counts every severally process on this home, through the seats.
|
|
366
|
-
|
|
439
|
+
// A follow-up's seat also names the chain and round it opens, so that two
|
|
440
|
+
// servers continuing one consultation at once cannot both take a round
|
|
441
|
+
// number: neither sees the other's round in its history until it ends.
|
|
442
|
+
const chainOf = (i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
|
|
443
|
+
const seats = req.targets.map((target, i) => ({
|
|
367
444
|
job_id: id('job'),
|
|
445
|
+
...(chainOf(i) ? { chain_id: chainOf(i).parent.chain_id, round: chainOf(i).round } : {}),
|
|
368
446
|
// Twice the budget plus an hour: the deadline is wall-clock, the budget is
|
|
369
447
|
// not, so a suspended laptop must not make a live owner look overdue.
|
|
370
448
|
owner: {
|
|
371
449
|
pid: process.pid,
|
|
372
|
-
deadline_at: new Date(
|
|
450
|
+
deadline_at: new Date(
|
|
451
|
+
Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000,
|
|
452
|
+
).toISOString(),
|
|
373
453
|
},
|
|
374
454
|
}));
|
|
375
455
|
const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
|
|
456
|
+
if (taken.chainBusy) {
|
|
457
|
+
const { live, mine } = taken.chainBusy;
|
|
458
|
+
throw new RequestError(
|
|
459
|
+
`another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}` +
|
|
460
|
+
'; wait for it (consult_get) and follow up the latest round',
|
|
461
|
+
'followup_not_latest',
|
|
462
|
+
);
|
|
463
|
+
}
|
|
376
464
|
if (!taken.ok) {
|
|
377
465
|
throw new RequestError(
|
|
378
|
-
`this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}`
|
|
379
|
-
|
|
380
|
-
|
|
466
|
+
`this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}` +
|
|
467
|
+
(taken.elsewhere ? `, and ${taken.elsewhere} of the ${taken.live} running are in other severally processes` : '') +
|
|
468
|
+
'. Wait for one to finish (consult_get) or cancel it',
|
|
381
469
|
'concurrency_limit',
|
|
382
470
|
);
|
|
383
471
|
}
|
|
@@ -404,7 +492,11 @@ export class JobManager {
|
|
|
404
492
|
for (const jid of group.job_ids) {
|
|
405
493
|
const member = this.jobs.get(jid) ?? store.loadRound(jid);
|
|
406
494
|
if (!member) {
|
|
407
|
-
skipped.push({
|
|
495
|
+
skipped.push({
|
|
496
|
+
job_id: jid,
|
|
497
|
+
target: null,
|
|
498
|
+
reason: 'not found: aged out of the history, or still running in another severally process',
|
|
499
|
+
});
|
|
408
500
|
continue;
|
|
409
501
|
}
|
|
410
502
|
const rounds = this.#chainRounds(member.chain_id);
|
|
@@ -413,10 +505,14 @@ export class JobManager {
|
|
|
413
505
|
// The name is kept, not re-resolved: a consultant renamed or turned off
|
|
414
506
|
// since is skipped, never continued by whichever one answers to it now.
|
|
415
507
|
const consultant = consultantByName(member.target);
|
|
416
|
-
const reason = !latest
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
508
|
+
const reason = !latest
|
|
509
|
+
? `${member.status}, so there is no answer to follow up`
|
|
510
|
+
: latest.status !== 'completed'
|
|
511
|
+
? `round ${latest.round} is still ${latest.status}`
|
|
512
|
+
: nextRound > POLICY.maxRounds
|
|
513
|
+
? `has used its ${POLICY.maxRounds} rounds`
|
|
514
|
+
: !consultant || !consultantAvailable(consultant)
|
|
515
|
+
? 'this consultant is no longer configured or available here'
|
|
420
516
|
: null;
|
|
421
517
|
if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
|
|
422
518
|
else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
|
|
@@ -441,7 +537,7 @@ export class JobManager {
|
|
|
441
537
|
// missing or a tree that blows the cap is the lead's to fix now rather
|
|
442
538
|
// than a job that fails having already spent a round of this chain.
|
|
443
539
|
let exposeManifest = null;
|
|
444
|
-
const parentEntries = req.context.expose_from_parent ? parent.brief?.expose_paths?.entries ?? [] : [];
|
|
540
|
+
const parentEntries = req.context.expose_from_parent ? (parent.brief?.expose_paths?.entries ?? []) : [];
|
|
445
541
|
if (req.context.expose_from_parent && parentEntries.length === 0) {
|
|
446
542
|
throw new RequestError(
|
|
447
543
|
`context.expose_from_parent: job "${parent.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
|
|
@@ -453,8 +549,11 @@ export class JobManager {
|
|
|
453
549
|
try {
|
|
454
550
|
fs.mkdirSync(path.join(snapshot, 'workspace'), { recursive: true, mode: 0o700 });
|
|
455
551
|
const plan = planInheritance(parentEntries, req.context.expose_paths);
|
|
456
|
-
exposeManifest = materializeExposedPaths(snapshot, plan.paths)
|
|
457
|
-
|
|
552
|
+
exposeManifest = materializeExposedPaths(snapshot, plan.paths) ?? {
|
|
553
|
+
entries: [],
|
|
554
|
+
totals: { files: 0, bytes: 0 },
|
|
555
|
+
skipped: [],
|
|
556
|
+
};
|
|
458
557
|
exposeManifest.skipped.push(...plan.skipped);
|
|
459
558
|
const parentSources = new Set(parentEntries.map((e) => e.source));
|
|
460
559
|
for (const e of exposeManifest.entries) if (parentSources.has(e.source)) e.inherited = true;
|
|
@@ -472,14 +571,21 @@ export class JobManager {
|
|
|
472
571
|
const room = Math.min(POLICY.input.artifactExcerptMax, POLICY.input.totalCharsMax - charCount(req));
|
|
473
572
|
const diff = exposeDiff(snapshot, req.context.expose_diff, exposeManifest, room);
|
|
474
573
|
if (diff) {
|
|
475
|
-
req.context.artifacts = [
|
|
476
|
-
|
|
574
|
+
req.context.artifacts = [
|
|
575
|
+
...req.context.artifacts,
|
|
576
|
+
{ name: `git diff ${exposeManifest.entries.at(-1).base}`, kind: 'diff', language: 'diff', excerpt: diff },
|
|
577
|
+
];
|
|
477
578
|
}
|
|
478
579
|
}
|
|
479
580
|
markSinceParent(exposeManifest, parentEntries);
|
|
480
581
|
// Every earlier path gone, and nothing else to go on: refused, as a
|
|
481
582
|
// first round with no context is.
|
|
482
|
-
if (
|
|
583
|
+
if (
|
|
584
|
+
parentEntries.length &&
|
|
585
|
+
!exposeManifest.entries.length &&
|
|
586
|
+
!req.context.facts.length &&
|
|
587
|
+
!req.context.artifacts.length
|
|
588
|
+
) {
|
|
483
589
|
throw new RequestError(
|
|
484
590
|
`context.expose_from_parent: nothing job "${parent.job_id}" exposed is there any more, and this request has no facts or artifacts`,
|
|
485
591
|
'nothing_to_inherit',
|
|
@@ -550,6 +656,7 @@ export class JobManager {
|
|
|
550
656
|
this.jobs.set(jobId, job);
|
|
551
657
|
this.order.push(jobId);
|
|
552
658
|
LIVE_HERE.add(jobId);
|
|
659
|
+
this.#watchCancels();
|
|
553
660
|
job._owner = seats[i].owner;
|
|
554
661
|
this.#mark(job);
|
|
555
662
|
return job;
|
|
@@ -564,7 +671,11 @@ export class JobManager {
|
|
|
564
671
|
job_ids: members.map((m) => m.job_id),
|
|
565
672
|
};
|
|
566
673
|
this.groups.set(groupId, group);
|
|
567
|
-
try {
|
|
674
|
+
try {
|
|
675
|
+
store.persistGroup(group);
|
|
676
|
+
} catch {
|
|
677
|
+
/* history is best effort */
|
|
678
|
+
}
|
|
568
679
|
|
|
569
680
|
// A fan-out is only comparable if every consultant answered the same
|
|
570
681
|
// question in the same words: every member's brief is the same request,
|
|
@@ -595,9 +706,12 @@ export class JobManager {
|
|
|
595
706
|
// consultation, but one about code that hands over no file is one the
|
|
596
707
|
// consultant answers without having seen the repository.
|
|
597
708
|
files_exposed: exposeManifest?.totals.files ?? 0,
|
|
598
|
-
...(exposeManifest?.totals.files
|
|
599
|
-
|
|
600
|
-
|
|
709
|
+
...(exposeManifest?.totals.files
|
|
710
|
+
? {}
|
|
711
|
+
: {
|
|
712
|
+
files_note:
|
|
713
|
+
'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',
|
|
714
|
+
}),
|
|
601
715
|
status: 'running',
|
|
602
716
|
accepted_at: group.created_at,
|
|
603
717
|
limits: limitsSummary(),
|
|
@@ -619,7 +733,11 @@ export class JobManager {
|
|
|
619
733
|
...common,
|
|
620
734
|
question: group.question, // the redacted text every member was actually sent
|
|
621
735
|
jobs: members.map((m) => ({
|
|
622
|
-
job_id: m.job_id,
|
|
736
|
+
job_id: m.job_id,
|
|
737
|
+
chain_id: m.chain_id,
|
|
738
|
+
target: m.target,
|
|
739
|
+
cli: m.cli,
|
|
740
|
+
model: m.model,
|
|
623
741
|
...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
|
|
624
742
|
})),
|
|
625
743
|
poll_with: `consult_get({ group_id: "${groupId}" })`,
|
|
@@ -645,10 +763,13 @@ export class JobManager {
|
|
|
645
763
|
const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
|
|
646
764
|
// The checked points first, then the unchecked ones, each with the
|
|
647
765
|
// lead's verdict and effect when there is one.
|
|
648
|
-
const ordered = (items, shape) =>
|
|
649
|
-
...items.filter((x) => verdicts.has(x.id)),
|
|
650
|
-
|
|
651
|
-
|
|
766
|
+
const ordered = (items, shape) =>
|
|
767
|
+
[...items.filter((x) => verdicts.has(x.id)), ...items.filter((x) => !verdicts.has(x.id))].map((x) => ({
|
|
768
|
+
id: x.id,
|
|
769
|
+
...shape(x),
|
|
770
|
+
verdict: verdicts.get(x.id)?.verdict ?? null,
|
|
771
|
+
effect: verdicts.get(x.id)?.effect ?? null,
|
|
772
|
+
}));
|
|
652
773
|
const checked = (x) => x.verdict !== null;
|
|
653
774
|
const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
|
|
654
775
|
return {
|
|
@@ -746,11 +867,11 @@ export class JobManager {
|
|
|
746
867
|
return this.#fail(
|
|
747
868
|
job,
|
|
748
869
|
'auth',
|
|
749
|
-
`no ${t.label} credential to hand the consultant: nothing at `
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
870
|
+
`no ${t.label} credential to hand the consultant: nothing at ` +
|
|
871
|
+
`${sandbox.credentialsSource}, and none of ${ALL_API_CREDENTIAL_VARS.join(' / ')} names a ` +
|
|
872
|
+
'usable credential. Log in with that CLI, point SEVERALLY_AGY_CRED_HOME at the home ' +
|
|
873
|
+
'directory that holds the token, or set one of those variables to authenticate with an API key ' +
|
|
874
|
+
'instead.',
|
|
754
875
|
);
|
|
755
876
|
}
|
|
756
877
|
|
|
@@ -782,8 +903,9 @@ export class JobManager {
|
|
|
782
903
|
// What this consultant was actually launched with, read off the
|
|
783
904
|
// invocation and the sandbox rather than restated from intent, so the
|
|
784
905
|
// record says what held for this run.
|
|
785
|
-
job.boundary =
|
|
786
|
-
hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
906
|
+
job.boundary =
|
|
907
|
+
adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
908
|
+
null;
|
|
787
909
|
|
|
788
910
|
const attempt = (timeout) => {
|
|
789
911
|
const { handle, done } = runChild({
|
|
@@ -825,7 +947,9 @@ export class JobManager {
|
|
|
825
947
|
if ((run.stalled || run.timedOut || run.cancelled) && adapter.diagnostics && sandbox) {
|
|
826
948
|
try {
|
|
827
949
|
job.diagnostics = store.writeDiagnostics(job.chain_id, job.round, redact(adapter.diagnostics({ run, sandbox })));
|
|
828
|
-
} catch {
|
|
950
|
+
} catch {
|
|
951
|
+
/* history is best effort */
|
|
952
|
+
}
|
|
829
953
|
}
|
|
830
954
|
if (run.spawnError) {
|
|
831
955
|
return this.#fail(job, 'spawn_error', `could not start ${invocation.command}: ${run.spawnError}`);
|
|
@@ -860,9 +984,12 @@ export class JobManager {
|
|
|
860
984
|
// No envelope at all: what stderr holds is the dump from stopping it,
|
|
861
985
|
// not a message to classify. Not re-run, as it may have spent its quota.
|
|
862
986
|
if (run.stalled && !interpreted.usageRaw) {
|
|
863
|
-
return this.#fail(
|
|
987
|
+
return this.#fail(
|
|
988
|
+
job,
|
|
989
|
+
'cli_error',
|
|
864
990
|
`${invocation.command} wrote its finish step but neither returned a result nor exited, and was stopped`,
|
|
865
|
-
adapter.progress?.(run) ?? null
|
|
991
|
+
adapter.progress?.(run) ?? null,
|
|
992
|
+
);
|
|
866
993
|
}
|
|
867
994
|
|
|
868
995
|
const remaining = budgetMs - (Date.now() - t0);
|
|
@@ -870,19 +997,35 @@ export class JobManager {
|
|
|
870
997
|
authTried = true;
|
|
871
998
|
const seeded = adapter.recoverAuthFailure({ interpreted, sandbox, model: job.model });
|
|
872
999
|
if (seeded && remaining >= MIN_RETRY_MS) {
|
|
873
|
-
job.retries.push({
|
|
1000
|
+
job.retries.push({
|
|
1001
|
+
failure_kind: interpreted.failureKind,
|
|
1002
|
+
reason: 'credentials re-seeded',
|
|
1003
|
+
at: new Date().toISOString(),
|
|
1004
|
+
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1005
|
+
});
|
|
874
1006
|
run = await attempt(remaining);
|
|
875
1007
|
continue;
|
|
876
1008
|
}
|
|
877
1009
|
}
|
|
878
1010
|
const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
|
|
879
|
-
if (
|
|
880
|
-
|
|
1011
|
+
if (
|
|
1012
|
+
AUTO_RETRY_KINDS.includes(interpreted.failureKind) &&
|
|
1013
|
+
autoRetries < POLICY.maxAutoRetries &&
|
|
1014
|
+
remaining - wait >= MIN_RETRY_MS
|
|
1015
|
+
) {
|
|
881
1016
|
autoRetries += 1;
|
|
882
|
-
job.retries.push({
|
|
1017
|
+
job.retries.push({
|
|
1018
|
+
failure_kind: interpreted.failureKind,
|
|
1019
|
+
reason: `transient; re-run after ${wait} ms`,
|
|
1020
|
+
at: new Date().toISOString(),
|
|
1021
|
+
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1022
|
+
});
|
|
883
1023
|
await new Promise((resolve) => {
|
|
884
1024
|
const timer = setTimeout(resolve, wait);
|
|
885
|
-
job._cancelFns.push(() => {
|
|
1025
|
+
job._cancelFns.push(() => {
|
|
1026
|
+
clearTimeout(timer);
|
|
1027
|
+
resolve();
|
|
1028
|
+
});
|
|
886
1029
|
});
|
|
887
1030
|
if (job.cancelRequested) return this.#fail(job, 'cancelled', 'consultation cancelled by the lead');
|
|
888
1031
|
run = await attempt(budgetMs - (Date.now() - t0));
|
|
@@ -891,8 +1034,12 @@ export class JobManager {
|
|
|
891
1034
|
break;
|
|
892
1035
|
}
|
|
893
1036
|
|
|
1037
|
+
// Without an answer, a process left holding the pipes is the failure to report.
|
|
1038
|
+
const leftover =
|
|
1039
|
+
run.pipesForced && `${invocation.command} exited, but a process it started kept its output open and was stopped`;
|
|
894
1040
|
if (!interpreted.ok) {
|
|
895
1041
|
job.usage = adapter.usageRecord(interpreted.usageRaw);
|
|
1042
|
+
if (leftover) return this.#fail(job, 'cli_error', leftover, interpreted.message);
|
|
896
1043
|
return this.#fail(job, interpreted.failureKind, interpreted.message);
|
|
897
1044
|
}
|
|
898
1045
|
|
|
@@ -916,6 +1063,7 @@ export class JobManager {
|
|
|
916
1063
|
}
|
|
917
1064
|
if (!normalized) {
|
|
918
1065
|
job.usage = adapter.usageRecord(interpreted.usageRaw);
|
|
1066
|
+
if (leftover) return this.#fail(job, 'cli_error', leftover, err.message);
|
|
919
1067
|
return this.#fail(job, 'invalid_output', err.message, redact(String(err.detail ?? '')).slice(0, 1200));
|
|
920
1068
|
}
|
|
921
1069
|
} else {
|
|
@@ -927,7 +1075,10 @@ export class JobManager {
|
|
|
927
1075
|
const notes = [
|
|
928
1076
|
adviceCaveat(normalized),
|
|
929
1077
|
sameVendorCaveat(job.caller, job.caller_model, consultant),
|
|
930
|
-
interpreted.recoveredError &&
|
|
1078
|
+
interpreted.recoveredError &&
|
|
1079
|
+
`the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
|
|
1080
|
+
leftover &&
|
|
1081
|
+
'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)',
|
|
931
1082
|
].filter(Boolean);
|
|
932
1083
|
// No `advice_usable` verdict here. It was always true, so it carried no
|
|
933
1084
|
// information -- and worse, it read as the server vouching for advice it
|
|
@@ -967,11 +1118,19 @@ export class JobManager {
|
|
|
967
1118
|
|
|
968
1119
|
// The in-progress marker other processes read (see store.writeRunning).
|
|
969
1120
|
#mark(job) {
|
|
970
|
-
try {
|
|
1121
|
+
try {
|
|
1122
|
+
store.writeRunning({ ...this.#record(job), owner: job._owner });
|
|
1123
|
+
} catch {
|
|
1124
|
+
/* history is best effort */
|
|
1125
|
+
}
|
|
971
1126
|
}
|
|
972
1127
|
|
|
973
1128
|
#finish(job) {
|
|
974
|
-
try {
|
|
1129
|
+
try {
|
|
1130
|
+
store.persistRound(this.#record(job));
|
|
1131
|
+
} catch {
|
|
1132
|
+
/* history is best effort */
|
|
1133
|
+
}
|
|
975
1134
|
store.removeRunning(job.job_id);
|
|
976
1135
|
store.releaseSeat(job.job_id);
|
|
977
1136
|
LIVE_HERE.delete(job.job_id);
|
|
@@ -986,16 +1145,17 @@ export class JobManager {
|
|
|
986
1145
|
// consultant did not produce and a word outside VERDICTS, then stores what
|
|
987
1146
|
// was written. It never decides that a finding was adopted, and it never
|
|
988
1147
|
// fills in a verdict the lead did not write.
|
|
1148
|
+
/** @param {Record<string, any>} [input] */
|
|
989
1149
|
record(input = {}) {
|
|
990
1150
|
if (input.records !== undefined) return this.#recordMany(input);
|
|
991
1151
|
const allowed = ['job_id', 'entries', 'reflection'];
|
|
992
1152
|
const unknown = Object.keys(input).filter((k) => !allowed.includes(k));
|
|
993
1153
|
if (unknown.length) {
|
|
994
1154
|
throw new RequestError(
|
|
995
|
-
`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records`
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1155
|
+
`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records` +
|
|
1156
|
+
(unknown.includes('prediction')
|
|
1157
|
+
? ". 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"
|
|
1158
|
+
: ''),
|
|
999
1159
|
'unknown_field',
|
|
1000
1160
|
);
|
|
1001
1161
|
}
|
|
@@ -1012,7 +1172,10 @@ export class JobManager {
|
|
|
1012
1172
|
#recordMany(input) {
|
|
1013
1173
|
const extra = Object.keys(input).filter((k) => k !== 'records');
|
|
1014
1174
|
if (extra.length) {
|
|
1015
|
-
throw new RequestError(
|
|
1175
|
+
throw new RequestError(
|
|
1176
|
+
`records cannot be combined with ${extra.map((k) => `"${k}"`).join(', ')}; put each job's verdicts inside records`,
|
|
1177
|
+
'unknown_field',
|
|
1178
|
+
);
|
|
1016
1179
|
}
|
|
1017
1180
|
const { records } = input;
|
|
1018
1181
|
if (!Array.isArray(records) || records.length === 0) {
|
|
@@ -1023,10 +1186,12 @@ export class JobManager {
|
|
|
1023
1186
|
const seen = new Set();
|
|
1024
1187
|
records.forEach((r, i) => {
|
|
1025
1188
|
try {
|
|
1026
|
-
if (seen.has(r?.job_id))
|
|
1189
|
+
if (seen.has(r?.job_id))
|
|
1190
|
+
throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
|
|
1027
1191
|
seen.add(r?.job_id);
|
|
1028
1192
|
const unknown = Object.keys(r ?? {}).filter((k) => !['job_id', 'entries', 'reflection'].includes(k));
|
|
1029
|
-
if (unknown.length)
|
|
1193
|
+
if (unknown.length)
|
|
1194
|
+
throw new RequestError(`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here`, 'unknown_field');
|
|
1030
1195
|
changes.push(this.#checkRecord(r ?? {}));
|
|
1031
1196
|
} catch (err) {
|
|
1032
1197
|
if (!(err instanceof RequestError)) throw err;
|
|
@@ -1047,9 +1212,13 @@ export class JobManager {
|
|
|
1047
1212
|
|
|
1048
1213
|
// Everything that can be wrong with a record, found before anything is
|
|
1049
1214
|
// written: what the job's record and reflection become, or a RequestError.
|
|
1215
|
+
/** @param {{job_id?: string, entries?: any[], reflection?: any}} rec */
|
|
1050
1216
|
#checkRecord({ job_id: jobId, entries, reflection }) {
|
|
1051
1217
|
if (entries === undefined && reflection === undefined) {
|
|
1052
|
-
throw new RequestError(
|
|
1218
|
+
throw new RequestError(
|
|
1219
|
+
'pass entries (verdicts on specific points), reflection (what the answer added), or both',
|
|
1220
|
+
'nothing_to_record',
|
|
1221
|
+
);
|
|
1053
1222
|
}
|
|
1054
1223
|
// A job this process ran is in memory; one from an earlier session is read
|
|
1055
1224
|
// back from its round file, so a verdict can still be written once the
|
|
@@ -1057,10 +1226,7 @@ export class JobManager {
|
|
|
1057
1226
|
const live = this.jobs.get(jobId);
|
|
1058
1227
|
const job = live ?? store.loadRound(jobId);
|
|
1059
1228
|
if (!job) {
|
|
1060
|
-
throw new RequestError(
|
|
1061
|
-
`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`,
|
|
1062
|
-
'unknown_job',
|
|
1063
|
-
);
|
|
1229
|
+
throw new RequestError(`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`, 'unknown_job');
|
|
1064
1230
|
}
|
|
1065
1231
|
if (!job.result) {
|
|
1066
1232
|
throw new RequestError(
|
|
@@ -1123,9 +1289,10 @@ export class JobManager {
|
|
|
1123
1289
|
newReflection = { delta, related_item_ids: related, recorded_at: now };
|
|
1124
1290
|
}
|
|
1125
1291
|
|
|
1126
|
-
const newRecord =
|
|
1127
|
-
|
|
1128
|
-
|
|
1292
|
+
const newRecord =
|
|
1293
|
+
entries === undefined
|
|
1294
|
+
? undefined
|
|
1295
|
+
: { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
|
|
1129
1296
|
return { job, live, record: newRecord, reflection: newReflection };
|
|
1130
1297
|
}
|
|
1131
1298
|
|
|
@@ -1183,16 +1350,32 @@ export class JobManager {
|
|
|
1183
1350
|
return view;
|
|
1184
1351
|
}
|
|
1185
1352
|
|
|
1353
|
+
// Cancels another server asked for (store.requestCancel), checked while
|
|
1354
|
+
// this one runs anything. ponytail: polled once a second, not signalled;
|
|
1355
|
+
// a watch on the slots directory if that delay ever matters.
|
|
1356
|
+
#watchCancels() {
|
|
1357
|
+
this._cancelWatch ??= setInterval(() => {
|
|
1358
|
+
for (const job of this.jobs.values()) {
|
|
1359
|
+
if ((job.status === 'running' || job.status === 'queued') && store.cancelRequested(job.job_id)) {
|
|
1360
|
+
store.clearCancel(job.job_id);
|
|
1361
|
+
this.cancel(job.job_id);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}, CANCEL_POLL_MS).unref();
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1186
1367
|
cancel(jobId) {
|
|
1187
1368
|
const job = this.jobs.get(jobId);
|
|
1188
1369
|
if (!job) {
|
|
1189
1370
|
const view = this.view(jobId);
|
|
1190
1371
|
if (!view) return null;
|
|
1372
|
+
if (view.status !== 'running' && view.status !== 'queued')
|
|
1373
|
+
return { ...view, cancel_effect: `job was already ${view.status}` };
|
|
1191
1374
|
return {
|
|
1192
1375
|
...view,
|
|
1193
|
-
cancel_effect:
|
|
1194
|
-
?
|
|
1195
|
-
:
|
|
1376
|
+
cancel_effect: store.requestCancel(jobId)
|
|
1377
|
+
? `asked the severally process running it to stop it; that takes up to ${CANCEL_POLL_MS / 1000} s, and consult_get shows when it has`
|
|
1378
|
+
: 'nothing signalled: the request to the severally process running it could not be written',
|
|
1196
1379
|
};
|
|
1197
1380
|
}
|
|
1198
1381
|
if (job.status !== 'running' && job.status !== 'queued') {
|
|
@@ -1207,6 +1390,7 @@ export class JobManager {
|
|
|
1207
1390
|
// Every field here is derived from the one `members` array it is handed, so a
|
|
1208
1391
|
// group view can never report one status in `members` and another in
|
|
1209
1392
|
// `comparison` or `status` for the same job.
|
|
1393
|
+
/** @param {any} group @param {any[]} members @param {{detail?: string}} [opts] */
|
|
1210
1394
|
#shapeGroup(group, members, { detail } = {}) {
|
|
1211
1395
|
const full = detail === 'full';
|
|
1212
1396
|
const cancelling = members.some((m) => m.status === 'cancelling');
|
|
@@ -1223,9 +1407,10 @@ export class JobManager {
|
|
|
1223
1407
|
// same thing three times. Hoist one copy to the group and strip it from
|
|
1224
1408
|
// the members, which keeps the payload readable without losing anything.
|
|
1225
1409
|
const firstBrief = members.find((m) => m.brief)?.brief ?? null;
|
|
1226
|
-
const groupBrief =
|
|
1227
|
-
|
|
1228
|
-
|
|
1410
|
+
const groupBrief =
|
|
1411
|
+
firstBrief && !full
|
|
1412
|
+
? { ...firstBrief, full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" }) or consult_export` }
|
|
1413
|
+
: firstBrief;
|
|
1229
1414
|
// The limits are the server's, not a member's: one copy, on the group.
|
|
1230
1415
|
// By default a member is only its state: what there is to compare is in
|
|
1231
1416
|
// `comparison`, once, and the full answer is one consult_get({ job_id }) away.
|
|
@@ -1233,7 +1418,8 @@ export class JobManager {
|
|
|
1233
1418
|
if (full) return rest;
|
|
1234
1419
|
const message = rest.failure && cut(rest.failure.message, FAILURE_MESSAGE_CHARS);
|
|
1235
1420
|
const progress = cut(rest.progress, FAILURE_MESSAGE_CHARS);
|
|
1236
|
-
const cutShort =
|
|
1421
|
+
const cutShort =
|
|
1422
|
+
progress !== rest.progress || Boolean(rest.failure && (message !== rest.failure.message || rest.failure.detail));
|
|
1237
1423
|
return {
|
|
1238
1424
|
job_id: rest.job_id,
|
|
1239
1425
|
target: rest.target,
|
|
@@ -1249,19 +1435,22 @@ export class JobManager {
|
|
|
1249
1435
|
const question = full ? group.question : cut(group.question, QUESTION_CHARS);
|
|
1250
1436
|
const view = {
|
|
1251
1437
|
group_id: group.group_id,
|
|
1252
|
-
status: cancelling ? 'cancelling' :
|
|
1438
|
+
status: cancelling ? 'cancelling' : live ? 'running' : 'done',
|
|
1253
1439
|
mode: group.mode,
|
|
1254
1440
|
question,
|
|
1255
|
-
...(question !== group.question
|
|
1441
|
+
...(question !== group.question
|
|
1442
|
+
? { question_full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" })` }
|
|
1443
|
+
: {}),
|
|
1256
1444
|
brief: groupBrief,
|
|
1257
1445
|
members_expected: group.job_ids.length,
|
|
1258
1446
|
members_available: members.length,
|
|
1259
1447
|
// History is capped, so an older member can already be gone. Say so:
|
|
1260
1448
|
// a fan-out that quietly reports fewer answers than it asked for is
|
|
1261
1449
|
// worse than one that admits the comparison is partial.
|
|
1262
|
-
incomplete_note:
|
|
1263
|
-
|
|
1264
|
-
|
|
1450
|
+
incomplete_note:
|
|
1451
|
+
missing > 0
|
|
1452
|
+
? `${missing} of ${group.job_ids.length} member consultation(s) have aged out of this session's history; the comparison below is partial`
|
|
1453
|
+
: null,
|
|
1265
1454
|
members: trimmed,
|
|
1266
1455
|
comparison: null,
|
|
1267
1456
|
cancellable: live,
|
|
@@ -1309,7 +1498,16 @@ export class JobManager {
|
|
|
1309
1498
|
if (!group) {
|
|
1310
1499
|
const view = this.groupView(groupId);
|
|
1311
1500
|
if (!view) return null;
|
|
1312
|
-
|
|
1501
|
+
// Started by another server: each member still running there is asked to stop.
|
|
1502
|
+
const asked = (store.loadGroup(groupId)?.job_ids ?? [])
|
|
1503
|
+
.map((jid) => this.cancel(jid))
|
|
1504
|
+
.filter((r) => /^asked/.test(r?.cancel_effect ?? '')).length;
|
|
1505
|
+
return {
|
|
1506
|
+
...view,
|
|
1507
|
+
cancel_effect: asked
|
|
1508
|
+
? `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`
|
|
1509
|
+
: 'nothing to signal: no consultant in this fan-out is still running',
|
|
1510
|
+
};
|
|
1313
1511
|
}
|
|
1314
1512
|
// Count what is actually about to be signalled before signalling it:
|
|
1315
1513
|
// cancel() short-circuits on a member that has already finished, so a
|
|
@@ -1325,11 +1523,12 @@ export class JobManager {
|
|
|
1325
1523
|
const total = group.job_ids.length;
|
|
1326
1524
|
return {
|
|
1327
1525
|
...this.#shapeGroup(group, members),
|
|
1328
|
-
cancel_effect:
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1526
|
+
cancel_effect:
|
|
1527
|
+
signalled === 0
|
|
1528
|
+
? 'nothing to signal: every consultant in this fan-out had already finished'
|
|
1529
|
+
: signalled === total
|
|
1530
|
+
? 'every consultant process group in this fan-out was signalled'
|
|
1531
|
+
: `${signalled} of ${total} consultant process groups in this fan-out were signalled; the rest had already finished`,
|
|
1333
1532
|
};
|
|
1334
1533
|
}
|
|
1335
1534
|
|
|
@@ -1345,7 +1544,10 @@ export class JobManager {
|
|
|
1345
1544
|
for (const job of live) {
|
|
1346
1545
|
job.cancelRequested = true;
|
|
1347
1546
|
for (const fn of job._cancelFns) fn();
|
|
1348
|
-
if (job._handle) {
|
|
1547
|
+
if (job._handle) {
|
|
1548
|
+
handles.push(job._handle);
|
|
1549
|
+
job._handle.stop();
|
|
1550
|
+
}
|
|
1349
1551
|
}
|
|
1350
1552
|
const settled = Promise.all(live.map((j) => j.promise).filter(Boolean));
|
|
1351
1553
|
await Promise.race([settled, new Promise((r) => setTimeout(r, graceMs).unref?.())]);
|
|
@@ -1392,8 +1594,7 @@ function inheritedSummary(manifest) {
|
|
|
1392
1594
|
return {
|
|
1393
1595
|
entries: carried.map((e) => ({ exposed_as: e.exposedAs, kind: e.kind, files: e.files, changed: e.changed ?? null })),
|
|
1394
1596
|
// Only what an earlier round showed carries the name it had there.
|
|
1395
|
-
left_out: manifest.skipped.filter((x) => x.exposed_as)
|
|
1396
|
-
.map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
|
|
1597
|
+
left_out: manifest.skipped.filter((x) => x.exposed_as).map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
|
|
1397
1598
|
files: carried.reduce((n, e) => n + e.files, 0),
|
|
1398
1599
|
};
|
|
1399
1600
|
}
|
|
@@ -1484,6 +1685,44 @@ function text(value) {
|
|
|
1484
1685
|
return t.length > POLICY.output.itemTextMax ? `${t.slice(0, POLICY.output.itemTextMax)}\n…[truncated by severally]` : t;
|
|
1485
1686
|
}
|
|
1486
1687
|
|
|
1688
|
+
// A follow-up restates what the round before was given unless it says
|
|
1689
|
+
// otherwise: the lead sends the new question, not the whole brief again. Only
|
|
1690
|
+
// a key left out is inherited -- one passed, even as an empty list, replaces
|
|
1691
|
+
// it -- and the validation that follows sees the result. Files are not:
|
|
1692
|
+
// copying them again stays expose_from_parent, named in the call (schema.mjs).
|
|
1693
|
+
function inherit(raw, round) {
|
|
1694
|
+
const { brief } = round;
|
|
1695
|
+
// A round kept off the web stays off: the material it carries over is the same.
|
|
1696
|
+
const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
|
|
1697
|
+
if (!brief) return out;
|
|
1698
|
+
for (const k of ['objective', 'success_criteria', 'constraints']) {
|
|
1699
|
+
if (
|
|
1700
|
+
out[k] === undefined &&
|
|
1701
|
+
raw.context?.[k] === undefined &&
|
|
1702
|
+
brief[k] != null &&
|
|
1703
|
+
!(Array.isArray(brief[k]) && !brief[k].length)
|
|
1704
|
+
)
|
|
1705
|
+
out[k] = brief[k];
|
|
1706
|
+
}
|
|
1707
|
+
const ctx = { ...(raw.context ?? {}) };
|
|
1708
|
+
for (const k of ['facts', 'proposal', 'counterpoints']) {
|
|
1709
|
+
if (ctx[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) ctx[k] = brief[k];
|
|
1710
|
+
}
|
|
1711
|
+
// The diff expose_diff added is not the lead's material: it is taken again
|
|
1712
|
+
// (or replaced) through expose_from_parent, against the files as they are now.
|
|
1713
|
+
// It is the last artifact (appended after the lead's own), so only that one is dropped.
|
|
1714
|
+
const artifacts = [...(brief.artifacts ?? [])];
|
|
1715
|
+
const last = artifacts.at(-1);
|
|
1716
|
+
if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff '))
|
|
1717
|
+
artifacts.pop();
|
|
1718
|
+
if (ctx.artifacts === undefined && artifacts.length) {
|
|
1719
|
+
// Stored with null for an absent language or source; the request schema takes them as optional strings.
|
|
1720
|
+
ctx.artifacts = artifacts.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v != null)));
|
|
1721
|
+
}
|
|
1722
|
+
out.context = ctx;
|
|
1723
|
+
return out;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1487
1726
|
function adviceCaveat(normalized) {
|
|
1488
1727
|
const { result, quality } = normalized;
|
|
1489
1728
|
const notes = [];
|
|
@@ -1491,12 +1730,17 @@ function adviceCaveat(normalized) {
|
|
|
1491
1730
|
notes.push('the consultant reported its evidence as insufficient: treat this as a request for more material, not as advice');
|
|
1492
1731
|
} else if (result.evidence_basis === 'thin') {
|
|
1493
1732
|
notes.push('the consultant reported thin evidence: weigh the findings accordingly');
|
|
1733
|
+
} else if (!result.evidence_basis) {
|
|
1734
|
+
notes.push('the consultant did not say how well its evidence supports the answer (evidence_basis)');
|
|
1494
1735
|
}
|
|
1736
|
+
// Read first in a comparison; a missing one is shown as null there, and named here.
|
|
1737
|
+
if (!result.stance)
|
|
1738
|
+
notes.push('the answer gives no bottom line (stance): read the summary and findings for one before relying on it');
|
|
1495
1739
|
if (quality.findings_without_grounds > 0) {
|
|
1496
1740
|
notes.push(`${quality.findings_without_grounds} finding(s) arrived without grounds: do not adopt them without checking`);
|
|
1497
1741
|
}
|
|
1498
1742
|
if (quality.unsourced && result.findings.length > 0) {
|
|
1499
|
-
notes.push(
|
|
1743
|
+
notes.push("no sources were cited; the findings rest on the consultant's own reasoning over the brief");
|
|
1500
1744
|
}
|
|
1501
1745
|
return notes.length ? notes.join('; ') : null;
|
|
1502
1746
|
}
|
|
@@ -1511,7 +1755,7 @@ function adviceCaveat(normalized) {
|
|
|
1511
1755
|
function sameVendorCaveat(caller, callerModel, consultant) {
|
|
1512
1756
|
// The lead's lineage: its declared model if that names one, else its CLI's
|
|
1513
1757
|
// own. An unknown lineage on either side is no caveat -- not a guess.
|
|
1514
|
-
const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (caller ? CLI_VENDORS[caller] ?? null : null);
|
|
1758
|
+
const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (caller ? (CLI_VENDORS[caller] ?? null) : null);
|
|
1515
1759
|
const vendor = consultant.vendor;
|
|
1516
1760
|
if (!callerVendor || !vendor || callerVendor !== vendor) return null;
|
|
1517
1761
|
// Say what a fresh session of the same lineage removes and what it keeps.
|
|
@@ -1555,7 +1799,7 @@ function comparison(members, budget) {
|
|
|
1555
1799
|
by_target: columns,
|
|
1556
1800
|
note:
|
|
1557
1801
|
'This server does not judge whether the consultants agree: similar summaries are not evidence of agreement. ' +
|
|
1558
|
-
|
|
1802
|
+
"Each stance is the consultant's own declaration, relayed as given -- consultants that share a finding can still " +
|
|
1559
1803
|
'declare opposite stances, and that is a divergence. Compare the grounds behind each point yourself, and spend a ' +
|
|
1560
1804
|
'follow-up only where a divergence would change your decision.',
|
|
1561
1805
|
};
|
|
@@ -1598,7 +1842,10 @@ function byTarget(m, budget) {
|
|
|
1598
1842
|
left -= n;
|
|
1599
1843
|
out.push(v);
|
|
1600
1844
|
}
|
|
1601
|
-
if (out.length < items.length) {
|
|
1845
|
+
if (out.length < items.length) {
|
|
1846
|
+
omitted[name] = items.length - out.length;
|
|
1847
|
+
truncated = true;
|
|
1848
|
+
}
|
|
1602
1849
|
return out;
|
|
1603
1850
|
};
|
|
1604
1851
|
const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
|
|
@@ -1608,15 +1855,31 @@ function byTarget(m, budget) {
|
|
|
1608
1855
|
// The findings first, with up to 60% of what is left and their points cut
|
|
1609
1856
|
// as short as it takes: they are what gets recorded against, so their ids
|
|
1610
1857
|
// matter most. The other lists then share the rest evenly.
|
|
1611
|
-
const findingPoints = list(
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1858
|
+
const findingPoints = list(
|
|
1859
|
+
'finding_points',
|
|
1860
|
+
r.findings,
|
|
1861
|
+
1,
|
|
1862
|
+
(f, n) => ({ id: f.id, severity: f.severity ?? null, point: clip(f.point, n) }),
|
|
1863
|
+
0.6,
|
|
1864
|
+
20,
|
|
1865
|
+
);
|
|
1866
|
+
const decisionChangers = list(
|
|
1867
|
+
'decision_changers',
|
|
1868
|
+
r.decision_changers,
|
|
1869
|
+
2,
|
|
1870
|
+
(d, n) => ({ condition: clip(d.condition, n), changes_to: clip(d.changes_to, n) }),
|
|
1871
|
+
1 / 4,
|
|
1872
|
+
);
|
|
1615
1873
|
const alternatives = list('alternative_options', r.alternatives, 1, (a, n) => clip(a.option, n), 1 / 3);
|
|
1616
1874
|
const unknowns = list('unknowns', r.unknowns, 1, (u, n) => ({ id: u.id, item: clip(u.item, n) }), 1 / 2);
|
|
1617
1875
|
const nextChecks = list('next_checks', r.next_checks, 1, (c, n) => ({ id: c.id, check: clip(c.check, n) }), 1 / 2);
|
|
1618
|
-
const disagreements = list(
|
|
1619
|
-
|
|
1876
|
+
const disagreements = list(
|
|
1877
|
+
'remaining_disagreements',
|
|
1878
|
+
r.remaining_disagreements,
|
|
1879
|
+
3,
|
|
1880
|
+
(d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
|
|
1881
|
+
1,
|
|
1882
|
+
);
|
|
1620
1883
|
const entry = {
|
|
1621
1884
|
job_id: m.job_id,
|
|
1622
1885
|
target: m.target,
|
|
@@ -1651,7 +1914,11 @@ function byTarget(m, budget) {
|
|
|
1651
1914
|
points_without_verdict: m.result ? (m.record?.unrecorded ?? recordableIds(m.result)).length : null,
|
|
1652
1915
|
};
|
|
1653
1916
|
// A member's status and failure are in `members`; say them once.
|
|
1654
|
-
if (!full) {
|
|
1917
|
+
if (!full) {
|
|
1918
|
+
delete entry.cli;
|
|
1919
|
+
delete entry.status;
|
|
1920
|
+
delete entry.failure_kind;
|
|
1921
|
+
}
|
|
1655
1922
|
if (Object.keys(omitted).length) entry.omitted = omitted;
|
|
1656
1923
|
if (truncated) {
|
|
1657
1924
|
entry.truncated = true;
|
|
@@ -1690,12 +1957,17 @@ export function pointsOf(view, ids) {
|
|
|
1690
1957
|
const known = recordableIds(r);
|
|
1691
1958
|
const unknown = ids.filter((id) => !known.includes(id));
|
|
1692
1959
|
if (unknown.length) {
|
|
1693
|
-
throw new RequestError(
|
|
1960
|
+
throw new RequestError(
|
|
1961
|
+
`${unknown.map((id) => `"${id}"`).join(', ')} not in this answer; it has: ${known.join(', ')}`,
|
|
1962
|
+
'unknown_entry_id',
|
|
1963
|
+
);
|
|
1694
1964
|
}
|
|
1695
1965
|
const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
|
|
1696
1966
|
const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
|
|
1697
1967
|
const points = [...new Set(ids)].map((id) => {
|
|
1698
|
-
const [kind, item] = Object.entries(lists)
|
|
1968
|
+
const [kind, item] = Object.entries(lists)
|
|
1969
|
+
.map(([k, l]) => [k, l.find((x) => x.id === id)])
|
|
1970
|
+
.find(([, x]) => x);
|
|
1699
1971
|
return { kind, ...item, recorded: verdicts.get(id) ?? null };
|
|
1700
1972
|
});
|
|
1701
1973
|
return {
|
|
@@ -1774,18 +2046,26 @@ function nextStep(job) {
|
|
|
1774
2046
|
// point showed, not when the answer arrives. Name the open ones until then.
|
|
1775
2047
|
const written = new Set((job.record?.entries ?? []).map((e) => e.id));
|
|
1776
2048
|
const open = job.result ? recordableIds(job.result).filter((id) => !written.has(id)).length : 0;
|
|
1777
|
-
const close =
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
2049
|
+
const close =
|
|
2050
|
+
open > 0
|
|
2051
|
+
? ` Then write what checking showed: consult_record({ job_id: "${job.job_id}", entries: [{ id, verdict, effect }] }) -- ${open} point(s) still carry no verdict.`
|
|
2052
|
+
: '';
|
|
2053
|
+
return (
|
|
2054
|
+
(left > 0
|
|
2055
|
+
? `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}").`
|
|
2056
|
+
: 'rounds exhausted: decide with what you have.') + close
|
|
2057
|
+
);
|
|
1783
2058
|
}
|
|
1784
|
-
if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error')
|
|
1785
|
-
|
|
1786
|
-
if (job.failure?.kind === '
|
|
1787
|
-
|
|
1788
|
-
if (job.failure?.kind === '
|
|
2059
|
+
if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error')
|
|
2060
|
+
return 'retriable: narrow the brief and start a new consultation';
|
|
2061
|
+
if (job.failure?.kind === 'interrupted')
|
|
2062
|
+
return 'the severally process running it exited before the consultant answered; start the consultation again if you still need it';
|
|
2063
|
+
if (job.failure?.kind === 'rate_limited')
|
|
2064
|
+
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';
|
|
2065
|
+
if (job.failure?.kind === 'usage_limit' || job.failure?.kind === 'auth')
|
|
2066
|
+
return 'not a consultation outcome: the consultant never answered. Proceed on your own judgement, or fix the credentials/quota first';
|
|
2067
|
+
if (job.failure?.kind === 'invalid_output')
|
|
2068
|
+
return 'the consultant answered but not in the required shape; retry once with a shorter brief before giving up';
|
|
1789
2069
|
return 'proceed on your own judgement and note that no peer input was obtained';
|
|
1790
2070
|
}
|
|
1791
2071
|
|