backpass 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/subprocess.js CHANGED
@@ -8,21 +8,35 @@ import { spawn } from "node:child_process";
8
8
  *
9
9
  * @param {string} bin
10
10
  * @param {string[]} args
11
- * @param {{ timeoutMs?: number, cwd?: string, input?: string }} [options]
11
+ * @param {{ timeoutMs?: number, cwd?: string, input?: string, env?: NodeJS.ProcessEnv }} [options]
12
12
  * @returns {Promise<{ code: number | null, stdout: string, stderr: string, timedOut?: boolean, spawnError?: NodeJS.ErrnoException }>}
13
13
  */
14
- export function runCapture(bin, args, { timeoutMs, cwd, input } = {}) {
14
+ export function runCapture(bin, args, { timeoutMs, cwd, input, env } = {}) {
15
15
  return new Promise((resolve) => {
16
- const child = spawn(bin, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
16
+ const child = spawn(bin, args, {
17
+ cwd,
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ env: env ? { ...process.env, ...env } : undefined,
20
+ // Give a timed command its own POSIX process group. acpx launches adapter
21
+ // wrappers and harnesses below itself, and killing acpx alone leaves those
22
+ // descendants running with our capture pipes open.
23
+ detached: process.platform !== "win32" && Boolean(timeoutMs),
24
+ });
17
25
  let stdout = "";
18
26
  let stderr = "";
19
27
  let timedOut = false;
28
+ let escalationTimer = null;
20
29
 
21
30
  const timer = timeoutMs
22
31
  ? setTimeout(() => {
23
32
  timedOut = true;
24
- child.kill("SIGTERM");
25
- setTimeout(() => child.kill("SIGKILL"), 5000).unref();
33
+ if (process.platform === "win32") {
34
+ killWindowsTree(child.pid);
35
+ return;
36
+ }
37
+ killPosixGroup(child, "SIGTERM");
38
+ escalationTimer = setTimeout(() => killPosixGroup(child, "SIGKILL"), 5000);
39
+ escalationTimer.unref();
26
40
  }, timeoutMs)
27
41
  : null;
28
42
 
@@ -34,10 +48,13 @@ export function runCapture(bin, args, { timeoutMs, cwd, input } = {}) {
34
48
  });
35
49
  child.on("error", (err) => {
36
50
  if (timer) clearTimeout(timer);
51
+ if (escalationTimer) clearTimeout(escalationTimer);
37
52
  resolve({ code: null, stdout, stderr: `${stderr}${err.message}`, spawnError: err });
38
53
  });
39
54
  child.on("close", (code) => {
40
55
  if (timer) clearTimeout(timer);
56
+ if (timedOut && process.platform !== "win32") killPosixGroup(child, "SIGKILL");
57
+ if (escalationTimer) clearTimeout(escalationTimer);
41
58
  resolve({ code, stdout, stderr, timedOut });
42
59
  });
43
60
 
@@ -45,3 +62,34 @@ export function runCapture(bin, args, { timeoutMs, cwd, input } = {}) {
45
62
  else child.stdin.end();
46
63
  });
47
64
  }
65
+
66
+ function killPosixGroup(child, signal) {
67
+ if (!child.pid) return;
68
+ try {
69
+ process.kill(-child.pid, signal);
70
+ } catch {
71
+ // Preserve direct-child cleanup if a platform cannot address the group.
72
+ try {
73
+ child.kill(signal);
74
+ } catch {
75
+ // The child already closed.
76
+ }
77
+ }
78
+ }
79
+
80
+ function killWindowsTree(pid) {
81
+ if (!pid) return;
82
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
83
+ stdio: "ignore",
84
+ windowsHide: true,
85
+ });
86
+ killer.on("error", () => {
87
+ // taskkill is part of Windows, but retain direct-child cleanup if it cannot start.
88
+ try {
89
+ process.kill(pid, "SIGKILL");
90
+ } catch {
91
+ // The child already closed.
92
+ }
93
+ });
94
+ killer.unref();
95
+ }
package/src/synthesize.js CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { extractJson, openSession, usageRecord } from "./acpx.js";
5
5
  import { renderEvidenceForPrompt } from "./fold.js";
6
6
  import { renderInstructionIndex } from "./memory.js";
7
- import { renderPrompt } from "./prompts.js";
7
+ import { renderPrompt, render, loadPrompt } from "./prompts.js";
8
8
  import { buildProposal, effectiveMaxEdits, ProposalViolation, renderChangesForPrompt } from "./proposal.js";
