@possumtech/rummy 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.env.example +13 -1
  2. package/PLUGINS.md +1 -1
  3. package/README.md +5 -1
  4. package/SPEC.md +211 -54
  5. package/migrations/001_initial_schema.sql +3 -4
  6. package/package.json +7 -3
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +183 -238
  9. package/src/agent/ContextAssembler.js +2 -0
  10. package/src/agent/KnownStore.js +36 -85
  11. package/src/agent/ResponseHealer.js +65 -31
  12. package/src/agent/TurnExecutor.js +284 -382
  13. package/src/agent/XmlParser.js +28 -4
  14. package/src/agent/known_queries.sql +1 -1
  15. package/src/agent/known_store.sql +32 -34
  16. package/src/agent/runs.sql +2 -2
  17. package/src/agent/tokens.js +1 -0
  18. package/src/agent/turns.sql +5 -0
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +2 -4
  21. package/src/hooks/ToolRegistry.js +8 -13
  22. package/src/plugins/ask_user/ask_userDoc.js +3 -8
  23. package/src/plugins/budget/README.md +26 -30
  24. package/src/plugins/budget/budget.js +69 -36
  25. package/src/plugins/budget/recovery.js +47 -0
  26. package/src/plugins/cp/cp.js +1 -1
  27. package/src/plugins/cp/cpDoc.js +5 -10
  28. package/src/plugins/env/envDoc.js +3 -8
  29. package/src/plugins/get/get.js +70 -2
  30. package/src/plugins/get/getDoc.js +19 -16
  31. package/src/plugins/hedberg/matcher.js +10 -29
  32. package/src/plugins/helpers.js +2 -2
  33. package/src/plugins/instructions/instructions.js +3 -2
  34. package/src/plugins/instructions/preamble.md +33 -12
  35. package/src/plugins/known/known.js +66 -17
  36. package/src/plugins/known/knownDoc.js +7 -10
  37. package/src/plugins/mv/mv.js +18 -1
  38. package/src/plugins/mv/mvDoc.js +9 -10
  39. package/src/plugins/{current → performed}/README.md +4 -3
  40. package/src/plugins/{current/current.js → performed/performed.js} +15 -20
  41. package/src/plugins/policy/policy.js +47 -0
  42. package/src/plugins/previous/README.md +2 -1
  43. package/src/plugins/previous/previous.js +31 -25
  44. package/src/plugins/progress/README.md +1 -2
  45. package/src/plugins/progress/progress.js +10 -60
  46. package/src/plugins/prompt/prompt.js +10 -8
  47. package/src/plugins/rm/rm.js +27 -15
  48. package/src/plugins/rm/rmDoc.js +6 -11
  49. package/src/plugins/rpc/rpc.js +3 -1
  50. package/src/plugins/set/set.js +125 -92
  51. package/src/plugins/set/setDoc.js +28 -37
  52. package/src/plugins/sh/shDoc.js +2 -7
  53. package/src/plugins/summarize/summarize.js +7 -0
  54. package/src/plugins/summarize/summarizeDoc.js +6 -11
  55. package/src/plugins/telemetry/telemetry.js +14 -9
  56. package/src/plugins/think/think.js +12 -0
  57. package/src/plugins/think/thinkDoc.js +18 -0
  58. package/src/plugins/unknown/README.md +2 -1
  59. package/src/plugins/unknown/unknown.js +26 -4
  60. package/src/plugins/unknown/unknownDoc.js +9 -14
  61. package/src/plugins/update/update.js +7 -0
  62. package/src/plugins/update/updateDoc.js +6 -11
  63. package/src/server/ClientConnection.js +69 -45
  64. package/src/sql/v_model_context.sql +7 -17
  65. package/src/plugins/budget/BudgetGuard.js +0 -74
@@ -1,3 +1,4 @@
1
+ import { advanceRecovery } from "../plugins/budget/recovery.js";
1
2
  import KnownStore from "./KnownStore.js";
