@stigmer/runner 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 (33) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/__test-utils__/hermetic-activity.d.ts +5 -2
  3. package/dist/__test-utils__/hermetic-activity.js +12 -2
  4. package/dist/__test-utils__/hermetic-activity.js.map +1 -1
  5. package/dist/activities/execute-deep-agent/turn-setup.js +6 -0
  6. package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
  7. package/dist/harness/run-turn.js +13 -13
  8. package/dist/harness/run-turn.js.map +1 -1
  9. package/dist/harness/terminal-table.d.ts +20 -5
  10. package/dist/harness/terminal-table.js +22 -8
  11. package/dist/harness/terminal-table.js.map +1 -1
  12. package/dist/shared/model-client.d.ts +10 -2
  13. package/dist/shared/model-client.js +1 -1
  14. package/dist/shared/model-client.js.map +1 -1
  15. package/dist/shared/worker-shutdown.d.ts +10 -5
  16. package/dist/shared/worker-shutdown.js +3 -3
  17. package/dist/shared/worker-shutdown.js.map +1 -1
  18. package/package.json +5 -5
  19. package/src/__test-utils__/harness-contract/runtime-contract.ts +27 -16
  20. package/src/__test-utils__/hermetic-activity.ts +11 -2
  21. package/src/activities/execute-cursor/__tests__/hermetic/goldens/pause.status.json +0 -6
  22. package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +28 -21
  23. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/pause.loop.after-tool.status.json +0 -6
  24. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/pause.loop.mid-tool.status.json +0 -6
  25. package/src/activities/execute-deep-agent/__tests__/hermetic/pause-vs-shutdown.test.ts +27 -21
  26. package/src/activities/execute-deep-agent/turn-setup.ts +6 -0
  27. package/src/harness/run-turn.ts +13 -13
  28. package/src/harness/terminal-table.ts +23 -8
  29. package/src/shared/__tests__/model-client-sampling.test.ts +56 -0
  30. package/src/shared/__tests__/model-client.test.ts +15 -0
  31. package/src/shared/__tests__/worker-shutdown.test.ts +2 -2
  32. package/src/shared/model-client.ts +11 -3
  33. package/src/shared/worker-shutdown.ts +11 -6
