pi-harness-runtime 1.1.394 → 1.1.396

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 (214) hide show
  1. package/AGENTS.md +135 -4
  2. package/harness/glm-quota-logger.ts +17 -0
  3. package/harness/langchain/planner-logger.ts +15 -0
  4. package/harness/service-diagnostics.ts +32 -1
  5. package/index.ts +92 -3
  6. package/package.json +1 -1
  7. package/packages/a2a-adapter/package.json +1 -1
  8. package/packages/agent-policy/package.json +1 -1
  9. package/packages/architecture-generator/package.json +1 -1
  10. package/packages/auth/package.json +1 -1
  11. package/packages/autonomous-refactor/package.json +1 -1
  12. package/packages/autonomous-runtime/package.json +1 -1
  13. package/packages/cache-strategy/package.json +1 -1
  14. package/packages/capability-registry/package.json +1 -1
  15. package/packages/checkpoint/package.json +1 -1
  16. package/packages/cli-plugin-sdk/package.json +1 -1
  17. package/packages/clipboard/package.json +1 -1
  18. package/packages/clipboard-plugin/package.json +1 -1
  19. package/packages/code-generation/package.json +1 -1
  20. package/packages/code-review/package.json +1 -1
  21. package/packages/codex-adapter/package.json +1 -1
  22. package/packages/codex-watcher/package.json +1 -1
  23. package/packages/config-capture/package.json +1 -1
  24. package/packages/connection-runtime/package.json +1 -1
  25. package/packages/context-compiler/package.json +1 -1
  26. package/packages/context-discovery/package.json +1 -1
  27. package/packages/context-manager/package.json +1 -1
  28. package/packages/contracts/dist/workspace-env-format.d.ts +87 -0
  29. package/packages/contracts/dist/workspace-env-format.js +174 -6
  30. package/packages/contracts/dist/workspace-env.js +120 -9
  31. package/packages/contracts/package.json +1 -1
  32. package/packages/contracts/src/workspace-env-format.ts +209 -6
  33. package/packages/contracts/src/workspace-env.ts +128 -7
  34. package/packages/contracts/test/workspace-env.test.ts +177 -0
  35. package/packages/cookie-sanitizer/package.json +1 -1
  36. package/packages/cost-optimizer/package.json +1 -1
  37. package/packages/dependency-analyzer/package.json +1 -1
  38. package/packages/django-plugin/package.json +1 -1
  39. package/packages/doc-generator/package.json +1 -1
  40. package/packages/durable-review/package.json +1 -1
  41. package/packages/e2e-tester/package.json +1 -1
  42. package/packages/evaluation-engine/package.json +1 -1
  43. package/packages/evaluation-runner/package.json +1 -1
  44. package/packages/event-bus/package.json +1 -1
  45. package/packages/event-sink/package.json +1 -1
  46. package/packages/event-store/package.json +1 -1
  47. package/packages/experience-replay/package.json +1 -1
  48. package/packages/feedback-collector/package.json +1 -1
  49. package/packages/file-copy-helper/package.json +1 -1
  50. package/packages/framework-detector/package.json +1 -1
  51. package/packages/framework-plugin-sdk/package.json +1 -1
  52. package/packages/frappe-plugin/package.json +1 -1
  53. package/packages/generic-web-plugin/package.json +1 -1
  54. package/packages/health-monitor/package.json +1 -1
  55. package/packages/intent-analyzer/package.json +1 -1
  56. package/packages/jev-judge/dist/continuation-controller.d.ts +47 -0
  57. package/packages/jev-judge/dist/continuation-controller.d.ts.map +1 -1
  58. package/packages/jev-judge/dist/continuation-controller.js +142 -16
  59. package/packages/jev-judge/dist/continuation-controller.js.map +1 -1
  60. package/packages/jev-judge/dist/index.d.ts +6 -0
  61. package/packages/jev-judge/dist/index.d.ts.map +1 -1
  62. package/packages/jev-judge/dist/index.js +6 -0
  63. package/packages/jev-judge/dist/index.js.map +1 -1
  64. package/packages/jev-judge/dist/validation-ledger.d.ts +129 -0
  65. package/packages/jev-judge/dist/validation-ledger.d.ts.map +1 -0
  66. package/packages/jev-judge/dist/validation-ledger.js +234 -0
  67. package/packages/jev-judge/dist/validation-ledger.js.map +1 -0
  68. package/packages/jev-judge/dist/validation-plan.d.ts +176 -0
  69. package/packages/jev-judge/dist/validation-plan.d.ts.map +1 -0
  70. package/packages/jev-judge/dist/validation-plan.js +293 -0
  71. package/packages/jev-judge/dist/validation-plan.js.map +1 -0
  72. package/packages/jev-judge/dist/validation-tie-breaker.d.ts +48 -0
  73. package/packages/jev-judge/dist/validation-tie-breaker.d.ts.map +1 -0
  74. package/packages/jev-judge/dist/validation-tie-breaker.js +83 -0
  75. package/packages/jev-judge/dist/validation-tie-breaker.js.map +1 -0
  76. package/packages/jev-judge/package.json +1 -1
  77. package/packages/jev-judge/src/continuation-controller.ts +179 -20
  78. package/packages/jev-judge/src/index.ts +41 -0
  79. package/packages/jev-judge/src/validation-ledger.ts +290 -0
  80. package/packages/jev-judge/src/validation-plan.ts +406 -0
  81. package/packages/jev-judge/src/validation-tie-breaker.ts +123 -0
  82. package/packages/jev-judge/tests/continuation-controller.test.ts +167 -0
  83. package/packages/jev-judge/tests/validation-ledger.test.ts +181 -0
  84. package/packages/jev-judge/tests/validation-plan.test.ts +170 -0
  85. package/packages/jev-judge/tests/validation-tie-breaker.test.ts +88 -0
  86. package/packages/knowledge-graph/package.json +1 -1
  87. package/packages/knowledge-retrieval/package.json +1 -1
  88. package/packages/laravel-plugin/package.json +1 -1
  89. package/packages/learning-engine/package.json +1 -1
  90. package/packages/logging/README.md +37 -0
  91. package/packages/logging/dist/buffer.d.ts +33 -0
  92. package/packages/logging/dist/buffer.d.ts.map +1 -0
  93. package/packages/logging/dist/buffer.js +60 -0
  94. package/packages/logging/dist/buffer.js.map +1 -0
  95. package/packages/logging/dist/config.d.ts +40 -0
  96. package/packages/logging/dist/config.d.ts.map +1 -0
  97. package/packages/logging/dist/config.js +114 -0
  98. package/packages/logging/dist/config.js.map +1 -0
  99. package/packages/logging/dist/index.d.ts +32 -0
  100. package/packages/logging/dist/index.d.ts.map +1 -0
  101. package/packages/logging/dist/index.js +31 -0
  102. package/packages/logging/dist/index.js.map +1 -0
  103. package/packages/logging/dist/redact.d.ts +43 -0
  104. package/packages/logging/dist/redact.d.ts.map +1 -0
  105. package/packages/logging/dist/redact.js +220 -0
  106. package/packages/logging/dist/redact.js.map +1 -0
  107. package/packages/logging/dist/sampler.d.ts +37 -0
  108. package/packages/logging/dist/sampler.d.ts.map +1 -0
  109. package/packages/logging/dist/sampler.js +78 -0
  110. package/packages/logging/dist/sampler.js.map +1 -0
  111. package/packages/logging/dist/shipper.d.ts +82 -0
  112. package/packages/logging/dist/shipper.d.ts.map +1 -0
  113. package/packages/logging/dist/shipper.js +238 -0
  114. package/packages/logging/dist/shipper.js.map +1 -0
  115. package/packages/logging/dist/singleton.d.ts +38 -0
  116. package/packages/logging/dist/singleton.d.ts.map +1 -0
  117. package/packages/logging/dist/singleton.js +95 -0
  118. package/packages/logging/dist/singleton.js.map +1 -0
  119. package/packages/logging/dist/spool.d.ts +39 -0
  120. package/packages/logging/dist/spool.d.ts.map +1 -0
  121. package/packages/logging/dist/spool.js +110 -0
  122. package/packages/logging/dist/spool.js.map +1 -0
  123. package/packages/logging/dist/transports/r2.d.ts +31 -0
  124. package/packages/logging/dist/transports/r2.d.ts.map +1 -0
  125. package/packages/logging/dist/transports/r2.js +60 -0
  126. package/packages/logging/dist/transports/r2.js.map +1 -0
  127. package/packages/logging/dist/transports/webhook.d.ts +22 -0
  128. package/packages/logging/dist/transports/webhook.d.ts.map +1 -0
  129. package/packages/logging/dist/transports/webhook.js +37 -0
  130. package/packages/logging/dist/transports/webhook.js.map +1 -0
  131. package/packages/logging/dist/types.d.ts +73 -0
  132. package/packages/logging/dist/types.d.ts.map +1 -0
  133. package/packages/logging/dist/types.js +19 -0
  134. package/packages/logging/dist/types.js.map +1 -0
  135. package/packages/logging/package.json +37 -0
  136. package/packages/logging/src/buffer.ts +77 -0
  137. package/packages/logging/src/config.ts +163 -0
  138. package/packages/logging/src/index.ts +87 -0
  139. package/packages/logging/src/redact.ts +271 -0
  140. package/packages/logging/src/sampler.ts +97 -0
  141. package/packages/logging/src/shipper.ts +281 -0
  142. package/packages/logging/src/singleton.ts +142 -0
  143. package/packages/logging/src/spool.ts +139 -0
  144. package/packages/logging/src/transports/r2.ts +86 -0
  145. package/packages/logging/src/transports/webhook.ts +54 -0
  146. package/packages/logging/src/types.ts +85 -0
  147. package/packages/logging/test/buffer.test.ts +44 -0
  148. package/packages/logging/test/config.test.ts +97 -0
  149. package/packages/logging/test/integration.test.ts +135 -0
  150. package/packages/logging/test/redact.test.ts +109 -0
  151. package/packages/logging/test/sampler.test.ts +57 -0
  152. package/packages/logging/test/shipper.test.ts +152 -0
  153. package/packages/logging/test/spool.test.ts +93 -0
  154. package/packages/mcp-adapter/package.json +1 -1
  155. package/packages/memory-engine/package.json +1 -1
  156. package/packages/milestone-manager/package.json +1 -1
  157. package/packages/model-registry/package.json +1 -1
  158. package/packages/nextjs-plugin/package.json +1 -1
  159. package/packages/notification/package.json +1 -1
  160. package/packages/observability/package.json +1 -1
  161. package/packages/okf-indexer/package.json +1 -1
  162. package/packages/paperclip-integration/package.json +1 -1
  163. package/packages/performance-optimizer/package.json +1 -1
  164. package/packages/permission-mode/package.json +1 -1
  165. package/packages/privilege-broker/package.json +1 -1
  166. package/packages/project-analyzer/package.json +1 -1
  167. package/packages/project-bootstrap/package.json +1 -1
  168. package/packages/projection-engine/package.json +1 -1
  169. package/packages/prompt-compiler/package.json +1 -1
  170. package/packages/prompt-versioning/package.json +1 -1
  171. package/packages/provider-adapter-sdk/package.json +1 -1
  172. package/packages/provider-router/package.json +1 -1
  173. package/packages/provider-selector/package.json +1 -1
  174. package/packages/providers/package.json +1 -1
  175. package/packages/qdrant-skills/package.json +1 -1
  176. package/packages/quota-manager/package.json +1 -1
  177. package/packages/rate-limiter/package.json +1 -1
  178. package/packages/react-vite-plugin/package.json +1 -1
  179. package/packages/relay/package.json +1 -1
  180. package/packages/release-manager/package.json +1 -1
  181. package/packages/requirement-compiler/package.json +1 -1
  182. package/packages/runtime/package.json +1 -1
  183. package/packages/scheduler/package.json +1 -1
  184. package/packages/scheduler-adapter/package.json +1 -1
  185. package/packages/session/package.json +1 -1
  186. package/packages/session-api/package.json +1 -1
  187. package/packages/session-export/package.json +1 -1
  188. package/packages/shared-context/package.json +1 -1
  189. package/packages/skill-mcp-client/package.json +1 -1
  190. package/packages/skill-registry/package.json +1 -1
  191. package/packages/skills/package.json +1 -1
  192. package/packages/sprint-planner/package.json +1 -1
  193. package/packages/subscription-engine/package.json +1 -1
  194. package/packages/task-compiler/package.json +1 -1
  195. package/packages/telegram/package.json +1 -1
  196. package/packages/tencentdb-memory/package.json +1 -1
  197. package/packages/tencentdb-sync/package.json +1 -1
  198. package/packages/test-data-generator/package.json +1 -1
  199. package/packages/test-generator/package.json +1 -1
  200. package/packages/todo-bd-sync/package.json +1 -1
  201. package/packages/token-estimation/package.json +1 -1
  202. package/packages/token-optimizer/package.json +1 -1
  203. package/packages/trajectory/package.json +1 -1
  204. package/packages/tui/package.json +1 -1
  205. package/packages/types/package.json +1 -1
  206. package/packages/voice-api/package.json +1 -1
  207. package/packages/webmcp-tester/package.json +1 -1
  208. package/packages/wiki/package.json +1 -1
  209. package/packages/workflow-events/package.json +1 -1
  210. package/packages/workspace-scanner/package.json +1 -1
  211. package/packages/worktree/package.json +1 -1
  212. package/packages/write-review/package.json +1 -1
  213. package/scripts/unified-webhook-server.ts +102 -0
  214. package/scripts/validation-run.ts +191 -0
