toolroll 0.9.13 → 0.9.14

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.
@@ -25,7 +25,8 @@ export function browserWorkActionHref(item) {
25
25
  return `/r/${runId}`;
26
26
  const anchor = action.code === 'approve-scope' ? '#approve'
27
27
  : action.code === 'inspect-stop' || action.code === 'resume-run' ? '#task-control'
28
- : action.code === 'unhold' || action.code === 'retry-task' ? '#task-actions'
28
+ // Retry (and Build again) is the task's status card itself, at the top: no anchor to its options.
29
+ : action.code === 'unhold' ? '#task-actions'
29
30
  : action.code === 'write-scope' || action.code === 'select-agent' ? '#scope'
30
31
  : action.code === 'inspect-hold' ? '#holds'
31
32
  : action.code === 'start-worker' || action.code === 'repair-dependency' ? '#run-status' : '';
@@ -367,6 +367,18 @@ export type BrowserTaskView = {
367
367
  rebuild?: {
368
368
  action: string;
369
369
  } | null;
370
+ /** A failed task's recorded reason in one line (the run's reason, the failing check's last error line, or that none was
371
+ * recorded) and where its log lines are; and Retry itself, with an optional note. */
372
+ failure?: {
373
+ line: string;
374
+ link: {
375
+ label: string;
376
+ href: string;
377
+ } | null;
378
+ } | null;
379
+ retry?: {
380
+ action: string;
381
+ } | null;
370
382
  /** Server cards that may need a person now (stop/resume, scope prompt, plan, live attempt). */
371
383
  lead: {
372
384
  key: string;
@@ -587,6 +599,22 @@ export type BrowserResultView = {
587
599
  problem: boolean;
588
600
  logHref: string | null;
589
601
  } | null;
602
+ /** A refuted result's recorded disagreements: the headline when the report doesn't match the changes (null when a
603
+ * failed check is the blocker, so the status keeps its own), each disagreement in plain words with the changed lines
604
+ * it concerns (`absent`: a file the changes don't have), and the recorded words they already say, so no caveat
605
+ * repeats them. Null otherwise. */
606
+ mismatch: {
607
+ headline: string | null;
608
+ rows: {
609
+ text: string;
610
+ path: string | null;
611
+ lines: string | null;
612
+ href: string | null;
613
+ absent: boolean;
614
+ noteLabel: string | null;
615
+ }[];
616
+ said: string[];
617
+ } | null;
590
618
  /** The one ink act that resolves the result, the one outline act beside it, and why it can't be accepted yet (result-acts.ts). */
591
619
  acts: ResultActs;
592
620
  /** Run checks on this result's commit: the project's check, when it didn't run. */
package/dist/dispatch.js CHANGED
@@ -13,6 +13,7 @@ import { plannerSourceProblemOf } from "./planner-source.js";
13
13
  import { authPauseOf, authWaitOf, providerName, signInReason } from "./provider-auth.js";
14
14
  import { REVIEW_TOKENS, resultStatusOf, reviewFactsOf } from "./workspace-ui.js";
15
15
  import { replacedWords } from "./task-status.js";
16
+ import { failedAttemptSentence, latestFinishedAttempt } from "./needs-you.js";
16
17
  import { buildReviewOf } from "./review-switch.js";
17
18
  import { BUILT_IN, parseCapabilityKey } from "./store.js";
18
19
  export const DEFAULT_MAX_OPEN_DECISIONS = 5;
@@ -218,8 +219,12 @@ export function diagnoseTaskDispatch(store, taskId, now) {
218
219
  return successor === null ? answer("cancelled", "terminal", "Cancelled", "Nothing else will run for this task.")
219
220
  : answer("cancelled", "terminal", replacedWords(successor), `${replacedWords(successor)}. Nothing else will run for this task.`);
220
221
  }
221
- if (task.state === "failed")
222
- return answer("failed", "terminal", "Needs a retry", "The last attempt stopped; review its incident, then retry it.", { action: "retry-task" });
222
+ if (task.state === "failed") {
223
+ // The family's latest finished attempt's own recorded reason, or that none was recorded: never "review its incident".
224
+ const family = store.taskFamilyOf(taskId, ref.repo === null ? [] : [ref.repo], ref.repo === null);
225
+ const runs = family === null || family.problem !== null ? store.runsFor(ref.id) : family.versions.flatMap(version => store.runsFor(version.refId));
226
+ return answer("failed", "terminal", "Needs a retry", failedAttemptSentence(latestFinishedAttempt(runs)?.reason), { action: "retry-task" });
227
+ }
223
228
  if (store.hasLiveClaim(ref.id, now))
224
229
  return answer("running", "running", "Running now", "A worker owns the current live claim.");
225
230
  if (task.state === "running")
@@ -59,4 +59,28 @@ export declare function processNeedOf(fact: {
59
59
  } | null;
60
60
  /** Dispatch and work-index codes that wait on a person, as their need. */
61
61
  export declare const CODE_NEED: Readonly<Record<string, NeedKey>>;
62
+ /** A stopped run's recorded reason code, in words (the run record, the thread, a failed task's status). */
63
+ export declare const RUN_REASON_WORDS: Readonly<Record<string, string>>;
64
+ /** A run's recorded reason in words. A recorded code is never shown as it is
65
+ * stored: one with no words of its own reads as an unexpected stop; a reason
66
+ * already written as words (it has a space) is said as written. */
67
+ export declare function runReasonWords(reason: string): string;
68
+ /** The latest finished build attempt among a task's runs (any order), whatever its outcome: planning and reviewing
69
+ * are not attempts, and an older failure never stands in for a newer attempt. */
70
+ export declare function latestFinishedAttempt<T extends {
71
+ id: number;
72
+ role: string;
73
+ finishedAt: string | null;
74
+ outcome?: string | null;
75
+ }>(runs: readonly T[]): T | null;
76
+ export declare const NO_REASON_RECORDED = "No reason was recorded for this attempt.";
77
+ export declare const INTERNAL_ERROR = "The attempt stopped with an internal error.";
78
+ /** About as long as one plain line reads. */
79
+ export declare const REASON_LINE_LIMIT = 140;
80
+ /** A recorded reason that is machine output, not words: a stack trace, a file path or "Error:" text. Its detail
81
+ * belongs behind a link, never in the line itself. */
82
+ export declare function isInternalErrorReason(reason: string | null | undefined): boolean;
83
+ /** A failed attempt in one plain line: its recorded reason in words (first line only, about 140 characters at most),
84
+ * that it stopped with an internal error when the reason is machine output, or that none was recorded. */
85
+ export declare function failedAttemptSentence(reason: string | null | undefined): string;
62
86
  export {};
package/dist/needs-you.js CHANGED
@@ -90,3 +90,103 @@ export const CODE_NEED = {
90
90
  "no-build-record": "review-result", "verification-needed": "review-result", "evidence-mismatch": "review-result",
91
91
  "record-incomplete": "review-result", "evidence-damaged": "review-result",
92
92
  };
93
+ /** A stopped run's recorded reason code, in words (the run record, the thread, a failed task's status). */
94
+ export const RUN_REASON_WORDS = {
95
+ agent: "the agent failed",
96
+ "agent-reported": "the agent reported it could not finish",
97
+ "no-op": "nothing changed when something should have",
98
+ "no-handoff": "the agent stopped before handing off; its work was kept and it is being resumed",
99
+ "moved-head": "the branch moved underneath the build",
100
+ "moved-branch": "the branch moved underneath the build",
101
+ timeout: "ran out of time",
102
+ git: "a git step failed",
103
+ "malformed-decision": "the agent's question was malformed",
104
+ "malformed-plan": "the plan was malformed",
105
+ fenced: "another worker took the task over",
106
+ unapproved: "the scope was not approved",
107
+ "scope-changed": "the scope changed after approval",
108
+ capability: "a requirement was missing",
109
+ setup: "the workspace preparation step failed",
110
+ "provider-init": "the agent could not start",
111
+ "commit-failure": "the commit failed",
112
+ "protected-branch": "refused to touch a protected branch",
113
+ "wrong-branch": "the checkout was on the wrong branch",
114
+ "not-leased": "the lease was not valid",
115
+ "no-claim": "the lease was not valid",
116
+ "not-yours": "the lease was not valid",
117
+ "no-run-record": "the run record was missing",
118
+ "missing-mailbox": "the resume mailbox could not be read",
119
+ "unreadable-mailbox": "the resume mailbox could not be read",
120
+ "revision-brief": "the revision brief could not be read",
121
+ "repaired-park": "resumed from a parked question",
122
+ stopped: "stopped by the operator",
123
+ acceptance: "the result didn't meet its signed requirements",
124
+ evidence: "its evidence could not be saved",
125
+ interrupted: "the attempt was interrupted",
126
+ orphaned: "the attempt lost its worker",
127
+ "retryable-infra": "a temporary problem on the machine stopped it",
128
+ unknown: "the attempt stopped unexpectedly",
129
+ "spawn-failed": "the agent process could not start",
130
+ "provider-protocol": "the agent's reply could not be read",
131
+ "auth-expired": "the agent's sign-in expired",
132
+ "budget-unenforceable": "its spending limit could not be enforced",
133
+ "stale-source": "the request changed while the plan was being made",
134
+ "stale-approval": "the approval no longer matched the scope",
135
+ "source-invalid": "the plan's source could not be recorded",
136
+ "external-closed": "the linked issue was closed while it was being built",
137
+ "repair-admission": "the repair could not start",
138
+ "reviewer-error": "the review failed",
139
+ "malformed-report": "the scout's report was malformed",
140
+ "attempts-exhausted": "it failed too many times in a row",
141
+ };
142
+ /** A run's recorded reason in words. A recorded code is never shown as it is
143
+ * stored: one with no words of its own reads as an unexpected stop; a reason
144
+ * already written as words (it has a space) is said as written. */
145
+ export function runReasonWords(reason) {
146
+ const known = RUN_REASON_WORDS[reason];
147
+ if (known !== undefined)
148
+ return known;
149
+ if (reason.startsWith("decision:"))
150
+ return "the agent asked a question";
151
+ return /\s/.test(reason.trim()) ? reason.trim() : "the attempt stopped unexpectedly";
152
+ }
153
+ /** The latest finished build attempt among a task's runs (any order), whatever its outcome: planning and reviewing
154
+ * are not attempts, and an older failure never stands in for a newer attempt. */
155
+ export function latestFinishedAttempt(runs) {
156
+ let latest = null;
157
+ // Ended means a finish time or an outcome: a reconcile can mark a run failed without stamping finished_at.
158
+ for (const run of runs)
159
+ if ((run.finishedAt !== null || (run.outcome ?? null) !== null) && run.role !== "planner" && run.role !== "reviewer" && (latest === null || run.id > latest.id))
160
+ latest = run;
161
+ return latest;
162
+ }
163
+ export const NO_REASON_RECORDED = "No reason was recorded for this attempt.";
164
+ export const INTERNAL_ERROR = "The attempt stopped with an internal error.";
165
+ /** About as long as one plain line reads. */
166
+ export const REASON_LINE_LIMIT = 140;
167
+ /** A recorded reason that is machine output, not words: a stack trace, a file path or "Error:" text. Its detail
168
+ * belongs behind a link, never in the line itself. */
169
+ export function isInternalErrorReason(reason) {
170
+ const text = reason?.trim() ?? "";
171
+ if (text === "" || RUN_REASON_WORDS[text] !== undefined)
172
+ return false;
173
+ return /\b[A-Za-z]*(?:Error|Exception):/.test(text) // Error:, TypeError:, SomeException:
174
+ || /^\s*at\s+\S+/m.test(text) && /\n/.test(text) // a stack frame below the first line
175
+ // An absolute path of two or more segments (/Users/…, ~/x/y): plain sentences may name a project file (src/ledger.ts).
176
+ || /(?:^|[\s(“"'])~?\/(?:[\w.@-]+\/)+[\w.@-]+/.test(text)
177
+ || /[A-Za-z]:\\/.test(text) // C:\…
178
+ || /\.[cm]?[jt]sx?:\d+/.test(text); // ledger.ts:14
179
+ }
180
+ /** A failed attempt in one plain line: its recorded reason in words (first line only, about 140 characters at most),
181
+ * that it stopped with an internal error when the reason is machine output, or that none was recorded. */
182
+ export function failedAttemptSentence(reason) {
183
+ const code = reason?.trim() ?? "";
184
+ if (code === "")
185
+ return NO_REASON_RECORDED;
186
+ if (isInternalErrorReason(code))
187
+ return INTERNAL_ERROR;
188
+ const words = runReasonWords(code.split(/\r?\n/, 1)[0].trim()).replace(/\s+/g, " ").replace(/[.\s]+$/, "");
189
+ const line = words.length <= REASON_LINE_LIMIT ? words
190
+ : `${words.slice(0, REASON_LINE_LIMIT - 1).replace(/\s+\S*$/, "").replace(/[,;:\s]+$/, "")}…`;
191
+ return `${line.charAt(0).toUpperCase()}${line.slice(1)}${line.endsWith("…") ? "" : "."}`;
192
+ }
@@ -14,6 +14,8 @@
14
14
  *
15
15
  * Accepting means what it always meant: the same forms post to the same
16
16
  * endpoints. This only decides which one is ink. */
17
+ /** The words over the reason field an Accept of a refuted result requires. */
18
+ export declare const ACCEPT_NEEDS_REASON = "Accepting needs a reason";
17
19
  export type ResultActKind = "accept" | "checks-running" | "run-checks" | "request-changes" | "rebuild" | "confirm-stopped" | "revise" | "draft-repair";
18
20
  export type ResultActFacts = {
19
21
  /** An Accept this page can post (Mark complete, or the person's acceptance); `ready`: every requirement met and checks passed. */
@@ -14,6 +14,8 @@
14
14
  *
15
15
  * Accepting means what it always meant: the same forms post to the same
16
16
  * endpoints. This only decides which one is ink. */
17
+ /** The words over the reason field an Accept of a refuted result requires. */
18
+ export const ACCEPT_NEEDS_REASON = "Accepting needs a reason";
17
19
  export function resultActsOf(facts) {
18
20
  const request = facts.canRequest ? "request-changes" : null;
19
21
  const accept = facts.accept === null ? null : "accept";
package/dist/serve.js CHANGED
@@ -75,7 +75,7 @@ import { leadBriefHtml, LEAD_CONTEXT_CSS } from './lead-context.js';
75
75
  import { assignmentCatchUp } from './assignment-brief.js';
76
76
  import { assignmentActionHref, assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml, assignmentWithEvidence, ASSIGNMENT_CSS } from './assignment-ui.js';
77
77
  import { assignmentStageOf, pullRequestFactOf, stageOfCode, stageOfDispatch, statusDetailsHtml, statusIconSvg, statusWhyHtml, taskStatusOf, TASK_STATUS_CSS } from './task-status.js';
78
- import { ASKS, ASK_LABEL, NEEDS } from './needs-you.js';
78
+ import { ASKS, ASK_LABEL, NEEDS, NO_REASON_RECORDED, failedAttemptSentence, isInternalErrorReason, latestFinishedAttempt, runReasonWords } from './needs-you.js';
79
79
  import { assignmentPresentationOf, historicalAssessmentReason, shortenedMaterialReason } from './assignment-presentation.js';
80
80
  import { ledgerBody } from "./ledger-view.js";
81
81
  import { EVIDENCE_PACK_CSS, evidencePack, evidencePackHtml, exportDay, ledgerExportChunks } from "./evidence-pack.js";
@@ -164,7 +164,7 @@ import { starterRecipes, savedRecipes, findRecipe, importRecipe, exportRecipe, c
164
164
  import { recipeFromForm, recipeLibraryHtml, recipeEditorHtml, workflowPreviewHtml, recipeScript, RECIPE_CSS, recipeDefinitionPreviewHtml, recipeRunHtml, recipeAnswersFromForm } from "./recipe-ui.js";
165
165
  import { EVIDENCE_CAPS, readVerifiedArtifact, readVerifiedReport, readVerifiedProofForRun, storeEvidence, writeEvidenceFile, scanForSecrets } from "./evidence.js";
166
166
  import { GOAL_ASSESSMENT_PENDING, reviewConflict, manualReviewOnly, manualReviewCriterionOf, personCheckWords, plainReasonWords, dispatchStatusToken, passFraction, semanticCoverage, coverageWords, coverageStateWords } from "./proof.js";
167
- import { WORK_VIEWS, REVIEW_TOKENS, RESULT_DECISION_SENTENCE, acceptWordsOf, cantAcceptYetOf, parseWorkView, resultStatusOf, resultHeadlineOf, receiptHeadingOf, receiptPublicationWords, reviewFactsOf, workStatusOf, primaryDestinationOf, needsPerson, dispatchActionLabel, } from "./workspace-ui.js";
167
+ import { WORK_VIEWS, REVIEW_TOKENS, RESULT_DECISION_SENTENCE, acceptWordsOf, cantAcceptYetOf, evidenceProblemOf, lastErrorLineOf, reportMismatchesOf, ACCEPT_NEEDS_REASON, MISMATCH_HEADLINE, parseWorkView, resultStatusOf, resultHeadlineOf, receiptHeadingOf, receiptPublicationWords, reviewFactsOf, workStatusOf, primaryDestinationOf, needsPerson, dispatchActionLabel, } from "./workspace-ui.js";
168
168
  import { PRICED_BUILD_MODELS } from "./pricing.js";
169
169
  import { RESULT_TABS, RESULT_REVIEW_SCRIPT, isRevisionFeedback, evidenceResultStatusOf, evidenceShortenedWords, parseResultTab, resultLeadOf, evidenceProblemDetailsOf, evidenceHealthOf, resultFactsAttributes, resultFactsAttributeMap, resultReturnTarget, commentSourceKey, REQUEST_TOKEN, revisionBatchOf, parseRevisionBatch, revisionSourceOf, } from "./result-review.js";
170
170
  import { parseReport } from "./scout-report.js";
@@ -5895,6 +5895,33 @@ export function createDecisionServer(options) {
5895
5895
  return null;
5896
5896
  return { taskId: rootId, view: null, offer: { taskId: receipt.taskId, runId: receipt.runId, digest: receipt.digest }, target };
5897
5897
  }
5898
+ /** What went wrong with the latest finished attempt (whatever its outcome, never an older failure), in one line: a
5899
+ * failing check's last error line (linked to that line in its saved log), else the attempt's recorded reason in plain
5900
+ * words, else that no reason was recorded. A reason that is machine output reads as an internal error, its detail
5901
+ * behind a link to that line of the attempt's record; nothing links to this task's own page. */
5902
+ function failureOf(runs, assignment) {
5903
+ const last = latestFinishedAttempt(runs);
5904
+ if (last === null)
5905
+ return { line: NO_REASON_RECORDED, link: null };
5906
+ const receipt = assignment?.receipt?.runId === last.id ? assignment.receipt : null;
5907
+ if (receipt !== null && receipt.checks.status === "failed" && receipt.checks.logArtifactId !== null) {
5908
+ const artifact = store.artifactsFor(last.id).find(one => one.id === receipt.checks.logArtifactId) ?? null;
5909
+ let read = null;
5910
+ try {
5911
+ read = artifact === null ? null : readVerifiedArtifact(evidenceRoot, artifact);
5912
+ }
5913
+ catch {
5914
+ read = null;
5915
+ }
5916
+ const found = read !== null && read.ok ? lastErrorLineOf(read.content.toString("utf8")) : null;
5917
+ if (found !== null) {
5918
+ return { line: oneLineOf(found.text, 140), link: { label: `Check output, line ${found.line}`,
5919
+ href: `${reviewHref(receipt.taskId)}&run=${last.id}&tab=checks#check-log-L${found.line}` } };
5920
+ }
5921
+ }
5922
+ return { line: failedAttemptSentence(last.reason),
5923
+ link: isInternalErrorReason(last.reason) ? { label: "The recorded error", href: `/r/${last.id}?record=1#run-reason-detail` } : null };
5924
+ }
5898
5925
  function taskScreen(response, who, taskId, problem, status, scopeDraft, cancelDraft) {
5899
5926
  const admittedFamily = familyOf(taskId);
5900
5927
  if (admittedFamily?.problem != null)
@@ -5917,6 +5944,8 @@ export function createDecisionServer(options) {
5917
5944
  versions: family.versions.map(one => ({ id: one.id, title: one.title, state: one.state })),
5918
5945
  runs: family.versions.flatMap(version => version.id === data.task.id ? data.runs.map(run => ({ ...run, taskId: version.id })) : store.runsFor(version.refId).map(run => ({ ...run, taskId: version.id }))) };
5919
5946
  }
5947
+ // What went wrong reads the family's latest finished attempt, as the Tasks row does.
5948
+ presentedData.failure = failureOf(presentedData.family?.runs ?? data.runs, data.assignment ?? null);
5920
5949
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
5921
5950
  const page = taskPage(paneProject === null && !unscopedMode
5922
5951
  ? chromeFor(paneProject, "tasks")
@@ -10446,10 +10475,21 @@ export function createDecisionServer(options) {
10446
10475
  return redirect(response, taskHref(taskId));
10447
10476
  }
10448
10477
  case "requeue": {
10478
+ // Retry may carry a note for the next attempt: the same steering note, checked before anything changes.
10479
+ const note = (body.get("note") ?? "").trim();
10480
+ if (note !== "" && who.via !== "cookie")
10481
+ return refuse(response, who, 403, "steering is a browser session's act");
10482
+ if (note !== "" && !validateNote(note).ok)
10483
+ return taskScreen(response, who, taskId, "that note will not store, so nothing was retried", 400);
10449
10484
  const requeued = store.requeueTask(taskId, who.name, now);
10450
10485
  if (!requeued.ok) {
10451
10486
  return taskScreen(response, who, taskId, `not requeued: ${requeued.reason}`, 409);
10452
10487
  }
10488
+ if (note !== "") {
10489
+ const filed = store.fileSteerNote(taskId, verifiedAuthor(who.name), note, now);
10490
+ if (!filed.ok)
10491
+ return taskScreen(response, who, taskId, "Retried. The note wasn't saved; add it under Steering.", 409);
10492
+ }
10453
10493
  // Allow-listed return only — never an arbitrary URL from the form.
10454
10494
  return redirect(response, body.get("return") === "inbox" ? "/inbox" : body.get("return") === "next" ? "/next" : taskHref(taskId));
10455
10495
  }
@@ -19828,7 +19868,8 @@ function taskBodyParts(data) {
19828
19868
  case "approve-scope": return ` <a href="#approve">Review and sign the exact scope</a>.`;
19829
19869
  case "answer-decision": return ` <a href="#decisions">Answer the waiting question</a>.`;
19830
19870
  case "unhold": return ` Use <strong>Remove hold</strong> when it can continue.`;
19831
- case "retry-task": return ` Review the incident, then use <strong>retry</strong> below.`;
19871
+ // The reason is the detail itself; Retry is the status card's own act, said once.
19872
+ case "retry-task": return "";
19832
19873
  case "repair-capability": return ` <a href="/caps">Repair the requirement</a>.`;
19833
19874
  case "repair-dependency":
19834
19875
  return blocker?.admitted === true
@@ -19871,7 +19912,8 @@ function taskBodyParts(data) {
19871
19912
  : `<p class="meta">This task resumes automatically when the builder reconnects. You do not need to file or approve it again.</p>`) +
19872
19913
  `<p><a href="/system">See connection status →</a></p></div></details>`);
19873
19914
  }
19874
- const href = taskRecoveryHref(task.id, diagnosis);
19915
+ // Never a link to this page's own acts: Retry sits on the status card and below.
19916
+ const href = diagnosis.action === "retry-task" ? null : taskRecoveryHref(task.id, diagnosis);
19875
19917
  return href === null ? "" : `<a class="button-link dispatch-action-link" href="${href}">${escape(dispatchActionLabel(diagnosis))}</a>`;
19876
19918
  })();
19877
19919
  const positive = diagnosis.code === "running" || diagnosis.code === "ready" || diagnosis.code === "planning-ready" || diagnosis.code === "scouting-ready";
@@ -20569,8 +20611,10 @@ function taskBodyParts(data) {
20569
20611
  // hold (the hold governs the NEXT start), and requeue refuses while a
20570
20612
  // claim is live — so the words say exactly when each becomes real.
20571
20613
  const canPlan = data.plan === null && !approval.approved && !data.claimed && task.state === "queued" && (data.coordinator === null || data.coordinator === undefined);
20572
- const primaryAct = stopControlsActive ? null : stalled && !data.claimed
20573
- ? { html: act("requeue", "retry — branch and workspace kept"), why: "resolves the incidents, clears the failed attempts, and queues the task again; the preserved branch and workspace are NOT erased", whyClass: "retry" }
20614
+ // A failed task's status card carries Retry itself (the same requeue); here it would be a second ink act.
20615
+ const cardRetries = task.state === "failed" && data.assignment != null && data.csrf !== "";
20616
+ const primaryAct = stopControlsActive || cardRetries ? null : stalled && !data.claimed
20617
+ ? { html: act("requeue", "Retry, keeping the branch and workspace"), why: "Resolves the incidents, clears the failed attempts and queues the task again. The branch and workspace are kept.", whyClass: "retry" }
20574
20618
  : canPlan
20575
20619
  ? { html: act("plan", "plan first"), why: "plan first sends an agent to read the repository, ask you questions, and propose a scope — nothing builds until you approve it", whyClass: "plan" }
20576
20620
  : data.plan === "requested"
@@ -20581,7 +20625,7 @@ function taskBodyParts(data) {
20581
20625
  const holdAct = `<form method="post" action="${taskHref(task.id)}/hold" class="inline act-hold">` +
20582
20626
  `<input type="hidden" name="csrf" value="${escape(data.csrf)}">` +
20583
20627
  `<input type="text" name="reason" class="inline" placeholder="reason (optional)" aria-label="hold reason">` +
20584
- `<button type="submit">hold next attempt</button></form>`;
20628
+ `<button type="submit">Hold the next attempt</button></form>`;
20585
20629
  const canHold = task.state === "queued" || task.state === "running" || task.state === "failed";
20586
20630
  const actsBar = [
20587
20631
  `<span id="task-actions"></span><div class="acts-bar">`,
@@ -20598,7 +20642,7 @@ function taskBodyParts(data) {
20598
20642
  `</div>`,
20599
20643
  primaryAct === null ? "" : `<p class="meta acts-why acts-why-${primaryAct.whyClass}">${primaryAct.why}</p>`,
20600
20644
  data.claimed && !stopControlsActive
20601
- ? `<p class="meta acts-why">a worker is building this right now — <em>hold next attempt</em> stops the one after it; cancel waits for the current build to finish${stalled ? "; retry becomes available after this attempt finishes" : ""}</p>`
20645
+ ? `<p class="meta acts-why">a worker is building this right now — <em>Hold the next attempt</em> stops the one after it; cancel waits for the current build to finish${stalled ? "; retry becomes available after this attempt finishes" : ""}</p>`
20602
20646
  : "",
20603
20647
  ].join("\n");
20604
20648
  // Cancel gets the same ceremony as an irreversible answer: armed behind
@@ -20791,7 +20835,7 @@ function taskBodyParts(data) {
20791
20835
  : run.outcome === null ? "Attempt never finished"
20792
20836
  : `${noun[0].toUpperCase()}${noun.slice(1)} ${run.outcome}`;
20793
20837
  thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "result", who: "agent", author: run.runner,
20794
- title: `${title}${revision}`, text: run.handoff === null ? (run.reason === null || run.outcome === "built" ? null : reasonWords(run.reason)) : oneLineOf(run.handoff, 600),
20838
+ title: `${title}${revision}`, text: run.handoff === null ? (run.outcome === "failed" ? failedAttemptSentence(run.reason) : run.reason === null || run.outcome === "built" ? null : reasonWords(run.reason)) : oneLineOf(run.handoff, 600),
20795
20839
  link: href, html: "",
20796
20840
  more: previousResult !== null && run.id === newestBuilt?.id && run.taskId === task.id ? { summary: "Result details", html: previousResult.html } : null });
20797
20841
  }
@@ -20886,6 +20930,22 @@ function taskBodyParts(data) {
20886
20930
  // needs a person, then facts, then folds; the mechanics under Manage.
20887
20931
  const MANAGE = new Set(["steering", "waits-for", "holds"]);
20888
20932
  const finished = task.state === "done" || task.state === "cancelled";
20933
+ // Failed: the card says what went wrong in one line, and its one act is Retry itself (the same requeue), never a link to
20934
+ // this page; someone who can't retry here (a viewer, a live claim) gets no act at all.
20935
+ const statusCard = data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions) : null;
20936
+ const failedCard = statusCard?.status.headline === "Failed";
20937
+ // A link to this very page, with no section to open, goes nowhere: a card never offers one.
20938
+ const bareSelfLink = (href) => {
20939
+ if (href.includes("#"))
20940
+ return false;
20941
+ const [path, query = ""] = href.split("?");
20942
+ const version = new URLSearchParams(query).get("version");
20943
+ return [data.assignment?.rootId, data.rootId, task.id].some(id => id != null && path === `/t/${encodeURIComponent(id)}`) && (version === null || version === task.id);
20944
+ };
20945
+ // Build again, in place, names its form after the card's own act.
20946
+ const rebuild = data.csrf !== "" && data.assignment?.primaryAction?.code === "retry-task" && data.assignment.need != null && "key" in data.assignment.need && data.assignment.need.key === "rebuild"
20947
+ ? { action: `${taskHref(data.assignment.rootId)}/requeue` } : null;
20948
+ const retry = failedCard && task.state === "failed" && !data.claimed && !stopControlsActive && data.csrf !== "" ? { action: `${taskHref(task.id)}/requeue` } : null;
20889
20949
  const view = {
20890
20950
  kind: "task",
20891
20951
  id: task.id,
@@ -20897,13 +20957,15 @@ function taskBodyParts(data) {
20897
20957
  { label: "Ask", href: taskChatHref(data.rootId ?? task.id), active: false },
20898
20958
  ],
20899
20959
  version: data.versionLabel == null ? null : { label: data.versionLabel, current: { label: "Current work", href: taskHref(data.rootId ?? task.id) } },
20900
- status: data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions) : null,
20960
+ status: statusCard === null ? null : failedCard ? { ...statusCard, action: null, status: { ...statusCard.status, sentence: data.failure?.line ?? statusCard.status.sentence } }
20961
+ : statusCard.action !== null && rebuild === null && bareSelfLink(statusCard.action.href) ? { ...statusCard, action: null } : statusCard,
20901
20962
  statusHtml,
20963
+ failure: failedCard ? data.failure ?? null : null,
20964
+ retry,
20902
20965
  approval: approvalHtml,
20903
20966
  confirmStopped: data.csrf !== "" && data.assignment?.primaryAction?.code === "confirm-stopped" && data.assignment.primaryAction.target.runId !== null
20904
20967
  ? { action: `${taskHref(data.assignment.rootId)}/confirm-stopped`, run: data.assignment.primaryAction.target.runId, checked: needsCheck(data.assignment) } : null,
20905
- rebuild: data.csrf !== "" && data.assignment?.primaryAction?.code === "retry-task" && data.assignment.need != null && "key" in data.assignment.need && data.assignment.need.key === "rebuild"
20906
- ? { action: `${taskHref(data.assignment.rootId)}/requeue` } : null,
20968
+ rebuild,
20907
20969
  // The plan, progress and plan changes are thread entries now; the rest still needs a person here.
20908
20970
  lead: [
20909
20971
  { key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
@@ -21393,7 +21455,7 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
21393
21455
  problem: view.detail === null ? view.historyProblem : null,
21394
21456
  next: reviewNextActionOf(view, csrf), complete: null, decision: null, checks: null, intent: intentView, noRun: null, panel: null, contest: "",
21395
21457
  notes: view.notes.map(one => ({ author: one.author, at: one.createdAt, note: one.note })),
21396
- acts: { primary: null, secondary: null, line: null }, runChecks: null,
21458
+ acts: { primary: null, secondary: null, line: null }, runChecks: null, mismatch: null,
21397
21459
  // The raw run record lives under Details now (2026-10-02): /r/<id> for this result redirects here.
21398
21460
  record: run === null ? null : { build: run.id, href: `/r/${run.id}?record=1`, facts: [
21399
21461
  { label: "Agent", value: [run.provider, run.model].filter(Boolean).join(" · ") },
@@ -21473,6 +21535,40 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
21473
21535
  need: need === null || need.accept != null ? null : need.rebuild != null ? "rebuild" : need.confirm !== null ? "confirm-stopped" : null,
21474
21536
  next: nextKind === "revise" || nextKind === "draft-repair" ? nextKind : null,
21475
21537
  });
21538
+ // A refuted result's card lists every recorded disagreement, each tied to its lines; when the report itself doesn't
21539
+ // match the changes (not a failed check), that is the headline.
21540
+ const mismatch = proof === null || accepted || proof.verdict !== "refuted" ? null : (() => {
21541
+ const patch = view.detail?.terminal?.patch ?? null;
21542
+ const files = patch === null || "problem" in patch ? [] : parseReviewDiff(patch.text).files;
21543
+ // Each file's first change: the lines it added (else every line it shows), and where that change starts (its link target).
21544
+ const hunkStart = new Map();
21545
+ const changes = new Map(files.map(file => {
21546
+ const shown = file.hunks[0]?.lines.filter(line => line.newLine !== null && line.kind !== "meta") ?? [];
21547
+ if (shown[0]?.newLine != null)
21548
+ hunkStart.set(file.path, shown[0].newLine);
21549
+ const added = shown.filter(line => line.kind === "addition");
21550
+ const lines = (added.length > 0 ? added : shown).map(line => line.newLine);
21551
+ return [file.path, lines.length === 0 ? null : { from: Math.min(...lines), to: Math.max(...lines) }];
21552
+ }));
21553
+ const changesHref = `${here}&run=${run.id}&tab=changes`;
21554
+ const found = reportMismatchesOf(proof.reasons, proof.proofProblem === null ? proof.matrix : [], changes);
21555
+ // The saved report itself can't be read: that is the evidence problem, said once.
21556
+ if (proof.proofProblem !== null)
21557
+ found.push({ text: `The saved report can't be read: ${proof.proofProblem}`, path: null, lines: null, inChanges: null, note: null, reason: proof.proofProblem });
21558
+ if (found.length === 0)
21559
+ return null;
21560
+ return {
21561
+ headline: evidenceProblemOf(proof.verdict, proof.reasons) === "mismatched" ? MISMATCH_HEADLINE : null,
21562
+ rows: found.map(one => ({
21563
+ text: one.text, path: one.path, absent: one.inChanges === false,
21564
+ lines: one.lines === null ? null : one.lines.from === one.lines.to ? `line ${one.lines.from}` : `lines ${one.lines.from}–${one.lines.to}`,
21565
+ href: one.path !== null && one.inChanges === true ? `${changesHref}#${diffFileAnchor(one.path)}${hunkStart.has(one.path) ? `-L${hunkStart.get(one.path)}` : ""}`
21566
+ : one.note !== null && (proof.proof?.caveats.length ?? 0) >= one.note ? `${here}&run=${run.id}#report-note-${one.note}` : null,
21567
+ noteLabel: one.path === null && one.note !== null ? `The report, note ${one.note}` : null,
21568
+ })),
21569
+ said: [...new Set([...proof.reasons, ...found.map(one => one.reason)].flatMap(reason => [reason, plainReasonWords(reason)]))],
21570
+ };
21571
+ })();
21476
21572
  if (view.notes.length > 0) {
21477
21573
  parts.push(`<section class="card cockpit-section" data-cockpit-section="notes"><h3>operator notes</h3>` +
21478
21574
  view.notes.map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span> ${escape(one.note)}</p>`).join("\n") +
@@ -21481,7 +21577,7 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
21481
21577
  return {
21482
21578
  html: `<section class="cockpit-detail">${parts.join("\n")}</section>`,
21483
21579
  selected: {
21484
- ...selected, complete, decision, panel: panel.panel, contest, acts, runChecks,
21580
+ ...selected, complete, decision, panel: panel.panel, contest, acts, runChecks, mismatch,
21485
21581
  checks: checks === undefined ? null : { detail: checks.detail, problem: checks.status === "failed" || checks.status === "unavailable", logHref: checks.logArtifactId === null ? null : `/r/${run.id}/evidence/${checks.logArtifactId}` },
21486
21582
  },
21487
21583
  };
@@ -21576,38 +21672,9 @@ function homePhaseWords(run) {
21576
21672
  function phaseWords(phase) {
21577
21673
  return PHASE_WORDS[phase] ?? "the agent is working";
21578
21674
  }
21579
- const REASON_WORDS = {
21580
- agent: "the agent failed",
21581
- "agent-reported": "the agent reported it could not finish",
21582
- "no-op": "nothing changed when something should have",
21583
- "no-handoff": "the agent stopped before handing off; its work was kept and it is being resumed",
21584
- "moved-head": "the branch moved underneath the build",
21585
- "moved-branch": "the branch moved underneath the build",
21586
- timeout: "ran out of time",
21587
- git: "a git step failed",
21588
- "malformed-decision": "the agent's question was malformed",
21589
- "malformed-plan": "the plan was malformed",
21590
- fenced: "another worker took the task over",
21591
- unapproved: "the scope was not approved",
21592
- "scope-changed": "the scope changed after approval",
21593
- capability: "a requirement was missing",
21594
- setup: "the workspace preparation step failed",
21595
- "provider-init": "the agent could not start",
21596
- "commit-failure": "the commit failed",
21597
- "protected-branch": "refused to touch a protected branch",
21598
- "wrong-branch": "the checkout was on the wrong branch",
21599
- "not-leased": "the lease was not valid",
21600
- "no-claim": "the lease was not valid",
21601
- "not-yours": "the lease was not valid",
21602
- "no-run-record": "the run record was missing",
21603
- "missing-mailbox": "the resume mailbox could not be read",
21604
- "unreadable-mailbox": "the resume mailbox could not be read",
21605
- "revision-brief": "the revision brief could not be read",
21606
- "repaired-park": "resumed from a parked question",
21607
- stopped: "stopped by the operator",
21608
- };
21675
+ /** A recorded reason in one plain line; machine output (a stack trace, a path, "Error:" text) waits on the run record. */
21609
21676
  function reasonWords(reason) {
21610
- return REASON_WORDS[reason] ?? "stopped — the build records have the detail";
21677
+ return isInternalErrorReason(reason) ? "stopped with an internal error" : oneLineOf(runReasonWords(reason.trim().split(/\r?\n/, 1)[0] ?? ""), 140);
21611
21678
  }
21612
21679
  const INCIDENT_WORDS = {
21613
21680
  "malformed-decision": "the agent's question was malformed",
@@ -22057,18 +22124,23 @@ function reviewDiffHtml(patch, stat, runId, commentable, anchors = new Map()) {
22057
22124
  : counts.additions === null || counts.deletions === null
22058
22125
  ? `<span class="diff-file-counts">binary</span>`
22059
22126
  : `<span class="diff-file-counts"><b>+${counts.additions}</b><i>−${counts.deletions}</i></span>`;
22060
- const hunks = one.hunks.map(hunk => `<section class="diff-hunk"><div class="diff-hunk-head">${escape(hunk.header)}</div>` +
22061
- `<div class="diff-lines">${hunk.lines.map(line => {
22062
- const lineNumber = line.newLine ?? line.oldLine;
22063
- const side = line.newLine === null && line.oldLine !== null ? "old" : "new";
22064
- const annotate = !commentable || lineNumber === null || line.kind === "meta"
22065
- ? `<span class="diff-annotate-space"></span>`
22066
- : `<button type="button" class="diff-annotate pick-line" data-path="${escape(one.path)}" data-line="${lineNumber}" data-side="${side}" aria-label="Annotate ${escape(one.path)}, ${side} line ${lineNumber}" title="Annotate this line">${annotateIcon}</button>`;
22067
- const marker = line.kind === "addition" ? "+" : line.kind === "deletion" ? "−" : line.kind === "context" ? " " : "·";
22068
- return (`<div class="diff-line diff-${line.kind}">${annotate}` +
22069
- `<span class="diff-gutter">${line.oldLine ?? ""}</span><span class="diff-gutter">${line.newLine ?? ""}</span>` +
22070
- `<code><b aria-hidden="true">${marker}</b>${escape(line.text)}</code></div>`);
22071
- }).join("")}</div></section>`).join("");
22127
+ const fileAnchor = anchors.get(one.path);
22128
+ // Each change is a link target of its own (#<file anchor>-L<first new line>): a result names the exact lines.
22129
+ const hunks = one.hunks.map(hunk => {
22130
+ const first = hunk.lines.find(line => line.newLine !== null && line.kind !== "meta")?.newLine ?? null;
22131
+ return `<section class="diff-hunk"${fileAnchor === undefined || first === null ? "" : ` id="${fileAnchor}-L${first}"`}><div class="diff-hunk-head">${escape(hunk.header)}</div>` +
22132
+ `<div class="diff-lines">${hunk.lines.map(line => {
22133
+ const lineNumber = line.newLine ?? line.oldLine;
22134
+ const side = line.newLine === null && line.oldLine !== null ? "old" : "new";
22135
+ const annotate = !commentable || lineNumber === null || line.kind === "meta"
22136
+ ? `<span class="diff-annotate-space"></span>`
22137
+ : `<button type="button" class="diff-annotate pick-line" data-path="${escape(one.path)}" data-line="${lineNumber}" data-side="${side}" aria-label="Annotate ${escape(one.path)}, ${side} line ${lineNumber}" title="Annotate this line">${annotateIcon}</button>`;
22138
+ const marker = line.kind === "addition" ? "+" : line.kind === "deletion" ? "−" : line.kind === "context" ? " " : "·";
22139
+ return (`<div class="diff-line diff-${line.kind}">${annotate}` +
22140
+ `<span class="diff-gutter">${line.oldLine ?? ""}</span><span class="diff-gutter">${line.newLine ?? ""}</span>` +
22141
+ `<code><b aria-hidden="true">${marker}</b>${escape(line.text)}</code></div>`);
22142
+ }).join("")}</div></section>`;
22143
+ }).join("");
22072
22144
  const anchor = anchors.get(one.path);
22073
22145
  return (`<details class="diff-file"${fileIndex === 0 ? " open" : ""}${anchor === undefined ? "" : ` id="${anchor}"`}>` +
22074
22146
  `<summary><span class="diff-file-name">${escape(one.path)}</span>${countWords}</summary>` +
@@ -22096,7 +22168,9 @@ function checkLogHtml(log, runId, attributes) {
22096
22168
  if (log.problem !== null) {
22097
22169
  return `<p class="problem" data-check-log="damaged"${attributes}>The check log no longer verifies (${escape(log.problem)}). Its output is not shown, and there is nothing to download.</p>`;
22098
22170
  }
22099
- return (`<details data-check-log="${log.truncated ? "shortened" : "ok"}"${attributes}><summary>Check output${log.truncated ? ` (shortened — ${log.bytesStored} of ${log.bytesOriginal} bytes stored)` : ""}</summary><pre class="mono" style="overflow-x:auto;max-height:18rem">${escape(log.text)}</pre></details>` +
22171
+ return (`<details data-check-log="${log.truncated ? "shortened" : "ok"}"${attributes}><summary>Check output${log.truncated ? ` (shortened — ${log.bytesStored} of ${log.bytesOriginal} bytes stored)` : ""}</summary><pre class="mono" style="overflow-x:auto;max-height:18rem">${
22172
+ // Each line is a link target (#check-log-L<n>): a failed task names the exact line its check ended on.
22173
+ log.text.split("\n").map((line, index) => `<span id="check-log-L${index + 1}">${escape(line)}</span>`).join("\n")}</pre></details>` +
22100
22174
  storedDownloadLink(runId, log.artifactId, "check log", log.truncated));
22101
22175
  }
22102
22176
  /** The download link for a stored record (repair 2026-09-14): a shortened
@@ -22512,6 +22586,8 @@ function resultPanelParts(detail, o) {
22512
22586
  ...(conclusion !== outcome ? [`<p class="recap">${escape(conclusion)}</p>`] : []),
22513
22587
  ...(handoff !== null && handoff.changes.length > 0 ? [`<ul class="result-changes">${handoff.changes.map(one => `<li>${escape(one)}</li>`).join("")}</ul>`] : []),
22514
22588
  ...(handoff !== null && handoff.verification.length > 0 ? [`<p class="meta">Checked by the agent:</p><ul class="result-changes" data-cockpit-source="agent-words">${handoff.verification.map(one => `<li>${escape(one)}</li>`).join("")}</ul>`] : []),
22589
+ // The report's own notes, each a link target (#report-note-<n>) a mismatch can point at.
22590
+ ...(proof?.proof == null || proof.proof.caveats.length === 0 ? [] : [`<p class="meta">Its notes:</p><ol class="result-changes" data-report-notes>${proof.proof.caveats.map((one, index) => `<li id="report-note-${index + 1}">${escape(one)}</li>`).join("")}</ol>`]),
22515
22591
  ];
22516
22592
  if (agentAccount.length > 0)
22517
22593
  summaryParts.push(`<details class="result-notes" data-result-notes-agent><summary>What the agent reported</summary>${agentAccount.join("")}</details>`);
@@ -22901,7 +22977,7 @@ function runFactsRows(run, taskId, live, route = null) {
22901
22977
  ["quality", qualityModeTitle(run.qualityMode ?? "default")],
22902
22978
  ["outcome", live ? "running" : (run.outcome ?? "never finished")],
22903
22979
  ["phase", live && run.phase !== null ? phaseWords(run.phase) : null],
22904
- ["reason", run.reason === null ? null : reasonWords(run.reason)],
22980
+ ["reason", run.reason === null ? null : isInternalErrorReason(run.reason) ? "stopped with an internal error (below)" : reasonWords(run.reason)],
22905
22981
  ["runner", run.runner, true],
22906
22982
  ["branch", run.branch, true],
22907
22983
  ["model", run.model, true],
@@ -22927,7 +23003,11 @@ function runFactsRows(run, taskId, live, route = null) {
22927
23003
  .filter((fact) => fact[1] !== null && fact[1] !== "")
22928
23004
  .map(([label, value, mono]) => `<p class="row"><span class="meta" style="min-width:8.5rem">${escape(label)}</span> ` +
22929
23005
  `<span${mono === true ? ` class="mono"` : ""}>${escape(value)}</span></p>`)
22930
- .join("\n") + elapsed);
23006
+ .join("\n") + elapsed +
23007
+ // Machine output (a stack trace, a path, "Error:" text) reads as an internal error everywhere else; its detail is here, as recorded.
23008
+ (run.reason !== null && isInternalErrorReason(run.reason)
23009
+ ? `<p class="row"><span class="meta" style="min-width:8.5rem">recorded error</span></p><pre class="mono" id="run-reason-detail" style="white-space:pre-wrap;overflow-wrap:anywhere">${escape(run.reason)}</pre>`
23010
+ : ""));
22931
23011
  }
22932
23012
  function runPage(chrome, run, taskId, artifacts, terminal = null, notes = [], csrf = "", comments = [], ciRepair = null, liveScript, peekable = false, running = false, editor = null, editorToggle = null, noted = false, heldTurns = null, continueOffer = null, structuredHandoff = null, proofBundle = null, route = null, publicationRow = null, review = null, result = null, sourceDigest = null) {
22933
23013
  const rows = runFactsRows(run, taskId, running, route);