loopforge 1.15.1 → 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 (185) 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.map +1 -1
  15. package/dist/enforcement-gate.js +28 -10
  16. package/dist/enforcement-gate.js.map +1 -1
  17. package/dist/engine.d.ts +7 -4
  18. package/dist/engine.d.ts.map +1 -1
  19. package/dist/engine.js +78 -138
  20. package/dist/engine.js.map +1 -1
  21. package/dist/evidence-provider.d.ts +113 -0
  22. package/dist/evidence-provider.d.ts.map +1 -0
  23. package/dist/evidence-provider.js +419 -0
  24. package/dist/evidence-provider.js.map +1 -0
  25. package/dist/index.d.ts +18 -10
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +15 -9
  28. package/dist/index.js.map +1 -1
  29. package/dist/init.d.ts +15 -0
  30. package/dist/init.d.ts.map +1 -0
  31. package/dist/init.js +49 -0
  32. package/dist/init.js.map +1 -0
  33. package/dist/interop.d.ts +30 -0
  34. package/dist/interop.d.ts.map +1 -0
  35. package/dist/interop.js +29 -0
  36. package/dist/interop.js.map +1 -0
  37. package/dist/loop-compiler.d.ts +14 -64
  38. package/dist/loop-compiler.d.ts.map +1 -1
  39. package/dist/loop-compiler.js +303 -2206
  40. package/dist/loop-compiler.js.map +1 -1
  41. package/dist/loop-store.d.ts +82 -0
  42. package/dist/loop-store.d.ts.map +1 -0
  43. package/dist/loop-store.js +332 -0
  44. package/dist/loop-store.js.map +1 -0
  45. package/dist/mcp/server.d.ts +9 -6
  46. package/dist/mcp/server.d.ts.map +1 -1
  47. package/dist/mcp/server.js +109 -73
  48. package/dist/mcp/server.js.map +1 -1
  49. package/dist/mcp/session.d.ts +75 -19
  50. package/dist/mcp/session.d.ts.map +1 -1
  51. package/dist/mcp/session.js +707 -361
  52. package/dist/mcp/session.js.map +1 -1
  53. package/dist/mcp/tools.d.ts +18 -268
  54. package/dist/mcp/tools.d.ts.map +1 -1
  55. package/dist/mcp/tools.js +225 -12
  56. package/dist/mcp/tools.js.map +1 -1
  57. package/dist/observability.d.ts +36 -16
  58. package/dist/observability.d.ts.map +1 -1
  59. package/dist/observability.js +82 -23
  60. package/dist/observability.js.map +1 -1
  61. package/dist/policy-metrics.d.ts +46 -0
  62. package/dist/policy-metrics.d.ts.map +1 -0
  63. package/dist/policy-metrics.js +146 -0
  64. package/dist/policy-metrics.js.map +1 -0
  65. package/dist/policy.d.ts +53 -140
  66. package/dist/policy.d.ts.map +1 -1
  67. package/dist/policy.js +119 -184
  68. package/dist/policy.js.map +1 -1
  69. package/dist/prompt-assembler.d.ts +30 -0
  70. package/dist/prompt-assembler.d.ts.map +1 -0
  71. package/dist/prompt-assembler.js +257 -0
  72. package/dist/prompt-assembler.js.map +1 -0
  73. package/dist/prompt-policy.d.ts +29 -0
  74. package/dist/prompt-policy.d.ts.map +1 -0
  75. package/dist/prompt-policy.js +51 -0
  76. package/dist/prompt-policy.js.map +1 -0
  77. package/dist/protocol.d.ts +88 -93
  78. package/dist/protocol.d.ts.map +1 -1
  79. package/dist/protocol.js +23 -39
  80. package/dist/protocol.js.map +1 -1
  81. package/dist/replay.d.ts.map +1 -1
  82. package/dist/replay.js +1 -27
  83. package/dist/replay.js.map +1 -1
  84. package/dist/round-coordinator.d.ts +90 -0
  85. package/dist/round-coordinator.d.ts.map +1 -0
  86. package/dist/round-coordinator.js +186 -0
  87. package/dist/round-coordinator.js.map +1 -0
  88. package/dist/round-driver.d.ts +53 -0
  89. package/dist/round-driver.d.ts.map +1 -0
  90. package/dist/round-driver.js +101 -0
  91. package/dist/round-driver.js.map +1 -0
  92. package/dist/round-transaction.d.ts +70 -0
  93. package/dist/round-transaction.d.ts.map +1 -0
  94. package/dist/round-transaction.js +290 -0
  95. package/dist/round-transaction.js.map +1 -0
  96. package/dist/runtime.d.ts +33 -19
  97. package/dist/runtime.d.ts.map +1 -1
  98. package/dist/runtime.js +355 -336
  99. package/dist/runtime.js.map +1 -1
  100. package/dist/storage.d.ts +38 -0
  101. package/dist/storage.d.ts.map +1 -0
  102. package/dist/storage.js +201 -0
  103. package/dist/storage.js.map +1 -0
  104. package/dist/verification-gate.d.ts +33 -7
  105. package/dist/verification-gate.d.ts.map +1 -1
  106. package/dist/verification-gate.js +195 -7
  107. package/dist/verification-gate.js.map +1 -1
  108. package/loop_policy.json +22 -48
  109. package/package.json +13 -10
  110. package/skills/perception/SKILL.md +80 -267
  111. package/dist/backends/fs.d.ts +0 -36
  112. package/dist/backends/fs.d.ts.map +0 -1
  113. package/dist/backends/fs.js +0 -362
  114. package/dist/backends/fs.js.map +0 -1
  115. package/dist/builder.d.ts +0 -19
  116. package/dist/builder.d.ts.map +0 -1
  117. package/dist/builder.js +0 -215
  118. package/dist/builder.js.map +0 -1
  119. package/dist/mcp-server.d.ts +0 -12
  120. package/dist/mcp-server.d.ts.map +0 -1
  121. package/dist/mcp-server.js +0 -14
  122. package/dist/mcp-server.js.map +0 -1
  123. package/dist/memory-bridge.d.ts +0 -47
  124. package/dist/memory-bridge.d.ts.map +0 -1
  125. package/dist/memory-bridge.js +0 -290
  126. package/dist/memory-bridge.js.map +0 -1
  127. package/dist/tests/_helpers.d.ts +0 -25
  128. package/dist/tests/_helpers.d.ts.map +0 -1
  129. package/dist/tests/_helpers.js +0 -47
  130. package/dist/tests/_helpers.js.map +0 -1
  131. package/dist/tests/builder.test.d.ts +0 -2
  132. package/dist/tests/builder.test.d.ts.map +0 -1
  133. package/dist/tests/builder.test.js +0 -112
  134. package/dist/tests/builder.test.js.map +0 -1
  135. package/dist/tests/enforcement-gate.test.d.ts +0 -2
  136. package/dist/tests/enforcement-gate.test.d.ts.map +0 -1
  137. package/dist/tests/enforcement-gate.test.js +0 -328
  138. package/dist/tests/enforcement-gate.test.js.map +0 -1
  139. package/dist/tests/engine.test.d.ts +0 -2
  140. package/dist/tests/engine.test.d.ts.map +0 -1
  141. package/dist/tests/engine.test.js +0 -375
  142. package/dist/tests/engine.test.js.map +0 -1
  143. package/dist/tests/generate-schema.test.d.ts +0 -8
  144. package/dist/tests/generate-schema.test.d.ts.map +0 -1
  145. package/dist/tests/generate-schema.test.js +0 -199
  146. package/dist/tests/generate-schema.test.js.map +0 -1
  147. package/dist/tests/loop-compiler.test.d.ts +0 -2
  148. package/dist/tests/loop-compiler.test.d.ts.map +0 -1
  149. package/dist/tests/loop-compiler.test.js +0 -1059
  150. package/dist/tests/loop-compiler.test.js.map +0 -1
  151. package/dist/tests/mcp.test.d.ts +0 -7
  152. package/dist/tests/mcp.test.d.ts.map +0 -1
  153. package/dist/tests/mcp.test.js +0 -505
  154. package/dist/tests/mcp.test.js.map +0 -1
  155. package/dist/tests/memory-bridge.test.d.ts +0 -8
  156. package/dist/tests/memory-bridge.test.d.ts.map +0 -1
  157. package/dist/tests/memory-bridge.test.js +0 -383
  158. package/dist/tests/memory-bridge.test.js.map +0 -1
  159. package/dist/tests/policy.test.d.ts +0 -2
  160. package/dist/tests/policy.test.d.ts.map +0 -1
  161. package/dist/tests/policy.test.js +0 -49
  162. package/dist/tests/policy.test.js.map +0 -1
  163. package/dist/tests/protocol.test.d.ts +0 -2
  164. package/dist/tests/protocol.test.d.ts.map +0 -1
  165. package/dist/tests/protocol.test.js +0 -73
  166. package/dist/tests/protocol.test.js.map +0 -1
  167. package/dist/tests/replay.test.d.ts +0 -2
  168. package/dist/tests/replay.test.d.ts.map +0 -1
  169. package/dist/tests/replay.test.js +0 -222
  170. package/dist/tests/replay.test.js.map +0 -1
  171. package/dist/tests/runtime.test.d.ts +0 -7
  172. package/dist/tests/runtime.test.d.ts.map +0 -1
  173. package/dist/tests/runtime.test.js +0 -337
  174. package/dist/tests/runtime.test.js.map +0 -1
  175. package/dist/tests/verification-gate.test.d.ts +0 -2
  176. package/dist/tests/verification-gate.test.d.ts.map +0 -1
  177. package/dist/tests/verification-gate.test.js +0 -407
  178. package/dist/tests/verification-gate.test.js.map +0 -1
  179. package/skills/prompt-techniques/SKILL.md +0 -52
  180. package/skills/prompt-techniques/references/chain-of-thought.md +0 -207
  181. package/skills/prompt-techniques/references/few-shot.md +0 -174
  182. package/skills/prompt-techniques/references/least-to-most.md +0 -182
  183. package/skills/prompt-techniques/references/step-back.md +0 -160
  184. package/skills/prompt-techniques/references/tree-of-thought.md +0 -191
  185. package/skills/prompt-techniques/references/zero-shot.md +0 -97