2
3
  import msg from "./messages.js";
3
4
  import ResponseHealer from "./ResponseHealer.js";
@@ -70,14 +71,15 @@ export default class AgentLoop {
70
71
  const existing = this.#activeRuns.get(existingRun.id);
71
72
  if (existing) existing.abort();
72
73
 
74
+ // Clean up stale proposals from interrupted runs
73
75
  const unresolved = await this.#knownStore.getUnresolved(existingRun.id);
74
- if (unresolved.length > 0) {
75
- return {
76
- runId: existingRun.id,
77
- alias: existingRun.alias,
78
- blocked: true,
79
- proposed: unresolved,
80
- };
76
+ for (const u of unresolved) {
77
+ await this.#knownStore.resolve(
78
+ existingRun.id,
79
+ u.path,
80
+ 499,
81
+ "Stale proposal from interrupted run",
82
+ );
81
83
  }
82
84
  return { runId: existingRun.id, alias: existingRun.alias };
83
85
  }
@@ -121,18 +123,10 @@ export default class AgentLoop {
121
123
  const noRepo = options?.noRepo === true;
122
124
  const noInteraction = options?.noInteraction === true;
123
125
  const noWeb = options?.noWeb === true;
126
+ const noProposals = options?.noProposals === true;
124
127
  const requestedModel = model;
125
128
 
126
129
  const runInfo = await this.#ensureRun(projectId, model, run, options);
127
- if (runInfo.blocked) {
128
- return {
129
- run: runInfo.alias,
130
- status: 202,
131
- remainingCount: runInfo.proposed.length,
132
- proposed: runInfo.proposed,
133
- };
134
- }
135
-
136
130
  const { runId: currentRunId, alias: currentAlias } = runInfo;
137
131
 
138
132
  const loopSeq = await this.#db.next_loop.get({ run_id: currentRunId });
@@ -146,6 +140,7 @@ export default class AgentLoop {
146
140
  noRepo,
147
141
  noInteraction,
148
142
  noWeb,
143
+ noProposals,
149
144
  temperature: options?.temperature,
150
145
  }),
151
146
  });
@@ -164,100 +159,74 @@ export default class AgentLoop {
164
159
  }
165
160
 
166
161
  async #drainQueue(currentRunId, currentAlias, projectId, project, options) {
167
- let panicAttempted = false;
168
-
169
- while (true) {
170
- const loop = await this.#db.claim_next_loop.get({
171
- run_id: currentRunId,
172
- });
173
- if (!loop) break;
174
-
175
- const loopConfig = loop.config ? JSON.parse(loop.config) : {};
176
- const hook =
177
- loop.mode === "panic"
178
- ? this.#hooks.panic
179
- : loop.mode === "ask"
180
- ? this.#hooks.ask
181
- : this.#hooks.act;
182
-
183
- const result = await this.#executeLoop({
184
- mode: loop.mode,
185
- project,
186
- projectId,
187
- currentRunId,
188
- currentAlias,
189
- currentLoopId: loop.id,
190
- requestedModel: loop.model,
191
- prompt: loop.prompt,
192
- noRepo: loopConfig.noRepo || false,
193
- noInteraction: loopConfig.noInteraction || false,
194
- noWeb: loopConfig.noWeb || false,
195
- options: { ...options, temperature: loopConfig.temperature },
196
- hook,
197
- });
162
+ const controller = new AbortController();
163
+ this.#activeRuns.set(currentRunId, controller);
198
164
 
