@stigmer/server 3.37.0 → 3.37.1

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 (73) hide show
  1. package/dist/domain/agentexecution/artifacts.d.ts.map +1 -1
  2. package/dist/domain/agentexecution/artifacts.js +15 -6
  3. package/dist/domain/agentexecution/artifacts.js.map +1 -1
  4. package/dist/domain/agentexecution/lifecycle.d.ts.map +1 -1
  5. package/dist/domain/agentexecution/lifecycle.js +5 -3
  6. package/dist/domain/agentexecution/lifecycle.js.map +1 -1
  7. package/dist/domain/agentexecution/platform-rows.d.ts +63 -0
  8. package/dist/domain/agentexecution/platform-rows.d.ts.map +1 -0
  9. package/dist/domain/agentexecution/platform-rows.js +106 -0
  10. package/dist/domain/agentexecution/platform-rows.js.map +1 -0
  11. package/dist/domain/agentexecution/subscribe.d.ts.map +1 -1
  12. package/dist/domain/agentexecution/subscribe.js +7 -4
  13. package/dist/domain/agentexecution/subscribe.js.map +1 -1
  14. package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
  15. package/dist/domain/agentexecution/update-status.js +48 -17
  16. package/dist/domain/agentexecution/update-status.js.map +1 -1
  17. package/dist/domain/workflowexecution/create-steps.d.ts.map +1 -1
  18. package/dist/domain/workflowexecution/create-steps.js +6 -2
  19. package/dist/domain/workflowexecution/create-steps.js.map +1 -1
  20. package/dist/domain/workflowexecution/lifecycle.d.ts.map +1 -1
  21. package/dist/domain/workflowexecution/lifecycle.js +5 -2
  22. package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
  23. package/dist/domain/workflowexecution/send-signal.d.ts.map +1 -1
  24. package/dist/domain/workflowexecution/send-signal.js +6 -3
  25. package/dist/domain/workflowexecution/send-signal.js.map +1 -1
  26. package/dist/domain/workflowexecution/submit-approval.d.ts.map +1 -1
  27. package/dist/domain/workflowexecution/submit-approval.js +6 -2
  28. package/dist/domain/workflowexecution/submit-approval.js.map +1 -1
  29. package/dist/domain/workflowexecution/submit-file-decision.d.ts.map +1 -1
  30. package/dist/domain/workflowexecution/submit-file-decision.js +6 -2
  31. package/dist/domain/workflowexecution/submit-file-decision.js.map +1 -1
  32. package/dist/domain/workflowexecution/submit-workflow-task-approval.d.ts.map +1 -1
  33. package/dist/domain/workflowexecution/submit-workflow-task-approval.js +6 -2
  34. package/dist/domain/workflowexecution/submit-workflow-task-approval.js.map +1 -1
  35. package/dist/domain/workflowexecution/subscribe-events.d.ts.map +1 -1
  36. package/dist/domain/workflowexecution/subscribe-events.js +6 -2
  37. package/dist/domain/workflowexecution/subscribe-events.js.map +1 -1
  38. package/dist/domain/workflowexecution/subscribe.d.ts.map +1 -1
  39. package/dist/domain/workflowexecution/subscribe.js +7 -4
  40. package/dist/domain/workflowexecution/subscribe.js.map +1 -1
  41. package/dist/temporal/agentexecution/runner-result.d.ts +12 -0
  42. package/dist/temporal/agentexecution/runner-result.d.ts.map +1 -1
  43. package/dist/temporal/agentexecution/runner-result.js +15 -0
  44. package/dist/temporal/agentexecution/runner-result.js.map +1 -1
  45. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.d.ts.map +1 -1
  46. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js +44 -22
  47. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js.map +1 -1
  48. package/package.json +6 -6
  49. package/src/domain/agentexecution/__tests__/store-faults.test.ts +315 -0
  50. package/src/domain/agentexecution/__tests__/update-status.test.ts +217 -0
  51. package/src/domain/agentexecution/artifacts.ts +21 -12
  52. package/src/domain/agentexecution/lifecycle.ts +5 -3
  53. package/src/domain/agentexecution/platform-rows.ts +133 -0
  54. package/src/domain/agentexecution/subscribe.ts +7 -3
  55. package/src/domain/agentexecution/update-status.ts +70 -19
  56. package/src/domain/workflowexecution/__tests__/store-faults.test.ts +418 -0
  57. package/src/domain/workflowexecution/create-steps.ts +6 -2
  58. package/src/domain/workflowexecution/lifecycle.ts +5 -2
  59. package/src/domain/workflowexecution/send-signal.ts +11 -4
  60. package/src/domain/workflowexecution/submit-approval.ts +6 -2
  61. package/src/domain/workflowexecution/submit-file-decision.ts +6 -2
  62. package/src/domain/workflowexecution/submit-workflow-task-approval.ts +6 -2
  63. package/src/domain/workflowexecution/subscribe-events.ts +6 -2
  64. package/src/domain/workflowexecution/subscribe.ts +11 -4
  65. package/src/pipeline/__tests__/blind-not-found.test.ts +297 -0
  66. package/src/temporal/agentexecution/__tests__/invoke-workflow.test.ts +51 -1
  67. package/src/temporal/agentexecution/__tests__/own-behalf-status-writes.test.ts +149 -9
  68. package/src/temporal/agentexecution/__tests__/replay-histories/runner-failed.json +757 -0
  69. package/src/temporal/agentexecution/__tests__/replay-histories/user-cancel.json +755 -0
  70. package/src/temporal/agentexecution/__tests__/replay.test.ts +4 -2
  71. package/src/temporal/agentexecution/runner-result.ts +16 -0
  72. package/src/temporal/agentexecution/workflows/invoke-agent-execution.ts +49 -22
  73. package/src/domain/agentexecution/__tests__/execution-usage-report.test.ts +0 -78
