@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.
@@ -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
- const out = mode === "replace" ? [] : [...(existing ?? [])];
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);