@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
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Holds the error contract's store-fault rule structurally: no error handler
3
+ * in the server answers NotFound without looking at the error it caught.
4
+ * Only a typed not-found (`ResourceNotFoundError` and its kin) means the
5
+ * record is missing; any other failure is an infrastructure fault answered
6
+ * through `internalError` (`src/pipeline/errors.ts`), because a client told
7
+ * "not found" may discard state that still exists
8
+ * (`.agents/skills/ts-server-dev-guidelines/SKILL.md`, "Errors").
9
+ *
10
+ * A *blind NotFound* is a `throw` of a NotFound that is a direct statement of
11
+ * a `catch` block or of a `.catch(...)` callback — not nested under any
12
+ * condition. That is the defect's exact shape: a discriminating handler throws
13
+ * its NotFound under `if (error instanceof ...)` or a code check, so it passes
14
+ * whatever error class it tests, and the guard needs no list of them. A throw
15
+ * constructs a NotFound when it is `new ConnectError(..., Code.NotFound)` or
16
+ * a call to a NotFound constructor; the constructors are derived from the
17
+ * source (`notFoundError`, plus every function whose body is a single
18
+ * `return` of a NotFound construction, to a fixed point), so a domain's own
19
+ * wrapper cannot hide a blind site, and a discriminating mapper is not one.
20
+ *
21
+ * The scan parses each module with the TypeScript compiler API rather than a
22
+ * regular expression, because a catch block's extent (nested braces, a log
23
+ * line before the throw) is a syntax question. `PENDING` is the list of known
24
+ * blind sites still to be fixed; the scan must equal it exactly, so a new
25
+ * blind site fails here, and so does a fixed site still listed.
26
+ */
27
+ import { readFileSync, readdirSync } from "node:fs";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ import ts from "typescript";
32
+ import { describe, expect, it } from "vitest";
33
+
34
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
35
+ const SRC = path.resolve(HERE, "../..");
36
+
37
+ /** Known blind sites by module (relative to `src`), with their count. */
38
+ const PENDING: ReadonlyMap<string, number> = new Map([
39
+ // Store loads, stigmer/stigmer#1345 (fixed domain by domain).
40
+ ["domain/agent/controller.ts", 1],
41
+ ["domain/agentchannel/controller.ts", 1],
42
+ ["domain/agentinstance/controller.ts", 1],
43
+ ["domain/agentshare/controller.ts", 1],
44
+ ["domain/artifact/controller.ts", 1],
45
+ ["domain/environment/controller.ts", 1],
46
+ ["domain/environment/steps.ts", 1],
47
+ ["domain/mcpserver/complete-oauth-connect.ts", 1],
48
+ ["domain/mcpserver/connect.ts", 1],
49
+ ["domain/mcpserver/controller.ts", 1],
50
+ ["domain/mcpserver/initiate-oauth-connect.ts", 1],
51
+ ["domain/mcpserver/start-connect.ts", 2],
52
+ ["domain/plugin/controller.ts", 1],
53
+ ["domain/skill/controller.ts", 1],
54
+ ["domain/workflow/controller.ts", 1],
55
+ ["domain/workflowinstance/controller.ts", 1],
56
+ // Parent loads through the in-process chain that fold every RPC error,
57
+ // validation included, stigmer/stigmer#1351.
58
+ ["domain/agentinstance/steps.ts", 1],
59
+ ["domain/workflowinstance/steps.ts", 1],
60
+ ]);
61
+
62
+ interface BlindSite {
63
+ readonly module: string;
64
+ readonly line: number;
65
+ }
66
+
67
+ function isCodeNotFound(node: ts.Expression): boolean {
68
+ return (
69
+ ts.isPropertyAccessExpression(node) &&
70
+ ts.isIdentifier(node.expression) &&
71
+ node.expression.text === "Code" &&
72
+ node.name.text === "NotFound"
73
+ );
74
+ }
75
+
76
+ function isNotFoundConstruction(
77
+ node: ts.Expression | undefined,
78
+ constructors: ReadonlySet<string>,
79
+ ): boolean {
80
+ if (node === undefined) {
81
+ return false;
82
+ }
83
+ if (ts.isCallExpression(node)) {
84
+ return (
85
+ ts.isIdentifier(node.expression) && constructors.has(node.expression.text)
86
+ );
87
+ }
88
+ if (ts.isNewExpression(node)) {
89
+ return (
90
+ ts.isIdentifier(node.expression) &&
91
+ node.expression.text === "ConnectError" &&
92
+ (node.arguments ?? []).some(isCodeNotFound)
93
+ );
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function forEachNode(node: ts.Node, visit: (node: ts.Node) => void): void {
99
+ visit(node);
100
+ ts.forEachChild(node, (child) => forEachNode(child, visit));
101
+ }
102
+
103
+ /** `notFoundError` plus every single-`return` wrapper of a NotFound. */
104
+ function notFoundConstructors(
105
+ sources: readonly ts.SourceFile[],
106
+ ): ReadonlySet<string> {
107
+ const constructors = new Set(["notFoundError"]);
108
+ let grew = true;
109
+ while (grew) {
110
+ grew = false;
111
+ for (const source of sources) {
112
+ forEachNode(source, (node) => {
113
+ if (
114
+ !ts.isFunctionDeclaration(node) ||
115
+ node.name === undefined ||
116
+ node.body === undefined ||
117
+ node.body.statements.length !== 1 ||
118
+ constructors.has(node.name.text)
119
+ ) {
120
+ return;
121
+ }
122
+ const only = node.body.statements[0];
123
+ if (
124
+ only !== undefined &&
125
+ ts.isReturnStatement(only) &&
126
+ isNotFoundConstruction(only.expression, constructors)
127
+ ) {
128
+ constructors.add(node.name.text);
129
+ grew = true;
130
+ }
131
+ });
132
+ }
133
+ }
134
+ return constructors;
135
+ }
136
+
137
+ /** The handler block of a `.catch(fn)` call, when `fn` has a block body. */
138
+ function catchCallbackBlock(node: ts.Node): ts.Block | undefined {
139
+ if (
140
+ !ts.isCallExpression(node) ||
141
+ !ts.isPropertyAccessExpression(node.expression) ||
142
+ node.expression.name.text !== "catch"
143
+ ) {
144
+ return undefined;
145
+ }
146
+ const handler = node.arguments[0];
147
+ if (
148
+ handler !== undefined &&
149
+ (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) &&
150
+ ts.isBlock(handler.body)
151
+ ) {
152
+ return handler.body;
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ function blindSites(
158
+ source: ts.SourceFile,
159
+ module: string,
160
+ constructors: ReadonlySet<string>,
161
+ ): BlindSite[] {
162
+ const sites: BlindSite[] = [];
163
+ forEachNode(source, (node) => {
164
+ const block = ts.isCatchClause(node)
165
+ ? node.block
166
+ : catchCallbackBlock(node);
167
+ if (block === undefined) {
168
+ return;
169
+ }
170
+ for (const statement of block.statements) {
171
+ if (
172
+ ts.isThrowStatement(statement) &&
173
+ isNotFoundConstruction(statement.expression, constructors)
174
+ ) {
175
+ const { line } = source.getLineAndCharacterOfPosition(
176
+ statement.getStart(source),
177
+ );
178
+ sites.push({ module, line: line + 1 });
179
+ }
180
+ }
181
+ });
182
+ return sites;
183
+ }
184
+
185
+ function parse(fileName: string, text: string): ts.SourceFile {
186
+ return ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
187
+ }
188
+
189
+ /** Every non-test module under `src`, parsed. */
190
+ function serverSources(): ts.SourceFile[] {
191
+ const sources: ts.SourceFile[] = [];
192
+ const walk = (dir: string): void => {
193
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
194
+ const full = path.join(dir, entry.name);
195
+ if (entry.isDirectory()) {
196
+ if (entry.name !== "__tests__") {
197
+ walk(full);
198
+ }
199
+ } else if (
200
+ entry.name.endsWith(".ts") &&
201
+ !entry.name.endsWith(".d.ts") &&
202
+ !entry.name.endsWith(".test.ts")
203
+ ) {
204
+ sources.push(parse(full, readFileSync(full, "utf8")));
205
+ }
206
+ }
207
+ };
208
+ walk(SRC);
209
+ return sources;
210
+ }
211
+
212
+ /** The rule applied to one inline module, for the rule's own cases. */
213
+ function blindLinesOf(text: string): number[] {
214
+ const source = parse("fixture.ts", text);
215
+ return blindSites(source, "fixture.ts", notFoundConstructors([source])).map(
216
+ (site) => site.line,
217
+ );
218
+ }
219
+
220
+ describe("the blind-NotFound rule", () => {
221
+ it("flags an unconditional NotFound in a catch block, bound or not", () => {
222
+ expect(
223
+ blindLinesOf(`
224
+ try { load(); } catch { throw notFoundError("agent", id); }
225
+ try { load(); } catch (error) {
226
+ log(error);
227
+ throw new ConnectError(copy(id), Code.NotFound);
228
+ }
229
+ `),
230
+ ).toEqual([2, 5]);
231
+ });
232
+
233
+ it("flags a NotFound built by a derived wrapper and one in a .catch callback", () => {
234
+ expect(
235
+ blindLinesOf(`
236
+ function missing(id: string) { return notFoundError("agent", id); }
237
+ function missingAgain(id: string) { return missing(id); }
238
+ try { load(); } catch { throw missingAgain(id); }
239
+ load().catch(() => { throw missing(id); });
240
+ `),
241
+ ).toEqual([4, 5]);
242
+ });
243
+
244
+ it("passes a NotFound thrown only under a test of the error", () => {
245
+ expect(
246
+ blindLinesOf(`
247
+ try { load(); } catch (error) {
248
+ if (error instanceof ResourceNotFoundError) {
249
+ throw notFoundError("agent", id);
250
+ }
251
+ throw internalError(error, "failed to load agent");
252
+ }
253
+ `),
254
+ ).toEqual([]);
255
+ });
256
+
257
+ it("does not treat a discriminating mapper as a constructor", () => {
258
+ expect(
259
+ blindLinesOf(`
260
+ function mapNotFound(error: unknown, id: string): unknown {
261
+ if (error instanceof WorkflowNotFoundError) {
262
+ return notFoundError("workflow", id);
263
+ }
264
+ return error;
265
+ }
266
+ try { load(); } catch (error) { throw mapNotFound(error, id); }
267
+ `),
268
+ ).toEqual([]);
269
+ });
270
+ });
271
+
272
+ describe("the server source", () => {
273
+ it("has no blind NotFound beyond the pending list, and the list names only live sites", () => {
274
+ const sources = serverSources();
275
+ const constructors = notFoundConstructors(sources);
276
+ const found = new Map<string, number[]>();
277
+ for (const source of sources) {
278
+ const module = path
279
+ .relative(SRC, source.fileName)
280
+ .split(path.sep)
281
+ .join("/");
282
+ for (const site of blindSites(source, module, constructors)) {
283
+ found.set(module, [...(found.get(module) ?? []), site.line]);
284
+ }
285
+ }
286
+
287
+ const counts = Object.fromEntries(
288
+ [...found].map(([module, lines]) => [module, lines.length]),
289
+ );
290
+ const where = [...found]
291
+ .flatMap(([module, lines]) => lines.map((line) => `${module}:${line}`))
292
+ .join("\n");
293
+ expect(counts, `blind NotFound sites found:\n${where}`).toEqual(
294
+ Object.fromEntries(PENDING),
295
+ );
296
+ });
297
+ });
@@ -16,6 +16,10 @@
16
16
  * - bounded auto-recovery on worker-shutdown shapes (#776: IN_PROGRESS
17
17
  * persisted, honest status copy on exhaustion);
18
18
  * - EXECUTION_FAILED propagation with the fallback persist;
19
+ * - the stop and failure copy each has one writer (stigmer#980): the
20
+ * pause persist carries the pause row, a runner-reported failure's
21
+ * write carries no lines (the runner explained it), and only a broken
22
+ * flow gets the platform's two lines;
19
23
  * - external cancellation cleanup (CANCELLED persisted quietly —
20
24
  * stigmer#282 — and the EC deleted);
21
25
  * - callback-token completion on success AND failure (the DD-001 lane,
@@ -50,9 +54,15 @@ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/ag
50
54
  import {
51
55
  ExecutionPhase,
52
56
  FileChangeSetStatus,
57
+ MessageType,
53
58
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
54
59
  import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
55
60
 
61
+ import {
62
+ CANCEL_STOP_ROW,
63
+ PAUSE_STOP_ROW,
64
+ } from "../../../domain/agentexecution/platform-rows.js";
65
+
56
66
  import {
57
67
  INVOKE_AGENT_EXECUTION_WORKFLOW_NAME,
58
68
  MEMO_ACTIVITY_TASK_QUEUE,
@@ -565,6 +575,12 @@ describe("invoke-agent-execution workflow (TestWorkflowEnvironment)", () => {
565
575
  .includes(ExecutionPhase.EXECUTION_IN_PROGRESS),
566
576
  "resume re-asserts IN_PROGRESS after the PAUSED persist",
567
577
  ).toBe(true);
578
+ // The workflow is the pause row's one writer: the runner cannot tell
579
+ // this stop from a cancel, so it settles without a row.
580
+ expect(
581
+ script.persistedStatuses[pausedAt]!.status.messages.map((m) => [m.type, m.content]),
582
+ "the PAUSED persist carries the pause row",
583
+ ).toEqual([[MessageType.MESSAGE_SYSTEM, PAUSE_STOP_ROW]]);
568
584
  }, 60_000);
569
585
 
570
586
  it("auto-recovers a worker-shutdown interruption and persists IN_PROGRESS (#776)", async (testCtx) => { if (!envReady) return testCtx.skip();
@@ -611,6 +627,40 @@ describe("invoke-agent-execution workflow (TestWorkflowEnvironment)", () => {
611
627
  );
612
628
  expect(failed.length).toBeGreaterThan(0);
613
629
  expect(failed[0]!.status.error).toBe("agent blew up");
630
+ // The runner already explained its own failure in the transcript, so
631
+ // no FAILED write of the workflow adds a second explanation.
632
+ expect(
633
+ failed.flatMap((entry) => entry.status.messages),
634
+ "a runner-reported failure adds no platform lines",
635
+ ).toEqual([]);
636
+ expectExecutionContextDeleted();
637
+ }, 30_000);
638
+
639
+ it("explains a flow that broke without a runner verdict with the platform's two lines", async (testCtx) => { if (!envReady) return testCtx.skip();
640
+ const { ApplicationFailure } = await import("@temporalio/common");
641
+ script.executeBehaviors = [
642
+ async () => {
643
+ // Not recoverable (not a cancellation, heartbeat timeout or worker
644
+ // shutdown), so the flow breaks with no FAILED result to read.
645
+ throw ApplicationFailure.create({ message: "runner crashed", nonRetryable: true });
646
+ },
647
+ ];
648
+
649
+ const handle = await startWorkflow(workflowInput());
650
+ await expect(handle.result()).rejects.toThrow(/Workflow execution failed/);
651
+
652
+ const failed = script.persistedStatuses.filter(
653
+ (entry) => entry.status.phase === ExecutionPhase.EXECUTION_FAILED,
654
+ );
655
+ expect(failed.length).toBeGreaterThan(0);
656
+ const lines = failed.at(-1)!.status.messages;
657
+ expect(lines.map((m) => m.type)).toEqual([
658
+ MessageType.MESSAGE_SYSTEM,
659
+ MessageType.MESSAGE_SYSTEM,
660
+ ]);
661
+ // The details carry the wrapped activity error (wrapActivityError's
662
+ // operator-actionable prefix), not a runner-authored sentence.
663
+ expect(lines[1]!.content).toMatch(/^Error details: activity 'ExecuteDeepAgent'/);
614
664
  expectExecutionContextDeleted();
615
665
  }, 30_000);
616
666
 
@@ -638,7 +688,7 @@ describe("invoke-agent-execution workflow (TestWorkflowEnvironment)", () => {
638
688
  // system message is the durable marker.
639
689
  expect(cancelled.status.error).toBe("");
640
690
  expect(cancelled.status.messages.map((message) => message.content)).toEqual([
641
- "Execution was cancelled.",
691
+ CANCEL_STOP_ROW,
642
692
  ]);
643
693
  expectExecutionContextDeleted();
644
694
  }, 60_000);
@@ -20,19 +20,24 @@
20
20
  * execution status` from authorize.ts — the dark production composition's
21
21
  * exact log line.
22
22
  *
23
- * The three payload shapes are the workflow's real ones
23
+ * The payload shapes are the workflow's real ones
24
24
  * (invoke-agent-execution.ts): updateStatusOnFailure's FAILED with two
25
- * MESSAGE_SYSTEM lines, updateStatusOnCancellation's CANCELLED with one,
25
+ * MESSAGE_SYSTEM lines (a flow that broke) or none (a runner-reported
26
+ * failure), updateStatusOnCancellation's CANCELLED with its stop row,
26
27
  * and the phase-only persistFinalStatus / persistInterruptedStatus /
27
- * persistResumedStatus shape. Transcript shape after a late fallback is
28
- * deliberately NOT asserted here — stigmer#980 owns that cross-edition
29
- * behavior.
28
+ * persistResumedStatus shape.
29
+ *
30
+ * The last arms pin the transcript a stopped execution keeps over this
31
+ * production lane (stigmer#980): the platform's lines are appended to the
32
+ * runner's transcript, never substituted for it, and the cancel row stays
33
+ * last when the runner's settle write lands after it. The merge's full
34
+ * case matrix is domain/agentexecution/__tests__/update-status.test.ts.
30
35
  */
31
36
  import { mkdtempSync, rmSync } from "node:fs";
32
37
  import { tmpdir } from "node:os";
33
38
  import path from "node:path";
34
39
  import { create, toJson } from "@bufbuild/protobuf";
35
- import type { JsonValue } from "@bufbuild/protobuf";
40
+ import type { JsonValue, MessageInitShape } from "@bufbuild/protobuf";
36
41
  import { createClient } from "@connectrpc/connect";
37
42
  import type { Client } from "@temporalio/client";
38
43
  import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
@@ -46,9 +51,12 @@ import {
46
51
  ExecutionPhase,
47
52
  MessageType,
48
53
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
54
+ import { AgentExecutionUpdateStatusInputSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
55
+ import type { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
49
56
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
50
57
 
51
58
  import { loadConfig } from "../../../boot/config.js";
59
+ import { CANCEL_STOP_ROW } from "../../../domain/agentexecution/platform-rows.js";
52
60
  import { composeServer } from "../../../boot/compose.js";
53
61
  import type { ComposedServer } from "../../../boot/compose.js";
54
62
  import { createLogger } from "../../../boot/logger.js";
@@ -147,7 +155,11 @@ describe("own-behalf status writes under an enforcing Authorizer", () => {
147
155
  transitions.length = 0;
148
156
  });
149
157
 
150
- async function seedExecution(id: string): Promise<void> {
158
+ async function seedExecution(
159
+ id: string,
160
+ phase: ExecutionPhase = ExecutionPhase.EXECUTION_IN_PROGRESS,
161
+ messages: MessageInitShape<typeof AgentMessageSchema>[] = [],
162
+ ): Promise<void> {
151
163
  // Seeded through the store: the create path is not under test, and
152
164
  // the denying Authorizer would refuse it over the wire.
153
165
  await server.store.saveResource(
@@ -159,7 +171,7 @@ describe("own-behalf status writes under an enforcing Authorizer", () => {
159
171
  kind: "AgentExecution",
160
172
  metadata: { id, name: "own-behalf", org: "acme" },
161
173
  spec: { agentId: "agt_1", sessionId: "ses_1" },
162
- status: { phase: ExecutionPhase.EXECUTION_IN_PROGRESS },
174
+ status: { phase, messages },
163
175
  }),
164
176
  );
165
177
  }
@@ -253,7 +265,7 @@ describe("own-behalf status writes under an enforcing Authorizer", () => {
253
265
  messages: [
254
266
  {
255
267
  type: MessageType.MESSAGE_SYSTEM,
256
- content: "Execution was cancelled.",
268
+ content: CANCEL_STOP_ROW,
257
269
  },
258
270
  ],
259
271
  }),
@@ -296,6 +308,134 @@ describe("own-behalf status writes under an enforcing Authorizer", () => {
296
308
  expect(authorizerCalls).toHaveLength(0);
297
309
  });
298
310
 
311
+ describe("the transcript a stopped execution keeps (stigmer#980)", () => {
312
+ const runnerTurns = [
313
+ { type: MessageType.MESSAGE_AI, content: "a1" },
314
+ { type: MessageType.MESSAGE_AI, content: "a2" },
315
+ ];
316
+
317
+ async function transcriptOf(id: string): Promise<string[]> {
318
+ return ((await persisted(id)).status?.messages ?? []).map(
319
+ (m) => m.content,
320
+ );
321
+ }
322
+
323
+ it("appends the cancel row to a transcript the Cancel RPC already stamped CANCELLED, and keeps it last across the runner's late settle write", async () => {
324
+ await seedExecution(
325
+ "aex_ownbehalf_cancel_race",
326
+ ExecutionPhase.EXECUTION_CANCELLED,
327
+ runnerTurns,
328
+ );
329
+
330
+ await updateStatus(
331
+ "aex_ownbehalf_cancel_race",
332
+ toJson(
333
+ AgentExecutionStatusSchema,
334
+ create(AgentExecutionStatusSchema, {
335
+ phase: ExecutionPhase.EXECUTION_CANCELLED,
336
+ messages: [
337
+ { type: MessageType.MESSAGE_SYSTEM, content: CANCEL_STOP_ROW },
338
+ ],
339
+ }),
340
+ ),
341
+ );
342
+ expect(await transcriptOf("aex_ownbehalf_cancel_race")).toEqual([
343
+ "a1",
344
+ "a2",
345
+ CANCEL_STOP_ROW,
346
+ ]);
347
+
348
+ // The runner learns of the cancel on its next heartbeat and settles
349
+ // its transcript over the same handler, with no row and no phase.
350
+ await statusWriter.updateStatus(
351
+ create(AgentExecutionUpdateStatusInputSchema, {
352
+ executionId: "aex_ownbehalf_cancel_race",
353
+ status: {
354
+ messages: [
355
+ ...runnerTurns,
356
+ { type: MessageType.MESSAGE_AI, content: "a3" },
357
+ ],
358
+ },
359
+ }),
360
+ );
361
+ expect(await transcriptOf("aex_ownbehalf_cancel_race")).toEqual([
362
+ "a1",
363
+ "a2",
364
+ "a3",
365
+ CANCEL_STOP_ROW,
366
+ ]);
367
+ });
368
+
369
+ it("appends a broken flow's two lines to the runner's transcript", async () => {
370
+ await seedExecution(
371
+ "aex_ownbehalf_broken_flow",
372
+ ExecutionPhase.EXECUTION_IN_PROGRESS,
373
+ runnerTurns,
374
+ );
375
+
376
+ await updateStatus(
377
+ "aex_ownbehalf_broken_flow",
378
+ toJson(
379
+ AgentExecutionStatusSchema,
380
+ create(AgentExecutionStatusSchema, {
381
+ phase: ExecutionPhase.EXECUTION_FAILED,
382
+ error: "activity crashed",
383
+ messages: [
384
+ {
385
+ type: MessageType.MESSAGE_SYSTEM,
386
+ content: "Internal system error occurred during execution.",
387
+ },
388
+ {
389
+ type: MessageType.MESSAGE_SYSTEM,
390
+ content: "Error details: activity crashed",
391
+ },
392
+ ],
393
+ }),
394
+ ),
395
+ );
396
+
397
+ const execution = await persisted("aex_ownbehalf_broken_flow");
398
+ expect(execution.status?.phase).toBe(ExecutionPhase.EXECUTION_FAILED);
399
+ expect(await transcriptOf("aex_ownbehalf_broken_flow")).toEqual([
400
+ "a1",
401
+ "a2",
402
+ "Internal system error occurred during execution.",
403
+ "Error details: activity crashed",
404
+ ]);
405
+ });
406
+
407
+ it("leaves a runner-reported failure's transcript as the runner wrote it", async () => {
408
+ await seedExecution(
409
+ "aex_ownbehalf_runner_failed",
410
+ ExecutionPhase.EXECUTION_FAILED,
411
+ [
412
+ ...runnerTurns,
413
+ {
414
+ type: MessageType.MESSAGE_SYSTEM,
415
+ content: "Execution failed: the tool refused",
416
+ },
417
+ ],
418
+ );
419
+
420
+ await updateStatus(
421
+ "aex_ownbehalf_runner_failed",
422
+ toJson(
423
+ AgentExecutionStatusSchema,
424
+ create(AgentExecutionStatusSchema, {
425
+ phase: ExecutionPhase.EXECUTION_FAILED,
426
+ error: "agent execution failed: the tool refused",
427
+ }),
428
+ ),
429
+ );
430
+
431
+ expect(await transcriptOf("aex_ownbehalf_runner_failed")).toEqual([
432
+ "a1",
433
+ "a2",
434
+ "Execution failed: the tool refused",
435
+ ]);
436
+ });
437
+ });
438
+
299
439
  it("surfaces the lane's NotFound as the activity's failure for a deleted execution", async () => {
300
440
  await expect(
301
441
  updateStatus("aex_ownbehalf_missing", { phase: "EXECUTION_FAILED" }),