199
- if (result.status === 413) {
200
- await this.#db.complete_loop.run({
201
- id: loop.id,
202
- status: 413,
203
- result: JSON.stringify(result),
165
+ try {
166
+ while (true) {
167
+ const loop = await this.#db.claim_next_loop.get({
168
+ run_id: currentRunId,
204
169
  });
170
+ if (!loop) break;
171
+
172
+ const loopConfig = loop.config ? JSON.parse(loop.config) : {};
173
+ const hook = loop.mode === "ask" ? this.#hooks.ask : this.#hooks.act;
174
+
175
+ let result;
176
+ try {
177
+ result = await this.#executeLoop({
178
+ mode: loop.mode,
179
+ project,
180
+ projectId,
181
+ currentRunId,
182
+ currentAlias,
183
+ currentLoopId: loop.id,
184
+ requestedModel: loop.model,
185
+ prompt: loop.prompt,
186
+ noRepo: loopConfig.noRepo || false,
187
+ noInteraction: loopConfig.noInteraction || false,
188
+ noWeb: loopConfig.noWeb || false,
189
+ noProposals: loopConfig.noProposals || false,
190
+ options: { ...options, temperature: loopConfig.temperature },
191
+ hook,
192
+ signal: controller.signal,
193
+ });
194
+ } catch (err) {
195
+ await this.#db.complete_loop.run({
196
+ id: loop.id,
197
+ status: 500,
198
+ result: JSON.stringify({ error: err.message }),
199
+ });
200
+ throw err;
201
+ }
205
202
 
206
- // One panic attempt per drain cycle
207
- if (loop.mode === "panic" || panicAttempted) {
203
+ if (result.status === 413) {
204
+ await this.#db.complete_loop.run({
205
+ id: loop.id,
206
+ status: 413,
207
+ result: JSON.stringify(result),
208
+ });
208
209
  return {
209
210
  run: currentAlias,
210
211
  status: 413,
211
- error:
212
- loop.mode === "panic"
213
- ? `Panic mode failed to free enough space (${result.overflow} tokens over).`
214
- : `Context full (${result.overflow} tokens over).`,
212
+ error: `Context full (${result.overflow} tokens over).`,
215
213
  };
216
214
  }
217
215
 
218
- panicAttempted = true;
219
-
220
- const panicPrompt = this.#hooks.budget.panicPrompt({
221
- assembledTokens: result.assembledTokens,
222
- contextSize: result.contextSize,
223
- });
224
-
225
- // Enqueue panic loop
226
- const panicSeq = await this.#db.next_loop.get({ run_id: currentRunId });
227
- await this.#db.enqueue_loop.get({
228
- run_id: currentRunId,
229
- sequence: panicSeq.sequence,
230
- mode: "panic",
231
- model: loop.model,
232
- prompt: panicPrompt,
233
- config: JSON.stringify({ noRepo: true }),
234
- });
235
-
236
- // Re-enqueue the original loop to retry after panic
237
- const retrySeq = await this.#db.next_loop.get({ run_id: currentRunId });
238
- await this.#db.enqueue_loop.get({
239
- run_id: currentRunId,
240
- sequence: retrySeq.sequence,
241
- mode: loop.mode,
242
- model: loop.model,
243
- prompt: loop.prompt,
244
- config: loop.config,
216
+ await this.#db.complete_loop.run({
217
+ id: loop.id,
218
+ status: result.status,
219
+ result: JSON.stringify(result),
245
220
  });
246
-
247
- continue;
248
221
  }
249
222
 
250
- await this.#db.complete_loop.run({
251
- id: loop.id,
252
- status: result.status === 202 ? 202 : result.status,
253
- result: JSON.stringify(result),
223
+ const runRow = await this.#db.get_run_by_alias.get({
224
+ alias: currentAlias,
254
225
  });
255
-
256
- if (result.status === 202) return result;
226
+ return { run: currentAlias, status: runRow?.status ?? 200 };
227
+ } finally {
228
+ this.#activeRuns.delete(currentRunId);
257
229
  }
258
-
259
- const runRow = await this.#db.get_run_by_alias.get({ alias: currentAlias });
260
- return { run: currentAlias, status: runRow?.status ?? 200 };
261
230
  }
262
231
 
263
232
  async #executeLoop({
