@stigmer/runner 3.1.4 → 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 (74) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.js +40 -2
  5. package/dist/activities/execute-cursor/index.js.map +1 -1
  6. package/dist/activities/execute-cursor/skill-resolver.js +1 -1
  7. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  8. package/dist/activities/execute-cursor/workspace-provision.d.ts +23 -4
  9. package/dist/activities/execute-cursor/workspace-provision.js +17 -6
  10. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  11. package/dist/activities/execute-deep-agent/index.js +39 -6
  12. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +1 -1
  14. package/dist/activities/execute-deep-agent/post-stream.d.ts +1 -1
  15. package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +1 -1
  16. package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +1 -1
  17. package/dist/activities/execute-deep-agent/streaming.d.ts +1 -1
  18. package/dist/activities/execute-deep-agent/v3-status-builder.d.ts +1 -1
  19. package/dist/shared/execution-status-writer.d.ts +31 -0
  20. package/dist/shared/execution-status-writer.js +43 -0
  21. package/dist/shared/execution-status-writer.js.map +1 -0
  22. package/dist/shared/filereview/git-substrate.js +4 -0
  23. package/dist/shared/filereview/git-substrate.js.map +1 -1
  24. package/dist/shared/workspace/git-identity.d.ts +36 -0
  25. package/dist/shared/workspace/git-identity.js +39 -0
  26. package/dist/shared/workspace/git-identity.js.map +1 -0
  27. package/dist/shared/workspace/local-backend.d.ts +6 -2
  28. package/dist/shared/workspace/local-backend.js +6 -2
  29. package/dist/shared/workspace/local-backend.js.map +1 -1
  30. package/dist/shared/workspace/stigmer-link.d.ts +54 -0
  31. package/dist/shared/workspace/stigmer-link.js +92 -0
  32. package/dist/shared/workspace/stigmer-link.js.map +1 -0
  33. package/dist/shared/workspace/types.d.ts +6 -4
  34. package/dist/shared/workspace/writeback-coordinator.d.ts +120 -0
  35. package/dist/shared/workspace/writeback-coordinator.js +393 -0
  36. package/dist/shared/workspace/writeback-coordinator.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +23 -15
  39. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  40. package/src/activities/execute-cursor/index.ts +42 -2
  41. package/src/activities/execute-cursor/skill-resolver.ts +1 -1
  42. package/src/activities/execute-cursor/workspace-provision.ts +39 -7
  43. package/src/activities/execute-deep-agent/__tests__/post-stream.test.ts +1 -1
  44. package/src/activities/execute-deep-agent/index.ts +44 -6
  45. package/src/activities/execute-deep-agent/inline-publisher.ts +1 -1
  46. package/src/activities/execute-deep-agent/post-stream.ts +1 -1
  47. package/src/activities/execute-deep-agent/status-builder.ts +1 -1
  48. package/src/activities/execute-deep-agent/streaming-side-effects.ts +1 -1
  49. package/src/activities/execute-deep-agent/streaming-terminal.ts +1 -1
  50. package/src/activities/execute-deep-agent/streaming.ts +1 -1
  51. package/src/activities/execute-deep-agent/v3-status-builder.ts +1 -1
  52. package/src/shared/execution-status-writer.ts +56 -0
  53. package/src/shared/filereview/git-substrate.ts +4 -0
  54. package/src/shared/workspace/__tests__/git-identity.test.ts +124 -0
  55. package/src/shared/workspace/__tests__/stigmer-link.test.ts +173 -0
  56. package/src/shared/workspace/__tests__/writeback-coordinator.integration.test.ts +230 -0
  57. package/src/shared/workspace/__tests__/writeback-coordinator.test.ts +477 -0
  58. package/src/shared/workspace/git-identity.ts +41 -0
  59. package/src/shared/workspace/local-backend.ts +6 -2
  60. package/src/shared/workspace/stigmer-link.ts +97 -0
  61. package/src/shared/workspace/types.ts +6 -4
  62. package/src/{activities/execute-deep-agent → shared/workspace}/writeback-coordinator.ts +209 -93
  63. package/dist/activities/execute-cursor/stigmer-link.d.ts +0 -35
  64. package/dist/activities/execute-cursor/stigmer-link.js +0 -73
  65. package/dist/activities/execute-cursor/stigmer-link.js.map +0 -1
  66. package/dist/activities/execute-deep-agent/execution-status-writer.d.ts +0 -17
  67. package/dist/activities/execute-deep-agent/execution-status-writer.js +0 -9
  68. package/dist/activities/execute-deep-agent/execution-status-writer.js.map +0 -1
  69. package/dist/activities/execute-deep-agent/writeback-coordinator.d.ts +0 -71
  70. package/dist/activities/execute-deep-agent/writeback-coordinator.js +0 -295
  71. package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +0 -1
  72. package/src/activities/execute-cursor/stigmer-link.ts +0 -78
  73. package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +0 -399
  74. package/src/activities/execute-deep-agent/execution-status-writer.ts +0 -19
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import {
7
+ AGENT_GIT_AUTHOR_NAME,
8
+ AGENT_GIT_AUTHOR_EMAIL,
9
+ AGENT_GIT_IDENTITY_FLAGS,
10
+ gitCommitAsAgent,
11
+ } from "../git-identity.js";
12
+
13
+ /**
14
+ * Environment that approximates the cloud sandbox: no global/system gitconfig,
15
+ * no GIT_AUTHOR / GIT_COMMITTER variables. In the real sandbox a bare
16
+ * `git commit` here fails with "Author identity unknown" (hostname-based
17
+ * auto-detection yields an invalid ident inside the pod); on a developer
18
+ * machine git may still auto-detect from username + hostname, so these tests
19
+ * assert the fix's guarantee — the pinned identity always wins — rather than
20
+ * the environment-dependent failure mode.
21
+ */
22
+ function identitylessEnv(): NodeJS.ProcessEnv {
23
+ const env = { ...process.env };
24
+ delete env.GIT_AUTHOR_NAME;
25
+ delete env.GIT_AUTHOR_EMAIL;
26
+ delete env.GIT_COMMITTER_NAME;
27
+ delete env.GIT_COMMITTER_EMAIL;
28
+ env.GIT_CONFIG_GLOBAL = "/dev/null";
29
+ env.GIT_CONFIG_SYSTEM = "/dev/null";
30
+ return env;
31
+ }
32
+
33
+ function sh(
34
+ cwd: string,
35
+ command: string,
36
+ envOverrides: Record<string, string> = {},
37
+ ): Promise<string> {
38
+ return new Promise((resolve, reject) => {
39
+ execFile(
40
+ "sh",
41
+ ["-c", command],
42
+ { cwd, env: { ...identitylessEnv(), ...envOverrides } },
43
+ (err, stdout, stderr) => {
44
+ if (err) reject(new Error(`Command failed: ${command}\n${stderr || err.message}`));
45
+ else resolve(stdout);
46
+ },
47
+ );
48
+ });
49
+ }
50
+
51
+ describe("gitCommitAsAgent (real repo, no ambient identity)", () => {
52
+ let repo: string;
53
+
54
+ beforeEach(async () => {
55
+ repo = mkdtempSync(join(tmpdir(), "git-identity-test-"));
56
+ await sh(repo, "git init -q");
57
+ writeFileSync(join(repo, "notes.md"), "# Project Notes\n");
58
+ await sh(repo, "git add -A");
59
+ });
60
+
61
+ afterEach(() => {
62
+ rmSync(repo, { recursive: true, force: true });
63
+ });
64
+
65
+ /**
66
+ * `user.useConfigOnly=true` (injected via git's config env) forbids the
67
+ * username+hostname auto-detection that a developer machine would otherwise
68
+ * fall back to — deterministically reproducing the sandbox condition where
69
+ * git has no usable identity.
70
+ */
71
+ const NO_AUTODETECT_ENV = {
72
+ GIT_CONFIG_COUNT: "1",
73
+ GIT_CONFIG_KEY_0: "user.useConfigOnly",
74
+ GIT_CONFIG_VALUE_0: "true",
75
+ };
76
+
77
+ it("reproduces the cloud failure: a bare commit has no identity", async () => {
78
+ await expect(
79
+ sh(repo, 'git commit -m "agent changes (1)"', NO_AUTODETECT_ENV),
80
+ ).rejects.toThrow(/user\.email|user\.name|Author identity unknown|empty ident/);
81
+ });
82
+
83
+ it("the agent identity satisfies git even when auto-detection is forbidden", async () => {
84
+ await sh(repo, gitCommitAsAgent("agent changes (1)"), NO_AUTODETECT_ENV);
85
+
86
+ const author = await sh(repo, "git log -1 --format='%an <%ae>'");
87
+ expect(author.trim()).toBe(
88
+ `${AGENT_GIT_AUTHOR_NAME} <${AGENT_GIT_AUTHOR_EMAIL}>`,
89
+ );
90
+ });
91
+
92
+ it("commits successfully with the agent identity", async () => {
93
+ await sh(repo, gitCommitAsAgent("agent changes (1)"));
94
+
95
+ const author = await sh(repo, "git log -1 --format='%an <%ae>'");
96
+ expect(author.trim()).toBe(
97
+ `${AGENT_GIT_AUTHOR_NAME} <${AGENT_GIT_AUTHOR_EMAIL}>`,
98
+ );
99
+
100
+ const committer = await sh(repo, "git log -1 --format='%cn <%ce>'");
101
+ expect(committer.trim()).toBe(
102
+ `${AGENT_GIT_AUTHOR_NAME} <${AGENT_GIT_AUTHOR_EMAIL}>`,
103
+ );
104
+
105
+ const message = await sh(repo, "git log -1 --format=%s");
106
+ expect(message.trim()).toBe("agent changes (1)");
107
+ });
108
+
109
+ it("never mutates the repository's git config", async () => {
110
+ await sh(repo, gitCommitAsAgent("agent changes (1)"));
111
+
112
+ // `git config --local` exits non-zero when the key is unset.
113
+ await expect(sh(repo, "git config --local user.name")).rejects.toThrow();
114
+ await expect(sh(repo, "git config --local user.email")).rejects.toThrow();
115
+ });
116
+ });
117
+
118
+ describe("AGENT_GIT_IDENTITY_FLAGS", () => {
119
+ it("pins both name and email via per-command -c flags", () => {
120
+ expect(AGENT_GIT_IDENTITY_FLAGS).toBe(
121
+ `-c user.name='${AGENT_GIT_AUTHOR_NAME}' -c user.email='${AGENT_GIT_AUTHOR_EMAIL}'`,
122
+ );
123
+ });
124
+ });
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, mkdir, rm, writeFile, readFile, lstat, readlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { FilesystemBackend } from "deepagents";
6
+
7
+ import {
8
+ ensureStigmerSymlink,
9
+ removeStigmerSymlink,
10
+ STIGMER_LOCAL_STATE_DIR,
11
+ } from "../stigmer-link.js";
12
+ import { LocalWorkspaceBackend } from "../local-backend.js";
13
+ import { injectAttachments } from "../../../activities/execute-deep-agent/attachment-injector.js";
14
+
15
+ /**
16
+ * The `.stigmer` symlink is the bridge that makes platform-mounted content
17
+ * (approved plan, skills, attachment inputs) readable by the agent's file
18
+ * tools in BOTH harnesses. The lifecycle tests pin the ownership contract
19
+ * (ensure replaces, remove is symlink-only); the convergence tests pin the
20
+ * end-to-end physics the native harness depends on: content written through
21
+ * the platform-routing LocalWorkspaceBackend must be readable at the same
22
+ * `.stigmer/…` path through a stock deepagents FilesystemBackend rooted at
23
+ * the workspace — which is exactly what broke when the native harness had no
24
+ * symlink (the "plan file doesn't exist" bug).
25
+ */
26
+ describe("stigmer-link", () => {
27
+ let workspaceDir: string;
28
+ let platformDir: string;
29
+
30
+ beforeEach(async () => {
31
+ workspaceDir = await mkdtemp(join(tmpdir(), "stigmer-link-ws-"));
32
+ platformDir = await mkdtemp(join(tmpdir(), "stigmer-link-platform-"));
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await rm(workspaceDir, { recursive: true, force: true });
37
+ await rm(platformDir, { recursive: true, force: true });
38
+ });
39
+
40
+ const linkPath = () => join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
41
+
42
+ describe("ensureStigmerSymlink", () => {
43
+ it("creates the workspace symlink pointing at the platform dir", async () => {
44
+ await ensureStigmerSymlink(workspaceDir, platformDir);
45
+
46
+ expect((await lstat(linkPath())).isSymbolicLink()).toBe(true);
47
+ expect(await readlink(linkPath())).toBe(platformDir);
48
+ });
49
+
50
+ it("is idempotent for a correct existing link", async () => {
51
+ await ensureStigmerSymlink(workspaceDir, platformDir);
52
+ await ensureStigmerSymlink(workspaceDir, platformDir);
53
+
54
+ expect(await readlink(linkPath())).toBe(platformDir);
55
+ });
56
+
57
+ it("re-points a stale link at the current platform dir", async () => {
58
+ const stalePlatform = await mkdtemp(join(tmpdir(), "stigmer-link-stale-"));
59
+ try {
60
+ await ensureStigmerSymlink(workspaceDir, stalePlatform);
61
+ await ensureStigmerSymlink(workspaceDir, platformDir);
62
+
63
+ expect(await readlink(linkPath())).toBe(platformDir);
64
+ } finally {
65
+ await rm(stalePlatform, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ it("replaces a real .stigmer directory (platform owns the name)", async () => {
70
+ // The ownership sharp edge, pinned deliberately: a non-symlink
71
+ // `.stigmer` (left behind by an older runner) must never shadow the
72
+ // platform mount. See the module header for why this is safe.
73
+ await mkdir(join(linkPath(), "old"), { recursive: true });
74
+ await writeFile(join(linkPath(), "old", "stale.txt"), "stale");
75
+
76
+ await ensureStigmerSymlink(workspaceDir, platformDir);
77
+
78
+ expect((await lstat(linkPath())).isSymbolicLink()).toBe(true);
79
+ expect(await readlink(linkPath())).toBe(platformDir);
80
+ });
81
+ });
82
+
83
+ describe("removeStigmerSymlink", () => {
84
+ it("removes the symlink and leaves the platform dir intact", async () => {
85
+ await writeFile(join(platformDir, "keep.txt"), "keep");
86
+ await ensureStigmerSymlink(workspaceDir, platformDir);
87
+
88
+ await removeStigmerSymlink(workspaceDir);
89
+
90
+ await expect(lstat(linkPath())).rejects.toMatchObject({ code: "ENOENT" });
91
+ expect(await readFile(join(platformDir, "keep.txt"), "utf-8")).toBe("keep");
92
+ });
93
+
94
+ it("leaves a real .stigmer directory untouched (symlink-only removal)", async () => {
95
+ await mkdir(linkPath(), { recursive: true });
96
+ await writeFile(join(linkPath(), "user-owned.txt"), "mine");
97
+
98
+ await removeStigmerSymlink(workspaceDir);
99
+
100
+ expect(await readFile(join(linkPath(), "user-owned.txt"), "utf-8")).toBe("mine");
101
+ });
102
+
103
+ it("is a no-op when no link exists", async () => {
104
+ await expect(removeStigmerSymlink(workspaceDir)).resolves.toBeUndefined();
105
+ });
106
+ });
107
+
108
+ // ── Write/read convergence (the native-harness bug, pinned) ─────────
109
+
110
+ describe("platform write / agent read convergence", () => {
111
+ /** Read through the agent's actual file-tool backend. */
112
+ function agentRead(path: string): Promise<{ content?: string; error?: string }> {
113
+ const agentBackend = new FilesystemBackend({ rootDir: workspaceDir });
114
+ return agentBackend.read(path) as Promise<{ content?: string; error?: string }>;
115
+ }
116
+
117
+ it("approved plan injected via injectAttachments is readable by the deepagents backend", async () => {
118
+ const planFileName = "notes_ab12cd34.plan.md";
119
+ const planText = "# The Approved Plan\n\nStep 1: do the thing.\n";
120
+ const uploadPath = join(platformDir, "upload-src.md");
121
+ await writeFile(uploadPath, planText);
122
+
123
+ // Write path: exactly what the build turn does — the attachment is
124
+ // materialized through the platform-routing backend at the mount path
125
+ // the implement-plan directive will tell the model to read.
126
+ const workspaceBackend = new LocalWorkspaceBackend(workspaceDir, platformDir);
127
+ const injected = await injectAttachments({
128
+ backend: workspaceBackend,
129
+ attachments: [{
130
+ filename: planFileName,
131
+ storageKey: "",
132
+ mountPath: `.stigmer/inputs/${planFileName}`,
133
+ contentType: "text/markdown",
134
+ extract: false,
135
+ localPath: uploadPath,
136
+ $typeName: "ai.stigmer.agentic.agentexecution.v1.Attachment",
137
+ } as any],
138
+ storage: undefined,
139
+ isLocalMode: true,
140
+ });
141
+ expect(injected.map((f) => f.path)).toEqual([`.stigmer/inputs/${planFileName}`]);
142
+
143
+ // Without the symlink the agent cannot see the plan — the bug this
144
+ // module fixes for the native harness. Pinned so the link stays
145
+ // load-bearing in reviewers' and agents' mental models.
146
+ const before = await agentRead(`.stigmer/inputs/${planFileName}`);
147
+ expect(before.content ?? "").not.toContain("The Approved Plan");
148
+
149
+ await ensureStigmerSymlink(workspaceDir, platformDir);
150
+
151
+ const after = await agentRead(`.stigmer/inputs/${planFileName}`);
152
+ expect(after.error).toBeUndefined();
153
+ expect(after.content).toContain("The Approved Plan");
154
+ });
155
+
156
+ it("skill written through the platform-routing backend is readable at its prompt location", async () => {
157
+ // Same physical write the skill-writer performs for
158
+ // `.stigmer/skills/{name}/SKILL.md` — the path the system prompt tells
159
+ // the model to read to activate the skill.
160
+ const workspaceBackend = new LocalWorkspaceBackend(workspaceDir, platformDir);
161
+ await workspaceBackend.writeFile(
162
+ ".stigmer/skills/my-skill/SKILL.md",
163
+ "# My Skill\n\nDo skillful things.\n",
164
+ );
165
+
166
+ await ensureStigmerSymlink(workspaceDir, platformDir);
167
+
168
+ const result = await agentRead(".stigmer/skills/my-skill/SKILL.md");
169
+ expect(result.error).toBeUndefined();
170
+ expect(result.content).toContain("My Skill");
171
+ });
172
+ });
173
+ });
@@ -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
+ });