@hyperdrive.bot/fleet-server 0.3.147 → 0.3.148
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/dist/server/server/agent/mcp-server.js +64 -2
- package/dist/server/server/agent/session-digest-generator.js +33 -2
- package/dist/server/server/agent/session-digest.d.ts +71 -0
- package/dist/server/server/agent/session-digest.js +117 -1
- package/dist/server/web-ui/_expo/static/js/web/{index-837630304edbf229f37aaa1d262c40d5.js → index-88d5f130a09403b86ae8c8c5fd1f97d4.js} +13 -13
- package/dist/server/web-ui/_expo/static/js/web/index-88d5f130a09403b86ae8c8c5fd1f97d4.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-88d5f130a09403b86ae8c8c5fd1f97d4.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-837630304edbf229f37aaa1d262c40d5.js.map.br → index-88d5f130a09403b86ae8c8c5fd1f97d4.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-837630304edbf229f37aaa1d262c40d5.js.map.gz → index-88d5f130a09403b86ae8c8c5fd1f97d4.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-837630304edbf229f37aaa1d262c40d5.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-837630304edbf229f37aaa1d262c40d5.js.gz +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { WorkflowNotFoundError } from "../workflow/workflow-manager.js";
|
|
4
|
-
import { WorkflowAgentPresetSchema, WorkflowSnapshotSchema, WorkflowStatusSchema, WorkflowTaskGraphSchema, SessionDigestSchema, } from "../messages.js";
|
|
4
|
+
import { WorkflowAgentPresetSchema, WorkflowSnapshotSchema, WorkflowStatusSchema, WorkflowTaskGraphSchema, SessionDigestSchema, PendingDecisionSchema, } from "../messages.js";
|
|
5
5
|
import { normalizeSecretForStorage } from "@hyperdrive.bot/fleet-protocol/session-secrets";
|
|
6
6
|
import { isTagColorName } from "@hyperdrive.bot/fleet-protocol/tag-color";
|
|
7
|
-
import { mergePendingDecisions } from "./session-digest.js";
|
|
7
|
+
import { answerPendingDecision, applyDecisionExpiry, mergePendingDecisions, } from "./session-digest.js";
|
|
8
8
|
import { AgentStatusEnum } from "./mcp-shared.js";
|
|
9
9
|
import { expandUserPath } from "../path-utils.js";
|
|
10
10
|
import { ensureValidJson } from "../json-utils.js";
|
|
@@ -605,6 +605,7 @@ export async function createAgentMcpServer(options) {
|
|
|
605
605
|
askedAt,
|
|
606
606
|
})), resolvedMode);
|
|
607
607
|
}
|
|
608
|
+
applyDecisionExpiry(next, existing);
|
|
608
609
|
next.selfReportedAt = new Date().toISOString();
|
|
609
610
|
await agentManager.updateAgentMetadata(callerAgentId, { digest: next });
|
|
610
611
|
return {
|
|
@@ -612,6 +613,67 @@ export async function createAgentMcpServer(options) {
|
|
|
612
613
|
structuredContent: ensureValidJson({ ok: true, digest: next }),
|
|
613
614
|
};
|
|
614
615
|
});
|
|
616
|
+
server.registerTool("resolve_decision", {
|
|
617
|
+
title: "Resolve a decision you already settled yourself",
|
|
618
|
+
description: "Close one of YOUR OWN pendingDecisions that no longer needs the user, because you " +
|
|
619
|
+
'resolved it in the work itself. Recorded as answeredBy: "agent", which is a ' +
|
|
620
|
+
"different fact from the user answering and stays visibly different forever. " +
|
|
621
|
+
"REQUIRES a note saying how it was settled: the user is being told they no longer " +
|
|
622
|
+
"have to decide, so they are owed the reason. Pass optionId when what you did " +
|
|
623
|
+
"matches one of the options you listed; omit it when the work went another way and " +
|
|
624
|
+
"let the note carry it. Refuses a decision the USER already answered - their answer " +
|
|
625
|
+
"stands and you read it with get_session_digest. Use this the moment a question " +
|
|
626
|
+
"stops being open, not at the end of the session.",
|
|
627
|
+
inputSchema: {
|
|
628
|
+
decisionId: z.string().min(1).max(80),
|
|
629
|
+
note: z.string().min(1).max(400),
|
|
630
|
+
optionId: z.string().min(1).max(80).optional(),
|
|
631
|
+
},
|
|
632
|
+
outputSchema: {
|
|
633
|
+
ok: z.boolean(),
|
|
634
|
+
decision: PendingDecisionSchema.optional(),
|
|
635
|
+
error: z.string().optional(),
|
|
636
|
+
},
|
|
637
|
+
}, async ({ decisionId, note, optionId }) => {
|
|
638
|
+
if (!callerAgentId) {
|
|
639
|
+
throw new Error("resolve_decision is only available to agent-scoped MCP sessions");
|
|
640
|
+
}
|
|
641
|
+
const existing = agentManager.getAgent(callerAgentId)?.digest;
|
|
642
|
+
const result = answerPendingDecision(existing?.pendingDecisions, {
|
|
643
|
+
decisionId,
|
|
644
|
+
optionId: optionId ?? null,
|
|
645
|
+
note,
|
|
646
|
+
at: new Date().toISOString(),
|
|
647
|
+
by: "agent",
|
|
648
|
+
});
|
|
649
|
+
if (!result.ok) {
|
|
650
|
+
/*
|
|
651
|
+
* Returned, not thrown. "Already answered" and "no such decision" are
|
|
652
|
+
* both ordinary outcomes of an agent sweeping its own open questions,
|
|
653
|
+
* and a thrown error reads to the model as a broken tool worth
|
|
654
|
+
* retrying. It is not: it is the answer.
|
|
655
|
+
*/
|
|
656
|
+
return {
|
|
657
|
+
content: [],
|
|
658
|
+
structuredContent: ensureValidJson({ ok: false, error: result.error }),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
const next = { ...existing, pendingDecisions: result.decisions };
|
|
662
|
+
/*
|
|
663
|
+
* This writer ages decisions too. It is the third digest write path and
|
|
664
|
+
* was the only one not doing it, which quietly broke the invariant
|
|
665
|
+
* `applyDecisionExpiry`'s own docblock states. An agent sweeping its open
|
|
666
|
+
* list is exactly when a sibling decision is most likely to be past the
|
|
667
|
+
* TTL, so skipping it here costs a whole idle cycle for no reason.
|
|
668
|
+
*/
|
|
669
|
+
applyDecisionExpiry(next, existing);
|
|
670
|
+
next.selfReportedAt = new Date().toISOString();
|
|
671
|
+
await agentManager.updateAgentMetadata(callerAgentId, { digest: next });
|
|
672
|
+
return {
|
|
673
|
+
content: [],
|
|
674
|
+
structuredContent: ensureValidJson({ ok: true, decision: result.decision }),
|
|
675
|
+
};
|
|
676
|
+
});
|
|
615
677
|
return server;
|
|
616
678
|
}
|
|
617
679
|
function unionStrings(existing, incoming) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent-response-loop.js";
|
|
3
3
|
import { resolveStructuredGenerationProviders, } from "./structured-generation-providers.js";
|
|
4
|
-
import { buildDigestSourceText, deriveKeyFilesFromTimeline, mergeDigest, } from "./session-digest.js";
|
|
4
|
+
import { applyDecisionExpiry, buildDigestSourceText, deriveKeyFilesFromTimeline, mergeDigest, } from "./session-digest.js";
|
|
5
5
|
const SUMMARY_MAX_CHARS = 600;
|
|
6
6
|
const ACCOMPLISHMENT_MAX = 10;
|
|
7
7
|
const NEXT_STEP_MAX_CHARS = 240;
|
|
@@ -200,7 +200,6 @@ export async function generateAndApplySessionDigest(options) {
|
|
|
200
200
|
}
|
|
201
201
|
// Deterministic, no LLM — always safe to refresh.
|
|
202
202
|
const keyFiles = deriveKeyFilesFromTimeline(rows);
|
|
203
|
-
const existing = agentManager.getAgent(agentId)?.digest;
|
|
204
203
|
let summaryPatch = {};
|
|
205
204
|
const sourceText = buildDigestSourceText(rows);
|
|
206
205
|
if (sourceText.trim().length > 0) {
|
|
@@ -263,11 +262,43 @@ export async function generateAndApplySessionDigest(options) {
|
|
|
263
262
|
// Fall through — still persist the deterministic keyFiles + timestamp.
|
|
264
263
|
}
|
|
265
264
|
}
|
|
265
|
+
/*
|
|
266
|
+
* Read the digest HERE, after the await above, not before it.
|
|
267
|
+
*
|
|
268
|
+
* It used to be read alongside `keyFiles`, then survive an LLM call capped at
|
|
269
|
+
* `DIGEST_GENERATION_TIMEOUT_MS` (120s), and `setDigest` replaces the digest
|
|
270
|
+
* wholesale, so this is a read-modify-write across a two-minute window. A user
|
|
271
|
+
* answering a decision inside that window had their answer written straight
|
|
272
|
+
* back out.
|
|
273
|
+
*
|
|
274
|
+
* On master that showed up as the entry reverting to unanswered: visibly
|
|
275
|
+
* wrong, and the user just answers again. With decision expiry it stops being
|
|
276
|
+
* recoverable, because the stale entry is still unanswered and a decision past
|
|
277
|
+
* the TTL gets stamped `answeredBy: "timeout"` - a terminal state
|
|
278
|
+
* `answerPendingDecision` refuses to overwrite. The card would then say
|
|
279
|
+
* "Expired unanswered" about a question they had answered, permanently.
|
|
280
|
+
*
|
|
281
|
+
* Nothing between the two points needs the old value: `existing` had exactly
|
|
282
|
+
* two uses, both below.
|
|
283
|
+
*/
|
|
284
|
+
const existing = agentManager.getAgent(agentId)?.digest;
|
|
266
285
|
const next = mergeDigest(existing, {
|
|
267
286
|
keyFiles,
|
|
268
287
|
generatedAt: new Date().toISOString(),
|
|
269
288
|
...summaryPatch,
|
|
270
289
|
});
|
|
290
|
+
/*
|
|
291
|
+
* The idle sweep is where a QUIET agent's decisions age out.
|
|
292
|
+
*
|
|
293
|
+
* `set_session_digest` expires them too, but an agent that has stopped
|
|
294
|
+
* self-reporting never calls it, and stopping is the failing case: the
|
|
295
|
+
* decision this whole mechanism exists for was one the agent answered in its
|
|
296
|
+
* own code and then never mentioned again. This runs on every running->idle
|
|
297
|
+
* transition, which that agent still makes, and it rewrites the record the
|
|
298
|
+
* reel actually reads - so without it a 72h-old question stays on the card
|
|
299
|
+
* forever no matter what the TTL says.
|
|
300
|
+
*/
|
|
301
|
+
applyDecisionExpiry(next, existing);
|
|
271
302
|
await agentManager.updateAgentMetadata(agentId, { digest: next });
|
|
272
303
|
}
|
|
273
304
|
/**
|
|
@@ -38,6 +38,77 @@ export declare function isDigestEmpty(digest: SessionDigest | undefined): boolea
|
|
|
38
38
|
* silently drops something still waiting on the user.
|
|
39
39
|
*/
|
|
40
40
|
export declare const MAX_PENDING_DECISIONS = 20;
|
|
41
|
+
/**
|
|
42
|
+
* How long an UNANSWERED decision stays open before it expires.
|
|
43
|
+
*
|
|
44
|
+
* Three hooks write decisions onto a digest (the protocol injector, the Stop
|
|
45
|
+
* capture net, the per-turn nudge) and, until this existed, nothing ever took
|
|
46
|
+
* one off. `capDecisions` reasons about answered-vs-unanswered and has no time
|
|
47
|
+
* dimension at all, and it protects unanswered entries hardest, so a `blocking`
|
|
48
|
+
* question the agent then answered ITSELF in code stayed on the digest until 20
|
|
49
|
+
* newer decisions pushed it out. Measured: `reel-tail-depth` was asked, built,
|
|
50
|
+
* shipped twice and released, and was still presented to the user as open 31
|
|
51
|
+
* hours later.
|
|
52
|
+
*
|
|
53
|
+
* 72h rather than 24h on purpose. Expiring a question the user is still
|
|
54
|
+
* thinking about is worse than carrying a dead one an extra day: the first
|
|
55
|
+
* loses a real decision, the second costs a row.
|
|
56
|
+
*/
|
|
57
|
+
export declare const DECISION_STALE_AFTER_MS: number;
|
|
58
|
+
/**
|
|
59
|
+
* Close out unanswered decisions older than the TTL, as `answeredBy: "timeout"`.
|
|
60
|
+
*
|
|
61
|
+
* NOT deleted. `answeredBy` already carried `"timeout"` as a value that nothing
|
|
62
|
+
* had ever written, which is the affordance this needs: the entry stays
|
|
63
|
+
* readable with no `chosenOptionId`, so "nobody ever answered" stays
|
|
64
|
+
* distinguishable from "answered, and this is the choice". Deleting would make
|
|
65
|
+
* a dropped question indistinguishable from one that never existed, which is
|
|
66
|
+
* the same class of silent loss the answer-stripping guard above prevents.
|
|
67
|
+
*
|
|
68
|
+
* Marking them answered also hands them to `capDecisions`, which already prunes
|
|
69
|
+
* answered entries oldest-first, so expiry needs no second eviction path.
|
|
70
|
+
*
|
|
71
|
+
* Daemon-side and after the merge, never on the incoming payload: `stripAnswer`
|
|
72
|
+
* means an agent cannot fake a timeout on its own question to make it go away.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately NOT called from `mergePendingDecisions`. Wiring it in there made
|
|
75
|
+
* that pure, deterministic function depend on the wall clock, and five existing
|
|
76
|
+
* tests went red on fixtures whose `askedAt` was simply in the past. They were
|
|
77
|
+
* right: a merge that silently rewrites entries based on when it happens to run
|
|
78
|
+
* is not a merge. The write path owns the clock; the merge owns the data.
|
|
79
|
+
*/
|
|
80
|
+
export declare function expireStaleDecisions(decisions: readonly PendingDecision[], now?: number, staleAfterMs?: number): PendingDecision[];
|
|
81
|
+
/**
|
|
82
|
+
* Age out decisions nobody answered, on EVERY digest write, from either writer.
|
|
83
|
+
*
|
|
84
|
+
* Not only when the agent sent `pendingDecisions`, which is the distinction the
|
|
85
|
+
* whole fix turns on: a decision goes stale precisely because the agent STOPPED
|
|
86
|
+
* mentioning it. `reel-tail-depth` was asked, answered by the agent in its own
|
|
87
|
+
* code, shipped and released, and never appeared in a payload again, so
|
|
88
|
+
* anything hanging off the merge would have run zero times on it. It was still
|
|
89
|
+
* shown to the user as an open blocking question 31 hours later.
|
|
90
|
+
*
|
|
91
|
+
* Reads the carried list when this write did not touch decisions, so a digest
|
|
92
|
+
* that only refreshes `nextStep` still ages them.
|
|
93
|
+
*
|
|
94
|
+
* Called from BOTH digest writers, because either one alone leaves the hole
|
|
95
|
+
* open at its own end:
|
|
96
|
+
* - `set_session_digest` (mcp-server.ts) is the agent reporting. An agent
|
|
97
|
+
* that has gone quiet does not call it, and quiet is the failing case.
|
|
98
|
+
* - `generateAndApplySessionDigest` (session-digest-generator.ts) runs on
|
|
99
|
+
* every running->idle transition, which is exactly what a quiet agent still
|
|
100
|
+
* does. Without it an agent that stops self-reporting keeps its stale
|
|
101
|
+
* decision on the card forever, TTL or no TTL, because nothing rewrites the
|
|
102
|
+
* record the reel reads.
|
|
103
|
+
*
|
|
104
|
+
* A helper rather than two inline blocks: the MCP handler was already at
|
|
105
|
+
* oxlint's complexity ceiling and one `if` tipped it to 21.
|
|
106
|
+
*/
|
|
107
|
+
export declare function applyDecisionExpiry(next: {
|
|
108
|
+
pendingDecisions?: PendingDecision[];
|
|
109
|
+
}, existing: {
|
|
110
|
+
pendingDecisions?: PendingDecision[];
|
|
111
|
+
} | undefined): void;
|
|
41
112
|
/**
|
|
42
113
|
* Merge agent-reported decisions onto the stored list, upserting by `id`.
|
|
43
114
|
*
|
|
@@ -128,6 +128,98 @@ function carryAnswerForward(incoming, existing) {
|
|
|
128
128
|
...(existing.answeredBy !== undefined ? { answeredBy: existing.answeredBy } : {}),
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* How long an UNANSWERED decision stays open before it expires.
|
|
133
|
+
*
|
|
134
|
+
* Three hooks write decisions onto a digest (the protocol injector, the Stop
|
|
135
|
+
* capture net, the per-turn nudge) and, until this existed, nothing ever took
|
|
136
|
+
* one off. `capDecisions` reasons about answered-vs-unanswered and has no time
|
|
137
|
+
* dimension at all, and it protects unanswered entries hardest, so a `blocking`
|
|
138
|
+
* question the agent then answered ITSELF in code stayed on the digest until 20
|
|
139
|
+
* newer decisions pushed it out. Measured: `reel-tail-depth` was asked, built,
|
|
140
|
+
* shipped twice and released, and was still presented to the user as open 31
|
|
141
|
+
* hours later.
|
|
142
|
+
*
|
|
143
|
+
* 72h rather than 24h on purpose. Expiring a question the user is still
|
|
144
|
+
* thinking about is worse than carrying a dead one an extra day: the first
|
|
145
|
+
* loses a real decision, the second costs a row.
|
|
146
|
+
*/
|
|
147
|
+
export const DECISION_STALE_AFTER_MS = 72 * 60 * 60 * 1000;
|
|
148
|
+
/**
|
|
149
|
+
* Close out unanswered decisions older than the TTL, as `answeredBy: "timeout"`.
|
|
150
|
+
*
|
|
151
|
+
* NOT deleted. `answeredBy` already carried `"timeout"` as a value that nothing
|
|
152
|
+
* had ever written, which is the affordance this needs: the entry stays
|
|
153
|
+
* readable with no `chosenOptionId`, so "nobody ever answered" stays
|
|
154
|
+
* distinguishable from "answered, and this is the choice". Deleting would make
|
|
155
|
+
* a dropped question indistinguishable from one that never existed, which is
|
|
156
|
+
* the same class of silent loss the answer-stripping guard above prevents.
|
|
157
|
+
*
|
|
158
|
+
* Marking them answered also hands them to `capDecisions`, which already prunes
|
|
159
|
+
* answered entries oldest-first, so expiry needs no second eviction path.
|
|
160
|
+
*
|
|
161
|
+
* Daemon-side and after the merge, never on the incoming payload: `stripAnswer`
|
|
162
|
+
* means an agent cannot fake a timeout on its own question to make it go away.
|
|
163
|
+
*
|
|
164
|
+
* Deliberately NOT called from `mergePendingDecisions`. Wiring it in there made
|
|
165
|
+
* that pure, deterministic function depend on the wall clock, and five existing
|
|
166
|
+
* tests went red on fixtures whose `askedAt` was simply in the past. They were
|
|
167
|
+
* right: a merge that silently rewrites entries based on when it happens to run
|
|
168
|
+
* is not a merge. The write path owns the clock; the merge owns the data.
|
|
169
|
+
*/
|
|
170
|
+
export function expireStaleDecisions(decisions, now = Date.now(), staleAfterMs = DECISION_STALE_AFTER_MS) {
|
|
171
|
+
return decisions.map((decision) => {
|
|
172
|
+
if (decision.answeredAt) {
|
|
173
|
+
return decision;
|
|
174
|
+
}
|
|
175
|
+
const askedAt = Date.parse(decision.askedAt);
|
|
176
|
+
/*
|
|
177
|
+
* An unparseable `askedAt` is left alone rather than treated as epoch 0,
|
|
178
|
+
* which would expire it instantly. A question with a broken timestamp is
|
|
179
|
+
* still a question.
|
|
180
|
+
*/
|
|
181
|
+
if (Number.isNaN(askedAt) || now - askedAt < staleAfterMs) {
|
|
182
|
+
return decision;
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
...decision,
|
|
186
|
+
answeredAt: new Date(now).toISOString(),
|
|
187
|
+
answeredBy: "timeout",
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Age out decisions nobody answered, on EVERY digest write, from either writer.
|
|
193
|
+
*
|
|
194
|
+
* Not only when the agent sent `pendingDecisions`, which is the distinction the
|
|
195
|
+
* whole fix turns on: a decision goes stale precisely because the agent STOPPED
|
|
196
|
+
* mentioning it. `reel-tail-depth` was asked, answered by the agent in its own
|
|
197
|
+
* code, shipped and released, and never appeared in a payload again, so
|
|
198
|
+
* anything hanging off the merge would have run zero times on it. It was still
|
|
199
|
+
* shown to the user as an open blocking question 31 hours later.
|
|
200
|
+
*
|
|
201
|
+
* Reads the carried list when this write did not touch decisions, so a digest
|
|
202
|
+
* that only refreshes `nextStep` still ages them.
|
|
203
|
+
*
|
|
204
|
+
* Called from BOTH digest writers, because either one alone leaves the hole
|
|
205
|
+
* open at its own end:
|
|
206
|
+
* - `set_session_digest` (mcp-server.ts) is the agent reporting. An agent
|
|
207
|
+
* that has gone quiet does not call it, and quiet is the failing case.
|
|
208
|
+
* - `generateAndApplySessionDigest` (session-digest-generator.ts) runs on
|
|
209
|
+
* every running->idle transition, which is exactly what a quiet agent still
|
|
210
|
+
* does. Without it an agent that stops self-reporting keeps its stale
|
|
211
|
+
* decision on the card forever, TTL or no TTL, because nothing rewrites the
|
|
212
|
+
* record the reel reads.
|
|
213
|
+
*
|
|
214
|
+
* A helper rather than two inline blocks: the MCP handler was already at
|
|
215
|
+
* oxlint's complexity ceiling and one `if` tipped it to 21.
|
|
216
|
+
*/
|
|
217
|
+
export function applyDecisionExpiry(next, existing) {
|
|
218
|
+
const carried = next.pendingDecisions ?? existing?.pendingDecisions;
|
|
219
|
+
if (carried?.length) {
|
|
220
|
+
next.pendingDecisions = expireStaleDecisions(carried);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
131
223
|
/**
|
|
132
224
|
* Trim to MAX_PENDING_DECISIONS, dropping ANSWERED entries oldest-first. An
|
|
133
225
|
* unanswered decision is only dropped when the list is entirely unanswered and
|
|
@@ -168,7 +260,31 @@ export function mergePendingDecisions(existing, incoming, mode = "merge") {
|
|
|
168
260
|
for (const decision of existing ?? []) {
|
|
169
261
|
byId.set(decision.id, decision);
|
|
170
262
|
}
|
|
171
|
-
|
|
263
|
+
/*
|
|
264
|
+
* `replace` replaces the agent's OPEN questions, never the user's answers.
|
|
265
|
+
*
|
|
266
|
+
* It used to start from `[]`, so an answered entry simply left out of the
|
|
267
|
+
* payload was dropped with its `chosenOptionId`, `answerNote` and
|
|
268
|
+
* `answeredBy` - the only place that choice lives. On its own that was
|
|
269
|
+
* "merely" erasure, and the question came back visibly open.
|
|
270
|
+
*
|
|
271
|
+
* `resolve_decision` turns it into forgery in three calls. Drop the answered
|
|
272
|
+
* entry with a `replace` that omits it; re-report the same id, which now has
|
|
273
|
+
* no stored `previous`, so `carryAnswerForward` never runs and it is stored
|
|
274
|
+
* unanswered; then `resolve_decision` sees no `answeredAt`, its guard does not
|
|
275
|
+
* fire, and the agent writes its OWN choice where the user's had been. The
|
|
276
|
+
* feed then shows a different answer, attributed to the agent, with no record
|
|
277
|
+
* that the user ever decided.
|
|
278
|
+
*
|
|
279
|
+
* Keeping answered entries unconditionally closes that at the source, for
|
|
280
|
+
* both tools at once. It costs nothing: `capDecisions` already evicts answered
|
|
281
|
+
* entries oldest-first, so they still age out, and an incoming id that matches
|
|
282
|
+
* one is found by the `findIndex` below and runs through `carryAnswerForward`
|
|
283
|
+
* exactly as in merge mode.
|
|
284
|
+
*/
|
|
285
|
+
const out = mode === "replace"
|
|
286
|
+
? (existing ?? []).filter((decision) => decision.answeredAt)
|
|
287
|
+
: [...(existing ?? [])];
|
|
172
288
|
for (const raw of incoming) {
|
|
173
289
|
const sanitized = stripAnswer(raw);
|
|
174
290
|
const previous = byId.get(raw.id);
|