@@ -272,8 +241,10 @@ export default class AgentLoop {
272
241
  noRepo,
273
242
  noInteraction,
274
243
  noWeb,
244
+ noProposals,
275
245
  options,
276
246
  hook,
247
+ signal,
277
248
  }) {
278
249
  const runRow = await this.#db.get_run_by_id.get({ id: currentRunId });
279
250
  if (runRow.status !== 102) {
@@ -292,18 +263,24 @@ export default class AgentLoop {
292
263
  const toolSet = this.#hooks.tools.resolveForLoop(mode, {
293
264
  noInteraction,
294
265
  noWeb,
266
+ noProposals,
295
267
  });
296
268
 
297
269
  let loopIteration = 0;
298
270
  const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS) || 15;
299
271
  const healer = new ResponseHealer();
300
272
 
301
- const controller = new AbortController();
302
- this.#activeRuns.set(currentRunId, controller);
303
-
304
273
  let _lastAssembledTokens = 0;
305
- let _panicStrikes = 0;
306
- let _lastPanicTokens = null;
274
+ let recovery = null; // { target, promptPath, strikes, lastTokens }
275
+
276
+ // Previous loop entries stay at full fidelity — the model is
277
+ // instructed to summarize and demote them. Budget enforcement
278
+ // catches overflow if the model fails to manage context.
279
+
280
+ // Restore any prompt entries left at summary fidelity by a recovery
281
+ // phase that was interrupted (server crash, restart). If the full
282
+ // prompt would overflow, Prompt Demotion on turn 1 handles it.
283
+ await this.#knownStore.restoreSummarizedPrompts(currentRunId);
307
284
 
308
285
  await this.#hooks.loop.started.emit({
309
286
  runId: currentRunId,
@@ -314,7 +291,7 @@ export default class AgentLoop {
314
291
 
315
292
  try {
316
293
  while (loopIteration < MAX_LOOP_ITERATIONS) {
317
- if (controller.signal.aborted) {
294
+ if (signal.aborted) {
318
295
  await this.#db.update_run_status.run({
319
296
  id: currentRunId,
320
297
  status: 499,
@@ -332,8 +309,6 @@ export default class AgentLoop {
332
309
  let turnPrompt;
333
310
  if (loopIteration === 1) {
334
311
  turnPrompt = prompt;
335
- } else if (mode === "panic") {
336
- turnPrompt = "Continue freeing space. Check <knowns> token counts.";
337
312
  } else {
338
313
  turnPrompt = this.#buildContinuationPrompt(
339
314
  loopIteration,
@@ -350,16 +325,26 @@ export default class AgentLoop {
350
325
  currentLoopId,
351
326
  requestedModel,
352
327
  loopPrompt: turnPrompt,
328
+ loopIteration,
353
329
  noRepo,
354
330
  toolSet,
331
+ inRecovery: recovery !== null,
355
332
  contextSize,
356
333
  options: { ...options, isContinuation: loopIteration > 1 },
357
- signal: controller.signal,
334
+ signal,
358
335
  });
359
336
 
360
- // Budget overflow — return 413 to drainQueue for panic mode
361
337
  if (result.status === 413) {
362
- return {
338
+ await this.#db.complete_loop.run({
339
+ id: currentLoopId,
340
+ status: 413,
341
+ result: null,
342
+ });
343
+ await this.#db.update_run_status.run({
344
+ id: currentRunId,
345
+ status: 200,
346
+ });
347
+ const out = {
363
348
  run: currentAlias,
364
349
  status: 413,
365
350
  overflow: result.overflow,
@@ -367,48 +352,34 @@ export default class AgentLoop {
367
352
  contextSize: result.contextSize,
368
353
  turn: result.turn,
369
354
  };
355
+ await hook.completed.emit({ projectId, ...out });
356
+ return out;
370
357
  }
371
358
 
372
359
  _lastAssembledTokens = result.assembledTokens;
373
360
 
374
- // Panic mode: target check + strike counting
375
- if (mode === "panic") {
376
- const panicTarget = Math.floor(contextSize * 0.75);
377
- if (result.assembledTokens <= panicTarget) {
378
- await this.#db.update_run_status.run({
379
- id: currentRunId,
380
- status: 200,
381
- });
382
- const out = {
383
- run: currentAlias,
384
- status: 200,
385
- turn: result.turn,
386
- };
387
- await hook.completed.emit({ projectId, ...out });
388
- return out;
389
- }
390
- if (_lastPanicTokens !== null) {
391
- if (result.assembledTokens < _lastPanicTokens) {
392
- _panicStrikes = 0;
393
- } else {
394
- _panicStrikes++;
395
- if (_panicStrikes >= 3) {
396
- await this.#db.update_run_status.run({
397
- id: currentRunId,
398
- status: 200,
399
- });
400
- return {
401
- run: currentAlias,
402
- status: 413,
403
- overflow: result.assembledTokens - contextSize,
404
- assembledTokens: result.assembledTokens,
405
- contextSize,
406
- turn: result.turn,
407
- };
408
- }
409
- }
410
- }
411
- _lastPanicTokens = result.assembledTokens;
361
+ // Budget recovery: enforce progress toward context target.
362
+ const ra = advanceRecovery(recovery, result);
363
+ recovery = ra.next;
364
+ if (ra.action === "restore" && ra.promptPath) {
365
+ await this.#knownStore.setFidelity(
366
+ currentRunId,
367
+ ra.promptPath,
368
+ "full",
369
+ );
370
+ }
371
+ if (ra.action === "hard413") {
372
+ await this.#db.update_run_status.run({
373
+ id: currentRunId,
374
+ status: 413,
375
+ });
376
+ const out = {
377
+ run: currentAlias,
378
+ status: 413,
379
+ turn: result.turn,
380
+ };
381
+ await hook.completed.emit({ projectId, ...out });
382
+ return out;
412
383
  }
