@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +34 -0
- package/dist/index.js +124 -88
- package/package.json +1 -1
- package/src/state.ts +11 -2
- package/src/storage/entries.ts +59 -2
- package/src/storage/memory-engine.ts +18 -3
- package/src/storage/sql-dialect.ts +16 -3
- package/test/entries-read-cutover.test.ts +80 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/harness@0.
|
|
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
|
[34mCLI[39m tsup v8.5.1
|
|
9
9
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mESM[39m Build start
|
|
11
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
11
|
+
[32mESM[39m [1mdist/index.js [22m[32m567.16 KB[39m
|
|
12
12
|
[32mESM[39m [1mdist/isolate-F2PPSUL6.js [22m[32m53.82 KB[39m
|
|
13
|
-
[32mESM[39m ⚡️ Build success in
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 256ms
|
|
14
14
|
[34mDTS[39m Build start
|
|
15
|
-
[32mDTS[39m ⚡️ Build success in
|
|
15
|
+
[32mDTS[39m ⚡️ Build success in 8437ms
|
|
16
16
|
[32mDTS[39m [1mdist/index.d.ts [22m[32m104.68 KB[39m
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
6584
|
-
var mcpLog =
|
|
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
|
|
7578
|
-
var logger =
|
|
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
|
|
8695
|
-
var eventLog =
|
|
8696
|
-
var telemetryLog =
|
|
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 =
|
|
8868
|
-
var telemetryLog2 =
|
|
8869
|
-
var costLog =
|
|
8870
|
-
var mcpLog2 =
|
|
8871
|
-
var modelLog =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
14397
|
-
var log =
|
|
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
package/src/state.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Message } from "@poncho-ai/sdk";
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
package/src/storage/entries.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|