9
9
  import { loadSkills, renderSkillIndex, resolveOverflowTarget } from "./skills.js";
10
10
  import { isSuppressedByRejection } from "./state.js";
@@ -13,26 +13,55 @@ import { measureWorkspace, prepareWorkspace, repoFingerprint } from "./workspace
13
13
  import { UserError, color, info, warn } from "./logger.js";
14
14
 
15
15
  /**
16
- * Stage 3 of the pipeline (design section 3): one high-reasoning session that turns
17
- * folded evidence into concrete edits.
16
+ * Stage 3 of the pipeline (design section 3): high-reasoning synthesis that turns folded
17
+ * evidence into concrete edits.
18
18
  *
19
19
  * The agent never describes an edit for backpass to locate - it makes the edit, with its
20
20
  * harness's own file tools, in a staging copy of the memory file (`src/workspace.js`).
21
- * One session, two kinds of turn:
21
+ * A run starts in one session with two kinds of turn:
22
22
  *
23
23
  * edit the synthesis prompt; the agent edits `./AGENTS.md` in the staging copy
24
24
  * annotate backpass measures the copy against the original (`src/diff.js`) and shows
25
25
  * the changes by id; the agent attaches kind, title, rationale, and evidence
26
26
  *
27
- * The annotation is what the mechanical gates validate (`buildProposal`); on a violation
28
- * the agent is re-prompted with the exact breaches, at most ANNOTATE_TURNS times in all,
29
- * then backpass fails loudly and saves the rejected proposal rather than quietly trimming
30
- * it (design section 6). The repo is fingerprinted before and checked after: a harness
31
- * that wrote past the staging copy is an error, never a silent apply.
27
+ * An empty annotation turn is retried once in a fresh session, as described below.
28
+ *
29
+ * The annotation is what the mechanical gates validate (`buildProposal`). A parseable
30
+ * gate-rejected answer is saved before the agent is re-prompted with the exact breaches;
31
+ * judged answers are bounded by ANNOTATE_TURNS, then backpass fails loudly rather than
32
+ * quietly trimming the result (design section 6). The repo is fingerprinted around each
33
+ * turn; a harness that wrote past the staging copy is an error, never a silent apply.
34
+ *
35
+ * Three things an annotate turn can be are deliberately kept apart, because they call for
36
+ * different responses and produce different advice at the end of a failed run:
37
+ *
38
+ * the files moved the ids the agent was asked about no longer exist. It is shown the
39
+ * fresh measurement and answers again; this is not a failed
40
+ * annotation and never costs an annotation attempt (REMEASURE_TURNS
41
+ * bounds it instead).
42
+ * the turn was empty the adapter returned success with no text at all. The model never
43
+ * spoke, so there is nothing to correct - the annotation is retried
44
+ * once in a NEW session, since the accumulated context of the old
45
+ * one is the likeliest reason it collapsed.
46
+ * the answer was judged the model spoke and the gates ruled. Only this consumes an
47
+ * annotation attempt, and only this writes a rejected proposal.
32
48
  */
33
49
 
34
- /** Annotation turns per run: the first answer plus re-prompts with the exact violations. */
50
+ /** Annotation attempts per run: the first answer plus re-prompts with the exact violations. */
35
51
  export const ANNOTATE_TURNS = 3;
52
+ /**
53
+ * Turns per run the agent may spend re-editing instead of answering. Counted for the whole
54
+ * run, not consecutively: an agent that alternates editing and answering is still an agent
55
+ * that never finishes, and the loop has to end.
56
+ */
57
+ export const REMEASURE_TURNS = 3;
58
+ /** Fresh-session retries for an adapter turn that produced no text at all. */
59
+ export const EMPTY_TURN_RETRIES = 1;
60
+
61
+ const EMPTY_TURN_VIOLATION =
62
+ "the synthesis harness ended its turn with no output at all - no JSON, no prose, no tool call";
63
+ const UNPARSEABLE_VIOLATION = "synthesis answered with text, but not with a JSON object";
64
+ const KEPT_EDITING_VIOLATION = "synthesis kept editing the staging copy instead of annotating the measured changes";
36
65
 
37
66
  function budgetRule(memoryFile, config, maxEdits) {
38
67
  const remaining = config.budgetTokens - memoryFile.tokens;
@@ -93,13 +122,16 @@ function assertRepoUntouched(repo, before, workspaceRoot) {
93
122
  );
94
123
  }