413
384
 
414
385
  const runUsage = await this.#db.get_run_usage.get({
@@ -418,8 +389,6 @@ export default class AgentLoop {
418
389
  const unknowns = await this.#db.get_unknowns.all({
419
390
  run_id: currentRunId,
420
391
  });
421
- const unresolved = await this.#knownStore.getUnresolved(currentRunId);
422
-
423
392
  const latestSummary = history
424
393
  .filter((e) => e.status === 200 && e.path?.startsWith("summarize://"))
425
394
  .at(-1);
@@ -428,26 +397,22 @@ export default class AgentLoop {
428
397
  projectId,
429
398
  run: currentAlias,
430
399
  turn: result.turn,
431
- status: unresolved.length > 0 ? 202 : 102,
400
+ status: 102,
432
401
  summary: latestSummary?.body || "",
433
402
  history,
434
403
  unknowns: unknowns.map((u) => ({ path: u.path, body: u.body })),
435
- proposed: unresolved.map((p) => ({
436
- path: p.path,
437
- type: KnownStore.toolFromPath(p.path) || "unknown",
438
- attributes: p.attributes ? JSON.parse(p.attributes) : null,
439
- })),
440
404
  telemetry: {
441
405
  modelAlias: result.modelAlias,
442
406
  model: result.model,
443
407
  temperature: result.temperature,
444
408
  context_size: result.contextSize,
445
- context_tokens: (
446
- await this.#db.get_turn_budget.get({
447
- run_id: currentRunId,
448
- turn: result.turn,
449
- })
450
- ).total,
409
+ context_tokens:
410
+ (
411
+ await this.#db.get_turn_context_tokens.get({
412
+ run_id: currentRunId,
413
+ sequence: result.turn,
414
+ })
415
+ )?.context_tokens ?? 0,
451
416
  prompt_tokens: runUsage.prompt_tokens,
452
417
  cached_tokens: runUsage.cached_tokens,
453
418
  completion_tokens: runUsage.completion_tokens,