@@ -1,5 +1,14 @@
1
1
  import type { TodoItem, TodoContext, TodoProvider } from "./todo-provider.js";
2
2
  import { createTodoProvider } from "./todo-provider.js";
3
+ import {
4
+ buildValidationSteerSuffix,
5
+ formatDuration,
6
+ parseValidationPlan,
7
+ validationBudgetStatus,
8
+ type ValidationBudgetStatus,
9
+ type ValidationState,
10
+ } from "./validation-plan.js";
11
+ import type { ValidationLedger } from "./validation-ledger.js";
3
12
 
4
13
  /**
5
14
  * Simple debug logger that writes to file, not stdout
@@ -88,8 +97,24 @@ export interface ContinuationConfig {
88
97
  * A dispatch that never settles must not block every later continuation.
89
98
  */
90
99
  inFlightTimeoutMs?: number;
100
+ /**
101
+ * Ledger of validation runs.
102
+ *
103
+ * When a ledger is wired, the controller knows which tier already ran and
104
+ * whether the final tier is still pending, and it can pause an item whose
105
+ * declared time budget is spent.
106
+ */
107
+ validationLedger?: ValidationLedger;
91
108
  }
92
109
 
110
+ /**
111
+ * What a dispatch wants to send.
112
+ *
113
+ * A dispatch may also abort: the message builder returns the reason and the
114
+ * dispatch is dropped before anything is put on the wire.
115
+ */
116
+ type DispatchPayload = { send: string } | { abort: string };
117
+
93
118
  /**
94
119
  * Continuation state
95
120
  */
