@stigmer/runner 3.14.0-dev.20260910084630 → 3.14.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.
- package/dist/.build-fingerprint +1 -1
- package/dist/__test-utils__/hermetic-activity.d.ts +245 -0
- package/dist/__test-utils__/hermetic-activity.js +369 -0
- package/dist/__test-utils__/hermetic-activity.js.map +1 -0
- package/dist/__test-utils__/mock-client.d.ts +13 -0
- package/dist/__test-utils__/mock-client.js +45 -0
- package/dist/__test-utils__/mock-client.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +172 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +331 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +167 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +239 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +97 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +132 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -0
- package/dist/harness/capabilities.d.ts +71 -0
- package/dist/harness/capabilities.js +36 -0
- package/dist/harness/capabilities.js.map +1 -0
- package/dist/harness/registry.d.ts +67 -0
- package/dist/harness/registry.js +112 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/harness/types.d.ts +268 -0
- package/dist/harness/types.js +55 -0
- package/dist/harness/types.js.map +1 -0
- package/package.json +4 -4
- package/src/__test-utils__/__tests__/harness-contract-self-check.test.ts +229 -0
- package/src/__test-utils__/config-fixture.ts +63 -0
- package/src/__test-utils__/harness-contract/contract.ts +536 -0
- package/src/__test-utils__/harness-contract/recording-sink.ts +96 -0
- package/src/__test-utils__/harness-contract/scripted-adapter.ts +289 -0
- package/src/__test-utils__/harness-contract/types.ts +100 -0
- package/src/__test-utils__/hermetic-activity.ts +477 -0
- package/src/__test-utils__/proto-helpers.ts +25 -0
- package/src/__tests__/harness-contract.test.ts +25 -0
- package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +422 -0
- package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +342 -0
- package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +166 -0
- package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +228 -0
- package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +180 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn1.status.json +55 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn2.status.json +77 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +126 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/pause.status.json +45 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/plain-turn.status.json +48 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/recovery-fresh-agent.status.json +53 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/tool-call.status.json +68 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/worker-shutdown.status.json +47 -0
- package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +201 -0
- package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +171 -0
- package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +156 -0
- package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +137 -0
- package/src/harness/__tests__/registry.test.ts +167 -0
- package/src/harness/capabilities.ts +75 -0
- package/src/harness/registry.ts +123 -0
- package/src/harness/types.ts +278 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness contract kit's self-check: proof that every invariant in
|
|
3
|
+
* `harness-contract/contract.ts` can FAIL.
|
|
4
|
+
*
|
|
5
|
+
* A contract kit that only ever passes proves nothing about the adapters it
|
|
6
|
+
* runs against — it might be asserting the wrong thing, or nothing. So each
|
|
7
|
+
* invariant is run here against an adapter deliberately broken in exactly the
|
|
8
|
+
* way that invariant exists to catch, and the kit must reject with a message
|
|
9
|
+
* that names the subject and the violation. The tester role's rule: a test
|
|
10
|
+
* that cannot fail is not a test.
|
|
11
|
+
*
|
|
12
|
+
* Every broken adapter wraps the honest scripted fake and breaks ONE thing,
|
|
13
|
+
* so a rejection here is attributable to the kit's assertion and not to some
|
|
14
|
+
* other defect of the double. Where an invariant races a hang against the
|
|
15
|
+
* interrupt bound, the bound is shortened through the assertion's parameter;
|
|
16
|
+
* the production bound stays what `contract.ts` declares.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from "vitest";
|
|
20
|
+
import { CancelledFailure } from "@temporalio/activity";
|
|
21
|
+
import { ApprovalAction, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
22
|
+
|
|
23
|
+
import type { HarnessAdapter, TurnInput, TurnOutcome, TurnSink } from "../../harness/types.js";
|
|
24
|
+
import {
|
|
25
|
+
assertConcurrentTurnsAreIndependent,
|
|
26
|
+
assertDecisionsExecuteExactlyOnce,
|
|
27
|
+
assertEveryExitIsAnOutcome,
|
|
28
|
+
assertLifetimesResolve,
|
|
29
|
+
assertProposalIsWaitingAndUnexecuted,
|
|
30
|
+
assertStateIdCapabilityAgrees,
|
|
31
|
+
assertStopSignalInterrupts,
|
|
32
|
+
assertUsageReachesSinkAsDeltas,
|
|
33
|
+
} from "../harness-contract/contract.js";
|
|
34
|
+
import { scriptedSubject } from "../harness-contract/scripted-adapter.js";
|
|
35
|
+
import type { ScriptedSubject } from "../harness-contract/scripted-adapter.js";
|
|
36
|
+
import type { HarnessContractSubject, TurnScenario } from "../harness-contract/types.js";
|
|
37
|
+
|
|
38
|
+
/** Short enough to keep this file fast; long enough that an honest microtask settle never trips it. */
|
|
39
|
+
const SHORT_BOUND_MS = 100;
|
|
40
|
+
|
|
41
|
+
// ── Wrapping helpers ────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** The honest fake's adapter with the named members replaced. */
|
|
44
|
+
function adapterOver(inner: HarnessAdapter, name: string, patch: Partial<HarnessAdapter>): HarnessAdapter {
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
capabilities: inner.capabilities,
|
|
48
|
+
boot: (config) => inner.boot(config),
|
|
49
|
+
shutdown: () => inner.shutdown(),
|
|
50
|
+
releaseSession: (sessionId) => inner.releaseSession(sessionId),
|
|
51
|
+
runTurn: (input, sink) => inner.runTurn(input, sink),
|
|
52
|
+
...patch,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A sink that delegates to the real one except for the named members. */
|
|
57
|
+
function sinkOver(inner: TurnSink, patch: Partial<TurnSink>): TurnSink {
|
|
58
|
+
return {
|
|
59
|
+
status: inner.status,
|
|
60
|
+
stopSignal: inner.stopSignal,
|
|
61
|
+
requestPersist: () => inner.requestPersist(),
|
|
62
|
+
recordActivity: () => inner.recordActivity(),
|
|
63
|
+
reportUsage: (delta) => inner.reportUsage(delta),
|
|
64
|
+
bindHarnessState: (id) => inner.bindHarnessState(id),
|
|
65
|
+
...patch,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A subject over a broken adapter that still arranges and observes through the honest fake. */
|
|
70
|
+
function subjectOver(inner: ScriptedSubject, adapter: HarnessAdapter): HarnessContractSubject {
|
|
71
|
+
return {
|
|
72
|
+
name: adapter.name,
|
|
73
|
+
adapter,
|
|
74
|
+
config: inner.config,
|
|
75
|
+
arrange: (turn) => inner.arrange(turn),
|
|
76
|
+
executionCount: (toolCallId) => inner.executionCount(toolCallId),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function honest(): ScriptedSubject {
|
|
81
|
+
return scriptedSubject({ pausePrimitive: "interrupt", stateIdSource: "engine-minted" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── The broken adapters, one violation each ─────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** Throws the one thing an adapter must never throw. */
|
|
87
|
+
function throwsCancelledFailure(): HarnessContractSubject {
|
|
88
|
+
const inner = honest();
|
|
89
|
+
return subjectOver(inner, adapterOver(inner.adapter, "broken:throws-CancelledFailure", {
|
|
90
|
+
runTurn: async () => {
|
|
91
|
+
throw new CancelledFailure("adapter decided this was a pause");
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Treats every proposal as approved: executes what was never decided. */
|
|
97
|
+
function executesUndecidedProposals(): HarnessContractSubject {
|
|
98
|
+
const inner = honest();
|
|
99
|
+
let proposedIds: string[] = [];
|
|
100
|
+
const adapter = adapterOver(inner.adapter, "broken:executes-undecided", {
|
|
101
|
+
runTurn: (input, sink) => {
|
|
102
|
+
const approvalDecisions = new Map(input.approvalDecisions);
|
|
103
|
+
for (const id of proposedIds) if (!approvalDecisions.has(id)) approvalDecisions.set(id, ApprovalAction.APPROVE);
|
|
104
|
+
return inner.adapter.runTurn({ ...input, approvalDecisions }, sink);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
...subjectOver(inner, adapter),
|
|
109
|
+
arrange: (turn: TurnScenario) => {
|
|
110
|
+
proposedIds = turn.flatMap((step) => (step.kind === "propose" ? [step.toolCallId] : []));
|
|
111
|
+
inner.arrange(turn);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Re-gates a row an earlier invocation already settled — the second-ledger drift. */
|
|
117
|
+
function reGatesSettledRows(): HarnessContractSubject {
|
|
118
|
+
const inner = honest();
|
|
119
|
+
return subjectOver(inner, adapterOver(inner.adapter, "broken:re-gates-settled", {
|
|
120
|
+
runTurn: (input, sink) => {
|
|
121
|
+
for (const message of sink.status.messages) {
|
|
122
|
+
for (const row of message.toolCalls) {
|
|
123
|
+
if (row.status === ToolCallStatus.TOOL_CALL_COMPLETED) row.status = ToolCallStatus.TOOL_CALL_WAITING_APPROVAL;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return inner.adapter.runTurn(input, sink);
|
|
127
|
+
},
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Hands the engine a signal nobody will ever abort. */
|
|
132
|
+
function ignoresStopSignal(): HarnessContractSubject {
|
|
133
|
+
const inner = honest();
|
|
134
|
+
return subjectOver(inner, adapterOver(inner.adapter, "broken:ignores-stopSignal", {
|
|
135
|
+
runTurn: (input, sink) => inner.adapter.runTurn(input, sinkOver(sink, { stopSignal: new AbortController().signal })),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Reports running totals where the contract asks for deltas. */
|
|
140
|
+
function reportsCumulativeUsage(): HarnessContractSubject {
|
|
141
|
+
const inner = honest();
|
|
142
|
+
return subjectOver(inner, adapterOver(inner.adapter, "broken:cumulative-usage", {
|
|
143
|
+
runTurn: (input, sink) => {
|
|
144
|
+
const total = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
145
|
+
return inner.adapter.runTurn(input, sinkOver(sink, {
|
|
146
|
+
reportUsage: (delta) => {
|
|
147
|
+
total.inputTokens += delta.inputTokens ?? 0;
|
|
148
|
+
total.outputTokens += delta.outputTokens ?? 0;
|
|
149
|
+
total.cacheReadTokens += delta.cacheReadTokens ?? 0;
|
|
150
|
+
total.cacheWriteTokens += delta.cacheWriteTokens ?? 0;
|
|
151
|
+
sink.reportUsage({ ...total });
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
},
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Declares an engine-minted id and never binds one. */
|
|
159
|
+
function claimsEngineMintedNeverBinds(): HarnessContractSubject {
|
|
160
|
+
const inner = scriptedSubject({ pausePrimitive: "interrupt", stateIdSource: "deterministic" });
|
|
161
|
+
return subjectOver(inner, adapterOver(inner.adapter, "broken:claims-engine-minted", {
|
|
162
|
+
capabilities: { ...inner.adapter.capabilities, stateIdSource: "engine-minted" },
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Refuses to release a session it does not know. */
|
|
167
|
+
function refusesUnknownRelease(): HarnessContractSubject {
|
|
168
|
+
const inner = honest();
|
|
169
|
+
const served = new Set<string>();
|
|
170
|
+
return subjectOver(inner, adapterOver(inner.adapter, "broken:refuses-unknown-release", {
|
|
171
|
+
runTurn: (input, sink) => {
|
|
172
|
+
served.add(input.sessionId);
|
|
173
|
+
return inner.adapter.runTurn(input, sink);
|
|
174
|
+
},
|
|
175
|
+
releaseSession: async (sessionId) => {
|
|
176
|
+
if (!served.has(sessionId)) throw new Error(`unknown session ${sessionId}`);
|
|
177
|
+
await inner.adapter.releaseSession(sessionId);
|
|
178
|
+
},
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Runs turns one at a time through a single slot: per-turn state on the adapter object. */
|
|
183
|
+
function serializesTurns(): HarnessContractSubject {
|
|
184
|
+
const inner = honest();
|
|
185
|
+
let slot: Promise<unknown> = Promise.resolve();
|
|
186
|
+
return subjectOver(inner, adapterOver(inner.adapter, "broken:serializes-turns", {
|
|
187
|
+
runTurn: (input: TurnInput, sink: TurnSink): Promise<TurnOutcome> => {
|
|
188
|
+
const next = slot.then(() => inner.adapter.runTurn(input, sink));
|
|
189
|
+
slot = next.catch(() => undefined);
|
|
190
|
+
return next;
|
|
191
|
+
},
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── The proof ───────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
describe("harness contract kit self-check — every invariant can fail", () => {
|
|
198
|
+
it("invariant 1 fires when runTurn throws a CancelledFailure", async () => {
|
|
199
|
+
await expect(assertEveryExitIsAnOutcome(throwsCancelledFailure(), SHORT_BOUND_MS)).rejects.toThrow(/broken:throws-CancelledFailure: runTurn rejected with a CancelledFailure/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("invariant 2 fires when an undecided proposal executes", async () => {
|
|
203
|
+
await expect(assertProposalIsWaitingAndUnexecuted(executesUndecidedProposals())).rejects.toThrow(/broken:executes-undecided: .*awaiting_approval/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("invariant 3 fires when a settled row is re-gated on a later invocation", async () => {
|
|
207
|
+
await expect(assertDecisionsExecuteExactlyOnce(reGatesSettledRows())).rejects.toThrow(/broken:re-gates-settled: .*must not re-gate/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("invariant 4 fires when the adapter ignores stopSignal", async () => {
|
|
211
|
+
await expect(assertStopSignalInterrupts(ignoresStopSignal(), SHORT_BOUND_MS)).rejects.toThrow(/broken:ignores-stopSignal: runTurn did not settle within 100ms/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("invariant 5 fires when usage is reported as running totals", async () => {
|
|
215
|
+
await expect(assertUsageReachesSinkAsDeltas(reportsCumulativeUsage())).rejects.toThrow(/broken:cumulative-usage: .*must sum to what the engine emitted/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("invariant 6 fires when an adapter claims engine-minted and never binds", async () => {
|
|
219
|
+
await expect(assertStateIdCapabilityAgrees(claimsEngineMintedNeverBinds())).rejects.toThrow(/broken:claims-engine-minted: .*must bind its state id/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("invariant 7 fires when releaseSession rejects an unknown session", async () => {
|
|
223
|
+
await expect(assertLifetimesResolve(refusesUnknownRelease())).rejects.toThrow(/broken:refuses-unknown-release: releaseSession for an unknown session rejected/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("invariant 8 fires when the adapter serializes turns through shared state", async () => {
|
|
227
|
+
await expect(assertConcurrentTurnsAreIndependent(serializesTurns(), SHORT_BOUND_MS)).rejects.toThrow(/broken:serializes-turns: runTurn did not settle within 100ms/);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A complete, inert runner `Config` for tests.
|
|
3
|
+
*
|
|
4
|
+
* `Config` has twenty required fields (plus two optional token refs), so
|
|
5
|
+
* every test that needs one has had to spell all of them out; eight test
|
|
6
|
+
* files carry their own literal today. This is the one place a test-only `Config` is
|
|
7
|
+
* built from now on: {@link testConfig} returns the whole record with values
|
|
8
|
+
* that dial nothing (loopback endpoints on a port nothing listens on, no
|
|
9
|
+
* proxy, no token, the in-memory checkpointer, a temp workspace root) and
|
|
10
|
+
* takes a `Partial<Config>` for the fields a test cares about.
|
|
11
|
+
*
|
|
12
|
+
* Inert means: a component handed this config may READ every field, but a
|
|
13
|
+
* component that tries to ACT on one (connect, authenticate, spawn) fails
|
|
14
|
+
* loudly and immediately rather than reaching a real service. That is the
|
|
15
|
+
* posture the harness contract kit needs for `HarnessAdapter.boot(config)`,
|
|
16
|
+
* and the one the existing inline literals were reaching for one at a time.
|
|
17
|
+
*
|
|
18
|
+
* Not a `vi.mock`: `Config` is plain data, and a fixture that is plain data
|
|
19
|
+
* stays type-checked against the real interface, so a field added to `Config`
|
|
20
|
+
* fails here at `tsc` time instead of at the first test that needed it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
import type { Config } from "../config.js";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS,
|
|
29
|
+
DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
|
|
30
|
+
DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
|
|
31
|
+
} from "../config.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Loopback on a port nothing listens on, so a component that dials it fails
|
|
35
|
+
* fast with ECONNREFUSED instead of hanging on a route to nowhere.
|
|
36
|
+
*/
|
|
37
|
+
const INERT_ENDPOINT = "http://127.0.0.1:1";
|
|
38
|
+
|
|
39
|
+
export function testConfig(overrides: Partial<Config> = {}): Config {
|
|
40
|
+
return {
|
|
41
|
+
taskQueue: "test-task-queue",
|
|
42
|
+
temporalAddress: "127.0.0.1:1",
|
|
43
|
+
temporalNamespace: "default",
|
|
44
|
+
stigmerBackendEndpoint: INERT_ENDPOINT,
|
|
45
|
+
stigmerToken: null,
|
|
46
|
+
mcpBridgeEndpoint: null,
|
|
47
|
+
cursorApiKey: "",
|
|
48
|
+
workspaceRootDir: join(tmpdir(), "stigmer-runner-test-workspaces"),
|
|
49
|
+
mode: "local",
|
|
50
|
+
proxyEndpoint: null,
|
|
51
|
+
maxConcurrentActivities: 1,
|
|
52
|
+
idleTimeoutSeconds: null,
|
|
53
|
+
cloudModeEnabled: false,
|
|
54
|
+
checkpointerType: "memory",
|
|
55
|
+
checkpointerProxyEndpoint: null,
|
|
56
|
+
artifactProxyEndpoint: null,
|
|
57
|
+
primaryModel: "test-model",
|
|
58
|
+
cursorStreamStallTimeoutMs: DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
|
|
59
|
+
agentResolveTimeoutMs: DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS,
|
|
60
|
+
workspaceLockTimeoutMs: DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|