@@ -460,21 +425,6 @@ export default class AgentLoop {
460
425
  }),
461
426
  },
462
427
  });
463
- if (unresolved.length > 0) {
464
- await this.#db.update_run_status.run({
465
- id: currentRunId,
466
- status: 202,
467
- });
468
- const out = {
469
- run: currentAlias,
470
- status: 202,
471
- turn: result.turn,
472
- proposed: unresolved,
473
- };
474
- await hook.completed.emit({ projectId, ...out });
475
- return out;
476
- }
477
-
478
428
  await this.#hooks.run.step.completed.emit({
479
429
  projectId,
480
430
  run: currentAlias,
@@ -482,6 +432,9 @@ export default class AgentLoop {
482
432
  flags: result.flags,
483
433
  });
484
434
 
435
+ // Don't exit while budget recovery is still active.
436
+ if (recovery !== null) continue;
437
+
485
438
  const repetition = healer.assessRepetition(result);
486
439
  if (!repetition.continue) {
487
440
  await this.#db.update_run_status.run({
@@ -526,7 +479,7 @@ export default class AgentLoop {
526
479
  await hook.completed.emit({ projectId, ...out });
527
480
  return out;
528
481
  } catch (err) {
529
- if (controller.signal.aborted) {
482
+ if (signal.aborted) {
530
483
  await this.#db.update_run_status.run({
531
484
  id: currentRunId,
532
485
  status: 499,
@@ -558,7 +511,6 @@ export default class AgentLoop {
558
511
  await hook.completed.emit({ projectId, ...out });
559
512
  return out;
560
513
  } finally {
561
- this.#activeRuns.delete(currentRunId);
562
514
  await this.#hooks.loop.completed
563
515
  .emit({
564
516
  runId: currentRunId,
@@ -599,9 +551,51 @@ export default class AgentLoop {
599
551
  }
600
552
 
601
553
  if (action === "accept") {
554
+ const projectId = runRow.project_id;
555
+ const project = await this.#db.get_project_by_id.get({
556
+ id: projectId,
557
+ });
558
+ const projectRoot = project?.project_root;
559
+
560
+ if (path.startsWith("set://") && attrs?.file && attrs?.merge) {
561
+ const fileBody = await this.#knownStore.getBody(runId, attrs.file);
562
+ if (fileBody != null) {
563
+ const blocks = attrs.merge.split(/(?=<<<<<<< SEARCH)/);
564
+ let patched = fileBody;
565
+ for (const block of blocks) {
566
+ const m = block.match(
567
+ /<<<<<<< SEARCH\n?([\s\S]*?)\n?=======\n?([\s\S]*?)\n?>>>>>>> REPLACE/,
568
+ );
569
+ if (m) patched = patched.replace(m[1], m[2]);
570
+ }
571
+ const turn = (await this.#db.get_run_by_id.get({ id: runId }))
572
+ .next_turn;
573
+ await this.#knownStore.upsert(
574
+ runId,
575
+ turn,
576
+ attrs.file,
577
+ patched,
578
+ 200,
579
+ );
580
+ // Write patched content to disk
581
+ if (projectRoot) {
582
+ const { writeFile } = await import("node:fs/promises");
583
+ const { join } = await import("node:path");
584
+ await writeFile(join(projectRoot, attrs.file), patched).catch(
585
+ () => {},
586
+ );
587
+ }
588
+ }
589
+ }
590
+
602
591
  if (path.startsWith("rm://")) {
603
592
  if (attrs?.path) {
604
593
  await this.#knownStore.remove(runId, attrs.path);
594
+ if (projectRoot) {
595
+ const { unlink } = await import("node:fs/promises");
596
+ const { join } = await import("node:path");
597
+ await unlink(join(projectRoot, attrs.path)).catch(() => {});
598
+ }
605
599
  }
606
600
  }
607
601
 
@@ -617,68 +611,9 @@ export default class AgentLoop {
617
611
  throw new Error(msg("error.resolution_invalid", { action }));
618
612
  }
619
613
 
620
- const unresolved = await this.#knownStore.getUnresolved(runId);
621
- if (unresolved.length > 0) {
622
- return {
623
- run: runAlias,
624
- status: 202,
625
- remainingCount: unresolved.length,
626
- proposed: unresolved,
627
- };
628
- }
629
-
630
- // Scope completion checks to the current loop
631
- const currentLoop = await this.#db.get_current_loop.get({ run_id: runId });
632
- const loopId = currentLoop?.id ?? null;
633
-
634
- if (await this.#knownStore.hasRejections(runId, loopId)) {
635
- if (currentLoop)
636
- await this.#db.complete_loop.run({
637
- id: loopId,
638
- status: 200,
639
- result: null,
640
- });
641
- await this.#db.update_run_status.run({ id: runId, status: 200 });
642
- return { run: runAlias, status: 200 };
643
- }
644
-
645
- const hasSummary = await this.#db.get_latest_summary.get({
646
- run_id: runId,
647
- loop_id: loopId,
648
- });
649
- if (hasSummary?.body) {
650
- if (currentLoop)
651
- await this.#db.complete_loop.run({
652
- id: loopId,
653
- status: 200,
654
- result: null,
655
- });
656
- await this.#db.update_run_status.run({ id: runId, status: 200 });
657
- return { run: runAlias, status: 200 };
658
- }
659
-
660
- // No summary and no rejections in this loop — resume it
661
- const projectId = runRow.project_id;
662
- const project = await this.#db.get_project_by_id.get({ id: projectId });
663
-
664
- const latestPrompt = await this.#db.get_latest_prompt.get({
665
- run_id: runId,
666
- });
667
- const resumeMode = latestPrompt?.attributes
668
- ? JSON.parse(latestPrompt.attributes).mode
669
- : "ask";
670
-
671
- // Re-enqueue the current loop's prompt to continue it
672
- const loopSeq = await this.#db.next_loop.get({ run_id: runId });
673
- await this.#db.enqueue_loop.get({
674
- run_id: runId,
675
- sequence: loopSeq.sequence,
676
- mode: resumeMode,
677
- model: runRow.model,
678
- prompt: "",
679
- config: "{}",
680
- });
681
- return this.#drainQueue(runId, runAlias, projectId, project, {});
614
+ // The dispatch loop is awaiting resolution. This unblocks it.
615
+ // Dispatch continuation is handled by the loop, not here.
616
+ return { run: runAlias, status: 200 };
682
617
  }
