@stigmer/server 3.38.1 → 3.38.3

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 (51) hide show
  1. package/dist/domain/agentexecution/controller.js +7 -3
  2. package/dist/domain/agentexecution/controller.js.map +1 -1
  3. package/dist/domain/agentexecution/update-status.d.ts +9 -2
  4. package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
  5. package/dist/domain/agentexecution/update-status.js +28 -5
  6. package/dist/domain/agentexecution/update-status.js.map +1 -1
  7. package/dist/domain/agentexecution/validate-thinking-mode.d.ts +31 -21
  8. package/dist/domain/agentexecution/validate-thinking-mode.d.ts.map +1 -1
  9. package/dist/domain/agentexecution/validate-thinking-mode.js +61 -18
  10. package/dist/domain/agentexecution/validate-thinking-mode.js.map +1 -1
  11. package/dist/domain/artifact/controller.d.ts.map +1 -1
  12. package/dist/domain/artifact/controller.js +9 -6
  13. package/dist/domain/artifact/controller.js.map +1 -1
  14. package/dist/domain/environment/controller.d.ts +2 -2
  15. package/dist/domain/environment/controller.js +7 -3
  16. package/dist/domain/environment/controller.js.map +1 -1
  17. package/dist/domain/environment/steps.d.ts +2 -1
  18. package/dist/domain/environment/steps.d.ts.map +1 -1
  19. package/dist/domain/environment/steps.js +10 -7
  20. package/dist/domain/environment/steps.js.map +1 -1
  21. package/dist/domain/workflow/registry/data/model-registry.json +23 -14
  22. package/dist/domain/workflow/registry/model-registry-store.d.ts +14 -3
  23. package/dist/domain/workflow/registry/model-registry-store.d.ts.map +1 -1
  24. package/dist/domain/workflow/registry/model-registry-store.js +14 -3
  25. package/dist/domain/workflow/registry/model-registry-store.js.map +1 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/pipeline/errors.d.ts +12 -13
  31. package/dist/pipeline/errors.d.ts.map +1 -1
  32. package/dist/pipeline/errors.js +29 -3
  33. package/dist/pipeline/errors.js.map +1 -1
  34. package/package.json +6 -6
  35. package/src/domain/agentexecution/__tests__/run-gate.test.ts +42 -1
  36. package/src/domain/agentexecution/__tests__/update-status.test.ts +58 -2
  37. package/src/domain/agentexecution/__tests__/validate-thinking-mode.test.ts +209 -61
  38. package/src/domain/agentexecution/controller.ts +7 -3
  39. package/src/domain/agentexecution/update-status.ts +43 -3
  40. package/src/domain/agentexecution/validate-thinking-mode.ts +102 -47
  41. package/src/domain/artifact/__tests__/store-faults.test.ts +117 -0
  42. package/src/domain/artifact/controller.ts +12 -6
  43. package/src/domain/environment/__tests__/store-faults.test.ts +150 -0
  44. package/src/domain/environment/controller.ts +8 -5
  45. package/src/domain/environment/steps.ts +10 -7
  46. package/src/domain/workflow/registry/data/model-registry.json +23 -14
  47. package/src/domain/workflow/registry/model-registry-store.ts +16 -3
  48. package/src/index.ts +2 -0
  49. package/src/pipeline/__tests__/blind-not-found.test.ts +0 -3
  50. package/src/pipeline/__tests__/errors.test.ts +72 -0
  51. package/src/pipeline/errors.ts +42 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/server",
3
- "version": "3.38.1",
3
+ "version": "3.38.3",
4
4
  "description": "The Stigmer OSS control plane as a composable library: composeServer, the extension registry and the driver seams a composition builds on. The self-contained deployable of the same server is @stigmer/server-slim.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -63,11 +63,11 @@
63
63
  "@connectrpc/connect-node": "^2.0.0",
64
64
  "@kubernetes/client-node": "2.0.0",
65
65
  "@opentelemetry/api": "^1.9.0",
66
- "@stigmer/outbound": "3.38.1",
67
- "@stigmer/plugin-package": "3.38.1",
68
- "@stigmer/protos": "3.38.1",
69
- "@stigmer/temporal-codecs": "3.38.1",
70
- "@stigmer/zip-structure": "3.38.1",
66
+ "@stigmer/outbound": "3.38.3",
67
+ "@stigmer/plugin-package": "3.38.3",
68
+ "@stigmer/protos": "3.38.3",
69
+ "@stigmer/temporal-codecs": "3.38.3",
70
+ "@stigmer/zip-structure": "3.38.3",
71
71
  "@temporalio/activity": "1.16.2",
