severally-mcp 2.0.0 → 2.3.1
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 +13 -7
- package/README.ja.md +157 -77
- package/README.md +162 -81
- package/bin/severally-mcp.mjs +3 -0
- package/config.example.json +26 -0
- package/docs/how-it-works.ja.md +11 -3
- package/docs/how-it-works.md +12 -4
- package/docs/install.ja.md +25 -6
- package/docs/install.md +30 -7
- package/package.json +5 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +4 -2
- package/plugins/severally/.mcp.json +2 -1
- package/plugins/severally/dist/severally-mcp.mjs +29639 -28215
- package/plugins/severally/skills/_template/SKILL.md.tmpl +19 -23
- package/plugins/severally/skills/_template/reference.md.tmpl +22 -10
- package/plugins/severally/skills/antigravity/severally/SKILL.md +19 -23
- package/plugins/severally/skills/antigravity/severally/reference.md +22 -10
- package/plugins/severally/skills/claude/severally/SKILL.md +19 -23
- package/plugins/severally/skills/claude/severally/reference.md +22 -10
- package/plugins/severally/skills/codex/severally/SKILL.md +19 -23
- package/plugins/severally/skills/codex/severally/reference.md +22 -10
- package/plugins/severally/skills/opencode/severally/SKILL.md +19 -23
- package/plugins/severally/skills/opencode/severally/reference.md +22 -10
- package/scripts/init-config.mjs +1 -1
- package/scripts/install.mjs +124 -9
- package/src/adapters/antigravity-sandbox.mjs +5 -1
- package/src/adapters/codex.mjs +17 -6
- package/src/brief.mjs +38 -16
- package/src/consultants.mjs +31 -10
- package/src/expose-paths.mjs +68 -5
- package/src/jobs.mjs +294 -56
- package/src/policy.mjs +49 -11
- package/src/redact.mjs +20 -1
- package/src/run.mjs +38 -5
- package/src/schema.mjs +35 -12
- package/src/server.mjs +96 -120
- package/src/status.mjs +63 -0
- package/src/store.mjs +44 -1
package/src/jobs.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { POLICY, VERDICTS, limitsSummary, timeoutMs, detectCaller, configProblem, consultantByName } from './policy.mjs';
|
|
7
|
+
import { POLICY, VERDICTS, limitsSummary, timeoutMs, detectCaller, configProblem, consultantByName, consultantAvailable } from './policy.mjs';
|
|
8
8
|
import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
|
|
9
9
|
import { parseRequest, RequestError, charCount } from './schema.mjs';
|
|
10
10
|
import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
|
|
@@ -27,6 +27,9 @@ const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
|
|
|
27
27
|
// anyway would silently extend the job's advertised wall-clock deadline.
|
|
28
28
|
const MIN_RETRY_MS = 5_000;
|
|
29
29
|
|
|
30
|
+
// How often a server looks for a cancel another server asked for.
|
|
31
|
+
const CANCEL_POLL_MS = 1_000;
|
|
32
|
+
|
|
30
33
|
// The second way a consultant can be authenticated. A linked token file and an
|
|
31
34
|
// API key / ADC file are alternatives, not both required, so the presence of
|
|
32
35
|
// either has to satisfy the credential check below. These are the names
|
|
@@ -226,14 +229,39 @@ export class JobManager {
|
|
|
226
229
|
return compactBrief(rec, detail);
|
|
227
230
|
}
|
|
228
231
|
|
|
232
|
+
// This session's consultations and the history's, so an earlier session's
|
|
233
|
+
// job_id and group_id can still be followed up and recorded against. The
|
|
234
|
+
// index only picks and orders; each row shown is read from its round, which
|
|
235
|
+
// holds the verdicts written since. Memory wins over disk for the same job.
|
|
229
236
|
list({ limit = 20 } = {}) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
237
|
+
const here = this.order.map((jid) => this.jobs.get(jid));
|
|
238
|
+
const seen = new Set(here.map((j) => j.job_id));
|
|
239
|
+
const elsewhere = [];
|
|
240
|
+
// Read only: a marker whose owner is gone is shown as interrupted, and
|
|
241
|
+
// left for consult_get or the startup sweep to close out.
|
|
242
|
+
for (const jobId of store.listRunning()) {
|
|
243
|
+
const live = seen.has(jobId) ? null : store.loadRunning(jobId);
|
|
244
|
+
if (!live) continue;
|
|
245
|
+
seen.add(jobId);
|
|
246
|
+
const state = ownerState(jobId, live.owner);
|
|
247
|
+
// A round saved just before its owner died is the answer, whatever the marker says.
|
|
248
|
+
const saved = state === 'alive' ? null : store.loadRoundAt(live);
|
|
249
|
+
elsewhere.push({ rec: state === 'alive' ? live : saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } } });
|
|
250
|
+
}
|
|
251
|
+
for (const row of store.indexRows()) if (!seen.has(row.job_id)) { seen.add(row.job_id); elsewhere.push({ row }); }
|
|
252
|
+
const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
|
|
253
|
+
const picked = [];
|
|
254
|
+
// Round files are read only for the rows shown; one that cannot be read is passed over.
|
|
255
|
+
for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
|
|
256
|
+
if (picked.length >= limit) break;
|
|
257
|
+
const j = x.row ? store.loadRoundAt(x.row) : x.rec ?? x;
|
|
258
|
+
if (j) picked.push(j);
|
|
259
|
+
}
|
|
260
|
+
return picked
|
|
261
|
+
.map((j) => {
|
|
235
262
|
return {
|
|
236
263
|
job_id: j.job_id,
|
|
264
|
+
group_id: j.group_id ?? null,
|
|
237
265
|
chain_id: j.chain_id,
|
|
238
266
|
round: j.round,
|
|
239
267
|
target: j.target,
|
|
@@ -246,7 +274,8 @@ export class JobManager {
|
|
|
246
274
|
duration_ms: j.duration_ms,
|
|
247
275
|
summary: j.result ? j.result.summary.slice(0, 200) : null,
|
|
248
276
|
recorded: Boolean(j.record),
|
|
249
|
-
verdicts: j.record ? tally(j.record.entries.map((e) => e.verdict)) : null,
|
|
277
|
+
verdicts: j.record?.entries ? tally(j.record.entries.map((e) => e.verdict)) : null,
|
|
278
|
+
this_session: this.jobs.has(j.job_id),
|
|
250
279
|
predicted: Boolean(j.prediction),
|
|
251
280
|
reflected: Boolean(j.reflection),
|
|
252
281
|
initiator: j.initiator ?? null,
|
|
@@ -296,7 +325,19 @@ export class JobManager {
|
|
|
296
325
|
? rawRequest.followup_to.trim()
|
|
297
326
|
: null;
|
|
298
327
|
let parent = null;
|
|
299
|
-
|
|
328
|
+
let fromGroup = null;
|
|
329
|
+
if (followupTo?.startsWith('group_')) {
|
|
330
|
+
// A follow-up to a fan-out goes to every member that can take one; the
|
|
331
|
+
// members are the targets, so the request names none.
|
|
332
|
+
if ((rawRequest.target ?? null) !== null || rawRequest.targets?.length) {
|
|
333
|
+
throw new RequestError(
|
|
334
|
+
'a follow-up to a group goes to every member that can take one, so leave target and targets out; to follow up one member, pass its job_id',
|
|
335
|
+
'followup_group_targets',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
fromGroup = this.#groupParents(followupTo);
|
|
339
|
+
rawRequest = { ...rawRequest, targets: fromGroup.links.map((l) => l.parent.target) };
|
|
340
|
+
} else if (followupTo) {
|
|
300
341
|
// A job from an earlier server session is read back from the history,
|
|
301
342
|
// so a restart does not end a consultation the lead is still checking.
|
|
302
343
|
parent = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
|
|
@@ -322,8 +363,26 @@ export class JobManager {
|
|
|
322
363
|
parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
|
|
323
364
|
}
|
|
324
365
|
|
|
325
|
-
|
|
366
|
+
if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
|
|
367
|
+
rawRequest = { ...rawRequest, target: parent.target };
|
|
368
|
+
}
|
|
369
|
+
// A fan-out's members were all handed the same brief, so any one of them holds it.
|
|
370
|
+
if (parent || fromGroup) rawRequest = inherit(rawRequest, fromGroup ? fromGroup.links[0].groupMember ?? fromGroup.links[0].parent : parent);
|
|
371
|
+
|
|
372
|
+
const req = parseRequest(rawRequest, {
|
|
373
|
+
isFollowup: Boolean(parent || fromGroup),
|
|
374
|
+
parentMode: parent?.mode ?? fromGroup?.group.mode ?? null,
|
|
375
|
+
groupFollowup: Boolean(fromGroup),
|
|
376
|
+
});
|
|
377
|
+
// The operator's setting only ever closes: a request cannot reopen the web.
|
|
378
|
+
if (POLICY.network === 'off') req.network = 'off';
|
|
326
379
|
|
|
380
|
+
if (fromGroup && fromGroup.links.some((l, i) => l.parent.target !== req.targets[i])) {
|
|
381
|
+
throw new RequestError(
|
|
382
|
+
`the members of group "${followupTo}" no longer resolve to the consultants that answered them; follow up each member by its job_id`,
|
|
383
|
+
'followup_target_mismatch',
|
|
384
|
+
);
|
|
385
|
+
}
|
|
327
386
|
if (parent && parent.target !== req.target) {
|
|
328
387
|
throw new RequestError(
|
|
329
388
|
`follow-up must go to the same consultant as job "${parent.job_id}" (${parent.target})`,
|
|
@@ -331,7 +390,7 @@ export class JobManager {
|
|
|
331
390
|
);
|
|
332
391
|
}
|
|
333
392
|
const round = parent ? parent.nextRound : 1;
|
|
334
|
-
if (round > POLICY.maxRounds) {
|
|
393
|
+
if (!fromGroup && round > POLICY.maxRounds) {
|
|
335
394
|
throw new RequestError(
|
|
336
395
|
`this consultation has used its ${POLICY.maxRounds} rounds (1 initial + ${POLICY.maxRounds - 1} follow-ups); decide with what you have, or start a new consultation with a materially different question`,
|
|
337
396
|
'round_limit',
|
|
@@ -341,8 +400,13 @@ export class JobManager {
|
|
|
341
400
|
// Admit or refuse the whole fan-out: a half-started group would leave the
|
|
342
401
|
// lead comparing answers to a question only some consultants were asked.
|
|
343
402
|
// The cap counts every severally process on this home, through the seats.
|
|
344
|
-
|
|
403
|
+
// A follow-up's seat also names the chain and round it opens, so that two
|
|
404
|
+
// servers continuing one consultation at once cannot both take a round
|
|
405
|
+
// 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) => ({
|
|
345
408
|
job_id: id('job'),
|
|
409
|
+
...(chainOf(i) ? { chain_id: chainOf(i).parent.chain_id, round: chainOf(i).round } : {}),
|
|
346
410
|
// Twice the budget plus an hour: the deadline is wall-clock, the budget is
|
|
347
411
|
// not, so a suspended laptop must not make a live owner look overdue.
|
|
348
412
|
owner: {
|
|
@@ -351,6 +415,14 @@ export class JobManager {
|
|
|
351
415
|
},
|
|
352
416
|
}));
|
|
353
417
|
const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
|
|
418
|
+
if (taken.chainBusy) {
|
|
419
|
+
const { live, mine } = taken.chainBusy;
|
|
420
|
+
throw new RequestError(
|
|
421
|
+
`another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}`
|
|
422
|
+
+ '; wait for it (consult_get) and follow up the latest round',
|
|
423
|
+
'followup_not_latest',
|
|
424
|
+
);
|
|
425
|
+
}
|
|
354
426
|
if (!taken.ok) {
|
|
355
427
|
throw new RequestError(
|
|
356
428
|
`this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}`
|
|
@@ -361,14 +433,57 @@ export class JobManager {
|
|
|
361
433
|
}
|
|
362
434
|
// A request refused from here on gives its seats back.
|
|
363
435
|
try {
|
|
364
|
-
|
|
436
|
+
// Each target's parent round and the round it opens, in req.targets order.
|
|
437
|
+
const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
|
|
438
|
+
return this.#admitted(req, links, seats, fromGroup);
|
|
365
439
|
} catch (err) {
|
|
366
440
|
for (const seat of seats) if (!this.jobs.has(seat.job_id)) store.releaseSeat(seat.job_id);
|
|
367
441
|
throw err;
|
|
368
442
|
}
|
|
369
443
|
}
|
|
370
444
|
|
|
371
|
-
|
|
445
|
+
// A follow-up to a fan-out: each member goes on from the newest answered
|
|
446
|
+
// round of its own chain. A member that is still running, never answered or
|
|
447
|
+
// has used its rounds is skipped and named; if that is every member, the
|
|
448
|
+
// request is refused.
|
|
449
|
+
#groupParents(groupId) {
|
|
450
|
+
const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
|
|
451
|
+
if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, 'unknown_group');
|
|
452
|
+
const links = [];
|
|
453
|
+
const skipped = [];
|
|
454
|
+
for (const jid of group.job_ids) {
|
|
455
|
+
const member = this.jobs.get(jid) ?? store.loadRound(jid);
|
|
456
|
+
if (!member) {
|
|
457
|
+
skipped.push({ job_id: jid, target: null, reason: 'not found: aged out of the history, or still running in another severally process' });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const rounds = this.#chainRounds(member.chain_id);
|
|
461
|
+
const latest = rounds.filter((j) => !['failed', 'cancelled'].includes(j.status)).sort((a, b) => b.round - a.round)[0];
|
|
462
|
+
const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
|
|
463
|
+
// The name is kept, not re-resolved: a consultant renamed or turned off
|
|
464
|
+
// since is skipped, never continued by whichever one answers to it now.
|
|
465
|
+
const consultant = consultantByName(member.target);
|
|
466
|
+
const reason = !latest ? `${member.status}, so there is no answer to follow up`
|
|
467
|
+
: latest.status !== 'completed' ? `round ${latest.round} is still ${latest.status}`
|
|
468
|
+
: nextRound > POLICY.maxRounds ? `has used its ${POLICY.maxRounds} rounds`
|
|
469
|
+
: !consultant || !consultantAvailable(consultant) ? 'this consultant is no longer configured or available here'
|
|
470
|
+
: null;
|
|
471
|
+
if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
|
|
472
|
+
else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
|
|
473
|
+
}
|
|
474
|
+
if (!links.length) {
|
|
475
|
+
throw new RequestError(
|
|
476
|
+
`no member of group "${groupId}" can take a follow-up: ${skipped.map((x) => `${x.target ?? x.job_id} (${x.reason})`).join(', ')}`,
|
|
477
|
+
'followup_nothing_to_continue',
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return { group, links, skipped };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#admitted(req, links, seats, fromGroup = null) {
|
|
484
|
+
// What expose_from_parent copies again: for a group, what that fan-out
|
|
485
|
+
// exposed (one manifest, shared by its members); otherwise the parent's.
|
|
486
|
+
const parent = links[0].groupMember ?? links[0].parent;
|
|
372
487
|
const groupId = id('group');
|
|
373
488
|
// Copied once for the whole request, before a round is counted or a job
|
|
374
489
|
// exists, into the group's own directory: every member is then handed the
|
|
@@ -428,7 +543,8 @@ export class JobManager {
|
|
|
428
543
|
req._snapshot = path.join(snapshot, 'workspace');
|
|
429
544
|
}
|
|
430
545
|
const members = req.targets.map((target, i) => {
|
|
431
|
-
const
|
|
546
|
+
const { parent: from, round } = links[i];
|
|
547
|
+
const chainId = from ? from.chain_id : id('chain');
|
|
432
548
|
const jobId = seats[i].job_id;
|
|
433
549
|
const job = {
|
|
434
550
|
job_id: jobId,
|
|
@@ -462,7 +578,7 @@ export class JobManager {
|
|
|
462
578
|
retries: [],
|
|
463
579
|
partial_output: null,
|
|
464
580
|
tool_calls: null,
|
|
465
|
-
followup_to:
|
|
581
|
+
followup_to: from?.job_id ?? null,
|
|
466
582
|
status: 'queued',
|
|
467
583
|
// What this consultant is: the CLI it runs on, the model and its
|
|
468
584
|
// lineage, as the operator defined it.
|
|
@@ -484,6 +600,7 @@ export class JobManager {
|
|
|
484
600
|
this.jobs.set(jobId, job);
|
|
485
601
|
this.order.push(jobId);
|
|
486
602
|
LIVE_HERE.add(jobId);
|
|
603
|
+
this.#watchCancels();
|
|
487
604
|
job._owner = seats[i].owner;
|
|
488
605
|
this.#mark(job);
|
|
489
606
|
return job;
|
|
@@ -500,14 +617,13 @@ export class JobManager {
|
|
|
500
617
|
this.groups.set(groupId, group);
|
|
501
618
|
try { store.persistGroup(group); } catch { /* history is best effort */ }
|
|
502
619
|
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// follow-up
|
|
507
|
-
// empty, and a follow-up is always a group of one on the parent's chain.
|
|
508
|
-
const chain = { round, priorRounds: this.#chainHistory(members[0].chain_id) };
|
|
509
|
-
const brief = renderBrief(req, chain, exposeManifest);
|
|
620
|
+
// A fan-out is only comparable if every consultant answered the same
|
|
621
|
+
// question in the same words: every member's brief is the same request,
|
|
622
|
+
// rendered the same way. Only the earlier rounds differ -- each member's
|
|
623
|
+
// own, on a follow-up to a group; none, on a first round.
|
|
510
624
|
for (const job of members) {
|
|
625
|
+
const chain = { round: job.round, priorRounds: this.#chainHistory(job.chain_id) };
|
|
626
|
+
const brief = renderBrief(req, chain, exposeManifest);
|
|
511
627
|
job.promise = this.#execute(job, { ...req, target: job.target }, chain, brief).catch((err) => {
|
|
512
628
|
this.#fail(job, 'cli_error', err?.message ?? String(err));
|
|
513
629
|
});
|
|
@@ -517,11 +633,14 @@ export class JobManager {
|
|
|
517
633
|
// if the process dies mid-consultation. Keep that copy ahead of any await.
|
|
518
634
|
if (req._snapshot) store.cleanupJobDir(groupId);
|
|
519
635
|
|
|
636
|
+
// The members of a follow-up to a group can be at different rounds.
|
|
637
|
+
const { round } = members[0];
|
|
638
|
+
const sameRound = members.every((m) => m.round === round);
|
|
520
639
|
const common = {
|
|
521
640
|
group_id: groupId,
|
|
522
|
-
round,
|
|
641
|
+
...(sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {}),
|
|
642
|
+
...(fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {}),
|
|
523
643
|
...(parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
|
|
524
|
-
rounds_remaining: POLICY.maxRounds - round,
|
|
525
644
|
mode: req.mode,
|
|
526
645
|
// Counted, not refused: a brief of facts alone is a legitimate
|
|
527
646
|
// consultation, but one about code that hands over no file is one the
|
|
@@ -550,7 +669,10 @@ export class JobManager {
|
|
|
550
669
|
return {
|
|
551
670
|
...common,
|
|
552
671
|
question: group.question, // the redacted text every member was actually sent
|
|
553
|
-
jobs: members.map((m) => ({
|
|
672
|
+
jobs: members.map((m) => ({
|
|
673
|
+
job_id: m.job_id, chain_id: m.chain_id, target: m.target, cli: m.cli, model: m.model,
|
|
674
|
+
...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
|
|
675
|
+
})),
|
|
554
676
|
poll_with: `consult_get({ group_id: "${groupId}" })`,
|
|
555
677
|
note: 'Every consultant got the identical brief. When they come back, compare the grounds behind the points that differ; matching summaries are not evidence of agreement.',
|
|
556
678
|
};
|
|
@@ -558,9 +680,8 @@ export class JobManager {
|
|
|
558
680
|
|
|
559
681
|
// The rounds before a follow-up, from the history on disk and from memory
|
|
560
682
|
// (memory wins: it can hold a verdict written since the round was saved).
|
|
561
|
-
// Each
|
|
562
|
-
// knowing which of its points were checked and how they came out.
|
|
563
|
-
// finding that carries a verdict is passed; of the rest, the first five.
|
|
683
|
+
// Each point goes with the lead's verdict on it, so the consultant answers
|
|
684
|
+
// knowing which of its points were checked and how they came out.
|
|
564
685
|
#chainRounds(chainId) {
|
|
565
686
|
const byRound = new Map(store.loadChain(chainId).map((j) => [j.round, j]));
|
|
566
687
|
for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
|
|
@@ -573,11 +694,24 @@ export class JobManager {
|
|
|
573
694
|
.sort((a, b) => a.round - b.round)
|
|
574
695
|
.map((j) => {
|
|
575
696
|
const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
.
|
|
580
|
-
|
|
697
|
+
// The checked points first, then the unchecked ones, each with the
|
|
698
|
+
// lead's verdict and effect when there is one.
|
|
699
|
+
const ordered = (items, shape) => [
|
|
700
|
+
...items.filter((x) => verdicts.has(x.id)),
|
|
701
|
+
...items.filter((x) => !verdicts.has(x.id)),
|
|
702
|
+
].map((x) => ({ id: x.id, ...shape(x), verdict: verdicts.get(x.id)?.verdict ?? null, effect: verdicts.get(x.id)?.effect ?? null }));
|
|
703
|
+
const checked = (x) => x.verdict !== null;
|
|
704
|
+
const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
|
|
705
|
+
return {
|
|
706
|
+
round: j.round,
|
|
707
|
+
mode: j.mode,
|
|
708
|
+
question: j.question,
|
|
709
|
+
summary: j.result.summary,
|
|
710
|
+
// Every checked finding; of the rest, the first five.
|
|
711
|
+
findings: [...findings.filter(checked), ...findings.filter((f) => !checked(f)).slice(0, 5)],
|
|
712
|
+
unknowns: ordered(j.result.unknowns, (u) => ({ point: u.item })).slice(0, 5),
|
|
713
|
+
next_checks: ordered(j.result.next_checks, (c) => ({ point: c.check })).slice(0, 5),
|
|
714
|
+
};
|
|
581
715
|
});
|
|
582
716
|
}
|
|
583
717
|
|
|
@@ -621,8 +755,9 @@ export class JobManager {
|
|
|
621
755
|
return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err.message}`);
|
|
622
756
|
}
|
|
623
757
|
}
|
|
624
|
-
// start() renders the brief
|
|
625
|
-
//
|
|
758
|
+
// start() renders the brief and hands it in: every member of a fan-out is
|
|
759
|
+
// sent the same request in the same words, with only its own earlier rounds
|
|
760
|
+
// added on a follow-up to the group.
|
|
626
761
|
const text = brief ?? renderBrief(req, chain);
|
|
627
762
|
const schemaPath = store.writeJobArtifact(job.job_id, 'response-schema.json', JSON.stringify(CONSULT_RESULT_SCHEMA, null, 2));
|
|
628
763
|
const sandbox = adapter.prepareSandbox ? adapter.prepareSandbox({ workdir, network: req.network }) : null;
|
|
@@ -766,6 +901,12 @@ export class JobManager {
|
|
|
766
901
|
}
|
|
767
902
|
const lastMessageText = invocation.lastMessagePath ? store.readIfExists(invocation.lastMessagePath) : '';
|
|
768
903
|
interpreted = adapter.interpret({ ...run, lastMessageText });
|
|
904
|
+
// An answer after an error the CLI reported is kept only if it parses;
|
|
905
|
+
// otherwise it is that error, re-run like any other.
|
|
906
|
+
const { recoveredError } = interpreted;
|
|
907
|
+
if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
|
|
908
|
+
interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
|
|
909
|
+
}
|
|
769
910
|
if (interpreted.ok) break;
|
|
770
911
|
// No envelope at all: what stderr holds is the dump from stopping it,
|
|
771
912
|
// not a message to classify. Not re-run, as it may have spent its quota.
|
|
@@ -801,8 +942,12 @@ export class JobManager {
|
|
|
801
942
|
break;
|
|
802
943
|
}
|
|
803
944
|
|
|
945
|
+
// Without an answer, a process left holding the pipes is the failure to report.
|
|
946
|
+
const leftover = run.pipesForced
|
|
947
|
+
&& `${invocation.command} exited, but a process it started kept its output open and was stopped`;
|
|
804
948
|
if (!interpreted.ok) {
|
|
805
949
|
job.usage = adapter.usageRecord(interpreted.usageRaw);
|
|
950
|
+
if (leftover) return this.#fail(job, 'cli_error', leftover, interpreted.message);
|
|
806
951
|
return this.#fail(job, interpreted.failureKind, interpreted.message);
|
|
807
952
|
}
|
|
808
953
|
|
|
@@ -826,6 +971,7 @@ export class JobManager {
|
|
|
826
971
|
}
|
|
827
972
|
if (!normalized) {
|
|
828
973
|
job.usage = adapter.usageRecord(interpreted.usageRaw);
|
|
974
|
+
if (leftover) return this.#fail(job, 'cli_error', leftover, err.message);
|
|
829
975
|
return this.#fail(job, 'invalid_output', err.message, redact(String(err.detail ?? '')).slice(0, 1200));
|
|
830
976
|
}
|
|
831
977
|
} else {
|
|
@@ -834,7 +980,12 @@ export class JobManager {
|
|
|
834
980
|
}
|
|
835
981
|
|
|
836
982
|
job.result = normalized.result;
|
|
837
|
-
const notes = [
|
|
983
|
+
const notes = [
|
|
984
|
+
adviceCaveat(normalized),
|
|
985
|
+
sameVendorCaveat(job.caller, job.caller_model, consultant),
|
|
986
|
+
interpreted.recoveredError && `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
|
|
987
|
+
leftover && '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)',
|
|
988
|
+
].filter(Boolean);
|
|
838
989
|
// No `advice_usable` verdict here. It was always true, so it carried no
|
|
839
990
|
// information -- and worse, it read as the server vouching for advice it
|
|
840
991
|
// cannot judge. What this server may assert is what it can derive from
|
|
@@ -1054,49 +1205,66 @@ export class JobManager {
|
|
|
1054
1205
|
message: `the verdict for "${job.job_id}" could not be written to the history (${e.message}); record it again`,
|
|
1055
1206
|
};
|
|
1056
1207
|
}
|
|
1208
|
+
// Counts only: the entries are what the lead just wrote, and
|
|
1209
|
+
// consult_get({ job_id, ids }) reads any of them back with its verdict.
|
|
1210
|
+
const summary = job.record && recordSummary(job);
|
|
1057
1211
|
return {
|
|
1058
1212
|
job_id: job.job_id,
|
|
1059
1213
|
chain_id: job.chain_id,
|
|
1060
1214
|
round: job.round,
|
|
1061
|
-
record:
|
|
1062
|
-
|
|
1063
|
-
reflection: job.reflection ?? null,
|
|
1215
|
+
record: summary ? { updated_at: summary.updated_at, verdicts: summary.verdicts, coverage: summary.coverage } : null,
|
|
1216
|
+
reflection_saved: job.reflection != null,
|
|
1064
1217
|
};
|
|
1065
1218
|
}
|
|
1066
1219
|
|
|
1067
|
-
async wait(jobId, waitMs, opts) {
|
|
1220
|
+
async wait(jobId, waitMs, opts, signal) {
|
|
1068
1221
|
const job = this.jobs.get(jobId);
|
|
1069
|
-
if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs);
|
|
1222
|
+
if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
|
|
1070
1223
|
const budget = Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1071
1224
|
if (budget === 0 || job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
|
|
1072
1225
|
return this.view(jobId, opts);
|
|
1073
1226
|
}
|
|
1074
|
-
await
|
|
1227
|
+
await settled(job.promise, budget, signal);
|
|
1075
1228
|
return this.view(jobId, opts);
|
|
1076
1229
|
}
|
|
1077
1230
|
|
|
1078
1231
|
// A consultation another process runs has no promise here, so its history
|
|
1079
1232
|
// is re-read once a second until it is no longer running or the wait is up.
|
|
1080
|
-
async #waitOnDisk(read, waitMs) {
|
|
1233
|
+
async #waitOnDisk(read, waitMs, signal) {
|
|
1081
1234
|
const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1082
1235
|
let view = read();
|
|
1083
|
-
while (view && ['running', 'queued'].includes(view.status) && Date.now() < until) {
|
|
1084
|
-
await
|
|
1236
|
+
while (view && ['running', 'queued'].includes(view.status) && Date.now() < until && !signal?.aborted) {
|
|
1237
|
+
await settled(null, Math.min(1000, until - Date.now()), signal);
|
|
1085
1238
|
view = read();
|
|
1086
1239
|
}
|
|
1087
1240
|
return view;
|
|
1088
1241
|
}
|
|
1089
1242
|
|
|
1243
|
+
// Cancels another server asked for (store.requestCancel), checked while
|
|
1244
|
+
// this one runs anything. ponytail: polled once a second, not signalled;
|
|
1245
|
+
// a watch on the slots directory if that delay ever matters.
|
|
1246
|
+
#watchCancels() {
|
|
1247
|
+
this._cancelWatch ??= setInterval(() => {
|
|
1248
|
+
for (const job of this.jobs.values()) {
|
|
1249
|
+
if ((job.status === 'running' || job.status === 'queued') && store.cancelRequested(job.job_id)) {
|
|
1250
|
+
store.clearCancel(job.job_id);
|
|
1251
|
+
this.cancel(job.job_id);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}, CANCEL_POLL_MS).unref();
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1090
1257
|
cancel(jobId) {
|
|
1091
1258
|
const job = this.jobs.get(jobId);
|
|
1092
1259
|
if (!job) {
|
|
1093
1260
|
const view = this.view(jobId);
|
|
1094
1261
|
if (!view) return null;
|
|
1262
|
+
if (view.status !== 'running' && view.status !== 'queued') return { ...view, cancel_effect: `job was already ${view.status}` };
|
|
1095
1263
|
return {
|
|
1096
1264
|
...view,
|
|
1097
|
-
cancel_effect:
|
|
1098
|
-
?
|
|
1099
|
-
:
|
|
1265
|
+
cancel_effect: store.requestCancel(jobId)
|
|
1266
|
+
? `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`
|
|
1267
|
+
: 'nothing signalled: the request to the severally process running it could not be written',
|
|
1100
1268
|
};
|
|
1101
1269
|
}
|
|
1102
1270
|
if (job.status !== 'running' && job.status !== 'queued') {
|
|
@@ -1182,7 +1350,10 @@ export class JobManager {
|
|
|
1182
1350
|
limits: live || !full ? null : limitsSummary(),
|
|
1183
1351
|
};
|
|
1184
1352
|
// The side-by-side gets what the rest of the response leaves of GROUP_CHARS.
|
|
1185
|
-
|
|
1353
|
+
// While no one has finished there is nothing to set side by side, and an
|
|
1354
|
+
// empty column per consultant is only context spent on every poll.
|
|
1355
|
+
const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
|
|
1356
|
+
if (anyFinished) view.comparison = comparison(members, full ? Infinity : GROUP_CHARS - printed(view));
|
|
1186
1357
|
return view;
|
|
1187
1358
|
}
|
|
1188
1359
|
|
|
@@ -1192,18 +1363,15 @@ export class JobManager {
|
|
|
1192
1363
|
return this.#shapeGroup(group, group.job_ids.map((jid) => this.view(jid, opts)).filter(Boolean), opts);
|
|
1193
1364
|
}
|
|
1194
1365
|
|
|
1195
|
-
async waitGroup(groupId, waitMs, opts) {
|
|
1366
|
+
async waitGroup(groupId, waitMs, opts, signal) {
|
|
1196
1367
|
const group = this.groups.get(groupId);
|
|
1197
1368
|
// A fan-out from another process or an earlier session: nothing here to
|
|
1198
1369
|
// wait on, so report what the history holds.
|
|
1199
|
-
if (!group) return this.#waitOnDisk(() => this.groupView(groupId, opts), waitMs);
|
|
1370
|
+
if (!group) return this.#waitOnDisk(() => this.groupView(groupId, opts), waitMs, signal);
|
|
1200
1371
|
const budget = Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1201
1372
|
if (budget > 0) {
|
|
1202
1373
|
const promises = group.job_ids.map((jid) => this.jobs.get(jid)?.promise).filter(Boolean);
|
|
1203
|
-
await Promise.
|
|
1204
|
-
Promise.all(promises),
|
|
1205
|
-
new Promise((r) => setTimeout(r, budget).unref?.()),
|
|
1206
|
-
]);
|
|
1374
|
+
await settled(Promise.all(promises), budget, signal);
|
|
1207
1375
|
}
|
|
1208
1376
|
return this.groupView(groupId, opts);
|
|
1209
1377
|
}
|
|
@@ -1213,7 +1381,12 @@ export class JobManager {
|
|
|
1213
1381
|
if (!group) {
|
|
1214
1382
|
const view = this.groupView(groupId);
|
|
1215
1383
|
if (!view) return null;
|
|
1216
|
-
|
|
1384
|
+
// Started by another server: each member still running there is asked to stop.
|
|
1385
|
+
const asked = (store.loadGroup(groupId)?.job_ids ?? [])
|
|
1386
|
+
.map((jid) => this.cancel(jid)).filter((r) => /^asked/.test(r?.cancel_effect ?? '')).length;
|
|
1387
|
+
return { ...view, cancel_effect: asked
|
|
1388
|
+
? `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`
|
|
1389
|
+
: 'nothing to signal: no consultant in this fan-out is still running' };
|
|
1217
1390
|
}
|
|
1218
1391
|
// Count what is actually about to be signalled before signalling it:
|
|
1219
1392
|
// cancel() short-circuits on a member that has already finished, so a
|
|
@@ -1321,6 +1494,36 @@ function partialOutput(run) {
|
|
|
1321
1494
|
};
|
|
1322
1495
|
}
|
|
1323
1496
|
|
|
1497
|
+
// Waits for `promise` (null: for nothing), at most `ms`, and no longer than
|
|
1498
|
+
// `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
|
|
1499
|
+
// cut short leaves nothing behind.
|
|
1500
|
+
async function settled(promise, ms, signal) {
|
|
1501
|
+
let timer;
|
|
1502
|
+
let stop;
|
|
1503
|
+
const until = new Promise((resolve) => {
|
|
1504
|
+
stop = resolve;
|
|
1505
|
+
timer = setTimeout(resolve, ms);
|
|
1506
|
+
if (signal?.aborted) resolve();
|
|
1507
|
+
else signal?.addEventListener('abort', resolve, { once: true });
|
|
1508
|
+
});
|
|
1509
|
+
try {
|
|
1510
|
+
await (promise ? Promise.race([promise, until]) : until);
|
|
1511
|
+
} finally {
|
|
1512
|
+
clearTimeout(timer);
|
|
1513
|
+
signal?.removeEventListener('abort', stop);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function parses(text) {
|
|
1518
|
+
try {
|
|
1519
|
+
normalizeResult(text);
|
|
1520
|
+
return true;
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
if (err instanceof OutputError) return false;
|
|
1523
|
+
throw err;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1324
1527
|
function recordableIds(result) {
|
|
1325
1528
|
return [
|
|
1326
1529
|
...result.findings.map((f) => f.id),
|
|
@@ -1358,6 +1561,37 @@ function text(value) {
|
|
|
1358
1561
|
return t.length > POLICY.output.itemTextMax ? `${t.slice(0, POLICY.output.itemTextMax)}\n…[truncated by severally]` : t;
|
|
1359
1562
|
}
|
|
1360
1563
|
|
|
1564
|
+
// A follow-up restates what the round before was given unless it says
|
|
1565
|
+
// otherwise: the lead sends the new question, not the whole brief again. Only
|
|
1566
|
+
// a key left out is inherited -- one passed, even as an empty list, replaces
|
|
1567
|
+
// it -- and the validation that follows sees the result. Files are not:
|
|
1568
|
+
// copying them again stays expose_from_parent, named in the call (schema.mjs).
|
|
1569
|
+
function inherit(raw, round) {
|
|
1570
|
+
const { brief } = round;
|
|
1571
|
+
// A round kept off the web stays off: the material it carries over is the same.
|
|
1572
|
+
const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
|
|
1573
|
+
if (!brief) return out;
|
|
1574
|
+
for (const k of ['objective', 'success_criteria', 'constraints']) {
|
|
1575
|
+
if (out[k] === undefined && raw.context?.[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) out[k] = brief[k];
|
|
1576
|
+
}
|
|
1577
|
+
const ctx = { ...(raw.context ?? {}) };
|
|
1578
|
+
for (const k of ['facts', 'proposal', 'counterpoints']) {
|
|
1579
|
+
if (ctx[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) ctx[k] = brief[k];
|
|
1580
|
+
}
|
|
1581
|
+
// The diff expose_diff added is not the lead's material: it is taken again
|
|
1582
|
+
// (or replaced) through expose_from_parent, against the files as they are now.
|
|
1583
|
+
// It is the last artifact (appended after the lead's own), so only that one is dropped.
|
|
1584
|
+
const artifacts = [...(brief.artifacts ?? [])];
|
|
1585
|
+
const last = artifacts.at(-1);
|
|
1586
|
+
if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff ')) artifacts.pop();
|
|
1587
|
+
if (ctx.artifacts === undefined && artifacts.length) {
|
|
1588
|
+
// Stored with null for an absent language or source; the request schema takes them as optional strings.
|
|
1589
|
+
ctx.artifacts = artifacts.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v != null)));
|
|
1590
|
+
}
|
|
1591
|
+
out.context = ctx;
|
|
1592
|
+
return out;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1361
1595
|
function adviceCaveat(normalized) {
|
|
1362
1596
|
const { result, quality } = normalized;
|
|
1363
1597
|
const notes = [];
|
|
@@ -1365,7 +1599,11 @@ function adviceCaveat(normalized) {
|
|
|
1365
1599
|
notes.push('the consultant reported its evidence as insufficient: treat this as a request for more material, not as advice');
|
|
1366
1600
|
} else if (result.evidence_basis === 'thin') {
|
|
1367
1601
|
notes.push('the consultant reported thin evidence: weigh the findings accordingly');
|
|
1602
|
+
} else if (!result.evidence_basis) {
|
|
1603
|
+
notes.push('the consultant did not say how well its evidence supports the answer (evidence_basis)');
|
|
1368
1604
|
}
|
|
1605
|
+
// Read first in a comparison; a missing one is shown as null there, and named here.
|
|
1606
|
+
if (!result.stance) notes.push('the answer gives no bottom line (stance): read the summary and findings for one before relying on it');
|
|
1369
1607
|
if (quality.findings_without_grounds > 0) {
|
|
1370
1608
|
notes.push(`${quality.findings_without_grounds} finding(s) arrived without grounds: do not adopt them without checking`);
|
|
1371
1609
|
}
|