loopforge 1.12.0 → 2.0.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 (182) hide show
  1. package/README.md +172 -95
  2. package/dist/backends/interface.d.ts +3 -9
  3. package/dist/backends/interface.d.ts.map +1 -1
  4. package/dist/backends/interface.js +1 -5
  5. package/dist/backends/interface.js.map +1 -1
  6. package/dist/canonical-state.d.ts +53 -0
  7. package/dist/canonical-state.d.ts.map +1 -0
  8. package/dist/canonical-state.js +161 -0
  9. package/dist/canonical-state.js.map +1 -0
  10. package/dist/cli.d.ts +4 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +227 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/enforcement-gate.d.ts +44 -0
  15. package/dist/enforcement-gate.d.ts.map +1 -0
  16. package/dist/enforcement-gate.js +271 -0
  17. package/dist/enforcement-gate.js.map +1 -0
  18. package/dist/engine.d.ts +17 -7
  19. package/dist/engine.d.ts.map +1 -1
  20. package/dist/engine.js +158 -249
  21. package/dist/engine.js.map +1 -1
  22. package/dist/evidence-provider.d.ts +113 -0
  23. package/dist/evidence-provider.d.ts.map +1 -0
  24. package/dist/evidence-provider.js +419 -0
  25. package/dist/evidence-provider.js.map +1 -0
  26. package/dist/index.d.ts +19 -11
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +16 -10
  29. package/dist/index.js.map +1 -1
  30. package/dist/init.d.ts +15 -0
  31. package/dist/init.d.ts.map +1 -0
  32. package/dist/init.js +49 -0
  33. package/dist/init.js.map +1 -0
  34. package/dist/interop.d.ts +30 -0
  35. package/dist/interop.d.ts.map +1 -0
  36. package/dist/interop.js +29 -0
  37. package/dist/interop.js.map +1 -0
  38. package/dist/loop-compiler.d.ts +14 -64
  39. package/dist/loop-compiler.d.ts.map +1 -1
  40. package/dist/loop-compiler.js +307 -2001
  41. package/dist/loop-compiler.js.map +1 -1
  42. package/dist/loop-store.d.ts +82 -0
  43. package/dist/loop-store.d.ts.map +1 -0
  44. package/dist/loop-store.js +332 -0
  45. package/dist/loop-store.js.map +1 -0
  46. package/dist/mcp/server.d.ts +9 -6
  47. package/dist/mcp/server.d.ts.map +1 -1
  48. package/dist/mcp/server.js +109 -73
  49. package/dist/mcp/server.js.map +1 -1
  50. package/dist/mcp/session.d.ts +84 -20
  51. package/dist/mcp/session.d.ts.map +1 -1
  52. package/dist/mcp/session.js +718 -316
  53. package/dist/mcp/session.js.map +1 -1
  54. package/dist/mcp/tools.d.ts +18 -268
  55. package/dist/mcp/tools.d.ts.map +1 -1
  56. package/dist/mcp/tools.js +225 -12
  57. package/dist/mcp/tools.js.map +1 -1
  58. package/dist/observability.d.ts +36 -16
  59. package/dist/observability.d.ts.map +1 -1
  60. package/dist/observability.js +82 -23
  61. package/dist/observability.js.map +1 -1
  62. package/dist/policy-metrics.d.ts +46 -0
  63. package/dist/policy-metrics.d.ts.map +1 -0
  64. package/dist/policy-metrics.js +146 -0
  65. package/dist/policy-metrics.js.map +1 -0
  66. package/dist/policy.d.ts +58 -75
  67. package/dist/policy.d.ts.map +1 -1
  68. package/dist/policy.js +122 -93
  69. package/dist/policy.js.map +1 -1
  70. package/dist/prompt-assembler.d.ts +30 -0
  71. package/dist/prompt-assembler.d.ts.map +1 -0
  72. package/dist/prompt-assembler.js +257 -0
  73. package/dist/prompt-assembler.js.map +1 -0
  74. package/dist/prompt-policy.d.ts +29 -0
  75. package/dist/prompt-policy.d.ts.map +1 -0
  76. package/dist/prompt-policy.js +51 -0
  77. package/dist/prompt-policy.js.map +1 -0
  78. package/dist/protocol.d.ts +117 -91
  79. package/dist/protocol.d.ts.map +1 -1
  80. package/dist/protocol.js +40 -36
  81. package/dist/protocol.js.map +1 -1
  82. package/dist/replay.d.ts.map +1 -1
  83. package/dist/replay.js +1 -27
  84. package/dist/replay.js.map +1 -1
  85. package/dist/round-coordinator.d.ts +90 -0
  86. package/dist/round-coordinator.d.ts.map +1 -0
  87. package/dist/round-coordinator.js +186 -0
  88. package/dist/round-coordinator.js.map +1 -0
  89. package/dist/round-driver.d.ts +53 -0
  90. package/dist/round-driver.d.ts.map +1 -0
  91. package/dist/round-driver.js +101 -0
  92. package/dist/round-driver.js.map +1 -0
  93. package/dist/round-transaction.d.ts +70 -0
  94. package/dist/round-transaction.d.ts.map +1 -0
  95. package/dist/round-transaction.js +290 -0
  96. package/dist/round-transaction.js.map +1 -0
  97. package/dist/runtime.d.ts +35 -17
  98. package/dist/runtime.d.ts.map +1 -1
  99. package/dist/runtime.js +357 -344
  100. package/dist/runtime.js.map +1 -1
  101. package/dist/storage.d.ts +38 -0
  102. package/dist/storage.d.ts.map +1 -0
  103. package/dist/storage.js +201 -0
  104. package/dist/storage.js.map +1 -0
  105. package/dist/verification-gate.d.ts +39 -7
  106. package/dist/verification-gate.d.ts.map +1 -1
  107. package/dist/verification-gate.js +198 -9
  108. package/dist/verification-gate.js.map +1 -1
  109. package/loop_policy.json +26 -47
  110. package/package.json +13 -10
  111. package/skills/perception/SKILL.md +80 -249
  112. package/dist/backends/fs.d.ts +0 -36
  113. package/dist/backends/fs.d.ts.map +0 -1
  114. package/dist/backends/fs.js +0 -358
  115. package/dist/backends/fs.js.map +0 -1
  116. package/dist/builder.d.ts +0 -12
  117. package/dist/builder.d.ts.map +0 -1
  118. package/dist/builder.js +0 -259
  119. package/dist/builder.js.map +0 -1
  120. package/dist/mcp-server.d.ts +0 -12
  121. package/dist/mcp-server.d.ts.map +0 -1
  122. package/dist/mcp-server.js +0 -14
  123. package/dist/mcp-server.js.map +0 -1
  124. package/dist/memory-bridge.d.ts +0 -47
  125. package/dist/memory-bridge.d.ts.map +0 -1
  126. package/dist/memory-bridge.js +0 -290
  127. package/dist/memory-bridge.js.map +0 -1
  128. package/dist/tests/_helpers.d.ts +0 -25
  129. package/dist/tests/_helpers.d.ts.map +0 -1
  130. package/dist/tests/_helpers.js +0 -47
  131. package/dist/tests/_helpers.js.map +0 -1
  132. package/dist/tests/builder.test.d.ts +0 -2
  133. package/dist/tests/builder.test.d.ts.map +0 -1
  134. package/dist/tests/builder.test.js +0 -110
  135. package/dist/tests/builder.test.js.map +0 -1
  136. package/dist/tests/engine.test.d.ts +0 -2
  137. package/dist/tests/engine.test.d.ts.map +0 -1
  138. package/dist/tests/engine.test.js +0 -371
  139. package/dist/tests/engine.test.js.map +0 -1
  140. package/dist/tests/generate-schema.test.d.ts +0 -8
  141. package/dist/tests/generate-schema.test.d.ts.map +0 -1
  142. package/dist/tests/generate-schema.test.js +0 -199
  143. package/dist/tests/generate-schema.test.js.map +0 -1
  144. package/dist/tests/loop-compiler.test.d.ts +0 -2
  145. package/dist/tests/loop-compiler.test.d.ts.map +0 -1
  146. package/dist/tests/loop-compiler.test.js +0 -1051
  147. package/dist/tests/loop-compiler.test.js.map +0 -1
  148. package/dist/tests/mcp.test.d.ts +0 -7
  149. package/dist/tests/mcp.test.d.ts.map +0 -1
  150. package/dist/tests/mcp.test.js +0 -485
  151. package/dist/tests/mcp.test.js.map +0 -1
  152. package/dist/tests/memory-bridge.test.d.ts +0 -8
  153. package/dist/tests/memory-bridge.test.d.ts.map +0 -1
  154. package/dist/tests/memory-bridge.test.js +0 -383
  155. package/dist/tests/memory-bridge.test.js.map +0 -1
  156. package/dist/tests/policy.test.d.ts +0 -2
  157. package/dist/tests/policy.test.d.ts.map +0 -1
  158. package/dist/tests/policy.test.js +0 -49
  159. package/dist/tests/policy.test.js.map +0 -1
  160. package/dist/tests/protocol.test.d.ts +0 -2
  161. package/dist/tests/protocol.test.d.ts.map +0 -1
  162. package/dist/tests/protocol.test.js +0 -73
  163. package/dist/tests/protocol.test.js.map +0 -1
  164. package/dist/tests/replay.test.d.ts +0 -2
  165. package/dist/tests/replay.test.d.ts.map +0 -1
  166. package/dist/tests/replay.test.js +0 -222
  167. package/dist/tests/replay.test.js.map +0 -1
  168. package/dist/tests/runtime.test.d.ts +0 -7
  169. package/dist/tests/runtime.test.d.ts.map +0 -1
  170. package/dist/tests/runtime.test.js +0 -323
  171. package/dist/tests/runtime.test.js.map +0 -1
  172. package/dist/tests/verification-gate.test.d.ts +0 -2
  173. package/dist/tests/verification-gate.test.d.ts.map +0 -1
  174. package/dist/tests/verification-gate.test.js +0 -407
  175. package/dist/tests/verification-gate.test.js.map +0 -1
  176. package/skills/prompt-techniques/SKILL.md +0 -52
  177. package/skills/prompt-techniques/references/chain-of-thought.md +0 -207
  178. package/skills/prompt-techniques/references/few-shot.md +0 -174
  179. package/skills/prompt-techniques/references/least-to-most.md +0 -182
  180. package/skills/prompt-techniques/references/step-back.md +0 -160
  181. package/skills/prompt-techniques/references/tree-of-thought.md +0 -191
  182. package/skills/prompt-techniques/references/zero-shot.md +0 -97