72
72
  "@temporalio/client": "1.16.2",
73
73
  "@temporalio/common": "1.16.2",
@@ -7,7 +7,10 @@
7
7
  * denial leaves no execution row AND creates no default instance — the
8
8
  * "no side effect precedes the check" property; (3) the gate precedes the
9
9
  * engine gate: an ALLOWED create on this engineless server answers the
10
- * engine's UNAVAILABLE, a DENIED one PERMISSION_DENIED. The authorizer
10
+ * engine's UNAVAILABLE, a DENIED one PERMISSION_DENIED; (4) the
11
+ * thinking-mode validation, which may read the stored session, runs behind
12
+ * the gate (stigmer/stigmer#1280): a DENIED create with an invalid thinking
13
+ * mode answers PERMISSION_DENIED, an ALLOWED one INVALID_ARGUMENT. The authorizer
11
14
  * under test answers only run-gate checks (runGateOnlyAuthorizer), so every
12
15
  * refusal here is the run gate's and nobody else's.
13
16
  */
@@ -25,6 +28,7 @@ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb"
25
28
  import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
26
29
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
27
30
  import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
31
+ import { ThinkingMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
28
32
 
29
33
  import { loadConfig } from "../../../boot/config.js";
30
34
  import { composeServer } from "../../../boot/compose.js";
@@ -134,6 +138,7 @@ function createInput(spec: {
134
138
  agentId?: string;
135
139
  sessionId?: string;
136
140
  sessionSpec?: { agentInstanceId: string };
141
+ executionConfig?: { modelName?: string; thinkingMode?: ThinkingMode };
137
142
  }) {
138
143
  return {
139
144
  apiVersion: API_VERSION,
@@ -242,4 +247,40 @@ describe("agent-execution-create run gate (composed server)", () => {
242
247
  expect(err.rawMessage).toBe(ENGINE_UNAVAILABLE_MESSAGE);
243
248
  expect(gate.runGateChecks.map((c) => c.resourceId)).toEqual([agentId]);
244
249
  });
250
+
251
+ it("thinking-mode validation runs behind the gate: a denied caller learns nothing about the session", async () => {
252
+ decision = { kind: "deny", reason: "" };
253
+ gate.runGateChecks.length = 0;
254
+
255
+ const err = await captureError(() =>
256
+ command.create(
257
+ createInput({
258
+ sessionId: "ses_01someoneelses",
259
+ executionConfig: { modelName: "composer-2.5", thinkingMode: ThinkingMode.ENABLED },
260
+ }),
261
+ ),
262
+ );
263
+
264
+ expect(err.code).toBe(Code.PermissionDenied);
265
+ expect(err.rawMessage).toBe(addExecutionToSessionDeniedMessage("ses_01someoneelses"));
266
+ });
267
+
268
+ it("an allowed create with an invalid thinking mode is refused by the validation, before the engine gate", async () => {
269
+ decision = { kind: "allow" };
270
+ const agentId = await createAgentWithoutDefaultInstance("Thinking agent");
271
+ const instancesBefore = await count(ApiResourceKind.agent_instance);
272
+
273
+ const err = await captureError(() =>
274
+ command.create(
275
+ createInput({
276
+ agentId,
277
+ executionConfig: { modelName: "composer-2.5", thinkingMode: ThinkingMode.ENABLED },
278
+ }),
279
+ ),
280
+ );
281
+
282
+ expect(err.code).toBe(Code.InvalidArgument);
283
+ expect(err.rawMessage).toContain("no thinking capability");
284
+ expect(await count(ApiResourceKind.agent_instance)).toBe(instancesBefore);
285
+ });
245
286
  });
@@ -8,7 +8,9 @@
8
8
  * Also pins the platform-row rules (platform-rows.ts, stigmer#980): a
9
9
  * platform marker write is appended to the transcript and never replaces
10
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.
11
+ * last across the runner's late writes, whatever order they land in; and a
12
+ * paused execution's phase moves only on the platform's own write
13
+ * (stigmer#1370), so the runner's straggler streaming cannot un-pause it.
12
14
  * These exercise applyUpdateStatusMerge directly on a clone — mirroring
13
15
  * the freshly-loaded resource the merge mutates in place inside the
14
16
  * updateResource write lock.
@@ -37,6 +39,7 @@ import { createLogger } from "../../../boot/logger.js";
37
39
  import { findToolCallInExecution } from "../submit-approval.js";
38
40
  import { CANCEL_STOP_ROW, PAUSE_STOP_ROW } from "../platform-rows.js";
39
41
  import { applyUpdateStatusMerge } from "../update-status.js";
42
+ import type { StatusWriter } from "../update-status.js";
40
43
 
41
44
  const silentLogger = createLogger({
42
45
  level: "error",
@@ -51,13 +54,14 @@ const silentLogger = createLogger({
51
54
  function runBuildStep(
52
55
  existing: AgentExecution,
53
56
  incoming: MessageInitShape<typeof AgentExecutionStatusSchema>,
57
+ writer: StatusWriter = "wire",
54
58
  ): AgentExecution {
55
59
  const input = create(AgentExecutionUpdateStatusInputSchema, {
56
60
  executionId: existing.metadata?.id ?? "",
57
61
  status: incoming,
58
62
  });
59
63
  const merged = clone(AgentExecutionSchema, existing);
60
- applyUpdateStatusMerge(merged, input, silentLogger);
64
+ applyUpdateStatusMerge(merged, input, silentLogger, writer);
61
65
  return merged;
62
66
  }
63
67
 
@@ -584,3 +588,55 @@ describe("platform rows (stigmer#980)", () => {
584
588
  });
585
589
  });
586
590
  });
591
+
592
+ describe("a paused execution stays paused until Resume (stigmer#1370)", () => {
593
+ function pausedWith(...contents: string[]): AgentExecution {
594
+ return create(AgentExecutionSchema, {
595
+ metadata: { id: "exec-paused", name: "exec-paused" },
596
+ spec: {},
597
+ status: {
598
+ phase: ExecutionPhase.EXECUTION_PAUSED,
599
+ messages: [
600
+ ...contents.map((content) => ({ type: MessageType.MESSAGE_AI, content })),
601
+ { type: MessageType.MESSAGE_SYSTEM, content: PAUSE_STOP_ROW },
602
+ ],
603
+ },
604
+ });
605
+ }
606
+ const contentsOf = (e: AgentExecution) => (e.status?.messages ?? []).map((m) => m.content);
607
+
608
+ it("keeps PAUSED across the runner's straggler streaming write, and merges its text beneath the pause row", () => {
609
+ // The Pause RPC and the workflow's row landed; the runner learns of the
610
+ // pause on a later heartbeat and keeps streaming IN_PROGRESS writes that
611
+ // carry its growing transcript without the row.
612
+ const merged = runBuildStep(pausedWith("The press, part"), {
613
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
614
+ messages: [{ type: MessageType.MESSAGE_AI, content: "The press, part one and two" }],
615
+ });
616
+ expect(merged.status?.phase, "a wire write cannot un-pause").toBe(ExecutionPhase.EXECUTION_PAUSED);
617
+ expect(contentsOf(merged)).toEqual(["The press, part one and two", PAUSE_STOP_ROW]);
618
+ });
619
+
620
+ it("keeps PAUSED against a wire phase-only IN_PROGRESS write", () => {
621
+ const merged = runBuildStep(pausedWith("a1"), { phase: ExecutionPhase.EXECUTION_IN_PROGRESS });
622
+ expect(merged.status?.phase).toBe(ExecutionPhase.EXECUTION_PAUSED);
623
+ });
624
+
625
+ it("lets the platform's own write move a paused execution (the workflow's resume heal)", () => {
626
+ const merged = runBuildStep(
627
+ pausedWith("a1"),
628
+ { phase: ExecutionPhase.EXECUTION_IN_PROGRESS },
629
+ "platform",
630
+ );
631
+ expect(merged.status?.phase).toBe(ExecutionPhase.EXECUTION_IN_PROGRESS);
632
+ expect(contentsOf(merged)).toEqual(["a1", PAUSE_STOP_ROW]);
633
+ });
634
+
635
+ it("leaves a wire write on a running execution as it was (the latch is PAUSED's alone)", () => {
636
+ const merged = runBuildStep(existingWith(ExecutionPhase.EXECUTION_IN_PROGRESS, "m1"), {
637
+ phase: ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
638
+ messages: [{ content: "m1" }],
639
+ });
640
+ expect(merged.status?.phase).toBe(ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL);
641
+ });
642
+ });
@@ -1,29 +1,38 @@
1
1
  /**
2
- * Pins validate-thinking-mode.ts against Go's
3
- * validate_thinking_mode_test.go case-for-case: fail-closed create-time
4
- * validation of ExecutionConfig.thinking_mode (#772) against the BUNDLED
5
- * registry. Capability-gated and cursor-harness-scoped: claude-opus-4-6
6
- * declares capabilities.thinking on its cursor entry; composer-2.5's
7
- * cursor entry declares thinking=false; claude-sonnet-4.6 declares the
8
- * capability but only on its NATIVE entry, which has no thinking wire
9
- * mapping in v1 and must refuse.
2
+ * Pins validate-thinking-mode.ts: fail-closed create-time validation of
3
+ * ExecutionConfig.thinking_mode (#772) against the BUNDLED registry, judged
4
+ * on the harness the execution will run on (#1280). The harness comes from
5
+ * the stored session for a turn on an existing session, else the bootstrap
6
+ * session_spec, UNSPECIFIED read as native; a real SQLite store holds the
7
+ * sessions. The bundled rows it leans on: claude-opus-4-6 declares
8
+ * `thinking` on its cursor entry and composer-2.5 declares none;
9
+ * claude-sonnet-5 is adaptive on its native entry and claude-haiku-4.5 is
10
+ * budget-shaped; claude-fable-5's native entry requires thinking.
10
11
  */
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import path from "node:path";
15
+
11
16
  import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
12
17
  import { create } from "@bufbuild/protobuf";
13
18
  import type { MessageInitShape } from "@bufbuild/protobuf";
14
19
  import { Code, ConnectError } from "@connectrpc/connect";
15
- import { describe, expect, it } from "vitest";
20
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
16
21
 
17
22
  import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
18
23
  import {
19
24
  ServiceTier,
20
25
  ThinkingMode,
21
26
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
22
- import { ExecutionConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
27
+ import { AgentExecutionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
28
+ import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
29
+ import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
23
30
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
24
31
 
25
32
  import { createLogger } from "../../../boot/logger.js";
26
33
  import { RequestContext } from "../../../pipeline/request-context.js";
34
+ import type { Store } from "../../../store/interface.js";
35
+ import { SqliteStore } from "../../../store/sqlite/store.js";
27
36
  import { bundledModelRegistryDocument } from "../../workflow/registry/bundled.js";
28
37
  import { ModelRegistryStore } from "../../workflow/registry/model-registry-store.js";
29
38
  import { newValidateThinkingModeStep } from "../validate-thinking-mode.js";
@@ -41,16 +50,31 @@ const registry = new ModelRegistryStore({
41
50
  logger: silentLogger,
42
51
  });
43
52
 
44
- const step = newValidateThinkingModeStep(registry);
53
+ let dir: string;
54
+ let store: Store;
55
+
56
+ beforeEach(() => {
57
+ dir = mkdtempSync(path.join(tmpdir(), "validate-thinking-mode-test-"));
58
+ store = SqliteStore.open(path.join(dir, "stigmer.db"));
59
+ });
60
+
61
+ afterEach(() => {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ });
64
+
65
+ type SpecInit = MessageInitShape<typeof AgentExecutionSpecSchema>;
66
+ type ExecutionConfigInit = NonNullable<SpecInit["executionConfig"]>;
45
67
 
46
68
  function contextFor(
47
- config: MessageInitShape<typeof ExecutionConfigSchema> | undefined,
69
+ config: ExecutionConfigInit | undefined,
70
+ target: Pick<SpecInit, "sessionId" | "sessionSpec"> = {},
48
71
  ): RequestContext<typeof AgentExecutionSchema> {
49
72
  return new RequestContext(
50
73
  AgentExecutionSchema,
51
74
  create(AgentExecutionSchema, {
52
75
  spec: {
53
76
  message: "hello",
77
+ ...target,
54
78
  ...(config === undefined ? {} : { executionConfig: config }),
55
79
  },
56
80
  }),
@@ -59,11 +83,24 @@ function contextFor(
59
83
  );
60
84
  }
61
85
 
62
- function refusal(
63
- config: MessageInitShape<typeof ExecutionConfigSchema>,
64
- ): ConnectError {
86
+ const onCursor = { sessionSpec: { harness: Harness.CURSOR } };
87
+ const onNative = { sessionSpec: { harness: Harness.NATIVE } };
88
+
89
+ async function passes(
90
+ config: ExecutionConfigInit | undefined,
91
+ target: Pick<SpecInit, "sessionId" | "sessionSpec"> = {},
92
+ onStore: Store = store,
93
+ ): Promise<void> {
94
+ await newValidateThinkingModeStep(registry, onStore).execute(contextFor(config, target));
95
+ }
96
+
97
+ async function refusal(
98
+ config: ExecutionConfigInit,
99
+ target: Pick<SpecInit, "sessionId" | "sessionSpec"> = {},
100
+ onStore: Store = store,
101
+ ): Promise<ConnectError> {
65
102
  try {
66
- step.execute(contextFor(config));
103
+ await passes(config, target, onStore);
67
104
  } catch (error) {
68
105
  expect(error).toBeInstanceOf(ConnectError);
69
106
  return error as ConnectError;
@@ -71,73 +108,184 @@ function refusal(
71
108
  throw new Error("expected a fail-closed refusal, step passed");
72
109
  }
73
110
 
74
- describe("ValidateThinkingMode (#772, bundled registry)", () => {
75
- it("no execution_config passes", () => {
76
- expect(() => step.execute(contextFor(undefined))).not.toThrow();
111
+ async function storeSession(id: string, harness: Harness): Promise<void> {
112
+ await store.saveResource(
113
+ ApiResourceKind.session,
114
+ id,
115
+ SessionSchema,
116
+ create(SessionSchema, { metadata: { id, org: "acme" }, spec: { harness } }),
117
+ );
118
+ }
119
+
120
+ describe("ValidateThinkingMode: modes that need no harness", () => {
121
+ it("no execution_config passes", async () => {
122
+ await expect(passes(undefined)).resolves.toBeUndefined();
77
123
  });
78
124
 
79
- it("explicit DISABLED passes without a model", () => {
80
- expect(() =>
81
- step.execute(contextFor({ thinkingMode: ThinkingMode.DISABLED })),
82
- ).not.toThrow();
125
+ it("explicit DISABLED passes without a model", async () => {
126
+ await expect(passes({ thinkingMode: ThinkingMode.DISABLED })).resolves.toBeUndefined();
83
127
  });
128
+ });
84
129
 
85
- it("ENABLED with a thinking-capable cursor model passes", () => {
86
- expect(() =>
87
- step.execute(
88
- contextFor({
89
- modelName: "claude-opus-4-6",
90
- thinkingMode: ThinkingMode.ENABLED,
91
- }),
92
- ),
93
- ).not.toThrow();
130
+ describe("ValidateThinkingMode on the cursor harness", () => {
131
+ it("ENABLED with a thinking-capable cursor model passes", async () => {
132
+ await expect(
133
+ passes({ modelName: "claude-opus-4-6", thinkingMode: ThinkingMode.ENABLED }, onCursor),
134
+ ).resolves.toBeUndefined();
94
135
  });
95
136
 
96
- it("ENABLED combines freely with FAST — the combination bills as the fast variant", () => {
97
- expect(() =>
98
- step.execute(
99
- contextFor({
100
- modelName: "claude-opus-4-6",
101
- serviceTier: ServiceTier.FAST,
102
- thinkingMode: ThinkingMode.ENABLED,
103
- }),
137
+ it("ENABLED combines freely with FAST — the combination bills as the fast variant", async () => {
138
+ await expect(
139
+ passes(
140
+ { modelName: "claude-opus-4-6", serviceTier: ServiceTier.FAST, thinkingMode: ThinkingMode.ENABLED },
141
+ onCursor,
104
142
  ),
105
- ).not.toThrow();
143
+ ).resolves.toBeUndefined();
106
144
  });
107
145
 
108
- it("ENABLED without model_name fails closed (no thinking-on-Auto)", () => {
109
- const err = refusal({ thinkingMode: ThinkingMode.ENABLED });
146
+ it("ENABLED without model_name fails closed, naming the cursor models that think", async () => {
147
+ const err = await refusal({ thinkingMode: ThinkingMode.ENABLED }, onCursor);
110
148
  expect(err.code).toBe(Code.InvalidArgument);
111
149
  expect(err.rawMessage).toContain("requires execution_config.model_name");
112
- // Thinking-capable suggestions ride along.
113
150
  expect(err.rawMessage).toContain("claude-opus-4-6");
114
151
  });
115
152
 
116
- it("ENABLED on a cursor model without the capability fails closed", () => {
117
- const err = refusal({
118
- modelName: "composer-2.5",
119
- thinkingMode: ThinkingMode.ENABLED,
120
- });
153
+ it("ENABLED on a cursor model without the capability fails closed", async () => {
154
+ const err = await refusal({ modelName: "composer-2.5", thinkingMode: ThinkingMode.ENABLED }, onCursor);
121
155
  expect(err.code).toBe(Code.InvalidArgument);
122
156
  expect(err.rawMessage).toContain("no thinking capability");
123
157
  expect(err.rawMessage).toContain("composer-2.5");
158
+ expect(err.rawMessage).toContain("cursor harness");
124
159
  });
125
160
 
126
- it("ENABLED on a native-only model fails closed (no native wire mapping in v1)", () => {
127
- const err = refusal({
128
- modelName: "claude-sonnet-4.6",
129
- thinkingMode: ThinkingMode.ENABLED,
130
- });
161
+ it("ENABLED on a native-only model fails closed on cursor: the cursor entry is the one that decides", async () => {
162
+ const err = await refusal({ modelName: "claude-sonnet-4.6", thinkingMode: ThinkingMode.ENABLED }, onCursor);
131
163
  expect(err.code).toBe(Code.InvalidArgument);
132
- expect(err.rawMessage).toContain("cursor");
133
- expect(err.rawMessage).toContain("claude-sonnet-4.6");
164
+ expect(err.rawMessage).toContain("cursor harness");
134
165
  });
135
166
 
136
- it("ENABLED on an unknown model fails closed", () => {
137
- const err = refusal({
138
- modelName: "not-a-model",
139
- thinkingMode: ThinkingMode.ENABLED,
140
- });
167
+ it("DISABLED on claude-fable-5 passes on cursor: only its native entry requires thinking", async () => {
168
+ await expect(
169
+ passes({ modelName: "claude-fable-5", thinkingMode: ThinkingMode.DISABLED }, onCursor),
170
+ ).resolves.toBeUndefined();
171
+ });
172
+ });
173
+
174
+ describe("ValidateThinkingMode on the native harness (#1280)", () => {
175
+ it("ENABLED on an adaptive native model passes — the #1280 case, claude-sonnet-5 on a native session", async () => {
176
+ await expect(
177
+ passes({ modelName: "claude-sonnet-5", thinkingMode: ThinkingMode.ENABLED }, onNative),
178
+ ).resolves.toBeUndefined();
179
+ });
180
+
181
+ it("ENABLED on a budget-shaped native model passes", async () => {
182
+ await expect(
183
+ passes({ modelName: "claude-haiku-4.5", thinkingMode: ThinkingMode.ENABLED }, onNative),
184
+ ).resolves.toBeUndefined();
185
+ });
186
+
187
+ it("an UNSPECIFIED harness is native", async () => {
188
+ await expect(
189
+ passes({ modelName: "claude-sonnet-4.6", thinkingMode: ThinkingMode.ENABLED }, { sessionSpec: {} }),
190
+ ).resolves.toBeUndefined();
191
+ await expect(passes({ modelName: "claude-sonnet-4.6", thinkingMode: ThinkingMode.ENABLED })).resolves.toBeUndefined();
192
+ });
193
+
194
+ it("ENABLED on a model with no native entry fails closed, naming the native models that think", async () => {
195
+ const err = await refusal({ modelName: "composer-2.5", thinkingMode: ThinkingMode.ENABLED }, onNative);
196
+ expect(err.code).toBe(Code.InvalidArgument);
197
+ expect(err.rawMessage).toContain("native harness");
198
+ expect(err.rawMessage).toContain("claude-sonnet-5");
199
+ expect(err.rawMessage).toContain("claude-haiku-4.5");
200
+ });
201
+
202
+ it("ENABLED on an unknown model fails closed", async () => {
203
+ const err = await refusal({ modelName: "not-a-model", thinkingMode: ThinkingMode.ENABLED }, onNative);
204
+ expect(err.code).toBe(Code.InvalidArgument);
205
+ });
206
+
207
+ it("an explicit DISABLED on a model that requires thinking fails closed", async () => {
208
+ const err = await refusal({ modelName: "claude-fable-5", thinkingMode: ThinkingMode.DISABLED }, onNative);
141
209
  expect(err.code).toBe(Code.InvalidArgument);
210
+ expect(err.rawMessage).toContain("always thinks");
211
+ expect(err.rawMessage).toContain("claude-fable-5");
212
+ });
213
+
214
+ it("UNSPECIFIED and ENABLED pass on a model that requires thinking", async () => {
215
+ await expect(passes({ modelName: "claude-fable-5" }, onNative)).resolves.toBeUndefined();
216
+ await expect(
217
+ passes({ modelName: "claude-fable-5", thinkingMode: ThinkingMode.ENABLED }, onNative),
218
+ ).resolves.toBeUndefined();
219
+ });
220
+
221
+ it("an explicit DISABLED passes on a model that may turn thinking off", async () => {
222
+ await expect(
223
+ passes({ modelName: "claude-sonnet-5", thinkingMode: ThinkingMode.DISABLED }, onNative),
224
+ ).resolves.toBeUndefined();
225
+ });
226
+ });
227
+
228
+ describe("ValidateThinkingMode reads a stored session's harness", () => {
229
+ it("a turn on a stored cursor session is judged on cursor", async () => {
230
+ await storeSession("ses_cursor", Harness.CURSOR);
231
+
232
+ await expect(
233
+ passes({ modelName: "claude-opus-4-6", thinkingMode: ThinkingMode.ENABLED }, { sessionId: "ses_cursor" }),
234
+ ).resolves.toBeUndefined();
235
+ const err = await refusal({ modelName: "claude-sonnet-4.6", thinkingMode: ThinkingMode.ENABLED }, { sessionId: "ses_cursor" });
236
+ expect(err.rawMessage).toContain("cursor harness");
237
+ });
238
+
239
+ it("a turn on a stored native session is judged on native, whatever the request's session_spec says", async () => {
240
+ await storeSession("ses_native", Harness.NATIVE);
241
+
242
+ await expect(
243
+ passes(
244
+ { modelName: "claude-sonnet-5", thinkingMode: ThinkingMode.ENABLED },
245
+ { sessionId: "ses_native", sessionSpec: { harness: Harness.CURSOR } },
246
+ ),
247
+ ).resolves.toBeUndefined();
248
+ });
249
+
250
+ it("a dangling session id is judged on the default native harness", async () => {
251
+ await expect(
252
+ passes({ modelName: "claude-sonnet-5", thinkingMode: ThinkingMode.ENABLED }, { sessionId: "ses_missing" }),
253
+ ).resolves.toBeUndefined();
254
+ });
255
+
256
+ it("a store fault is Internal, never a reading of the session", async () => {
257
+ const faulty = new Proxy(store, {
258
+ get(target, prop, receiver) {
259
+ if (prop === "getResource") {
260
+ return async () => {
261
+ throw new Error("simulated store fault");
262
+ };
263
+ }
264
+ return Reflect.get(target, prop, receiver);
265
+ },
266
+ });
267
+
268
+ const err = await refusal(
269
+ { modelName: "claude-sonnet-5", thinkingMode: ThinkingMode.ENABLED },
270
+ { sessionId: "ses_any" },
271
+ faulty,
272
+ );
273
+ expect(err.code).toBe(Code.Internal);
274
+ });
275
+
276
+ it("a mode that needs no harness never reads the store", async () => {
277
+ const untouchable = new Proxy(store, {
278
+ get(target, prop, receiver) {
279
+ if (prop === "getResource") {
280
+ throw new Error("the store must not be read");
281
+ }
282
+ return Reflect.get(target, prop, receiver);
283
+ },
284
+ });
285
+
286
+ await expect(passes(undefined, { sessionId: "ses_any" }, untouchable)).resolves.toBeUndefined();
287
+ await expect(
288
+ passes({ thinkingMode: ThinkingMode.DISABLED }, { sessionId: "ses_any" }, untouchable),
289
+ ).resolves.toBeUndefined();
142
290
  });
143
291
  });
@@ -279,12 +279,16 @@ export function registerAgentExecutionServices(
279
279
 
280
280
  /**
281
281
  * Create — create.go buildCreatePipeline, step-for-step: validation
282
- * (proto → visibility → tier #357 → thinking #772) → the run gate
282
+ * (proto → visibility → tier #357) → the run gate
283
283
  * (AuthorizeRunTarget, P1 sp.run-gate: asking the target's own permission
284
284
  * by request shape — session, instance or blueprint; the all-empty shape
285
285
  * is the built-in assistant, admitted by Authorize's organization check
286
286
  * and gated on nothing further — before the engine gate so a denied caller
287
- * learns nothing about engine state, and before every side effect) → the standard build
287
+ * learns nothing about engine state, and before every side effect) → the
288
+ * thinking-mode validation (#772), which judges the model on the harness
289
+ * the execution will run on and so may read the stored session: it runs
290
+ * behind the run gate, so nothing about a session is read or disclosed
291
+ * before the caller may add a turn to it (#1280) → the standard build
288
292
  * → the engine gate (fail fast BEFORE the first side effect, so a down
289
293
  * engine orphans nothing) → the pre-side-effect gate slot (O4; empty in OSS) → the
290
294
  * side-effecting steps (default instance, session bootstrap,
@@ -317,10 +321,10 @@ async function createExecution(
317
321
  .addStep(newValidateProtoStep())
318
322
  .addStep(newValidateVisibilityStep())
319
323
  .addStep(newValidateServiceTierStep(deps.modelRegistry))
320
- .addStep(newValidateThinkingModeStep(deps.modelRegistry))
321
324
  .addStep(
322
325
  newAuthorizeRunTargetStep(deps.authorizer, agentExecutionRunTarget),
323
326
  )
327
+ .addStep(newValidateThinkingModeStep(deps.modelRegistry, deps.store))
324
328
  .addStep(newResolveSlugStep())
325
329
  .addStep(newBuildNewStateStep())
326
330
  // Vouches the runner-stamped workflow lineage labels (or refuses a
@@ -29,7 +29,11 @@
29
29
  * appended and never replaces the transcript, and a PAUSED or CANCELLED
30
30
  * execution's stop row stays last across the runner's later writes,
31
31
  * because the Pause and Cancel RPCs, the invoke workflow and the runner
32
- * all write a stopped execution and nothing orders them.
32
+ * all write a stopped execution and nothing orders them. For the same
33
+ * reason a PAUSED execution's phase moves only on the platform's own
34
+ * write (stigmer#1370): the runner learns of a pause on a later heartbeat
35
+ * and keeps streaming IN_PROGRESS until then, and without the latch its
36
+ * stragglers un-pause the execution and Resume finds nothing to resume.
33
37
  *
34
38
  * Broadcast rides in-memory channels (ADR 011).
35
39
  */
@@ -66,6 +70,7 @@ import {
66
70
  } from "../../pipeline/errors.js";
67
71
  import { newPipeline } from "../../pipeline/pipeline.js";
68
72
  import type { CallerIdentity } from "../../extensions/identity.js";
73
+ import { isServerComposedRequest } from "../../extensions/identity.js";
69
74
  import { RequestContext } from "../../pipeline/request-context.js";
70
75
  import { newAuthorizeStep } from "../../pipeline/steps/authorize.js";
71
76
  import { ResourceNotFoundError } from "../../store/interface.js";
@@ -161,7 +166,12 @@ export async function updateStatus(
161
166
  oldPhase =
162
167
  execution.status?.phase ??
163
168
  ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
164
- applyUpdateStatusMerge(execution, ctx.input, deps.logger);
169
+ applyUpdateStatusMerge(
170
+ execution,
171
+ ctx.input,
172
+ deps.logger,
173
+ isServerComposedRequest(identity) ? "platform" : "wire",
174
+ );
165
175
  },
166
176
  );
167
177
  } catch (error) {
@@ -315,18 +325,27 @@ function mergeTranscript(
315
325
  return next;
316
326
  }
317
327
 
328
+ /**
329
+ * Who sent a status write: the platform's own (the invoke workflow's
330
+ * writes, which reach this handler over the in-process transport, the
331
+ * one origin the wire cannot claim) or the wire (the runner).
332
+ */
333
+ export type StatusWriter = "platform" | "wire";
334
+
318
335
  /**
319
336
  * Merges an incoming status update into the execution in place — the
320
337
  * runner-owns-the-transcript merge rules, exactly update_status.go
321
338
  * applyUpdateStatusMerge. Runs inside the updateResource closure
322
339
  * (synchronous by store contract), so the merge, the approval event
323
340
  * authoring, and the pending_approvals projection all see the same
324
- * snapshot that will be persisted.
341
+ * snapshot that will be persisted. `writer` defaults to the wire, the
342
+ * side that grants nothing extra.
325
343
  */
326
344
  export function applyUpdateStatusMerge(
327
345
  execution: AgentExecution,
328
346
  input: AgentExecutionUpdateStatusInput,
329
347
  logger: Logger,
348
+ writer: StatusWriter = "wire",
330
349
  ): void {
331
350
  if (execution.status === undefined) {
332
351
  execution.status = create(AgentExecutionStatusSchema);
@@ -415,8 +434,29 @@ export function applyUpdateStatusMerge(
415
434
  // re-terminalize it is gone. Recover is the one sanctioned
416
435
  // un-terminalizer and runs through its own lifecycle step, never this
417
436
  // merge.
437
+ //
438
+ // A PAUSED execution is latched the same way against the wire
439
+ // (stigmer#1370): the runner's straggler persists, sent before its
440
+ // heartbeat delivers the pause, carry IN_PROGRESS. Resume moves the
441
+ // phase through its own lifecycle step; through this merge only the
442
+ // platform's own writes do (the workflow's resume and recovery
443
+ // re-assertions, which also heal a stale PAUSED over a fast resume,
444
+ // oss#869). The straggler's transcript still merges, beneath the held
445
+ // pause row.
418
446
  if (requestStatus.phase !== ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED) {
419
447
  if (
448
+ existingPhase === ExecutionPhase.EXECUTION_PAUSED &&
449
+ writer === "wire" &&
450
+ requestStatus.phase !== existingPhase
451
+ ) {
452
+ logger.warn(
453
+ "Ignored a wire phase change on a paused execution; Resume or the platform's own write moves it",
454
+ {
455
+ executionId: input.executionId,
456
+ ignoredPhase: ExecutionPhase[requestStatus.phase],
457
+ },
458
+ );
459
+ } else if (
420
460
  isTerminalExecutionPhase(existingPhase) &&
421
461
  requestStatus.phase !== existingPhase
422
462
  ) {