683
618
 
684
619
  async #composeResolvedContent(runId, path, _attrs, output) {
@@ -735,3 +670,13 @@ export default class AgentLoop {
735
670
  return this.#knownStore.getLog(runRow.id);
736
671
  }
737
672
  }
673
+
674
+ /**
675
+ * Pure recovery state transition — exported for testing.
676
+ *
677
+ * @param {object|null} recovery Current recovery state (mutated copy returned).
678
+ * @param {{ assembledTokens: number, budgetRecovery?: { target: number, promptPath: string|null } }} result
679
+ * @returns {{ next: object|null, action: null|'restore'|'hard413', promptPath: string|null }}
680
+ */
681
+ // Re-export for backward compatibility with tests
682
+ export { advanceRecovery } from "../plugins/budget/recovery.js";
@@ -13,6 +13,7 @@ export default class ContextAssembler {
13
13
  demoted = [],
14
14
  toolSet = null,
15
15
  lastContextTokens = 0,
16
+ turn = 1,
16
17
  } = {},
17
18
  hooks,
18
19
  ) {
@@ -30,6 +31,7 @@ export default class ContextAssembler {
30
31
  lastContextTokens,
31
32
  demoted,
32
33
  toolSet,
34
+ turn,
33
35
  };
34
36
 
35
37
  const system = await hooks.assembly.system.filter(systemPrompt, ctx);