atris 3.61.0 → 3.62.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.
Files changed (41) hide show
  1. package/README.md +37 -5
  2. package/atris/skills/engines/SKILL.md +9 -9
  3. package/bin/atris.js +1 -0
  4. package/commands/autoland.js +36 -5
  5. package/commands/autopilot.js +105 -6
  6. package/commands/engine.js +96 -21
  7. package/commands/host.js +56 -4
  8. package/commands/land.js +129 -8
  9. package/commands/member.js +33 -8
  10. package/commands/mission.js +112 -3
  11. package/commands/one-lap.js +14 -10
  12. package/commands/stream.js +56 -22
  13. package/commands/team.js +82 -10
  14. package/commands/worktree.js +39 -0
  15. package/commands/youtube.js +36 -26
  16. package/lib/engine-ask.js +112 -9
  17. package/lib/engine-registry.js +283 -39
  18. package/lib/fleet.js +444 -26
  19. package/lib/functional-owner.js +15 -6
  20. package/lib/host.js +296 -170
  21. package/lib/member-engine.js +11 -1
  22. package/lib/member-park.js +138 -0
  23. package/lib/member-scaffold.js +37 -0
  24. package/lib/roster-markdown.js +1 -1
  25. package/lib/roster-prep.js +289 -0
  26. package/lib/roster-runs.js +389 -0
  27. package/lib/roster-suggest.js +145 -0
  28. package/lib/router-brain.js +111 -3
  29. package/lib/self-drive.js +26 -2
  30. package/lib/state-detection.js +3 -3
  31. package/lib/task-db.js +38 -3
  32. package/lib/team-lineup.js +7 -2
  33. package/lib/todo.js +1 -1
  34. package/lib/wish-audit.js +1 -1
  35. package/package.json +1 -1
  36. package/scripts/det/cpu-hotspots.js +216 -0
  37. package/scripts/det/ytnotes +105 -19
  38. package/scripts/det/ytquote-repair.js +18 -4
  39. package/templates/members/host/MEMBER.md +5 -3
  40. package/templates/members/host/MISSION.md +1 -1
  41. package/templates/members/host/skills/host/SKILL.md +64 -16
package/lib/fleet.js CHANGED
@@ -23,7 +23,9 @@ const {
23
23
  worktreeBaseRef,
24
24
  } = require('./brief-ledger');
25
25
  const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
26
- const { engineFailureHealthStatus, setEngineHealth } = require('./engine-registry');
26
+ const { setEngineHealth, recordEngineRunHealth, engineStallReason, engineFailureHealthStatus, resolveJobTeam, rosterDecided, rosterPinFor } = require('./engine-registry');
27
+ const { appendRosterAttempts, parseRunUsage } = require('./roster-runs');
28
+ const { engineRunsView } = require('./roster-models');
27
29
  const { resolveDefaultVerifier } = require('./default-verifier');
28
30
  const { rankEnginesDetailed } = require('./router-brain');
