@stigmer/server 3.37.0 → 3.38.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.
Files changed (105) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +3 -0
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/domain/agentexecution/artifacts.d.ts.map +1 -1
  5. package/dist/domain/agentexecution/artifacts.js +15 -6
  6. package/dist/domain/agentexecution/artifacts.js.map +1 -1
  7. package/dist/domain/agentexecution/lifecycle.d.ts.map +1 -1
  8. package/dist/domain/agentexecution/lifecycle.js +5 -3
  9. package/dist/domain/agentexecution/lifecycle.js.map +1 -1
  10. package/dist/domain/agentexecution/platform-rows.d.ts +63 -0
  11. package/dist/domain/agentexecution/platform-rows.d.ts.map +1 -0
  12. package/dist/domain/agentexecution/platform-rows.js +106 -0
  13. package/dist/domain/agentexecution/platform-rows.js.map +1 -0
  14. package/dist/domain/agentexecution/subscribe.d.ts.map +1 -1
  15. package/dist/domain/agentexecution/subscribe.js +7 -4
  16. package/dist/domain/agentexecution/subscribe.js.map +1 -1
  17. package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
  18. package/dist/domain/agentexecution/update-status.js +48 -17
  19. package/dist/domain/agentexecution/update-status.js.map +1 -1
  20. package/dist/domain/iampolicy/grant-path.d.ts +7 -3
  21. package/dist/domain/iampolicy/grant-path.d.ts.map +1 -1
  22. package/dist/domain/iampolicy/grant-path.js +10 -2
  23. package/dist/domain/iampolicy/grant-path.js.map +1 -1
  24. package/dist/domain/organization/controller.d.ts +4 -1
  25. package/dist/domain/organization/controller.d.ts.map +1 -1
  26. package/dist/domain/organization/controller.js +28 -4
  27. package/dist/domain/organization/controller.js.map +1 -1
  28. package/dist/domain/organization/steps.d.ts +22 -0
  29. package/dist/domain/organization/steps.d.ts.map +1 -1
  30. package/dist/domain/organization/steps.js +50 -2
  31. package/dist/domain/organization/steps.js.map +1 -1
  32. package/dist/domain/workflowexecution/create-steps.d.ts.map +1 -1
  33. package/dist/domain/workflowexecution/create-steps.js +6 -2
  34. package/dist/domain/workflowexecution/create-steps.js.map +1 -1
  35. package/dist/domain/workflowexecution/lifecycle.d.ts.map +1 -1
  36. package/dist/domain/workflowexecution/lifecycle.js +5 -2
  37. package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
  38. package/dist/domain/workflowexecution/send-signal.d.ts.map +1 -1
  39. package/dist/domain/workflowexecution/send-signal.js +6 -3
  40. package/dist/domain/workflowexecution/send-signal.js.map +1 -1
  41. package/dist/domain/workflowexecution/submit-approval.d.ts.map +1 -1
  42. package/dist/domain/workflowexecution/submit-approval.js +6 -2
  43. package/dist/domain/workflowexecution/submit-approval.js.map +1 -1
  44. package/dist/domain/workflowexecution/submit-file-decision.d.ts.map +1 -1
  45. package/dist/domain/workflowexecution/submit-file-decision.js +6 -2
  46. package/dist/domain/workflowexecution/submit-file-decision.js.map +1 -1
  47. package/dist/domain/workflowexecution/submit-workflow-task-approval.d.ts.map +1 -1
  48. package/dist/domain/workflowexecution/submit-workflow-task-approval.js +6 -2
  49. package/dist/domain/workflowexecution/submit-workflow-task-approval.js.map +1 -1
  50. package/dist/domain/workflowexecution/subscribe-events.d.ts.map +1 -1
  51. package/dist/domain/workflowexecution/subscribe-events.js +6 -2
  52. package/dist/domain/workflowexecution/subscribe-events.js.map +1 -1
  53. package/dist/domain/workflowexecution/subscribe.d.ts.map +1 -1
  54. package/dist/domain/workflowexecution/subscribe.js +7 -4
  55. package/dist/domain/workflowexecution/subscribe.js.map +1 -1
  56. package/dist/extensions/gate-slots.d.ts +14 -1
  57. package/dist/extensions/gate-slots.d.ts.map +1 -1
  58. package/dist/extensions/gate-slots.js +1 -0
  59. package/dist/extensions/gate-slots.js.map +1 -1
  60. package/dist/pipeline/steps/authorization-tuples.d.ts +6 -2
  61. package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
  62. package/dist/pipeline/steps/authorization-tuples.js +6 -2
  63. package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
  64. package/dist/temporal/agentexecution/runner-result.d.ts +12 -0
  65. package/dist/temporal/agentexecution/runner-result.d.ts.map +1 -1
  66. package/dist/temporal/agentexecution/runner-result.js +15 -0
  67. package/dist/temporal/agentexecution/runner-result.js.map +1 -1
  68. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.d.ts.map +1 -1
  69. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js +44 -22
  70. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js.map +1 -1
  71. package/package.json +6 -6
  72. package/src/boot/compose.ts +3 -0
  73. package/src/domain/agentexecution/__tests__/store-faults.test.ts +315 -0
  74. package/src/domain/agentexecution/__tests__/update-status.test.ts +217 -0
  75. package/src/domain/agentexecution/artifacts.ts +21 -12
  76. package/src/domain/agentexecution/lifecycle.ts +5 -3
  77. package/src/domain/agentexecution/platform-rows.ts +133 -0
  78. package/src/domain/agentexecution/subscribe.ts +7 -3
  79. package/src/domain/agentexecution/update-status.ts +70 -19
  80. package/src/domain/iampolicy/__tests__/grant-path.test.ts +40 -7
  81. package/src/domain/iampolicy/grant-path.ts +19 -5
  82. package/src/domain/organization/__tests__/organization-delete.test.ts +266 -0
  83. package/src/domain/organization/controller.ts +40 -8
  84. package/src/domain/organization/steps.ts +66 -2
  85. package/src/domain/workflowexecution/__tests__/store-faults.test.ts +418 -0
  86. package/src/domain/workflowexecution/create-steps.ts +6 -2
  87. package/src/domain/workflowexecution/lifecycle.ts +5 -2
  88. package/src/domain/workflowexecution/send-signal.ts +11 -4
  89. package/src/domain/workflowexecution/submit-approval.ts +6 -2
  90. package/src/domain/workflowexecution/submit-file-decision.ts +6 -2
  91. package/src/domain/workflowexecution/submit-workflow-task-approval.ts +6 -2
  92. package/src/domain/workflowexecution/subscribe-events.ts +6 -2
  93. package/src/domain/workflowexecution/subscribe.ts +11 -4
  94. package/src/extensions/__tests__/registry.test.ts +3 -0
  95. package/src/extensions/gate-slots.ts +14 -0
  96. package/src/pipeline/__tests__/blind-not-found.test.ts +297 -0
  97. package/src/pipeline/steps/authorization-tuples.ts +6 -2
  98. package/src/temporal/agentexecution/__tests__/invoke-workflow.test.ts +51 -1
  99. package/src/temporal/agentexecution/__tests__/own-behalf-status-writes.test.ts +149 -9
  100. package/src/temporal/agentexecution/__tests__/replay-histories/runner-failed.json +757 -0
  101. package/src/temporal/agentexecution/__tests__/replay-histories/user-cancel.json +755 -0
  102. package/src/temporal/agentexecution/__tests__/replay.test.ts +4 -2
  103. package/src/temporal/agentexecution/runner-result.ts +16 -0
  104. package/src/temporal/agentexecution/workflows/invoke-agent-execution.ts +49 -22
  105. package/src/domain/agentexecution/__tests__/execution-usage-report.test.ts +0 -78
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Pins the store-fault contract every agent-execution load shares: a typed
3
+ * ResourceNotFoundError answers NotFound with the surface's own pinned copy,
4
+ * and any other store failure is an infrastructure fault answered as a
5
+ * sanitized Internal (`failed to load agent execution`), never a NotFound
6
+ * that tells a client the execution does not exist (stigmer/stigmer#859,
7
+ * stigmer/stigmer#1337).
8
+ *
9
+ * The surfaces: the five lifecycle verbs (their shared LoadExecutionById
10
+ * step), subscribe's snapshot read, the two artifact reads' existence
11
+ * checks, and the usage report's LoadExecution. The composed suites reach
12
+ * only a real store, which cannot fail selectively, so each surface runs
13
+ * here against a store whose one read throws. Every other dependency is
14
+ * untouchable: a load that fails must stop the call before any of them.
15
+ *
16
+ * Out of scope: the NotFound copy itself (wire contract, pinned where each
17
+ * surface is otherwise tested) and loads outside this domain.
18
+ */
19
+ import { create } from "@bufbuild/protobuf";
20
+ import type { HandlerContext } from "@connectrpc/connect";
21
+ import { Code, ConnectError, createContextValues } from "@connectrpc/connect";
22
+ import { describe, expect, it } from "vitest";
23
+
24
+ import {
25
+ AgentExecutionIdSchema,
26
+ CancelAgentExecutionInputSchema,
27
+ GetArtifactContentRequestSchema,
28
+ GetArtifactDownloadUrlRequestSchema,
29
+ PauseAgentExecutionInputSchema,
30
+ RecoverAgentExecutionInputSchema,
31
+ ResumeAgentExecutionInputSchema,
32
+ TerminateAgentExecutionInputSchema,
33
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
34
+ import { AgentExecutionQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/query_pb";
35
+
36
+ import type { ArtifactStorage } from "../../../artifactstorage/artifact-storage.js";
37
+ import { createLogger } from "../../../boot/logger.js";
38
+ import { callerIdentityKey } from "../../../pipeline/interceptors/auth.js";
39
+ import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
40
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
41
+ import { ResourceNotFoundError } from "../../../store/interface.js";
42
+ import type { Store } from "../../../store/interface.js";
43
+
44
+ import { getArtifactContent, getArtifactDownloadUrl } from "../artifacts.js";
45
+ import type { LifecycleDeps } from "../lifecycle.js";
46
+ import {
47
+ cancelExecution,
48
+ pauseExecution,
49
+ recoverExecution,
50
+ resumeExecution,
51
+ terminateExecution,
52
+ } from "../lifecycle.js";
53
+ import { StreamBroker } from "../stream-broker.js";
54
+ import { subscribeExecution } from "../subscribe.js";
55
+ import { getExecutionUsageReport } from "../usage.js";
56
+
57
+ const silentLogger = createLogger({
58
+ level: "error",
59
+ pretty: false,
60
+ write: () => {},
61
+ });
62
+
63
+ const EXECUTION_ID = "aex_storefault";
64
+
65
+ const LOAD_FAULT_COPY = "failed to load agent execution";
66
+
67
+ /** A dependency the call must never reach once its load has failed. */
68
+ function untouchable<T extends object>(name: string): T {
69
+ return new Proxy({} as T, {
70
+ get(_target, prop) {
71
+ throw new Error(`${name}.${String(prop)} reached after a failed load`);
72
+ },
73
+ });
74
+ }
75
+
76
+ /** A store whose every read fails with the given error. */
77
+ function failingStore(error: Error): Store {
78
+ return {
79
+ getResource: () => Promise.reject(error),
80
+ } as unknown as Store;
81
+ }
82
+
83
+ const MISSING = (): Error =>
84
+ new ResourceNotFoundError(`agent_execution/${EXECUTION_ID}`);
85
+ const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
86
+
87
+ async function errorOf(call: () => Promise<unknown>): Promise<ConnectError> {
88
+ const error = await call().then(
89
+ () => undefined,
90
+ (e: unknown) => e,
91
+ );
92
+ expect(error, "the call must fail").toBeInstanceOf(ConnectError);
93
+ return error as ConnectError;
94
+ }
95
+
96
+ function lifecycleDeps(store: Store): LifecycleDeps {
97
+ return {
98
+ store,
99
+ logger: silentLogger,
100
+ authorizer: newPermissiveSingleTeamAuthorizer(),
101
+ broker: untouchable("broker"),
102
+ engineState: untouchable("engineState"),
103
+ executionContextBuilder: untouchable("executionContextBuilder"),
104
+ gateSteps: new Map(),
105
+ statusObservers: [],
106
+ sandboxLane: untouchable("sandboxLane"),
107
+ temporalConfig: untouchable("temporalConfig"),
108
+ };
109
+ }
110
+
111
+ const LIFECYCLE_VERBS: ReadonlyArray<
112
+ readonly [string, (deps: LifecycleDeps) => Promise<unknown>]
113
+ > = [
114
+ [
115
+ "cancel",
116
+ (deps) =>
117
+ cancelExecution(
118
+ deps,
119
+ create(CancelAgentExecutionInputSchema, { id: EXECUTION_ID }),
120
+ testCallerIdentity(),
121
+ ),
122
+ ],
123
+ [
124
+ "terminate",
125
+ (deps) =>
126
+ terminateExecution(
127
+ deps,
128
+ create(TerminateAgentExecutionInputSchema, { id: EXECUTION_ID }),
129
+ testCallerIdentity(),
130
+ ),
131
+ ],
132
+ [
133
+ "pause",
134
+ (deps) =>
135
+ pauseExecution(
136
+ deps,
137
+ create(PauseAgentExecutionInputSchema, { id: EXECUTION_ID }),
138
+ testCallerIdentity(),
139
+ ),
140
+ ],
141
+ [
142
+ "resume",
143
+ (deps) =>
144
+ resumeExecution(
145
+ deps,
146
+ create(ResumeAgentExecutionInputSchema, { id: EXECUTION_ID }),
147
+ testCallerIdentity(),
148
+ ),
149
+ ],
150
+ [
151
+ "recover",
152
+ (deps) =>
153
+ recoverExecution(
154
+ deps,
155
+ create(RecoverAgentExecutionInputSchema, { id: EXECUTION_ID }),
156
+ testCallerIdentity(),
157
+ ),
158
+ ],
159
+ ];
160
+
161
+ describe.each(LIFECYCLE_VERBS)("%s — LoadExecutionById", (_verb, run) => {
162
+ it("a missing execution answers NotFound with the lifecycle copy", async () => {
163
+ const error = await errorOf(() =>
164
+ run(lifecycleDeps(failingStore(MISSING()))),
165
+ );
166
+
167
+ expect(error.code).toBe(Code.NotFound);
168
+ expect(error.rawMessage).toBe(`agent_execution not found: ${EXECUTION_ID}`);
169
+ });
170
+
171
+ it("any other store failure answers a sanitized Internal", async () => {
172
+ const error = await errorOf(() =>
173
+ run(lifecycleDeps(failingStore(LOCKED()))),
174
+ );
175
+
176
+ expect(error.code).toBe(Code.Internal);
177
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
178
+ });
179
+ });
180
+
181
+ describe("subscribe — the snapshot read", () => {
182
+ function handlerContext(): HandlerContext {
183
+ const values = createContextValues();
184
+ values.set(callerIdentityKey, testCallerIdentity());
185
+ return { signal: new AbortController().signal, values } as HandlerContext;
186
+ }
187
+
188
+ async function subscribeError(
189
+ store: Store,
190
+ broker: StreamBroker,
191
+ ): Promise<ConnectError> {
192
+ return errorOf(() =>
193
+ subscribeExecution(
194
+ {
195
+ store,
196
+ logger: silentLogger,
197
+ broker,
198
+ authorizer: newPermissiveSingleTeamAuthorizer(),
199
+ },
200
+ create(AgentExecutionIdSchema, { value: EXECUTION_ID }),
201
+ handlerContext(),
202
+ ).next(),
203
+ );
204
+ }
205
+
206
+ it("a missing execution answers NotFound with the subscribe copy", async () => {
207
+ const broker = new StreamBroker(silentLogger);
208
+ const error = await subscribeError(failingStore(MISSING()), broker);
209
+
210
+ expect(error.code).toBe(Code.NotFound);
211
+ expect(error.rawMessage).toBe(`AgentExecution not found: ${EXECUTION_ID}`);
212
+ expect(broker.getSubscriberCount(EXECUTION_ID)).toBe(0);
213
+ });
214
+
215
+ it("any other store failure answers a sanitized Internal and releases the subscription", async () => {
216
+ const broker = new StreamBroker(silentLogger);
217
+ const error = await subscribeError(failingStore(LOCKED()), broker);
218
+
219
+ expect(error.code).toBe(Code.Internal);
220
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
221
+ expect(broker.getSubscriberCount(EXECUTION_ID)).toBe(0);
222
+ });
223
+ });
224
+
225
+ describe("artifact reads — the existence check", () => {
226
+ function artifactDeps(store: Store) {
227
+ return {
228
+ store,
229
+ logger: silentLogger,
230
+ artifactStorage: untouchable<ArtifactStorage>("artifactStorage"),
231
+ authorizer: newPermissiveSingleTeamAuthorizer(),
232
+ };
233
+ }
234
+
235
+ // getArtifactContent checks the key's prefix before its load, so the key
236
+ // sits under the execution; getArtifactDownloadUrl loads first.
237
+ const ARTIFACT_READS: ReadonlyArray<
238
+ readonly [string, (store: Store) => Promise<unknown>]
239
+ > = [
240
+ [
241
+ "getArtifactContent",
242
+ (store) =>
243
+ getArtifactContent(
244
+ artifactDeps(store),
245
+ create(GetArtifactContentRequestSchema, {
246
+ executionId: EXECUTION_ID,
247
+ storageKey: `artifacts/${EXECUTION_ID}/out.txt`,
248
+ }),
249
+ testCallerIdentity(),
250
+ ),
251
+ ],
252
+ [
253
+ "getArtifactDownloadUrl",
254
+ (store) =>
255
+ getArtifactDownloadUrl(
256
+ artifactDeps(store),
257
+ create(GetArtifactDownloadUrlRequestSchema, {
258
+ executionId: EXECUTION_ID,
259
+ storageKey: `artifacts/${EXECUTION_ID}/out.txt`,
260
+ }),
261
+ testCallerIdentity(),
262
+ ),
263
+ ],
264
+ ];
265
+
266
+ describe.each(ARTIFACT_READS)("%s", (_read, run) => {
267
+ it("a missing execution answers NotFound with the artifact copy", async () => {
268
+ const error = await errorOf(() => run(failingStore(MISSING())));
269
+
270
+ expect(error.code).toBe(Code.NotFound);
271
+ expect(error.rawMessage).toBe(`execution not found: ${EXECUTION_ID}`);
272
+ });
273
+
274
+ it("any other store failure answers a sanitized Internal", async () => {
275
+ const error = await errorOf(() => run(failingStore(LOCKED())));
276
+
277
+ expect(error.code).toBe(Code.Internal);
278
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
279
+ });
280
+ });
281
+ });
282
+
283
+ describe("getExecutionUsageReport — LoadExecution", () => {
284
+ function reportError(store: Store): Promise<ConnectError> {
285
+ return errorOf(() =>
286
+ getExecutionUsageReport(
287
+ {
288
+ store,
289
+ logger: silentLogger,
290
+ authorizer: newPermissiveSingleTeamAuthorizer(),
291
+ listReadScope: undefined,
292
+ },
293
+ create(
294
+ AgentExecutionQueryController.method.getExecutionUsageReport.input,
295
+ { executionId: EXECUTION_ID },
296
+ ),
297
+ testCallerIdentity(),
298
+ ),
299
+ );
300
+ }
301
+
302
+ it("a missing execution answers NotFound with the domain's copy", async () => {
303
+ const error = await reportError(failingStore(MISSING()));
304
+
305
+ expect(error.code).toBe(Code.NotFound);
306
+ expect(error.rawMessage).toBe(`agent_execution not found: ${EXECUTION_ID}`);
307
+ });
308
+
309
+ it("any other store failure answers a sanitized Internal", async () => {
310
+ const error = await reportError(failingStore(LOCKED()));
311
+
312
+ expect(error.code).toBe(Code.Internal);
313
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
314
+ });
315
+ });
@@ -5,6 +5,10 @@
5
5
  * transcript regression guard (shrink + front-truncation-with-append +
6
6
  * the terminal exemption), the equal-length approval finalize, and the
7
7
  * presence-guarded runner-owned field pattern (recalled_memories_report).
8
+ * Also pins the platform-row rules (platform-rows.ts, stigmer#980): a
9
+ * platform marker write is appended to the transcript and never replaces
10
+ * it, idempotently, and a PAUSED or CANCELLED execution's stop row stays
11
+ * last across the runner's late writes, whatever order they land in.
8
12
  * These exercise applyUpdateStatusMerge directly on a clone — mirroring
9
13
  * the freshly-loaded resource the merge mutates in place inside the
10
14
  * updateResource write lock.
@@ -31,6 +35,7 @@ import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexec
31
35
 
32
36
  import { createLogger } from "../../../boot/logger.js";
33
37
  import { findToolCallInExecution } from "../submit-approval.js";
38
+ import { CANCEL_STOP_ROW, PAUSE_STOP_ROW } from "../platform-rows.js";
34
39
  import { applyUpdateStatusMerge } from "../update-status.js";
35
40
 
36
41
  const silentLogger = createLogger({
@@ -367,3 +372,215 @@ describe("the presence-guarded runner-owned field pattern (recalled_memories_rep
367
372
  expect(merged.status?.recalledMemoriesReport).toBeUndefined();
368
373
  });
369
374
  });
375
+
376
+ describe("platform rows (stigmer#980)", () => {
377
+ // The runner's transcript: AI and tool turns, then (for a failure it
378
+ // reports itself) its own system rows.
379
+ function runnerTranscript(...contents: string[]) {
380
+ return contents.map((content) => ({ type: MessageType.MESSAGE_AI, content }));
381
+ }
382
+ function system(...contents: string[]) {
383
+ return contents.map((content) => ({ type: MessageType.MESSAGE_SYSTEM, content }));
384
+ }
385
+ function executionAt(
386
+ phase: ExecutionPhase,
387
+ msgs: MessageInitShape<typeof AgentMessageSchema>[],
388
+ ): AgentExecution {
389
+ return create(AgentExecutionSchema, {
390
+ metadata: { id: "exec-rows", name: "exec-rows" },
391
+ spec: {},
392
+ status: { phase, messages: msgs },
393
+ });
394
+ }
395
+ function contents(execution: AgentExecution): string[] {
396
+ return (execution.status?.messages ?? []).map((m) => m.content);
397
+ }
398
+
399
+ describe("a platform marker write is appended, never substituted", () => {
400
+ it("appends the cancel row to a transcript already persisted CANCELLED", () => {
401
+ // The Cancel RPC persisted CANCELLED first, then the workflow's
402
+ // cancellation cleanup lands its row: today it replaced the whole
403
+ // transcript with that one line.
404
+ const merged = runBuildStep(
405
+ executionAt(ExecutionPhase.EXECUTION_CANCELLED, runnerTranscript("a1", "a2")),
406
+ { phase: ExecutionPhase.EXECUTION_CANCELLED, messages: system(CANCEL_STOP_ROW) },
407
+ );
408
+ expect(contents(merged)).toEqual(["a1", "a2", CANCEL_STOP_ROW]);
409
+ });
410
+
411
+ it("appends the broken-flow failure lines to a FAILED transcript", () => {
412
+ const merged = runBuildStep(
413
+ executionAt(ExecutionPhase.EXECUTION_FAILED, runnerTranscript("a1", "a2", "a3")),
414
+ {
415
+ phase: ExecutionPhase.EXECUTION_FAILED,
416
+ error: "boom",
417
+ messages: system("Internal failure.", "Error details: boom"),
418
+ },
419
+ );
420
+ expect(contents(merged)).toEqual([
421
+ "a1",
422
+ "a2",
423
+ "a3",
424
+ "Internal failure.",
425
+ "Error details: boom",
426
+ ]);
427
+ });
428
+
429
+ it("appends a marker landing before the phase is terminal instead of dropping it to the shrink guard", () => {
430
+ // The runner died without persisting a terminal: the stored phase is
431
+ // still IN_PROGRESS, and the failure lines are shorter than the
432
+ // transcript. Today the shrink guard dropped them.
433
+ const merged = runBuildStep(
434
+ executionAt(ExecutionPhase.EXECUTION_IN_PROGRESS, runnerTranscript("a1", "a2", "a3")),
435
+ {
436
+ phase: ExecutionPhase.EXECUTION_FAILED,
437
+ messages: system("Internal failure.", "Error details: boom"),
438
+ },
439
+ );
440
+ expect(contents(merged)).toEqual([
441
+ "a1",
442
+ "a2",
443
+ "a3",
444
+ "Internal failure.",
445
+ "Error details: boom",
446
+ ]);
447
+ expect(merged.status?.phase).toBe(ExecutionPhase.EXECUTION_FAILED);
448
+ });
449
+
450
+ it("is idempotent: an activity retry of the same marker appends nothing", () => {
451
+ const merged = runBuildStep(
452
+ executionAt(ExecutionPhase.EXECUTION_FAILED, [
453
+ ...runnerTranscript("a1"),
454
+ ...system("Internal failure.", "Error details: boom"),
455
+ ]),
456
+ {
457
+ phase: ExecutionPhase.EXECUTION_FAILED,
458
+ messages: system("Internal failure.", "Error details: boom"),
459
+ },
460
+ );
461
+ expect(contents(merged)).toEqual(["a1", "Internal failure.", "Error details: boom"]);
462
+ });
463
+
464
+ it("keeps an all-system write that extends the stored transcript a replacement (the runner's own growth)", () => {
465
+ const merged = runBuildStep(
466
+ executionAt(ExecutionPhase.EXECUTION_IN_PROGRESS, system("s1")),
467
+ { phase: ExecutionPhase.EXECUTION_IN_PROGRESS, messages: system("s1", "s2") },
468
+ );
469
+ expect(contents(merged)).toEqual(["s1", "s2"]);
470
+ });
471
+
472
+ it("keeps a system write onto an empty transcript a replacement", () => {
473
+ const merged = runBuildStep(
474
+ executionAt(ExecutionPhase.EXECUTION_IN_PROGRESS, []),
475
+ { phase: ExecutionPhase.EXECUTION_FAILED, messages: system("Internal failure.") },
476
+ );
477
+ expect(contents(merged)).toEqual(["Internal failure."]);
478
+ });
479
+
480
+ it("keeps the runner's full terminal write a replacement (the terminal exemption is untouched)", () => {
481
+ const merged = runBuildStep(
482
+ executionAt(ExecutionPhase.EXECUTION_FAILED, runnerTranscript("a1", "a2")),
483
+ {
484
+ phase: ExecutionPhase.EXECUTION_FAILED,
485
+ messages: [...runnerTranscript("a1-final"), ...system("Execution failed: x")],
486
+ },
487
+ );
488
+ expect(contents(merged)).toEqual(["a1-final", "Execution failed: x"]);
489
+ });
490
+ });
491
+
492
+ describe("a stop row stays last across the runner's late writes", () => {
493
+ it("re-appends the cancel row after a late runner write that extends the transcript", () => {
494
+ // Cancel's three writers race: the RPC's CANCELLED persist and the
495
+ // workflow's row landed; the runner's settle write (it learns of the
496
+ // cancel on its next heartbeat) lands last, carrying no row.
497
+ const merged = runBuildStep(
498
+ executionAt(ExecutionPhase.EXECUTION_CANCELLED, [
499
+ ...runnerTranscript("a1"),
500
+ ...system(CANCEL_STOP_ROW),
501
+ ]),
502
+ { messages: runnerTranscript("a1", "a2") },
503
+ );
504
+ expect(contents(merged)).toEqual(["a1", "a2", CANCEL_STOP_ROW]);
505
+ expect(merged.status?.phase).toBe(ExecutionPhase.EXECUTION_CANCELLED);
506
+ });
507
+
508
+ it("keeps the pause row last when the runner's settle write lands after it", () => {
509
+ const merged = runBuildStep(
510
+ executionAt(ExecutionPhase.EXECUTION_PAUSED, [
511
+ ...runnerTranscript("a1"),
512
+ ...system(PAUSE_STOP_ROW),
513
+ ]),
514
+ { messages: runnerTranscript("a1") },
515
+ );
516
+ expect(
517
+ contents(merged),
518
+ "the settle write does not trip the shrink guard on the row, and does not drop it",
519
+ ).toEqual(["a1", PAUSE_STOP_ROW]);
520
+ });
521
+
522
+ it("merges a longer settle write under the pause row", () => {
523
+ const merged = runBuildStep(
524
+ executionAt(ExecutionPhase.EXECUTION_PAUSED, [
525
+ ...runnerTranscript("a1"),
526
+ ...system(PAUSE_STOP_ROW),
527
+ ]),
528
+ { messages: runnerTranscript("a1", "a2", "a3") },
529
+ );
530
+ expect(contents(merged)).toEqual(["a1", "a2", "a3", PAUSE_STOP_ROW]);
531
+ });
532
+
533
+ it("still guards a paused transcript against a real shrink", () => {
534
+ const merged = runBuildStep(
535
+ executionAt(ExecutionPhase.EXECUTION_PAUSED, [
536
+ ...runnerTranscript("a1", "a2"),
537
+ ...system(PAUSE_STOP_ROW),
538
+ ]),
539
+ { messages: runnerTranscript("a1") },
540
+ );
541
+ expect(contents(merged)).toEqual(["a1", "a2", PAUSE_STOP_ROW]);
542
+ });
543
+
544
+ it("does not duplicate the row when a retry of the stop marker lands on it", () => {
545
+ const merged = runBuildStep(
546
+ executionAt(ExecutionPhase.EXECUTION_CANCELLED, [
547
+ ...runnerTranscript("a1"),
548
+ ...system(CANCEL_STOP_ROW),
549
+ ]),
550
+ { phase: ExecutionPhase.EXECUTION_CANCELLED, messages: system(CANCEL_STOP_ROW) },
551
+ );
552
+ expect(contents(merged)).toEqual(["a1", CANCEL_STOP_ROW]);
553
+ });
554
+
555
+ it("treats the pause row as ordinary history once a resume moved the phase on", () => {
556
+ // The resumed runner seeds the persisted transcript, row included,
557
+ // and grows it; nothing is held back or re-appended.
558
+ const merged = runBuildStep(
559
+ executionAt(ExecutionPhase.EXECUTION_IN_PROGRESS, [
560
+ ...runnerTranscript("a1"),
561
+ ...system(PAUSE_STOP_ROW),
562
+ ]),
563
+ {
564
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
565
+ messages: [
566
+ ...runnerTranscript("a1"),
567
+ ...system(PAUSE_STOP_ROW),
568
+ ...runnerTranscript("a2"),
569
+ ],
570
+ },
571
+ );
572
+ expect(contents(merged)).toEqual(["a1", PAUSE_STOP_ROW, "a2"]);
573
+ });
574
+
575
+ it("never holds a row the phase does not own (a pause row on a CANCELLED execution is history)", () => {
576
+ const merged = runBuildStep(
577
+ executionAt(ExecutionPhase.EXECUTION_CANCELLED, [
578
+ ...runnerTranscript("a1"),
579
+ ...system(PAUSE_STOP_ROW),
580
+ ]),
581
+ { messages: [...runnerTranscript("a1"), ...system(PAUSE_STOP_ROW), ...runnerTranscript("a2")] },
582
+ );
583
+ expect(contents(merged)).toEqual(["a1", PAUSE_STOP_ROW, "a2"]);
584
+ });
585
+ });
586
+ });
@@ -57,6 +57,7 @@ import {
57
57
  invalidArgumentError,
58
58
  } from "../../pipeline/errors.js";
59
59
  import { authorizeDirect } from "../../pipeline/steps/authorize.js";
60
+ import { ResourceNotFoundError } from "../../store/interface.js";
60
61
  import type { Store } from "../../store/interface.js";
61
62
  import { casBlobContentMismatch } from "./filereview/cas-blob.js";
62
63
 
@@ -254,18 +255,22 @@ export async function getArtifactContent(
254
255
  throw invalidArgumentError("storage_key does not belong to this execution");
255
256
  }
256
257
 
257
- // Existence check (Go answers NotFound for any load failure here).
258
+ // Existence check: only a missing execution is NotFound; any other store
259
+ // failure is an infrastructure fault.
258
260
  try {
259
261
  await deps.store.getResource(
260
262
  ApiResourceKind.agent_execution,
261
263
  req.executionId,
262
264
  AgentExecutionSchema,
263
265
  );
264
- } catch {
265
- throw new ConnectError(
266
- `execution not found: ${req.executionId}`,
267
- Code.NotFound,
268
- );
266
+ } catch (error) {
267
+ if (error instanceof ResourceNotFoundError) {
268
+ throw new ConnectError(
269
+ `execution not found: ${req.executionId}`,
270
+ Code.NotFound,
271
+ );
272
+ }
273
+ throw internalError(error, "failed to load agent execution");
269
274
  }
270
275
 
271
276
  let maxBytes = Number(req.maxBytes);
@@ -424,7 +429,8 @@ export async function getArtifactDownloadUrl(
424
429
  );
425
430
 
426
431
  // Load BEFORE the key check: the attachment arm needs spec.attachments,
427
- // and this doubles as the existence check.
432
+ // and this doubles as the existence check (a missing execution is
433
+ // NotFound; any other store failure is an infrastructure fault).
428
434
  let execution: AgentExecution;
429
435
  try {
430
436
  execution = await deps.store.getResource(
@@ -432,11 +438,14 @@ export async function getArtifactDownloadUrl(
432
438
  req.executionId,
433
439
  AgentExecutionSchema,
434
440
  );
435
- } catch {
436
- throw new ConnectError(
437
- `execution not found: ${req.executionId}`,
438
- Code.NotFound,
439
- );
441
+ } catch (error) {
442
+ if (error instanceof ResourceNotFoundError) {
443
+ throw new ConnectError(
444
+ `execution not found: ${req.executionId}`,
445
+ Code.NotFound,
446
+ );
447
+ }
448
+ throw internalError(error, "failed to load agent execution");
440
449
  }
441
450
 
442
451
  // Same normalize-before-check posture as getArtifactContent
@@ -135,9 +135,11 @@ function newLoadExecutionByIdStep<Desc extends DescMessage>(
135
135
  executionId,
136
136
  AgentExecutionSchema,
137
137
  );
138
- } catch {
139
- // Go converts every load failure here to the same NotFound.
140
- throw notFoundError("agent_execution", executionId);
138
+ } catch (error) {
139
+ if (error instanceof ResourceNotFoundError) {
140
+ throw notFoundError("agent_execution", executionId);
141
+ }
142
+ throw internalError(error, "failed to load agent execution");
141
143
  }
142
144
  ctx.set(LOADED_EXECUTION_KEY, execution);
143
145
  },