@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.
Files changed (45) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/index.js +39 -1
  3. package/dist/activities/execute-cursor/index.js.map +1 -1
  4. package/dist/activities/execute-cursor/workspace-provision.d.ts +23 -4
  5. package/dist/activities/execute-cursor/workspace-provision.js +17 -6
  6. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  7. package/dist/activities/execute-deep-agent/index.js +8 -1
  8. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  9. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +1 -1
  10. package/dist/activities/execute-deep-agent/post-stream.d.ts +1 -1
  11. package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +1 -1
  12. package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +1 -1
  13. package/dist/activities/execute-deep-agent/streaming.d.ts +1 -1
  14. package/dist/activities/execute-deep-agent/v3-status-builder.d.ts +1 -1
  15. package/dist/shared/execution-status-writer.d.ts +31 -0
  16. package/dist/shared/execution-status-writer.js +43 -0
  17. package/dist/shared/execution-status-writer.js.map +1 -0
  18. package/dist/shared/workspace/writeback-coordinator.d.ts +120 -0
  19. package/dist/shared/workspace/writeback-coordinator.js +393 -0
  20. package/dist/shared/workspace/writeback-coordinator.js.map +1 -0
  21. package/package.json +2 -2
  22. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +23 -15
  23. package/src/activities/execute-cursor/index.ts +41 -1
  24. package/src/activities/execute-cursor/workspace-provision.ts +39 -7
  25. package/src/activities/execute-deep-agent/__tests__/post-stream.test.ts +1 -1
  26. package/src/activities/execute-deep-agent/index.ts +8 -1
  27. package/src/activities/execute-deep-agent/inline-publisher.ts +1 -1
  28. package/src/activities/execute-deep-agent/post-stream.ts +1 -1
  29. package/src/activities/execute-deep-agent/status-builder.ts +1 -1
  30. package/src/activities/execute-deep-agent/streaming-side-effects.ts +1 -1
  31. package/src/activities/execute-deep-agent/streaming-terminal.ts +1 -1
  32. package/src/activities/execute-deep-agent/streaming.ts +1 -1
  33. package/src/activities/execute-deep-agent/v3-status-builder.ts +1 -1
  34. package/src/shared/execution-status-writer.ts +56 -0
  35. package/src/shared/workspace/__tests__/writeback-coordinator.integration.test.ts +230 -0
  36. package/src/shared/workspace/__tests__/writeback-coordinator.test.ts +477 -0
  37. package/src/{activities/execute-deep-agent → shared/workspace}/writeback-coordinator.ts +206 -94
  38. package/dist/activities/execute-deep-agent/execution-status-writer.d.ts +0 -17
  39. package/dist/activities/execute-deep-agent/execution-status-writer.js +0 -9
  40. package/dist/activities/execute-deep-agent/execution-status-writer.js.map +0 -1
  41. package/dist/activities/execute-deep-agent/writeback-coordinator.d.ts +0 -71
  42. package/dist/activities/execute-deep-agent/writeback-coordinator.js +0 -299
  43. package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +0 -1
  44. package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +0 -426
  45. 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 = await provisionCursorWorkspace(
142
- makeConfig(workspaceRoot),
143
- gitRepoSession(source),
144
- {},
145
- "test-session-clone",
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
- blueprint.workspaceDirs = await provisionCursorWorkspace(
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. A session with no workspace
23
- * entries gets its own empty per-session directory (see session-root.ts)
24
- * never the shared root, which would leak other sessions' files into it.
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<string[]> {
54
+ ): Promise<CursorWorkspaceProvision> {
35
55
  const entries = session.spec?.workspaceEntries ?? [];
56
+ const platformDir = await ensurePlatformDir(sessionId);
57
+
36
58
  if (entries.length === 0) {
37
- return [await resolveSessionWorkspaceRoot(config.workspaceRootDir, entries, sessionId)];
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 dirs.length > 0 ? dirs : [config.workspaceRootDir];
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 "../writeback-coordinator.js";
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 "./writeback-coordinator.js";
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 "./execution-status-writer.js";
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 "./writeback-coordinator.js";
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 "./execution-status-writer.js";
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 "./writeback-coordinator.js";
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 "./execution-status-writer.js";
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 "./writeback-coordinator.js";
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 "./execution-status-writer.js";
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
+ });