package/dist/runtime.js CHANGED
@@ -14,94 +14,22 @@
14
14
  */
15
15
  import { EventEmitter } from "node:events";
16
16
  import { extractSelfEvaluation, heuristicSelfEvaluation, createEngine, } from "./engine.js";
17
- import { tokenize, jaccard } from "./loop-compiler.js";
18
- import { Mode, RuntimeStatus, makeLoopCompileRequest, makeLoopRoundResult, makeSelfEvaluation, makeVaultConfig, } from "./protocol.js";
19
- import { getPolicy, resolveAllowedPhases } from "./policy.js";
20
- import { verifySelfEvaluation } from "./verification-gate.js";
17
+ import { Mode, RuntimeStatus, makeLoopCompileRequest, makeLoopRoundResult, makeSelfEvaluation, makeTaskId, } from "./protocol.js";
18
+ import { getPolicy } from "./policy.js";
19
+ import { prepareRoundTransaction, } from "./round-transaction.js";
20
+ import { RoundDriver } from "./round-driver.js";
21
21
  import { logEvent } from "./observability.js";
22
- import { tryAutoConfigure } from "./memory-bridge.js";
22
+ import { policyMetrics } from "./policy-metrics.js";
23
23
  // ═══════════════════════════════════════════════════════════════════════════
24
24
  // Helpers
25
25
  // ═══════════════════════════════════════════════════════════════════════════
