@turri-dev/cli 0.1.3-next.a5d3797 → 0.1.3-next.b675516

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.
@@ -7,6 +7,7 @@ import { confinedTools } from "./confine.js";
7
7
  import { ensureInModulesScratch, modulesScratchDir } from "./paths.js";
8
8
  import { workspaceBlock } from "./workspace.js";
9
9
  import { reportActivity } from "./activity.js";
10
+ import { runTiming, withTestingScripts } from "./test-scripts.js";
10
11
  import { createWebTools, WEB_TOOLS } from "./web.js";
11
12
  import { diffFiles, digestFiles } from "./codebase.js";
12
13
  import { ERROR_CAUSE_RULES, fixMethod, INTEGRITY_RULES, specPrimer } from "./prompt.js";
@@ -377,9 +378,17 @@ export function explorePrompt(buildDir, spec, scope, testingScripts, question, p
377
378
  ...(testingScripts !== undefined && testingScripts.length > 0
378
379
  ? [
379
380
  "",
380
- "This is the project's conformance testing script, which the harness runs to decide whether the",
381
- "tests pass - read it for what it builds or installs before the tests run, and so what the tests can depend on:",
382
- ...testingScripts.flatMap((script) => ["", `<script path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</script>"]),
381
+ ...(testingScripts.some((script) => script.prepare === true)
382
+ ? [
383
+ "These are the project's prepare script, which the harness runs on the build first, and its conformance testing",
384
+ "script, which runs only once the prepare script passes and decides whether the tests pass - read both for what",
385
+ "they build or install before the tests run, and so what the tests can depend on:",
386
+ ]
387
+ : [
388
+ "This is the project's conformance testing script, which the harness runs to decide whether the",
389
+ "tests pass - read it for what it builds or installs before the tests run, and so what the tests can depend on:",
390
+ ]),
391
+ ...testingScripts.flatMap((script) => ["", `<script${script.prepare === true ? ' kind="prepare environment"' : ""} path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</script>"]),
383
392
  ]
384
393
  : []),
385
394
  ...(previous !== undefined && question === undefined
@@ -703,7 +712,7 @@ export function unfoundedEvidence(evidence, opened) {
703
712
  "opened, the testing script, or the failing run - each with where. What <how to invoke it> says is not evidence: it " +
704
713
  "may be wrong, so check it against the testing script and the files.");
705
714
  }
706
- const inPrompt = (source) => /^(the )?(conformance )?testing script$|^(the )?failing run$/i.test(source.trim());
715
+ const inPrompt = (source) => /^(the )?(conformance )?testing script$|^(the )?prepare script$|^(the )?failing run$/i.test(source.trim());
707
716
  const unread = given.filter((item) => !inPrompt(item.source) && !opened(item.source.trim()));
708
717
  if (unread.length > 0) {
709
718
  return (`Your evidence cites ${unread.map((item) => item.source.trim()).join(", ")}, which you did not open. Open each file you cite ` +
@@ -734,7 +743,7 @@ export async function judgeFailure(options) {
734
743
  description: "For code: the conformance testing script's own words for what it needs the build to have - the file, entry point or module the run failed on - copied exactly from the script",
735
744
  })),
736
745
  evidence: Type.Optional(Type.Array(Type.Object({
737
- source: Type.String({ description: "Where: the path of a file you opened, `testing script`, or `failing run`" }),
746
+ source: Type.String({ description: "Where: the path of a file you opened, `testing script`, `prepare script` where there is one, or `failing run`" }),
738
747
  shows: Type.String({ description: "What it shows, e.g. `the script runs node dist/cli.js` or `tsconfig.json emits src/cli.ts as dist/src/cli.js`" }),
739
748
  }), { description: "What you found yourself, each with where - never what <how to invoke it> claims unchecked" })),
740
749
  functionalities: Type.Optional(Type.Array(Type.Object({
@@ -882,6 +891,8 @@ export function judgePrompt(options) {
882
891
  " the tests set up themselves - a credential, a variable or a fixture a test sets, overrides or names wrong: that is",
883
892
  " tests; nor for a toolchain or package missing where the tests run: the agents install those. The run stops on it",
884
893
  ` once it has been judged so ${ENVIRONMENT_RAISES} times in a row; until then the tests are checked for whether they cause it.`,
894
+ " A run the time limit stopped is environment only when nothing hangs and it needs more time than a test run may",
895
+ " take - and then it stops at once.",
885
896
  "",
886
897
  "When the output does not show why the software failed - it answered with an error that names no cause, and",
887
898
  "logged nothing about it - the code is what is wrong first: it hides the cause, and nobody can fix what they",
@@ -897,6 +908,12 @@ export function judgePrompt(options) {
897
908
  "what is wrong. What it does with the tests, the tests have to fit; what it needs of the code - a file it runs or",
898
909
  "checks for, a command it builds with - the code has to provide. A verdict that the script is wrong is neither",
899
910
  "code nor tests: say which of the two has to change to get past it.",
911
+ ...(scripts.some((script) => script.prepare === true)
912
+ ? [
913
+ "The prepare script below it runs on the build before every run, and the conformance testing script only once it",
914
+ "passes: it binds the same way - neither agent can change it, and what it needs of the code the code provides.",
915
+ ]
916
+ : []),
900
917
  "",
901
918
  ]
902
919
  : []),
@@ -926,13 +943,41 @@ export function judgePrompt(options) {
926
943
  "",
927
944
  `<how to invoke it>\n${options.invocation.trim()}\n</how to invoke it>`,
928
945
  "",
929
- ...scripts.flatMap((script) => [`<conformance testing script path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</conformance testing script>", ""]),
946
+ ...scripts.flatMap((script) => {
947
+ const tag = script.prepare === true ? "prepare script" : "conformance testing script";
948
+ return [`<${tag} path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), `</${tag}>`, ""];
949
+ }),
950
+ ...(options.run.timedOut === true ? [...timedOutBlock(options.run), ""] : []),
930
951
  `<failing run exit="${options.run.exitCode}" command="${options.run.command}">`,
952
+ runTiming(options.run),
953
+ "",
931
954
  options.run.output.trimEnd(),
932
955
  "</failing run>",
933
956
  ...(options.escalation !== undefined ? ["", ...escalationBlock(options.escalation)] : []),
934
957
  ].join("\n");
935
958
  }
959
+ /**
960
+ * For the judge, of a run the time limit stopped: what kept it from finishing - something that hangs, the code's or a
961
+ * test's, or a suite that is only slow, which no agent can give more time.
962
+ */
963
+ export function timedOutBlock(run) {
964
+ const limit = Math.round(run.timeoutMs / 1000);
965
+ return [
966
+ "<timed out>",
967
+ `This run did not end on its own: the time limit stopped it at ${limit}s, and it counts as failed. Its output ends where it`,
968
+ "was stopped - the step running then, or the one after the last it printed, is what did not finish. Decide what kept it:",
969
+ "- code: the software hangs - it waits on input, keeps a server in the foreground, never exits, loops, or blocks on a",
970
+ " lock or a call that never answers - when the tests reach it as the specification says.",
971
+ "- tests: a test waits for something that never comes - a process it started and never stops, output it waits for that",
972
+ " the software never prints, a port or a file nobody makes, a sleep or a retry with no end.",
973
+ "- environment: nothing hangs - the output shows the run making steady progress right up to the limit (installing,",
974
+ " building, test after test passing) and it needs more time than a test run may take. No agent can give it that: the",
975
+ " run stops at once, and the person is told to raise the limit. Say from the output how far it got, and what took",
976
+ " the time.",
977
+ "Judge from the output and the files, never from the time alone.",
978
+ "</timed out>",
979
+ ];
980
+ }
936
981
  /** For the judge: the tests were fixed for this failure, and it did not go away - is it the code's? */
937
982
  function escalationBlock(escalation) {
938
983
  const changed = escalation.changed.length === 0 ? "changed no file" : `changed ${escalation.changed.slice(0, 8).join(", ")}${escalation.changed.length > 8 ? ` and ${escalation.changed.length - 8} more` : ""}`;
@@ -976,7 +1021,10 @@ export async function createCodeFixer(options) {
976
1021
  model: options.model,
977
1022
  label: `${options.label} coding agent`, role: "coding",
978
1023
  system: `${fence.note}\n\n${workspaceBlock({ dir: options.buildDir, hasMyDiff: options.toolNames.includes("my_diff") })}`,
979
- task: codeFixerTask(options),
1024
+ // What the conformance testing script needs of the build, the code provides: the fixer reads it, not only a judge's quote of it.
1025
+ task: options.testingScripts !== undefined
1026
+ ? withTestingScripts(codeFixerTask(options), options.testingScripts.scripts, ["conformance", "unit"], options.testingScripts.root)
1027
+ : codeFixerTask(options),
980
1028
  compaction: { instructions: CODER_COMPACTION },
981
1029
  ...(options.checkEnvironment !== undefined ? { checkEnvironment: options.checkEnvironment } : {}),
982
1030
  });
@@ -1029,6 +1077,8 @@ export function codeFixPrompt(options) {
1029
1077
  `<why the code>\n${options.reason.trim()}\n</why the code>`,
1030
1078
  "",
1031
1079
  `<failing run command="${options.run.command}">`,
1080
+ runTiming(options.run),
1081
+ "",
1032
1082
  options.run.output.trimEnd(),
1033
1083
  "</failing run>",
1034
1084
  "",
@@ -1062,6 +1112,8 @@ export function escalatedFixPrompt(options) {
1062
1112
  "</evidence>",
1063
1113
  "",
1064
1114
  `<failing run command="${options.run.command}">`,
1115
+ runTiming(options.run),
1116
+ "",
1065
1117
  options.run.output.trimEnd(),
1066
1118
  "</failing run>",
1067
1119
  "",
@@ -1099,6 +1151,8 @@ export function testsFixPrompt(options) {
1099
1151
  `<why the tests>\n${options.reason.trim()}\n</why the tests>`,
1100
1152
  "",
1101
1153
  "<failing run>",
1154
+ runTiming(options.run),
1155
+ "",
1102
1156
  options.run.output.trimEnd(),
1103
1157
  "</failing run>",
1104
1158
  "",
@@ -9,12 +9,12 @@ import { abandonWork, diffSinceSnapshot, moduleDirOf, recordHistory, startWork,
9
9
  import { createWorkLedger, describeAccounts, SUBMIT_WORK_NOTE, unanswered, unansweredPrompt } from "./submit-work.js";
10
10
  import { CONFORMANCE_COMPACTION } from "./compaction.js";
11
11
  import { NOTES_TURN, notesForSession, passedOutcome, readNotes, reflectOnReview, withNotes } from "./notes.js";
12
- import { labelsPrompt, labelsPromptFor, misplacedConformanceTests, misplacedTestsPrompt, missingTestLabels } from "./test-kinds.js";
12
+ import { acceptanceLabel, acceptanceText, labelsPrompt, labelsPromptFor, misplacedConformanceTests, misplacedTestsPrompt, missingTestLabels } from "./test-kinds.js";
13
13
  import { createScriptTool, findTestScripts, projectRootFor, requireTestScripts, runConformanceScript, runUnitScript, failureSignature, STUCK_ROUNDS, scriptFailure, staticChecksOnly, withTestingScripts, } from "./test-scripts.js";
14
14
  import { createWebTools, WEB_TOOLS } from "./web.js";
15
15
  import { createMyDiffTool } from "./self-check.js";
16
16
  import { createServiceTools } from "./services.js";
17
- import { missingToolchainError, toolchainError } from "./environment-error.js";
17
+ import { missingToolchainError, timeLimitError, toolchainError } from "./environment-error.js";
18
18
  import { workspaceBlock } from "./workspace.js";
19
19
  import { codeFixPrompt, conflictKey, escalatedFixPrompt, createCodeFixer, answerAsExplorer, createAskExplorerTool, exploreInvocation, judgeFailure, SPEC_CONFLICT_RAISES, ENVIRONMENT_RAISES, environmentCheckPrompt, createTestReviewer, testReviewRequest, reviewFeedbackPrompt, testsFixPrompt, sortedRefs, } from "./conformance-agents.js";
20
20
  import { resolveRequiresChain } from "./requires.js";
@@ -157,9 +157,11 @@ export async function renderConformanceTests(options) {
157
157
  // may read the code but writes only tests. An explorer reads the code first and writes down how the
158
158
  // software is invoked, which is what the tests are written against.
159
159
  const label = `${moduleName} ${options.diff !== undefined ? "spec change" : frid}`;
160
- const specContext = { moduleName, text: specText(specifications, moduleName, frids, required, options.change ?? options.diff, sectionModules(tree, moduleName)) };
161
160
  // For a spec change: the functionalities it touches, whose acceptance tests are labelled, checked and reviewed.
162
161
  const touched = forChange ? touchedFunctionalities(tree, frids, options.diff) : [];
162
+ // The acceptance tests the run's tests carry out: every functionality the change touches, or the one under test.
163
+ const carriedOut = forChange ? touched : [{ frid: frid, acceptanceTests: specifications[ACCEPTANCE_TESTS] ?? [] }];
164
+ const specContext = { moduleName, text: specText(specifications, moduleName, frids, required, carriedOut, options.change ?? options.diff, sectionModules(tree, moduleName)) };
163
165
  // Any functionality of the codebase - this module's, and those of the modules it requires. A
164
166
  // functionality's run may raise a spec conflict only with that functionality; a spec diff's, between any two.
165
167
  const own = (forChange ? frids : frids.slice(0, frids.indexOf(frid) + 1)).map((id) => ({ module: moduleName, frid: id }));
@@ -223,11 +225,15 @@ export async function renderConformanceTests(options) {
223
225
  };
224
226
  stage(`exploring the code for how it is invoked${options.model ? ` (${options.model})` : ""}`);
225
227
  // The explorer reads the conformance testing script too: it is how the tests are built and run, and so how they reach the software.
228
+ // The prepare script, where there is one, runs on the build before it - and only once it passes does the suite run.
226
229
  const testingScripts = [
230
+ ...(scripts.prepare !== undefined
231
+ ? [{ path: relative(root, scripts.prepare), runs: "<script> <copy of the build>, before every run of the conformance testing script", content: readFileSync(scripts.prepare, "utf8"), prepare: true }]
232
+ : []),
227
233
  { path: relative(root, scripts.conformance), runs: "<script> <copy of the build> <copy of the whole conformance suite>", content: readFileSync(scripts.conformance, "utf8") },
228
234
  ];
229
235
  // The acceptance tests the explorer's plan has to carry out, step by step.
230
- const toCarryOut = (forChange ? touched : [{ frid: frid, acceptanceTests: specifications[ACCEPTANCE_TESTS] ?? [] }])
236
+ const toCarryOut = carriedOut
231
237
  .map((item) => ({ module: moduleName, frid: item.frid, count: item.acceptanceTests.length }))
232
238
  .filter((item) => item.count > 0);
233
239
  let invocation = await exploreInvocation({ buildDir, spec: specContext, scope, testingScripts, acceptance: toCarryOut, label, model: options.model, onDelta: options.onDelta });
@@ -319,6 +325,7 @@ export async function renderConformanceTests(options) {
319
325
  ],
320
326
  toolNames: ["run_unit_tests", "run_conformance_tests", ...fixerServices.names, "update_notes"],
321
327
  checkEnvironment: { run: () => runUnitScript(buildDir, scripts.unit), script: scripts.unit },
328
+ testingScripts: { scripts, root },
322
329
  });
323
330
  fixerConformanceRuns.newTurn();
324
331
  return ask(fixer, withNotes(text, buildDir), options.onDelta);
@@ -494,19 +501,6 @@ export async function renderConformanceTests(options) {
494
501
  idleFixes = 0;
495
502
  continue;
496
503
  }
497
- // A run out of time is not a question of test or code: the tests' environment is the conformance agent's.
498
- if (recheck.timedOut === true) {
499
- const moved = await unmoved();
500
- if (moved === "stuck")
501
- break;
502
- if (moved === "asked")
503
- continue;
504
- stage("the conformance run did not get to the tests - back to the conformance agent");
505
- const fixed = await changing(() => askConformance(scriptFailure(recheck, "conformance testing"), options.onDelta));
506
- response = fixed.result;
507
- recordAttempt(rounds, recheck, `not a question of code or tests (${recheck.timedOut === true ? "the time limit" : `exit ${recheck.exitCode}`})`, { who: "conformance agent", files: fixed.files });
508
- continue;
509
- }
510
504
  stage(`conformance tests failing - working out whether the code, the tests or the spec are wrong${judgeModel ? ` (${judgeModel})` : ""}`);
511
505
  // A fix that changed no file leaves the next run as this one: IDLE_FIXES of them in a row, and nothing will change.
512
506
  const idle = (files) => {
@@ -541,6 +535,14 @@ export async function renderConformanceTests(options) {
541
535
  onDelta: options.onDelta,
542
536
  });
543
537
  const reason = verdict.reason.split("\n")[0].slice(0, 160);
538
+ // Stopped by the time limit while it was still getting on: no agent can give it more time. The pulse stops now -
539
+ // rolled back, no retry - and the person is told how to raise the limit.
540
+ if (verdict.culprit === "environment" && recheck.timedOut === true) {
541
+ stage(`the conformance run needs more than the ${Math.round(recheck.timeoutMs / 1000)}s a test run may take - stopping`);
542
+ if (start !== undefined)
543
+ abandonWork(start, moduleName, "the conformance testing script needs more time than a test run may take");
544
+ throw timeLimitError(recheck, { moduleName, script: scripts.conformance, kind: "conformance", reason: verdict.reason });
545
+ }
544
546
  // The environment - what the run is given - is the person's to fix: once judged so twice in a row, the run stops.
545
547
  if (verdict.culprit === "environment") {
546
548
  environmentRaises += 1;
@@ -692,7 +694,7 @@ export async function renderConformanceTests(options) {
692
694
  buildDir,
693
695
  conformanceDir,
694
696
  spec: specContext,
695
- functionalities: forChange ? touched : [{ frid: frid, acceptanceTests: specifications[ACCEPTANCE_TESTS] ?? [] }],
697
+ functionalities: carriedOut,
696
698
  forChange,
697
699
  invocation,
698
700
  label,
@@ -952,9 +954,10 @@ export function failureLine(run) {
952
954
  }
953
955
  /**
954
956
  * The spec as the explorer, the judge and the coding agent are shown it - its functional specs
955
- * numbered with their ids, so a spec conflict can name them.
957
+ * numbered with their ids, so a spec conflict can name them, and the acceptance tests the run carries
958
+ * out labelled as their tests are, so a verdict can quote them.
956
959
  */
957
- function specText(specifications, moduleName, frids, required, diff, modules) {
960
+ function specText(specifications, moduleName, frids, required, acceptance, diff, modules) {
958
961
  // Every functionality in the codebase, each with its module: the required modules' first, then this one's.
959
962
  const numbered = (key, items) => key === FUNCTIONAL_REQUIREMENTS
960
963
  ? [
@@ -962,10 +965,14 @@ function specText(specifications, moduleName, frids, required, diff, modules) {
962
965
  ]
963
966
  : items;
964
967
  // Each section in its tag, as every agent is shown it (see specPrimer).
965
- const sections = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, FUNCTIONAL_REQUIREMENTS, ACCEPTANCE_TESTS]
968
+ const sections = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, FUNCTIONAL_REQUIREMENTS]
966
969
  .map((key) => [key, specifications[key] ?? []])
967
970
  .filter(([, items]) => items.length > 0)
968
- .map(([key, items]) => (key === FUNCTIONAL_REQUIREMENTS || key === ACCEPTANCE_TESTS ? section(key, numbered(key, items)) : moduleSection(key, items, moduleName, modules)));
971
+ .map(([key, items]) => (key === FUNCTIONAL_REQUIREMENTS ? section(key, numbered(key, items)) : moduleSection(key, items, moduleName, modules)));
972
+ // Not only the last functionality's: a spec change's run carries out the acceptance tests of every one it touches.
973
+ const tests = acceptance.flatMap((item) => item.acceptanceTests.map((test, index) => `${acceptanceLabel(item.frid, index + 1)}: ${acceptanceText(test)}`));
974
+ if (tests.length > 0)
975
+ sections.push(section(ACCEPTANCE_TESTS, tests));
969
976
  if (diff !== undefined)
970
977
  sections.push(specChangeBlock(diff));
971
978
  return sections.join("\n\n");
@@ -4,6 +4,7 @@ import { Type } from "typebox";
4
4
  import { ask, BEGIN, createExecutor } from "../pi.js";
5
5
  import { confinedTools } from "./confine.js";
6
6
  import { createWebTools, WEB_TOOLS } from "./web.js";
7
+ import { runTiming } from "./test-scripts.js";
7
8
  /**
8
9
  * A fresh look at a failure the coding agent keeps hitting.
9
10
  *
@@ -24,9 +25,12 @@ export function diagnosisText(diagnosis) {
24
25
  }
25
26
  function debugPrompt(input) {
26
27
  const script = input.script !== undefined && existsSync(input.script) ? readFileSync(input.script, "utf8") : undefined;
28
+ const timedOut = input.run?.timedOut === true;
27
29
  return [
28
- `The unit tests of the codebase in ${input.buildDir} have failed the same way ${input.times} times in a row. The coding agent`,
29
- "keeps changing things and the failure does not move: it has not seen the cause. You look at it with fresh eyes.",
30
+ timedOut
31
+ ? `The unit testing script of the codebase in ${input.buildDir} did not finish: the time limit stopped it. You look at it with fresh eyes.`
32
+ : `The unit tests of the codebase in ${input.buildDir} have failed the same way ${input.times} times in a row. The coding agent`,
33
+ ...(timedOut ? [] : ["keeps changing things and the failure does not move: it has not seen the cause. You look at it with fresh eyes."]),
30
34
  "",
31
35
  "Find the root cause - the one thing that makes this failure happen - and the change that fixes it. Read the failing",
32
36
  "run below, the tests it names, the code they exercise, the build files (pyproject.toml, package.json, go.mod, pom.xml,",
@@ -37,6 +41,21 @@ function debugPrompt(input) {
37
41
  "",
38
42
  "You change nothing - you only read. When you know, call submit_diagnosis: the root cause, with the evidence for it,",
39
43
  "and the fix - which files, and exactly what to change in them.",
44
+ ...(timedOut
45
+ ? [
46
+ "",
47
+ "<timed out>",
48
+ "The run's output ends where the time limit stopped it: the test running then, or the step after the last one it",
49
+ "printed, is what did not finish. Find what kept it:",
50
+ "- something hangs - a test or the code waits on input, keeps a server in the foreground, never exits, loops, blocks",
51
+ " on a lock or a network call that never answers: the root cause and the fix, as for any failure;",
52
+ "- nothing hangs - the output shows the run making steady progress right up to the limit (installing, building, test",
53
+ " after test passing) and it needs more time than a test run may take: set too_slow, and say in root_cause from the",
54
+ " output how far it got and what took the time. No agent can give it more time: the work stops, and the person is",
55
+ " told to raise the limit. Judge from the output and the files, never from the time alone.",
56
+ "</timed out>",
57
+ ]
58
+ : []),
40
59
  "",
41
60
  "<what the change is meant to do>",
42
61
  input.task.trim(),
@@ -44,6 +63,7 @@ function debugPrompt(input) {
44
63
  ...(script !== undefined ? ["", `<testing script path="${input.script}">`, script.trimEnd(), "</testing script>"] : []),
45
64
  "",
46
65
  "<failing run>",
66
+ ...(input.run !== undefined ? [runTiming(input.run), ""] : []),
47
67
  input.failure.trimEnd().slice(-8000),
48
68
  "</failing run>",
49
69
  ].join("\n");
@@ -58,10 +78,14 @@ export async function debugFailure(input) {
58
78
  parameters: Type.Object({
59
79
  root_cause: Type.String({ description: "The one thing that makes the failure happen, and the evidence for it: the lines of the run, the code, the docs" }),
60
80
  fix: Type.String({ description: "The change that fixes it: which files, and exactly what to change in them" }),
81
+ too_slow: Type.Optional(Type.Boolean({
82
+ description: "Only for a run the time limit stopped: true when nothing hangs - it was making progress up to the limit and needs more time than a test run may take",
83
+ })),
61
84
  }),
62
85
  async execute(_id, params) {
63
86
  const given = params;
64
- found = { rootCause: given.root_cause, fix: given.fix };
87
+ // Too slow is a verdict on a run the limit stopped, and on no other.
88
+ found = { rootCause: given.root_cause, fix: given.fix, ...(given.too_slow === true && input.run?.timedOut === true ? { tooSlow: true } : {}) };
65
89
  return { content: [{ type: "text", text: "Recorded." }], details: {}, terminate: true };
66
90
  },
67
91
  });
@@ -224,6 +224,23 @@ export function toolchainError(missing, details) {
224
224
  source: "harness",
225
225
  });
226
226
  }
227
+ /**
228
+ * A testing script the time limit stopped while it was still making progress - judged so by the judge or the debugger,
229
+ * from where its output stops: nothing hangs, the suite needs more time than a test run may take. No agent can give it
230
+ * that, so the pulse stops and the person is told how to.
231
+ */
232
+ export function timeLimitError(run, details) {
233
+ const limit = Math.round(run.timeoutMs / 1000);
234
+ return new EnvironmentError({
235
+ moduleName: details.moduleName,
236
+ missing: `more time for a test run than its ${limit}s limit`,
237
+ neededBy: `the ${details.kind} testing script, \`${basename(details.script)}\``,
238
+ shownBy: `it ran ${Math.round((run.elapsedMs ?? run.timeoutMs) / 1000)}s and was stopped at the limit, still making progress - ${details.reason.replace(/\s+/g, " ").trim().slice(0, 400)}`,
239
+ fix: `give a test run more time - set TURRI_COMMAND_TIMEOUT, in seconds, above ${limit} (e.g. TURRI_COMMAND_TIMEOUT=${limit * 3}) in the ` +
240
+ "environment turri runs in - or make the suite faster: what it installs or builds on every run, and the tests that take longest.",
241
+ source: "agent",
242
+ });
243
+ }
227
244
  /** The harness's run of a testing script, when it could not find its toolchain: the error that stops the pulse. */
228
245
  export function missingToolchainError(run, details) {
229
246
  const missing = missingToolchain(run);
@@ -278,8 +278,10 @@ function itemText(serialized) {
278
278
  return item;
279
279
  const { markdown, [ACCEPTANCE_TESTS]: tests } = (item ?? {});
280
280
  const lines = [markdown ?? JSON.stringify(item)];
281
+ // Each test is an item of the tree, its text in its markdown - as spec-steps.ts `itemText` reads it.
282
+ const testText = (test) => (typeof test === "string" ? test : (test?.markdown ?? JSON.stringify(test)));
281
283
  if (Array.isArray(tests) && tests.length > 0)
282
- lines.push(" <acceptance tests>", ...tests.map((test) => ` ${String(test)}`), " </acceptance tests>");
284
+ lines.push(" <acceptance tests>", ...tests.map((test) => ` ${testText(test).replace(/\n/g, "\n ")}`), " </acceptance tests>");
283
285
  return lines.join("\n");
284
286
  }
285
287
  const indent = (text, by) => text.split("\n").map((line) => `${by}${line}`).join("\n");
@@ -20,7 +20,7 @@ import { createWebTools, WEB_TOOLS } from "./web.js";
20
20
  import { createCanItFailTool, createMyDiffTool, selfCheckNote } from "./self-check.js";
21
21
  import { createServiceTools } from "./services.js";
22
22
  import { createUnitGate } from "./unit-gate.js";
23
- import { toolchainError } from "./environment-error.js";
23
+ import { timeLimitError, toolchainError } from "./environment-error.js";
24
24
  import { workspaceBlock } from "./workspace.js";
25
25
  import { ATTEMPTS, describeStop } from "./stop-report.js";
26
26
  import { summarizeDiff } from "./job-panel.js";
@@ -333,6 +333,8 @@ export async function renderSpecDiff(options) {
333
333
  let draftAsks = 0;
334
334
  /** What the unit testing script needs and does not find, last run. */
335
335
  let missing;
336
+ /** The unit testing script needs more time than a test run may take - the debugger's finding: the pulse stops on it. */
337
+ let slow;
336
338
  /** The last round failed on the script's static checks alone: the unit tests passed. */
337
339
  let staticOnly = false;
338
340
  const testRemovals = [];
@@ -423,24 +425,35 @@ export async function renderSpecDiff(options) {
423
425
  seenFailures.set(key, seen);
424
426
  let prompt = verdict.prompt;
425
427
  const circling = loop.stuck;
426
- if (circling || (key !== undefined && seen >= STUCK_ROUNDS)) {
428
+ // A run the time limit stopped is looked at at once: whether something hangs, or the suite only needs more time.
429
+ const timedOut = verdict.run.timedOut === true;
430
+ if (circling || timedOut || (key !== undefined && seen >= STUCK_ROUNDS)) {
427
431
  if (key !== undefined)
428
432
  seenFailures.delete(key);
429
433
  const times = circling ? Math.max(seen, loop.failing) : seen;
430
434
  loop.reset();
431
435
  stage(circling
432
436
  ? `the coding agent is going round in circles - ${times} failing runs - a debugger looks at it with fresh eyes`
433
- : `the same failure ${seen} times - a debugger looks at it with fresh eyes`);
437
+ : timedOut && seen < STUCK_ROUNDS
438
+ ? `the unit testing script did not finish in ${Math.round(verdict.run.timeoutMs / 1000)}s - a debugger works out whether something hangs`
439
+ : `the same failure ${seen} times - a debugger looks at it with fresh eyes`);
434
440
  const found = await debugFailure({
435
441
  buildDir,
436
442
  failure: verdict.run.output,
437
443
  times,
438
444
  task: steeredChange() ?? codingText ?? changeText ?? options.diff ?? "",
439
445
  script: verdict.script,
446
+ run: verdict.run,
440
447
  label: `${moduleName} spec change`,
441
448
  ...(debuggerModel !== undefined ? { model: debuggerModel } : {}),
442
449
  onDelta: options.onDelta,
443
450
  }).catch(() => undefined);
451
+ // Nothing hangs: the suite needs more time than a test run may take, and no agent can give it that.
452
+ if (found?.tooSlow === true) {
453
+ stage(`the unit testing script needs more than the ${Math.round(verdict.run.timeoutMs / 1000)}s a test run may take - stopping`);
454
+ slow = timeLimitError(verdict.run, { moduleName, script: verdict.script, kind: "unit", reason: found.rootCause });
455
+ return false;
456
+ }
444
457
  if (found !== undefined) {
445
458
  const cause = found.rootCause.split("\n")[0].slice(0, 240);
446
459
  reportActivity(` the debugger: ${cause.slice(0, 160)}`);
@@ -486,6 +499,7 @@ export async function renderSpecDiff(options) {
486
499
  ...(options.cases !== undefined ? { cases: options.cases } : {}),
487
500
  label: `${moduleName} spec change`,
488
501
  ...(scripts.runOn === "copy" ? { unitScript: scripts.unit } : {}),
502
+ testingScript: { path: relative(root, scripts.unit), content: readFileSync(scripts.unit, "utf8") },
489
503
  model: options.judgeModel ?? modelSpec(inheritModel(configForSpec(tip.plainFile).judgeModel, modelOnly(options.model))),
490
504
  send: async (next) => {
491
505
  response = await ask(executor, next, options.onDelta);
@@ -543,13 +557,17 @@ export async function renderSpecDiff(options) {
543
557
  stage(unitReviewStopped ? "the unit tests pass but still leave the change untested - undoing the change" : "unit tests NOT passing - undoing the change");
544
558
  abandonWork(unit, moduleName, missing !== undefined
545
559
  ? "the unit testing script could not run"
546
- : staticOnly
547
- ? "the spec change did not pass its static checks"
548
- : "the spec change did not pass its unit tests");
560
+ : slow !== undefined
561
+ ? "the unit testing script needs more time than a test run may take"
562
+ : staticOnly
563
+ ? "the spec change did not pass its static checks"
564
+ : "the spec change did not pass its unit tests");
549
565
  // The environment lacks what the testing script needs: the pulse stops, rolled back, and the person is told.
550
566
  if (missing !== undefined) {
551
567
  throw toolchainError(missing, { moduleName, script: scripts.unit, kind: "unit" });
552
568
  }
569
+ if (slow !== undefined)
570
+ throw slow;
553
571
  return { ...base, passed: false, stoppedAt: "unit tests", ...(staticOnly && !unitReviewStopped ? { staticChecks: true } : {}) };
554
572
  }
555
573
  stage("unit tests pass - landing the change on them");
@@ -335,7 +335,15 @@ async function runScript(cwd, script, args, logDir, options = {}) {
335
335
  const noted = withScriptLineNote(text, script);
336
336
  // Its failures, counts and end are what the agent is shown; the whole is kept, a read_output away.
337
337
  const log = keepTestOutput(logDir, basename(script), noted);
338
- return { command: shown(script), exitCode: timedOut === true ? 124 : exitCode, output: shownTestOutput(noted, log), timedOut: timedOut === true, timeoutMs, ...(log !== undefined ? { log } : {}) };
338
+ return {
339
+ command: shown(script),
340
+ exitCode: timedOut === true ? 124 : exitCode,
341
+ output: shownTestOutput(noted, log),
342
+ timedOut: timedOut === true,
343
+ timeoutMs,
344
+ elapsedMs: Date.now() - started,
345
+ ...(log !== undefined ? { log } : {}),
346
+ };
339
347
  }
340
348
  /** What bash says when a line of the script itself cannot run: a command or file that is not there, or not runnable. */
341
349
  const OWN_LINE_FAILURE = /: (No such file or directory|command not found|Permission denied)$/;
@@ -589,6 +597,19 @@ function loopNotice(loop) {
589
597
  "the failure with fresh eyes, and you will be given what it finds.",
590
598
  ].join("\n");
591
599
  }
600
+ /**
601
+ * How long a run took against its time limit, in a line: for whoever judges it or works out its cause - so a run that
602
+ * ended on its own is never taken for one the limit stopped, nor the other way.
603
+ */
604
+ export function runTiming(run) {
605
+ const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
606
+ const limit = seconds(run.timeoutMs);
607
+ if (run.timedOut === true) {
608
+ return `The time limit stopped this run: it had run ${seconds(run.elapsedMs ?? run.timeoutMs)} of the ${limit} a test run may take, and had not finished.`;
609
+ }
610
+ const took = run.elapsedMs !== undefined ? `after ${seconds(run.elapsedMs)} of the ${limit} a test run may take` : `well within the ${limit} a test run may take`;
611
+ return `This run ended on its own - exit ${run.exitCode}, ${took}. The time limit did not stop it and played no part in how it ended.`;
612
+ }
592
613
  /** A run the time limit stopped: failed, and what most often keeps a test run from ending. */
593
614
  function timedOutFailure(run, what) {
594
615
  const limit = Math.round(run.timeoutMs / 1000);
@@ -63,6 +63,17 @@ export function unitReviewTask(options) {
63
63
  options.spec.trim(),
64
64
  "</specification>",
65
65
  "",
66
+ ...(options.testingScript !== undefined
67
+ ? [
68
+ "The harness runs the unit tests with this script, and it decides whether they pass. Read it for which tests it",
69
+ "runs and how: a test it never reaches - a file it does not pick up, a folder it does not look in - is not run, and",
70
+ "checks nothing.",
71
+ `<unit testing script path="${options.testingScript.path}">`,
72
+ options.testingScript.content.trimEnd(),
73
+ "</unit testing script>",
74
+ "",
75
+ ]
76
+ : []),
66
77
  ...(options.cases !== undefined ? [...codeReviewCasesBlock(options.cases), ""] : []),
67
78
  // A job that touches no functional spec is held only to that: the checks of behaviour are not its.
68
79
  ...(mechanical ? [] : ["Check that the code does what the specification states for this work:"]),
@@ -160,6 +160,10 @@ runs the unit testing script itself**, and only that run counts (see
160
160
  tried. An agent circles a cause it has not thought of - an in-memory database every connection opens
161
161
  empty, a test runner that leaves the code off its path - and this is the way out. The rounds go on as
162
162
  before, up to 20; should the same failure come round three times again, the debugger looks again.
163
+ **A run the time limit stopped** is looked at by the debugger at once, told how long the run had and that the limit
164
+ stopped it: it finds what hangs - the root cause and the fix, as above - or that nothing does, the run getting on up
165
+ to the limit and needing more time than a test run may take. Then the pulse stops, rolled back and not retried, and
166
+ the person is told to raise `TURRI_COMMAND_TIMEOUT` or make the suite faster.
163
167
  - **It goes round in circles in its own runs.** An agent stuck on a failure can try the same few changes in
164
168
  turn - three signatures of a stand-in function, say - and run the tests after each, dozens of times in one
165
169
  turn. Its own `run_unit_tests` runs are watched (`createLoopWatch` in `test-scripts.ts`): the code folder back
@@ -262,7 +266,8 @@ Passing unit tests prove only what they check, and a coding agent is quick to wr
262
266
  next to nothing - that a function exists, that a result is not empty, that a status is 200 - or code
263
267
  that no test reaches. So, with `review: true` in the [config](config.md), once the unit testing script
264
268
  passes a reviewer on the judge model reads what the work wrote, beside the spec, and changes nothing. It
265
- reviews both halves. The review is off by default - `review: false`, or the key left out - and so is the
269
+ reviews both halves, with the unit testing script word for word in its prompt: a test the script never reaches is
270
+ not run, and checks nothing. The review is off by default - `review: false`, or the key left out - and so is the
266
271
  conformance tests' review: the work goes on once its tests pass.
267
272
 
268
273
  - **The code.** Every behaviour, edge case, validation rule and error the spec states for this work
@@ -499,8 +504,9 @@ testing script, stops before it starts and says which skill adds it.
499
504
  `prepare-environment-script:` in the config, else the one `prepare_environment*.sh` at the root, else in
500
505
  `test_scripts/` - on the build, before every run of the conformance testing script: the harness's own, and the agents'
501
506
  `run_conformance_tests`. Both get the same build folder - the code in place, or the same fresh copy - so what it sets up
502
- for that build, the conformance testing script finds. With conformance off it is not run. The conformance agents are
503
- shown it, before the conformance testing script.
507
+ for that build, the conformance testing script finds. With conformance off it is not run. Every agent shown the
508
+ conformance testing script - the explorer, the conformance agent, the judge, the code fixer and the repair agent - is
509
+ shown it too, before the conformance testing script.
504
510
 
505
511
  **Where they run.** On this machine, as you would run them: unfenced - no sandbox, no command check, no audit - in
506
512
  your own environment with this machine's toolchains, turri's model providers' API keys alone left out. They are yours,
@@ -832,7 +838,8 @@ coding agent that fixes the code is told the same of the tests: it can read them
832
838
  Before the test author starts, an explorer - an agent in
833
839
  the code's folder with the file tools alone: it reads the code, runs nothing and changes nothing - hands over everything
834
840
  the author needs, in parts: how to build and run it; how the conformance tests reach it as the project's
835
- conformance testing script runs them - the explorer is given the script, and says what it builds or
841
+ conformance testing script runs them - the explorer is given the script, and the prepare script before it where there
842
+ is one, and says what they build or
836
843
  installs and what the tests depend on or start (a package, a module, an entry point, by its exact name);
837
844
  how each functionality is reached from outside
838
845
  (the command, or the route, method and the request and response fields); what it needs around it; and
@@ -915,7 +922,8 @@ culprit: what it does with the tests, the tests fit; what it needs of the build,
915
922
  - **the code:** the coding agent - in the code's folder, reading the tests but never changing them - fixes it,
916
923
  and keeps its unit tests passing. The judge quotes the spec's own words the code does not meet, or the testing
917
924
  script's for what it needs of the build (`script_needs`: a build output, an entry point, a module) - each checked
918
- by the harness against the text it quotes;
925
+ by the harness against the text it quotes. The acceptance tests are in that text, labelled as their tests are:
926
+ for a spec change, those of every functional spec it touches, not only the last one's;
919
927
  - **the tests:** the conformance agent corrects them to what the spec says;
920
928
  - **the spec:** two functional specs cannot both hold, whatever the code does;
921
929
  - **the environment:** neither the code nor the tests can fix it - only the person can, in what the run is given:
@@ -957,7 +965,9 @@ spending the rest of its 20 attempts on it. Two failures are the same once what
957
965
  taken out of their lines - numbers, times, paths, colour codes, and the ids a service makes up for each
958
966
  response, a UUID or a correlation id. Nothing changes either when the agent sent to fix the code or the tests
959
967
  changes no file: two such fixes in a row, and the run stops. The conformance agent has the testing script in its prompt,
960
- and is told to fit the tests' own build to it.
968
+ and is told to fit the tests' own build to it. The code fixer has the testing scripts its fixes are run by - the
969
+ conformance testing script, the prepare script before it, and the unit testing script - word for word in its task:
970
+ what the conformance testing script needs of the build, it reads there, not only in the judge's quote of it.
961
971
 
962
972
  **Escalation.** A failure judged the tests' goes to the conformance agent first: it owns the tests. When the run then
963
973
  fails the same way - the fix changed nothing, or nothing that mattered - the judge is asked again, now whether the
@@ -978,9 +988,18 @@ the run failed with, how it was judged, and which files the agent sent to fix it
978
988
  attempt's line says what came of it - and each judge reads them first, so it does not send a
979
989
  failure the way that already failed. A run that does not pass is undone whole, its notes with it.
980
990
 
981
- Then the suite runs again. A run stopped by its time limit goes back to the conformance agent without a judge: the
982
- tests' environment is its. So does a run that exits 80 - the tests pass and only their static checks do not (see
983
- [the static checks](#the-static-checks)): the findings are the tests'. A run whose script cannot find its
991
+ Then the suite runs again. A run that exits 80 goes back to the conformance agent without a judge - the tests pass and
992
+ only their static checks do not (see [the static checks](#the-static-checks)): the findings are the tests'.
993
+
994
+ **Timing.** Every run is shown to the judge, the debugger and whoever fixes it with how long it ran against its time
995
+ limit (`runTiming` in `test-scripts.ts`): `This run ended on its own - exit 1, after 37.2s of the 120.0s a test run
996
+ may take`, or `The time limit stopped this run: it had run 120.0s of the 120.0s ...` - so none takes a run that
997
+ ended on its own for one the limit stopped, or the other way. A run the time limit stopped is judged like any other,
998
+ and the judge is told what keeps a run from finishing: the code hangs (it waits on input, keeps a server in the
999
+ foreground, never exits) - the code's; a test waits for what never comes - the tests'; or nothing hangs, the output
1000
+ showing the run getting on up to the limit, and it needs more time than a test run may take - the environment. That
1001
+ verdict stops the pulse at once, rolled back and not retried, and the person is told to raise `TURRI_COMMAND_TIMEOUT`
1002
+ or make the suite faster - no agent can give a run more time. A run whose script cannot find its
984
1003
  toolchain stops the pulse instead - see [when the environment lacks something](#when-the-environment-lacks-something).
985
1004
  Unit tests failing after the suite passes go to the coding agent. It goes round up to 20 times; still failing, the work is undone.
986
1005
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turri-dev/cli",
3
- "version": "0.1.3-next.a5d3797",
3
+ "version": "0.1.3-next.b675516",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -33,8 +33,8 @@
33
33
  "grammar:sync": "node scripts/sync-plyn-grammar.mjs"
34
34
  },
35
35
  "dependencies": {
36
- "@earendil-works/pi-coding-agent": "^1.1.0",
37
- "@earendil-works/pi-tui": "^1.1.0",
36
+ "@earendil-works/pi-coding-agent": "^1.2.0",
37
+ "@earendil-works/pi-tui": "^1.2.0",
38
38
  "@langchain/langgraph": "1.4.15",
39
39
  "gray-matter": "^4.0.3",
40
40
  "js-yaml": "^5.4.2",