@stigmer/runner 3.1.5 → 3.1.7
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/discover-mcp-server.js +23 -0
- package/dist/activities/discover-mcp-server.js.map +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/mcp-oauth-detect.d.ts +53 -0
- package/dist/shared/mcp-oauth-detect.js +99 -0
- package/dist/shared/mcp-oauth-detect.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/discover-mcp-server.ts +25 -0
- 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/__tests__/mcp-oauth-detect.test.ts +147 -0
- package/src/shared/execution-status-writer.ts +56 -0
- package/src/shared/mcp-oauth-detect.ts +112 -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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git write-back coordinator for workspace entries.
|
|
3
|
+
*
|
|
4
|
+
* Commits the workspace's changes to the session's write-back branch, pushes,
|
|
5
|
+
* and keeps one pull request open per session. The branch and PR are
|
|
6
|
+
* SESSION-scoped (`stigmer/<session-id>`): a session is one workstream, so
|
|
7
|
+
* every approved turn appends commits to the same branch and the same PR —
|
|
8
|
+
* mirroring how Cursor cloud agents and Codex present a session's deliverable.
|
|
9
|
+
* Execution-scoped branches were a modeling bug: each turn branched off the
|
|
10
|
+
* previous turn's branch, leaving a trail of superseded PRs.
|
|
11
|
+
*
|
|
12
|
+
* Because the branch outlives any single execution, every git/GitHub step is
|
|
13
|
+
* idempotent across coordinator instances: the branch is checked out if it
|
|
14
|
+
* already exists (locally, or on the remote after a sandbox re-provision), and
|
|
15
|
+
* an already-open PR for the branch is adopted instead of re-created.
|
|
16
|
+
*
|
|
17
|
+
* When this runs depends on the harness's file-review mode:
|
|
18
|
+
* - Capture mode (apply-then-review — every git workspace): the streaming
|
|
19
|
+
* trigger is suppressed and `finalize()` runs exactly once on the APPROVED
|
|
20
|
+
* tree, after review decisions reconcile (see processCaptureWriteback in
|
|
21
|
+
* index.ts). Speculative mid-turn edits never reach GitHub.
|
|
22
|
+
* - Legacy non-capture turns: `onFileModified(path)` triggers an incremental
|
|
23
|
+
* commit/push per file-modifying tool call (DD-5), with `finalize()` as the
|
|
24
|
+
* post-stream safety net.
|
|
25
|
+
*
|
|
26
|
+
* Concurrency: one mutex per workspace entry serializes git operations.
|
|
27
|
+
*/
|
|
28
|
+
import { create } from "@bufbuild/protobuf";
|
|
29
|
+
import { WorkspaceWriteBackSchema, } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
|
|
30
|
+
import { WorkspaceWriteBackPhase, } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
|
|
31
|
+
import { GitWriteBackMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
32
|
+
import { SourceType } from "./types.js";
|
|
33
|
+
import { gitCommitAsAgent } from "./git-identity.js";
|
|
34
|
+
const WRITE_BACK_ENABLED_MODES = new Set([
|
|
35
|
+
GitWriteBackMode.GIT_WRITE_BACK_MODE_UNSPECIFIED,
|
|
36
|
+
GitWriteBackMode.GIT_WRITE_BACK_BRANCH_AND_PR,
|
|
37
|
+
]);
|
|
38
|
+
const GITHUB_API = "https://api.github.com";
|
|
39
|
+
export class WriteBackCoordinator {
|
|
40
|
+
statusWriter;
|
|
41
|
+
executionId;
|
|
42
|
+
workspaceBackend;
|
|
43
|
+
branchName;
|
|
44
|
+
/**
|
|
45
|
+
* Token for the GitHub PR API, plumbed explicitly from the resolved
|
|
46
|
+
* execution env (the same GITHUB_TOKEN that credentials the clone/push).
|
|
47
|
+
* Empty when the session has none — commit/push may still succeed via the
|
|
48
|
+
* repo-local credential store, so an empty token degrades to PUSHED with an
|
|
49
|
+
* actionable error rather than blocking the write-back.
|
|
50
|
+
*/
|
|
51
|
+
githubToken;
|
|
52
|
+
eligible = new Map();
|
|
53
|
+
state = new Map();
|
|
54
|
+
locks = new Map();
|
|
55
|
+
constructor(opts) {
|
|
56
|
+
this.statusWriter = opts.statusWriter;
|
|
57
|
+
this.executionId = opts.executionId;
|
|
58
|
+
this.workspaceBackend = opts.workspaceBackend;
|
|
59
|
+
this.githubToken = opts.githubToken;
|
|
60
|
+
// The FULL session id: a truncated ULID is timestamp-dominated, so two
|
|
61
|
+
// sessions created near-simultaneously would collide on a short prefix.
|
|
62
|
+
this.branchName = `stigmer/${opts.sessionId}`;
|
|
63
|
+
this.initEligibleEntries(opts.provisionResults, opts.workspaceEntries);
|
|
64
|
+
}
|
|
65
|
+
get hasEligibleEntries() {
|
|
66
|
+
return this.eligible.size > 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Called after a file-modifying tool completes (legacy non-capture turns
|
|
70
|
+
* only). Resolves the path to a workspace entry and runs an incremental
|
|
71
|
+
* commit/push cycle. Fire-and-forget: errors are logged, never thrown.
|
|
72
|
+
*/
|
|
73
|
+
async onFileModified(path) {
|
|
74
|
+
try {
|
|
75
|
+
const entryName = this.resolveEntry(path);
|
|
76
|
+
if (!entryName)
|
|
77
|
+
return;
|
|
78
|
+
await this.withLock(entryName, () => this.writeBackEntry(entryName));
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
console.warn(`[WriteBack] execution=${this.executionId} — ` +
|
|
82
|
+
`onFileModified error for '${path}': ${err}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Commits and pushes every eligible workspace entry's remaining uncommitted
|
|
87
|
+
* changes. In capture mode this is THE write-back — invoked once on the
|
|
88
|
+
* approved tree after review reconcile; on legacy turns it is the
|
|
89
|
+
* post-stream safety net.
|
|
90
|
+
*/
|
|
91
|
+
async finalize() {
|
|
92
|
+
for (const entryName of this.eligible.keys()) {
|
|
93
|
+
try {
|
|
94
|
+
await this.withLock(entryName, () => this.writeBackEntry(entryName));
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.warn(`[WriteBack] execution=${this.executionId} entry=${entryName} — ` +
|
|
98
|
+
`finalize error: ${err}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ── Initialization ──────────────────────────────────────────────────
|
|
103
|
+
initEligibleEntries(provisionResults, workspaceEntries) {
|
|
104
|
+
const modeMap = new Map();
|
|
105
|
+
for (const entry of workspaceEntries) {
|
|
106
|
+
const source = entry.source;
|
|
107
|
+
if (source?.source.case === "gitRepo") {
|
|
108
|
+
modeMap.set(entry.name, source.source.value.writeBackMode);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const pr of provisionResults) {
|
|
112
|
+
if (pr.sourceType !== SourceType.GIT_REPO)
|
|
113
|
+
continue;
|
|
114
|
+
if (!pr.gitMetadata)
|
|
115
|
+
continue;
|
|
116
|
+
if (!pr.gitMetadata.gitCredentialsConfigured)
|
|
117
|
+
continue;
|
|
118
|
+
const mode = modeMap.get(pr.entryName) ?? GitWriteBackMode.GIT_WRITE_BACK_MODE_UNSPECIFIED;
|
|
119
|
+
if (!WRITE_BACK_ENABLED_MODES.has(mode))
|
|
120
|
+
continue;
|
|
121
|
+
this.eligible.set(pr.entryName, {
|
|
122
|
+
provisionResult: pr,
|
|
123
|
+
baseBranch: pr.gitMetadata.branch,
|
|
124
|
+
rootDir: pr.rootDir,
|
|
125
|
+
entryName: pr.entryName,
|
|
126
|
+
});
|
|
127
|
+
this.state.set(pr.entryName, {
|
|
128
|
+
branchReady: false,
|
|
129
|
+
prCreated: false,
|
|
130
|
+
prUrl: "",
|
|
131
|
+
prNumber: 0,
|
|
132
|
+
commitCount: 0,
|
|
133
|
+
lastCommitSha: "",
|
|
134
|
+
githubOwner: "",
|
|
135
|
+
githubRepo: "",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (this.eligible.size > 0) {
|
|
139
|
+
console.log(`[WriteBack] execution=${this.executionId} — coordinator initialized with ` +
|
|
140
|
+
`${this.eligible.size} eligible workspace(s): ${[...this.eligible.keys()].join(", ")}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ── Path Resolution ─────────────────────────────────────────────────
|
|
144
|
+
resolveEntry(path) {
|
|
145
|
+
if (this.eligible.size === 0)
|
|
146
|
+
return null;
|
|
147
|
+
if (this.eligible.size === 1)
|
|
148
|
+
return this.eligible.keys().next().value;
|
|
149
|
+
const normalized = path.replace(/^\/+/, "");
|
|
150
|
+
for (const entryName of this.eligible.keys()) {
|
|
151
|
+
if (normalized.startsWith(entryName + "/") || normalized === entryName) {
|
|
152
|
+
return entryName;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
// ── Core Write-Back Cycle ───────────────────────────────────────────
|
|
158
|
+
/**
|
|
159
|
+
* One write-back cycle for an entry: branch → commit → push → PR → status.
|
|
160
|
+
*
|
|
161
|
+
* Failure semantics are split by what actually succeeded (the phases are
|
|
162
|
+
* facts, not a single verdict):
|
|
163
|
+
* - branch/commit/push failure → FAILED (the work did not reach GitHub);
|
|
164
|
+
* - PR failure after a successful push → PUSHED with the PR error carried
|
|
165
|
+
* in `error` — the branch is live and usable, and saying "failed" for a
|
|
166
|
+
* pushed branch would be dishonest (the original cloud regression).
|
|
167
|
+
*/
|
|
168
|
+
async writeBackEntry(entryName) {
|
|
169
|
+
const entry = this.eligible.get(entryName);
|
|
170
|
+
const entryState = this.state.get(entryName);
|
|
171
|
+
const rootDir = entry.rootDir;
|
|
172
|
+
const exec = async (cmd) => {
|
|
173
|
+
return this.workspaceBackend.execute(`cd ${rootDir} && ${cmd}`);
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
const hasChanges = await this.hasChanges(exec);
|
|
177
|
+
if (!hasChanges)
|
|
178
|
+
return;
|
|
179
|
+
if (!entryState.branchReady) {
|
|
180
|
+
await this.ensureBranch(entryName, entryState, exec);
|
|
181
|
+
}
|
|
182
|
+
await this.commitAndPush(entryName, entryState, exec);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.warn(`[WriteBack] execution=${this.executionId} entry=${entryName} — ` +
|
|
186
|
+
`commit/push error: ${err}`);
|
|
187
|
+
const wb = create(WorkspaceWriteBackSchema, {
|
|
188
|
+
workspaceEntryName: entryName,
|
|
189
|
+
baseBranch: entry.baseBranch,
|
|
190
|
+
branchName: entryState.branchReady ? this.branchName : "",
|
|
191
|
+
phase: WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED,
|
|
192
|
+
error: String(err),
|
|
193
|
+
});
|
|
194
|
+
if (entryState.prCreated) {
|
|
195
|
+
wb.pullRequestUrl = entryState.prUrl;
|
|
196
|
+
wb.pullRequestNumber = entryState.prNumber;
|
|
197
|
+
}
|
|
198
|
+
this.statusWriter.addWriteBack(wb);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
let prError = "";
|
|
202
|
+
if (!entryState.prCreated) {
|
|
203
|
+
try {
|
|
204
|
+
await this.ensurePr(entryName, entryState, entry);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
console.warn(`[WriteBack] execution=${this.executionId} entry=${entryName} — ` +
|
|
208
|
+
`PR error (branch is pushed): ${err}`);
|
|
209
|
+
prError = String(err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await this.updateStatus(entryName, entryState, entry, exec, prError);
|
|
213
|
+
}
|
|
214
|
+
// ── Git Operations ──────────────────────────────────────────────────
|
|
215
|
+
async hasChanges(exec) {
|
|
216
|
+
const diff = await exec("git diff --stat").catch(() => "");
|
|
217
|
+
const staged = await exec("git diff --cached --stat").catch(() => "");
|
|
218
|
+
if (diff.trim() || staged.trim())
|
|
219
|
+
return true;
|
|
220
|
+
const untracked = await exec("git ls-files --others --exclude-standard").catch(() => "");
|
|
221
|
+
return untracked.trim().length > 0;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Put the working tree on the session branch, wherever the branch already
|
|
225
|
+
* lives. Three cases, in order:
|
|
226
|
+
* 1. HEAD is already on it — a later turn in the same workspace (the common
|
|
227
|
+
* multi-turn path; the previous turn's cycle left HEAD there).
|
|
228
|
+
* 2. It exists locally or on the remote — a re-provisioned workspace whose
|
|
229
|
+
* clone lost the local branch. Checked out (tracking the remote ref when
|
|
230
|
+
* that is the only copy). The checkout carries this turn's uncommitted
|
|
231
|
+
* changes along; if they collide with the branch's prior commits git
|
|
232
|
+
* refuses, and the error surfaces as an honest FAILED record — we never
|
|
233
|
+
* force (-B) over the session's pushed history.
|
|
234
|
+
* 3. Neither — the session's first write-back creates it.
|
|
235
|
+
*/
|
|
236
|
+
async ensureBranch(entryName, entryState, exec) {
|
|
237
|
+
const current = (await exec("git branch --show-current").catch(() => "")).trim();
|
|
238
|
+
if (current === this.branchName) {
|
|
239
|
+
entryState.branchReady = true;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const localRef = await exec(`git rev-parse --verify --quiet refs/heads/${this.branchName}`).then((out) => out.trim(), () => "");
|
|
243
|
+
if (localRef) {
|
|
244
|
+
await exec(`git checkout ${this.branchName}`);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const remoteRef = (await exec(`git ls-remote --heads origin ${this.branchName}`).catch(() => "")).trim();
|
|
248
|
+
if (remoteRef) {
|
|
249
|
+
await exec(`git fetch origin ${this.branchName}`);
|
|
250
|
+
await exec(`git checkout -b ${this.branchName} origin/${this.branchName}`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
await exec(`git checkout -b ${this.branchName}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
entryState.branchReady = true;
|
|
257
|
+
console.log(`[WriteBack] execution=${this.executionId} entry=${entryName} — ` +
|
|
258
|
+
`on branch ${this.branchName}`);
|
|
259
|
+
}
|
|
260
|
+
async commitAndPush(entryName, entryState, exec) {
|
|
261
|
+
await exec("git add -A");
|
|
262
|
+
// Committed as the agent identity: the cloud sandbox has no git identity
|
|
263
|
+
// configured (a bare commit fails with "Author identity unknown"), and in
|
|
264
|
+
// local mode the agent's work should not be attributed to the host user.
|
|
265
|
+
// The execution id in the message links each commit back to its turn.
|
|
266
|
+
await exec(gitCommitAsAgent(`agent changes (${this.executionId})`));
|
|
267
|
+
entryState.commitCount++;
|
|
268
|
+
const shaOutput = await exec("git rev-parse HEAD");
|
|
269
|
+
entryState.lastCommitSha = shaOutput.trim();
|
|
270
|
+
// Always -u: idempotent whether this push creates the remote branch or
|
|
271
|
+
// appends to it, and it (re-)establishes tracking after a re-provision.
|
|
272
|
+
await exec(`git push -u origin ${this.branchName}`);
|
|
273
|
+
console.log(`[WriteBack] execution=${this.executionId} entry=${entryName} — ` +
|
|
274
|
+
`commit pushed to ${this.branchName} (sha=${entryState.lastCommitSha.slice(0, 12)})`);
|
|
275
|
+
}
|
|
276
|
+
// ── GitHub PR ───────────────────────────────────────────────────────
|
|
277
|
+
/**
|
|
278
|
+
* Ensure one open PR exists for the session branch: adopt an already-open
|
|
279
|
+
* one (a prior turn — possibly a prior coordinator instance — created it),
|
|
280
|
+
* else create it. Runs only after a successful push; a thrown error here is
|
|
281
|
+
* reported as PUSHED + error, never FAILED.
|
|
282
|
+
*/
|
|
283
|
+
async ensurePr(entryName, entryState, entry) {
|
|
284
|
+
const meta = entry.provisionResult.gitMetadata;
|
|
285
|
+
if (!entryState.githubOwner) {
|
|
286
|
+
const { owner, repo } = parseGithubRepo(meta.repoUrl);
|
|
287
|
+
entryState.githubOwner = owner;
|
|
288
|
+
entryState.githubRepo = repo;
|
|
289
|
+
}
|
|
290
|
+
if (!this.githubToken) {
|
|
291
|
+
throw new Error("No GitHub token available to open a pull request. The branch " +
|
|
292
|
+
`'${this.branchName}' was pushed — open the PR manually, or configure ` +
|
|
293
|
+
"GITHUB_TOKEN for the session so PRs are created automatically.");
|
|
294
|
+
}
|
|
295
|
+
const existing = await this.findOpenPr(entryState);
|
|
296
|
+
if (existing) {
|
|
297
|
+
entryState.prCreated = true;
|
|
298
|
+
entryState.prUrl = existing.url;
|
|
299
|
+
entryState.prNumber = existing.number;
|
|
300
|
+
console.log(`[WriteBack] execution=${this.executionId} entry=${entryName} — ` +
|
|
301
|
+
`adopted open PR #${existing.number}: ${existing.url}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const shortSessionId = this.branchName.replace(/^stigmer\//, "");
|
|
305
|
+
const prBody = `Automated pull request from a Stigmer agent session.\n\n` +
|
|
306
|
+
`**Session:** \`${shortSessionId}\`\n` +
|
|
307
|
+
`**Workspace:** \`${entryName}\`\n\n` +
|
|
308
|
+
`Each approved turn appends its commits to this pull request.\n`;
|
|
309
|
+
const resp = await fetch(`${GITHUB_API}/repos/${entryState.githubOwner}/${entryState.githubRepo}/pulls`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: this.githubHeaders(),
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
title: `Stigmer agent changes (${shortSessionId})`,
|
|
314
|
+
body: prBody,
|
|
315
|
+
head: this.branchName,
|
|
316
|
+
base: entry.baseBranch,
|
|
317
|
+
}),
|
|
318
|
+
});
|
|
319
|
+
if (!resp.ok) {
|
|
320
|
+
const body = await resp.text();
|
|
321
|
+
throw new Error(`GitHub API error (HTTP ${resp.status}): ${body}`);
|
|
322
|
+
}
|
|
323
|
+
const data = await resp.json();
|
|
324
|
+
entryState.prCreated = true;
|
|
325
|
+
entryState.prUrl = data.html_url ?? "";
|
|
326
|
+
entryState.prNumber = data.number ?? 0;
|
|
327
|
+
console.log(`[WriteBack] execution=${this.executionId} entry=${entryName} — ` +
|
|
328
|
+
`PR #${entryState.prNumber} created: ${entryState.prUrl}`);
|
|
329
|
+
}
|
|
330
|
+
/** The open PR whose head is the session branch, if one exists. */
|
|
331
|
+
async findOpenPr(entryState) {
|
|
332
|
+
const head = `${entryState.githubOwner}:${this.branchName}`;
|
|
333
|
+
const resp = await fetch(`${GITHUB_API}/repos/${entryState.githubOwner}/${entryState.githubRepo}` +
|
|
334
|
+
`/pulls?head=${encodeURIComponent(head)}&state=open`, { headers: this.githubHeaders() });
|
|
335
|
+
if (!resp.ok) {
|
|
336
|
+
const body = await resp.text();
|
|
337
|
+
throw new Error(`GitHub API error listing PRs (HTTP ${resp.status}): ${body}`);
|
|
338
|
+
}
|
|
339
|
+
const data = await resp.json();
|
|
340
|
+
const pr = data[0];
|
|
341
|
+
if (!pr)
|
|
342
|
+
return null;
|
|
343
|
+
return { url: pr.html_url ?? "", number: pr.number ?? 0 };
|
|
344
|
+
}
|
|
345
|
+
githubHeaders() {
|
|
346
|
+
return {
|
|
347
|
+
"Authorization": `Bearer ${this.githubToken}`,
|
|
348
|
+
"Accept": "application/vnd.github+json",
|
|
349
|
+
"Content-Type": "application/json",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// ── Status Reporting ────────────────────────────────────────────────
|
|
353
|
+
async updateStatus(entryName, entryState, entry, exec, prError) {
|
|
354
|
+
const summaryOutput = await exec(`git diff --stat ${entry.baseBranch}...HEAD`).catch(() => "");
|
|
355
|
+
const phase = entryState.prCreated
|
|
356
|
+
? WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED
|
|
357
|
+
: WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED;
|
|
358
|
+
const wb = create(WorkspaceWriteBackSchema, {
|
|
359
|
+
workspaceEntryName: entryName,
|
|
360
|
+
branchName: this.branchName,
|
|
361
|
+
baseBranch: entry.baseBranch,
|
|
362
|
+
commitSha: entryState.lastCommitSha,
|
|
363
|
+
pullRequestUrl: entryState.prUrl,
|
|
364
|
+
pullRequestNumber: entryState.prNumber,
|
|
365
|
+
diffSummary: summaryOutput.trim(),
|
|
366
|
+
phase,
|
|
367
|
+
// Non-empty only for a PUSHED record whose PR step failed: the branch
|
|
368
|
+
// is live, and the error tells the user why there is no PR link yet.
|
|
369
|
+
error: prError,
|
|
370
|
+
});
|
|
371
|
+
this.statusWriter.addWriteBack(wb);
|
|
372
|
+
}
|
|
373
|
+
// ── Concurrency ─────────────────────────────────────────────────────
|
|
374
|
+
async withLock(entryName, fn) {
|
|
375
|
+
const existing = this.locks.get(entryName) ?? Promise.resolve();
|
|
376
|
+
const next = existing.then(fn, fn);
|
|
377
|
+
this.locks.set(entryName, next);
|
|
378
|
+
await next;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
382
|
+
/**
|
|
383
|
+
* Extract GitHub owner and repo from a clone URL.
|
|
384
|
+
* Supports HTTPS (with or without .git) and SSH formats.
|
|
385
|
+
*/
|
|
386
|
+
export function parseGithubRepo(repoUrl) {
|
|
387
|
+
const httpsMatch = repoUrl.match(/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
388
|
+
if (httpsMatch) {
|
|
389
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`Cannot parse GitHub owner/repo from URL: ${repoUrl}`);
|
|
392
|
+
}
|
|
393
|
+
//# sourceMappingURL=writeback-coordinator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"writeback-coordinator.js","sourceRoot":"","sources":["../../../src/shared/workspace/writeback-coordinator.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/runner",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.7",
|
|
4
4
|
"description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"@opentelemetry/sdk-metrics": "^2.0.0",
|
|
86
86
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
|
87
87
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
|
88
|
-
"@stigmer/protos": "3.1.
|
|
88
|
+
"@stigmer/protos": "3.1.7",
|
|
89
89
|
"@temporalio/activity": "^1.11.0",
|
|
90
90
|
"@temporalio/client": "^1.11.0",
|
|
91
91
|
"@temporalio/common": "^1.11.0",
|
|
@@ -24,6 +24,7 @@ import { activityStarted, activityFinished } from "../idle-watchdog.js";
|
|
|
24
24
|
import { StigmerClient } from "../client/stigmer-client.js";
|
|
25
25
|
import { mcpServerToResolved } from "../shared/mcp-resolver.js";
|
|
26
26
|
import { toMcpClientConfig } from "../shared/mcp-manager.js";
|
|
27
|
+
import { detectOAuthChallenge } from "../shared/mcp-oauth-detect.js";
|
|
27
28
|
import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
28
29
|
import type { Config } from "../config.js";
|
|
29
30
|
|
|
@@ -374,6 +375,14 @@ async function connectAndDiscover(
|
|
|
374
375
|
);
|
|
375
376
|
}
|
|
376
377
|
});
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// The MCP client surfaces a 401 OAuth challenge as an opaque aggregate
|
|
380
|
+
// ("unhandled errors in a TaskGroup"). For HTTP servers, re-probe once to
|
|
381
|
+
// see if the endpoint is actually asking for OAuth and, if so, replace the
|
|
382
|
+
// useless error with an actionable one. Non-OAuth failures rethrow as-is.
|
|
383
|
+
const oauthError = await classifyHttpOAuthFailure(slug, connectionConfig);
|
|
384
|
+
if (oauthError) throw oauthError;
|
|
385
|
+
throw err;
|
|
377
386
|
} finally {
|
|
378
387
|
await client.close().catch((err: unknown) => {
|
|
379
388
|
console.warn(
|
|
@@ -386,6 +395,22 @@ async function connectAndDiscover(
|
|
|
386
395
|
return { tools, resourceTemplates };
|
|
387
396
|
}
|
|
388
397
|
|
|
398
|
+
/**
|
|
399
|
+
* If the server uses HTTP transport, probe its endpoint to classify a discovery
|
|
400
|
+
* failure as an OAuth challenge. Returns the actionable error to throw, or
|
|
401
|
+
* `null` for stdio servers and non-OAuth failures (caller rethrows original).
|
|
402
|
+
*/
|
|
403
|
+
async function classifyHttpOAuthFailure(
|
|
404
|
+
slug: string,
|
|
405
|
+
connectionConfig: ReturnType<typeof toMcpClientConfig>,
|
|
406
|
+
): Promise<Error | null> {
|
|
407
|
+
const connection = connectionConfig[slug];
|
|
408
|
+
if (!connection || connection.transport !== "http" || !connection.url) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
return detectOAuthChallenge(connection.url, connection.headers, slug);
|
|
412
|
+
}
|
|
413
|
+
|
|
389
414
|
async function withTimeout<T>(
|
|
390
415
|
ms: number,
|
|
391
416
|
serverSlug: string,
|
|
@@ -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
|
|