package/dist/runtime.js CHANGED
@@ -13,73 +13,23 @@
13
13
  * const result = await rt.start();
14
14
  */
15
15
  import { EventEmitter } from "node:events";
16
- import { writeFileSync, mkdirSync } from "node:fs";
17
- import { join } from "node:path";
18
16
  import { extractSelfEvaluation, heuristicSelfEvaluation, createEngine, } from "./engine.js";
19
- import { tokenize, jaccard } from "./loop-compiler.js";
20
- import { Mode, RuntimeStatus, makeLoopCompileRequest, makeLoopRoundResult, makeSelfEvaluation, makeVaultConfig, } from "./protocol.js";
21
- import { getPolicy, resolveAllowedPhases, resolveInjectionPhase, buildAccumulatedMemoryContext, buildBaseMemoryWriteback } from "./policy.js";
22
- import { verifySelfEvaluation } from "./verification-gate.js";
23
- import { enforceRound, buildRejectionPrompt } from "./enforcement-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";
24
21
  import { logEvent } from "./observability.js";
25
- import { tryAutoConfigure } from "./memory-bridge.js";
22
+ import { policyMetrics } from "./policy-metrics.js";
26
23
  // ═══════════════════════════════════════════════════════════════════════════
27
24
  // Helpers
28
25
  // ═══════════════════════════════════════════════════════════════════════════
