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.
Files changed (40) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +13 -7
  3. package/README.ja.md +157 -77
  4. package/README.md +162 -81
  5. package/bin/severally-mcp.mjs +3 -0
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +11 -3
  8. package/docs/how-it-works.md +12 -4
  9. package/docs/install.ja.md +25 -6
  10. package/docs/install.md +30 -7
  11. package/package.json +5 -3
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +4 -2
  14. package/plugins/severally/.mcp.json +2 -1
  15. package/plugins/severally/dist/severally-mcp.mjs +29639 -28215
  16. package/plugins/severally/skills/_template/SKILL.md.tmpl +19 -23
  17. package/plugins/severally/skills/_template/reference.md.tmpl +22 -10
  18. package/plugins/severally/skills/antigravity/severally/SKILL.md +19 -23
  19. package/plugins/severally/skills/antigravity/severally/reference.md +22 -10
  20. package/plugins/severally/skills/claude/severally/SKILL.md +19 -23
  21. package/plugins/severally/skills/claude/severally/reference.md +22 -10
  22. package/plugins/severally/skills/codex/severally/SKILL.md +19 -23
  23. package/plugins/severally/skills/codex/severally/reference.md +22 -10
  24. package/plugins/severally/skills/opencode/severally/SKILL.md +19 -23
  25. package/plugins/severally/skills/opencode/severally/reference.md +22 -10
  26. package/scripts/init-config.mjs +1 -1
  27. package/scripts/install.mjs +124 -9
  28. package/src/adapters/antigravity-sandbox.mjs +5 -1
  29. package/src/adapters/codex.mjs +17 -6
  30. package/src/brief.mjs +38 -16
  31. package/src/consultants.mjs +31 -10
  32. package/src/expose-paths.mjs +68 -5
  33. package/src/jobs.mjs +294 -56
  34. package/src/policy.mjs +49 -11
  35. package/src/redact.mjs +20 -1
  36. package/src/run.mjs +38 -5
  37. package/src/schema.mjs +35 -12
  38. package/src/server.mjs +96 -120
  39. package/src/status.mjs +63 -0
  40. 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
- return this.order
231
- .slice(-limit)
232
- .reverse()
233
- .map((jid) => {
234
- const j = this.jobs.get(jid);
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
- if (followupTo) {
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
- const req = parseRequest(rawRequest, { isFollowup: Boolean(parent), parentMode: parent?.mode ?? null });
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
- const seats = req.targets.map((target) => ({
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
- return this.#admitted(req, parent, round, seats);
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
- #admitted(req, parent, round, seats) {
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 chainId = parent ? parent.chain_id : id('chain');
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: parent?.job_id ?? null,
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
- // One brief, rendered once for the whole group: a fan-out is only comparable
504
- // if every consultant answered the same question in the same words. Every
505
- // member shares one chain history by construction -- a fan-out is never a
506
- // follow-up (parseRequest refuses that), so each member's fresh chain is
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) => ({ job_id: m.job_id, chain_id: m.chain_id, target: m.target, cli: m.cli, model: m.model })),
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 finding goes with the lead's verdict on it, so the consultant answers
562
- // knowing which of its points were checked and how they came out. Every
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
- let unchecked = 0;
577
- const findings = j.result.findings
578
- .filter((f) => verdicts.has(f.id) || unchecked++ < 5)
579
- .map((f) => ({ id: f.id, point: f.point, verdict: verdicts.get(f.id)?.verdict ?? null, effect: verdicts.get(f.id)?.effect ?? null }));
580
- return { round: j.round, mode: j.mode, question: j.question, summary: j.result.summary, findings };
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 once per chain and hands it in, so every member
625
- // of a fan-out is sent byte-identical text.
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 = [adviceCaveat(normalized), sameVendorCaveat(job.caller, job.caller_model, consultant)].filter(Boolean);
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: job.record ? recordSummary(job) : null,
1062
- prediction: job.prediction ?? null,
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 Promise.race([job.promise, new Promise((r) => setTimeout(r, budget).unref?.())]);
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 new Promise((r) => setTimeout(r, Math.min(1000, until - Date.now())));
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: view.status === 'running' || view.status === 'queued'
1098
- ? 'nothing signalled: this consultation runs in another severally process, and only that process can cancel it'
1099
- : `job was already ${view.status}`,
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
- view.comparison = comparison(members, full ? Infinity : GROUP_CHARS - printed(view));
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.race([
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
- return { ...view, cancel_effect: 'nothing signalled: this fan-out was started by another severally process or before a restart, and only a live process can cancel its own consultants' };
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
  }