26
- /** Build a writeback payload from the loop's final state.
27
- * Extracts project-level outcome, tactical lessons, and vault reference.
28
- * Returns null if there is no meaningful data to write back. */
29
- function buildWritebackPayload(loopId, task, stopReason, result) {
30
- // Only write back for meaningful loops (at least 1 round completed)
31
- if (result.roundsCompleted < 1)
32
- return null;
33
- const policy = getPolicy();
34
- const wp = policy.memory_writeback;
35
- const outcome = ["completed", "circuit_breaker", "stalled", "max_rounds", "stopped"].find((o) => stopReason === o) ?? "stopped";
36
- // Build project entry
37
- const projectEntry = {
38
- title: `${task.slice(0, 80)} — ${outcome}`,
39
- objective: task.slice(0, 200),
40
- keyOutcome: stopReason === "task_complete"
41
- ? `Completed successfully in ${result.roundsCompleted} rounds.`
42
- : `Terminated with reason '${stopReason}' after ${result.roundsCompleted} rounds.`,
43
- keyDiscoveries: [],
44
- date: new Date().toISOString().split("T")[0],
45
- };
46
- // Build feedback entries from success trajectory patterns
47
- const feedbackEntries = [];
48
- const trajectory = result.successTrajectory;
49
- if (trajectory.length >= 3) {
50
- // Detect improving trend via success rate
51
- const firstHalf = trajectory.slice(0, Math.floor(trajectory.length / 2));
52
- const secondHalf = trajectory.slice(Math.floor(trajectory.length / 2));
53
- const firstRate = firstHalf.filter(Boolean).length / firstHalf.length;
54
- const secondRate = secondHalf.filter(Boolean).length / secondHalf.length;
55
- if (secondRate - firstRate > 0.3) {
56
- feedbackEntries.push({
57
- rule: `Success rate improved from ${(firstRate * 100).toFixed(0)}% to ${(secondRate * 100).toFixed(0)}% over ${trajectory.length} rounds`,
58
- why: `Mid-loop strategy adjustment unlocked progress in task "${task.slice(0, 80)}"`,
59
- howToApply: "Consider the strategy used in later rounds for similar tasks",
60
- });
61
- }
62
- }
63
- // Detect flatlining — 3 consecutive failures
64
- if (trajectory.length >= 3) {
65
- const recent = trajectory.slice(-3);
66
- if (recent.every((v) => v === false)) {
67
- feedbackEntries.push({
68
- rule: `Failed 3 consecutive rounds at end of loop`,
69
- why: `Task "${task.slice(0, 80)}" stopped with reason '${stopReason}' — strategy may have stagnated`,
70
- howToApply: "If re-attempting, start with a different technique than the one that produced the failures",
71
- });
72
- }
73
- }
74
- // Build reference entry pointing to vault
75
- const referenceEntry = {
76
- description: `LoopForge vault data for "${task.slice(0, 80)}"`,
77
- vaultLocation: `.promptcraft/prompt_vault.json → loop:${loopId}:*`,
78
- };
79
- return {
80
- loopId,
81
- task,
82
- outcome,
83
- roundsCompleted: result.roundsCompleted,
84
- projectEntry: {
85
- ...projectEntry,
86
- keyDiscoveries: projectEntry.keyDiscoveries.slice(0, wp.max_discoveries_in_project),
87
- },
88
- feedbackEntries: feedbackEntries.slice(0, wp.max_feedback_entries),
89
- referenceEntry,
90
- };
91
- }
92
- function autoLoopId(task) {
93
- let slug = task.toLowerCase().trim().slice(0, 60);
94
- slug = slug.replace(/[^a-z0-9\s-]/g, "");
95
- slug = slug.replace(/\s+/g, "-");
96
- return slug || "unnamed-loop";
97
- }
98
26
  function resolveConfig(raw) {
99
27
  const policy = getPolicy();
100
28
  const rtPolicy = policy.runtime;
101
29
  const config = {
102
30
  task: raw.task,
103
31
  execute: raw.execute,
104
- loopId: raw.loopId || autoLoopId(raw.task),
32
+ loopId: raw.loopId || makeTaskId(raw.task),
105
33
  goalId: raw.goalId || "",
106
34
  maxRounds: raw.maxRounds ?? rtPolicy.max_rounds,
107
35
  roundTimeoutMs: raw.roundTimeoutMs ?? rtPolicy.round_timeout_ms,
@@ -118,25 +46,25 @@ function resolveConfig(raw) {
118
46
  onHeartbeat: raw.onHeartbeat,
119
47
  onTimeout: raw.onTimeout,
120
48
  onHealthWarning: raw.onHealthWarning,
121
- memoryProvider: raw.memoryProvider,
122
- memoryWriter: raw.memoryWriter,
49
+ contextProvider: raw.contextProvider,
50
+ terminalSinks: raw.terminalSinks ?? [],
123
51
  };
124
52
  // Auto-detect claude-mem if no explicit provider was given
125
- if (!config.memoryProvider && !config.memoryWriter) {
126
- const auto = tryAutoConfigure();
127
- if (auto.memoryProvider)
128
- config.memoryProvider = auto.memoryProvider;
129
- if (auto.memoryWriter)
130
- config.memoryWriter = auto.memoryWriter;
131
- }
132
53
  return config;
133
54
  }
55
+ class RoundStalledError extends Error {
56
+ constructor() {
57
+ super("Round exceeded timeout and stall grace period");
58
+ this.name = "RoundStalledError";
59
+ }
60
+ }
134
61
  // ═══════════════════════════════════════════════════════════════════════════
135
62
  // LoopRuntime
136
63
  // ═══════════════════════════════════════════════════════════════════════════
137
64
  export class LoopRuntime extends EventEmitter {
138
65
  config;
139
66
  engine;
67
+ roundDriver;
140
68
  _status = RuntimeStatus.IDLE;
141
69
  currentRound = 1;
142
70
  successTrajectory = [];
@@ -146,22 +74,30 @@ export class LoopRuntime extends EventEmitter {
146
74
  // Verification gate state (v1.6 unified — was MCP-only, now also in runtime)
147
75
  lastSelfEval = null;
148
76
  pendingVerificationFlags = [];
149
- // Memory integration state (v1.7)
150
- injectionCount = 0;
151
- lastInjectionRound = 0;
152
- injectedContexts = [];
153
- phase2Triggered = false;
154
- phase3Triggered = false;
77
+ // Enforcement gate state (v1.13)
78
+ consecutiveRejections = 0;
79
+ /** Which enforcement check triggered the last rejection.
80
+ * Only same-check rejections accumulate; a different check resets the counter. */
81
+ lastRejectionCheck = "";
82
+ pendingRejectionNotice = "";
83
+ roundSnapshot = null;
84
+ // Pause/resume state (v1.18)
85
+ pauseTimestamp = 0;
86
+ pauseRequested = false;
87
+ driverPromise = null;
88
+ // Explicit Agent-supplied context for the next compilation.
155
89
  pendingExternalContext = "";
156
90
  // Shared mutable state between the main loop and the heartbeat tick
157
91
  roundStartTime = 0;
158
92
  lastProgressTime = 0;
159
93
  activeCtx = null;
94
+ activeAbortController = null;
160
95
  timedOut = false;
161
96
  constructor(rawConfig) {
162
97
  super();
163
98
  this.config = resolveConfig(rawConfig);
164
99
  this.engine = createEngine();
100
+ this.roundDriver = new RoundDriver(this.engine);
165
101
  }
166
102
  get status() {
167
103
  return this._status;
@@ -172,98 +108,10 @@ export class LoopRuntime extends EventEmitter {
172
108
  getSuccessTrajectory() {
173
109
  return [...this.successTrajectory];
174
110
  }
175
- // ── Memory Integration (v1.7) ──────────────────────────────────────────
176
- /** Derive the current progress estimate from the last self evaluation.
177
- * Returns -1 if no progress data is available. */
178
- getCurrentProgress() {
179
- if (!this.lastSelfEval?.execution_evidence)
180
- return -1;
181
- const pe = this.lastSelfEval.execution_evidence.progress_estimate;
182
- return typeof pe === "number" ? pe : -1;
183
- }
184
- /** Determine whether memory should be injected this round based on
185
- * tier-based allowed phases, progress thresholds, and round spacing. */
186
- shouldInjectMemory() {
187
- const policy = getPolicy();
188
- const mi = policy.memory_injection;
189
- if (!mi.enabled)
190
- return false;
191
- if (!this.config.memoryProvider)
192
- return false;
193
- if (this.lastInjectionRound > 0 &&
194
- this.currentRound - this.lastInjectionRound < mi.min_rounds_between_injections) {
195
- return false;
196
- }
197
- // Resolve which phases are allowed for this loop's maxRounds
198
- const allowedPhases = new Set(resolveAllowedPhases(this.config.maxRounds, mi.round_tiers));
199
- const maxInjections = allowedPhases.size;
200
- if (this.injectionCount >= maxInjections)
201
- return false;
202
- const progress = this.getCurrentProgress();
203
- // Phase 1: round 1, always check if allowed
204
- if (this.currentRound === 1 && this.injectionCount === 0 && allowedPhases.has(1)) {
205
- return true;
206
- }
207
- // Phase 2: progress threshold (only if allowed by tier)
208
- if (allowedPhases.has(2) &&
209
- !this.phase2Triggered &&
210
- progress >= mi.phase_thresholds.phase2.threshold) {
211
- return true;
212
- }
213
- // Phase 3: progress threshold (only if allowed by tier)
214
- if (allowedPhases.has(3) &&
215
- !this.phase3Triggered &&
216
- progress >= mi.phase_thresholds.phase3.threshold) {
217
- return true;
218
- }
219
- return false;
220
- }
221
- /** Build the accumulated context for constructing a targeted memory query. */
222
- buildAccumulatedContext() {
223
- const recurringIssues = [];
224
- const failedPatterns = [];
225
- const keyLessons = [];
226
- const remainingCriteria = [];
227
- if (this.lastSelfEval) {
228
- // Recurring issues: violations that have appeared before
229
- if (this.lastSelfEval.constraint_violations.length) {
230
- recurringIssues.push(...this.lastSelfEval.constraint_violations);
231
- }
232
- // Remaining criteria
233
- if (this.lastSelfEval.execution_evidence?.success_criteria_remaining?.length) {
234
- remainingCriteria.push(...this.lastSelfEval.execution_evidence.success_criteria_remaining);
235
- }
236
- // Key lessons from emerged subtasks
237
- if (this.lastSelfEval.emerged_subtasks?.length) {
238
- keyLessons.push(...this.lastSelfEval.emerged_subtasks);
239
- }
240
- // Wrong assumptions as negative lessons
241
- if (this.lastSelfEval.wrong_assumptions?.length) {
242
- keyLessons.push(...this.lastSelfEval.wrong_assumptions.map((a) => `Wrong: ${a}`));
243
- }
244
- }
245
- return { recurringIssues, failedPatterns, keyLessons, remainingCriteria };
246
- }
247
- /** Deduplicate external context against previously injected contexts.
248
- * Returns empty string if the new context is too similar to any prior. */
249
- dedupAndStoreContext(newContext) {
250
- if (!newContext.trim())
251
- return "";
252
- const policy = getPolicy();
253
- const threshold = policy.memory_injection.dedup_threshold;
254
- const newTokens = tokenize(newContext);
255
- for (const old of this.injectedContexts) {
256
- const oldTokens = tokenize(old);
257
- if (jaccard(newTokens, oldTokens) > threshold) {
258
- return ""; // Too similar — skip
259
- }
260
- }
261
- this.injectedContexts.push(newContext);
262
- return newContext;
263
- }
264
111
  // ── Lifecycle ──────────────────────────────────────────────────────────
265
112
  /** Start the loop. Returns when the loop terminates (task complete,
266
- * circuit breaker, max rounds, stalled, or manual stop). */
113
+ * circuit breaker, max rounds, stalled, paused, or manual stop).
114
+ * Can only be called from IDLE state. */
267
115
  async start() {
268
116
  if (this._status !== RuntimeStatus.IDLE) {
269
117
  throw new Error(`Cannot start: runtime is ${this._status}`);
@@ -272,122 +120,164 @@ export class LoopRuntime extends EventEmitter {
272
120
  this.registerSignalHandlers();
273
121
  this.startHeartbeat();
274
122
  this.emit("start");
123
+ return this.launchDriver(false);
124
+ }
125
+ /** Start the one allowed loop driver and clear its ownership on exit. */
126
+ launchDriver(isResume) {
127
+ if (this.driverPromise)
128
+ return this.driverPromise;
129
+ const driver = this._continue(isResume);
130
+ this.driverPromise = driver;
131
+ void driver.then(() => {
132
+ if (this.driverPromise === driver)
133
+ this.driverPromise = null;
134
+ }, () => {
135
+ if (this.driverPromise === driver)
136
+ this.driverPromise = null;
137
+ });
138
+ return driver;
139
+ }
140
+ /** Internal loop driver. Called by both start() (isResume=false) and
141
+ * resume() (isResume=true). When resuming, skips the currentRound=1
142
+ * reset — the loop picks up exactly where it left off. */
143
+ async _continue(isResume) {
275
144
  let stopReason = "max_rounds";
276
145
  let previousAgentOutput = null;
277
146
  let consecutiveErrors = 0;
278
147
  try {
279
- for (this.currentRound = 1; this.currentRound <= this.config.maxRounds; this.currentRound++) {
280
- if (this._status !== RuntimeStatus.RUNNING) {
281
- stopReason = this._status === RuntimeStatus.STALLED ? "stalled" : "stopped";
148
+ // When starting fresh (not resuming), reset to round 1
149
+ if (!isResume) {
150
+ this.currentRound = 1;
151
+ }
152
+ for (; this.currentRound <= this.config.maxRounds; this.currentRound++) {
153
+ if (this.pauseRequested) {
154
+ this.pauseRequested = false;
155
+ this._status = RuntimeStatus.PAUSED;
156
+ stopReason = "paused";
282
157
  break;
283
158
  }
284
- // ── v1.7: Memory Injection (before compile, only at L2 phases) ──
285
- this.pendingExternalContext = "";
286
- if (this.shouldInjectMemory()) {
287
- const progress = this.getCurrentProgress();
288
- let phase;
289
- if (this.currentRound === 1) {
290
- phase = 1;
159
+ if (this._status !== RuntimeStatus.RUNNING) {
160
+ if (this._status === RuntimeStatus.PAUSED) {
161
+ stopReason = "paused";
291
162
  }
292
- else if (!this.phase2Triggered && progress >= getPolicy().memory_injection.phase_thresholds.phase2.threshold) {
293
- phase = 2;
294
- this.phase2Triggered = true;
163
+ else if (this._status === RuntimeStatus.STALLED) {
164
+ stopReason = "stalled";
295
165
  }
296
166
  else {
297
- phase = 3;
298
- this.phase3Triggered = true;
167
+ stopReason = "cancelled";
299
168
  }
169
+ break;
170
+ }
171
+ // ── Explicit external context ─────────────────────────────────
172
+ this.pendingExternalContext = "";
173
+ if (this.config.contextProvider) {
300
174
  try {
301
- const ctx = {
175
+ this.pendingExternalContext = (await this.config.contextProvider({
302
176
  loopId: this.config.loopId,
303
177
  round: this.currentRound,
304
178
  task: this.config.task,
305
179
  domain: this.config.domain,
306
- phase,
307
- progressEstimate: progress,
308
- accumulatedContext: this.buildAccumulatedContext(),
309
- };
310
- const rawContext = await this.config.memoryProvider(ctx);
311
- this.pendingExternalContext = this.dedupAndStoreContext(rawContext);
312
- if (this.pendingExternalContext) {
313
- this.injectionCount++;
314
- this.lastInjectionRound = this.currentRound;
315
- logEvent("memory_injected", {
316
- loopId: this.config.loopId,
317
- round: this.currentRound,
318
- phase,
319
- injectionCount: this.injectionCount,
320
- contextLength: this.pendingExternalContext.length,
321
- });
322
- }
180
+ lastEvaluation: this.lastSelfEval ?? undefined,
181
+ })).trim();
323
182
  }
324
183
  catch {
325
- // memoryProvider failed — degrade gracefully, continue without context
184
+ // Provider failures are isolated from loop execution.
326
185
  this.pendingExternalContext = "";
327
- logEvent("memory_provider_error", {
186
+ logEvent("context_provider_error", {
328
187
  loopId: this.config.loopId,
329
188
  round: this.currentRound,
330
189
  });
331
190
  }
332
191
  }
333
192
  // ── Compile ──────────────────────────────────────────────────
334
- const lcr = this.buildCompileRequest(previousAgentOutput);
335
- const compileResult = this.engine.invokeLoopCompile({
336
- task: this.config.task,
337
- mode: Mode.LOOP_COMPILE,
338
- vault_config: makeVaultConfig(),
339
- feedback: null,
340
- skill_name: null,
341
- task_id: null,
342
- loop_id: lcr.loop_id,
343
- round: lcr.round,
344
- goal_id: lcr.goal_id,
345
- domain: lcr.domain ?? "",
346
- plan_source: lcr.plan_source ?? null,
347
- constraints_from_plan: lcr.constraints_from_plan ?? [],
348
- health_check_interval: lcr.health_check_interval,
349
- last_round_result: (lcr.last_round_result ?? undefined),
350
- verification_flags: this.pendingVerificationFlags,
351
- external_context: this.pendingExternalContext || undefined,
352
- });
353
- if (!compileResult.response?.prompt) {
354
- stopReason = "stalled";
355
- break;
193
+ let prompt;
194
+ let level;
195
+ if (this.pendingRejectionNotice) {
196
+ if (!this.roundSnapshot || this.roundSnapshot.phase !== "rejected") {
197
+ stopReason = "stalled";
198
+ break;
199
+ }
200
+ const retryRequest = this.buildEngineCompileRequest(null);
201
+ const prepared = await this.roundDriver.prepareRetry(retryRequest, this.roundSnapshot, this.pendingRejectionNotice, this.consecutiveRejections);
202
+ if (!prepared) {
203
+ stopReason = "stalled";
204
+ break;
205
+ }
206
+ prompt = prepared.prompt;
207
+ level = prepared.level;
208
+ this.roundSnapshot = prepared.snapshot;
209
+ }
210
+ else {
211
+ const compileRequest = this.buildEngineCompileRequest(previousAgentOutput);
212
+ const prepared = await this.roundDriver.prepare(compileRequest, this.config.loopId, this.currentRound);
213
+ if (!prepared) {
214
+ stopReason = "stalled";
215
+ break;
216
+ }
217
+ prompt = prepared.prompt;
218
+ level = prepared.level;
219
+ this.roundSnapshot = prepared.snapshot;
356
220
  }
357
- const prompt = compileResult.response.prompt;
358
- const analysis = compileResult.response.analysis;
359
- const level = analysis?.rationale?.includes("l0")
360
- ? "l0"
361
- : analysis?.rationale?.includes("l1")
362
- ? "l1"
363
- : "l2";
364
- const technique = analysis?.technique ?? "unknown";
365
221
  // ── Emit round:start ──────────────────────────────────────────
366
- const startInfo = { round: this.currentRound, level, technique, prompt };
222
+ if (!this.roundSnapshot) {
223
+ this.roundSnapshot = prepareRoundTransaction(this.config.loopId, this.currentRound, []);
224
+ }
225
+ const roundId = this.roundSnapshot.roundId;
226
+ policyMetrics.recordStrategy(this.config.loopId, level);
227
+ const startInfo = {
228
+ round: this.currentRound,
229
+ roundId,
230
+ level,
231
+ prompt,
232
+ };
367
233
  this.emit("round:start", startInfo);
368
234
  this.config.onRoundStart?.(startInfo);
369
235
  // ── Execute ───────────────────────────────────────────────────
370
236
  this.roundStartTime = Date.now();
371
237
  this.lastProgressTime = this.roundStartTime;
372
238
  this.timedOut = false;
239
+ const abortController = new AbortController();
373
240
  const ctx = {
374
241
  round: this.currentRound,
375
- signal: { aborted: false },
242
+ roundId,
243
+ signal: abortController.signal,
376
244
  reportProgress: (message) => {
377
245
  this.lastProgressTime = Date.now();
378
246
  void message; // consumed by heartbeat via lastProgressTime
379
247
  },
380
248
  };
381
249
  this.activeCtx = ctx;
250
+ this.activeAbortController = abortController;
382
251
  let agentOutput;
383
252
  try {
384
- agentOutput = await this.config.execute(prompt, ctx);
253
+ agentOutput = await this.executeWithDeadline(prompt, ctx);
385
254
  consecutiveErrors = 0;
386
255
  }
387
256
  catch (err) {
257
+ // Calls from timers/signal handlers can mutate status while this await
258
+ // is pending; TypeScript's control-flow narrowing cannot observe that.
259
+ const statusAfterExecution = this._status;
260
+ // A cooperative executor may reject as soon as its AbortSignal fires,
261
+ // winning the race against RoundStalledError. The runtime's state is
262
+ // authoritative in that case: timeout/stall is not an executor fault.
263
+ if (err instanceof RoundStalledError ||
264
+ this.timedOut ||
265
+ statusAfterExecution === RuntimeStatus.STALLED) {
266
+ this.activeCtx = null;
267
+ this.activeAbortController = null;
268
+ stopReason = "stalled";
269
+ break;
270
+ }
271
+ if (statusAfterExecution === RuntimeStatus.STOPPED) {
272
+ this.activeCtx = null;
273
+ this.activeAbortController = null;
274
+ stopReason = "cancelled";
275
+ break;
276
+ }
388
277
  consecutiveErrors++;
389
278
  const dur = Date.now() - this.roundStartTime;
390
279
  this.activeCtx = null;
280
+ this.activeAbortController = null;
391
281
  // Emit round:complete with error info
392
282
  const errInfo = {
393
283
  round: this.currentRound,
@@ -407,13 +297,15 @@ export class LoopRuntime extends EventEmitter {
407
297
  stopReason = "executor_failure";
408
298
  break;
409
299
  }
410
- // Record as a failed round so the next round's compilation
411
- // sees the failure context (forces L1/L2)
412
- previousAgentOutput = null;
300
+ // Build a synthetic self-eval so the next round's compilation
301
+ // sees the failure context (forces L1/L2). A null previousAgentOutput
302
+ // would hide the crash from the compiler entirely.
303
+ previousAgentOutput = `---loopforge-eval\n${JSON.stringify(errInfo.selfEval)}\n---end-loopforge-eval`;
413
304
  continue;
414
305
  }
415
306
  const durationMs = Date.now() - this.roundStartTime;
416
307
  this.activeCtx = null;
308
+ this.activeAbortController = null;
417
309
  // ── Extract self-eval ─────────────────────────────────────────
418
310
  let selfEval = extractSelfEvaluation(agentOutput);
419
311
  let extractionSucceeded = selfEval !== null;
@@ -421,40 +313,70 @@ export class LoopRuntime extends EventEmitter {
421
313
  selfEval = heuristicSelfEvaluation(agentOutput);
422
314
  extractionSucceeded = false;
423
315
  }
424
- // ── Auto-feedback ─────────────────────────────────────────────
425
- if (selfEval !== null) {
426
- this.engine.autoFeedback(selfEval, this.config.loopId, this.currentRound, this.config.task);
427
- }
428
- // ── Verification gate (v1.6 unified) ──────────────────────────
429
- // Run cross-round consistency checks between self-eval and lineage.
430
- // Flags are injected into the NEXT round's prompt via the engine.
316
+ // ── Unified round transaction: evaluate → verify → commit/reject ──
431
317
  this.pendingVerificationFlags = [];
432
- let gateContradicted = false;
318
+ let roundSuccess = selfEval?.success ?? false;
433
319
  if (selfEval !== null) {
434
- const vaultEntries = this.engine.getBackend().queryEntries({
435
- prefix: `loop:${this.config.loopId}:r`,
320
+ const completed = await this.roundDriver.complete({
321
+ snapshot: this.roundSnapshot,
322
+ loopId: this.config.loopId,
323
+ task: this.config.task,
324
+ maxRounds: this.config.maxRounds,
325
+ selfEval,
326
+ extractionSucceeded,
327
+ lastSelfEval: this.lastSelfEval ?? undefined,
328
+ consecutiveRejections: this.consecutiveRejections,
329
+ successTrajectory: this.successTrajectory,
436
330
  });
437
- const verifyResult = verifySelfEvaluation(selfEval, this.currentRound, vaultEntries, this.lastSelfEval);
438
- this.pendingVerificationFlags = verifyResult.flags;
439
- if (verifyResult.verdict === "contradicted") {
440
- gateContradicted = true;
441
- logEvent("gate_contradicted", {
442
- loopId: this.config.loopId,
443
- round: this.currentRound,
444
- flags: verifyResult.flags.map((f) => f.check),
445
- });
331
+ const outcome = completed.outcome;
332
+ this.roundSnapshot = outcome.snapshot;
333
+ const pr = outcome.result;
334
+ policyMetrics.recordStrategyOutcome(this.config.loopId, level, pr, outcome.replayed);
335
+ this.pendingVerificationFlags = pr.verificationFlags;
336
+ roundSuccess = pr.roundSuccess;
337
+ // Track per-rule rejections: only same-check rejections
338
+ // accumulate. A different rejection reason resets the counter.
339
+ if (pr.action === "reject" && pr.rejectionCheck) {
340
+ this.consecutiveRejections =
341
+ pr.rejectionCheck === this.lastRejectionCheck
342
+ ? pr.newConsecutiveRejections
343
+ : 1;
344
+ this.lastRejectionCheck = pr.rejectionCheck;
345
+ }
346
+ else {
347
+ this.consecutiveRejections = pr.newConsecutiveRejections;
348
+ if (pr.action !== "reject")
349
+ this.lastRejectionCheck = "";
350
+ }
351
+ if (pr.newLastSelfEval)
352
+ this.lastSelfEval = pr.newLastSelfEval;
353
+ if (pr.shouldPushSuccessTrajectory)
354
+ this.successTrajectory.push(roundSuccess);
355
+ if (pr.action === "reject") {
356
+ this.pendingRejectionNotice = pr.rejectionPrompt ?? "";
357
+ this.currentRound--;
358
+ continue;
359
+ }
360
+ // The retry was accepted. Future rounds must return to normal compile.
361
+ this.pendingRejectionNotice = "";
362
+ if (pr.action === "terminate") {
363
+ stopReason = (pr.stopReason ?? "enforcement_terminated");
364
+ break;
365
+ }
366
+ if (pr.action === "stop") {
367
+ stopReason = (pr.stopReason ?? "stalled");
368
+ break;
446
369
  }
447
- this.lastSelfEval = selfEval;
448
370
  }
449
- // Compute round success
450
- const roundSuccess = selfEval?.success ?? false;
451
- // Contradicted gate verdict: skip success trend (match MCP path behavior)
452
- if (!gateContradicted) {
453
- this.successTrajectory.push(roundSuccess);
371
+ else {
372
+ // Null selfEval: extraction completely failed
373
+ stopReason = "stalled";
374
+ break;
454
375
  }
455
376
  // ── Emit round:complete ───────────────────────────────────────
456
377
  const completeInfo = {
457
378
  round: this.currentRound,
379
+ roundId,
458
380
  roundSuccess,
459
381
  selfEval,
460
382
  durationMs,
@@ -466,22 +388,8 @@ export class LoopRuntime extends EventEmitter {
466
388
  round: this.currentRound,
467
389
  success: roundSuccess,
468
390
  durationMs,
469
- technique: "loop_runtime",
470
391
  });
471
- // ── Check stop conditions ─────────────────────────────────────
472
- // Extraction check FIRST — heuristic results are low-confidence
473
- if (!extractionSucceeded) {
474
- stopReason = "stalled"; // extraction_failure mapped to stalled
475
- break;
476
- }
477
- if (selfEval && !selfEval.should_continue) {
478
- stopReason = "task_complete";
479
- break;
480
- }
481
- if (this.engine.shouldBreak()) {
482
- stopReason = "circuit_breaker";
483
- break;
484
- }
392
+ // ── Post-round state ──────────────────────────────────────────
485
393
  if (this.timedOut) {
486
394
  // Next round will be forced L2 via the compile request
487
395
  this.timedOut = false;
@@ -491,14 +399,17 @@ export class LoopRuntime extends EventEmitter {
491
399
  }
492
400
  finally {
493
401
  // ── Cleanup — always runs even if the loop body throws ──────────
494
- this.stopHeartbeat();
495
- this.unregisterSignalHandlers();
496
- if (this._status === RuntimeStatus.RUNNING) {
497
- this._status = RuntimeStatus.STOPPED;
402
+ // Only fully stop if we're not paused (paused loops stay alive)
403
+ if (this._status !== RuntimeStatus.PAUSED) {
404
+ this.stopHeartbeat();
405
+ this.unregisterSignalHandlers();
406
+ if (this._status === RuntimeStatus.RUNNING) {
407
+ this._status = RuntimeStatus.STOPPED;
408
+ }
498
409
  }
499
410
  }
500
411
  const result = {
501
- success: stopReason === "task_complete",
412
+ success: stopReason === "completed",
502
413
  stopReason,
503
414
  roundsCompleted: this.currentRound > this.config.maxRounds
504
415
  ? this.config.maxRounds
@@ -506,40 +417,114 @@ export class LoopRuntime extends EventEmitter {
506
417
  successTrajectory: [...this.successTrajectory],
507
418
  };
508
419
  // ── v1.7: Memory Writeback ───────────────────────────────────────
509
- const wp = getPolicy().memory_writeback;
510
- if (wp.enabled && this.config.memoryWriter) {
511
- const shouldWrite = wp.write_on_outcomes.includes(stopReason) ||
512
- wp.write_on_outcomes.length === 0;
513
- if (shouldWrite && result.roundsCompleted > 0) {
514
- try {
515
- const payload = buildWritebackPayload(this.config.loopId, this.config.task, stopReason, result);
516
- if (payload) {
517
- await this.config.memoryWriter(payload);
518
- logEvent("memory_writeback", {
519
- loopId: this.config.loopId,
520
- stopReason,
521
- feedbackCount: payload.feedbackEntries.length,
522
- });
523
- }
524
- }
525
- catch {
526
- // memoryWriter failed — degrade gracefully
527
- logEvent("memory_writeback_error", {
528
- loopId: this.config.loopId,
529
- stopReason,
530
- });
531
- }
532
- }
420
+ // Skip writeback for paused loops — they may be resumed later
421
+ if (stopReason !== "paused" && this.config.terminalSinks.length > 0) {
422
+ const event = {
423
+ ...result,
424
+ loopId: this.config.loopId,
425
+ task: this.config.task,
426
+ lastEvaluation: this.lastSelfEval ?? undefined,
427
+ };
428
+ await Promise.allSettled(this.config.terminalSinks.map((sink) => Promise.resolve(sink(event))));
533
429
  }
534
430
  this.emit("done", result);
535
431
  return result;
536
432
  }
433
+ /** Execute one round with a hard driver deadline. The AbortSignal lets a
434
+ * cooperative executor stop early; the race guarantees the runtime itself
435
+ * still resolves when an executor ignores cancellation. */
436
+ async executeWithDeadline(prompt, ctx) {
437
+ if (this.config.interactive) {
438
+ return this.config.execute(prompt, ctx);
439
+ }
440
+ const deadlineMs = this.config.roundTimeoutMs + this.config.stallGraceMs;
441
+ let timer = null;
442
+ const deadline = new Promise((_resolve, reject) => {
443
+ timer = setTimeout(() => {
444
+ const elapsed = Date.now() - this.roundStartTime;
445
+ this.triggerTimeout(elapsed);
446
+ this.triggerStall(elapsed);
447
+ reject(new RoundStalledError());
448
+ }, deadlineMs);
449
+ });
450
+ try {
451
+ return await Promise.race([this.config.execute(prompt, ctx), deadline]);
452
+ }
453
+ finally {
454
+ if (timer !== null)
455
+ clearTimeout(timer);
456
+ }
457
+ }
458
+ triggerTimeout(elapsed) {
459
+ if (this.timedOut)
460
+ return;
461
+ this.timedOut = true;
462
+ this.activeAbortController?.abort();
463
+ const timeoutInfo = {
464
+ round: this.currentRound,
465
+ elapsedMs: elapsed,
466
+ };
467
+ this.emit("timeout", timeoutInfo);
468
+ this.config.onTimeout?.(timeoutInfo);
469
+ }
470
+ triggerStall(elapsed) {
471
+ if (this._status === RuntimeStatus.STALLED)
472
+ return;
473
+ this._status = RuntimeStatus.STALLED;
474
+ const stallMsg = `Round ${this.currentRound} stalled after ${elapsed}ms`;
475
+ this.emit("stalled", {
476
+ reason: "round_timeout",
477
+ lastRound: this.currentRound,
478
+ elapsedMs: elapsed,
479
+ message: stallMsg,
480
+ });
481
+ }
537
482
  /** Stop the loop gracefully. Safe to call from any thread/timer. */
538
483
  stop() {
539
- if (this._status === RuntimeStatus.RUNNING) {
484
+ if (this._status === RuntimeStatus.RUNNING ||
485
+ this._status === RuntimeStatus.PAUSED) {
486
+ const driverActive = this.driverPromise !== null;
487
+ this.pauseRequested = false;
488
+ this.activeAbortController?.abort();
540
489
  this._status = RuntimeStatus.STOPPED;
541
490
  this.emit("stop");
491
+ if (!driverActive) {
492
+ this.stopHeartbeat();
493
+ this.unregisterSignalHandlers();
494
+ }
495
+ }
496
+ }
497
+ /** Pause the loop gracefully. Completes the current round, then suspends.
498
+ * Safe to call from signal handlers or callbacks.
499
+ * Only has effect when the loop is RUNNING. */
500
+ pause() {
501
+ if (this._status === RuntimeStatus.RUNNING && !this.pauseRequested) {
502
+ this.pauseRequested = true;
503
+ this.pauseTimestamp = Date.now();
504
+ this.emit("pause");
505
+ }
506
+ }
507
+ /** Resume a paused loop. Re-enters the main loop from currentRound.
508
+ * The loop continues exactly where it was suspended. */
509
+ async resume() {
510
+ // A resume requested before the current round reaches its pause boundary
511
+ // cancels that pending pause and reuses the existing driver.
512
+ if (this._status === RuntimeStatus.RUNNING && this.pauseRequested) {
513
+ this.pauseRequested = false;
514
+ this.pauseTimestamp = 0;
515
+ this.emit("resume");
516
+ if (!this.driverPromise) {
517
+ throw new Error("Cannot resume: active runtime has no driver");
518
+ }
519
+ return this.driverPromise;
542
520
  }
521
+ if (this._status !== RuntimeStatus.PAUSED) {
522
+ throw new Error(`Cannot resume: runtime is ${this._status}`);
523
+ }
524
+ this._status = RuntimeStatus.RUNNING;
525
+ this.pauseTimestamp = 0;
526
+ this.emit("resume");
527
+ return this.launchDriver(true);
543
528
  }
544
529
  // ── Internal: compile request builder ─────────────────────────────────
545
530
  buildCompileRequest(previousAgentOutput) {
@@ -552,7 +537,6 @@ export class LoopRuntime extends EventEmitter {
552
537
  plan_source: this.config.planSource ?? null,
553
538
  constraints_from_plan: this.config.constraintsFromPlan,
554
539
  health_check_interval: this.config.healthCheckInterval,
555
- vault_config: makeVaultConfig(),
556
540
  });
557
541
  if (this.timedOut && this.currentRound > 1) {
558
542
  lcr.force_level = "l2";
@@ -581,11 +565,36 @@ export class LoopRuntime extends EventEmitter {
581
565
  // v1.10: Checkpoint
582
566
  compression_checkpoint: selfEval.compression_checkpoint ?? false,
583
567
  checkpoint_label: selfEval.checkpoint_label ?? "",
568
+ // v1.16: Agent's declared next action
569
+ next_action: selfEval.next_action,
584
570
  });
585
571
  }
586
572
  }
587
573
  return lcr;
588
574
  }
575
+ /** Adapt the typed compiler request to the engine envelope once for both
576
+ * normal rounds and enforcement retry attempts. */
577
+ buildEngineCompileRequest(previousAgentOutput) {
578
+ const lcr = this.buildCompileRequest(previousAgentOutput);
579
+ return {
580
+ task: this.config.task,
581
+ mode: Mode.LOOP_COMPILE,
582
+ feedback: null,
583
+ skill_name: null,
584
+ task_id: null,
585
+ loop_id: lcr.loop_id,
586
+ round: lcr.round,
587
+ goal_id: lcr.goal_id,
588
+ domain: lcr.domain,
589
+ plan_source: lcr.plan_source,
590
+ constraints_from_plan: lcr.constraints_from_plan,
591
+ health_check_interval: lcr.health_check_interval,
592
+ last_round_result: lcr.last_round_result ?? undefined,
593
+ verification_flags: this.pendingVerificationFlags,
594
+ external_context: this.pendingExternalContext || undefined,
595
+ max_rounds: this.config.maxRounds,
596
+ };
597
+ }
589
598
  // ── Internal: heartbeat ──────────────────────────────────────────────
590
599
  startHeartbeat() {
591
600
  if (this.config.heartbeatIntervalMs <= 0)
@@ -629,28 +638,12 @@ export class LoopRuntime extends EventEmitter {
629
638
  }
630
639
  // Timeout
631
640
  if (elapsed > this.config.roundTimeoutMs && !this.timedOut) {
632
- this.timedOut = true;
633
- if (this.activeCtx) {
634
- this.activeCtx.signal.aborted = true;
635
- }
636
- const timeoutInfo = {
637
- round: this.currentRound,
638
- elapsedMs: elapsed,
639
- };
640
- this.emit("timeout", timeoutInfo);
641
- this.config.onTimeout?.(timeoutInfo);
641
+ this.triggerTimeout(elapsed);
642
642
  }
643
643
  // Stall — timeout + grace period elapsed, execute still hasn't returned
644
644
  if (this.activeCtx !== null &&
645
645
  elapsed > this.config.roundTimeoutMs + this.config.stallGraceMs) {
646
- this._status = RuntimeStatus.STALLED;
647
- const stallMsg = `Round ${this.currentRound} stalled after ${elapsed}ms`;
648
- this.emit("stalled", {
649
- reason: "round_timeout",
650
- lastRound: this.currentRound,
651
- elapsedMs: elapsed,
652
- message: stallMsg,
653
- });
646
+ this.triggerStall(elapsed);
654
647
  }
655
648
  }
656
649
  catch {
@@ -666,7 +659,27 @@ export class LoopRuntime extends EventEmitter {
666
659
  // ── Internal: signal handlers ────────────────────────────────────────
667
660
  registerSignalHandlers() {
668
661
  this.sigintHandler = () => {
669
- this.stop();
662
+ const policy = getPolicy();
663
+ const doubleTapMs = policy.runtime.pause_double_tap_ms;
664
+ const now = Date.now();
665
+ const withinDoubleTapWindow = doubleTapMs > 0 &&
666
+ this.pauseTimestamp > 0 &&
667
+ now - this.pauseTimestamp <= doubleTapMs;
668
+ // First SIGINT requests a boundary pause. A second signal stops only
669
+ // inside the configured window; after it expires, it starts a new window.
670
+ if ((this.pauseRequested || this._status === RuntimeStatus.PAUSED) &&
671
+ withinDoubleTapWindow) {
672
+ this.stop();
673
+ }
674
+ else if (doubleTapMs > 0 && this._status === RuntimeStatus.RUNNING) {
675
+ this.pause();
676
+ }
677
+ else if (doubleTapMs > 0 && this._status === RuntimeStatus.PAUSED) {
678
+ this.pauseTimestamp = now;
679
+ }
680
+ else {
681
+ this.stop();
682
+ }
670
683
  };
671
684
  this.sigtermHandler = () => {
672
685
  this.stop();