@@ -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
  },
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Platform rows: the MESSAGE_SYSTEM lines the server itself writes into
3
+ * an execution's transcript, and the transcript rules that keep them.
4
+ * The runner owns the transcript; these are the few rows only the
5
+ * platform can write, because only the platform knows the fact they
6
+ * state (stigmer#980).
7
+ *
8
+ * - Stop rows say why a run stopped when the stop came from outside the
9
+ * turn. The runner cannot tell a Pause from a Cancel: both reach its
10
+ * activity as the same Temporal cancellation (reason CANCELLED), and
11
+ * both RPCs signal the engine before they persist the phase
12
+ * (lifecycle.ts), so no stored fact distinguishes them in time. The
13
+ * invoke workflow knows which stop it is running, so it writes the row
14
+ * (workflows/invoke-agent-execution.ts) and the runner writes none.
15
+ * - A platform marker write is a status update whose messages are all
16
+ * MESSAGE_SYSTEM and do not extend the stored transcript: the
17
+ * workflow's stop rows and its broken-flow failure lines. A marker is
18
+ * appended, never substituted for the transcript. Every runner write
19
+ * is a superset of the stored transcript (a resumed runner seeds from
20
+ * it, runner/src/harness/turn-context.ts) and the runner only ever
21
+ * appends system rows, so a runner write never reads as a marker.
22
+ *
23
+ * Messages carry no id (agentexecution/v1/message.proto), so rows are
24
+ * identified by type and content: the stop copy is these exact
25
+ * constants, defined once here for the workflow and the merge, and never
26
+ * matched against copy some other component authors.
27
+ *
28
+ * Pure (protos only): the workflow bundle imports this module by direct
29
+ * path (src/temporal/README.md, workflow-bundle import discipline).
30
+ */
31
+ import {
32
+ ExecutionPhase,
33
+ MessageType,
34
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
35
+ import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
36
+
37
+ /** The stop row of a paused execution. Byte-identical to the copy the runner wrote before stigmer#980 moved it here. */
38
+ export const PAUSE_STOP_ROW =
39
+ "Execution paused by user. Use resume to continue.";
40
+
41
+ /** The stop row of a cancelled execution: a quiet terminal state, never a failure (stigmer#282). */
42
+ export const CANCEL_STOP_ROW = "Execution was cancelled.";
43
+
44
+ /** The stop row a phase carries, or undefined for a phase the platform does not explain. */
45
+ export function stopRowForPhase(phase: ExecutionPhase): string | undefined {
46
+ switch (phase) {
47
+ case ExecutionPhase.EXECUTION_PAUSED:
48
+ return PAUSE_STOP_ROW;
49
+ case ExecutionPhase.EXECUTION_CANCELLED:
50
+ return CANCEL_STOP_ROW;
51
+ default:
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** Whether two rows are the same row: messages carry no id, so identity is type and content. */
57
+ function sameRow(a: AgentMessage, b: AgentMessage): boolean {
58
+ return a.type === b.type && a.content === b.content;
59
+ }
60
+
61
+ /** Whether transcript ends with tail, row for row. */
62
+ export function endsWithRows(
63
+ transcript: readonly AgentMessage[],
64
+ tail: readonly AgentMessage[],
65
+ ): boolean {
66
+ if (tail.length > transcript.length) {
67
+ return false;
68
+ }
69
+ const offset = transcript.length - tail.length;
70
+ return tail.every((row, i) => sameRow(transcript[offset + i], row));
71
+ }
72
+
73
+ /**
74
+ * Whether incoming is a platform marker write against existing: every
75
+ * incoming row is MESSAGE_SYSTEM, and it does not extend a non-empty
76
+ * stored transcript (existing is not a row-for-row prefix of incoming).
77
+ * An all-system write that does extend it is the runner's own growth; a
78
+ * write onto an empty transcript has nothing to preserve.
79
+ */
80
+ export function isPlatformMarkerWrite(
81
+ existing: readonly AgentMessage[],
82
+ incoming: readonly AgentMessage[],
83
+ ): boolean {
84
+ if (existing.length === 0 || incoming.length === 0) {
85
+ return false;
86
+ }
87
+ if (!incoming.every((row) => row.type === MessageType.MESSAGE_SYSTEM)) {
88
+ return false;
89
+ }
90
+ const extendsExisting =
91
+ incoming.length >= existing.length &&
92
+ existing.every((row, i) => sameRow(row, incoming[i]));
93
+ return !extendsExisting;
94
+ }
95
+
96
+ /**
97
+ * Appends a marker write's rows to the transcript; a retry of a marker
98
+ * already at the transcript's end appends nothing, so the workflow's
99
+ * activity retries stay idempotent.
100
+ */
101
+ export function appendPlatformMarker(
102
+ existing: readonly AgentMessage[],
103
+ marker: readonly AgentMessage[],
104
+ ): AgentMessage[] {
105
+ if (endsWithRows(existing, marker)) {
106
+ return [...existing];
107
+ }
108
+ return [...existing, ...marker];
109
+ }
110
+
111
+ /**
112
+ * The stop row the merge holds last: the stored transcript's final row
113
+ * when it is the stored phase's own stop row. While the execution rests
114
+ * in that phase, the row is the platform's, not part of what the runner's
115
+ * later writes replace; once the phase moves on (a resume), it is
116
+ * ordinary history and nothing is held.
117
+ */
118
+ export function heldStopRow(
119
+ phase: ExecutionPhase,
120
+ transcript: readonly AgentMessage[],
121
+ ): AgentMessage | undefined {
122
+ const copy = stopRowForPhase(phase);
123
+ const last = transcript[transcript.length - 1];
124
+ if (
125
+ copy === undefined ||
126
+ last === undefined ||
127
+ last.type !== MessageType.MESSAGE_SYSTEM ||
128
+ last.content !== copy
129
+ ) {
130
+ return undefined;
131
+ }
132
+ return last;
133
+ }
@@ -46,10 +46,12 @@ import type { Logger } from "../../boot/logger.js";
46
46
  import type { Authorizer } from "../../extensions/authorizer.js";
47
47
  import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
48
48
  import {
49
+ internalError,
49
50
  invalidArgumentError,
50
51
  notFoundError,
51
52
  } from "../../pipeline/errors.js";
52
53
  import { authorizeDirect } from "../../pipeline/steps/authorize.js";
54
+ import { ResourceNotFoundError } from "../../store/interface.js";
53
55
  import type { Store } from "../../store/interface.js";
54
56
 
55
57
  import { isTranscriptTerminalPhase } from "./phases.js";
@@ -97,9 +99,11 @@ export async function* subscribeExecution(
97
99
  id,
98
100
  AgentExecutionSchema,
99
101
  );
100
- } catch {
101
- // Go converts every load failure here to the same NotFound.
102
- throw notFoundError("AgentExecution", id);
102
+ } catch (error) {
103
+ if (error instanceof ResourceNotFoundError) {
104
+ throw notFoundError("AgentExecution", id);
105
+ }
106
+ throw internalError(error, "failed to load agent execution");
103
107
  }
104
108
 
105
109
  yield snapshot;
@@ -2,8 +2,8 @@
2
2
  * UpdateStatus — ports controller/update_status.go: the runner's
3
3
  * progressive status-update RPC and the domain's single merge chokepoint.
4
4
  *
5
- * Chain per Go: ValidateUpdateStatusInput → MergeAndPersistExecution →
6
- * NotifyStatusObservers → BroadcastToStreams. The merge+persist is ONE
5
+ * Chain: Authorize → ValidateUpdateStatusInput → MergeAndPersistExecution
6
+ * → NotifyStatusObservers → BroadcastToStreams. The merge+persist is ONE
7
7
  * atomic read-modify-write under the store's per-resource write lock
8
8
  * (store.updateResource) — the same discipline SubmitApproval uses,
9
9
  * load-bearing now that the append-only approval_event_stream is the
@@ -24,10 +24,14 @@
24
24
  * wholesale with the runner's latest complete state, while server-owned
25
25
  * fields (approval decisions, the approval_event_stream, the file_review
26
26
  * ledger) are preserved/authored here because the runner never sends
27
- * them.
27
+ * them. The transcript's platform rows are the same kind of server-owned
28
+ * state (platform-rows.ts, stigmer#980): a platform marker write is
29
+ * appended and never replaces the transcript, and a PAUSED or CANCELLED
30
+ * execution's stop row stays last across the runner's later writes,
31
+ * because the Pause and Cancel RPCs, the invoke workflow and the runner
32
+ * all write a stopped execution and nothing orders them.
28
33
  *
29
- * Versus Stigmer Cloud, OSS excludes the Authorize, PublishToRedis, and
30
- * Publish steps (broadcast rides in-memory channels per ADR 011).
34
+ * Broadcast rides in-memory channels (ADR 011).
31
35
  */
32
36
  import { create } from "@bufbuild/protobuf";
33
37
 
@@ -78,6 +82,12 @@ import {
78
82
  isTerminalExecutionPhase,
79
83
  isTranscriptTerminalPhase,
80
84
  } from "./phases.js";
85
+ import {
86
+ appendPlatformMarker,
87
+ endsWithRows,
88
+ heldStopRow,
89
+ isPlatformMarkerWrite,
90
+ } from "./platform-rows.js";
81
91
  import {
82
92
  applyResponseDecorators,
83
93
  notifyStatusObservers,
@@ -257,6 +267,54 @@ export function nonTerminalTranscriptRegression(
257
267
  return { reject: false, reason: "" };
258
268
  }
259
269
 
270
+ /**
271
+ * The transcript the merge persists, or undefined to keep the stored one.
272
+ * The stored phase's stop row, when it closes the transcript, is held
273
+ * aside so the runner's settle write (which never carries it) is judged
274
+ * against the transcript beneath it, and re-appended after. A platform
275
+ * marker write is appended; anything else is the runner's write, replaced
276
+ * wholesale unless the regression guard rejects it.
277
+ */
278
+ function mergeTranscript(
279
+ existingPhase: ExecutionPhase,
280
+ existing: AgentMessage[],
281
+ incoming: AgentMessage[],
282
+ executionId: string,
283
+ logger: Logger,
284
+ ): AgentMessage[] | undefined {
285
+ const held = heldStopRow(existingPhase, existing);
286
+ const base = held === undefined ? existing : existing.slice(0, -1);
287
+
288
+ let next: AgentMessage[];
289
+ if (isPlatformMarkerWrite(base, incoming)) {
290
+ next = appendPlatformMarker(base, incoming);
291
+ } else {
292
+ const { reject, reason } = nonTerminalTranscriptRegression(
293
+ existingPhase,
294
+ base,
295
+ incoming,
296
+ );
297
+ if (reject) {
298
+ logger.warn(
299
+ "Rejected status update that would drop committed transcript history for a non-terminal execution; keeping existing messages",
300
+ {
301
+ executionId,
302
+ existingMessages: existing.length,
303
+ incomingMessages: incoming.length,
304
+ reason,
305
+ },
306
+ );
307
+ return undefined;
308
+ }
309
+ next = incoming;
310
+ }
311
+
312
+ if (held !== undefined && !endsWithRows(next, [held])) {
313
+ next = [...next, held];
314
+ }
315
+ return next;
316
+ }
317
+
260
318
  /**
261
319
  * Merges an incoming status update into the execution in place — the
262
320
  * runner-owns-the-transcript merge rules, exactly update_status.go
@@ -294,7 +352,8 @@ export function applyUpdateStatusMerge(
294
352
  // any update that would drop committed transcript history for a
295
353
  // non-terminal execution. The runner owns the transcript and only ever
296
354
  // GROWS it in flight; two regressions are rejected at this single
297
- // persistence chokepoint:
355
+ // persistence chokepoint (the platform's own rows are handled first,
356
+ // see mergeTranscript):
298
357
  // 1. A strictly SHORTER transcript — the classic partial write.
299
358
  // 2. A transcript that DROPS a committed tool-call id while appending
300
359
  // enough later turns to keep the count equal-or-greater
@@ -305,23 +364,15 @@ export function applyUpdateStatusMerge(
305
364
  // post-denial narration redaction), so a content check would reject
306
365
  // valid writes.
307
366
  if (requestStatus.messages.length > 0) {
308
- const { reject, reason } = nonTerminalTranscriptRegression(
367
+ const merged = mergeTranscript(
309
368
  existingPhase,
310
369
  existingMessages,
311
370
  requestStatus.messages,
371
+ input.executionId,
372
+ logger,
312
373
  );
313
- if (reject) {
314
- logger.warn(
315
- "Rejected status update that would drop committed transcript history for a non-terminal execution; keeping existing messages",
316
- {
317
- executionId: input.executionId,
318
- existingMessages: existingMessages.length,
319
- incomingMessages: requestStatus.messages.length,
320
- reason,
321
- },
322
- );
323
- } else {
324
- status.messages = requestStatus.messages;
374
+ if (merged !== undefined) {
375
+ status.messages = merged;
325
376
  }
326
377
  }
327
378