@@ -148,6 +173,8 @@ export interface ContinuationResult {
148
173
  nextItemId?: number;
149
174
  question?: string;
150
175
  reason?: string;
176
+ /** Budget status of the item the decision was about. */
177
+ budget?: ValidationBudgetStatus;
151
178
  }
152
179
 
153
180
  /**
@@ -198,9 +225,11 @@ export class TodoContinuationController {
198
225
  private _circuitBreakerCooldownMs = 5 * 60 * 1000; // How long the breaker stays open
199
226
  private _inFlightTimeoutMs = INFLIGHT_TIMEOUT_MS; // Watchdog for a hanging dispatch
200
227
  private _attemptsByItem = new Map<number, number>(); // Successful dispatches per item
228
+ private _itemStartedAt = new Map<number, number>(); // First continuation of an item (elapsed-time budget)
201
229
  private _totalAttempts = 0; // Successful dispatches in this session (runaway guard)
202
230
  private _maxTotalSteerAttempts = 9; // Session-wide cap on successful dispatches
203
231
  private _disposed = false; // Set on shutdown: no dispatch and no timer may act anymore
232
+ private _validationLedger?: ValidationLedger; // Optional ledger of validation runs
204
233
 
205
234
  /** True while a dispatch is on the wire */
206
235
  private get inFlight(): boolean {
@@ -215,6 +244,7 @@ export class TodoContinuationController {
215
244
  this._circuitBreakerCooldownMs = config?.circuitBreakerCooldownMs ?? 5 * 60 * 1000;
216
245
  this._maxConsecutiveFailures = config?.maxConsecutiveFailures ?? 3;
217
246
  this._inFlightTimeoutMs = config?.inFlightTimeoutMs ?? INFLIGHT_TIMEOUT_MS;
247
+ this._validationLedger = config?.validationLedger;
218
248
  this.config = {
219
249
  jevFallbackMinWaitMs: 5 * 60 * 1000, // 5 minutes
220
250
  routineThreshold: 0.7,
@@ -237,6 +267,56 @@ export class TodoContinuationController {
237
267
  return this._attemptsByItem.get(itemId) ?? 0;
238
268
  }
239
269
 
270
+ /**
271
+ * Elapsed work time on an item, measured from the first continuation.
272
+ *
273
+ * `undefined` means the item was never continued, so no elapsed time can
274
+ * be reported yet.
275
+ */
276
+ private elapsedForItem(itemId: number): number | undefined {
277
+ const startedAt = this._itemStartedAt.get(itemId);
278
+ if (startedAt === undefined) return undefined;
279
+ return Date.now() - startedAt;
280
+ }
281
+
282
+ /**
283
+ * Remember when work on an item started (first continuation only).
284
+ */
285
+ private markItemStarted(itemId: number): void {
286
+ if (!this._itemStartedAt.has(itemId)) this._itemStartedAt.set(itemId, Date.now());
287
+ }
288
+
289
+ /**
290
+ * The validation state of an item, read from the ledger.
291
+ *
292
+ * `undefined` when no ledger is wired: the caller then falls back to the
293
+ * declared plan alone.
294
+ */
295
+ private validationStateFor(item: TodoItem | undefined): ValidationState | undefined {
296
+ if (!item || !this._validationLedger) return undefined;
297
+ return this._validationLedger.stateFor(item);
298
+ }
299
+
300
+ /**
301
+ * The budget status of an item.
302
+ *
303
+ * Only a declared `budgetMinutes` arms a budget - the harness never
304
+ * invents one, because an invented budget could pause legitimate work.
305
+ */
306
+ private budgetStatusFor(item: TodoItem | undefined): ValidationBudgetStatus {
307
+ const plan = parseValidationPlan(item);
308
+ return validationBudgetStatus(plan, item ? this.elapsedForItem(item.id) : undefined);
309
+ }
310
+
311
+ /**
312
+ * The reason reported when an item ran out of its declared time budget.
313
+ */
314
+ private budgetAbortReason(item: TodoItem, budget: ValidationBudgetStatus): string {
315
+ const spent = formatDuration(budget.elapsedMs ?? 0);
316
+ const total = formatDuration(budget.budgetMs ?? 0);
317
+ return `budget exhausted for task ${item.id} (${spent} of ${total}) - pausing at a safe boundary`;
318
+ }
319
+
240
320
  /**
241
321
  * True while the circuit breaker forbids a send.
242
322
  *
@@ -469,7 +549,18 @@ export class TodoContinuationController {
469
549
  if (this._totalAttempts >= this._maxTotalSteerAttempts) {
470
550
  return { action: "stop", reason: `max total attempts (${this._maxTotalSteerAttempts}) reached` };
471
551
  }
472
-
552
+
553
+ // A declared time budget that is spent pauses the item at a safe
554
+ // boundary: the work stays open, the report says what is pending.
555
+ const budget = this.budgetStatusFor(item);
556
+ if (budget.exhausted) {
557
+ return {
558
+ action: "stop",
559
+ reason: this.budgetAbortReason(item, budget),
560
+ budget,
561
+ };
562
+ }
563
+
473
564
  this.state.currentItemId = item.id;
474
565
  this.state.nextItemId = item.id;
475
566
  return { action: "continue", nextItemId: item.id };
@@ -492,7 +583,18 @@ export class TodoContinuationController {
492
583
  if (this._totalAttempts >= this._maxTotalSteerAttempts) {
493
584
  return { action: "stop", reason: `max total attempts (${this._maxTotalSteerAttempts}) reached` };
494
585
  }
495
-
586
+
587
+ // A declared time budget that is spent pauses the item at a safe
588
+ // boundary: the work stays open, the report says what is pending.
589
+ const budget = this.budgetStatusFor(item);
590
+ if (budget.exhausted) {
591
+ return {
592
+ action: "stop",
593
+ reason: this.budgetAbortReason(item, budget),
594
+ budget,
595
+ };
596
+ }
597
+
496
598
  this.state.currentItemId = item.id;
497
599
  this.state.nextItemId = item.id;
498
600
  return { action: "continue", nextItemId: item.id };
@@ -530,20 +632,41 @@ export class TodoContinuationController {
530
632
  return;
531
633
  }
532
634
 
533
- // Build continuation message
534
- const message = item.description
535
- ? `Continue task: ${item.subject}\n\n${item.description}`
536
- : `Continue task: ${item.subject}`;
537
- // A retry after a stalled dispatch gets the same duplicate warning as
538
- // the automatic continuation path.
539
- const finalMessage =
540
- this._stalledItemId === item.id
541
- ? `${message} (retry after a stalled dispatch - a duplicate continuation for this item may still arrive)`
542
- : message;
635
+ // The message is built when the dispatch is about to be sent: the
636
+ // validation context must reflect the state at that moment.
637
+ this.markItemStarted(item.id);
543
638
 
544
639
  debugLog("TodoContinuation", "Sending continuation:", item.subject);
545
640
 
546
- const result = await this.guardedDispatch(item.id, finalMessage);
641
+ const result = await this.guardedDispatch(item.id, (dispatched) => {
642
+ const target = dispatched ?? item;
643
+ const baseMessage = target.description
644
+ ? `Continue task: ${target.subject}\n\n${target.description}`
645
+ : `Continue task: ${target.subject}`;
646
+ // A retry after a stalled dispatch gets the same duplicate warning
647
+ // as the automatic continuation path.
648
+ const retryNote =
649
+ this._stalledItemId === item.id
650
+ ? " (retry after a stalled dispatch - a duplicate continuation for this item may still arrive)"
651
+ : "";
652
+ // A spent time budget pauses the item: nothing goes on the wire.
653
+ const budget = this.budgetStatusFor(target);
654
+ if (budget.exhausted) {
655
+ return { abort: this.budgetAbortReason(target, budget) };
656
+ }
657
+ // A declared staged-validation plan rides along with the
658
+ // continuation: the agent must know which tier is cheap before it
659
+ // picks a command.
660
+ const validation = buildValidationSteerSuffix(target, {
661
+ elapsedMs: this.elapsedForItem(target.id),
662
+ state: this.validationStateFor(target),
663
+ });
664
+ return {
665
+ send: validation
666
+ ? `${baseMessage}${retryNote}\n\n${validation}`
667
+ : `${baseMessage}${retryNote}`,
668
+ };
669
+ });
547
670
  if (result.dispatched) {
548
671
  // Only a dispatch that actually reached the agent consumes an attempt
549
672
  this.recordAttempt(item.id);
@@ -566,8 +689,15 @@ export class TodoContinuationController {
566
689
  /**
567
690
  * Guarded dispatch - checks all conditions before sending
568
691
  * Uses in-flight guard to prevent race conditions
692
+ *
693
+ * The message is built by `buildMessage` at the moment the dispatch is
694
+ * about to send, so the payload always reflects the state at that moment
695
+ * (and the item comes from the snapshot read this dispatch already does).
569
696
  */
570
- private async guardedDispatch(itemId: number, message: string): Promise<GuardedDispatchResult> {
697
+ private async guardedDispatch(
698
+ itemId: number,
699
+ buildMessage: (item: TodoItem | undefined) => DispatchPayload,
700
+ ): Promise<GuardedDispatchResult> {
571
701
  // 0. A disposed controller never dispatches.
572
702
  if (this._disposed) {
573
703
  debugLog("TodoContinuation", "Guarded dispatch: controller disposed, skipping");
@@ -716,6 +846,18 @@ export class TodoContinuationController {
716
846
  this.scheduleGuardRelease();
717
847
 
718
848
  try {
849
+ const payload = buildMessage(item);
850
+ if ("abort" in payload) {
851
+ // The message builder refused the dispatch (e.g. the item
852
+ // ran out of its declared time budget). Nothing goes on the
853
+ // wire, and the guard is released again.
854
+ debugLog("TodoContinuation", "Guarded dispatch: aborted before send:", payload.abort);
855
+ this.state.continuationQueued = false;
856
+ this.cancelGuardTimer();
857
+ this._blockedDispatches++;
858
+ return { dispatched: false, reason: payload.abort, blocked: true };
859
+ }
860
+ const message = payload.send;
719
861
  await this.config.dispatch(message);
720
862
  this.state.lastSteerMessage = message;
721
863
  // Only the dispatch that owns the guard may reset the
@@ -824,14 +966,31 @@ export class TodoContinuationController {
824
966
  };
825
967
  }
826
968
 
827
- const message =
828
- this._stalledItemId === itemId
829
- ? `Continue todo item ID ${itemId}. (retry after a stalled dispatch - a duplicate continuation for this item may still arrive)`
830
- : `Continue todo item ID ${itemId}.`;
969
+ this.markItemStarted(itemId);
831
970
  debugLog("TodoContinuation", "Attempting steer to continue item", itemId);
832
971
 
833
- // Use guarded dispatch to verify all conditions
834
- const result = await this.guardedDispatch(itemId, message);
972
+ // The message is built when the dispatch is about to be sent, from the
973
+ // item the dispatch itself read: one snapshot read, no extra latency,
974
+ // and the validation context is always current.
975
+ const result = await this.guardedDispatch(itemId, (item) => {
976
+ const baseMessage =
977
+ this._stalledItemId === itemId
978
+ ? `Continue todo item ID ${itemId}. (retry after a stalled dispatch - a duplicate continuation for this item may still arrive)`
979
+ : `Continue todo item ID ${itemId}.`;
980
+ // A spent time budget pauses the item: nothing goes on the wire.
981
+ const budget = this.budgetStatusFor(item);
982
+ if (budget.exhausted && item) {
983
+ return { abort: this.budgetAbortReason(item, budget) };
984
+ }
985
+ // A declared staged-validation plan rides along with the
986
+ // continuation: the agent must know which tier is cheap before it
987
+ // picks a command.
988
+ const validation = buildValidationSteerSuffix(item, {
989
+ elapsedMs: this.elapsedForItem(itemId),
990
+ state: this.validationStateFor(item),
991
+ });
992
+ return { send: validation ? `${baseMessage}\n\n${validation}` : baseMessage };
993
+ });
835
994
  if (result.dispatched) {
836
995
  // Only a dispatch that actually reached the agent consumes an attempt
837
996
  this.recordAttempt(itemId);
@@ -728,6 +728,47 @@ export type { ContinuationConfig, ContinuationState, ContinuationResult } from "
728
728
  export { createTodoProvider } from "./todo-provider.js";
729
729
  export type { TodoProvider, TodoAvailability, TodoItem, TodoStatus, TodoSnapshot, TodoContext } from "./todo-provider.js";
730
730
 
731
+ // Export staged validation plans
732
+ export {
733
+ parseValidationPlan,
734
+ formatValidationContext,
735
+ buildValidationSteerSuffix,
736
+ describeTier,
737
+ parseTier,
738
+ parseDurationMs,
739
+ formatDuration,
740
+ summarizeValidationState,
741
+ validationBudgetStatus,
742
+ } from "./validation-plan.js";
743
+ export type {
744
+ ValidationPlan,
745
+ ValidationTier,
746
+ ValidationTierKind,
747
+ ValidationState,
748
+ ValidationTierState,
749
+ ValidationBudgetStatus,
750
+ } from "./validation-plan.js";
751
+
752
+ // Export the validation ledger (durable record of the tiers that ran)
753
+ export {
754
+ ValidationLedger,
755
+ defaultLedgerPath,
756
+ parseRunRecord,
757
+ parseLedgerContent,
758
+ } from "./validation-ledger.js";
759
+ export type {
760
+ ValidationRunRecord,
761
+ ValidationRunStatus,
762
+ ValidationLedgerOptions,
763
+ } from "./validation-ledger.js";
764
+
765
+ // Export the validation tie-breaker (typed verdict about one item)
766
+ export {
767
+ tieBreakValidation,
768
+ formatTieBreaker,
769
+ } from "./validation-tie-breaker.js";
770
+ export type { ValidationVerdict, ValidationTieBreaker } from "./validation-tie-breaker.js";
771
+
731
772
  // Export Task Intake
732
773
  export { TaskIntakeController, createTaskIntake } from "./task-intake.js";
733
774
  export type { TaskIntakeConfig, TaskIntakeResult } from "./task-intake.js";
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Validation Ledger - a durable record of which validation tier ran, when,
3
+ * how long it took and whether it passed.
4
+ *
5
+ * The harness never runs the tests itself, so the ledger is written by the
6
+ * agent (or by any tool that knows the result) through
7
+ * `scripts/validation-run.ts`. The ledger is the only mechanical source for
8
+ * the question "did the final tier already pass?" - without it the harness
9
+ * would have to trust the agent's own report, and a todo could be completed
10
+ * while its final acceptance check is still pending.
11
+ *
12
+ * Storage is plain NDJSON (one JSON object per line) so the file stays
13
+ * inspectable with `tail`, `jq` or `less`:
14
+ *
15
+ * ```text
16
+ * ~/.pi-harness-runtime/logs/validation-runs.jsonl
17
+ * ```
18
+ *
19
+ * The file is rotated when it grows past `maxBytes` (the previous content
20
+ * becomes `<path>.1`).
21
+ */
22
+
23
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join } from "node:path";
26
+ import type { TodoItem } from "./todo-provider.js";
27
+ import {
28
+ parseValidationPlan,
29
+ summarizeValidationState,
30
+ type ValidationState,
31
+ type ValidationTierKind,
32
+ type ValidationTierState,
33
+ } from "./validation-plan.js";
34
+
35
+ /**
36
+ * Result of a single validation run.
37
+ */
38
+ export type ValidationRunStatus = "passed" | "failed" | "timeout" | "error" | "skipped";
39
+
40
+ /**
41
+ * One recorded validation run.
42
+ */
43
+ export interface ValidationRunRecord {
44
+ /** ISO timestamp of the end of the run. */
45
+ ts: string;
46
+ /** Which tier was executed. */
47
+ tier: ValidationTierKind;
48
+ /** Outcome of the run. */
49
+ status: ValidationRunStatus;
50
+ /** Todo item the run belongs to. */
51
+ itemId?: number;
52
+ /** Subject of the todo item (denormalized for readable logs). */
53
+ itemSubject?: string;
54
+ /** The command that was executed. */
55
+ command?: string;
56
+ /** Human label of the tier. */
57
+ label?: string;
58
+ /** Wall-clock duration of the run in milliseconds. */
59
+ durationMs?: number;
60
+ /** Number of records the tier exercised, when it is data-volume bound. */
61
+ datasetSize?: number;
62
+ /** Exit code of the command, when it produced one. */
63
+ exitCode?: number;
64
+ /** Free-form note (e.g. "5 of 320 tests failed"). */
65
+ note?: string;
66
+ }
67
+
68
+ /**
69
+ * Aggregated validation state of a todo item.
70
+ */
71
+ export type { ValidationState, ValidationTierState };
72
+
73
+ /**
74
+ * Options for the ledger.
75
+ */
76
+ export interface ValidationLedgerOptions {
77
+ /** Path of the NDJSON ledger file. */
78
+ path?: string;
79
+ /** Rotation threshold in bytes (default 5 MB). */
80
+ maxBytes?: number;
81
+ /**
82
+ * Mirror sink: called for every recorded run.
83
+ *
84
+ * The harness uses it to ship the ledger through the central logging
85
+ * pipeline, so the runs are archived next to the other application logs.
86
+ */
87
+ onRecord?: (record: ValidationRunRecord) => void;
88
+ }
89
+
90
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
91
+
92
+ /**
93
+ * Default location of the ledger.
94
+ */
95
+ export function defaultLedgerPath(): string {
96
+ return join(homedir(), ".pi-harness-runtime", "logs", "validation-runs.jsonl");
97
+ }
98
+
99
+ function isRunStatus(value: unknown): value is ValidationRunStatus {
100
+ return (
101
+ value === "passed" ||
102
+ value === "failed" ||
103
+ value === "timeout" ||
104
+ value === "error" ||
105
+ value === "skipped"
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Copy the optional fields of a raw ledger object onto a typed record.
111
+ */
112
+ function copyOptionalFields(record: Record<string, unknown>, out: ValidationRunRecord): void {
113
+ if (typeof record.itemId === "number" && Number.isFinite(record.itemId)) out.itemId = record.itemId;
114
+ if (typeof record.itemSubject === "string") out.itemSubject = record.itemSubject;
115
+ if (typeof record.command === "string") out.command = record.command;
116
+ if (typeof record.label === "string") out.label = record.label;
117
+ if (typeof record.durationMs === "number" && Number.isFinite(record.durationMs)) {
118
+ out.durationMs = record.durationMs;
119
+ }
120
+ if (typeof record.datasetSize === "number" && Number.isFinite(record.datasetSize)) {
121
+ out.datasetSize = record.datasetSize;
122
+ }
123
+ if (typeof record.exitCode === "number" && Number.isFinite(record.exitCode)) {
124
+ out.exitCode = record.exitCode;
125
+ }
126
+ if (typeof record.note === "string") out.note = record.note;
127
+ }
128
+
129
+ /**
130
+ * Parse one ledger line. Returns `null` for malformed lines.
131
+ */
132
+ export function parseRunRecord(line: string): ValidationRunRecord | null {
133
+ const text = line.trim();
134
+ if (!text) return null;
135
+ let raw: unknown;
136
+ try {
137
+ raw = JSON.parse(text);
138
+ } catch {
139
+ return null;
140
+ }
141
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
142
+ const record = raw as Record<string, unknown>;
143
+ const tier = record.tier === "fast" || record.tier === "full" ? record.tier : undefined;
144
+ if (!tier) return null;
145
+ if (!isRunStatus(record.status)) return null;
146
+
147
+ const out: ValidationRunRecord = {
148
+ ts: typeof record.ts === "string" ? record.ts : new Date(0).toISOString(),
149
+ tier,
150
+ status: record.status,
151
+ };
152
+ copyOptionalFields(record, out);
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * Parse a whole ledger file. Malformed lines are skipped, not fatal.
158
+ */
159
+ export function parseLedgerContent(content: string): ValidationRunRecord[] {
160
+ const records: ValidationRunRecord[] = [];
161
+ for (const line of content.split("\n")) {
162
+ const record = parseRunRecord(line);
163
+ if (record) records.push(record);
164
+ }
165
+ return records;
166
+ }
167
+
168
+ /**
169
+ * Durable ledger of validation runs.
170
+ */
171
+ export class ValidationLedger {
172
+ private readonly path: string;
173
+ private readonly maxBytes: number;
174
+ private readonly onRecord?: (record: ValidationRunRecord) => void;
175
+
176
+ constructor(options: ValidationLedgerOptions = {}) {
177
+ this.path = options.path ?? process.env.PI_VALIDATION_LEDGER ?? defaultLedgerPath();
178
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
179
+ this.onRecord = options.onRecord;
180
+ }
181
+
182
+ /** Path of the ledger file. */
183
+ get filePath(): string {
184
+ return this.path;
185
+ }
186
+
187
+ /**
188
+ * Record a validation run.
189
+ *
190
+ * Never throws: a broken ledger must not break the work it describes.
191
+ */
192
+ record(
193
+ input: Omit<ValidationRunRecord, "ts"> & { ts?: string },
194
+ ): ValidationRunRecord | null {
195
+ const record: ValidationRunRecord = {
196
+ ts: input.ts ?? new Date().toISOString(),
197
+ tier: input.tier,
198
+ status: input.status,
199
+ };
200
+ if (input.itemId !== undefined) record.itemId = input.itemId;
201
+ if (input.itemSubject !== undefined) record.itemSubject = input.itemSubject;
202
+ if (input.command !== undefined) record.command = input.command;
203
+ if (input.label !== undefined) record.label = input.label;
204
+ if (input.durationMs !== undefined) record.durationMs = input.durationMs;
205
+ if (input.datasetSize !== undefined) record.datasetSize = input.datasetSize;
206
+ if (input.exitCode !== undefined) record.exitCode = input.exitCode;
207
+ if (input.note !== undefined) record.note = input.note;
208
+
209
+ try {
210
+ this.append(record);
211
+ } catch {
212
+ // A ledger write failure is not a reason to fail the run it describes.
213
+ }
214
+ try {
215
+ this.onRecord?.(record);
216
+ } catch {
217
+ // The mirror sink must never break the producer either.
218
+ }
219
+ return record;
220
+ }
221
+
222
+ /**
223
+ * Read every recorded run.
224
+ */
225
+ read(): ValidationRunRecord[] {
226
+ try {
227
+ if (!existsSync(this.path)) return [];
228
+ return parseLedgerContent(readFileSync(this.path, "utf-8"));
229
+ } catch {
230
+ return [];
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Every run recorded for one todo item.
236
+ */
237
+ runsFor(itemId: number): ValidationRunRecord[] {
238
+ return this.read().filter((run) => run.itemId === itemId);
239
+ }
240
+
241
+ /**
242
+ * The latest run of one tier for one todo item.
243
+ */
244
+ latestFor(itemId: number, tier: ValidationTierKind): ValidationRunRecord | undefined {
245
+ const runs = this.runsFor(itemId).filter((run) => run.tier === tier);
246
+ if (runs.length === 0) return undefined;
247
+ // The ledger is append-only: with an equal timestamp the later run wins.
248
+ return runs.reduce((a, b) => (a.ts > b.ts ? a : b));
249
+ }
250
+
251
+ /**
252
+ * The aggregated validation state of a todo item.
253
+ */
254
+ stateFor(item: TodoItem | null | undefined): ValidationState {
255
+ const plan = parseValidationPlan(item);
256
+ const runs = item?.id === undefined ? [] : this.runsFor(item.id);
257
+ return summarizeValidationState(plan, runs);
258
+ }
259
+
260
+ /**
261
+ * True when the item must not be completed yet because its final tier is
262
+ * still pending.
263
+ */
264
+ isFinalTierPending(item: TodoItem | null | undefined): boolean {
265
+ return this.stateFor(item).finalTierPending;
266
+ }
267
+
268
+ private append(record: ValidationRunRecord): void {
269
+ const dir = dirname(this.path);
270
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
271
+ this.rotateIfNeeded();
272
+ appendFileSync(this.path, `${JSON.stringify(record)}\n`, "utf-8");
273
+ }
274
+
275
+ private rotateIfNeeded(): void {
276
+ if (!existsSync(this.path)) return;
277
+ let size = 0;
278
+ try {
279
+ size = statSync(this.path).size;
280
+ } catch {
281
+ return;
282
+ }
283
+ if (size < this.maxBytes) return;
284
+ try {
285
+ renameSync(this.path, `${this.path}.1`);
286
+ } catch {
287
+ // Rotation is best effort: an unwritable directory must not lose runs.
288
+ }
289
+ }
290
+ }