29
- /** Build a writeback payload from the loop's final state.
30
- * Uses shared base builder from policy.ts, then layers runtime-specific
31
- * feedback entries from trajectory analysis on top. */
32
- function buildWritebackPayload(loopId, task, stopReason, result, discoveries = []) {
33
- if (result.roundsCompleted < 1)
34
- return null;
35
- const wp = getPolicy().memory_writeback;
36
- const base = buildBaseMemoryWriteback({
37
- loopId, task, stopReason, roundsCompleted: result.roundsCompleted, discoveries,
38
- });
39
- // Build feedback entries from success trajectory patterns (runtime-specific)
40
- const feedbackEntries = [];
41
- const trajectory = result.successTrajectory;
42
- if (trajectory.length >= 3) {
43
- const firstHalf = trajectory.slice(0, Math.floor(trajectory.length / 2));
44
- const secondHalf = trajectory.slice(Math.floor(trajectory.length / 2));
45
- const firstRate = firstHalf.filter(Boolean).length / firstHalf.length;
46
- const secondRate = secondHalf.filter(Boolean).length / secondHalf.length;
47
- if (secondRate - firstRate > 0.3) {
48
- feedbackEntries.push({
49
- rule: `Success rate improved from ${(firstRate * 100).toFixed(0)}% to ${(secondRate * 100).toFixed(0)}% over ${trajectory.length} rounds`,
50
- why: `Mid-loop strategy adjustment unlocked progress in task "${task.slice(0, 80)}"`,
51
- howToApply: "Consider the strategy used in later rounds for similar tasks",
52
- });
53
- }
54
- }
55
- if (trajectory.length >= 3) {
56
- const recent = trajectory.slice(-3);
57
- if (recent.every((v) => v === false)) {
58
- feedbackEntries.push({
59
- rule: `Failed 3 consecutive rounds at end of loop`,
60
- why: `Task "${task.slice(0, 80)}" stopped with reason '${stopReason}' — strategy may have stagnated`,
61
- howToApply: "If re-attempting, start with a different technique than the one that produced the failures",
62
- });
63
- }
64
- }
65
- return {
66
- ...base,
67
- feedbackEntries: feedbackEntries.slice(0, wp.max_feedback_entries),
68
- };
69
- }
70
- function autoLoopId(task) {
71
- let slug = task.toLowerCase().trim().slice(0, 60);
72
- slug = slug.replace(/[^a-z0-9\s-]/g, "");
73
- slug = slug.replace(/\s+/g, "-");
74
- return slug || "unnamed-loop";
75
- }
76
26
  function resolveConfig(raw) {
77
27
  const policy = getPolicy();
78
28
  const rtPolicy = policy.runtime;
79
29
  const config = {
80
30
  task: raw.task,
81
31
  execute: raw.execute,
82
- loopId: raw.loopId || autoLoopId(raw.task),
32
+ loopId: raw.loopId || makeTaskId(raw.task),
83
33
  goalId: raw.goalId || "",
84
34
  maxRounds: raw.maxRounds ?? rtPolicy.max_rounds,
85
35
  roundTimeoutMs: raw.roundTimeoutMs ?? rtPolicy.round_timeout_ms,
@@ -96,25 +46,25 @@ function resolveConfig(raw) {
96
46
  onHeartbeat: raw.onHeartbeat,
97
47
  onTimeout: raw.onTimeout,
98
48
  onHealthWarning: raw.onHealthWarning,
99
- memoryProvider: raw.memoryProvider,
100
- memoryWriter: raw.memoryWriter,
49
+ contextProvider: raw.contextProvider,
50
+ terminalSinks: raw.terminalSinks ?? [],
101
51
  };
102
52
  // Auto-detect claude-mem if no explicit provider was given
103
- if (!config.memoryProvider && !config.memoryWriter) {
104
- const auto = tryAutoConfigure();
105
- if (auto.memoryProvider)
106
- config.memoryProvider = auto.memoryProvider;
107
- if (auto.memoryWriter)
108
- config.memoryWriter = auto.memoryWriter;
109
- }
110
53
  return config;
111
54
  }
55
+ class RoundStalledError extends Error {
56
+ constructor() {
57
+ super("Round exceeded timeout and stall grace period");
58
+ this.name = "RoundStalledError";
59
+ }
60
+ }
112
61
  // ═══════════════════════════════════════════════════════════════════════════
113
62
  // LoopRuntime
114
63
  // ═══════════════════════════════════════════════════════════════════════════
115
64
  export class LoopRuntime extends EventEmitter {
116
65
  config;
117
66
  engine;
67
+ roundDriver;
118
68
  _status = RuntimeStatus.IDLE;
119
69
  currentRound = 1;
120
70
  successTrajectory = [];
@@ -126,23 +76,28 @@ export class LoopRuntime extends EventEmitter {
126
76
  pendingVerificationFlags = [];
127
77
  // Enforcement gate state (v1.13)
128
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 = "";
129
82
  pendingRejectionNotice = "";
130
- // Memory integration state (v1.7)
131
- injectionCount = 0;
132
- lastInjectionRound = 0;
133
- injectedContexts = [];
134
- phase2Triggered = false;
135
- phase3Triggered = false;
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.
136
89
  pendingExternalContext = "";
137
90
  // Shared mutable state between the main loop and the heartbeat tick
138
91
  roundStartTime = 0;
139
92
  lastProgressTime = 0;
140
93
  activeCtx = null;
94
+ activeAbortController = null;
141
95
  timedOut = false;
142
96
  constructor(rawConfig) {
143
97
  super();
144
98
  this.config = resolveConfig(rawConfig);
145
99
  this.engine = createEngine();
100
+ this.roundDriver = new RoundDriver(this.engine);
146
101
  }
147
102
  get status() {
148
103
  return this._status;
@@ -153,64 +108,10 @@ export class LoopRuntime extends EventEmitter {
153
108
  getSuccessTrajectory() {
154
109
  return [...this.successTrajectory];
155
110
  }
156
- // ── Memory Integration (v1.7) ──────────────────────────────────────────
157
- /** Derive the current progress estimate from the last self evaluation.
158
- * Returns -1 if no progress data is available. */
159
- getCurrentProgress() {
160
- if (!this.lastSelfEval?.execution_evidence)
161
- return -1;
162
- const pe = this.lastSelfEval.execution_evidence.progress_estimate;
163
- return typeof pe === "number" ? pe : -1;
164
- }
165
- /** Determine which memory injection phase (0/1/2/3) should fire this round.
166
- * Returns 0 if no injection should occur. Phase tracking (phase2Triggered /
167
- * phase3Triggered) is updated by the caller based on the returned phase. */
168
- getInjectionPhase() {
169
- const policy = getPolicy();
170
- const mi = policy.memory_injection;
171
- if (!mi.enabled)
172
- return 0;
173
- if (!this.config.memoryProvider)
174
- return 0;
175
- if (this.lastInjectionRound > 0 &&
176
- this.currentRound - this.lastInjectionRound < mi.min_rounds_between_injections) {
177
- return 0;
178
- }
179
- const allowedPhases = new Set(resolveAllowedPhases(this.config.maxRounds, mi.round_tiers));
180
- const progress = this.getCurrentProgress();
181
- return resolveInjectionPhase(this.currentRound, this.injectionCount, allowedPhases, progress, this.phase2Triggered, this.phase3Triggered, {
182
- phase2: mi.phase_thresholds.phase2.threshold,
183
- phase3: mi.phase_thresholds.phase3.threshold,
184
- });
185
- }
186
- /** Build the accumulated context for constructing a targeted memory query.
187
- * Delegates to the shared buildAccumulatedMemoryContext() utility. */
188
- buildAccumulatedContext() {
189
- if (!this.lastSelfEval) {
190
- return { recurringIssues: [], failedPatterns: [], keyLessons: [], remainingCriteria: [] };
191
- }
192
- return buildAccumulatedMemoryContext(this.lastSelfEval);
193
- }
194
- /** Deduplicate external context against previously injected contexts.
195
- * Returns empty string if the new context is too similar to any prior. */
196
- dedupAndStoreContext(newContext) {
197
- if (!newContext.trim())
198
- return "";
199
- const policy = getPolicy();
200
- const threshold = policy.memory_injection.dedup_threshold;
201
- const newTokens = tokenize(newContext);
202
- for (const old of this.injectedContexts) {
203
- const oldTokens = tokenize(old);
204
- if (jaccard(newTokens, oldTokens) > threshold) {
205
- return ""; // Too similar — skip
206
- }
207
- }
208
- this.injectedContexts.push(newContext);
209
- return newContext;
210
- }
211
111
  // ── Lifecycle ──────────────────────────────────────────────────────────
212
112
  /** Start the loop. Returns when the loop terminates (task complete,
213
- * 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. */
214
115
  async start() {
215
116
  if (this._status !== RuntimeStatus.IDLE) {
216
117
  throw new Error(`Cannot start: runtime is ${this._status}`);
@@ -219,130 +120,164 @@ export class LoopRuntime extends EventEmitter {
219
120
  this.registerSignalHandlers();
220
121
  this.startHeartbeat();
221
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) {
222
144
  let stopReason = "max_rounds";
223
145
  let previousAgentOutput = null;
224
146
  let consecutiveErrors = 0;
225
147
  try {
226
- for (this.currentRound = 1; this.currentRound <= this.config.maxRounds; this.currentRound++) {
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";
157
+ break;
158
+ }
227
159
  if (this._status !== RuntimeStatus.RUNNING) {
228
- stopReason = this._status === RuntimeStatus.STALLED ? "stalled" : "stopped";
160
+ if (this._status === RuntimeStatus.PAUSED) {
161
+ stopReason = "paused";
162
+ }
163
+ else if (this._status === RuntimeStatus.STALLED) {
164
+ stopReason = "stalled";
165
+ }
166
+ else {
167
+ stopReason = "cancelled";
168
+ }
229
169
  break;
230
170
  }
231
- // ── v1.7: Memory Injection (before compile, only at L2 phases) ──
171
+ // ── Explicit external context ─────────────────────────────────
232
172
  this.pendingExternalContext = "";
233
- const injectionPhase = this.getInjectionPhase();
234
- if (injectionPhase !== 0) {
235
- // Track phase triggers
236
- if (injectionPhase === 2)
237
- this.phase2Triggered = true;
238
- if (injectionPhase === 3)
239
- this.phase3Triggered = true;
240
- const progress = this.getCurrentProgress();
173
+ if (this.config.contextProvider) {
241
174
  try {
242
- const ctx = {
175
+ this.pendingExternalContext = (await this.config.contextProvider({
243
176
  loopId: this.config.loopId,
244
177
  round: this.currentRound,
245
178
  task: this.config.task,
246
179
  domain: this.config.domain,
247
- phase: injectionPhase,
248
- progressEstimate: progress,
249
- accumulatedContext: this.buildAccumulatedContext(),
250
- };
251
- const rawContext = await this.config.memoryProvider(ctx);
252
- this.pendingExternalContext = this.dedupAndStoreContext(rawContext);
253
- if (this.pendingExternalContext) {
254
- this.injectionCount++;
255
- this.lastInjectionRound = this.currentRound;
256
- logEvent("memory_injected", {
257
- loopId: this.config.loopId,
258
- round: this.currentRound,
259
- phase: injectionPhase,
260
- injectionCount: this.injectionCount,
261
- contextLength: this.pendingExternalContext.length,
262
- });
263
- }
180
+ lastEvaluation: this.lastSelfEval ?? undefined,
181
+ })).trim();
264
182
  }
265
183
  catch {
266
- // memoryProvider failed — degrade gracefully, continue without context
184
+ // Provider failures are isolated from loop execution.
267
185
  this.pendingExternalContext = "";
268
- logEvent("memory_provider_error", {
186
+ logEvent("context_provider_error", {
269
187
  loopId: this.config.loopId,
270
188
  round: this.currentRound,
271
189
  });
272
190
  }
273
191
  }
274
192
  // ── Compile ──────────────────────────────────────────────────
275
- const lcr = this.buildCompileRequest(previousAgentOutput);
276
- const compileResult = this.engine.invokeLoopCompile({
277
- task: this.config.task,
278
- mode: Mode.LOOP_COMPILE,
279
- vault_config: makeVaultConfig(),
280
- feedback: null,
281
- skill_name: null,
282
- task_id: null,
283
- loop_id: lcr.loop_id,
284
- round: lcr.round,
285
- goal_id: lcr.goal_id,
286
- domain: lcr.domain ?? "",
287
- plan_source: lcr.plan_source ?? null,
288
- constraints_from_plan: lcr.constraints_from_plan ?? [],
289
- health_check_interval: lcr.health_check_interval,
290
- last_round_result: (lcr.last_round_result ?? undefined),
291
- verification_flags: this.pendingVerificationFlags,
292
- external_context: this.pendingExternalContext || undefined,
293
- max_rounds: this.config.maxRounds,
294
- });
295
- // v1.14: Write state file if the compiler produced one and policy allows it
296
- const sfContent = compileResult.response?.state_file_content;
297
- if (sfContent && getPolicy().state_file.enabled) {
298
- const sfDir = join(process.cwd(), getPolicy().state_file.directory);
299
- mkdirSync(sfDir, { recursive: true });
300
- writeFileSync(join(sfDir, `${this.config.loopId}-state.md`), sfContent, "utf-8");
301
- }
302
- if (!compileResult.response?.prompt) {
303
- stopReason = "stalled";
304
- break;
305
- }
306
- let prompt = compileResult.response.prompt;
307
- // If this is a redo of a rejected round, use ONLY the rejection prompt.
308
- // The agent needs to fix a specific issue, not get a fresh compilation.
309
- // Normal compilation resumes on the next round after enforcement accepts.
193
+ let prompt;
194
+ let level;
310
195
  if (this.pendingRejectionNotice) {
311
- prompt = 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;
312
220
  }
313
- const analysis = compileResult.response.analysis;
314
- const level = analysis?.rationale?.includes("l0")
315
- ? "l0"
316
- : analysis?.rationale?.includes("l1")
317
- ? "l1"
318
- : "l2";
319
- const technique = analysis?.technique ?? "unknown";
320
221
  // ── Emit round:start ──────────────────────────────────────────
321
- 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
+ };
322
233
  this.emit("round:start", startInfo);
323
234
  this.config.onRoundStart?.(startInfo);
324
235
  // ── Execute ───────────────────────────────────────────────────
325
236
  this.roundStartTime = Date.now();
326
237
  this.lastProgressTime = this.roundStartTime;
327
238
  this.timedOut = false;
239
+ const abortController = new AbortController();
328
240
  const ctx = {
329
241
  round: this.currentRound,
330
- signal: { aborted: false },
242
+ roundId,
243
+ signal: abortController.signal,
331
244
  reportProgress: (message) => {
332
245
  this.lastProgressTime = Date.now();
333
246
  void message; // consumed by heartbeat via lastProgressTime
334
247
  },
335
248
  };
336
249
  this.activeCtx = ctx;
250
+ this.activeAbortController = abortController;
337
251
  let agentOutput;
338
252
  try {
339
- agentOutput = await this.config.execute(prompt, ctx);
253
+ agentOutput = await this.executeWithDeadline(prompt, ctx);
340
254
  consecutiveErrors = 0;
341
255
  }
342
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
+ }
343
277
  consecutiveErrors++;
344
278
  const dur = Date.now() - this.roundStartTime;
345
279
  this.activeCtx = null;
280
+ this.activeAbortController = null;
346
281
  // Emit round:complete with error info
347
282
  const errInfo = {
348
283
  round: this.currentRound,
@@ -362,13 +297,15 @@ export class LoopRuntime extends EventEmitter {
362
297
  stopReason = "executor_failure";
363
298
  break;
364
299
  }
365
- // Record as a failed round so the next round's compilation
366
- // sees the failure context (forces L1/L2)
367
- 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`;
368
304
  continue;
369
305
  }
370
306
  const durationMs = Date.now() - this.roundStartTime;
371
307
  this.activeCtx = null;
308
+ this.activeAbortController = null;
372
309
  // ── Extract self-eval ─────────────────────────────────────────
373
310
  let selfEval = extractSelfEvaluation(agentOutput);
374
311
  let extractionSucceeded = selfEval !== null;
@@ -376,71 +313,70 @@ export class LoopRuntime extends EventEmitter {
376
313
  selfEval = heuristicSelfEvaluation(agentOutput);
377
314
  extractionSucceeded = false;
378
315
  }
379
- // ── Verification gate (v1.6 unified) + Enforcement (v1.13) ────
380
- // Run BEFORE autoFeedback so rejected rounds don't pollute vault.
316
+ // ── Unified round transaction: evaluate → verify → commit/reject ──
381
317
  this.pendingVerificationFlags = [];
382
- let gateContradicted = false;
318
+ let roundSuccess = selfEval?.success ?? false;
383
319
  if (selfEval !== null) {
384
- const vaultEntries = this.engine.getBackend().queryEntries({
385
- 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,
386
330
  });
387
- const verifyResult = verifySelfEvaluation(selfEval, this.currentRound, vaultEntries, this.lastSelfEval);
388
- this.pendingVerificationFlags = verifyResult.flags;
389
- if (verifyResult.verdict === "contradicted") {
390
- gateContradicted = true;
391
- logEvent("gate_contradicted", {
392
- loopId: this.config.loopId,
393
- round: this.currentRound,
394
- flags: verifyResult.flags.map((f) => f.check),
395
- });
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;
396
369
  }
397
- // ── Enforcement gate (v1.13) — before autoFeedback ──────────
398
- // Only run for structured self-evals, not heuristic fallback.
399
- if (extractionSucceeded) {
400
- const enforceResult = enforceRound(selfEval, verifyResult, this.currentRound, vaultEntries, this.consecutiveRejections);
401
- if (enforceResult.action === "reject") {
402
- this.consecutiveRejections++;
403
- this.pendingRejectionNotice = buildRejectionPrompt(this.currentRound, this.config.task, enforceResult);
404
- logEvent("enforcement_reject", {
405
- loopId: this.config.loopId,
406
- round: this.currentRound,
407
- reason: enforceResult.reason.slice(0, 120),
408
- consecutiveRejections: this.consecutiveRejections,
409
- });
410
- // Don't update lastSelfEval or successTrajectory.
411
- // Decrement currentRound so the for-loop increment returns
412
- // to the same round for the retry.
413
- this.currentRound--;
414
- continue;
415
- }
416
- if (enforceResult.action === "terminate") {
417
- stopReason = "enforcement_terminated";
418
- logEvent("session_end", {
419
- loopId: this.config.loopId,
420
- stopReason: "enforcement_terminated",
421
- round: this.currentRound,
422
- });
423
- break;
424
- }
425
- // Accept: reset rejection counter
426
- this.consecutiveRejections = 0;
427
- this.pendingRejectionNotice = "";
428
- } // end if (extractionSucceeded)
429
- this.lastSelfEval = selfEval;
430
- }
431
- // ── Auto-feedback (AFTER enforcement, only if accepted) ─────────
432
- if (selfEval !== null) {
433
- this.engine.autoFeedback(selfEval, this.config.loopId, this.currentRound, this.config.task);
434
370
  }
435
- // Compute round success
436
- const roundSuccess = selfEval?.success ?? false;
437
- // Contradicted gate verdict: skip success trend (match MCP path behavior)
438
- if (!gateContradicted) {
439
- this.successTrajectory.push(roundSuccess);
371
+ else {
372
+ // Null selfEval: extraction completely failed
373
+ stopReason = "stalled";
374
+ break;
440
375
  }
441
376
  // ── Emit round:complete ───────────────────────────────────────
442
377
  const completeInfo = {
443
378
  round: this.currentRound,
379
+ roundId,
444
380
  roundSuccess,
445
381
  selfEval,
446
382
  durationMs,
@@ -452,22 +388,8 @@ export class LoopRuntime extends EventEmitter {
452
388
  round: this.currentRound,
453
389
  success: roundSuccess,
454
390
  durationMs,
455
- technique: "loop_runtime",
456
391
  });
457
- // ── Check stop conditions ─────────────────────────────────────
458
- // Extraction check FIRST — heuristic results are low-confidence
459
- if (!extractionSucceeded) {
460
- stopReason = "stalled"; // extraction_failure mapped to stalled
461
- break;
462
- }
463
- if (selfEval && !selfEval.should_continue) {
464
- stopReason = "task_complete";
465
- break;
466
- }
467
- if (this.engine.shouldBreak()) {
468
- stopReason = "circuit_breaker";
469
- break;
470
- }
392
+ // ── Post-round state ──────────────────────────────────────────
471
393
  if (this.timedOut) {
472
394
  // Next round will be forced L2 via the compile request
473
395
  this.timedOut = false;
@@ -477,14 +399,17 @@ export class LoopRuntime extends EventEmitter {
477
399
  }
478
400
  finally {
479
401
  // ── Cleanup — always runs even if the loop body throws ──────────
480
- this.stopHeartbeat();
481
- this.unregisterSignalHandlers();
482
- if (this._status === RuntimeStatus.RUNNING) {
483
- 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
+ }
484
409
  }
485
410
  }
486
411
  const result = {
487
- success: stopReason === "task_complete",
412
+ success: stopReason === "completed",
488
413
  stopReason,
489
414
  roundsCompleted: this.currentRound > this.config.maxRounds
490
415
  ? this.config.maxRounds
@@ -492,48 +417,114 @@ export class LoopRuntime extends EventEmitter {
492
417
  successTrajectory: [...this.successTrajectory],
493
418
  };
494
419
  // ── v1.7: Memory Writeback ───────────────────────────────────────
495
- const wp = getPolicy().memory_writeback;
496
- if (wp.enabled && this.config.memoryWriter) {
497
- const shouldWrite = wp.write_on_outcomes.includes(stopReason) ||
498
- wp.write_on_outcomes.length === 0;
499
- if (shouldWrite && result.roundsCompleted > 0) {
500
- try {
501
- const lastEval = this.lastSelfEval;
502
- const discoveries = lastEval
503
- ? [
504
- ...(lastEval.wrong_assumptions ?? []),
505
- ...(lastEval.emerged_subtasks ?? []),
506
- ...(lastEval.discovered_constraints ?? []),
507
- ]
508
- : [];
509
- const payload = buildWritebackPayload(this.config.loopId, this.config.task, stopReason, result, discoveries);
510
- if (payload) {
511
- await this.config.memoryWriter(payload);
512
- logEvent("memory_writeback", {
513
- loopId: this.config.loopId,
514
- stopReason,
515
- feedbackCount: payload.feedbackEntries.length,
516
- });
517
- }
518
- }
519
- catch {
520
- // memoryWriter failed — degrade gracefully
521
- logEvent("memory_writeback_error", {
522
- loopId: this.config.loopId,
523
- stopReason,
524
- });
525
- }
526
- }
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))));
527
429
  }
528
430
  this.emit("done", result);
529
431
  return result;
530
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
+ }
531
482
  /** Stop the loop gracefully. Safe to call from any thread/timer. */
532
483
  stop() {
533
- 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();
534
489
  this._status = RuntimeStatus.STOPPED;
535
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;
520
+ }
521
+ if (this._status !== RuntimeStatus.PAUSED) {
522
+ throw new Error(`Cannot resume: runtime is ${this._status}`);
536
523
  }
524
+ this._status = RuntimeStatus.RUNNING;
525
+ this.pauseTimestamp = 0;
526
+ this.emit("resume");
527
+ return this.launchDriver(true);
537
528
  }
538
529
  // ── Internal: compile request builder ─────────────────────────────────
539
530
  buildCompileRequest(previousAgentOutput) {
@@ -546,7 +537,6 @@ export class LoopRuntime extends EventEmitter {
546
537
  plan_source: this.config.planSource ?? null,
547
538
  constraints_from_plan: this.config.constraintsFromPlan,
548
539
  health_check_interval: this.config.healthCheckInterval,
549
- vault_config: makeVaultConfig(),
550
540
  });
551
541
  if (this.timedOut && this.currentRound > 1) {
552
542
  lcr.force_level = "l2";
@@ -575,11 +565,36 @@ export class LoopRuntime extends EventEmitter {
575
565
  // v1.10: Checkpoint
576
566
  compression_checkpoint: selfEval.compression_checkpoint ?? false,
577
567
  checkpoint_label: selfEval.checkpoint_label ?? "",
568
+ // v1.16: Agent's declared next action
569
+ next_action: selfEval.next_action,
578
570
  });
579
571
  }
580
572
  }
581
573
  return lcr;
582
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
+ }
583
598
  // ── Internal: heartbeat ──────────────────────────────────────────────
584
599
  startHeartbeat() {
585
600
  if (this.config.heartbeatIntervalMs <= 0)
@@ -623,28 +638,12 @@ export class LoopRuntime extends EventEmitter {
623
638
  }
624
639
  // Timeout
625
640
  if (elapsed > this.config.roundTimeoutMs && !this.timedOut) {
626
- this.timedOut = true;
627
- if (this.activeCtx) {
628
- this.activeCtx.signal.aborted = true;
629
- }
630
- const timeoutInfo = {
631
- round: this.currentRound,
632
- elapsedMs: elapsed,
633
- };
634
- this.emit("timeout", timeoutInfo);
635
- this.config.onTimeout?.(timeoutInfo);
641
+ this.triggerTimeout(elapsed);
636
642
  }
637
643
  // Stall — timeout + grace period elapsed, execute still hasn't returned
638
644
  if (this.activeCtx !== null &&
639
645
  elapsed > this.config.roundTimeoutMs + this.config.stallGraceMs) {
640
- this._status = RuntimeStatus.STALLED;
641
- const stallMsg = `Round ${this.currentRound} stalled after ${elapsed}ms`;
642
- this.emit("stalled", {
643
- reason: "round_timeout",
644
- lastRound: this.currentRound,
645
- elapsedMs: elapsed,
646
- message: stallMsg,
647
- });
646
+ this.triggerStall(elapsed);
648
647
  }
649
648
  }
650
649
  catch {
@@ -660,7 +659,27 @@ export class LoopRuntime extends EventEmitter {
660
659
  // ── Internal: signal handlers ────────────────────────────────────────
661
660
  registerSignalHandlers() {
662
661
  this.sigintHandler = () => {
663
- 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
+ }
664
683
  };
665
684
  this.sigtermHandler = () => {
666
685
  this.stop();