@stigmer/runner 3.1.5 → 3.1.6
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/activities/execute-cursor/index.js +39 -1
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-provision.d.ts +23 -4
- package/dist/activities/execute-cursor/workspace-provision.js +17 -6
- package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +8 -1
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/inline-publisher.d.ts +1 -1
- package/dist/activities/execute-deep-agent/post-stream.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.d.ts +1 -1
- package/dist/shared/execution-status-writer.d.ts +31 -0
- package/dist/shared/execution-status-writer.js +43 -0
- package/dist/shared/execution-status-writer.js.map +1 -0
- package/dist/shared/workspace/writeback-coordinator.d.ts +120 -0
- package/dist/shared/workspace/writeback-coordinator.js +393 -0
- package/dist/shared/workspace/writeback-coordinator.js.map +1 -0
- package/package.json +2 -2
- package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +23 -15
- package/src/activities/execute-cursor/index.ts +41 -1
- package/src/activities/execute-cursor/workspace-provision.ts +39 -7
- package/src/activities/execute-deep-agent/__tests__/post-stream.test.ts +1 -1
- package/src/activities/execute-deep-agent/index.ts +8 -1
- package/src/activities/execute-deep-agent/inline-publisher.ts +1 -1
- package/src/activities/execute-deep-agent/post-stream.ts +1 -1
- package/src/activities/execute-deep-agent/status-builder.ts +1 -1
- package/src/activities/execute-deep-agent/streaming-side-effects.ts +1 -1
- package/src/activities/execute-deep-agent/streaming-terminal.ts +1 -1
- package/src/activities/execute-deep-agent/streaming.ts +1 -1
- package/src/activities/execute-deep-agent/v3-status-builder.ts +1 -1
- package/src/shared/execution-status-writer.ts +56 -0
- package/src/shared/workspace/__tests__/writeback-coordinator.integration.test.ts +230 -0
- package/src/shared/workspace/__tests__/writeback-coordinator.test.ts +477 -0
- package/src/{activities/execute-deep-agent → shared/workspace}/writeback-coordinator.ts +206 -94
- package/dist/activities/execute-deep-agent/execution-status-writer.d.ts +0 -17
- package/dist/activities/execute-deep-agent/execution-status-writer.js +0 -9
- package/dist/activities/execute-deep-agent/execution-status-writer.js.map +0 -1
- package/dist/activities/execute-deep-agent/writeback-coordinator.d.ts +0 -71
- package/dist/activities/execute-deep-agent/writeback-coordinator.js +0 -299
- package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +0 -1
- package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +0 -426
- package/src/activities/execute-deep-agent/execution-status-writer.ts +0 -19
|
@@ -138,12 +138,13 @@ describe("provisionCursorWorkspace", () => {
|
|
|
138
138
|
const workspaceRoot = join(tmpRoot, "workspace");
|
|
139
139
|
mkdirSync(workspaceRoot, { recursive: true });
|
|
140
140
|
|
|
141
|
-
const dirs
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
const { workspaceDirs: dirs, provisionResults, workspaceBackend } =
|
|
142
|
+
await provisionCursorWorkspace(
|
|
143
|
+
makeConfig(workspaceRoot),
|
|
144
|
+
gitRepoSession(source),
|
|
145
|
+
{},
|
|
146
|
+
"test-session-clone",
|
|
147
|
+
);
|
|
147
148
|
|
|
148
149
|
expect(dirs).toHaveLength(1);
|
|
149
150
|
const clonedFile = join(dirs[0], MARKER_FILE);
|
|
@@ -151,6 +152,12 @@ describe("provisionCursorWorkspace", () => {
|
|
|
151
152
|
expect(readFileSync(clonedFile, "utf-8")).toBe(MARKER_CONTENT);
|
|
152
153
|
// The clone must be a real git repo (so the agent can run git in it).
|
|
153
154
|
expect(existsSync(join(dirs[0], ".git"))).toBe(true);
|
|
155
|
+
|
|
156
|
+
// The provision results carry the git metadata the write-back
|
|
157
|
+
// coordinator consumes (repo URL, base branch), through the same backend.
|
|
158
|
+
expect(provisionResults).toHaveLength(1);
|
|
159
|
+
expect(provisionResults[0].gitMetadata?.branch).toBe("main");
|
|
160
|
+
expect(workspaceBackend).toBeDefined();
|
|
154
161
|
}, GIT_TEST_TIMEOUT_MS);
|
|
155
162
|
|
|
156
163
|
it("clones into a workspace root that already contains lost+found (PVC simulation)", async () => {
|
|
@@ -161,7 +168,7 @@ describe("provisionCursorWorkspace", () => {
|
|
|
161
168
|
mkdirSync(join(workspaceRoot, "lost+found"), { recursive: true });
|
|
162
169
|
writeFileSync(join(workspaceRoot, "lost+found", "stray"), "fsck-artifact");
|
|
163
170
|
|
|
164
|
-
const dirs = await provisionCursorWorkspace(
|
|
171
|
+
const { workspaceDirs: dirs } = await provisionCursorWorkspace(
|
|
165
172
|
makeConfig(workspaceRoot),
|
|
166
173
|
gitRepoSession(source),
|
|
167
174
|
{},
|
|
@@ -196,7 +203,7 @@ describe("provisionCursorWorkspace", () => {
|
|
|
196
203
|
const workspaceRoot = join(tmpRoot, "default-branch-workspace");
|
|
197
204
|
mkdirSync(workspaceRoot, { recursive: true });
|
|
198
205
|
|
|
199
|
-
const dirs = await provisionCursorWorkspace(
|
|
206
|
+
const { workspaceDirs: dirs } = await provisionCursorWorkspace(
|
|
200
207
|
makeConfig(workspaceRoot),
|
|
201
208
|
gitRepoSessionNoBranch(source),
|
|
202
209
|
{},
|
|
@@ -221,7 +228,7 @@ describe("provisionCursorWorkspace", () => {
|
|
|
221
228
|
// a worktree here, so it must be left untouched.
|
|
222
229
|
mkdirSync(join(workspaceRoot, "lost+found"), { recursive: true });
|
|
223
230
|
|
|
224
|
-
const dirs = await provisionCursorWorkspace(
|
|
231
|
+
const { workspaceDirs: dirs } = await provisionCursorWorkspace(
|
|
225
232
|
makeConfig(workspaceRoot),
|
|
226
233
|
multiGitRepoSession([
|
|
227
234
|
{ name: "frontend", url: frontend },
|
|
@@ -259,13 +266,14 @@ describe("provisionCursorWorkspace", () => {
|
|
|
259
266
|
// Leftovers from other sessions at the shared root must not be visible.
|
|
260
267
|
writeFileSync(join(workspaceRoot, "other-session-leftover.md"), "not yours");
|
|
261
268
|
|
|
262
|
-
const dirs = await provisionCursorWorkspace(
|
|
269
|
+
const { workspaceDirs: dirs, provisionResults } = await provisionCursorWorkspace(
|
|
263
270
|
makeConfig(workspaceRoot),
|
|
264
271
|
emptySession(),
|
|
265
272
|
{},
|
|
266
273
|
"test-session-empty",
|
|
267
274
|
);
|
|
268
275
|
|
|
276
|
+
expect(provisionResults).toEqual([]);
|
|
269
277
|
expect(dirs).toEqual([join(workspaceRoot, "sessions", "test-session-empty")]);
|
|
270
278
|
expect(existsSync(dirs[0])).toBe(true);
|
|
271
279
|
expect(existsSync(join(dirs[0], "other-session-leftover.md"))).toBe(false);
|
|
@@ -276,9 +284,9 @@ describe("provisionCursorWorkspace", () => {
|
|
|
276
284
|
mkdirSync(workspaceRoot, { recursive: true });
|
|
277
285
|
const config = makeConfig(workspaceRoot);
|
|
278
286
|
|
|
279
|
-
const turn1 = await provisionCursorWorkspace(config, emptySession(), {}, "stable-session");
|
|
287
|
+
const turn1 = (await provisionCursorWorkspace(config, emptySession(), {}, "stable-session")).workspaceDirs;
|
|
280
288
|
writeFileSync(join(turn1[0], "notes.md"), "turn 1 output");
|
|
281
|
-
const turn2 = await provisionCursorWorkspace(config, emptySession(), {}, "stable-session");
|
|
289
|
+
const turn2 = (await provisionCursorWorkspace(config, emptySession(), {}, "stable-session")).workspaceDirs;
|
|
282
290
|
|
|
283
291
|
expect(turn2).toEqual(turn1);
|
|
284
292
|
expect(readFileSync(join(turn2[0], "notes.md"), "utf-8")).toBe("turn 1 output");
|
|
@@ -289,8 +297,8 @@ describe("provisionCursorWorkspace", () => {
|
|
|
289
297
|
mkdirSync(workspaceRoot, { recursive: true });
|
|
290
298
|
const config = makeConfig(workspaceRoot);
|
|
291
299
|
|
|
292
|
-
const [dirA] = await provisionCursorWorkspace(config, emptySession(), {}, "session-a");
|
|
293
|
-
const [dirB] = await provisionCursorWorkspace(config, emptySession(), {}, "session-b");
|
|
300
|
+
const [dirA] = (await provisionCursorWorkspace(config, emptySession(), {}, "session-a")).workspaceDirs;
|
|
301
|
+
const [dirB] = (await provisionCursorWorkspace(config, emptySession(), {}, "session-b")).workspaceDirs;
|
|
294
302
|
|
|
295
303
|
expect(dirA).not.toBe(dirB);
|
|
296
304
|
writeFileSync(join(dirA, "a.md"), "session a");
|
|
@@ -304,7 +312,7 @@ describe("provisionCursorWorkspace", () => {
|
|
|
304
312
|
const workspaceRoot = join(tmpRoot, "workspace-local");
|
|
305
313
|
mkdirSync(workspaceRoot, { recursive: true });
|
|
306
314
|
|
|
307
|
-
const dirs = await provisionCursorWorkspace(
|
|
315
|
+
const { workspaceDirs: dirs } = await provisionCursorWorkspace(
|
|
308
316
|
makeConfig(workspaceRoot),
|
|
309
317
|
localPathSession(projectDir),
|
|
310
318
|
{},
|
|
@@ -99,6 +99,8 @@ import {
|
|
|
99
99
|
import { deriveExecutionFingerprintKey } from "../../shared/approval-fingerprint.js";
|
|
100
100
|
import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
|
|
101
101
|
import { provisionCursorWorkspace } from "./workspace-provision.js";
|
|
102
|
+
import { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
|
|
103
|
+
import { statusProtoWriter } from "../../shared/execution-status-writer.js";
|
|
102
104
|
import { setInterceptorExecutionId, runWithExecutionContext } from "./fetch-interceptor.js";
|
|
103
105
|
import { closeProxySessions } from "./http2-interceptor.js";
|
|
104
106
|
import { resolveModelId, ensureLoaded as ensurePricingLoaded } from "./model-pricing.js";
|
|
@@ -277,11 +279,32 @@ async function executeCursorInner(
|
|
|
277
279
|
// native harness. Git provisioning is idempotent across multi-turn and
|
|
278
280
|
// HITL reinvocations.
|
|
279
281
|
await reportSetupProgress(client, executionId, "Provisioning workspace");
|
|
280
|
-
|
|
282
|
+
const workspaceProvision = await provisionCursorWorkspace(
|
|
281
283
|
config, session, envVars, sessionId ?? "",
|
|
282
284
|
);
|
|
285
|
+
blueprint.workspaceDirs = workspaceProvision.workspaceDirs;
|
|
283
286
|
heartbeat();
|
|
284
287
|
|
|
288
|
+
// Git write-back: pushes the session's APPROVED tree to the session
|
|
289
|
+
// branch (stigmer/<session-id>) and keeps one PR open — the same
|
|
290
|
+
// approval-gated model as the deep-agent harness (its
|
|
291
|
+
// processCaptureWriteback). Finalize runs at exactly two seams below:
|
|
292
|
+
// the pure-file-review resume (after decisions reconcile) and terminal
|
|
293
|
+
// completion. Never mid-turn: the working tree is speculative until
|
|
294
|
+
// reviewed. Non-eligible workspaces (local paths, no credentials) make
|
|
295
|
+
// this a no-op coordinator.
|
|
296
|
+
const writebackCoordinator = workspaceProvision.provisionResults.length > 0
|
|
297
|
+
? new WriteBackCoordinator({
|
|
298
|
+
statusWriter: statusProtoWriter(status),
|
|
299
|
+
executionId,
|
|
300
|
+
sessionId: sessionId ?? "",
|
|
301
|
+
githubToken: envVars.GITHUB_TOKEN ?? "",
|
|
302
|
+
provisionResults: workspaceProvision.provisionResults,
|
|
303
|
+
workspaceEntries: session.spec?.workspaceEntries ?? [],
|
|
304
|
+
workspaceBackend: workspaceProvision.workspaceBackend,
|
|
305
|
+
})
|
|
306
|
+
: null;
|
|
307
|
+
|
|
285
308
|
// Apply-then-review is the universal file-review model (Slice 2c). When the
|
|
286
309
|
// primary workspace is a real git work tree, file edits flow during the turn
|
|
287
310
|
// and are captured per-file from the git diff at the turn boundary
|
|
@@ -478,6 +501,14 @@ async function executeCursorInner(
|
|
|
478
501
|
// (Cursor-like). The reconcile is done; the execution is complete.
|
|
479
502
|
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|
|
480
503
|
status.completedAt = utcTimestamp();
|
|
504
|
+
// Push the APPROVED tree — reconcile snapped rejected files back to
|
|
505
|
+
// baseline, so what finalize commits is exactly what the user kept.
|
|
506
|
+
// Mirrors the deep-agent's processCaptureWriteback: after reconcile,
|
|
507
|
+
// before persist, never on a failed reconcile (diverged bytes must
|
|
508
|
+
// not reach the remote).
|
|
509
|
+
if (!fileReviewFailed && writebackCoordinator) {
|
|
510
|
+
await writebackCoordinator.finalize();
|
|
511
|
+
}
|
|
481
512
|
if (fileReviewFailed) {
|
|
482
513
|
// What-you-approve-is-what-applies could not be honored (on-disk bytes
|
|
483
514
|
// diverged from the approved digest). Surface it to the human; the
|
|
@@ -1566,6 +1597,15 @@ async function executeCursorInner(
|
|
|
1566
1597
|
);
|
|
1567
1598
|
}
|
|
1568
1599
|
|
|
1600
|
+
// Write-back safety net on terminal completion. A capture-mode turn with
|
|
1601
|
+
// captured changes always paused above (boundary.waiting), so reaching
|
|
1602
|
+
// here means no reviewable delta this turn and this is normally a no-op —
|
|
1603
|
+
// it exists for the same reason the deep-agent finalizes on completion:
|
|
1604
|
+
// stragglers outside the capture (and it never runs mid-turn).
|
|
1605
|
+
if (status.phase === ExecutionPhase.EXECUTION_COMPLETED && writebackCoordinator) {
|
|
1606
|
+
await writebackCoordinator.finalize();
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1569
1609
|
// NOW persist — subscriber sees COMPLETED + structured_output atomically
|
|
1570
1610
|
await persist(status);
|
|
1571
1611
|
|
|
@@ -10,18 +10,38 @@
|
|
|
10
10
|
import type { Config } from "../../config.js";
|
|
11
11
|
import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
12
12
|
import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
|
|
13
|
+
import type { ProvisionResult, WorkspaceBackend } from "../../shared/workspace/types.js";
|
|
13
14
|
import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
|
|
14
15
|
import { ensurePlatformDir } from "../../shared/workspace/platform-dir.js";
|
|
15
16
|
import { resolveSessionWorkspaceRoot } from "../../shared/workspace/session-root.js";
|
|
16
17
|
|
|
18
|
+
/** What {@link provisionCursorWorkspace} hands back to the harness. */
|
|
19
|
+
export interface CursorWorkspaceProvision {
|
|
20
|
+
/** The directories the agent should operate in (never empty). */
|
|
21
|
+
readonly workspaceDirs: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Per-entry provision outcomes — carries the git metadata (repo URL, base
|
|
24
|
+
* branch, credential state) the write-back coordinator needs. Empty for a
|
|
25
|
+
* session with no workspace entries.
|
|
26
|
+
*/
|
|
27
|
+
readonly provisionResults: ProvisionResult[];
|
|
28
|
+
/**
|
|
29
|
+
* The backend the entries were provisioned through — the write-back
|
|
30
|
+
* coordinator executes its git commands through it.
|
|
31
|
+
*/
|
|
32
|
+
readonly workspaceBackend: WorkspaceBackend;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
/**
|
|
18
36
|
* Provision the session's workspace entries for a LOCAL Cursor agent.
|
|
19
37
|
*
|
|
20
38
|
* Clones git-repo entries (using the user's GITHUB_TOKEN from the resolved
|
|
21
39
|
* execution environment) and mounts local-path entries, then returns the
|
|
22
|
-
* directories the agent should operate in
|
|
23
|
-
*
|
|
24
|
-
*
|
|
40
|
+
* directories the agent should operate in — along with the provision results
|
|
41
|
+
* and backend the git write-back coordinator needs. A session with no
|
|
42
|
+
* workspace entries gets its own empty per-session directory (see
|
|
43
|
+
* session-root.ts) — never the shared root, which would leak other sessions'
|
|
44
|
+
* files into it.
|
|
25
45
|
*
|
|
26
46
|
* provisionGit is idempotent (it reuses an existing clone), so this is safe
|
|
27
47
|
* to call on every execution, including multi-turn and HITL reinvocations.
|
|
@@ -31,13 +51,21 @@ export async function provisionCursorWorkspace(
|
|
|
31
51
|
session: Session,
|
|
32
52
|
envVars: Record<string, string>,
|
|
33
53
|
sessionId: string,
|
|
34
|
-
): Promise<
|
|
54
|
+
): Promise<CursorWorkspaceProvision> {
|
|
35
55
|
const entries = session.spec?.workspaceEntries ?? [];
|
|
56
|
+
const platformDir = await ensurePlatformDir(sessionId);
|
|
57
|
+
|
|
36
58
|
if (entries.length === 0) {
|
|
37
|
-
|
|
59
|
+
const sessionRoot = await resolveSessionWorkspaceRoot(
|
|
60
|
+
config.workspaceRootDir, entries, sessionId,
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
workspaceDirs: [sessionRoot],
|
|
64
|
+
provisionResults: [],
|
|
65
|
+
workspaceBackend: new LocalWorkspaceBackend(sessionRoot, platformDir),
|
|
66
|
+
};
|
|
38
67
|
}
|
|
39
68
|
|
|
40
|
-
const platformDir = await ensurePlatformDir(sessionId);
|
|
41
69
|
const backend = new LocalWorkspaceBackend(config.workspaceRootDir, platformDir);
|
|
42
70
|
|
|
43
71
|
const provisioner = new WorkspaceProvisioner();
|
|
@@ -53,5 +81,9 @@ export async function provisionCursorWorkspace(
|
|
|
53
81
|
.map((result) => result.rootDir)
|
|
54
82
|
.filter((dir): dir is string => Boolean(dir));
|
|
55
83
|
|
|
56
|
-
return
|
|
84
|
+
return {
|
|
85
|
+
workspaceDirs: dirs.length > 0 ? dirs : [config.workspaceRootDir],
|
|
86
|
+
provisionResults: results,
|
|
87
|
+
workspaceBackend: backend,
|
|
88
|
+
};
|
|
57
89
|
}
|
|
@@ -3,7 +3,7 @@ import { create } from "@bufbuild/protobuf";
|
|
|
3
3
|
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
4
4
|
import { processPostStream } from "../post-stream.js";
|
|
5
5
|
import type { InlinePublisher } from "../inline-publisher.js";
|
|
6
|
-
import type { WriteBackCoordinator } from "
|
|
6
|
+
import type { WriteBackCoordinator } from "../../../shared/workspace/writeback-coordinator.js";
|
|
7
7
|
|
|
8
8
|
function mockInlinePublisher(): InlinePublisher {
|
|
9
9
|
return {
|
|
@@ -45,7 +45,7 @@ import { streamExecution, type StreamResult } from "./streaming.js";
|
|
|
45
45
|
import { loadStreamingConfig } from "../../shared/streaming-scheduler.js";
|
|
46
46
|
import { StatusBuilder } from "./status-builder.js";
|
|
47
47
|
import { InlinePublisher } from "./inline-publisher.js";
|
|
48
|
-
import { WriteBackCoordinator } from "
|
|
48
|
+
import { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
|
|
49
49
|
import { processPostStream } from "./post-stream.js";
|
|
50
50
|
import { resolveResumeInput, type GraphStateSnapshot } from "./hitl.js";
|
|
51
51
|
import { captureApprovalArtifacts } from "./approval-file-change.js";
|
|
@@ -184,6 +184,13 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
184
184
|
? new WriteBackCoordinator({
|
|
185
185
|
statusWriter: statusBuilder,
|
|
186
186
|
executionId,
|
|
187
|
+
// Branch/PR are session-scoped: every turn of this session
|
|
188
|
+
// appends commits to the same stigmer/<session-id> branch.
|
|
189
|
+
sessionId: setup.session.metadata?.id ?? "",
|
|
190
|
+
// The PR API token, plumbed from the resolved execution env —
|
|
191
|
+
// gitMetadata.repoUrl is token-stripped by construction, so the
|
|
192
|
+
// coordinator can never recover it from provisioning state.
|
|
193
|
+
githubToken: setup.mergedEnvVars.GITHUB_TOKEN ?? "",
|
|
187
194
|
provisionResults: setup.provisionResults,
|
|
188
195
|
workspaceEntries: workspaceEntries as any,
|
|
189
196
|
workspaceBackend: setup.workspaceBackend,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
21
21
|
import type { ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
22
22
|
import type { WorkspaceBackend } from "../../shared/workspace/types.js";
|
|
23
|
-
import type { ExecutionStatusWriter } from "
|
|
23
|
+
import type { ExecutionStatusWriter } from "../../shared/execution-status-writer.js";
|
|
24
24
|
import { utcTimestamp } from "../../shared/status.js";
|
|
25
25
|
import { isSecretLikePath } from "../../shared/filereview/secret-paths.js";
|
|
26
26
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
14
14
|
import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
15
15
|
import type { InlinePublisher } from "./inline-publisher.js";
|
|
16
|
-
import type { WriteBackCoordinator } from "
|
|
16
|
+
import type { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
|
|
17
17
|
import { autoPublishWrittenFiles } from "./auto-publish.js";
|
|
18
18
|
|
|
19
19
|
export interface PostStreamOptions {
|
|
@@ -31,7 +31,7 @@ import { classifyTool } from "../../shared/tool-kind.js";
|
|
|
31
31
|
import { applyTodoUpdate } from "../../shared/todos.js";
|
|
32
32
|
import { ExecutionState } from "./execution-state.js";
|
|
33
33
|
import { utcTimestamp } from "../../shared/status.js";
|
|
34
|
-
import type { ExecutionStatusWriter } from "
|
|
34
|
+
import type { ExecutionStatusWriter } from "../../shared/execution-status-writer.js";
|
|
35
35
|
import {
|
|
36
36
|
UsageAccumulator,
|
|
37
37
|
extractToolResult,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { V3ProtocolEvent } from "./v3-event-recorder.js";
|
|
10
10
|
import type { InlinePublisher } from "./inline-publisher.js";
|
|
11
|
-
import type { WriteBackCoordinator } from "
|
|
11
|
+
import type { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
|
|
12
12
|
import { extractFilePath, isFileModifyingTool } from "../../shared/file-tools.js";
|
|
13
13
|
|
|
14
14
|
interface CachedToolInput {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { create } from "@bufbuild/protobuf";
|
|
9
9
|
import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
10
10
|
import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
11
|
-
import type { ExecutionStatusWriter } from "
|
|
11
|
+
import type { ExecutionStatusWriter } from "../../shared/execution-status-writer.js";
|
|
12
12
|
import type { StreamResult } from "./streaming.js";
|
|
13
13
|
import { slimStatus, utcTimestamp } from "../../shared/status.js";
|
|
14
14
|
|
|
@@ -39,7 +39,7 @@ import type { ToolOutputOffloadContext } from "../../shared/status-offload.js";
|
|
|
39
39
|
import type { StigmerClient } from "../../client/stigmer-client.js";
|
|
40
40
|
import type { GracefulStopMiddleware } from "../../middleware/index.js";
|
|
41
41
|
import type { InlinePublisher } from "./inline-publisher.js";
|
|
42
|
-
import type { WriteBackCoordinator } from "
|
|
42
|
+
import type { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
|
|
43
43
|
import { createV2EventRecorder } from "./event-recorder.js";
|
|
44
44
|
import { streamExecutionV3 } from "./streaming-v3.js";
|
|
45
45
|
import { extractFilePath, isFileModifyingTool } from "../../shared/file-tools.js";
|
|
@@ -30,7 +30,7 @@ import { classifyTool } from "../../shared/tool-kind.js";
|
|
|
30
30
|
import { applyTodoUpdate } from "../../shared/todos.js";
|
|
31
31
|
import { ExecutionState } from "./execution-state.js";
|
|
32
32
|
import { utcTimestamp } from "../../shared/status.js";
|
|
33
|
-
import type { ExecutionStatusWriter } from "
|
|
33
|
+
import type { ExecutionStatusWriter } from "../../shared/execution-status-writer.js";
|
|
34
34
|
import type { ApprovalPolicyProvider } from "./status-builder.js";
|
|
35
35
|
import type { StigmerRunEvent, V3UsagePayload } from "./v3-events.js";
|
|
36
36
|
import { namespaceDepth } from "./v3-events.js";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared interface for proto mutation consumed by side-effect classes
|
|
3
|
+
* (InlinePublisher, WriteBackCoordinator) and streaming loops.
|
|
4
|
+
*
|
|
5
|
+
* The deep-agent harness implements it on its v2 StatusBuilder and
|
|
6
|
+
* V3StatusBuilder; the Cursor harness — which mutates a bare status proto
|
|
7
|
+
* with no builder — uses {@link statusProtoWriter}. Side-effect classes
|
|
8
|
+
* depend only on this interface, never on a specific implementation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
12
|
+
import type { ExecutionArtifact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
|
|
13
|
+
import type { WorkspaceWriteBack } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
|
|
14
|
+
|
|
15
|
+
export interface ExecutionStatusWriter {
|
|
16
|
+
readonly currentStatus: AgentExecutionStatus;
|
|
17
|
+
readonly forceNextUpdate: boolean;
|
|
18
|
+
clearForceFlag(): void;
|
|
19
|
+
addArtifact(artifact: ExecutionArtifact): void;
|
|
20
|
+
addWriteBack(wb: WorkspaceWriteBack): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An {@link ExecutionStatusWriter} over a bare status proto, for harnesses
|
|
25
|
+
* that mutate the status directly instead of through a builder (the Cursor
|
|
26
|
+
* harness). Mutations apply synchronously to the given proto; the caller's
|
|
27
|
+
* own persist points pick them up — the force flag is a streaming-loop
|
|
28
|
+
* concern the direct-mutation style has no use for, so it stays `false`.
|
|
29
|
+
*
|
|
30
|
+
* Write-backs upsert by `workspaceEntryName` — the same contract as the
|
|
31
|
+
* deep-agent `StatusBuilder.addWriteBack`: each git-backed workspace entry
|
|
32
|
+
* carries at most one record, progressing through phases.
|
|
33
|
+
*/
|
|
34
|
+
export function statusProtoWriter(status: AgentExecutionStatus): ExecutionStatusWriter {
|
|
35
|
+
return {
|
|
36
|
+
get currentStatus() {
|
|
37
|
+
return status;
|
|
38
|
+
},
|
|
39
|
+
forceNextUpdate: false,
|
|
40
|
+
clearForceFlag() {},
|
|
41
|
+
addArtifact(artifact: ExecutionArtifact) {
|
|
42
|
+
status.artifacts.push(artifact);
|
|
43
|
+
},
|
|
44
|
+
addWriteBack(wb: WorkspaceWriteBack) {
|
|
45
|
+
const backs = status.workspaceWriteBacks;
|
|
46
|
+
const idx = backs.findIndex(
|
|
47
|
+
(b) => b.workspaceEntryName === wb.workspaceEntryName,
|
|
48
|
+
);
|
|
49
|
+
if (idx >= 0) {
|
|
50
|
+
backs[idx] = wb;
|
|
51
|
+
} else {
|
|
52
|
+
backs.push(wb);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-git integration tests for the session-scoped write-back lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* A hermetic origin (local bare repo) and a working clone stand in for
|
|
5
|
+
* GitHub + the session workspace; only the PR API is mocked. These prove the
|
|
6
|
+
* behaviors the mocked unit suite cannot: that `ensureBranch`'s checkout
|
|
7
|
+
* cases and the always-`-u` push compose correctly against real git across
|
|
8
|
+
* SEQUENTIAL COORDINATOR INSTANCES (turn 1 and turn 2 of one session), and
|
|
9
|
+
* that only the tree's content at finalize — the post-reconcile, approved
|
|
10
|
+
* content — reaches the session branch.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { create } from "@bufbuild/protobuf";
|
|
18
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
19
|
+
import { WorkspaceWriteBackPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
|
|
20
|
+
import { GitWriteBackMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
21
|
+
import { WriteBackCoordinator } from "../writeback-coordinator.js";
|
|
22
|
+
import { LocalWorkspaceBackend } from "../local-backend.js";
|
|
23
|
+
import { statusProtoWriter, type ExecutionStatusWriter } from "../../execution-status-writer.js";
|
|
24
|
+
import type { ProvisionResult } from "../types.js";
|
|
25
|
+
import { SourceType } from "../types.js";
|
|
26
|
+
import { AGENT_GIT_AUTHOR_EMAIL } from "../git-identity.js";
|
|
27
|
+
|
|
28
|
+
const SESSION_ID = "ses-int-01";
|
|
29
|
+
const SESSION_BRANCH = `stigmer/${SESSION_ID}`;
|
|
30
|
+
|
|
31
|
+
// Real git spawns (init/clone/commit/push, ~a dozen per test) are slow under
|
|
32
|
+
// full-suite parallel load — observed >40s wall clock at ~4s of actual work.
|
|
33
|
+
const GIT_TEST_TIMEOUT_MS = 120_000;
|
|
34
|
+
|
|
35
|
+
let tmpRoot: string;
|
|
36
|
+
let originDir: string;
|
|
37
|
+
let workDir: string;
|
|
38
|
+
|
|
39
|
+
function git(cwd: string, args: string[]): string {
|
|
40
|
+
return execFileSync("git", args, {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: "utf-8",
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
GIT_AUTHOR_NAME: "human",
|
|
46
|
+
GIT_AUTHOR_EMAIL: "human@example.com",
|
|
47
|
+
GIT_COMMITTER_NAME: "human",
|
|
48
|
+
GIT_COMMITTER_EMAIL: "human@example.com",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeCoordinator(
|
|
54
|
+
executionId: string,
|
|
55
|
+
writer: ExecutionStatusWriter,
|
|
56
|
+
): WriteBackCoordinator {
|
|
57
|
+
const provisionResult: ProvisionResult = {
|
|
58
|
+
rootDir: workDir,
|
|
59
|
+
sourceType: SourceType.GIT_REPO,
|
|
60
|
+
consumedKeys: [],
|
|
61
|
+
workspaceDescription: "integration",
|
|
62
|
+
entryName: "repo",
|
|
63
|
+
gitMetadata: {
|
|
64
|
+
repoUrl: "https://github.com/acme/repo.git",
|
|
65
|
+
branch: "main",
|
|
66
|
+
baseCommit: git(workDir, ["rev-parse", "main"]).trim(),
|
|
67
|
+
gitCredentialsConfigured: true,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
return new WriteBackCoordinator({
|
|
71
|
+
statusWriter: writer,
|
|
72
|
+
executionId,
|
|
73
|
+
sessionId: SESSION_ID,
|
|
74
|
+
githubToken: "ghp_test",
|
|
75
|
+
provisionResults: [provisionResult],
|
|
76
|
+
workspaceEntries: [
|
|
77
|
+
{
|
|
78
|
+
name: "repo",
|
|
79
|
+
source: {
|
|
80
|
+
source: {
|
|
81
|
+
case: "gitRepo" as const,
|
|
82
|
+
value: { writeBackMode: GitWriteBackMode.GIT_WRITE_BACK_BRANCH_AND_PR },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
} as any,
|
|
86
|
+
],
|
|
87
|
+
workspaceBackend: new LocalWorkspaceBackend(workDir),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The PR API mock: no open PR on first list, then created; adopted after. */
|
|
92
|
+
function mockGithubApi(): Array<{ method: string; url: string }> {
|
|
93
|
+
const calls: Array<{ method: string; url: string }> = [];
|
|
94
|
+
let created = false;
|
|
95
|
+
globalThis.fetch = vi.fn(async (url: any, init?: any) => {
|
|
96
|
+
const method = init?.method ?? "GET";
|
|
97
|
+
calls.push({ method, url: String(url) });
|
|
98
|
+
if (method === "GET") {
|
|
99
|
+
const body = created
|
|
100
|
+
? [{ html_url: "https://github.com/acme/repo/pull/5", number: 5 }]
|
|
101
|
+
: [];
|
|
102
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
103
|
+
}
|
|
104
|
+
created = true;
|
|
105
|
+
return new Response(
|
|
106
|
+
JSON.stringify({ html_url: "https://github.com/acme/repo/pull/5", number: 5 }),
|
|
107
|
+
{ status: 201 },
|
|
108
|
+
);
|
|
109
|
+
}) as typeof fetch;
|
|
110
|
+
return calls;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const originalFetch = globalThis.fetch;
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "wb-int-"));
|
|
117
|
+
originDir = join(tmpRoot, "origin.git");
|
|
118
|
+
workDir = join(tmpRoot, "work");
|
|
119
|
+
|
|
120
|
+
execFileSync("git", ["init", "--bare", "-b", "main", originDir], { stdio: "pipe" });
|
|
121
|
+
execFileSync("git", ["clone", originDir, workDir], { stdio: "pipe" });
|
|
122
|
+
writeFileSync(join(workDir, "README.md"), "# seed\n");
|
|
123
|
+
git(workDir, ["add", "-A"]);
|
|
124
|
+
git(workDir, ["commit", "-m", "seed"]);
|
|
125
|
+
git(workDir, ["push", "-u", "origin", "main"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
globalThis.fetch = originalFetch;
|
|
130
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("WriteBackCoordinator against real git", () => {
|
|
134
|
+
it(
|
|
135
|
+
"pushes turn 1 to the session branch, then turn 2 (new instance) appends to it and adopts the PR",
|
|
136
|
+
async () => {
|
|
137
|
+
const apiCalls = mockGithubApi();
|
|
138
|
+
|
|
139
|
+
// ── Turn 1: the agent created a file; the review kept it. ──
|
|
140
|
+
writeFileSync(join(workDir, "notes.md"), "approved content\n");
|
|
141
|
+
const status1 = create(AgentExecutionStatusSchema, {});
|
|
142
|
+
const coord1 = makeCoordinator("exec-turn-1", statusProtoWriter(status1));
|
|
143
|
+
await coord1.finalize();
|
|
144
|
+
|
|
145
|
+
const wb1 = status1.workspaceWriteBacks[0];
|
|
146
|
+
expect(wb1.phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED);
|
|
147
|
+
expect(wb1.branchName).toBe(SESSION_BRANCH);
|
|
148
|
+
expect(wb1.pullRequestNumber).toBe(5);
|
|
149
|
+
|
|
150
|
+
// The commit is on origin's session branch, authored as the agent, and
|
|
151
|
+
// main is untouched.
|
|
152
|
+
const tip1 = git(originDir, ["rev-parse", SESSION_BRANCH]).trim();
|
|
153
|
+
expect(git(originDir, ["show", `${tip1}:notes.md`])).toBe("approved content\n");
|
|
154
|
+
expect(git(originDir, ["log", "-1", "--format=%ae", SESSION_BRANCH]).trim())
|
|
155
|
+
.toBe(AGENT_GIT_AUTHOR_EMAIL);
|
|
156
|
+
expect(() => git(originDir, ["show", "main:notes.md"])).toThrow();
|
|
157
|
+
|
|
158
|
+
// ── Turn 2: a NEW coordinator (new execution, same session). The agent
|
|
159
|
+
// edited two files; the review discarded one — the reconcile snapped it
|
|
160
|
+
// back BEFORE finalize, so only the kept edit is in the tree. ──
|
|
161
|
+
writeFileSync(join(workDir, "notes.md"), "approved content\nkept edit\n");
|
|
162
|
+
writeFileSync(join(workDir, "rejected.md"), "discarded content\n");
|
|
163
|
+
rmSync(join(workDir, "rejected.md")); // the reconcile's snap-back
|
|
164
|
+
|
|
165
|
+
const status2 = create(AgentExecutionStatusSchema, {});
|
|
166
|
+
const coord2 = makeCoordinator("exec-turn-2", statusProtoWriter(status2));
|
|
167
|
+
await coord2.finalize();
|
|
168
|
+
|
|
169
|
+
const wb2 = status2.workspaceWriteBacks[0];
|
|
170
|
+
expect(wb2.phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED);
|
|
171
|
+
expect(wb2.branchName, "same session branch across executions").toBe(SESSION_BRANCH);
|
|
172
|
+
expect(wb2.pullRequestNumber, "adopted, not re-created").toBe(5);
|
|
173
|
+
expect(apiCalls.filter((c) => c.method === "POST"), "exactly one PR ever created")
|
|
174
|
+
.toHaveLength(1);
|
|
175
|
+
|
|
176
|
+
// Both turns' commits are on the ONE session branch; the discarded file
|
|
177
|
+
// never reached the remote.
|
|
178
|
+
const tip2 = git(originDir, ["rev-parse", SESSION_BRANCH]).trim();
|
|
179
|
+
expect(git(originDir, ["show", `${tip2}:notes.md`])).toBe("approved content\nkept edit\n");
|
|
180
|
+
expect(() => git(originDir, ["show", `${tip2}:rejected.md`])).toThrow();
|
|
181
|
+
expect(
|
|
182
|
+
git(originDir, ["rev-list", "--count", `main..${SESSION_BRANCH}`]).trim(),
|
|
183
|
+
"one commit per approved turn",
|
|
184
|
+
).toBe("2");
|
|
185
|
+
|
|
186
|
+
// Each commit message carries its execution id for traceability.
|
|
187
|
+
const messages = git(originDir, ["log", "--format=%s", SESSION_BRANCH]);
|
|
188
|
+
expect(messages).toContain("agent changes (exec-turn-1)");
|
|
189
|
+
expect(messages).toContain("agent changes (exec-turn-2)");
|
|
190
|
+
},
|
|
191
|
+
GIT_TEST_TIMEOUT_MS,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
it(
|
|
195
|
+
"recovers the session branch from the remote after a workspace re-provision",
|
|
196
|
+
async () => {
|
|
197
|
+
mockGithubApi();
|
|
198
|
+
|
|
199
|
+
// Turn 1 establishes the branch on origin.
|
|
200
|
+
writeFileSync(join(workDir, "notes.md"), "turn one\n");
|
|
201
|
+
const coord1 = makeCoordinator(
|
|
202
|
+
"exec-a",
|
|
203
|
+
statusProtoWriter(create(AgentExecutionStatusSchema, {})),
|
|
204
|
+
);
|
|
205
|
+
await coord1.finalize();
|
|
206
|
+
|
|
207
|
+
// The sandbox is torn down; a fresh clone knows nothing of the local
|
|
208
|
+
// branch (only origin has it).
|
|
209
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
210
|
+
execFileSync("git", ["clone", originDir, workDir], { stdio: "pipe" });
|
|
211
|
+
expect(git(workDir, ["branch", "--show-current"]).trim()).toBe("main");
|
|
212
|
+
|
|
213
|
+
// Turn 2 in the fresh clone must continue the SAME branch, not fork a
|
|
214
|
+
// new one from main (which would orphan turn 1's commit).
|
|
215
|
+
writeFileSync(join(workDir, "more.md"), "turn two\n");
|
|
216
|
+
const status2 = create(AgentExecutionStatusSchema, {});
|
|
217
|
+
const coord2 = makeCoordinator("exec-b", statusProtoWriter(status2));
|
|
218
|
+
await coord2.finalize();
|
|
219
|
+
|
|
220
|
+
expect(status2.workspaceWriteBacks[0].branchName).toBe(SESSION_BRANCH);
|
|
221
|
+
const tip = git(originDir, ["rev-parse", SESSION_BRANCH]).trim();
|
|
222
|
+
expect(git(originDir, ["show", `${tip}:notes.md`]), "turn 1's commit is an ancestor")
|
|
223
|
+
.toBe("turn one\n");
|
|
224
|
+
expect(git(originDir, ["show", `${tip}:more.md`])).toBe("turn two\n");
|
|
225
|
+
expect(git(originDir, ["rev-list", "--count", `main..${SESSION_BRANCH}`]).trim())
|
|
226
|
+
.toBe("2");
|
|
227
|
+
},
|
|
228
|
+
GIT_TEST_TIMEOUT_MS,
|
|
229
|
+
);
|
|
230
|
+
});
|