@renseiai/agentfactory 0.8.13 → 0.8.14
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/src/orchestrator/completion-contracts.d.ts +89 -0
- package/dist/src/orchestrator/completion-contracts.d.ts.map +1 -0
- package/dist/src/orchestrator/completion-contracts.js +228 -0
- package/dist/src/orchestrator/completion-contracts.test.d.ts +2 -0
- package/dist/src/orchestrator/completion-contracts.test.d.ts.map +1 -0
- package/dist/src/orchestrator/completion-contracts.test.js +195 -0
- package/dist/src/orchestrator/index.d.ts +4 -0
- package/dist/src/orchestrator/index.d.ts.map +1 -1
- package/dist/src/orchestrator/index.js +3 -0
- package/dist/src/orchestrator/orchestrator.d.ts +32 -0
- package/dist/src/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/src/orchestrator/orchestrator.js +157 -26
- package/dist/src/orchestrator/session-backstop.d.ts +67 -0
- package/dist/src/orchestrator/session-backstop.d.ts.map +1 -0
- package/dist/src/orchestrator/session-backstop.js +394 -0
- package/dist/src/orchestrator/session-backstop.test.d.ts +2 -0
- package/dist/src/orchestrator/session-backstop.test.d.ts.map +1 -0
- package/dist/src/orchestrator/session-backstop.test.js +245 -0
- package/dist/src/orchestrator/worktree-checks.test.d.ts +2 -0
- package/dist/src/orchestrator/worktree-checks.test.d.ts.map +1 -0
- package/dist/src/orchestrator/worktree-checks.test.js +159 -0
- package/dist/src/providers/a2a-provider.d.ts +4 -0
- package/dist/src/providers/a2a-provider.d.ts.map +1 -1
- package/dist/src/providers/a2a-provider.js +4 -0
- package/dist/src/providers/amp-provider.d.ts +4 -0
- package/dist/src/providers/amp-provider.d.ts.map +1 -1
- package/dist/src/providers/amp-provider.js +4 -0
- package/dist/src/providers/claude-provider.d.ts +4 -0
- package/dist/src/providers/claude-provider.d.ts.map +1 -1
- package/dist/src/providers/claude-provider.js +6 -0
- package/dist/src/providers/codex-provider.d.ts +4 -0
- package/dist/src/providers/codex-provider.d.ts.map +1 -1
- package/dist/src/providers/codex-provider.js +4 -0
- package/dist/src/providers/index.d.ts +1 -1
- package/dist/src/providers/index.d.ts.map +1 -1
- package/dist/src/providers/spring-ai-provider.d.ts +4 -0
- package/dist/src/providers/spring-ai-provider.d.ts.map +1 -1
- package/dist/src/providers/spring-ai-provider.js +4 -0
- package/dist/src/providers/types.d.ts +22 -0
- package/dist/src/providers/types.d.ts.map +1 -1
- package/dist/src/templates/types.d.ts +3 -0
- package/dist/src/templates/types.d.ts.map +1 -1
- package/dist/src/templates/types.js +2 -0
- package/dist/src/tools/index.d.ts +1 -0
- package/dist/src/tools/index.d.ts.map +1 -1
- package/dist/src/tools/registry.d.ts +6 -1
- package/dist/src/tools/registry.d.ts.map +1 -1
- package/dist/src/tools/registry.js +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Backstop
|
|
3
|
+
*
|
|
4
|
+
* Deterministic post-session recovery that runs after every agent session.
|
|
5
|
+
* Validates the session's outputs against the work type's completion contract,
|
|
6
|
+
* then takes backstop actions for any recoverable gaps.
|
|
7
|
+
*
|
|
8
|
+
* This is provider-agnostic — it operates on the worktree and GitHub API,
|
|
9
|
+
* not on the agent session. Every provider gets the same backstop.
|
|
10
|
+
*
|
|
11
|
+
* Architecture:
|
|
12
|
+
* 1. Collect session outputs (git state, PR detection, work result markers)
|
|
13
|
+
* 2. Validate against the completion contract
|
|
14
|
+
* 3. Run backstop actions for recoverable fields (push, create PR)
|
|
15
|
+
* 4. Return structured result for the orchestrator to act on
|
|
16
|
+
*/
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import { getCompletionContract, validateCompletion, formatMissingFields, } from './completion-contracts.js';
|
|
19
|
+
/**
|
|
20
|
+
* Collect structured outputs from the completed session.
|
|
21
|
+
* Inspects git state, agent process data, and tracked flags.
|
|
22
|
+
*/
|
|
23
|
+
export function collectSessionOutputs(ctx) {
|
|
24
|
+
const { agent } = ctx;
|
|
25
|
+
const outputs = {
|
|
26
|
+
prUrl: agent.pullRequestUrl ?? undefined,
|
|
27
|
+
workResult: agent.workResult ?? 'unknown',
|
|
28
|
+
commentPosted: ctx.commentPosted,
|
|
29
|
+
issueUpdated: ctx.issueUpdated,
|
|
30
|
+
subIssuesCreated: ctx.subIssuesCreated,
|
|
31
|
+
};
|
|
32
|
+
// Inspect git state for code-producing work types
|
|
33
|
+
if (agent.worktreePath) {
|
|
34
|
+
try {
|
|
35
|
+
outputs.commitsPresent = hasCommitsAheadOfMain(agent.worktreePath);
|
|
36
|
+
outputs.branchPushed = isBranchPushed(agent.worktreePath);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Git inspection failed — leave as undefined (unknown)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// If PR URL exists, check merged status
|
|
43
|
+
if (outputs.prUrl) {
|
|
44
|
+
try {
|
|
45
|
+
outputs.prMerged = isPrMerged(outputs.prUrl, agent.worktreePath);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// GitHub check failed — leave as undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return outputs;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run the post-session backstop for an agent.
|
|
55
|
+
*
|
|
56
|
+
* 1. Collects session outputs
|
|
57
|
+
* 2. Validates against completion contract
|
|
58
|
+
* 3. Runs backstop actions for recoverable gaps
|
|
59
|
+
* 4. Returns structured result
|
|
60
|
+
*
|
|
61
|
+
* This function is safe to call for any work type — it returns a no-op
|
|
62
|
+
* result for work types without contracts.
|
|
63
|
+
*/
|
|
64
|
+
export function runBackstop(ctx, options) {
|
|
65
|
+
const workType = ctx.agent.workType ?? 'development';
|
|
66
|
+
const contract = getCompletionContract(workType);
|
|
67
|
+
// No contract for this work type — nothing to validate
|
|
68
|
+
if (!contract) {
|
|
69
|
+
return {
|
|
70
|
+
contract: null,
|
|
71
|
+
outputs: collectSessionOutputs(ctx),
|
|
72
|
+
validation: null,
|
|
73
|
+
backstop: { actions: [], fullyRecovered: true, remainingGaps: [] },
|
|
74
|
+
diagnosticMessage: null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Collect and validate
|
|
78
|
+
const outputs = collectSessionOutputs(ctx);
|
|
79
|
+
const validation = validateCompletion(contract, outputs);
|
|
80
|
+
// Already satisfied — no backstop needed
|
|
81
|
+
if (validation.satisfied) {
|
|
82
|
+
return {
|
|
83
|
+
contract,
|
|
84
|
+
outputs,
|
|
85
|
+
validation,
|
|
86
|
+
backstop: { actions: [], fullyRecovered: true, remainingGaps: [] },
|
|
87
|
+
diagnosticMessage: null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Run backstop actions for recoverable fields
|
|
91
|
+
const actions = [];
|
|
92
|
+
for (const fieldType of validation.backstopRecoverable) {
|
|
93
|
+
switch (fieldType) {
|
|
94
|
+
case 'branch_pushed': {
|
|
95
|
+
if (options?.dryRun) {
|
|
96
|
+
actions.push({ field: 'branch_pushed', action: 'would push branch', success: false, detail: 'dry-run' });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const pushResult = backstopPushBranch(ctx.agent.worktreePath);
|
|
100
|
+
actions.push(pushResult);
|
|
101
|
+
if (pushResult.success) {
|
|
102
|
+
outputs.branchPushed = true;
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case 'pr_url': {
|
|
107
|
+
// Can only create PR if branch is pushed (either already or by backstop above)
|
|
108
|
+
if (!outputs.branchPushed) {
|
|
109
|
+
actions.push({
|
|
110
|
+
field: 'pr_url',
|
|
111
|
+
action: 'skipped PR creation — branch not pushed',
|
|
112
|
+
success: false,
|
|
113
|
+
detail: 'Branch must be pushed before PR can be created',
|
|
114
|
+
});
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
if (options?.dryRun) {
|
|
118
|
+
actions.push({ field: 'pr_url', action: 'would create PR', success: false, detail: 'dry-run' });
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
const prResult = backstopCreatePR(ctx.agent, options);
|
|
122
|
+
actions.push(prResult);
|
|
123
|
+
if (prResult.success && prResult.detail) {
|
|
124
|
+
outputs.prUrl = prResult.detail;
|
|
125
|
+
ctx.agent.pullRequestUrl = prResult.detail;
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Re-validate after backstop
|
|
132
|
+
const postBackstopValidation = validateCompletion(contract, outputs);
|
|
133
|
+
const remainingGaps = postBackstopValidation.missingFields;
|
|
134
|
+
const fullyRecovered = postBackstopValidation.satisfied;
|
|
135
|
+
const backstopResult = {
|
|
136
|
+
actions,
|
|
137
|
+
fullyRecovered,
|
|
138
|
+
remainingGaps,
|
|
139
|
+
};
|
|
140
|
+
// Build diagnostic message for unrecoverable gaps
|
|
141
|
+
const diagnosticMessage = fullyRecovered
|
|
142
|
+
? null
|
|
143
|
+
: formatMissingFields(contract, postBackstopValidation);
|
|
144
|
+
return {
|
|
145
|
+
contract,
|
|
146
|
+
outputs,
|
|
147
|
+
validation,
|
|
148
|
+
backstop: backstopResult,
|
|
149
|
+
diagnosticMessage,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Git inspection helpers
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
function hasCommitsAheadOfMain(worktreePath) {
|
|
156
|
+
try {
|
|
157
|
+
const currentBranch = execSync('git branch --show-current', {
|
|
158
|
+
cwd: worktreePath,
|
|
159
|
+
encoding: 'utf-8',
|
|
160
|
+
timeout: 10000,
|
|
161
|
+
}).trim();
|
|
162
|
+
if (currentBranch === 'main' || currentBranch === 'master' || !currentBranch) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const aheadOutput = execSync('git rev-list --count main..HEAD', {
|
|
166
|
+
cwd: worktreePath,
|
|
167
|
+
encoding: 'utf-8',
|
|
168
|
+
timeout: 10000,
|
|
169
|
+
}).trim();
|
|
170
|
+
return parseInt(aheadOutput, 10) > 0;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function isBranchPushed(worktreePath) {
|
|
177
|
+
try {
|
|
178
|
+
const currentBranch = execSync('git branch --show-current', {
|
|
179
|
+
cwd: worktreePath,
|
|
180
|
+
encoding: 'utf-8',
|
|
181
|
+
timeout: 10000,
|
|
182
|
+
}).trim();
|
|
183
|
+
if (!currentBranch || currentBranch === 'main' || currentBranch === 'master') {
|
|
184
|
+
return true; // main is always "pushed"
|
|
185
|
+
}
|
|
186
|
+
const remoteRef = execSync(`git ls-remote --heads origin ${currentBranch}`, {
|
|
187
|
+
cwd: worktreePath,
|
|
188
|
+
encoding: 'utf-8',
|
|
189
|
+
timeout: 15000,
|
|
190
|
+
}).trim();
|
|
191
|
+
if (remoteRef.length === 0) {
|
|
192
|
+
return false; // remote branch doesn't exist
|
|
193
|
+
}
|
|
194
|
+
// Check if local is ahead of remote (unpushed commits)
|
|
195
|
+
try {
|
|
196
|
+
execSync('git rev-parse --abbrev-ref @{u}', {
|
|
197
|
+
cwd: worktreePath,
|
|
198
|
+
encoding: 'utf-8',
|
|
199
|
+
timeout: 10000,
|
|
200
|
+
});
|
|
201
|
+
const unpushed = execSync('git rev-list --count @{u}..HEAD', {
|
|
202
|
+
cwd: worktreePath,
|
|
203
|
+
encoding: 'utf-8',
|
|
204
|
+
timeout: 10000,
|
|
205
|
+
}).trim();
|
|
206
|
+
return parseInt(unpushed, 10) === 0;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// No tracking branch but remote exists — close enough
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function isPrMerged(prUrl, worktreePath) {
|
|
218
|
+
try {
|
|
219
|
+
const prMatch = prUrl.match(/\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
|
220
|
+
if (!prMatch)
|
|
221
|
+
return false;
|
|
222
|
+
const [, owner, repo, prNum] = prMatch;
|
|
223
|
+
const json = execSync(`gh pr view ${prNum} --repo ${owner}/${repo} --json state --jq '.state'`, {
|
|
224
|
+
cwd: worktreePath ?? process.cwd(),
|
|
225
|
+
encoding: 'utf-8',
|
|
226
|
+
timeout: 15000,
|
|
227
|
+
}).trim();
|
|
228
|
+
return json === 'MERGED';
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Backstop actions
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
function backstopPushBranch(worktreePath) {
|
|
238
|
+
try {
|
|
239
|
+
const currentBranch = execSync('git branch --show-current', {
|
|
240
|
+
cwd: worktreePath,
|
|
241
|
+
encoding: 'utf-8',
|
|
242
|
+
timeout: 10000,
|
|
243
|
+
}).trim();
|
|
244
|
+
if (!currentBranch || currentBranch === 'main' || currentBranch === 'master') {
|
|
245
|
+
return {
|
|
246
|
+
field: 'branch_pushed',
|
|
247
|
+
action: 'skipped — on main/master branch',
|
|
248
|
+
success: false,
|
|
249
|
+
detail: 'Cannot push from main branch',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
execSync(`git push -u origin ${currentBranch}`, {
|
|
253
|
+
cwd: worktreePath,
|
|
254
|
+
encoding: 'utf-8',
|
|
255
|
+
timeout: 60000,
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
field: 'branch_pushed',
|
|
259
|
+
action: 'auto-pushed branch to remote',
|
|
260
|
+
success: true,
|
|
261
|
+
detail: currentBranch,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return {
|
|
266
|
+
field: 'branch_pushed',
|
|
267
|
+
action: 'failed to push branch',
|
|
268
|
+
success: false,
|
|
269
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function backstopCreatePR(agent, options) {
|
|
274
|
+
const worktreePath = agent.worktreePath;
|
|
275
|
+
if (!worktreePath) {
|
|
276
|
+
return {
|
|
277
|
+
field: 'pr_url',
|
|
278
|
+
action: 'skipped — no worktree path',
|
|
279
|
+
success: false,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const currentBranch = execSync('git branch --show-current', {
|
|
284
|
+
cwd: worktreePath,
|
|
285
|
+
encoding: 'utf-8',
|
|
286
|
+
timeout: 10000,
|
|
287
|
+
}).trim();
|
|
288
|
+
// Check if PR already exists for this branch (might have been missed during output parsing)
|
|
289
|
+
try {
|
|
290
|
+
const existingPr = execSync(`gh pr list --head "${currentBranch}" --json url --limit 1`, {
|
|
291
|
+
cwd: worktreePath,
|
|
292
|
+
encoding: 'utf-8',
|
|
293
|
+
timeout: 15000,
|
|
294
|
+
}).trim();
|
|
295
|
+
const prs = JSON.parse(existingPr);
|
|
296
|
+
if (prs.length > 0 && prs[0].url) {
|
|
297
|
+
return {
|
|
298
|
+
field: 'pr_url',
|
|
299
|
+
action: 'found existing PR (missed during session)',
|
|
300
|
+
success: true,
|
|
301
|
+
detail: prs[0].url,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// PR check failed — proceed with creation
|
|
307
|
+
}
|
|
308
|
+
// Build PR title and body
|
|
309
|
+
const identifier = agent.identifier;
|
|
310
|
+
const title = options?.prTitleTemplate
|
|
311
|
+
? options.prTitleTemplate.replace('{identifier}', identifier)
|
|
312
|
+
: `feat: ${identifier} (auto-recovered by backstop)`;
|
|
313
|
+
const body = options?.prBodyTemplate
|
|
314
|
+
? options.prBodyTemplate.replace('{identifier}', identifier)
|
|
315
|
+
: [
|
|
316
|
+
`## Summary`,
|
|
317
|
+
``,
|
|
318
|
+
`Auto-created by the session backstop for ${identifier}.`,
|
|
319
|
+
`The agent completed work but did not create a PR.`,
|
|
320
|
+
``,
|
|
321
|
+
`> This PR was created automatically by the orchestrator backstop to prevent work loss.`,
|
|
322
|
+
`> Please review carefully before merging.`,
|
|
323
|
+
].join('\n');
|
|
324
|
+
const prOutput = execSync(`gh pr create --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}"`, {
|
|
325
|
+
cwd: worktreePath,
|
|
326
|
+
encoding: 'utf-8',
|
|
327
|
+
timeout: 30000,
|
|
328
|
+
}).trim();
|
|
329
|
+
// gh pr create outputs the PR URL
|
|
330
|
+
const prUrlMatch = prOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/);
|
|
331
|
+
const prUrl = prUrlMatch ? prUrlMatch[0] : prOutput;
|
|
332
|
+
return {
|
|
333
|
+
field: 'pr_url',
|
|
334
|
+
action: 'auto-created PR via backstop',
|
|
335
|
+
success: true,
|
|
336
|
+
detail: prUrl,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
return {
|
|
341
|
+
field: 'pr_url',
|
|
342
|
+
action: 'failed to create PR',
|
|
343
|
+
success: false,
|
|
344
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Diagnostic formatting
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
/**
|
|
352
|
+
* Format a backstop result into a diagnostic comment for the issue tracker.
|
|
353
|
+
*/
|
|
354
|
+
export function formatBackstopComment(result) {
|
|
355
|
+
// Nothing to report if contract was satisfied or no contract exists
|
|
356
|
+
if (!result.contract || result.backstop.fullyRecovered) {
|
|
357
|
+
// If backstop took actions to recover, report those
|
|
358
|
+
if (result.backstop.actions.length > 0) {
|
|
359
|
+
const lines = [
|
|
360
|
+
`**Session backstop recovered missing outputs for ${result.contract?.workType ?? 'unknown'} work.**`,
|
|
361
|
+
'',
|
|
362
|
+
'Actions taken:',
|
|
363
|
+
...result.backstop.actions.map(a => `- ${a.field}: ${a.action}${a.success ? ' ✓' : ' ✗'}${a.detail ? ` (${a.detail})` : ''}`),
|
|
364
|
+
];
|
|
365
|
+
return lines.join('\n');
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
// Contract not satisfied — build diagnostic
|
|
370
|
+
const lines = [
|
|
371
|
+
`⚠️ **Session completion check failed for ${result.contract.workType} work.**`,
|
|
372
|
+
'',
|
|
373
|
+
];
|
|
374
|
+
if (result.backstop.actions.length > 0) {
|
|
375
|
+
lines.push('**Backstop actions attempted:**');
|
|
376
|
+
for (const action of result.backstop.actions) {
|
|
377
|
+
lines.push(`- ${action.field}: ${action.action}${action.success ? ' ✓' : ' ✗'}`);
|
|
378
|
+
if (action.detail && !action.success) {
|
|
379
|
+
lines.push(` > ${action.detail}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
lines.push('');
|
|
383
|
+
}
|
|
384
|
+
if (result.backstop.remainingGaps.length > 0) {
|
|
385
|
+
lines.push('**Still missing (requires manual action or re-trigger):**');
|
|
386
|
+
for (const gap of result.backstop.remainingGaps) {
|
|
387
|
+
const field = result.contract.required.find(f => f.type === gap);
|
|
388
|
+
lines.push(`- ${field?.label ?? gap}`);
|
|
389
|
+
}
|
|
390
|
+
lines.push('');
|
|
391
|
+
lines.push('**Issue status was NOT updated automatically** to prevent incomplete work from advancing.');
|
|
392
|
+
}
|
|
393
|
+
return lines.join('\n');
|
|
394
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-backstop.test.d.ts","sourceRoot":"","sources":["../../../src/orchestrator/session-backstop.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { collectSessionOutputs, runBackstop, formatBackstopComment } from './session-backstop.js';
|
|
3
|
+
// Mock execSync to avoid actual git/gh commands
|
|
4
|
+
vi.mock('node:child_process', () => ({
|
|
5
|
+
execSync: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
const mockExecSync = vi.mocked(execSync);
|
|
9
|
+
function createMockAgent(overrides) {
|
|
10
|
+
return {
|
|
11
|
+
issueId: 'issue-123',
|
|
12
|
+
identifier: 'SUP-123',
|
|
13
|
+
status: 'completed',
|
|
14
|
+
startedAt: new Date(),
|
|
15
|
+
completedAt: new Date(),
|
|
16
|
+
pid: 1234,
|
|
17
|
+
lastActivityAt: new Date(),
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function createSessionContext(overrides) {
|
|
22
|
+
return {
|
|
23
|
+
agent: createMockAgent(),
|
|
24
|
+
commentPosted: false,
|
|
25
|
+
issueUpdated: false,
|
|
26
|
+
subIssuesCreated: false,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
mockExecSync.mockReset();
|
|
32
|
+
});
|
|
33
|
+
describe('collectSessionOutputs', () => {
|
|
34
|
+
it('collects basic outputs from agent process', () => {
|
|
35
|
+
const ctx = createSessionContext({
|
|
36
|
+
agent: createMockAgent({
|
|
37
|
+
pullRequestUrl: 'https://github.com/org/repo/pull/42',
|
|
38
|
+
workResult: 'passed',
|
|
39
|
+
}),
|
|
40
|
+
commentPosted: true,
|
|
41
|
+
issueUpdated: true,
|
|
42
|
+
});
|
|
43
|
+
const outputs = collectSessionOutputs(ctx);
|
|
44
|
+
expect(outputs.prUrl).toBe('https://github.com/org/repo/pull/42');
|
|
45
|
+
expect(outputs.workResult).toBe('passed');
|
|
46
|
+
expect(outputs.commentPosted).toBe(true);
|
|
47
|
+
expect(outputs.issueUpdated).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it('inspects git state for worktree-based agents', () => {
|
|
50
|
+
// Mock git commands for commit and branch checks
|
|
51
|
+
mockExecSync
|
|
52
|
+
.mockReturnValueOnce('feat/sup-123\n') // git branch --show-current
|
|
53
|
+
.mockReturnValueOnce('3\n') // git rev-list --count main..HEAD
|
|
54
|
+
.mockReturnValueOnce('feat/sup-123\n') // git branch --show-current (isBranchPushed)
|
|
55
|
+
.mockReturnValueOnce('abc123 refs/heads/feat/sup-123\n') // git ls-remote
|
|
56
|
+
.mockReturnValueOnce('origin/feat/sup-123\n') // git rev-parse @{u}
|
|
57
|
+
.mockReturnValueOnce('0\n'); // git rev-list --count @{u}..HEAD
|
|
58
|
+
const ctx = createSessionContext({
|
|
59
|
+
agent: createMockAgent({ worktreePath: '/tmp/worktree' }),
|
|
60
|
+
});
|
|
61
|
+
const outputs = collectSessionOutputs(ctx);
|
|
62
|
+
expect(outputs.commitsPresent).toBe(true);
|
|
63
|
+
expect(outputs.branchPushed).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('defaults to unknown work result when not set', () => {
|
|
66
|
+
const ctx = createSessionContext();
|
|
67
|
+
const outputs = collectSessionOutputs(ctx);
|
|
68
|
+
expect(outputs.workResult).toBe('unknown');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('runBackstop', () => {
|
|
72
|
+
it('returns no-op for work types without contracts', () => {
|
|
73
|
+
// Use a work type cast that has no contract
|
|
74
|
+
const ctx = createSessionContext({
|
|
75
|
+
agent: createMockAgent({ workType: 'unknown-type' }),
|
|
76
|
+
});
|
|
77
|
+
const result = runBackstop(ctx);
|
|
78
|
+
expect(result.contract).toBeNull();
|
|
79
|
+
expect(result.backstop.fullyRecovered).toBe(true);
|
|
80
|
+
expect(result.backstop.actions).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
it('returns satisfied for development with all outputs present', () => {
|
|
83
|
+
// Mock git: commits ahead of main, branch pushed
|
|
84
|
+
mockExecSync
|
|
85
|
+
.mockReturnValueOnce('feat/sup-123\n') // branch
|
|
86
|
+
.mockReturnValueOnce('2\n') // rev-list count
|
|
87
|
+
.mockReturnValueOnce('feat/sup-123\n') // branch (isBranchPushed)
|
|
88
|
+
.mockReturnValueOnce('abc refs/heads/feat/sup-123\n') // ls-remote
|
|
89
|
+
.mockReturnValueOnce('origin/feat/sup-123\n') // @{u}
|
|
90
|
+
.mockReturnValueOnce('0\n'); // unpushed count
|
|
91
|
+
const ctx = createSessionContext({
|
|
92
|
+
agent: createMockAgent({
|
|
93
|
+
workType: 'development',
|
|
94
|
+
worktreePath: '/tmp/worktree',
|
|
95
|
+
pullRequestUrl: 'https://github.com/org/repo/pull/42',
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
const result = runBackstop(ctx);
|
|
99
|
+
expect(result.validation.satisfied).toBe(true);
|
|
100
|
+
expect(result.backstop.actions).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
it('auto-pushes branch when commits exist but branch not pushed', () => {
|
|
103
|
+
// collectSessionOutputs calls:
|
|
104
|
+
// hasCommitsAheadOfMain: git branch, git rev-list
|
|
105
|
+
// isBranchPushed: git branch, git ls-remote (empty = not pushed)
|
|
106
|
+
// backstopPushBranch calls:
|
|
107
|
+
// git branch, git push
|
|
108
|
+
// backstopCreatePR calls:
|
|
109
|
+
// git branch, gh pr list, gh pr create
|
|
110
|
+
// (re-validation uses mutated outputs object, no git calls)
|
|
111
|
+
mockExecSync
|
|
112
|
+
// --- collectSessionOutputs ---
|
|
113
|
+
.mockReturnValueOnce('feat/sup-123\n') // hasCommits: git branch
|
|
114
|
+
.mockReturnValueOnce('2\n') // hasCommits: git rev-list
|
|
115
|
+
.mockReturnValueOnce('feat/sup-123\n') // isBranchPushed: git branch
|
|
116
|
+
.mockReturnValueOnce('') // isBranchPushed: ls-remote (empty = not pushed)
|
|
117
|
+
// --- backstopPushBranch ---
|
|
118
|
+
.mockReturnValueOnce('feat/sup-123\n') // git branch
|
|
119
|
+
.mockReturnValueOnce('') // git push -u origin
|
|
120
|
+
// --- backstopCreatePR ---
|
|
121
|
+
.mockReturnValueOnce('feat/sup-123\n') // git branch
|
|
122
|
+
.mockReturnValueOnce('[]') // gh pr list (empty)
|
|
123
|
+
.mockReturnValueOnce('https://github.com/org/repo/pull/99\n'); // gh pr create
|
|
124
|
+
const ctx = createSessionContext({
|
|
125
|
+
agent: createMockAgent({
|
|
126
|
+
workType: 'development',
|
|
127
|
+
worktreePath: '/tmp/worktree',
|
|
128
|
+
// No PR URL — agent didn't create one
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
const result = runBackstop(ctx);
|
|
132
|
+
const pushAction = result.backstop.actions.find(a => a.field === 'branch_pushed');
|
|
133
|
+
expect(pushAction).toBeDefined();
|
|
134
|
+
expect(pushAction.success).toBe(true);
|
|
135
|
+
expect(pushAction.action).toContain('auto-pushed');
|
|
136
|
+
});
|
|
137
|
+
it('skips backstop actions in dry-run mode', () => {
|
|
138
|
+
// Mock git: commits present, branch not pushed
|
|
139
|
+
mockExecSync
|
|
140
|
+
.mockReturnValueOnce('feat/sup-123\n') // branch
|
|
141
|
+
.mockReturnValueOnce('2\n') // count
|
|
142
|
+
.mockReturnValueOnce('feat/sup-123\n') // branch (isBranchPushed)
|
|
143
|
+
.mockReturnValueOnce('\n'); // ls-remote empty
|
|
144
|
+
const ctx = createSessionContext({
|
|
145
|
+
agent: createMockAgent({
|
|
146
|
+
workType: 'development',
|
|
147
|
+
worktreePath: '/tmp/worktree',
|
|
148
|
+
pullRequestUrl: 'https://github.com/org/repo/pull/42',
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
const result = runBackstop(ctx, { dryRun: true });
|
|
152
|
+
const pushAction = result.backstop.actions.find(a => a.field === 'branch_pushed');
|
|
153
|
+
expect(pushAction).toBeDefined();
|
|
154
|
+
expect(pushAction.success).toBe(false);
|
|
155
|
+
expect(pushAction.detail).toBe('dry-run');
|
|
156
|
+
});
|
|
157
|
+
it('reports satisfied for QA with pass result and comment', () => {
|
|
158
|
+
const ctx = createSessionContext({
|
|
159
|
+
agent: createMockAgent({ workType: 'qa', workResult: 'passed' }),
|
|
160
|
+
commentPosted: true,
|
|
161
|
+
});
|
|
162
|
+
const result = runBackstop(ctx);
|
|
163
|
+
expect(result.validation.satisfied).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
it('reports unsatisfied for QA with unknown work result', () => {
|
|
166
|
+
const ctx = createSessionContext({
|
|
167
|
+
agent: createMockAgent({ workType: 'qa' }),
|
|
168
|
+
commentPosted: true,
|
|
169
|
+
});
|
|
170
|
+
const result = runBackstop(ctx);
|
|
171
|
+
expect(result.validation.satisfied).toBe(false);
|
|
172
|
+
expect(result.backstop.remainingGaps).toContain('work_result');
|
|
173
|
+
});
|
|
174
|
+
it('reports satisfied for refinement with comment posted', () => {
|
|
175
|
+
const ctx = createSessionContext({
|
|
176
|
+
agent: createMockAgent({ workType: 'refinement' }),
|
|
177
|
+
commentPosted: true,
|
|
178
|
+
});
|
|
179
|
+
const result = runBackstop(ctx);
|
|
180
|
+
expect(result.validation.satisfied).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
it('reports unsatisfied for refinement without comment', () => {
|
|
183
|
+
const ctx = createSessionContext({
|
|
184
|
+
agent: createMockAgent({ workType: 'refinement' }),
|
|
185
|
+
commentPosted: false,
|
|
186
|
+
});
|
|
187
|
+
const result = runBackstop(ctx);
|
|
188
|
+
expect(result.validation.satisfied).toBe(false);
|
|
189
|
+
expect(result.backstop.remainingGaps).toContain('comment_posted');
|
|
190
|
+
});
|
|
191
|
+
it('reports satisfied for backlog-creation with sub-issues', () => {
|
|
192
|
+
const ctx = createSessionContext({
|
|
193
|
+
agent: createMockAgent({ workType: 'backlog-creation' }),
|
|
194
|
+
subIssuesCreated: true,
|
|
195
|
+
});
|
|
196
|
+
const result = runBackstop(ctx);
|
|
197
|
+
expect(result.validation.satisfied).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe('formatBackstopComment', () => {
|
|
201
|
+
it('returns null when contract satisfied with no actions', () => {
|
|
202
|
+
const ctx = createSessionContext({
|
|
203
|
+
agent: createMockAgent({ workType: 'qa', workResult: 'passed' }),
|
|
204
|
+
commentPosted: true,
|
|
205
|
+
});
|
|
206
|
+
const result = runBackstop(ctx);
|
|
207
|
+
const comment = formatBackstopComment(result);
|
|
208
|
+
expect(comment).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
it('returns recovery summary when backstop took actions', () => {
|
|
211
|
+
// Mock: commits present, branch not pushed, push succeeds
|
|
212
|
+
mockExecSync
|
|
213
|
+
.mockReturnValueOnce('feat/sup-123\n')
|
|
214
|
+
.mockReturnValueOnce('2\n')
|
|
215
|
+
.mockReturnValueOnce('feat/sup-123\n')
|
|
216
|
+
.mockReturnValueOnce('\n') // not pushed
|
|
217
|
+
.mockReturnValueOnce('feat/sup-123\n') // backstop push branch
|
|
218
|
+
.mockReturnValueOnce('') // push succeeds
|
|
219
|
+
// Now PR creation attempt
|
|
220
|
+
.mockReturnValueOnce('feat/sup-123\n') // branch for PR
|
|
221
|
+
.mockReturnValueOnce('[]\n') // no existing PR
|
|
222
|
+
.mockReturnValueOnce('https://github.com/org/repo/pull/99\n'); // gh pr create
|
|
223
|
+
const ctx = createSessionContext({
|
|
224
|
+
agent: createMockAgent({
|
|
225
|
+
workType: 'development',
|
|
226
|
+
worktreePath: '/tmp/worktree',
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
const result = runBackstop(ctx);
|
|
230
|
+
const comment = formatBackstopComment(result);
|
|
231
|
+
expect(comment).toBeDefined();
|
|
232
|
+
expect(comment).toContain('backstop');
|
|
233
|
+
});
|
|
234
|
+
it('includes remaining gaps when not fully recovered', () => {
|
|
235
|
+
const ctx = createSessionContext({
|
|
236
|
+
agent: createMockAgent({ workType: 'qa' }),
|
|
237
|
+
commentPosted: false,
|
|
238
|
+
});
|
|
239
|
+
const result = runBackstop(ctx);
|
|
240
|
+
const comment = formatBackstopComment(result);
|
|
241
|
+
expect(comment).toBeDefined();
|
|
242
|
+
expect(comment).toContain('Still missing');
|
|
243
|
+
expect(comment).toContain('NOT updated automatically');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worktree-checks.test.d.ts","sourceRoot":"","sources":["../../../src/orchestrator/worktree-checks.test.ts"],"names":[],"mappings":""}
|