95
124
 
96
- export async function synthesizeProposal({ memoryFile, summary, config, repo, transcripts, runNote = "" }) {
125
+ /**
126
+ * Everything the edit and annotation turns need: prompt values, the `buildProposal`
127
+ * context, and the overflow target.
128
+ */
129
+ function synthesisSetup({ memoryFile, summary, config, repo, harnessCounts }) {
97
130
  const state = config.state;
98
131
  const rejections = state.readRejections();
99
132
  const overflow = resolveOverflowTarget(repo.root, config.skillsDir);
100
133
  for (const w of overflow.warnings) warn(w);
101
134
  const skillFiles = loadSkills(repo.root, overflow.dir);
102
- const harnessCounts = harnessCountsOf(transcripts);
103
135
  const maxEdits = effectiveMaxEdits(memoryFile, config);
104
136
 
105
137
  const common = {
@@ -108,6 +140,228 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
108
140
  MAX_EDITS: String(maxEdits),
109
141
  MIN_GAP_EVIDENCE: String(config.minGapEvidence),
110
142
  };
143
+
144
+ const context = {
145
+ memoryFile,
146
+ config: { ...config, skillsDir: overflow.dir },
147
+ repo,
148
+ summary,
149
+ harnessCounts,
150
+ rejections,
151
+ isSuppressed: isSuppressedByRejection,
152
+ skillFiles,
153
+ };
154
+
155
+ const promptDir = path.join(state.root, "prompts");
156
+ fs.mkdirSync(promptDir, { recursive: true });
157
+
158
+ return { state, rejections, overflow, skillFiles, maxEdits, common, context, promptDir };
159
+ }
160
+
161
+ /**
162
+ * The header a fresh annotation session needs. An in-session annotate turn inherits the
163
+ * repository, the budget, and the evidence from the editing turn that preceded it; the
164
+ * fresh session used after an empty reply would otherwise be asked to quote evidence it
165
+ * has never been shown.
166
+ */
167
+ function prefaceFor({ memoryFile, summary, config, repo, workspaceRoot }) {
168
+ return render(loadPrompt("annotate-preface"), {
169
+ MEMORY_PATH: memoryFile.path,
170
+ REPO_NAME: repo.name,
171
+ REPO_ROOT: repo.root,
172
+ WORKSPACE_ROOT: workspaceRoot,
173
+ CURRENT_TOKENS: String(memoryFile.tokens),
174
+ BUDGET_STATE: budgetState(memoryFile, config),
175
+ TRANSCRIPT_COUNT: String(summary.analyzedSessions),
176
+ EVIDENCE: renderEvidenceForPrompt(summary),
177
+ });
178
+ }
179
+
180
+ const REMEASURE_NOTICE =
181
+ `\n\n## The files moved after they were measured\n\nYou changed the files again during your last turn, so the ids you were given no longer ` +
182
+ `describe them. Nothing is wrong with the shape of your answer - annotate the re-measured ` +
183
+ `changes above instead. This did not use up an annotation attempt.\n`;
184
+
185
+ const rejectionBlock = (violations) =>
186
+ `\n\n## Your previous answer was rejected\n\nIt violated these hard rules. Fix every one of them ` +
187
+ `(edit the files first if a change must go or move) and return the corrected JSON object only.\n\n` +
188
+ `${violations.map((v) => `- ${v}`).join("\n")}\n`;
189
+
190
+ /** The headline of a failed run, named after the condition it actually ended on. */
191
+ function terminalMessage(reason, attempts, violations) {
192
+ if (reason === "empty") {
193
+ return "synthesis ended its turn with no output, in the run's session and again in a fresh one";
194
+ }
195
+ if (reason === "editing") {
196
+ return `synthesis kept editing the staging copy instead of annotating it (${REMEASURE_TURNS} re-measurements)`;
197
+ }
198
+ return (
199
+ `synthesis could not produce a valid proposal after ${Math.max(attempts - 1, 0)} re-prompt(s) ` +
200
+ `(${violations.length} violation(s))`
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Drive the annotate turns to a valid proposal, or throw a `ProposalViolation` describing
206
+ * the condition the run actually ended on.
207
+ *
208
+ * `holder.session` is the live session; the loop replaces it when it needs a fresh one and
209
+ * the caller closes whatever is in the holder at the end.
210
+ */
211
+ async function annotateLoop({
212
+ holder,
213
+ freshSession,
214
+ workspace,
215
+ fingerprint,
216
+ repo,
217
+ context,
218
+ common,
219
+ promptDir,
220
+ timeoutSeconds,
221
+ promptRetries,
222
+ usage,
223
+ notes,
224
+ noteOnce,
225
+ overflow,
226
+ progress,
227
+ renderPreface,
228
+ startFresh = false,
229
+ }) {
230
+ const { memoryFile, config } = context;
231
+ const state = config.state;
232
+
233
+ let attempts = 0;
234
+ let remeasures = 0;
235
+ let emptyTurns = 0;
236
+ let violationsToShow = [];
237
+ let justRemeasured = false;
238
+ let owePreface = startFresh;
239
+ /** @type {{ attempt: number, violations: string[] } | null} */
240
+ let saved = null;
241
+ /** @type {{ reason: string, violations: string[] }} */
242
+ let terminal;
243
+
244
+ for (let turn = 1; ; turn += 1) {
245
+ assertRepoUntouched(repo, fingerprint, workspace.root);
246
+ const measured = measureWorkspace(workspace);
247
+
248
+ let prompt = renderPrompt("annotate", {
249
+ ...common,
250
+ PREFACE: owePreface ? renderPreface() : "",
251
+ CHANGES: renderChangesForPrompt(measured, memoryFile),
252
+ });
253
+ owePreface = false;
254
+ if (justRemeasured) prompt += REMEASURE_NOTICE;
255
+ else if (violationsToShow.length) prompt += rejectionBlock(violationsToShow);
256
+
257
+ const promptFile = path.join(promptDir, `synthesis-annotate-${turn}.md`);
258
+ fs.writeFileSync(promptFile, prompt);
259
+ progress("annotate", { attempt: attempts + 1, turn, changes: measured.changes.length });
260
+
261
+ const result = await holder.prompt({
262
+ promptFile,
263
+ approveAll: true,
264
+ timeoutSeconds,
265
+ promptRetries,
266
+ });
267
+ usage.push(usageRecord(holder.ranWith, result));
268
+ for (const note of result.notes || []) noteOnce(note);
269
+
270
+ // The agent may keep editing during an annotate turn; the ids it was answering about
271
+ // are then stale, so the answer is dropped and the fresh measurement shown instead.
272
+ // That is a measurement problem, not a failed annotation: it costs no attempt.
273
+ assertRepoUntouched(repo, fingerprint, workspace.root);
274
+ if (measureWorkspace(workspace).signature !== measured.signature) {
275
+ remeasures += 1;
276
+ justRemeasured = true;
277
+ violationsToShow = [];
278
+ if (remeasures >= REMEASURE_TURNS) {
279
+ terminal = { reason: "editing", violations: [KEPT_EDITING_VIOLATION] };
280
+ break;
281
+ }
282
+ warn(
283
+ `synthesis edited the staging copy again; re-measuring and re-annotating ` +
284
+ `(annotation attempt ${attempts + 1} of ${ANNOTATE_TURNS} is still unspent)`,
285
+ );
286
+ emitProgress("synth:remeasure", { turn, attempt: attempts + 1, remeasures });
287
+ continue;
288
+ }
289
+ justRemeasured = false;
290
+
291
+ // An empty turn is not a bad answer; it is no answer. Retry it once in a new session,
292
+ // because the accumulated context of this one is the likeliest reason it collapsed.
293
+ if (!(result.text || "").trim()) {
294
+ emptyTurns += 1;
295
+ if (emptyTurns > EMPTY_TURN_RETRIES) {
296
+ terminal = { reason: "empty", violations: [EMPTY_TURN_VIOLATION] };
297
+ break;
298
+ }
299
+ warn("synthesis ended its turn with no output; retrying the annotation once in a fresh session");
300
+ emitProgress("synth:empty", { turn, attempt: attempts + 1, emptyTurns });
301
+ await holder.session.close();
302
+ holder.session = await freshSession();
303
+ // The new session knows nothing, so it is given the preface; `violationsToShow` is
304
+ // kept because those gates are still what the run's annotation has to satisfy.
305
+ owePreface = true;
306
+ continue;
307
+ }
308
+
309
+ attempts += 1;
310
+ const parsed = extractJson(result.text);
311
+ if (!parsed) {
312
+ violationsToShow = [UNPARSEABLE_VIOLATION];
313
+ if (attempts >= ANNOTATE_TURNS) {
314
+ terminal = { reason: "unparseable", violations: violationsToShow };
315
+ break;
316
+ }
317
+ warn(`synthesis violated 1 gate(s); re-prompting with the exact violations`);
318
+ emitProgress("synth:violations", { attempt: attempts, violations: violationsToShow });
319
+ continue;
320
+ }
321
+
322
+ const { proposal, violations } = buildProposal(parsed, { ...context, measured });
323
+ proposal.notes = [...proposal.notes, ...notes];
324
+ proposal.usage = usage;
325
+ proposal.overflowTarget = overflow;
326
+ proposal.attempt = attempts;
327
+ if (!violations.length) {
328
+ emitProgress("synth:done", { edits: proposal.edits.length, attempt: attempts });
329
+ return { proposal, violations: [] };
330
+ }
331
+
332
+ violationsToShow = violations;
333
+ proposal.violations = violations;
334
+ // Keep the rejected proposal so a loud failure is still inspectable. It records which
335
+ // attempt produced it, so a later empty turn cannot be reported as its author.
336
+ state.writeProposal(proposal);
337
+ saved = { attempt: attempts, violations };
338
+ if (attempts >= ANNOTATE_TURNS) {
339
+ terminal = { reason: "gates", violations };
340
+ break;
341
+ }
342
+ warn(`synthesis violated ${violations.length} gate(s); re-prompting with the exact violations`);
343
+ emitProgress("synth:violations", { attempt: attempts, violations });
344
+ }
345
+
346
+ throw new ProposalViolation(terminalMessage(terminal.reason, attempts, terminal.violations), terminal.violations, {
347
+ reason: terminal.reason,
348
+ attempts,
349
+ saved,
350
+ proposalPath: saved ? state.proposalPath : null,
351
+ });
352
+ }
353
+
354
+ export async function synthesizeProposal({ memoryFile, summary, config, repo, transcripts, runNote = "" }) {
355
+ config.state.clearProposal();
356
+ const harnessCounts = harnessCountsOf(transcripts);
357
+ const { state, rejections, overflow, skillFiles, maxEdits, common, context, promptDir } = synthesisSetup({
358
+ memoryFile,
359
+ summary,
360
+ config,
361
+ repo,
362
+ harnessCounts,
363
+ });
364
+
111
365
  const editValues = {
112
366
  ...common,
113
367
  REPO_NAME: repo.name,
@@ -128,19 +382,6 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
128
382
  REJECTIONS: renderRejections(rejections),
129
383
  };
130
384
 
131
- const context = {
132
- memoryFile,
133
- config: { ...config, skillsDir: overflow.dir },
134
- repo,
135
- summary,
136
- harnessCounts,
137
- rejections,
138
- isSuppressed: isSuppressedByRejection,
139
- skillFiles,
140
- };
141
-
142
- const promptDir = path.join(state.root, "prompts");
143
- fs.mkdirSync(promptDir, { recursive: true });
144
385
  const editPromptFile = path.join(promptDir, "synthesis-edit.md");
145
386
  fs.writeFileSync(editPromptFile, renderPrompt("synthesis", editValues));
146
387
 
@@ -163,6 +404,7 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
163
404
  `${pick.effort ? ` effort=${pick.effort}` : ""}`,
164
405
  );
165
406
  let ranWith = pick.agent;
407
+ let chosen = pick;
166
408
  const progress = (phase, extra = {}) =>
167
409
  emitProgress("synth:start", {
168
410
  agent: ranWith,
@@ -181,16 +423,25 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
181
423
  // through to the next ladder candidate; the switch is recorded in the notes so the
182
424
  // proposal's provenance is visible. Once the editing turn has run, later turns stay
183
425
  // on the same candidate - a run never silently switches models after real work.
184
- /** @type {Awaited<ReturnType<typeof openSession>> | null} */
185
- let session = null;
426
+ /** @type {{ session: Awaited<ReturnType<typeof openSession>> | null, ranWith: string, prompt: Function }} */
427
+ const holder = {
428
+ session: null,
429
+ ranWith,
430
+ prompt(args) {
431
+ if (!this.session) throw new Error("synthesis session is not open");
432
+ return this.session.prompt(args);
433
+ },
434
+ };
186
435
  /** @type {ReturnType<typeof prepareWorkspace>} */
187
436
  let workspace = null;
188
437
  const editResult = await config.agents.withFallthrough("synthesis", async (current) => {
189
438
  ranWith = current.agent;
439
+ holder.ranWith = current.agent;
440
+ chosen = current;
190
441
  if (current !== pick) notes.push(`synthesis fell through to ${current.agent} (${current.model})`);
191
442
  workspace = prepareWorkspace({ state, repo, memoryFile, skillsDir: overflow.dir });
192
443
  progress("edit", { attempt: 1 });
193
- session = await openSession({
444
+ holder.session = await openSession({
194
445
  agent: current.agent,
195
446
  model: current.model,
196
447
  effort: current.effort,
@@ -198,90 +449,51 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
198
449
  cwd: workspace.root,
199
450
  });
200
451
  try {
201
- return await session.prompt({
452
+ return await holder.session.prompt({
202
453
  promptFile: editPromptFile,
203
454
  approveAll: true,
204
455
  timeoutSeconds,
205
456
  promptRetries: config.promptRetries,
206
457
  });
207
458
  } catch (err) {
208
- await session.close();
209
- session = null;
459
+ await holder.session.close();
460
+ holder.session = null;
210
461
  throw err;
211
462
  }
212
463
  });
213
464
  usage.push(usageRecord(ranWith, editResult));
214
465
  for (const note of editResult.notes || []) noteOnce(note);
215
466
 
216
- const turn = session;
217
- try {
218
- let lastViolations = [];
219
- for (let attempt = 1; attempt <= ANNOTATE_TURNS; attempt += 1) {
220
- assertRepoUntouched(repo, fingerprint, workspace.root);
221
- const measured = measureWorkspace(workspace);
222
-
223
- let prompt = renderPrompt("annotate", {
224
- ...common,
225
- CHANGES: renderChangesForPrompt(measured, memoryFile),
226
- });
227
- if (lastViolations.length) {
228
- prompt +=
229
- `\n\n## Your previous answer was rejected\n\nIt violated these hard rules. Fix every one of them ` +
230
- `(edit the files first if a change must go or move) and return the corrected JSON object only.\n\n` +
231
- `${lastViolations.map((v) => `- ${v}`).join("\n")}\n`;
232
- }
233
- const promptFile = path.join(promptDir, `synthesis-annotate-${attempt}.md`);
234
- fs.writeFileSync(promptFile, prompt);
235
- progress("annotate", { attempt, changes: measured.changes.length });
236
-
237
- const result = await turn.prompt({
238
- promptFile,
239
- approveAll: true,
240
- timeoutSeconds,
241
- promptRetries: config.promptRetries,
242
- });
243
- usage.push(usageRecord(ranWith, result));
244
- for (const note of result.notes || []) noteOnce(note);
245
-
246
- // The agent may keep editing during an annotate turn; ids are then stale, so the
247
- // answer is discarded and the fresh measurement is shown instead.
248
- assertRepoUntouched(repo, fingerprint, workspace.root);
249
- const remeasured = measureWorkspace(workspace);
250
- if (remeasured.signature !== measured.signature) {
251
- lastViolations = [
252
- "the files changed after the changes were measured; annotate the re-measured changes shown above",
253
- ];
254
- } else {
255
- const parsed = extractJson(result.text);
256
- if (!parsed) {
257
- lastViolations = ["synthesis returned no parseable JSON object"];
258
- } else {
259
- const { proposal, violations } = buildProposal(parsed, { ...context, measured });
260
- proposal.notes = [...proposal.notes, ...notes];
261
- proposal.usage = usage;
262
- proposal.overflowTarget = overflow;
263
- if (!violations.length) {
264
- emitProgress("synth:done", { edits: proposal.edits.length, attempt });
265
- return { proposal, violations: [] };
266
- }
267
- lastViolations = violations;
268
- proposal.violations = violations;
269
- // Keep the rejected proposal so a loud failure is still inspectable.
270
- state.writeProposal(proposal);
271
- }
272
- }
273
-
274
- if (attempt < ANNOTATE_TURNS) {
275
- warn(`synthesis violated ${lastViolations.length} gate(s); re-prompting with the exact violations`);
276
- emitProgress("synth:violations", { attempt, violations: lastViolations });
277
- }
278
- }
467
+ let serial = 1;
468
+ const freshSession = () =>
469
+ openSession({
470
+ agent: chosen.agent,
471
+ model: chosen.model,
472
+ effort: chosen.effort,
473
+ sessionName: `${sessionName}-r${(serial += 1)}`,
474
+ cwd: workspace.root,
475
+ });
279
476
 
280
- throw new ProposalViolation(
281
- `synthesis could not produce a valid proposal after ${ANNOTATE_TURNS - 1} re-prompt(s) (${lastViolations.length} violation(s))`,
282
- lastViolations,
283
- );
477
+ try {
478
+ return await annotateLoop({
479
+ holder,
480
+ freshSession,
481
+ workspace,
482
+ fingerprint,
483
+ repo,
484
+ context,
485
+ common,
486
+ promptDir,
487
+ timeoutSeconds,
488
+ promptRetries: config.promptRetries,
489
+ usage,
490
+ notes,
491
+ noteOnce,
492
+ overflow,
493
+ progress,
494
+ renderPreface: () => prefaceFor({ memoryFile, summary, config, repo, workspaceRoot: workspace.root }),
495
+ });
284
496
  } finally {
285
- await turn.close();
497
+ await holder.session.close();
286
498
  }
287
499
  }
package/src/tui/index.js CHANGED
@@ -103,6 +103,7 @@ export function initialState(meta) {
103
103
  instructions: 0,
104
104
  suppressed: 0,
105
105
  violations: [],
106
+ annotateCondition: null,
106
107
  edits: 0,
107
108
  },
108
109
  };
@@ -233,6 +234,17 @@ export function reduceEvent(state, event, data, now = Date.now()) {
233
234
  break;
234
235
  case "synth:violations":
235
236
  s.violations = data.violations || [];
237
+ s.annotateCondition = null;
238
+ break;
239
+ // Neither of these is a rejected answer, so neither clears into the violations list:
240
+ // the files moved under the ids, or the harness said nothing at all.
241
+ case "synth:remeasure":
242
+ s.annotateCondition = "remeasure";
243
+ s.violations = [];
244
+ break;
245
+ case "synth:empty":
246
+ s.annotateCondition = "empty";
247
+ s.violations = [];
236
248
  break;
237
249
  case "synth:done":
238
250
  s.status = "done";
package/src/tui/render.js CHANGED
@@ -247,6 +247,9 @@ function synthSummary(theme, s) {
247
247
  const model = [s.agent, s.model].filter(Boolean).join(" · ");
248
248
  const effort = s.effort ? ` · effort ${s.effort}` : "";
249
249
  if (s.status === "done") return theme.paint(`${s.edits} edit(s) · passed validation`, "dim");
250
+ if (s.phase === "annotate" && s.annotateCondition === "empty") {
251
+ return theme.paint("fresh session after an empty turn · ", "dim") + theme.paint(`${model}${effort}`, "dim");
252
+ }
250
253
  if (s.phase === "annotate" && s.attempt > 1) {
251
254
  return (
252
255
  theme.paint("re-prompt ", "dim") +
@@ -384,6 +387,19 @@ function synthesizeDetail(state, theme, width, spin) {
384
387
  sectionRule(theme, STAGE_LABELS.synthesize, `aggregated gradients → at most ${state.meta.maxEdits} edits`, width),
385
388
  ];
386
389
 
390
+ // A re-measurement and an empty turn are named for what they are: neither spent one of
391
+ // the annotation attempts, and saying "violated 1 gate(s)" for either is how a run gets
392
+ // read as almost-passing when nothing was judged at all.
393
+ if (s.phase === "annotate" && s.annotateCondition === "remeasure") {
394
+ lines.push(
395
+ ` ${theme.paint("~", "yellow")} ${theme.paint("the files moved; re-annotating the new ids", "text")} ${theme.paint("· no annotation attempt spent", "dim")}`,
396
+ );
397
+ }
398
+ if (s.phase === "annotate" && s.annotateCondition === "empty") {
399
+ lines.push(
400
+ ` ${theme.paint("~", "yellow")} ${theme.paint("the harness returned an empty turn", "text")} ${theme.paint("· retrying in a fresh session", "dim")}`,
401
+ );
402
+ }
387
403
  if (s.phase === "annotate" && s.attempt > 1 && s.violations.length) {
388
404
  lines.push(
389
405
  ` ${theme.paint("!", "yellow")} ${theme.paint(`synthesis violated ${s.violations.length} gate(s)`, "text")} ${theme.paint("· re-prompting with the exact breaches", "dim")}`,