@poncho-ai/harness 0.57.0 → 0.59.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.57.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.59.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,9 +8,9 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 565.81 KB
11
+ ESM dist/index.js 567.16 KB
12
12
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
13
- ESM ⚡️ Build success in 254ms
13
+ ESM ⚡️ Build success in 256ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 7133ms
15
+ DTS ⚡️ Build success in 8437ms
16
16
  DTS dist/index.d.ts 104.68 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.59.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#157](https://github.com/cesr/poncho-ai/pull/157) [`3f65382`](https://github.com/cesr/poncho-ai/commit/3f653820c9e0c66a12b544842c1ad3ddefdfd4a6) Thanks [@cesr](https://github.com/cesr)! - storage: scope the entry read-cutover to pendingSubagentResults only
8
+
9
+ The append-only read rebuild now overrides ONLY `pendingSubagentResults`
10
+ from the entry log — the single conversation field with a write race (a
11
+ subagent finishing mid-turn vs. the parent turn's whole-blob write). Each
12
+ result is a race-free INSERT (subagent_result entry) and consumption is a
13
+ callback_started entry, so reading it from entries means the parent
14
+ clobbering the blob copy is harmless — that's the clobber-race kill.
15
+
16
+ Message history (`messages` / `_harnessMessages`) is written solely by the
17
+ serialized turn finalize and is never raced, so it stays on the blob
18
+ (known-good; far simpler than faithfully rebuilding the LLM transcript
19
+ from entries, which the callback path did not capture correctly).
20
+
21
+ ## 0.58.0
22
+
23
+ ### Minor Changes
24
+
25
+ - [#155](https://github.com/cesr/poncho-ai/pull/155) [`9939955`](https://github.com/cesr/poncho-ai/commit/9939955585f0ea204e070192827f0b213e84d283) Thanks [@cesr](https://github.com/cesr)! - Phase 3c read cutover: conversation reads rebuild from the append-only
26
+ `conversation_entries` log. Both engines' `get`/`getWithArchive` paths now
27
+ call `rebuildConversationFromEntries`, which overrides `_harnessMessages`
28
+ (via `buildLlmContext`), `messages` (via `buildDisplaySnapshot`, full
29
+ transcript), and `pendingSubagentResults` (via `getPendingSubagentResults`)
30
+ when the entry log is non-empty. Conversations that predate dual-write have
31
+ no entries and fall back to the mutable blob untouched — no migration
32
+ script needed. The rebuild is wrapped in try/catch and never throws on the
33
+ read path. A kill-switch (`PONCHO_READ_ENTRIES=0`) instantly reverts to
34
+ blob reads without a deploy. `_continuationMessages` and `pendingApprovals`
35
+ remain blob fields (not yet modeled as entries).
36
+
3
37
  ## 0.57.0
4
38
 
5
39
  ### Minor Changes
package/dist/index.js CHANGED
@@ -2443,7 +2443,7 @@ var ponchoDocsTool = defineTool({
2443
2443
  import { randomUUID as randomUUID5 } from "crypto";
2444
2444
  import { readFile as readFile9 } from "fs/promises";
2445
2445
  import { resolve as resolve11 } from "path";
2446
- import { defineTool as defineTool12, getTextContent as getTextContent2, createLogger as createLogger6, formatError as fmtErr, url as urlColor } from "@poncho-ai/sdk";
2446
+ import { defineTool as defineTool12, getTextContent as getTextContent2, createLogger as createLogger7, formatError as fmtErr, url as urlColor } from "@poncho-ai/sdk";
2447
2447
 
2448
2448
  // src/upload-store.ts
2449
2449
  import { createHash as createHash2 } from "crypto";
@@ -2776,6 +2776,84 @@ var createUploadStore = async (config, workingDir) => {
2776
2776
 
2777
2777
  // src/storage/memory-engine.ts
2778
2778
  import { randomUUID as randomUUID3 } from "crypto";
2779
+
2780
+ // src/storage/entries.ts
2781
+ import { createLogger as createLogger2 } from "@poncho-ai/sdk";
2782
+ var entriesReadLog = createLogger2("entries-read");
2783
+ function buildLlmContext(entries) {
2784
+ let latestCompaction;
2785
+ for (const e of entries) {
2786
+ if (e.type === "compaction" && (!latestCompaction || e.seq > latestCompaction.seq)) {
2787
+ latestCompaction = e;
2788
+ }
2789
+ }
2790
+ const harnessMsgs = entries.filter(
2791
+ (e) => e.type === "harness_message"
2792
+ );
2793
+ if (latestCompaction) {
2794
+ const kept = harnessMsgs.filter((e) => e.seq >= latestCompaction.firstKeptSeq).map((e) => e.message);
2795
+ return [latestCompaction.summaryMessage, ...kept];
2796
+ }
2797
+ return harnessMsgs.map((e) => e.message);
2798
+ }
2799
+ function buildDisplaySnapshot(entries, tailN) {
2800
+ const amendmentsByTarget = /* @__PURE__ */ new Map();
2801
+ for (const e of entries) {
2802
+ if (e.type === "assistant_amendment") {
2803
+ const list = amendmentsByTarget.get(e.targetEntryId) ?? [];
2804
+ list.push(e);
2805
+ amendmentsByTarget.set(e.targetEntryId, list);
2806
+ }
2807
+ }
2808
+ const built = [];
2809
+ for (const e of entries) {
2810
+ if (e.type === "user_message") {
2811
+ if (e.hidden) continue;
2812
+ built.push({ seq: e.seq, message: e.message });
2813
+ } else if (e.type === "assistant_message") {
2814
+ let content = typeof e.message.content === "string" ? e.message.content : "";
2815
+ const amendments = amendmentsByTarget.get(e.id);
2816
+ if (amendments) {
2817
+ for (const a of amendments.sort((x, y) => x.seq - y.seq)) {
2818
+ if (a.appendText) content += a.appendText;
2819
+ }
2820
+ }
2821
+ built.push({ seq: e.seq, message: { ...e.message, content } });
2822
+ }
2823
+ }
2824
+ const total = built.length;
2825
+ const tail = tailN >= total ? built : built.slice(total - tailN);
2826
+ return {
2827
+ messages: tail.map((b) => b.message),
2828
+ totalMessages: total,
2829
+ headSeq: tail.length > 0 ? tail[0].seq : null
2830
+ };
2831
+ }
2832
+ function getPendingSubagentResults(entries) {
2833
+ const consumed = /* @__PURE__ */ new Set();
2834
+ for (const e of entries) {
2835
+ if (e.type === "callback_started") {
2836
+ for (const s of e.consumedSeqs) consumed.add(s);
2837
+ }
2838
+ }
2839
+ return entries.filter((e) => e.type === "subagent_result").filter((e) => !consumed.has(e.seq)).map((e) => e.result);
2840
+ }
2841
+ async function rebuildConversationFromEntries(conversation, readEntries) {
2842
+ if (process.env.PONCHO_READ_ENTRIES === "0") return conversation;
2843
+ try {
2844
+ const entries = await readEntries(conversation.conversationId);
2845
+ if (entries.length === 0) return conversation;
2846
+ conversation.pendingSubagentResults = getPendingSubagentResults(entries);
2847
+ return conversation;
2848
+ } catch (err) {
2849
+ entriesReadLog.warn(
2850
+ `[entries-read] ${conversation.conversationId} pendingSubagentResults rebuild failed, using blob: ${err instanceof Error ? err.message : String(err)}`
2851
+ );
2852
+ return conversation;
2853
+ }
2854
+ }
2855
+
2856
+ // src/storage/memory-engine.ts
2779
2857
  var DEFAULT_TENANT = "__default__";
2780
2858
  var DEFAULT_OWNER = "local-owner";
2781
2859
  var normalizeTenant = (tenantId) => tenantId ?? DEFAULT_TENANT;
@@ -2826,12 +2904,22 @@ var InMemoryEngine = class {
2826
2904
  return results;
2827
2905
  },
2828
2906
  get: async (conversationId) => {
2829
- return this.convs.get(conversationId);
2907
+ const c = this.convs.get(conversationId);
2908
+ if (!c) return void 0;
2909
+ return rebuildConversationFromEntries(
2910
+ { ...c },
2911
+ (id) => this.conversations.readEntries(id)
2912
+ );
2830
2913
  },
2831
2914
  // In-memory storage has no separate archive blob, so both variants
2832
2915
  // return the same conversation object.
2833
2916
  getWithArchive: async (conversationId) => {
2834
- return this.convs.get(conversationId);
2917
+ const c = this.convs.get(conversationId);
2918
+ if (!c) return void 0;
2919
+ return rebuildConversationFromEntries(
2920
+ { ...c },
2921
+ (id) => this.conversations.readEntries(id)
2922
+ );
2835
2923
  },
2836
2924
  getStatusSnapshot: async (conversationId) => {
2837
2925
  const c = this.convs.get(conversationId);
@@ -3292,7 +3380,7 @@ import { dirname as dirname2, resolve as resolve6 } from "path";
3292
3380
 
3293
3381
  // src/storage/sql-dialect.ts
3294
3382
  import { randomUUID as randomUUID4 } from "crypto";
3295
- import { createLogger as createLogger2 } from "@poncho-ai/sdk";
3383
+ import { createLogger as createLogger3 } from "@poncho-ai/sdk";
3296
3384
 
3297
3385
  // src/storage/schema.ts
3298
3386
  var migrations = [
@@ -3517,7 +3605,7 @@ var migrations = [
3517
3605
  ];
3518
3606
 
3519
3607
  // src/storage/sql-dialect.ts
3520
- var egressLog = createLogger2("egress");
3608
+ var egressLog = createLogger3("egress");
3521
3609
  var sqliteDialect = {
3522
3610
  tag: "sqlite",
3523
3611
  param: () => "?",
@@ -3707,7 +3795,10 @@ var SqlStorageEngine = class {
3707
3795
  if (row.continuation_messages) {
3708
3796
  conv._continuationMessages = typeof row.continuation_messages === "string" ? JSON.parse(row.continuation_messages) : row.continuation_messages;
3709
3797
  }
3710
- return conv;
3798
+ return rebuildConversationFromEntries(
3799
+ conv,
3800
+ (id) => this.conversations.readEntries(id)
3801
+ );
3711
3802
  },
3712
3803
  getStatusSnapshot: async (conversationId) => {
3713
3804
  const runStatusExpr = this.dialect.tag === "sqlite" ? "json_extract(data, '$.runStatus')" : "data->>'runStatus'";
@@ -3757,7 +3848,10 @@ var SqlStorageEngine = class {
3757
3848
  if (row.continuation_messages) {
3758
3849
  conv._continuationMessages = typeof row.continuation_messages === "string" ? JSON.parse(row.continuation_messages) : row.continuation_messages;
3759
3850
  }
3760
- return conv;
3851
+ return rebuildConversationFromEntries(
3852
+ conv,
3853
+ (id) => this.conversations.readEntries(id)
3854
+ );
3761
3855
  },
3762
3856
  create: async (ownerId, title, tenantId, init) => {
3763
3857
  const id = randomUUID4();
@@ -6580,8 +6674,8 @@ var createReminderTools = (store) => [
6580
6674
  ];
6581
6675
 
6582
6676
  // src/mcp.ts
6583
- import { createLogger as createLogger3 } from "@poncho-ai/sdk";
6584
- var mcpLog = createLogger3("mcp");
6677
+ import { createLogger as createLogger4 } from "@poncho-ai/sdk";
6678
+ var mcpLog = createLogger4("mcp");
6585
6679
  var McpHttpError = class extends Error {
6586
6680
  status;
6587
6681
  constructor(status, message) {
@@ -7574,8 +7668,8 @@ var createModelProvider = (provider, config) => {
7574
7668
  import { readFile as readFile8, readdir as readdir3, stat as stat2 } from "fs/promises";
7575
7669
  import { dirname as dirname5, resolve as resolve9, normalize as normalize2 } from "path";
7576
7670
  import YAML3 from "yaml";
7577
- import { createLogger as createLogger4 } from "@poncho-ai/sdk";
7578
- var logger = createLogger4("skills");
7671
+ import { createLogger as createLogger5 } from "@poncho-ai/sdk";
7672
+ var logger = createLogger5("skills");
7579
7673
  var DEFAULT_SKILL_DIRS = ["skills"];
7580
7674
  var resolveSkillDirs = (workingDir, extraPaths) => {
7581
7675
  const dirs = [...DEFAULT_SKILL_DIRS];
@@ -8691,9 +8785,9 @@ import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace
8691
8785
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
8692
8786
 
8693
8787
  // src/telemetry.ts
8694
- import { createLogger as createLogger5 } from "@poncho-ai/sdk";
8695
- var eventLog = createLogger5("event");
8696
- var telemetryLog = createLogger5("telemetry");
8788
+ import { createLogger as createLogger6 } from "@poncho-ai/sdk";
8789
+ var eventLog = createLogger6("event");
8790
+ var telemetryLog = createLogger6("telemetry");
8697
8791
  var MAX_FIELD_LENGTH = 200;
8698
8792
  var OMIT_FROM_LOG = /* @__PURE__ */ new Set(["continuationMessages", "_harnessMessages", "messages", "compactedHistory"]);
8699
8793
  function sanitizeEventForLog(event) {
@@ -8864,11 +8958,11 @@ var ToolDispatcher = class {
8864
8958
  };
8865
8959
 
8866
8960
  // src/harness.ts
8867
- var harnessLog = createLogger6("harness");
8868
- var telemetryLog2 = createLogger6("telemetry");
8869
- var costLog = createLogger6("cost");
8870
- var mcpLog2 = createLogger6("mcp");
8871
- var modelLog = createLogger6("model");
8961
+ var harnessLog = createLogger7("harness");
8962
+ var telemetryLog2 = createLogger7("telemetry");
8963
+ var costLog = createLogger7("cost");
8964
+ var mcpLog2 = createLogger7("mcp");
8965
+ var modelLog = createLogger7("model");
8872
8966
  function formatOtlpError(err) {
8873
8967
  if (!(err instanceof Error)) return String(err);
8874
8968
  const parts = [];
@@ -9840,7 +9934,7 @@ var AgentHarness = class _AgentHarness {
9840
9934
  const key = `${effectiveTenant}:${skipped.name}`;
9841
9935
  if (this.vfsSkillCollisionWarnings.has(key)) return;
9842
9936
  this.vfsSkillCollisionWarnings.add(key);
9843
- createLogger6("skills").warn(
9937
+ createLogger7("skills").warn(
9844
9938
  `VFS skill "${skipped.name}" for tenant ${effectiveTenant} ignored: a repo skill with the same name takes precedence.`
9845
9939
  );
9846
9940
  });
@@ -10093,7 +10187,7 @@ var AgentHarness = class _AgentHarness {
10093
10187
  this.agentFileFingerprint = rawContent;
10094
10188
  return true;
10095
10189
  } catch (error) {
10096
- createLogger6("agent").warn(`failed to refresh AGENT.md in dev: ${fmtErr(error)}`);
10190
+ createLogger7("agent").warn(`failed to refresh AGENT.md in dev: ${fmtErr(error)}`);
10097
10191
  return false;
10098
10192
  }
10099
10193
  }
@@ -10141,7 +10235,7 @@ var AgentHarness = class _AgentHarness {
10141
10235
  await this.refreshMcpTools("skills:changed");
10142
10236
  return true;
10143
10237
  } catch (error) {
10144
- createLogger6("skills").warn(`failed to refresh skills in dev: ${fmtErr(error)}`);
10238
+ createLogger7("skills").warn(`failed to refresh skills in dev: ${fmtErr(error)}`);
10145
10239
  return false;
10146
10240
  }
10147
10241
  }
@@ -10251,7 +10345,7 @@ var AgentHarness = class _AgentHarness {
10251
10345
  }
10252
10346
  if (config?.browser) {
10253
10347
  await this.initBrowserTools(config).catch((e) => {
10254
- createLogger6("browser").warn(`failed to load browser tools: ${fmtErr(e)}`);
10348
+ createLogger7("browser").warn(`failed to load browser tools: ${fmtErr(e)}`);
10255
10349
  });
10256
10350
  }
10257
10351
  const stateConfig = resolveStateConfig(config);
@@ -12149,7 +12243,9 @@ var InMemoryConversationStore = class {
12149
12243
  }
12150
12244
  async get(conversationId) {
12151
12245
  this.purgeExpired();
12152
- return this.conversations.get(conversationId);
12246
+ const c = this.conversations.get(conversationId);
12247
+ if (!c) return void 0;
12248
+ return rebuildConversationFromEntries({ ...c }, (id) => this.readEntries(id));
12153
12249
  }
12154
12250
  // In-memory stores already hold the full conversation object, so there's
12155
12251
  // no separate archive blob to load. Both variants return the same data.
@@ -12276,66 +12372,6 @@ var createConversationStore = (config, _options) => {
12276
12372
  return new InMemoryConversationStore(ttl);
12277
12373
  };
12278
12374
 
12279
- // src/storage/entries.ts
12280
- function buildLlmContext(entries) {
12281
- let latestCompaction;
12282
- for (const e of entries) {
12283
- if (e.type === "compaction" && (!latestCompaction || e.seq > latestCompaction.seq)) {
12284
- latestCompaction = e;
12285
- }
12286
- }
12287
- const harnessMsgs = entries.filter(
12288
- (e) => e.type === "harness_message"
12289
- );
12290
- if (latestCompaction) {
12291
- const kept = harnessMsgs.filter((e) => e.seq >= latestCompaction.firstKeptSeq).map((e) => e.message);
12292
- return [latestCompaction.summaryMessage, ...kept];
12293
- }
12294
- return harnessMsgs.map((e) => e.message);
12295
- }
12296
- function buildDisplaySnapshot(entries, tailN) {
12297
- const amendmentsByTarget = /* @__PURE__ */ new Map();
12298
- for (const e of entries) {
12299
- if (e.type === "assistant_amendment") {
12300
- const list = amendmentsByTarget.get(e.targetEntryId) ?? [];
12301
- list.push(e);
12302
- amendmentsByTarget.set(e.targetEntryId, list);
12303
- }
12304
- }
12305
- const built = [];
12306
- for (const e of entries) {
12307
- if (e.type === "user_message") {
12308
- if (e.hidden) continue;
12309
- built.push({ seq: e.seq, message: e.message });
12310
- } else if (e.type === "assistant_message") {
12311
- let content = typeof e.message.content === "string" ? e.message.content : "";
12312
- const amendments = amendmentsByTarget.get(e.id);
12313
- if (amendments) {
12314
- for (const a of amendments.sort((x, y) => x.seq - y.seq)) {
12315
- if (a.appendText) content += a.appendText;
12316
- }
12317
- }
12318
- built.push({ seq: e.seq, message: { ...e.message, content } });
12319
- }
12320
- }
12321
- const total = built.length;
12322
- const tail = tailN >= total ? built : built.slice(total - tailN);
12323
- return {
12324
- messages: tail.map((b) => b.message),
12325
- totalMessages: total,
12326
- headSeq: tail.length > 0 ? tail[0].seq : null
12327
- };
12328
- }
12329
- function getPendingSubagentResults(entries) {
12330
- const consumed = /* @__PURE__ */ new Set();
12331
- for (const e of entries) {
12332
- if (e.type === "callback_started") {
12333
- for (const s of e.consumedSeqs) consumed.add(s);
12334
- }
12335
- }
12336
- return entries.filter((e) => e.type === "subagent_result").filter((e) => !consumed.has(e.seq)).map((e) => e.result);
12337
- }
12338
-
12339
12375
  // src/tenant-token.ts
12340
12376
  import { jwtVerify } from "jose";
12341
12377
  async function verifyTenantToken(signingKey, token) {
@@ -12668,7 +12704,7 @@ var CALLBACK_LOCK_STALE_MS = 5 * 60 * 1e3;
12668
12704
  var STALE_SUBAGENT_THRESHOLD_MS = 5 * 60 * 1e3;
12669
12705
 
12670
12706
  // src/orchestrator/orchestrator.ts
12671
- import { createLogger as createLogger7, getTextContent as getTextContent4 } from "@poncho-ai/sdk";
12707
+ import { createLogger as createLogger8, getTextContent as getTextContent4 } from "@poncho-ai/sdk";
12672
12708
 
12673
12709
  // src/orchestrator/entries-dual-write.ts
12674
12710
  import { randomUUID as randomUUID6 } from "crypto";
@@ -12802,7 +12838,7 @@ var verifyEntriesParity = async (store, conversationId, blob, log2) => {
12802
12838
  };
12803
12839
 
12804
12840
  // src/orchestrator/orchestrator.ts
12805
- var dualWriteLog = createLogger7("orchestrator:entries");
12841
+ var dualWriteLog = createLogger8("orchestrator:entries");
12806
12842
  var assistantMessageText = (message) => {
12807
12843
  const raw = getTextContent4(message).trim();
12808
12844
  if (raw.startsWith("{") && raw.includes('"tool_calls"')) {
@@ -14393,8 +14429,8 @@ ${resultBody}`,
14393
14429
 
14394
14430
  // src/orchestrator/run-conversation-turn.ts
14395
14431
  import { randomUUID as randomUUID7 } from "crypto";
14396
- import { createLogger as createLogger8 } from "@poncho-ai/sdk";
14397
- var log = createLogger8("orchestrator");
14432
+ import { createLogger as createLogger9 } from "@poncho-ai/sdk";
14433
+ var log = createLogger9("orchestrator");
14398
14434
  var runConversationTurn = async (opts) => {
14399
14435
  const conversation = await opts.conversationStore.getWithArchive(opts.conversationId);
14400
14436
  if (!conversation) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/state.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { Message } from "@poncho-ai/sdk";
2
- import type { ConversationEntry, NewConversationEntry } from "./storage/entries.js";
2
+ import {
3
+ type ConversationEntry,
4
+ type NewConversationEntry,
5
+ rebuildConversationFromEntries,
6
+ } from "./storage/entries.js";
3
7
 
4
8
  export interface ConversationState {
5
9
  runId: string;
@@ -269,7 +273,12 @@ export class InMemoryConversationStore implements ConversationStore {
269
273
 
270
274
  async get(conversationId: string): Promise<Conversation | undefined> {
271
275
  this.purgeExpired();
272
- return this.conversations.get(conversationId);
276
+ const c = this.conversations.get(conversationId);
277
+ if (!c) return undefined;
278
+ // Phase 3c read cutover: rebuild reader-facing fields from the entry log
279
+ // (blob fallback for un-migrated conversations). Clone first — the map
280
+ // holds a live mutable reference and the rebuild overrides fields.
281
+ return rebuildConversationFromEntries({ ...c }, (id) => this.readEntries(id));
273
282
  }
274
283
 
275
284
  // In-memory stores already hold the full conversation object, so there's
@@ -1,5 +1,7 @@
1
- import type { Message } from "@poncho-ai/sdk";
2
- import type { PendingSubagentResult } from "../state.js";
1
+ import { createLogger, type Message } from "@poncho-ai/sdk";
2
+ import type { Conversation, PendingSubagentResult } from "../state.js";
3
+
4
+ const entriesReadLog = createLogger("entries-read");
3
5
 
4
6
  /**
5
7
  * Append-only conversation entries (Phase 3 substrate).
@@ -215,3 +217,58 @@ export function getPendingSubagentResults(
215
217
  .filter((e) => !consumed.has(e.seq))
216
218
  .map((e) => e.result);
217
219
  }
220
+
221
+ /**
222
+ * Phase 3c read cutover: rebuild a conversation's reader-facing fields from
223
+ * the append-only entry log, with a blob fallback for conversations that
224
+ * predate dual-write.
225
+ *
226
+ * Call this in every conversation `get`/`getWithArchive` path AFTER the
227
+ * Conversation has been constructed from the stored row/blob. It:
228
+ * - reads the entry log via `readEntries`,
229
+ * - if NON-EMPTY, overrides `_harnessMessages`, `messages`, and
230
+ * `pendingSubagentResults` with entry-derived values,
231
+ * - if EMPTY (un-migrated conversation), leaves the blob-derived fields
232
+ * untouched (fallback),
233
+ * - on ANY error, logs and falls back to the blob (never throws — this is
234
+ * a hot read path).
235
+ *
236
+ * `_continuationMessages` and `pendingApprovals` are NOT modeled as entries
237
+ * yet and are intentionally left as blob fields.
238
+ *
239
+ * Kill-switch: set `PONCHO_READ_ENTRIES=0` to instantly revert to pure blob
240
+ * reads without a deploy (rebuild is ON by default).
241
+ *
242
+ * NOTE: mutates `conversation` in place and returns it. Callers that hand
243
+ * back a shared/mutable Conversation reference (the in-memory stores) MUST
244
+ * pass a clone, or the override will corrupt their stored object.
245
+ */
246
+ export async function rebuildConversationFromEntries(
247
+ conversation: Conversation,
248
+ readEntries: (conversationId: string) => Promise<ConversationEntry[]>,
249
+ ): Promise<Conversation> {
250
+ // Targeted append-only: only `pendingSubagentResults` is read from the
251
+ // entry log, because it's the ONLY conversation field with a write race
252
+ // (a subagent finishing mid-turn vs. the parent turn's whole-blob write).
253
+ // The message history (`messages` / `_harnessMessages`) is written solely
254
+ // by the turn finalize, which the orchestrator serializes per
255
+ // conversation — never raced — so it stays on the blob (known-good, and
256
+ // far simpler than faithfully rebuilding the LLM transcript from entries).
257
+ //
258
+ // Kill-switch: ON by default; PONCHO_READ_ENTRIES="0" reverts to the blob.
259
+ if (process.env.PONCHO_READ_ENTRIES === "0") return conversation;
260
+
261
+ try {
262
+ const entries = await readEntries(conversation.conversationId);
263
+ if (entries.length === 0) return conversation; // fallback: pre-dual-write
264
+ conversation.pendingSubagentResults = getPendingSubagentResults(entries);
265
+ return conversation;
266
+ } catch (err) {
267
+ entriesReadLog.warn(
268
+ `[entries-read] ${conversation.conversationId} pendingSubagentResults rebuild failed, using blob: ${
269
+ err instanceof Error ? err.message : String(err)
270
+ }`,
271
+ );
272
+ return conversation;
273
+ }
274
+ }
@@ -14,7 +14,11 @@ import type { MainMemory } from "../memory.js";
14
14
  import type { TodoItem } from "../todo-tools.js";
15
15
  import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
16
16
  import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
17
- import type { ConversationEntry, NewConversationEntry } from "./entries.js";
17
+ import {
18
+ type ConversationEntry,
19
+ type NewConversationEntry,
20
+ rebuildConversationFromEntries,
21
+ } from "./entries.js";
18
22
 
19
23
  // ---------------------------------------------------------------------------
20
24
  // Internal VFS entry type
@@ -103,13 +107,24 @@ export class InMemoryEngine implements StorageEngine {
103
107
  },
104
108
 
105
109
  get: async (conversationId: string): Promise<Conversation | undefined> => {
106
- return this.convs.get(conversationId);
110
+ const c = this.convs.get(conversationId);
111
+ if (!c) return undefined;
112
+ // Phase 3c read cutover: rebuild reader-facing fields from the entry
113
+ // log (blob fallback for un-migrated conversations). Clone first — the
114
+ // map holds a live mutable reference and the rebuild overrides fields.
115
+ return rebuildConversationFromEntries({ ...c }, (id) =>
116
+ this.conversations.readEntries(id),
117
+ );
107
118
  },
108
119
 
109
120
  // In-memory storage has no separate archive blob, so both variants
110
121
  // return the same conversation object.
111
122
  getWithArchive: async (conversationId: string): Promise<Conversation | undefined> => {
112
- return this.convs.get(conversationId);
123
+ const c = this.convs.get(conversationId);
124
+ if (!c) return undefined;
125
+ return rebuildConversationFromEntries({ ...c }, (id) =>
126
+ this.conversations.readEntries(id),
127
+ );
113
128
  },
114
129
 
115
130
  getStatusSnapshot: async (
@@ -22,7 +22,11 @@ import type { MainMemory } from "../memory.js";
22
22
  import type { TodoItem } from "../todo-tools.js";
23
23
  import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
24
24
  import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
25
- import type { ConversationEntry, NewConversationEntry } from "./entries.js";
25
+ import {
26
+ type ConversationEntry,
27
+ type NewConversationEntry,
28
+ rebuildConversationFromEntries,
29
+ } from "./entries.js";
26
30
  import { type DialectTag, migrations } from "./schema.js";
27
31
 
28
32
  // ---------------------------------------------------------------------------
@@ -325,7 +329,12 @@ export abstract class SqlStorageEngine implements StorageEngine {
325
329
  ? JSON.parse(row.continuation_messages)
326
330
  : row.continuation_messages;
327
331
  }
328
- return conv;
332
+ // Phase 3c read cutover: rebuild reader-facing fields from the
333
+ // append-only entry log, falling back to the blob for un-migrated
334
+ // conversations. parseConversation returns a fresh object, so no clone.
335
+ return rebuildConversationFromEntries(conv, (id) =>
336
+ this.conversations.readEntries(id),
337
+ );
329
338
  },
330
339
 
331
340
  getStatusSnapshot: async (
@@ -408,7 +417,11 @@ export abstract class SqlStorageEngine implements StorageEngine {
408
417
  ? JSON.parse(row.continuation_messages)
409
418
  : row.continuation_messages;
410
419
  }
411
- return conv;
420
+ // Phase 3c read cutover: rebuild reader-facing fields from the entry
421
+ // log (blob fallback for un-migrated conversations).
422
+ return rebuildConversationFromEntries(conv, (id) =>
423
+ this.conversations.readEntries(id),
424
+ );
412
425
  },
413
426
 
414
427
  create: async (
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { InMemoryConversationStore } from "../src/state.js";
3
+ import type { NewConversationEntry } from "../src/storage/entries.js";
4
+ import type { Message } from "@poncho-ai/sdk";
5
+
6
+ const msg = (role: Message["role"], content: string): Message => ({ role, content });
7
+
8
+ // Targeted cutover: ONLY pendingSubagentResults is read from entries. Two
9
+ // subagent results; one later consumed by a callback_started entry.
10
+ function subagentEntries(): NewConversationEntry[] {
11
+ return [
12
+ { type: "subagent_result", id: "sr1", result: { subagentId: "s1", task: "a", status: "completed", timestamp: 1 } },
13
+ { type: "subagent_result", id: "sr2", result: { subagentId: "s2", task: "b", status: "completed", timestamp: 2 } },
14
+ ];
15
+ }
16
+
17
+ describe("Phase 3 targeted read cutover (pendingSubagentResults only)", () => {
18
+ const prevFlag = process.env.PONCHO_READ_ENTRIES;
19
+ afterEach(() => {
20
+ if (prevFlag === undefined) delete process.env.PONCHO_READ_ENTRIES;
21
+ else process.env.PONCHO_READ_ENTRIES = prevFlag;
22
+ });
23
+
24
+ it("rebuilds pendingSubagentResults from entries, leaving message history on the blob", async () => {
25
+ delete process.env.PONCHO_READ_ENTRIES; // ON by default
26
+ const store = new InMemoryConversationStore();
27
+ const conv = await store.create("owner", "title", null);
28
+ // Blob message history must be preserved (never raced, stays authoritative).
29
+ conv.messages = [msg("user", "hi"), msg("assistant", "hello")];
30
+ conv._harnessMessages = [msg("user", "hi"), msg("assistant", "hello")];
31
+ conv.pendingSubagentResults = []; // stale blob value
32
+ await store.update(conv);
33
+
34
+ await store.appendEntries(conv.conversationId, "agent", null, subagentEntries());
35
+
36
+ const loaded = await store.get(conv.conversationId);
37
+ expect(loaded).toBeDefined();
38
+ // pendingSubagentResults comes from entries
39
+ expect(loaded!.pendingSubagentResults?.map((r) => r.subagentId)).toEqual(["s1", "s2"]);
40
+ // message history is UNTOUCHED (still the blob)
41
+ expect(loaded!.messages.map((m) => m.content)).toEqual(["hi", "hello"]);
42
+ expect(loaded!._harnessMessages?.map((m) => m.content)).toEqual(["hi", "hello"]);
43
+ });
44
+
45
+ it("excludes results consumed by a callback_started entry", async () => {
46
+ delete process.env.PONCHO_READ_ENTRIES;
47
+ const store = new InMemoryConversationStore();
48
+ const conv = await store.create("owner", "title", null);
49
+ const stored = await store.appendEntries(conv.conversationId, "agent", null, subagentEntries());
50
+ await store.appendEntries(conv.conversationId, "agent", null, [
51
+ { type: "callback_started", id: "cb1", consumedSeqs: [stored[0]!.seq] },
52
+ ]);
53
+
54
+ const loaded = await store.get(conv.conversationId);
55
+ expect(loaded!.pendingSubagentResults?.map((r) => r.subagentId)).toEqual(["s2"]);
56
+ });
57
+
58
+ it("falls back to the blob pendingSubagentResults when there are no entries", async () => {
59
+ delete process.env.PONCHO_READ_ENTRIES;
60
+ const store = new InMemoryConversationStore();
61
+ const conv = await store.create("owner", "title", null);
62
+ conv.pendingSubagentResults = [{ subagentId: "blob", task: "x", status: "completed", timestamp: 0 }];
63
+ await store.update(conv);
64
+
65
+ const loaded = await store.get(conv.conversationId);
66
+ expect(loaded!.pendingSubagentResults?.map((r) => r.subagentId)).toEqual(["blob"]);
67
+ });
68
+
69
+ it("kill-switch PONCHO_READ_ENTRIES=0 reverts to blob even with entries", async () => {
70
+ process.env.PONCHO_READ_ENTRIES = "0";
71
+ const store = new InMemoryConversationStore();
72
+ const conv = await store.create("owner", "title", null);
73
+ conv.pendingSubagentResults = [{ subagentId: "blobwins", task: "x", status: "completed", timestamp: 0 }];
74
+ await store.update(conv);
75
+ await store.appendEntries(conv.conversationId, "agent", null, subagentEntries());
76
+
77
+ const loaded = await store.get(conv.conversationId);
78
+ expect(loaded!.pendingSubagentResults?.map((r) => r.subagentId)).toEqual(["blobwins"]);
79
+ });
80
+ });