@@ -1 +1 @@
1
- {"version":3,"file":"worker-shutdown.js","sourceRoot":"","sources":["../../src/shared/worker-shutdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA2B,CAAC;AAEpD;;;;;GAKG;AACH,MAAM,UAAU,4BAA4B,CAAC,SAAiB;IAC5D,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,QAAQ,CAAC,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACpC,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,yBAAyB,CAAC,SAAiB;IACzD,OAAO,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;AACzC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,SAAiB;IACpD,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,CAAC;AACnC,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,8BAA8B,CAAC,SAAiB;IAC9D,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;AAC7B,CAAC;AAaD,wFAAwF;AACxF,MAAM,UAAU,oBAAoB,CAAC,MAAmB;IACtD,IAAI,CAAC,MAAM,CAAC,OAAO;QAAE,OAAO,SAAS,CAAC;IACtC,MAAM,MAAM,GAAY,MAAM,CAAC,MAAM,CAAC;IACtC,IAAI,MAAM,YAAY,KAAK;QAAE,OAAO,MAAM,CAAC,OAAO,CAAC;IACnD,OAAO,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC;AAC3D,CAAC;AAsBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,wBAAwB,CAAC,QAAkC;IACzE,MAAM,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,GAAG,QAAQ,CAAC;IAC/D,IAAI,kBAAkB,KAAK,SAAS,EAAE,CAAC;QACrC,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,IAAI,qBAAqB,IAAI,kBAAkB,KAAK,iBAAiB,EAAE,CAAC;QACtE,OAAO,iBAAiB,CAAC;IAC3B,CAAC;IACD,OAAO,kBAAkB,KAAK,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,gBAAgB,CAAC;AACzE,CAAC"}
1
+ {"version":3,"file":"worker-shutdown.js","sourceRoot":"","sources":["../../src/shared/worker-shutdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA2B,CAAC;AAEpD;;;;;GAKG;AACH,MAAM,UAAU,4BAA4B,CAAC,SAAiB;IAC5D,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,QAAQ,CAAC,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACpC,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,yBAAyB,CAAC,SAAiB;IACzD,OAAO,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;AACzC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,SAAiB;IACpD,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,CAAC;AACnC,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,8BAA8B,CAAC,SAAiB;IAC9D,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;AAC7B,CAAC;AAaD,wFAAwF;AACxF,MAAM,UAAU,oBAAoB,CAAC,MAAmB;IACtD,IAAI,CAAC,MAAM,CAAC,OAAO;QAAE,OAAO,SAAS,CAAC;IACtC,MAAM,MAAM,GAAY,MAAM,CAAC,MAAM,CAAC;IACtC,IAAI,MAAM,YAAY,KAAK;QAAE,OAAO,MAAM,CAAC,OAAO,CAAC;IACnD,OAAO,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC;AAC3D,CAAC;AA2BD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,wBAAwB,CAAC,QAAkC;IACzE,MAAM,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,GAAG,QAAQ,CAAC;IAC/D,IAAI,kBAAkB,KAAK,SAAS,EAAE,CAAC;QACrC,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,IAAI,qBAAqB,IAAI,kBAAkB,KAAK,iBAAiB,EAAE,CAAC;QACtE,OAAO,iBAAiB,CAAC;IAC3B,CAAC;IACD,OAAO,kBAAkB,KAAK,WAAW,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,gBAAgB,CAAC;AACrF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/runner",
3
- "version": "3.37.0",
3
+ "version": "3.38.0",
4
4
  "description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -108,10 +108,10 @@
108
108
  "@opentelemetry/sdk-metrics": "^2.0.0",
109
109
  "@opentelemetry/sdk-trace-base": "^2.0.0",
110
110
  "@opentelemetry/sdk-trace-node": "^2.0.0",
111
- "@stigmer/outbound": "3.37.0",
112
- "@stigmer/protos": "3.37.0",
113
- "@stigmer/temporal-codecs": "3.37.0",
114
- "@stigmer/zip-structure": "3.37.0",
111
+ "@stigmer/outbound": "3.38.0",
112
+ "@stigmer/protos": "3.38.0",
113
+ "@stigmer/temporal-codecs": "3.38.0",
114
+ "@stigmer/zip-structure": "3.38.0",
115
115
  "@temporalio/activity": "1.16.2",
116
116
  "@temporalio/common": "1.16.2",
117
117
  "@temporalio/interceptors-opentelemetry": "1.16.2",
@@ -594,12 +594,17 @@ function hangingTurn(before: string): TurnScenario {
594
594
  }
595
595
 
596
596
  /**
597
- * A user pause: Temporal's cancellation delivered while the engine hangs
598
- * persists PAUSED with the pause row written exactly once, no error, no
599
- * `completedAt`, and THROWS the pause `CancelledFailure` so the workflow
600
- * knows to wait.
597
+ * An orchestrator stop (a user's Pause or Cancel, one cancellation to the
598
+ * turn): Temporal's cancellation delivered while the engine hangs settles
599
+ * the transcript into one write that carries no phase and no row (the
600
+ * workflow writes both, stigmer#980), no error, no `completedAt`, and
601
+ * THROWS the stop's `CancelledFailure` so the workflow sees the activity
602
+ * stop.
601
603
  */
602
- export async function assertPausePersistsAndThrows(harness: RuntimeContractHarness, label = "rt-pause"): Promise<RuntimeArmResult> {
604
+ export async function assertOrchestratorStopSettlesAndThrows(
605
+ harness: RuntimeContractHarness,
606
+ label = "rt-orchestrator-stop",
607
+ ): Promise<RuntimeArmResult> {
603
608
  const { subject } = harness;
604
609
  const driver = new RuntimeExecutionDriver(harness, label);
605
610
  let controls: InvocationControls | undefined;
@@ -607,16 +612,22 @@ export async function assertPausePersistsAndThrows(harness: RuntimeContractHarne
607
612
 
608
613
  const invocation = await driver.turn(hangingTurn("Starting on the parser."), { onControls: (c) => (controls = c) });
609
614
 
610
- expect(threwCancelledFailure(invocation.outcome), `${subject.name}: a pause THROWS a CancelledFailure`).toBe(true);
615
+ expect(threwCancelledFailure(invocation.outcome), `${subject.name}: an orchestrator stop THROWS a CancelledFailure`).toBe(true);
611
616
  if (!threwCancelledFailure(invocation.outcome)) throw new Error("unreachable");
612
- expect(invocation.outcome.error.message).toBe(TERMINAL_COPY.pause.throwMessage);
613
- expect(driver.record.persistedPhases.at(-1)).toBe(ExecutionPhase.EXECUTION_PAUSED);
614
- const final = finalStatusOf(harness, driver);
615
- expect(final.error, `${subject.name}: a pause is not an error`).toBe("");
616
- expect(final.completedAt, `${subject.name}: a paused turn is not complete`).toBe("");
617
- expect(systemMessages(final), `${subject.name}: the pause row, exactly once`).toEqual([TERMINAL_COPY.pause.row]);
618
- expect(aiMessages(final)).not.toContain(NEVER_SEEN);
619
- return { driver, invocations: [invocation], final };
617
+ expect(invocation.outcome.error.message).toBe(TERMINAL_COPY.orchestratorStop.throwMessage);
618
+ expect(
619
+ driver.record.persistedPhases.at(-1),
620
+ `${subject.name}: the stop writes no phase; the last phase is the turn's own`,
621
+ ).toBe(ExecutionPhase.EXECUTION_IN_PROGRESS);
622
+ const settle = driver.record.persisted.at(-1);
623
+ if (!settle) throw new Error(`${subject.name}: the runtime persisted nothing`);
624
+ expect(settle.phase, `${subject.name}: the settle write carries no phase`).toBe(ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED);
625
+ assertTranscriptStreamsNothing(subject.name, settle);
626
+ expect(settle.error, `${subject.name}: a stop is not an error`).toBe("");
627
+ expect(settle.completedAt, `${subject.name}: a stopped turn is not complete`).toBe("");
628
+ expect(systemMessages(settle), `${subject.name}: no stop row; the workflow writes it`).toEqual([]);
629
+ expect(aiMessages(settle)).not.toContain(NEVER_SEEN);
630
+ return { driver, invocations: [invocation], final: settle };
620
631
  }
621
632
 
622
633
  /**
@@ -970,9 +981,9 @@ export function describeHarnessRuntimeContract(harness: RuntimeContractHarness,
970
981
  await assertNonExecutingDecisionSettlesSkipped(harness, action);
971
982
  });
972
983
  }
973
- it("a user pause persists PAUSED with the pause row once and throws the pause CancelledFailure", async () => {
984
+ it("an orchestrator stop settles the transcript with no phase and no row, and throws the stop's CancelledFailure", async () => {
974
985
  clock.reset();
975
- await assertPausePersistsAndThrows(harness);
986
+ await assertOrchestratorStopSettlesAndThrows(harness);
976
987
  });
977
988
  it("a worker shutdown persists FAILED with the shutdown copy once and throws the shutdown CancelledFailure", async () => {
978
989
  clock.reset();
@@ -331,8 +331,11 @@ export class ExecutionRecord {
331
331
 
332
332
  /**
333
333
  * Apply one `UpdateStatus` write and answer the control signal the server
334
- * would. A setup-progress report never carries a signal (the runner ignores
335
- * the response there; the server has nothing to say about a label).
334
+ * would. A write with no phase never carries a signal (the runner ignores
335
+ * the response there). It is a setup-progress report, or the transcript an
336
+ * orchestrator stop settles (the runtime writes no phase for it; the
337
+ * workflow does, stigmer#980): the server's merge applies the transcript and
338
+ * leaves the phase to its writers, so the double keeps the held phase.
336
339
  */
337
340
  applyStatusUpdate(status: AgentExecutionStatus): ExecutionControlSignal {
338
341
  const snapshot = clone(AgentExecutionStatusSchema, status);
@@ -341,6 +344,12 @@ export class ExecutionRecord {
341
344
  if (snapshot.setupProgress?.currentPhase) {
342
345
  this.setupProgress.push(snapshot.setupProgress.currentPhase);
343
346
  }
347
+ if (snapshot.messages.length > 0) {
348
+ const heldPhase = this.execution.status?.phase ?? ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
349
+ this.execution.status = clone(AgentExecutionStatusSchema, snapshot);
350
+ this.execution.status.phase = heldPhase;
351
+ for (const listener of [...this.persistListeners]) listener();
352
+ }
344
353
  return ExecutionControlSignal.UNSPECIFIED;
345
354
  }
346
355
  this.execution.status = clone(AgentExecutionStatusSchema, snapshot);
@@ -4,14 +4,8 @@
4
4
  "type": "MESSAGE_AI",
5
5
  "content": "Starting on the parser.",
6
6
  "timestamp": "2026-01-01T00:00:02.000Z"
7
- },
8
- {
9
- "type": "MESSAGE_SYSTEM",
10
- "content": "Execution paused by user. Use resume to continue.",
11
- "timestamp": "2026-01-01T00:00:03.000Z"
12
7
  }
13
8
  ],
14
- "phase": "EXECUTION_PAUSED",
15
9
  "startedAt": "2026-01-01T00:00:00.000Z",
16
10
  "fileReviewEventStream": {
17
11
  "executionId": "aex_hermetic_0001",
@@ -1,23 +1,28 @@
1
1
  /**
2
- * Hermetic goldens: USER PAUSE versus WORKER SHUTDOWN — the two interruptions
3
- * that THROW, and the byte-pinned copy that tells them apart.
2
+ * Hermetic goldens: ORCHESTRATOR STOP versus WORKER SHUTDOWN — the two
3
+ * interruptions that THROW, and what tells them apart.
4
4
  *
5
5
  * Invariant pinned (the throw-vs-return table the runtime owns,
6
6
  * `harness/terminal-table.ts`; parent §5c): both interruptions end the activity with a thrown Temporal
7
7
  * `CancelledFailure` (never a return), but they persist DIFFERENT terminal
8
8
  * states the control plane keys on:
9
9
  *
10
- * - a user pause (Temporal delivers cancellation, no shutdown signal) persists
11
- * EXECUTION_PAUSED with "Execution paused by user. Use resume to continue."
12
- * and throws "Activity paused by orchestrator";
10
+ * - an orchestrator stop (Temporal delivers cancellation, no shutdown
11
+ * signal: a user's Pause or Cancel, which the turn cannot tell apart)
12
+ * settles the transcript in one write with NO phase and NO row, and throws
13
+ * "Activity paused by orchestrator" (bytes kept: wire copy). The invoke
14
+ * workflow, which knows which stop it runs, writes the phase and the stop
15
+ * row, and the server's merge keeps the row last (stigmer#980). Until then
16
+ * this arm wrote PAUSED and the pause row, so a cancelled run ended with an
17
+ * instruction to resume it;
13
18
  * - a worker shutdown (the runner's per-queue shutdown signal is aborted AND
14
19
  * cancellation is delivered) persists EXECUTION_FAILED with the error copy
15
20
  * "Execution interrupted: runner worker was shut down. Retry or resume." and
16
21
  * throws "Activity cancelled (worker shutdown, not user pause)".
17
22
  *
18
- * The cloud channel decision table matches on that copy, so the goldens
19
- * (`goldens/pause.status.json`, `goldens/worker-shutdown.status.json`) are the
20
- * wire contract, character for character. Both interruptions are triggered from
23
+ * The goldens (`goldens/pause.status.json`, the stop's settle write, and
24
+ * `goldens/worker-shutdown.status.json`) are the wire contract, character for
25
+ * character. Both interruptions are triggered from
21
26
  * a script `effect` step — never a timer — so the instant is deterministic: the
22
27
  * stream loop sees `isCancelled()` on its next iteration and never processes
23
28
  * the event that follows.
@@ -31,8 +36,9 @@
31
36
  * writer of a terminal arm, so a second row has no code path to come from.
32
37
  *
33
38
  * Runtime phases exercised: the adapter's stop-signal cancel; the runtime's
34
- * `classifyTurnInterruption`; the terminal table's pause and worker-shutdown
35
- * arms through `settleWith`; the `finally` teardown under a thrown exit.
39
+ * `classifyTurnInterruption`; the terminal table's orchestrator-stop and
40
+ * worker-shutdown arms through `settleWith`; the `finally` teardown under a
41
+ * thrown exit.
36
42
  *
37
43
  * Regeneration history:
38
44
  * - #1070: timestamp-only diff — every terminal stamp
@@ -41,6 +47,8 @@
41
47
  * and the clock ticks on, one more step. Transcript unchanged.
42
48
  * - 2026-09-12 (stigmer#1054, the PR after #1070): one removed system row per
43
49
  * golden, nothing else. The transcript now carries each terminal row once.
50
+ * - stigmer#980: `pause.status.json` loses its phase and its pause row (the
51
+ * workflow writes both now); the transcript is otherwise unchanged.
44
52
  *
45
53
  * Regenerate ONLY after a deliberate behavior change:
46
54
  * npx vitest run src/activities/execute-cursor/__tests__/hermetic -u
@@ -108,7 +116,7 @@ function interruptedTurn(
108
116
  });
109
117
  }
110
118
 
111
- describe("ExecuteCursor hermetic — user pause vs worker shutdown", () => {
119
+ describe("ExecuteCursor hermetic — orchestrator stop vs worker shutdown", () => {
112
120
  let env: HermeticEnvironment;
113
121
  let registry: ReturnType<typeof stubRegistryFetch>;
114
122
  const clock = new ScriptedClock();
@@ -125,7 +133,7 @@ describe("ExecuteCursor hermetic — user pause vs worker shutdown", () => {
125
133
  env.dispose();
126
134
  });
127
135
 
128
- it("a user pause persists PAUSED and throws CancelledFailure", async () => {
136
+ it("an orchestrator stop settles the transcript with no phase and no row, and throws CancelledFailure", async () => {
129
137
  // ── Arrange ──────────────────────────────────────────────────────────────
130
138
  clock.reset(); // both goldens read from :00
131
139
  let controls: InvocationControls | undefined;
@@ -143,19 +151,18 @@ describe("ExecuteCursor hermetic — user pause vs worker shutdown", () => {
143
151
  const invocation = await runCursorTurn(scenario, { onControls: (c) => (controls = c) });
144
152
 
145
153
  // ── Assert: the throw and the persisted state ────────────────────────────
146
- expect(threwCancelledFailure(invocation.outcome), "a pause THROWS CancelledFailure").toBe(true);
154
+ expect(threwCancelledFailure(invocation.outcome), "an orchestrator stop THROWS CancelledFailure").toBe(true);
147
155
  if (!threwCancelledFailure(invocation.outcome)) throw new Error("unreachable");
148
156
  expect(invocation.outcome.error.message).toBe("Activity paused by orchestrator");
149
- expect(record.persistedPhases).toEqual([
150
- ExecutionPhase.EXECUTION_IN_PROGRESS,
151
- ExecutionPhase.EXECUTION_PAUSED,
152
- ]);
153
- const final = record.lastFullStatus!;
154
- expect(final.error, "a pause is not an error").toBe("");
155
- expect(final.completedAt, "a paused turn is not complete").toBe("");
157
+ expect(record.persistedPhases, "the stop writes no phase").toEqual([ExecutionPhase.EXECUTION_IN_PROGRESS]);
158
+ const final = record.persisted.at(-1)!;
159
+ expect(final.phase, "the settle write carries no phase").toBe(ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED);
160
+ expect(final.error, "a stop is not an error").toBe("");
161
+ expect(final.completedAt, "a stopped turn is not complete").toBe("");
156
162
  expect(
157
163
  final.messages.filter((m) => m.type === MessageType.MESSAGE_SYSTEM).map((m) => m.content),
158
- ).toEqual(["Execution paused by user. Use resume to continue."]);
164
+ "no stop row: the workflow writes it",
165
+ ).toEqual([]);
159
166
  expect(final.messages.some((m) => m.content === NEVER_SEEN), "nothing after the cancel is processed").toBe(false);
160
167
 
161
168
  const json = JSON.stringify(toJson(AgentExecutionStatusSchema, final), null, 2) + "\n";
@@ -19,14 +19,8 @@
19
19
  "toolKind": "TOOL_KIND_FILE_READ"
20
20
  }
21
21
  ]
22
- },
23
- {
24
- "type": "MESSAGE_SYSTEM",
25
- "content": "Execution paused by user. Use resume to continue.",
26
- "timestamp": "2026-01-01T00:00:02.000Z"
27
22
  }
28
23
  ],
29
- "phase": "EXECUTION_PAUSED",
30
24
  "startedAt": "2026-01-01T00:00:00.000Z",
31
25
  "streamingUsage": {
32
26
  "inputTokens": "1200",
@@ -17,14 +17,8 @@
17
17
  "toolKind": "TOOL_KIND_FILE_READ"
18
18
  }
19
19
  ]
20
- },
21
- {
22
- "type": "MESSAGE_SYSTEM",
23
- "content": "Execution paused by user. Use resume to continue.",
24
- "timestamp": "2026-01-01T00:00:01.000Z"
25
20
  }
26
21
  ],
27
- "phase": "EXECUTION_PAUSED",
28
22
  "startedAt": "2026-01-01T00:00:00.000Z",
29
23
  "streamingUsage": {
30
24
  "inputTokens": "1200",
@@ -3,19 +3,22 @@
3
3
  * the activity tells them apart — the throw-vs-return table the control
4
4
  * plane's workflow keys on. Since #1096 the activity is the turn runtime
5
5
  * over the native adapter, and the shapes below are the runtime's terminal
6
- * table (`harness/terminal-table.ts`; the pause copy is Cursor's) and
7
- * stigmer#1071 ("persist a thrown terminal once").
6
+ * table (`harness/terminal-table.ts`) and stigmer#1071 ("persist a thrown
7
+ * terminal once").
8
8
  *
9
- * 1. USER PAUSE: the activity's `cancellationSignal` aborts. Whether the
9
+ * 1. ORCHESTRATOR STOP (a user's Pause or Cancel; the turn cannot tell
10
+ * them apart): the activity's `cancellationSignal` aborts. Whether the
10
11
  * abort lands inside a model call (staged from the scripted model's
11
12
  * `onTurn`) or while the loop is persisting (staged from the control
12
13
  * plane's persist channel, the record's `controlSignal`), the runtime's
13
14
  * stop controller aborts the adapter's signal, the adapter cancels the
14
- * graph run and settles `interrupted`, and the runtime persists PAUSED
15
- * ONCE — WITH the transcript and the row "Execution paused by user. Use
16
- * resume to continue." (the runtime's pause copy is Cursor's; the
17
- * orchestrator's said "… from this checkpoint.") — then throws
18
- * `CancelledFailure("Activity paused by orchestrator")`. Until #1096 the
15
+ * graph run and settles `interrupted`, and the runtime persists the
16
+ * transcript ONCE with no phase and no row, then throws
17
+ * `CancelledFailure("Activity paused by orchestrator")` (bytes kept:
18
+ * wire copy). The invoke workflow writes the phase and the stop row, and
19
+ * the server's merge keeps the row last (stigmer#980); until then this
20
+ * write carried PAUSED and "Execution paused by user. Use resume to
21
+ * continue.", which a cancelled run showed too. Until #1096 the
19
22
  * orchestrator persisted PAUSED TWICE, the second write bare and erasing
20
23
  * the transcript (stigmer#1054's double persist);
21
24
  * `pause.bare.status.json` recorded that write and is gone with it. The
@@ -132,30 +135,33 @@ describe("ExecuteDeepAgent hermetic — pause vs worker shutdown", () => {
132
135
  },
133
136
  loopGolden: "./goldens/pause.loop.mid-tool.status.json",
134
137
  },
135
- ])("user pause $staging", ({ begin, controlSignal, loopGolden }) => {
136
- it("persists PAUSED once, with the transcript and the row, and throws as a pause", async () => {
138
+ ])("orchestrator stop $staging", ({ begin, controlSignal, loopGolden }) => {
139
+ it("settles the transcript once, with no phase and no row, and throws as the stop", async () => {
137
140
  clock.reset();
138
141
  const record = deepAgentExecutionRecord({ message: "Look at my notes.", controlSignal });
139
142
  inFlight = begin(record);
140
143
 
141
144
  const invocation = await runDeepAgentTurn(inFlight);
142
145
 
143
- expect(threwCancelledFailure(invocation.outcome), "a pause THROWS CancelledFailure").toBe(true);
146
+ expect(threwCancelledFailure(invocation.outcome), "an orchestrator stop THROWS CancelledFailure").toBe(true);
144
147
  expect((invocation.outcome as { error: Error }).error.message).toBe("Activity paused by orchestrator");
145
- expect(record.persistedPhases).toEqual([ExecutionPhase.EXECUTION_IN_PROGRESS, ExecutionPhase.EXECUTION_PAUSED]);
146
-
147
- const pausedWrites = record.persisted.filter((s) => s.phase === ExecutionPhase.EXECUTION_PAUSED);
148
- expect(pausedWrites, "PAUSED is persisted exactly once (stigmer#1071)").toHaveLength(1);
149
- const [paused] = pausedWrites;
150
- expect(paused.messages.filter((m) => m.type === MessageType.MESSAGE_SYSTEM).map((m) => m.content)).toEqual([
151
- "Execution paused by user. Use resume to continue.",
152
- ]);
148
+ expect(record.persistedPhases, "the stop writes no phase").toEqual([ExecutionPhase.EXECUTION_IN_PROGRESS]);
149
+
150
+ const settleWrites = record.persisted.filter(
151
+ (s) => s.phase === ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED && s.messages.length > 0,
152
+ );
153
+ expect(settleWrites, "the stop is settled exactly once (stigmer#1071)").toHaveLength(1);
154
+ const [paused] = settleWrites;
155
+ expect(
156
+ paused.messages.filter((m) => m.type === MessageType.MESSAGE_SYSTEM).map((m) => m.content),
157
+ "no stop row: the workflow writes it (stigmer#980)",
158
+ ).toEqual([]);
153
159
  expect(
154
160
  paused.messages.flatMap((m) => m.toolCalls).map((tc) => tc.name),
155
- "the read that landed before the pause is in the one write",
161
+ "the read that landed before the stop is in the one write",
156
162
  ).toEqual(["read_file"]);
157
163
  expect(paused.completedAt).toBe("");
158
- expect(record.lastFullStatus, "the wire ends on the write that carries the transcript").toEqual(paused);
164
+ expect(record.persisted.at(-1), "the wire ends on the write that carries the transcript").toEqual(paused);
159
165
 
160
166
  const json = JSON.stringify(toJson(AgentExecutionStatusSchema, paused), null, 2) + "\n";
161
167
  await expect(json).toMatchFileSnapshot(loopGolden);
@@ -345,6 +345,11 @@ export async function buildEngine(
345
345
  // native-thinking heuristic, and sub-agent inheritance. The credential is
346
346
  // read from the ref at build, once per turn: a LangChain client carries
347
347
  // its headers for its life, so this is the freshest a turn's model can be.
348
+ //
349
+ // No temperature: current Anthropic models refuse one other than their
350
+ // default (claude-opus-4.7 and later and claude-fable-5 inside
351
+ // @langchain/anthropic, before any request; claude-sonnet-5 at the
352
+ // provider), and the Cursor harness sends none (stigmer/stigmer#1341).
348
353
  const buildModelFor = async (name: string) =>
349
354
  (await buildChatModel({
350
355
  modelName: name,
@@ -352,6 +357,7 @@ export async function buildEngine(
352
357
  stigmerToken: config.stigmerTokenRef.current ?? undefined,
353
358
  headerScope: { executionId },
354
359
  serviceTier: input.model.serviceTier,
360
+ temperature: null,
355
361
  })).model;
356
362
  const model = await buildModelFor(modelName);
357
363
  sink.setupTiming.mark("build_model");
@@ -74,7 +74,7 @@ import {
74
74
  failedArm,
75
75
  fileReviewResolvedArm,
76
76
  infrastructureCancelArm,
77
- pauseArm,
77
+ orchestratorStopArm,
78
78
  platformStopArm,
79
79
  stallArm,
80
80
  TERMINAL_COPY,
@@ -463,7 +463,7 @@ async function runTurn(deps: TurnRuntimeDeps, input: NormalizedActivityInput): P
463
463
  * The table for an `interrupted` outcome, in the orchestrator's precedence:
464
464
  * a stall (FAILED, return) outranks everything; a cost cap (TERMINATED,
465
465
  * return); then Temporal's cancellation as the signal's reason reads it —
466
- * the runner's drain, the orchestrator's pause, an infrastructure cancel
466
+ * the runner's drain, the orchestrator's stop, an infrastructure cancel
467
467
  * (all thrown); last a platform stop (COMPLETED, return).
468
468
  */
469
469
  async function settleInterrupted(maxCostUsd: number, accumulator: UsageAccumulator): Promise<Settled> {
@@ -487,9 +487,9 @@ async function runTurn(deps: TurnRuntimeDeps, input: NormalizedActivityInput): P
487
487
  case "worker-shutdown":
488
488
  console.log(`${activityName} interrupted (worker shutdown): execution=${executionId}`);
489
489
  return settleWith(workerShutdownArm());
490
- case "pause":
491
- console.log(`${activityName} paused: execution=${executionId}`);
492
- return settleWith(pauseArm());
490
+ case "orchestrator-stop":
491
+ console.log(`${activityName} stopped by the orchestrator (pause or cancel): execution=${executionId}`);
492
+ return settleWith(orchestratorStopArm());
493
493
  case "infrastructure":
494
494
  console.log(`${activityName} interrupted (infrastructure cancel): execution=${executionId}`);
495
495
  return settleWith(infrastructureCancelArm());
@@ -659,9 +659,9 @@ async function runTurn(deps: TurnRuntimeDeps, input: NormalizedActivityInput): P
659
659
  case "worker-shutdown":
660
660
  console.log(`${activityName} cancelled (worker shutdown) for execution ${executionId}`);
661
661
  return workerShutdownArm();
662
- case "pause":
663
- console.log(`${activityName} cancelled (pause) for execution ${executionId}`);
664
- return pauseArm();
662
+ case "orchestrator-stop":
663
+ console.log(`${activityName} cancelled (orchestrator stop) for execution ${executionId}`);
664
+ return orchestratorStopArm();
665
665
  case "infrastructure":
666
666
  console.log(`${activityName} cancelled (infrastructure) for execution ${executionId}`);
667
667
  return infrastructureCancelArm();
@@ -687,11 +687,11 @@ async function runTurn(deps: TurnRuntimeDeps, input: NormalizedActivityInput): P
687
687
  if (arm !== undefined) {
688
688
  // A non-cancellation error while cancelled was most likely caused by the
689
689
  // cancellation itself (a teardown mid-flight) and must not overwrite the
690
- // state the workflow already expects: a pause stays PAUSED; anything
691
- // else reads as the interruption it was.
692
- if (arm.phase === ExecutionPhase.EXECUTION_PAUSED) {
693
- console.log(`${activityName} error during pause (treating as pause): execution=${executionId}, error=${errDetail}`);
694
- return settleWith(arm, new CancelledFailure(TERMINAL_COPY.pause.throwMessageAfterError));
690
+ // state the workflow already expects: an orchestrator stop stays the
691
+ // workflow's to write; anything else reads as the interruption it was.
692
+ if (interruption() === "orchestrator-stop") {
693
+ console.log(`${activityName} error during an orchestrator stop (treating as the stop): execution=${executionId}, error=${errDetail}`);
694
+ return settleWith(arm, new CancelledFailure(TERMINAL_COPY.orchestratorStop.throwMessageAfterError));
695
695
  }
696
696
  console.log(`${activityName} error during infrastructure cancel: execution=${executionId}, error=${errDetail}`);
697
697
  return settleWith(
@@ -21,6 +21,16 @@
21
21
  * infrastructure cancel it delivered — with the message the control
22
22
  * planes recognise.
23
23
  *
24
+ * One stop has no copy here: the orchestrator's (a Pause or a Cancel, which
25
+ * reach the activity as the same workflow-requested cancellation). The turn
26
+ * cannot tell which it is, so its arm writes no phase and no row, and the
27
+ * invoke workflow, which knows, writes the phase and the stop row that the
28
+ * server's merge keeps last (stigmer#980;
29
+ * `backend/services/stigmer-server/src/domain/agentexecution/platform-rows.ts`).
30
+ * The pause row lived here until then, and a cancelled run ended with an
31
+ * instruction to resume it. The arm's throw messages keep their bytes: they
32
+ * are the wire copy the control planes see on the activity failure.
33
+ *
24
34
  * An arm's rows are appended exactly once, by `applyTerminalArm`, and an arm
25
35
  * is persisted exactly once, by `run-turn.ts`'s `settleWith` — the one
26
36
  * writer of a terminal. The orchestrator this table replaced appended the
@@ -47,6 +57,7 @@ export type TerminalDisposition = { readonly kind: "return" } | { readonly kind:
47
57
 
48
58
  /** One arm of the table: what to write, and how to end. */
49
59
  export interface TerminalArm {
60
+ /** `EXECUTION_PHASE_UNSPECIFIED` writes no phase: the merge applies a phase only when one is sent. */
50
61
  readonly phase: ExecutionPhase;
51
62
  /** `status.error`; absent leaves it as it is (a pause is not an error). */
52
63
  readonly error?: string;
@@ -61,8 +72,7 @@ const RETURN: TerminalDisposition = { kind: "return" };
61
72
 
62
73
  /** The fixed strings of the table, in one place. Every one is pinned by a golden or a hermetic assertion. */
63
74
  export const TERMINAL_COPY = {
64
- pause: {
65
- row: "Execution paused by user. Use resume to continue.",
75
+ orchestratorStop: {
66
76
  throwMessage: "Activity paused by orchestrator",
67
77
  throwMessageAfterError: "Activity paused by orchestrator (error during pause)",
68
78
  },
@@ -138,7 +148,7 @@ export function toolCallLimitArm(): TerminalArm {
138
148
  };
139
149
  }
140
150
 
141
- /** The runner is draining. Not a user pause: FAILED with the shutdown copy, thrown as cancelled. */
151
+ /** The runner is draining. Not a user's stop: FAILED with the shutdown copy, thrown as cancelled. */
142
152
  export function workerShutdownArm(): TerminalArm {
143
153
  return {
144
154
  phase: ExecutionPhase.EXECUTION_FAILED,
@@ -149,13 +159,18 @@ export function workerShutdownArm(): TerminalArm {
149
159
  };
150
160
  }
151
161
 
152
- /** The orchestrator paused the execution. Not an error, not complete; thrown as cancelled. */
153
- export function pauseArm(): TerminalArm {
162
+ /**
163
+ * The orchestrator stopped the turn: a Pause or a Cancel, indistinguishable
164
+ * here (the module header). The transcript is settled and persisted with no
165
+ * phase and no row, because the workflow writes both; not an error, not
166
+ * complete; thrown as cancelled so the workflow sees the activity stop.
167
+ */
168
+ export function orchestratorStopArm(): TerminalArm {
154
169
  return {
155
- phase: ExecutionPhase.EXECUTION_PAUSED,
156
- rows: [TERMINAL_COPY.pause.row],
170
+ phase: ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED,
171
+ rows: [],
157
172
  completes: false,
158
- disposition: { kind: "throw", message: TERMINAL_COPY.pause.throwMessage },
173
+ disposition: { kind: "throw", message: TERMINAL_COPY.orchestratorStop.throwMessage },
159
174
  };
160
175
  }
161
176
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The native execution turn's request against the REAL @langchain/anthropic
3
+ * (stigmer/stigmer#1341): the unit suite in model-client.test.ts mocks the
4
+ * wrapper, so it cannot see the wrapper's own parameter validation. That
5
+ * validation is what failed every native turn on claude-opus-4.7,
6
+ * claude-opus-4.8 and claude-fable-5 while the runner pinned temperature 0.
7
+ * These tests build the client the way the execution turn does and read the
8
+ * request parameters offline; no request leaves the process.
9
+ */
10
+ import { ChatAnthropic } from "@langchain/anthropic";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import { buildChatModel } from "../model-client.js";
14
+ import { _resetRegistryCache } from "../model-registry.js";
15
+
16
+ // Ids the wrapper treats as adaptive-only and refuses a sampling override
17
+ // for, and ids it lets one through for (the provider itself refuses it on
18
+ // claude-sonnet-5); the registry is unreachable in these tests, so each id
19
+ // reaches the wrapper unchanged.
20
+ const ADAPTIVE_ONLY_IDS = ["claude-opus-4-7", "claude-opus-4-8", "claude-fable-5"];
21
+ const SAMPLING_TOLERANT_IDS = ["claude-sonnet-4-6", "claude-opus-4-6", "claude-sonnet-5", "claude-haiku-4-5-20251001"];
22
+
23
+ async function executionTurnParams(modelName: string, temperature: number | null | undefined) {
24
+ const { model } = await buildChatModel({
25
+ modelName,
26
+ proxyEndpoint: "http://127.0.0.1:9",
27
+ stigmerToken: "sampling-test",
28
+ temperature,
29
+ });
30
+ return (model as ChatAnthropic).invocationParams({});
31
+ }
32
+
33
+ describe("the native execution turn's sampling parameters (real @langchain/anthropic)", () => {
34
+ beforeEach(() => {
35
+ _resetRegistryCache();
36
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("registry unreachable in this test"));
37
+ vi.spyOn(console, "warn").mockImplementation(() => {});
38
+ });
39
+
40
+ afterEach(() => {
41
+ _resetRegistryCache();
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ it.each(ADAPTIVE_ONLY_IDS)("%s: the pinned temperature 0 is refused before any request", async (id) => {
46
+ await expect(executionTurnParams(id, undefined)).rejects.toThrow(/temperature is not supported/);
47
+ });
48
+
49
+ it.each([...ADAPTIVE_ONLY_IDS, ...SAMPLING_TOLERANT_IDS])(
50
+ "%s: with no temperature the request builds and carries no sampling override",
51
+ async (id) => {
52
+ const params = await executionTurnParams(id, null);
53
+ expect(params).not.toHaveProperty("temperature");
54
+ },
55
+ );
56
+ });
@@ -156,6 +156,21 @@ describe("buildChatModel", () => {
156
156
  expect(lastAnthropicArgs()).toMatchObject({ maxTokens: 4096 });
157
157
  });
158
158
 
159
+ it("pins temperature 0 by default, and sends none when the caller passes null", async () => {
160
+ mockRegistryResponse([
161
+ { id: "claude-opus-4.8", apiModelId: "claude-opus-4-8", provider: "anthropic" },
162
+ ]);
163
+
164
+ await buildChatModel({ modelName: "claude-opus-4.8" });
165
+ expect(lastAnthropicArgs()).toMatchObject({ temperature: 0 });
166
+
167
+ await buildChatModel({ modelName: "claude-opus-4.8", temperature: 0.3 });
168
+ expect(lastAnthropicArgs()).toMatchObject({ temperature: 0.3 });
169
+
170
+ await buildChatModel({ modelName: "claude-opus-4.8", temperature: null });
171
+ expect(lastAnthropicArgs()).not.toHaveProperty("temperature");
172
+ });
173
+
159
174
  it("passes the model id through unchanged when the registry is unavailable", async () => {
160
175
  vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("network down"));
161
176
 
@@ -54,8 +54,8 @@ describe("classifyTurnInterruption", () => {
54
54
  expect(classifyTurnInterruption({ cancellationReason: undefined, shutdownSignalAborted: true })).toBe("none");
55
55
  });
56
56
 
57
- it("a workflow-requested cancellation without a shutdown signal is the orchestrator's pause", () => {
58
- expect(classifyTurnInterruption({ cancellationReason: "CANCELLED", shutdownSignalAborted: false })).toBe("pause");
57
+ it("a workflow-requested cancellation without a shutdown signal is the orchestrator's stop (a Pause or a Cancel, indistinguishable here)", () => {
58
+ expect(classifyTurnInterruption({ cancellationReason: "CANCELLED", shutdownSignalAborted: false })).toBe("orchestrator-stop");
59
59
  });
60
60
 
61
61
  it("a delivered cancellation WITH the shutdown signal aborted is a worker shutdown, not a pause (the #776 misclassification)", () => {
@@ -53,8 +53,16 @@ export interface BuildChatModelOptions {
53
53
  mcpServerId?: string;
54
54
  workflowExecutionId?: string;
55
55
  };
56
- /** Defaults to 0. */
57
- readonly temperature?: number;
56
+ /**
57
+ * Defaults to 0. `null` sends no sampling override at all, so the
58
+ * provider's default applies: the native execution turn passes it,
59
+ * because current Anthropic models refuse a temperature other than their
60
+ * default (claude-opus-4.7 and later, claude-sonnet-5, claude-fable-5;
61
+ * @langchain/anthropic throws before the request for some of them) and
62
+ * the Cursor harness sends none either (stigmer/stigmer#1341). Utility
63
+ * calls keep their deterministic 0.
64
+ */
65
+ readonly temperature?: number | null;
58
66
  /**
59
67
  * Pass-through only; intentionally no default. Anthropic requires a value,
60
68
  * but callers differ (setup omits it, call-llm uses 4096), so the default
@@ -258,7 +266,7 @@ export async function buildChatModel(opts: BuildChatModelOptions): Promise<Built
258
266
  ? { maxRetries: opts.maxRetries ?? 0 }
259
267
  : {};
260
268
  const common = {
261
- temperature: opts.temperature ?? 0,
269
+ ...(opts.temperature === null ? {} : { temperature: opts.temperature ?? 0 }),
262
270
  apiKey,
263
271
  ...(maxTokens ? { maxTokens } : {}),
264
272
  ...maxRetries,