29
31
  const {
@@ -37,6 +39,7 @@ const { sleepSync } = require('./sleep-sync');
37
39
  const { ownerIdentity } = require('../utils/owner-identity');
38
40
  const { matchTaste } = require('./taste-lessons');
39
41
  const { buildVerifiedScoutPack, appendVerifiedScoutPack } = require('./dispatch-scout');
42
+ const { runPrepPass, withPrepBrief, prepRecordFields } = require('./roster-prep');
40
43
  const { mapNotesPath } = require('./map-refs');
41
44
  const {
42
45
  applySecretGrantEnvironment,
@@ -220,7 +223,17 @@ function validatorPin(validatorModels, engine) {
220
223
  const pin = validatorModels && validatorModels[engine];
221
224
  if (!pin) return { model: '', effort: '', maxSeconds: 0 };
222
225
  if (typeof pin === 'string') return { model: pin, effort: '', maxSeconds: 0 };
223
- return { model: String(pin.model || ''), effort: String(pin.effort || ''), maxSeconds: capSeconds(pin.max_seconds) };
226
+ return {
227
+ model: String(pin.model || ''),
228
+ effort: String(pin.effort || ''),
229
+ maxSeconds: capSeconds(pin.max_seconds),
230
+ ...(pin.prep ? { prep: String(pin.prep) } : {}),
231
+ };
232
+ }
233
+
234
+ // The engine flags a pin sets, never its prep job.
235
+ function pinEngineFlags(pin) {
236
+ return { model: pin.model, effort: pin.effort, maxSeconds: pin.maxSeconds };
224
237
  }
225
238
 
226
239
  function wrapCodexWithWatchdog(cmd, watchdogPath = CODEX_WATCHDOG_SOURCE, receiptPath = '', maxSeconds = 0) {
@@ -650,9 +663,20 @@ function detectDeadEngineDispatch(result) {
650
663
  const signal = dispatchResultSignal(result);
651
664
  if (signal) return { reason: 'signalled', signal, exitCode };
652
665
  if (terminal.reason === 'ok') return null;
653
- const output = dispatchResultOutput(result).toLowerCase();
666
+ // A credit wall is read from engine-level signals only. The report and
667
+ // stdout are the task's own work: a failing "rate limit" test says nothing
668
+ // about the engine's credits.
669
+ const rateLimitInfo = result && result.rate_limit_info;
670
+ const rateLimitStatus = String(
671
+ rateLimitInfo && typeof rateLimitInfo === 'object' ? rateLimitInfo.status || '' : rateLimitInfo || '',
672
+ ).trim().toLowerCase();
673
+ const engineText = [
674
+ result && result.stderr,
675
+ result && result.error && (result.error.message || result.error.code || result.error),
676
+ rateLimitStatus && rateLimitStatus !== 'allowed' ? `rate limit ${rateLimitStatus}` : '',
677
+ ].filter(Boolean).map(String).join('\n').toLowerCase();
654
678
  const pattern = Number.isInteger(exitCode) && exitCode !== 0
655
- ? DEAD_ENGINE_OUTPUT_PATTERNS.find((p) => output.includes(p))
679
+ ? DEAD_ENGINE_OUTPUT_PATTERNS.find((p) => engineText.includes(p))
656
680
  : null;
657
681
  if (pattern) return { reason: 'usage_limit', pattern };
658
682
  if (terminal.reason === 'no_output' || terminal.reason === 'unknown' || terminal.reason === 'timeout' || terminal.reason === 'cancelled') {
@@ -680,16 +704,15 @@ function plainDispatchFailureCause(result, failure = {}) {
680
704
  return (reason || 'the engine failed').replace(/_/g, ' ');
681
705
  }
682
706
 
707
+ // A stalled run (time cap, watchdog, dropped connection) benches its engine
708
+ // for the cooldown; other failures keep today's health write.
683
709
  function recordDispatchEngineHealth(result, failure, root) {
684
710
  if (!result || !result.engine) return null;
685
- const status = failure
686
- ? engineFailureHealthStatus({
687
- ...result,
688
- status: 'errored',
689
- reason: [result.reason, failure.reason].filter(Boolean).join('\n'),
690
- })
691
- : 'ready';
692
- return status ? setEngineHealth(result.engine, status, root) : null;
711
+ if (!failure) return setEngineHealth(result.engine, 'ready', root);
712
+ return recordEngineRunHealth(result.engine, {
713
+ ...result,
714
+ reason: [result.reason, failure.reason].filter(Boolean).join('\n'),
715
+ }, root);
693
716
  }
694
717
 
695
718
  function normalizeInstalledEngines(engines) {
@@ -754,6 +777,97 @@ function failedDispatchLeg(result, engineName) {
754
777
  return leg;
755
778
  }
756
779
 
780
+ // How one attempt ended, before landing decides the last one: a credit wall,
781
+ // a stall with its plain text, a real task failure, or provisionally landed.
782
+ // Same engine-level rule as the health write: the task's own words never
783
+ // decide the engine's outcome.
784
+ function rosterAttemptOutcome(result) {
785
+ const dead = detectDeadEngineDispatch(result);
786
+ if (!dead) return { outcome: 'landed' };
787
+ const health = engineFailureHealthStatus({
788
+ ...result,
789
+ status: 'errored',
790
+ reason: [result && result.reason, dead.reason].filter(Boolean).join('\n'),
791
+ });
792
+ if (health === 'credit_out') return { outcome: 'credit out', detail: dead.pattern ? `said "${dead.pattern}"` : plainDispatchFailureCause(result, dead) };
793
+ const stall = engineStallReason(result);
794
+ if (stall) return { outcome: 'stalled', detail: stall.text };
795
+ return { outcome: 'failed', detail: plainDispatchFailureCause(result, dead) };
796
+ }
797
+
798
+ // One attempt's line: the worker as it really ran (its pin, else the model
799
+ // the engine runs on its own), the seconds, and tokens only when printed.
800
+ function rosterAttemptRecord(engineName, pin, startedMs, endedMs, result, prep = null) {
801
+ const model = (pin && pin.model) || engineRunsView(engineName).model || '';
802
+ return {
803
+ ...prepRecordFields(prep),
804
+ at: new Date(startedMs).toISOString(),
805
+ engine: engineName,
806
+ ...(model ? { model } : {}),
807
+ ...(pin && pin.effort ? { effort: pin.effort } : {}),
808
+ ...(pin && pin.maxSeconds ? { max_seconds: pin.maxSeconds } : {}),
809
+ seconds: Math.max(0, (endedMs - startedMs) / 1000),
810
+ ...parseRunUsage({
811
+ usage: result && result.usage,
812
+ cost_usd: result && result.cost_usd,
813
+ stdout: result && (result.report || result.stdout),
814
+ stderr: result && result.stderr,
815
+ }),
816
+ ...rosterAttemptOutcome(result),
817
+ };
818
+ }
819
+
820
+ // After landing: the last attempt landed only when its task is in the landed
821
+ // or proof-ready rows; otherwise it failed at the stage that paused it. Every
822
+ // earlier attempt keeps its own outcome and names who took over.
823
+ function recordFlightRosterRuns(root, flight, results, { job = 'build', member = '', source = '' } = {}) {
824
+ for (const { entry, result } of results || []) {
825
+ const attempts = (result && Array.isArray(result.attempts) ? result.attempts : []).map((attempt) => ({ ...attempt }));
826
+ if (!attempts.length) continue;
827
+ const taskId = String(entry.taskId || dispatchTaskId(entry.task));
828
+ const sameTask = (row) => row && String(row.task) === taskId;
829
+ const last = attempts[attempts.length - 1];
830
+ if (last.outcome === 'landed') {
831
+ const landed = (flight.landed || []).some(sameTask) || (flight.ready || []).some(sameTask);
832
+ const paused = (flight.paused || []).find(sameTask);
833
+ if (!landed) {
834
+ last.outcome = 'failed';
835
+ last.detail = paused
836
+ ? `paused at ${String(paused.stage || 'landing').replace(/_/g, ' ')}${paused.detail ? `: ${String(paused.detail).split('\n')[0]}` : ''}`
837
+ : 'did not land';
838
+ }
839
+ }
840
+ appendRosterAttempts(root, attempts, {
841
+ job: entry.roster_job || job,
842
+ ...(member ? { member } : {}),
843
+ task: taskId,
844
+ ...(source ? { source } : {}),
845
+ });
846
+ }
847
+ }
848
+
849
+ // A dispatch no flight watches (a mission blocker's build): the same health
850
+ // write and run line the flight paths make, so a stall benches the engine
851
+ // and the record shows how it did. Never throws.
852
+ function recordDispatchOutcome(root, { engine, pin = {}, startedMs, endedMs, result, job = 'build', task = '', source = '', member = '' } = {}) {
853
+ try {
854
+ const run = normalizeDispatchResult(result, engine);
855
+ if (!run.max_seconds && pin && pin.maxSeconds) run.max_seconds = pin.maxSeconds;
856
+ const dead = detectDeadEngineDispatch(run);
857
+ recordDispatchEngineHealth(run, dead, root);
858
+ const attempt = rosterAttemptRecord(engine, pin, startedMs, endedMs, run);
859
+ appendRosterAttempts(root, [attempt], {
860
+ job,
861
+ ...(task ? { task: String(task) } : {}),
862
+ ...(source ? { source } : {}),
863
+ ...(member ? { member } : {}),
864
+ });
865
+ return { outcome: attempt.outcome, ...(attempt.detail ? { detail: attempt.detail } : {}) };
866
+ } catch {
867
+ return null;
868
+ }
869
+ }
870
+
757
871
  async function dispatchEntryWithRestaff({
758
872
  entry,
759
873
  engine,
@@ -762,8 +876,55 @@ async function dispatchEntryWithRestaff({
762
876
  installedEngines = null,
763
877
  restaffState,
764
878
  scoutAsk,
879
+ clock = Date.now,
880
+ defaultPin = null,
881
+ job = 'build',
882
+ prepAsk = undefined,
883
+ }) {
884
+ const attempts = [];
885
+ const result = await dispatchEntryAttempts({ entry, engine, root, dispatch, installedEngines, restaffState, scoutAsk, clock, defaultPin, attempts, job, prepAsk });
886
+ return { ...result, attempts };
887
+ }
888
+
889
+ // One prep pass per prep job per task: a backup whose line asks for the same
890
+ // prep job reuses the brief the lead's prep already wrote.
891
+ function prepPassCache({ entry, root, clock, prepAsk, job }) {
892
+ const passes = new Map();
893
+ return (prepJob, prompt) => {
894
+ if (!prepJob) return Promise.resolve(null);
895
+ if (!passes.has(prepJob)) {
896
+ passes.set(prepJob, runPrepPass({
897
+ prepJob,
898
+ forJob: entry.roster_job || job,
899
+ task: entry.task,
900
+ prompt,
901
+ worktreePath: entry.worktreePath,
902
+ root,
903
+ clock,
904
+ ...(prepAsk ? { ask: prepAsk } : {}),
905
+ record: { member: entry.member || '' },
906
+ }));
907
+ }
908
+ return passes.get(prepJob);
909
+ };
910
+ }
911
+
912
+ async function dispatchEntryAttempts({
913
+ entry,
914
+ engine,
915
+ root,
916
+ dispatch,
917
+ installedEngines = null,
918
+ restaffState,
919
+ scoutAsk,
920
+ clock = Date.now,
921
+ defaultPin = null,
922
+ attempts = [],
923
+ job = 'build',
924
+ prepAsk = undefined,
765
925
  }) {
766
926
  const basePrompt = entry.prompt || buildFleetPrompt(entry.task, { worktreePath: entry.worktreePath, yolo: entry.yolo });
927
+ const prepFor = prepPassCache({ entry, root, clock, prepAsk, job });
767
928
  let scoutPackPromise = null;
768
929
  const scoutPack = () => {
769
930
  if (scoutAsk === false) return Promise.resolve(null);
@@ -776,8 +937,12 @@ async function dispatchEntryWithRestaff({
776
937
  }
777
938
  return scoutPackPromise;
778
939
  };
779
- const runOnce = async (engineName) => {
780
- const prompt = appendVerifiedScoutPack(basePrompt, await scoutPack(), { worktreePath: entry.worktreePath });
940
+ const runOnce = async (engineName, pin = null) => {
941
+ const ranWith = pin || entry.roster_pin || (engineName === engine ? defaultPin : null) || {};
942
+ const scouted = appendVerifiedScoutPack(basePrompt, await scoutPack(), { worktreePath: entry.worktreePath });
943
+ // A worker whose roster line asks for prep gets its brief first.
944
+ const prep = ranWith.prep ? await prepFor(ranWith.prep, scouted) : null;
945
+ const prompt = withPrepBrief(scouted, prep);
781
946
  const brief = captureDispatchBrief({
782
947
  root,
783
948
  task: entry.task,
@@ -788,24 +953,36 @@ async function dispatchEntryWithRestaff({
788
953
  author: entry.author || '',
789
954
  yolo: entry.yolo,
790
955
  });
956
+ const startedMs = clock();
957
+ let runResult;
791
958
  try {
792
- const runResult = normalizeDispatchResult(await dispatch({ ...entry, engine: engineName, prompt, brief_id: brief.brief_id, briefId: brief.brief_id, skipBriefCapture: true }), engineName);
959
+ runResult = normalizeDispatchResult(await dispatch({ ...entry, engine: engineName, prompt, brief_id: brief.brief_id, briefId: brief.brief_id, skipBriefCapture: true, ...(pin ? { roster_pin: pin } : {}) }), engineName);
793
960
  if (!runResult.brief_id) runResult.brief_id = brief.brief_id;
794
- return runResult;
961
+ if (pin && pin.maxSeconds && !runResult.max_seconds) runResult.max_seconds = pin.maxSeconds;
795
962
  } catch (err) {
796
- return normalizeDispatchResult({
963
+ runResult = normalizeDispatchResult({
797
964
  brief_id: brief.brief_id,
798
965
  exitCode: 1,
799
966
  report: '',
800
967
  stderr: String(err && err.message || err),
801
968
  }, engineName);
802
969
  }
970
+ if (!runResult.max_seconds && ranWith.maxSeconds) runResult.max_seconds = ranWith.maxSeconds;
971
+ attempts.push(rosterAttemptRecord(engineName, ranWith, startedMs, clock(), runResult, prep));
972
+ return runResult;
803
973
  };
804
974
 
805
975
  const first = await runOnce(engine);
976
+ if (!first.max_seconds && entry.maxSeconds) first.max_seconds = entry.maxSeconds;
806
977
  const deadEngine = detectDeadEngineDispatch(first);
807
978
  recordDispatchEngineHealth(first, deadEngine, root);
808
979
  if (!deadEngine) return first;
980
+ // A stall (time cap, watchdog, dropped connection) walks the roster's build
981
+ // team; with no roster line the one-shot restaff below runs as before.
982
+ const handover = deadEngine && engineStallReason(first)
983
+ ? await handOverStalledDispatch({ entry, engine, first, root, runOnce })
984
+ : null;
985
+ if (handover) return handover;
809
986
  stampDispatchBrief(root, first.brief_id, 'fail', `restaffed from ${engine}: ${deadEngine.reason}`);
810
987
 
811
988
  const outage = { from: engine, reason: deadEngine.reason };
@@ -832,6 +1009,129 @@ async function dispatchEntryWithRestaff({
832
1009
  };
833
1010
  }
834
1011
 
1012
+ function stallLine(result, engineName) {
1013
+ const stall = engineStallReason(result);
1014
+ return `${engineName} ${stall ? stall.text : 'failed'}`;
1015
+ }
1016
+
1017
+ // A stalled run hands the same brief to the next ready worker on the build
1018
+ // roster, each with its own model, effort, and time cap, once per worker.
1019
+ // It stops at the first run that succeeds or fails for a real task reason,
1020
+ // or when the team runs out. The stalled engine is already benched, so the
1021
+ // team read here starts past it. Null when no roster line decides build.
1022
+ async function handOverStalledDispatch({ entry, engine, first, root, runOnce, job = 'build' }) {
1023
+ const tried = new Set([engine]);
1024
+ const reasons = [];
1025
+ const legs = [];
1026
+ let last = first;
1027
+ let lastEngine = engine;
1028
+ for (;;) {
1029
+ let team;
1030
+ try {
1031
+ team = resolveJobTeam(entry.roster_job || job, root, { lowStakes: false });
1032
+ } catch {
1033
+ team = null;
1034
+ }
1035
+ // Once every worker is benched the roster stops deciding; that is the
1036
+ // team running out, not a missing roster.
1037
+ const decided = Boolean(team && rosterDecided(team.source));
1038
+ const next = decided
1039
+ ? team.team.find((worker) => worker && !tried.has(worker.id) && DISPATCH_CAPABLE.includes(worker.id))
1040
+ : null;
1041
+ if (!next) {
1042
+ if (!legs.length) return null;
1043
+ reasons.push(`${stallLine(last, lastEngine)}; no one left on the ${entry.roster_job || job} team`);
1044
+ break;
1045
+ }
1046
+ const line = stallLine(last, lastEngine);
1047
+ tried.add(next.id);
1048
+ reasons.push(`${line}; ${next.id} took over`);
1049
+ stampDispatchBrief(root, last.brief_id, 'fail', reasons[reasons.length - 1]);
1050
+ legs.push(failedDispatchLeg(last, lastEngine));
1051
+ // A backup preps only when its own line asks.
1052
+ const pin = {
1053
+ model: next.roster_model || '',
1054
+ effort: next.roster_effort || '',
1055
+ maxSeconds: capSeconds(next.roster_max_seconds),
1056
+ prep: next.roster_prep || '',
1057
+ };
1058
+ const result = await runOnce(next.id, pin);
1059
+ const dead = detectDeadEngineDispatch(result);
1060
+ recordDispatchEngineHealth(result, dead, root);
1061
+ last = result;
1062
+ lastEngine = next.id;
1063
+ if (!dead || !engineStallReason(result)) break;
1064
+ }
1065
+ return {
1066
+ ...last,
1067
+ handover: {
1068
+ from: engine,
1069
+ to: lastEngine,
1070
+ reasons,
1071
+ failed_legs: legs,
1072
+ },
1073
+ handover_reason: reasons.join('. '),
1074
+ };
1075
+ }
1076
+
1077
+ // Claude prints plain text by default, so a claude run had no tokens on its
1078
+ // record. Asked for its result as JSON, it prints one object with the same
1079
+ // final text plus usage and cost. Only the claude CLI itself gets the flag;
1080
+ // a custom runner template or a renamed runner keeps its plain launch.
1081
+ const CLAUDE_JSON_ENGINES = new Set(['claude', 'fable', 'haiku']);
1082
+
1083
+ function claudeJsonLaunch(engine, command) {
1084
+ return CLAUDE_JSON_ENGINES.has(engine) && /^'?(?:[^'\s]*\/)?claude'? -p /.test(String(command || ''));
1085
+ }
1086
+
1087
+ // One parsed claude result object from a line of stdout, or null when the
1088
+ // line is not one (plain text, partial json, some other event shape).
1089
+ function claudeResultEvent(line) {
1090
+ const text = String(line || '').trim();
1091
+ if (!text.startsWith('{')) return null;
1092
+ try {
1093
+ const event = JSON.parse(text);
1094
+ if (!event || typeof event !== 'object' || (event.type !== 'result' && typeof event.result !== 'string')) return null;
1095
+ return event;
1096
+ } catch {
1097
+ return null;
1098
+ }
1099
+ }
1100
+
1101
+ // The report text for a claude result event. A run that ended in an error
1102
+ // carries no result string, only a subtype and an errors list; without this
1103
+ // the report and live log went blank on exactly the runs that needed their
1104
+ // error kept.
1105
+ function claudeResultText(event) {
1106
+ const result = typeof event.result === 'string' ? event.result : '';
1107
+ if (result.trim()) return result;
1108
+ const errors = (Array.isArray(event.errors) ? event.errors : [])
1109
+ .map((entry) => String(entry).trim())
1110
+ .filter(Boolean);
1111
+ if (event.is_error !== true && !errors.length) return result;
1112
+ return [String(event.subtype || '').trim(), errors.join('; ')].filter(Boolean).join(': ');
1113
+ }
1114
+
1115
+ // The final text, usage, and cost from claude's JSON result, or null when
1116
+ // the output is not one (the run died first, or a stand-in printed text).
1117
+ // The result is the last line that parses as one, so plain lines the engine
1118
+ // printed earlier in the run do not hide it.
1119
+ function unwrapClaudeJsonResult(stdout) {
1120
+ const text = String(stdout || '').trim();
1121
+ if (!text) return null;
1122
+ const candidates = [text, ...text.split(/\r?\n/).reverse()];
1123
+ for (const candidate of candidates) {
1124
+ const event = claudeResultEvent(candidate);
1125
+ if (!event) continue;
1126
+ return {
1127
+ text: claudeResultText(event),
1128
+ ...(event.usage && typeof event.usage === 'object' ? { usage: event.usage } : {}),
1129
+ ...(typeof event.total_cost_usd === 'number' ? { cost_usd: event.total_cost_usd } : {}),
1130
+ };
1131
+ }
1132
+ return null;
1133
+ }
1134
+
835
1135
  // Run one engine on one task in one worktree. Blocking; the conductor runs
836
1136
  // dispatches in parallel via child processes, not threads. `runner` is
837
1137
  // injectable for tests. `prompt` is injectable too: a caller-supplied prompt
@@ -858,7 +1158,7 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
858
1158
  fs.mkdirSync(artifactDir, { recursive: true });
859
1159
  watchdogArtifact = path.join(artifactDir, `codex-watchdog-${task.display_id || 'task'}-${crypto.randomBytes(4).toString('hex')}.json`);
860
1160
  }
861
- const command = buildEngineCommand(engine, promptFile, {
1161
+ let command = buildEngineCommand(engine, promptFile, {
862
1162
  yolo,
863
1163
  sealed,
864
1164
  allowedTools,
@@ -868,6 +1168,9 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
868
1168
  effort,
869
1169
  maxSeconds,
870
1170
  });
1171
+ // Tokens for the run record only: the report stays claude's final text.
1172
+ const claudeJson = claudeJsonLaunch(engine, command);
1173
+ if (claudeJson) command = `${command} --output-format json`;
871
1174
  // A roster time cap replaces the default backstop. Codex keeps a minute of
872
1175
  // slack past its watchdog so the watchdog's own receipt gets written.
873
1176
  const cap = capSeconds(maxSeconds);
@@ -875,6 +1178,7 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
875
1178
  ? cap * 1000 + (engine === 'codex' ? 60000 : 0)
876
1179
  : (engine === 'codex' ? CODEX_DISPATCH_TIMEOUT_MS : DEFAULT_DISPATCH_TIMEOUT_MS);
877
1180
  const dispatchTimeoutMs = timeoutMs === null || timeoutMs === undefined ? defaultTimeoutMs : timeoutMs;
1181
+ let flushLiveLogTail = null;
878
1182
  const exec = runner || ((cmd) => {
879
1183
  const childEnv = sealed && environment
880
1184
  ? { ...environment }
@@ -907,7 +1211,28 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
907
1211
  };
908
1212
  if (liveLogPath) {
909
1213
  fs.mkdirSync(path.dirname(liveLogPath), { recursive: true });
910
- spawnOptions.onOutputChunk = (chunk) => appendEngineLiveLogChunk(liveLogPath, chunk);
1214
+ // Claude's result JSON is one line at the end of the stream: every
1215
+ // other stdout line goes to the live log as it lands so a watching
1216
+ // operator sees the run while it runs, and the held-back result line
1217
+ // is written as its final text once the run closes. A partial last
1218
+ // line waits in the tail until more output or the flush.
1219
+ let stdoutTail = '';
1220
+ spawnOptions.onOutputChunk = (chunk, stream) => {
1221
+ if (!claudeJson || stream !== 'stdout') {
1222
+ appendEngineLiveLogChunk(liveLogPath, chunk);
1223
+ return;
1224
+ }
1225
+ stdoutTail += String(chunk);
1226
+ const lines = stdoutTail.split('\n');
1227
+ stdoutTail = lines.pop();
1228
+ const pass = lines.filter((line) => !claudeResultEvent(line));
1229
+ if (pass.length) appendEngineLiveLogChunk(liveLogPath, `${pass.join('\n')}\n`);
1230
+ };
1231
+ flushLiveLogTail = () => {
1232
+ const tail = stdoutTail;
1233
+ stdoutTail = '';
1234
+ if (tail && !claudeResultEvent(tail)) appendEngineLiveLogChunk(liveLogPath, tail);
1235
+ };
911
1236
  }
912
1237
  const run = sandboxProfile
913
1238
  ? runInReapedProcessGroup(executable, args, spawnOptions, cleanupControlDir, statusFile)
@@ -927,6 +1252,14 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
927
1252
  if (watchdogArtifact && fs.existsSync(watchdogArtifact)) {
928
1253
  try { watchdogReceipt = JSON.parse(fs.readFileSync(watchdogArtifact, 'utf8')); } catch {}
929
1254
  }
1255
+ const unwrapped = claudeJson ? unwrapClaudeJsonResult(run.stdout) : null;
1256
+ const stdout = unwrapped ? unwrapped.text : String(run.stdout || '');
1257
+ if (flushLiveLogTail) {
1258
+ try { flushLiveLogTail(); } catch {}
1259
+ }
1260
+ if (claudeJson && liveLogPath && stdout) {
1261
+ try { appendEngineLiveLogChunk(liveLogPath, stdout); } catch {}
1262
+ }
930
1263
  return {
931
1264
  task: task.display_id || task.id,
932
1265
  engine,
@@ -940,8 +1273,10 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
940
1273
  signal: run.signal || null,
941
1274
  timed_out: Boolean(run.error && run.error.code === 'ETIMEDOUT'),
942
1275
  cancelled: Boolean(run.cancelled),
943
- report: String(run.stdout || '').slice(-8000),
1276
+ report: stdout.slice(-8000),
944
1277
  stderr: String(run.stderr || '').slice(-2000),
1278
+ ...(unwrapped && unwrapped.usage ? { usage: unwrapped.usage } : {}),
1279
+ ...(unwrapped && unwrapped.cost_usd !== undefined ? { cost_usd: unwrapped.cost_usd } : {}),
945
1280
  };
946
1281
  };
947
1282
  if (result && typeof result.then === 'function') return result.then(toDispatch);
@@ -1232,6 +1567,7 @@ module.exports = {
1232
1567
  nextInstalledFleetEngine,
1233
1568
  rankFleetEnginesDetailed,
1234
1569
  dispatchToEngine,
1570
+ recordDispatchOutcome,
1235
1571
  taskTags,
1236
1572
  isHumanHoldTag,
1237
1573
  isDecisionTask,
@@ -2204,7 +2540,23 @@ function reviewWorktreeSnapshot(worktreePath) {
2204
2540
  };
2205
2541
  }
2206
2542
 
2207
- async function runIndependentValidator({
2543
+ // Every validator attempt records one review line, handed over when a credit
2544
+ // wall passes the review to the next validator.
2545
+ async function runIndependentValidator(options) {
2546
+ const attempts = [];
2547
+ const clock = typeof options.clock === 'function' ? options.clock : Date.now;
2548
+ try {
2549
+ return await validateWithAttempts({ ...options, clock, attempts });
2550
+ } finally {
2551
+ appendRosterAttempts(options.root || process.cwd(), attempts, {
2552
+ job: 'review',
2553
+ task: dispatchTaskId(options.task),
2554
+ source: 'one lap',
2555
+ });
2556
+ }
2557
+ }
2558
+
2559
+ async function validateWithAttempts({
2208
2560
  root,
2209
2561
  task,
2210
2562
  worktreePath,
@@ -2214,6 +2566,9 @@ async function runIndependentValidator({
2214
2566
  validatorDispatcher = null,
2215
2567
  stateInspector = reviewWorktreeSnapshot,
2216
2568
  validatorModels = null,
2569
+ clock = Date.now,
2570
+ attempts = [],
2571
+ prepAsk = undefined,
2217
2572
  }) {
2218
2573
  const candidates = [...new Set((validatorEngines || []).map((value) => String(value || '').trim()).filter(Boolean))]
2219
2574
  .filter((name) => RUNNER_PROFILE_DEFS[name] && name !== executorEngine);
@@ -2236,7 +2591,7 @@ async function runIndependentValidator({
2236
2591
  };
2237
2592
  }
2238
2593
  const dispatch = validatorDispatcher || ((entry) => Promise.resolve(dispatchToEngine({
2239
- ...validatorPin(validatorModels, entry.engine),
2594
+ ...pinEngineFlags(validatorPin(validatorModels, entry.engine)),
2240
2595
  task,
2241
2596
  engine: entry.engine,
2242
2597
  worktreePath,
@@ -2252,9 +2607,20 @@ async function runIndependentValidator({
2252
2607
  skipBriefCapture: true,
2253
2608
  })));
2254
2609
 
2610
+ // One prep pass per prep job for this task: a backup reviewer whose line
2611
+ // asks for the same prep reuses the brief the first reviewer's prep wrote.
2612
+ const reviewPrompt = buildOneLapValidatorPrompt(task, { verifierCommand, executorEngine });
2613
+ const prepPasses = new Map();
2255
2614
  for (let index = 0; index < candidates.length; index += 1) {
2256
2615
  const validatorEngine = candidates[index];
2257
- const prompt = buildOneLapValidatorPrompt(task, { verifierCommand, executorEngine });
2616
+ const pin = validatorPin(validatorModels, validatorEngine);
2617
+ // A reviewer whose roster line asks for prep reads a brief first; the
2618
+ // prep pass is read-only and runs before the worktree snapshot.
2619
+ if (pin.prep && !prepPasses.has(pin.prep)) {
2620
+ prepPasses.set(pin.prep, runPrepPass({ prepJob: pin.prep, forJob: 'review', task, prompt: reviewPrompt, worktreePath, root, clock, ...(prepAsk ? { ask: prepAsk } : {}) }));
2621
+ }
2622
+ const prep = pin.prep ? await prepPasses.get(pin.prep) : null;
2623
+ const prompt = withPrepBrief(reviewPrompt, prep);
2258
2624
  const before = stateInspector(worktreePath);
2259
2625
  if (!before || before.ok !== true) {
2260
2626
  return {
@@ -2275,6 +2641,7 @@ async function runIndependentValidator({
2275
2641
  };
2276
2642
  }
2277
2643
  let dispatched;
2644
+ const startedMs = clock();
2278
2645
  try {
2279
2646
  dispatched = normalizeDispatchResult(await dispatch({
2280
2647
  task,
@@ -2285,6 +2652,8 @@ async function runIndependentValidator({
2285
2652
  } catch (error) {
2286
2653
  dispatched = normalizeDispatchResult({ exitCode: 1, stderr: error.message || String(error) }, validatorEngine);
2287
2654
  }
2655
+ const attempt = rosterAttemptRecord(validatorEngine, pin, startedMs, clock(), dispatched, prep);
2656
+ attempts.push(attempt);
2288
2657
  const after = stateInspector(worktreePath);
2289
2658
  const output = dispatchResultOutput(dispatched).slice(-8000);
2290
2659
  const verdictOutput = [dispatched.report, dispatched.stdout]
@@ -2306,6 +2675,12 @@ async function runIndependentValidator({
2306
2675
  brief_id: dispatched.brief_id || null,
2307
2676
  worktree_unchanged: unchanged,
2308
2677
  };
2678
+ if (attempt.outcome === 'landed') {
2679
+ if (!unchanged) Object.assign(attempt, { outcome: 'failed', detail: 'changed the worktree it was reviewing' });
2680
+ else if (verdict.verdict === 'reject') attempt.detail = 'rejected the change';
2681
+ else if (verdict.passed) attempt.detail = 'signed off';
2682
+ else Object.assign(attempt, { outcome: 'failed', detail: 'gave no clear verdict' });
2683
+ }
2309
2684
  if (!unchanged) return { ok: false, stage: 'validator_mutated_worktree', validator_result: validatorResult };
2310
2685
  if (exitCode !== 0) {
2311
2686
  const outage = detectDeadEngineDispatch(dispatched);
@@ -2620,6 +2995,17 @@ function defaultSelfLandCheck({ worktreePath, targetRef = DISPATCH_SELF_LAND_TAR
2620
2995
  // One flight. Staff -> dispatch in parallel -> land serially -> receipt.
2621
2996
  // No fleet state file: progress is narrated via `log`, durability lives in
2622
2997
  // worktrees, task claims, and the receipt written at the end.
2998
+ // Fleet builds run on each engine's own defaults; the one thing the build
2999
+ // line adds is its prep job, when the line for this same engine asks for one.
3000
+ function fleetPrepPin(engineName, root) {
3001
+ try {
3002
+ const pin = rosterPinFor('executor', engineName, root);
3003
+ return pin.roster_prep ? { prep: pin.roster_prep } : null;
3004
+ } catch {
3005
+ return null;
3006
+ }
3007
+ }
3008
+
2623
3009
  async function runFleetFlight({
2624
3010
  root = process.cwd(),
2625
3011
  slots = 3,
@@ -2633,6 +3019,8 @@ async function runFleetFlight({
2633
3019
  checkoutBase = 'origin/master',
2634
3020
  guardCliLink = guardGlobalCliLink,
2635
3021
  scoutAsk = null,
3022
+ clock = Date.now,
3023
+ prepAsk = undefined,
2636
3024
  } = {}) {
2637
3025
  const cli = ownCli || defaultOwnCli(root);
2638
3026
  // Staff first, rank second: every task staffFlight returns already cleared
@@ -2694,6 +3082,7 @@ async function runFleetFlight({
2694
3082
  briefId: entry.brief_id,
2695
3083
  skipBriefCapture: true,
2696
3084
  liveLogPath,
3085
+ ...(entry.roster_pin ? { model: entry.roster_pin.model, effort: entry.roster_pin.effort, maxSeconds: entry.roster_pin.maxSeconds } : {}),
2697
3086
  }));
2698
3087
  }));
2699
3088
  const restaffState = { used: false };
@@ -2733,12 +3122,16 @@ async function runFleetFlight({
2733
3122
  installedEngines: roster,
2734
3123
  restaffState,
2735
3124
  scoutAsk: scoutAsk === null && dispatcher ? false : (scoutAsk || undefined),
3125
+ clock,
3126
+ defaultPin: fleetPrepPin(entry.engine, root),
3127
+ prepAsk,
2736
3128
  }).then((result) => ({ entry, result }))
2737
3129
  ));
2738
3130
  flight.results = results.map(({ entry, result }) => {
2739
3131
  const row = { task: entry.task.display_id, engine: result.engine || entry.engine, exitCode: result.exitCode };
2740
3132
  if (result.brief_id) row.brief_id = result.brief_id;
2741
3133
  if (result.restaffed) row.restaffed = result.restaffed;
3134
+ if (result.handover) row.handover = result.handover;
2742
3135
  if (result.deadEngine) row.deadEngine = result.deadEngine;
2743
3136
  return row;
2744
3137
  });
@@ -2760,6 +3153,7 @@ async function runFleetFlight({
2760
3153
  const activeEngine = result.engine || entry.engine;
2761
3154
  const landingEntry = { ...entry, engine: activeEngine };
2762
3155
  if (result.restaffed) log(` restaffed ${entry.task.display_id}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.reason})`);
3156
+ if (result.handover) log(` handed over ${entry.task.display_id}: ${result.handover_reason}`);
2763
3157
  const buildFailure = detectDeadEngineDispatch(result);
2764
3158
  if (buildFailure) {
2765
3159
  const paused = {
@@ -2770,6 +3164,7 @@ async function runFleetFlight({
2770
3164
  detail: String(result.stderr || buildFailure.reason).slice(0, 200),
2771
3165
  };
2772
3166
  if (result.restaffed) paused.restaffed = result.restaffed;
3167
+ if (result.handover) paused.handover = result.handover;
2773
3168
  if (result.deadEngine) paused.deadEngine = result.deadEngine;
2774
3169
  flight.paused.push(paused);
2775
3170
  stampDispatchBrief(root, result.brief_id, 'fail', `build failed for ${entry.task.display_id}`);
@@ -2824,6 +3219,7 @@ async function runFleetFlight({
2824
3219
  log,
2825
3220
  });
2826
3221
 
3222
+ recordFlightRosterRuns(root, flight, results, { job: 'build', source: 'fleet' });
2827
3223
  flight.status = flight.paused.length ? 'failed' : 'completed';
2828
3224
  flight.finished_at = new Date().toISOString();
2829
3225
  atomicWriteFlightReceipt(flight, receiptPath);
@@ -2920,6 +3316,13 @@ async function runDispatchFlight({
2920
3316
  effort = '',
2921
3317
  maxSeconds = 0,
2922
3318
  validatorModels = null,
3319
+ // The roster job this dispatch runs for, and the clock its run record uses.
3320
+ rosterJob = 'build',
3321
+ clock = Date.now,
3322
+ // The job whose lead writes the requested engine a brief first ("prep:
3323
+ // search" on its roster line), and an injectable ask for that prep pass.
3324
+ prep = '',
3325
+ prepAsk = undefined,
2923
3326
  } = {}) {
2924
3327
  if (!engine) throw new Error('runDispatchFlight: engine is required');
2925
3328
  if (!DISPATCH_CAPABLE.includes(engine)) {
@@ -3046,6 +3449,7 @@ async function runDispatchFlight({
3046
3449
  remoteBoundary,
3047
3450
  remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
3048
3451
  startCommit,
3452
+ ...(maxSeconds ? { maxSeconds } : {}),
3049
3453
  ...(trustedPrompt ? { prompt: trustedPrompt } : {}),
3050
3454
  });
3051
3455
  log(` building ${taskId} in ${path.basename(worktreePath)}`);
@@ -3077,9 +3481,9 @@ async function runDispatchFlight({
3077
3481
  briefId: entry.brief_id,
3078
3482
  skipBriefCapture: true,
3079
3483
  liveLogPath,
3080
- model: entry.engine === engine ? model : '',
3081
- effort: entry.engine === engine ? effort : '',
3082
- maxSeconds: entry.engine === engine ? maxSeconds : 0,
3484
+ model: entry.roster_pin ? entry.roster_pin.model : (entry.engine === engine ? model : ''),
3485
+ effort: entry.roster_pin ? entry.roster_pin.effort : (entry.engine === engine ? effort : ''),
3486
+ maxSeconds: entry.roster_pin ? entry.roster_pin.maxSeconds : (entry.engine === engine ? maxSeconds : 0),
3083
3487
  }));
3084
3488
  }));
3085
3489
  const restaffState = { used: false };
@@ -3098,6 +3502,10 @@ async function runDispatchFlight({
3098
3502
  installedEngines,
3099
3503
  restaffState,
3100
3504
  scoutAsk: scoutAsk === null && dispatcher ? false : (scoutAsk || undefined),
3505
+ clock,
3506
+ defaultPin: { model, effort, maxSeconds: capSeconds(maxSeconds), ...(prep ? { prep } : {}) },
3507
+ job: rosterJob,
3508
+ prepAsk,
3101
3509
  }).then((result) => {
3102
3510
  const completedAtMs = Date.now();
3103
3511
  return {
@@ -3124,6 +3532,7 @@ async function runDispatchFlight({
3124
3532
  if (entry.startCommit) row.start_commit = entry.startCommit;
3125
3533
  if (result.brief_id) row.brief_id = result.brief_id;
3126
3534
  if (result.restaffed) row.restaffed = result.restaffed;
3535
+ if (result.handover) row.handover = result.handover;
3127
3536
  if (result.deadEngine) row.deadEngine = result.deadEngine;
3128
3537
  if (candidate && candidate.ok) {
3129
3538
  row.candidate_commit = candidate.commit;
@@ -3216,6 +3625,7 @@ async function runDispatchFlight({
3216
3625
  const readyActor = explicitActor || `fleet-${activeEngine}`;
3217
3626
  const landingEntry = { ...entry, engine: activeEngine, candidate };
3218
3627
  if (result.restaffed) log(` restaffed ${entry.taskId}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.reason})`);
3628
+ if (result.handover) log(` handed over ${entry.taskId}: ${result.handover_reason}`);
3219
3629
  const buildFailure = detectDeadEngineDispatch(result);
3220
3630
  if (buildFailure) {
3221
3631
  const paused = {
@@ -3227,6 +3637,7 @@ async function runDispatchFlight({
3227
3637
  worktree: entry.worktreePath,
3228
3638
  };
3229
3639
  if (result.restaffed) paused.restaffed = result.restaffed;
3640
+ if (result.handover) paused.handover = result.handover;
3230
3641
  if (result.deadEngine) paused.deadEngine = result.deadEngine;
3231
3642
  flight.paused.push(paused);
3232
3643
  stampDispatchBrief(root, result.brief_id, 'fail', `build failed for ${entry.taskId}`);
@@ -3368,6 +3779,8 @@ async function runDispatchFlight({
3368
3779
  validatorDispatcher,
3369
3780
  stateInspector: validatorStateInspector || reviewWorktreeSnapshot,
3370
3781
  validatorModels,
3782
+ clock,
3783
+ prepAsk,
3371
3784
  });
3372
3785
  validatorResult = validation.validator_result;
3373
3786
  flight.validator_result = validatorResult;
@@ -3612,6 +4025,11 @@ async function runDispatchFlight({
3612
4025
  log,
3613
4026
  });
3614
4027
 
4028
+ recordFlightRosterRuns(root, flight, results, {
4029
+ job: rosterJob,
4030
+ member: /^fleet-/.test(explicitActor) ? '' : explicitActor,
4031
+ source: flight.context && flight.context.source === 'one_lap' ? 'one lap' : 'dispatch',
4032
+ });
3615
4033
  flight.status = flight.paused.length ? 'failed' : 'completed';
3616
4034
  flight.finished_at = new Date().toISOString();
3617
4035
  writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly, enforceRemoteBoundary });