@bridge4dev/runner 0.13.1 → 0.26.0
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/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auth-relay.d.ts +33 -3
- package/dist/auth-relay.js +199 -16
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/environment.d.ts +171 -0
- package/dist/environment.js +409 -0
- package/dist/git.d.ts +81 -0
- package/dist/git.js +301 -15
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +715 -8
- package/dist/paths.d.ts +35 -0
- package/dist/paths.js +45 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +21 -0
- package/dist/self-update.js +73 -1
- package/dist/service-unit.d.ts +61 -2
- package/dist/service-unit.js +150 -14
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1045 -57
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { log } from './log.js';
|
|
2
|
-
import {
|
|
4
|
+
import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
|
|
5
|
+
import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
|
|
3
6
|
import { JournalStore } from './journal.js';
|
|
4
|
-
import { deleteSessionBranch, ensureSessionWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
|
|
5
|
-
import {
|
|
7
|
+
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
|
|
8
|
+
import { readRecipeProposal } from './recipe.js';
|
|
9
|
+
import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
|
|
10
|
+
import { proposeCommitMessage } from './commit-message.js';
|
|
11
|
+
import { VerifyRunner, runOneOffCommand, } from './verify.js';
|
|
12
|
+
import { VerifyReportQueue } from './verify-queue.js';
|
|
13
|
+
import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs, gitShow, gitStatus, revertApply, gitStage, gitUnstage, gitDiscard, gitPull, gitMergeAbort, updateFromBase, workspaceState, } from './gitops.js';
|
|
6
14
|
import { fsView } from './fsview.js';
|
|
7
|
-
import { agentAuthStatuses, AuthRelay } from './auth-relay.js';
|
|
15
|
+
import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
|
|
8
16
|
import { selfUpdate } from './self-update.js';
|
|
17
|
+
import { rememberWorkspacePath } from './environment.js';
|
|
9
18
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
10
19
|
export class Supervisor {
|
|
11
20
|
ws;
|
|
@@ -30,14 +39,46 @@ export class Supervisor {
|
|
|
30
39
|
repoLocks = new Map();
|
|
31
40
|
/** An update is installing right now — a second one would fight it. */
|
|
32
41
|
selfUpdateInFlight = false;
|
|
42
|
+
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
43
|
+
verify;
|
|
44
|
+
verifyReports = new VerifyReportQueue();
|
|
33
45
|
constructor(ws, opts) {
|
|
34
46
|
this.ws = ws;
|
|
35
47
|
this.opts = opts;
|
|
36
48
|
this.journals = opts.journals ?? new JournalStore();
|
|
49
|
+
this.verify = new VerifyRunner({
|
|
50
|
+
enabled: opts.verifyEnabled !== false,
|
|
51
|
+
onReport: (report) => {
|
|
52
|
+
// Queue FIRST, then try the wire. The other order loses the verdict of
|
|
53
|
+
// every build that finishes while the socket happens to be down — and
|
|
54
|
+
// a build finishing during a reconnect is not a rare case, it is a
|
|
55
|
+
// twenty-minute window.
|
|
56
|
+
this.verifyReports.add(report);
|
|
57
|
+
this.flushVerifyReports();
|
|
58
|
+
},
|
|
59
|
+
});
|
|
37
60
|
ws.on('frame', (frame) => {
|
|
38
61
|
void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
|
|
39
62
|
});
|
|
40
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Push every unacked verdict at the API.
|
|
66
|
+
*
|
|
67
|
+
* Called on each reconnect and whenever a run finishes. Sending a report the
|
|
68
|
+
* API already has is harmless — it is keyed by `runId` and stored idempotently
|
|
69
|
+
* — while not sending one is a verdict that never existed.
|
|
70
|
+
*/
|
|
71
|
+
flushVerifyReports() {
|
|
72
|
+
for (const report of this.verifyReports.pending()) {
|
|
73
|
+
const sent = this.ws.send({
|
|
74
|
+
type: 'verify_report',
|
|
75
|
+
report: report,
|
|
76
|
+
});
|
|
77
|
+
if (!sent)
|
|
78
|
+
break;
|
|
79
|
+
this.verifyReports.markAttempted(report.runId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
41
82
|
get activeSessionIds() {
|
|
42
83
|
return [...this.sessions.keys()];
|
|
43
84
|
}
|
|
@@ -46,10 +87,42 @@ export class Supervisor {
|
|
|
46
87
|
case 'hello_ack':
|
|
47
88
|
this.setMaxSessions(frame.maxSessions);
|
|
48
89
|
await this.reconcile(frame.sessions);
|
|
90
|
+
// A build that finished while the socket was down has its verdict
|
|
91
|
+
// sitting on disk. This is the moment it can be delivered.
|
|
92
|
+
this.flushVerifyReports();
|
|
93
|
+
break;
|
|
94
|
+
case 'verify_report_ack':
|
|
95
|
+
this.verifyReports.ack(frame.runId);
|
|
49
96
|
break;
|
|
50
97
|
case 'server_settings':
|
|
51
98
|
this.setMaxSessions(frame.maxSessions);
|
|
52
99
|
break;
|
|
100
|
+
/**
|
|
101
|
+
* Session 15: the project's trust level or auto-commit switch changed
|
|
102
|
+
* while sessions are running. Applied live — both are read on every tool
|
|
103
|
+
* call — because the direction that matters is the TIGHTENING one: a
|
|
104
|
+
* manager switching a project to STRICT and being told it is STRICT while
|
|
105
|
+
* an agent keeps running under AUTO is the worst version of this control.
|
|
106
|
+
*/
|
|
107
|
+
case 'workspace_settings': {
|
|
108
|
+
for (const running of this.sessions.values()) {
|
|
109
|
+
if (running.descriptor.workspace.id !== frame.workspaceId)
|
|
110
|
+
continue;
|
|
111
|
+
if (frame.trustMode !== undefined) {
|
|
112
|
+
running.descriptor.workspace.trustMode = frame.trustMode;
|
|
113
|
+
}
|
|
114
|
+
if (frame.agentAutoCommit !== undefined) {
|
|
115
|
+
running.descriptor.workspace.agentAutoCommit = frame.agentAutoCommit;
|
|
116
|
+
}
|
|
117
|
+
running.session?.setWorkspacePolicy({
|
|
118
|
+
...(frame.trustMode !== undefined ? { trustMode: frame.trustMode } : {}),
|
|
119
|
+
...(frame.agentAutoCommit !== undefined
|
|
120
|
+
? { agentAutoCommit: frame.agentAutoCommit }
|
|
121
|
+
: {}),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
53
126
|
case 'session_start':
|
|
54
127
|
await this.startSession(frame.session);
|
|
55
128
|
break;
|
|
@@ -61,6 +134,9 @@ export class Supervisor {
|
|
|
61
134
|
running?.session?.answerPermission(frame.requestId, frame.allow, frame.note);
|
|
62
135
|
break;
|
|
63
136
|
}
|
|
137
|
+
case 'question_answer':
|
|
138
|
+
this.onQuestionAnswer(frame);
|
|
139
|
+
break;
|
|
64
140
|
case 'session_stop':
|
|
65
141
|
this.stopSession(frame.sessionId);
|
|
66
142
|
break;
|
|
@@ -108,7 +184,7 @@ export class Supervisor {
|
|
|
108
184
|
if (descriptor.epoch > existing.epoch) {
|
|
109
185
|
existing.pendingRestart = descriptor;
|
|
110
186
|
existing.stopRequested = true;
|
|
111
|
-
existing.session?.stop();
|
|
187
|
+
existing.session?.stop('session_stopped');
|
|
112
188
|
}
|
|
113
189
|
return;
|
|
114
190
|
}
|
|
@@ -118,9 +194,13 @@ export class Supervisor {
|
|
|
118
194
|
if (!this.ensureCapacity(descriptor.id)) {
|
|
119
195
|
const limit = this.maxSessions;
|
|
120
196
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
121
|
-
errorMessage: limit === 1
|
|
197
|
+
errorMessage: (limit === 1
|
|
122
198
|
? 'Runner already has an active session'
|
|
123
|
-
: `Runner is already running ${limit} active sessions — stop one first
|
|
199
|
+
: `Runner is already running ${limit} active sessions — stop one first`) +
|
|
200
|
+
// Since session 12 a session with an open question holds its slot on
|
|
201
|
+
// purpose — so "finish the turn" is not something waiting will fix,
|
|
202
|
+
// and the message has to say what actually frees it.
|
|
203
|
+
this.waitingForAnswerSuffix(descriptor.id),
|
|
124
204
|
});
|
|
125
205
|
return;
|
|
126
206
|
}
|
|
@@ -143,6 +223,7 @@ export class Supervisor {
|
|
|
143
223
|
activeMs: descriptor.activeMsBase,
|
|
144
224
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
145
225
|
epoch: descriptor.epoch,
|
|
226
|
+
openQuestions: new Set(),
|
|
146
227
|
mode: descriptor.mode,
|
|
147
228
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
148
229
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -166,13 +247,15 @@ export class Supervisor {
|
|
|
166
247
|
}
|
|
167
248
|
}
|
|
168
249
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
250
|
+
const prepared = await this.prepareWorkspace(descriptor);
|
|
251
|
+
running.branch = prepared.branch;
|
|
252
|
+
running.worktreePath = prepared.worktreePath;
|
|
253
|
+
// Only when WE created the branch: the fork point is a fact the API can
|
|
254
|
+
// learn nowhere else, and it pins the first answer it gets.
|
|
255
|
+
if (prepared.baseSha)
|
|
256
|
+
running.baseSha = prepared.baseSha;
|
|
257
|
+
if (prepared.baseBranch)
|
|
258
|
+
running.baseBranch = prepared.baseBranch;
|
|
176
259
|
}
|
|
177
260
|
catch (error) {
|
|
178
261
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
@@ -204,6 +287,29 @@ export class Supervisor {
|
|
|
204
287
|
}
|
|
205
288
|
this.flushPendingMessages(running);
|
|
206
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Where this session is going to work — a worktree of its own, or the project
|
|
292
|
+
* folder itself (session 16).
|
|
293
|
+
*
|
|
294
|
+
* The BRANCH path takes the repo lock because `worktree add` writes into the
|
|
295
|
+
* shared `.git` (registration + prune), exactly like commit/apply/revert. The
|
|
296
|
+
* DIRECT path takes none: it only READS which branch a folder is on, and
|
|
297
|
+
* queueing every session start behind whatever merge happens to be running
|
|
298
|
+
* would be a lock bought for nothing.
|
|
299
|
+
*/
|
|
300
|
+
async prepareWorkspace(descriptor) {
|
|
301
|
+
// Also here, not only at bind time: a server paired before this existed has
|
|
302
|
+
// never sent a `validate_path`, and its projects would be invisible to
|
|
303
|
+
// `doctor` until somebody re-bound them.
|
|
304
|
+
rememberWorkspacePath(descriptor.workspace.path);
|
|
305
|
+
if (descriptor.workMode === 'DIRECT') {
|
|
306
|
+
return prepareDirectWorkspace(descriptor.workspace.path);
|
|
307
|
+
}
|
|
308
|
+
return this.withRepoLockFor(descriptor.workspace.path, () => ensureSessionWorktree(descriptor.workspace.path, descriptor.id, descriptor.branchHint, {
|
|
309
|
+
requireExistingBranch: hasWorkToResume(descriptor),
|
|
310
|
+
...(descriptor.branchPlan ? { plan: descriptor.branchPlan } : {}),
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
207
313
|
/**
|
|
208
314
|
* Spin the adapter up — for a fresh session, a resume-on-next-message, or a
|
|
209
315
|
* free CHAT session with no prompt at all (the agent boots, reports its
|
|
@@ -246,16 +352,23 @@ export class Supervisor {
|
|
|
246
352
|
if (running.budgetSpent) {
|
|
247
353
|
this.sendEvent(running, 'notice', {
|
|
248
354
|
level: 'warn',
|
|
249
|
-
text: 'The time budget is used up — press
|
|
355
|
+
text: 'The time budget is used up — press «Continue» to give the agent more time.',
|
|
250
356
|
});
|
|
251
357
|
return false;
|
|
252
358
|
}
|
|
253
359
|
running.lastPrompt = prompt;
|
|
360
|
+
// Facts about this session only. The project's own documentation is read by
|
|
361
|
+
// each agent itself — see `composeWorkspaceContext`.
|
|
362
|
+
const workspaceContext = composeWorkspaceContext(descriptor);
|
|
254
363
|
running.session = adapter.startSession({
|
|
255
364
|
sessionId: descriptor.id,
|
|
256
365
|
cwd: running.worktreePath,
|
|
257
366
|
...(prompt ? { prompt } : {}),
|
|
367
|
+
...(workspaceContext ? { workspaceContext } : {}),
|
|
258
368
|
trustMode: descriptor.workspace.trustMode,
|
|
369
|
+
...(descriptor.workspace.agentAutoCommit === undefined
|
|
370
|
+
? {}
|
|
371
|
+
: { agentAutoCommit: descriptor.workspace.agentAutoCommit }),
|
|
259
372
|
mode: running.mode,
|
|
260
373
|
...(running.model ? { model: running.model } : {}),
|
|
261
374
|
...(running.effort ? { effort: running.effort } : {}),
|
|
@@ -395,16 +508,17 @@ export class Supervisor {
|
|
|
395
508
|
void Promise.resolve(running.session?.interrupt()).catch(() => undefined);
|
|
396
509
|
this.sendEvent(running, 'notice', {
|
|
397
510
|
level: 'warn',
|
|
398
|
-
text: `The agent worked for its full ${minutes} minutes and was paused. Press
|
|
511
|
+
text: `The agent worked for its full ${minutes} minutes and was paused. Press «Continue» to give it more time.`,
|
|
399
512
|
});
|
|
400
513
|
running.stopRequested = true;
|
|
514
|
+
this.withdrawOpenQuestions(running, 'budget_spent');
|
|
401
515
|
this.reportStatus(descriptor.id, 'STOPPED', {
|
|
402
516
|
costUsd: running.costUsd,
|
|
403
517
|
endReason: 'TIME_BUDGET',
|
|
404
518
|
activeMs: running.activeMs,
|
|
405
519
|
errorMessage: `Time budget (${minutes} min of agent work) reached`,
|
|
406
520
|
});
|
|
407
|
-
running.session?.stop();
|
|
521
|
+
running.session?.stop('budget_spent');
|
|
408
522
|
}
|
|
409
523
|
async pumpEvents(running) {
|
|
410
524
|
const session = running.session;
|
|
@@ -425,6 +539,21 @@ export class Supervisor {
|
|
|
425
539
|
// Stream ended — the agent process is gone.
|
|
426
540
|
if (running.session !== session)
|
|
427
541
|
return; // superseded (shouldn't happen in v0)
|
|
542
|
+
// Backstop for a process that died without going through `stop()` (a crash,
|
|
543
|
+
// an SDK error, an agent that exited mid-question): the adapter never got
|
|
544
|
+
// to withdraw its cards, and a card nobody can answer must not stay live.
|
|
545
|
+
// Guarded like the event loop above — a journal that cannot be written (a
|
|
546
|
+
// full disk, a daemon already shutting down) must not take the exit path
|
|
547
|
+
// with it, because everything below it is cleanup.
|
|
548
|
+
try {
|
|
549
|
+
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
log.warn('supervisor: could not withdraw open questions', {
|
|
553
|
+
sessionId: descriptor.id,
|
|
554
|
+
error: String(error),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
428
557
|
// The process is down, so nothing is billable any more regardless of the
|
|
429
558
|
// last status we reported.
|
|
430
559
|
if (running.activeSince !== undefined) {
|
|
@@ -515,6 +644,46 @@ export class Supervisor {
|
|
|
515
644
|
// A slot just came free — hand it to whoever was told to wait for one.
|
|
516
645
|
this.drainSessionsWaitingForCapacity();
|
|
517
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* "…and N of them are waiting for an answer from you."
|
|
649
|
+
*
|
|
650
|
+
* An open question pins its slot deliberately (`isParkable`), so a message
|
|
651
|
+
* that blames a running turn sends the user to wait for something that will
|
|
652
|
+
* never happen. Empty when nothing is waiting.
|
|
653
|
+
*/
|
|
654
|
+
waitingForAnswerSuffix(exceptSessionId) {
|
|
655
|
+
let waiting = 0;
|
|
656
|
+
for (const running of this.sessions.values()) {
|
|
657
|
+
if (running.descriptor.id === exceptSessionId)
|
|
658
|
+
continue;
|
|
659
|
+
if (running.openQuestions.size > 0)
|
|
660
|
+
waiting++;
|
|
661
|
+
}
|
|
662
|
+
if (waiting === 0)
|
|
663
|
+
return '';
|
|
664
|
+
return waiting === 1
|
|
665
|
+
? ' — one of them is waiting for your answer to a question'
|
|
666
|
+
: ` — ${waiting} of them are waiting for your answer to a question`;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Close out every ask this session still has open, with a stated cause.
|
|
670
|
+
*
|
|
671
|
+
* Idempotent: the adapter reports its own `question_resolved` when it can, and
|
|
672
|
+
* `forwardEvent` clears the id — so by the time this runs the set is usually
|
|
673
|
+
* already empty. What it catches is the path where the adapter never got the
|
|
674
|
+
* chance, and the alternative there is a card that stays clickable forever.
|
|
675
|
+
*/
|
|
676
|
+
withdrawOpenQuestions(running, reason) {
|
|
677
|
+
for (const askId of [...running.openQuestions]) {
|
|
678
|
+
running.openQuestions.delete(askId);
|
|
679
|
+
this.sendEvent(running, 'question_resolved', {
|
|
680
|
+
askId,
|
|
681
|
+
outcome: 'invalidated',
|
|
682
|
+
source: 'runner',
|
|
683
|
+
reason,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
518
687
|
/**
|
|
519
688
|
* Deliver messages that were held because every slot was taken.
|
|
520
689
|
*
|
|
@@ -612,19 +781,33 @@ export class Supervisor {
|
|
|
612
781
|
}
|
|
613
782
|
return this.liveSessionCount(exceptSessionId) < limit;
|
|
614
783
|
}
|
|
615
|
-
/**
|
|
784
|
+
/**
|
|
785
|
+
* Idle after a finished turn — safe to kill the process and resume later.
|
|
786
|
+
*
|
|
787
|
+
* An open question is the exception (session 12): WAITING_INPUT there does
|
|
788
|
+
* NOT mean "the turn is over", it means the agent's tool call is parked on a
|
|
789
|
+
* human. Parking such a session killed a live turn — and, worse, killed the
|
|
790
|
+
* card the user was about to answer — the moment another session wanted a
|
|
791
|
+
* slot.
|
|
792
|
+
*/
|
|
616
793
|
isParkable(running) {
|
|
617
794
|
return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
|
|
795
|
+
running.openQuestions.size === 0 &&
|
|
618
796
|
Boolean(running.descriptor.providerSessionId));
|
|
619
797
|
}
|
|
620
798
|
park(running) {
|
|
621
799
|
if (!running.session)
|
|
622
800
|
return;
|
|
623
801
|
running.parkRequested = true;
|
|
802
|
+
// `code` is what the dashboard reads to tell «parked» from «your turn»
|
|
803
|
+
// (#124). The prose stays for runners older than 0.23.0, which the
|
|
804
|
+
// dashboard still matches on; delete that fallback once the fleet has
|
|
805
|
+
// moved, not before.
|
|
624
806
|
this.sendEvent(running, 'system_note', {
|
|
807
|
+
code: 'session_parked',
|
|
625
808
|
text: 'Session parked — the runner switched to another session. Send a message to resume.',
|
|
626
809
|
});
|
|
627
|
-
running.session.stop();
|
|
810
|
+
running.session.stop('session_parked');
|
|
628
811
|
}
|
|
629
812
|
forwardEvent(running, event) {
|
|
630
813
|
const { descriptor } = running;
|
|
@@ -694,6 +877,15 @@ export class Supervisor {
|
|
|
694
877
|
});
|
|
695
878
|
return;
|
|
696
879
|
}
|
|
880
|
+
// The retry is spent and the agent is still refused — this is the only
|
|
881
|
+
// authority on a login the credentials file cannot see through (a
|
|
882
|
+
// provider-side revocation leaves the file looking perfectly healthy).
|
|
883
|
+
// The panel is told from here, not from a guess (#121).
|
|
884
|
+
if (isAuthCode(event.code)) {
|
|
885
|
+
const refused = relayAgent(String(descriptor.agent).toLowerCase());
|
|
886
|
+
if (refused)
|
|
887
|
+
noteAgentAuthFailure(refused);
|
|
888
|
+
}
|
|
697
889
|
// Forward the code: the API stores the payload as-is, so the dashboard
|
|
698
890
|
// can offer "Sign in" instead of a dead error card.
|
|
699
891
|
this.sendEvent(running, 'error', {
|
|
@@ -733,6 +925,13 @@ export class Supervisor {
|
|
|
733
925
|
}
|
|
734
926
|
return;
|
|
735
927
|
case 'message':
|
|
928
|
+
// The provider answered, so this sign-in works — drop any refusal we
|
|
929
|
+
// are still holding against it (#121).
|
|
930
|
+
if (event.role === 'assistant') {
|
|
931
|
+
const working = relayAgent(String(descriptor.agent).toLowerCase());
|
|
932
|
+
if (working)
|
|
933
|
+
clearAgentAuthFailure(working);
|
|
934
|
+
}
|
|
736
935
|
this.sendEvent(running, 'message', { role: event.role, text: event.text });
|
|
737
936
|
return;
|
|
738
937
|
case 'thinking':
|
|
@@ -740,20 +939,66 @@ export class Supervisor {
|
|
|
740
939
|
return;
|
|
741
940
|
case 'question':
|
|
742
941
|
// The API maps `question` to WAITING_INPUT and notifies the user. The
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
|
|
942
|
+
// clock stops with it: since session 12 the agent's tool call is PARKED
|
|
943
|
+
// on this question, so the agent is genuinely idle — billing the wait
|
|
944
|
+
// is the bug session 7 removed (five of the first twelve prod sessions
|
|
945
|
+
// died having spent their budget waiting for a human).
|
|
946
|
+
running.openQuestions.add(event.askId);
|
|
947
|
+
this.sendEvent(running, 'question', {
|
|
948
|
+
askId: event.askId,
|
|
949
|
+
questions: event.questions,
|
|
950
|
+
// Mirror fields — an API/dashboard mid-deploy still draws a card.
|
|
951
|
+
text: event.text,
|
|
952
|
+
options: event.options,
|
|
953
|
+
});
|
|
747
954
|
running.lastReported = 'WAITING_INPUT';
|
|
748
955
|
this.syncBudgetClock(running);
|
|
749
956
|
return;
|
|
750
|
-
case '
|
|
957
|
+
case 'question_resolved':
|
|
958
|
+
running.openQuestions.delete(event.askId);
|
|
959
|
+
this.sendEvent(running, 'question_resolved', {
|
|
960
|
+
askId: event.askId,
|
|
961
|
+
outcome: event.outcome,
|
|
962
|
+
source: event.source,
|
|
963
|
+
reason: event.reason,
|
|
964
|
+
answers: event.answers,
|
|
965
|
+
summary: event.summary,
|
|
966
|
+
});
|
|
967
|
+
// The human answered — the parked tool call is released and the agent
|
|
968
|
+
// is working again, so the clock starts. Same shape as the permission
|
|
969
|
+
// path above, and guarded the same way so a terminal status wins.
|
|
970
|
+
// `openQuestions.size` matters: both agents can park several asks at
|
|
971
|
+
// once, and reporting RUNNING while another card is still waiting would
|
|
972
|
+
// bill a human's thinking time all over again (QA-106 M4).
|
|
973
|
+
if (event.source === 'user' &&
|
|
974
|
+
running.openQuestions.size === 0 &&
|
|
975
|
+
running.lastReported === 'WAITING_INPUT') {
|
|
976
|
+
running.lastReported = 'RUNNING';
|
|
977
|
+
this.reportStatus(descriptor.id, 'RUNNING', {});
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
case 'capabilities': {
|
|
981
|
+
// The ADAPTER is the authority on which model is selected — it has the
|
|
982
|
+
// live catalogue and has already resolved the wire id the agent reports
|
|
983
|
+
// back to the row it belongs to (ticket #111). `running.model` is only
|
|
984
|
+
// what was asked for at launch, and it is routinely a name no row in
|
|
985
|
+
// that catalogue carries: sessions whose column was written from an
|
|
986
|
+
// older capabilities event hold a resolved id like `claude-opus-5[1m]`,
|
|
987
|
+
// which the dashboard can match to nothing — so it drew the raw id in
|
|
988
|
+
// the model picker and, because effort levels hang off the selected
|
|
989
|
+
// row, no effort control at all.
|
|
990
|
+
const currentModel = event.capabilities.currentModel ?? running.model;
|
|
991
|
+
// Adopt it, so the next descriptor and the session column carry a name
|
|
992
|
+
// the picker can find instead of re-poisoning them from the old value.
|
|
993
|
+
if (currentModel)
|
|
994
|
+
running.model = currentModel;
|
|
751
995
|
this.sendEvent(running, 'capabilities', {
|
|
752
996
|
...event.capabilities,
|
|
753
997
|
currentMode: running.mode,
|
|
754
|
-
...(
|
|
998
|
+
...(currentModel ? { currentModel } : {}),
|
|
755
999
|
});
|
|
756
1000
|
return;
|
|
1001
|
+
}
|
|
757
1002
|
case 'settings':
|
|
758
1003
|
if (event.mode)
|
|
759
1004
|
running.mode = event.mode;
|
|
@@ -777,6 +1022,16 @@ export class Supervisor {
|
|
|
777
1022
|
maxTokens: event.maxTokens,
|
|
778
1023
|
});
|
|
779
1024
|
return;
|
|
1025
|
+
case 'agent_tasks':
|
|
1026
|
+
// Ticket #113. A LEVEL signal: every frame carries the whole live set,
|
|
1027
|
+
// so the dashboard replaces rather than reconciles and a dropped frame
|
|
1028
|
+
// cannot leave a finished subagent spinning in the tray forever.
|
|
1029
|
+
this.sendEvent(running, 'agent_tasks', {
|
|
1030
|
+
tasks: event.tasks,
|
|
1031
|
+
done: event.done,
|
|
1032
|
+
total: event.total,
|
|
1033
|
+
});
|
|
1034
|
+
return;
|
|
780
1035
|
case 'notice': {
|
|
781
1036
|
// Only adapter notices are de-duplicated here. The supervisor's own
|
|
782
1037
|
// notices (turn interrupted, session parked, budget warnings) go
|
|
@@ -802,6 +1057,59 @@ export class Supervisor {
|
|
|
802
1057
|
return;
|
|
803
1058
|
}
|
|
804
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* The dashboard's answer to a parked question (session 12).
|
|
1062
|
+
*
|
|
1063
|
+
* A miss is reported in the feed rather than swallowed: the three cases that
|
|
1064
|
+
* get here — a card from a previous life of the session, a second click, an
|
|
1065
|
+
* ask the runner already withdrew — all look identical to the user unless
|
|
1066
|
+
* somebody says so.
|
|
1067
|
+
*/
|
|
1068
|
+
onQuestionAnswer(frame) {
|
|
1069
|
+
const running = this.sessions.get(frame.sessionId);
|
|
1070
|
+
if (!running) {
|
|
1071
|
+
log.warn('supervisor: question answer for unknown session', { sessionId: frame.sessionId });
|
|
1072
|
+
this.ws.send({ type: 'session_unknown', sessionId: frame.sessionId });
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const typed = frame.text?.trim();
|
|
1076
|
+
// Asked first, echoed second: the adapter resolves synchronously but its
|
|
1077
|
+
// own events reach the feed a microtask later, so the user's words still
|
|
1078
|
+
// land before the agent's reaction — and on a miss we can hand the text to
|
|
1079
|
+
// the ordinary delivery path, which does the echo itself.
|
|
1080
|
+
const accepted = running.session?.answerQuestion({
|
|
1081
|
+
askId: frame.askId,
|
|
1082
|
+
action: frame.action,
|
|
1083
|
+
...(frame.answers ? { answers: frame.answers } : {}),
|
|
1084
|
+
...(frame.text !== undefined ? { text: frame.text } : {}),
|
|
1085
|
+
});
|
|
1086
|
+
if (accepted) {
|
|
1087
|
+
// What the user typed belongs in the conversation, whichever exit they took.
|
|
1088
|
+
if (typed)
|
|
1089
|
+
this.sendEvent(running, 'message', { role: 'user', text: typed });
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
running.openQuestions.delete(frame.askId);
|
|
1093
|
+
this.sendEvent(running, 'system_note', {
|
|
1094
|
+
text: typed
|
|
1095
|
+
? 'That question is no longer open — sending your reply as an ordinary message instead.'
|
|
1096
|
+
: 'That question is no longer open — the agent has already moved on.',
|
|
1097
|
+
});
|
|
1098
|
+
// The words must not be eaten. This is the ordinary case after a runner
|
|
1099
|
+
// restart: the card in the browser outlived the process that asked, and
|
|
1100
|
+
// showing the user's own message in the feed while nothing receives it is
|
|
1101
|
+
// the exact failure `onUserMessage` was hardened against (QA-106 M2).
|
|
1102
|
+
if (typed) {
|
|
1103
|
+
void this.onUserMessage(frame.sessionId, typed).catch((error) => log.error('supervisor: could not deliver a reply to a closed question', {
|
|
1104
|
+
sessionId: frame.sessionId,
|
|
1105
|
+
error: String(error),
|
|
1106
|
+
}));
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
// Nothing to deliver — but the API optimistically flipped the session to
|
|
1110
|
+
// RUNNING when it relayed the answer, so put the real status back.
|
|
1111
|
+
this.reportStatus(frame.sessionId, statusForReport(running), {});
|
|
1112
|
+
}
|
|
805
1113
|
async onUserMessage(sessionId, text, attachments) {
|
|
806
1114
|
const running = this.sessions.get(sessionId);
|
|
807
1115
|
if (!running) {
|
|
@@ -932,9 +1240,11 @@ export class Supervisor {
|
|
|
932
1240
|
// Parked session: the follow-up message becomes the resume prompt.
|
|
933
1241
|
if (!this.ensureCapacity(running.descriptor.id)) {
|
|
934
1242
|
this.sendEvent(running, 'system_note', {
|
|
935
|
-
|
|
1243
|
+
code: 'runner_busy',
|
|
1244
|
+
text: (this.maxSessions === 1
|
|
936
1245
|
? 'The runner is busy with another session — this one continues as soon as it finishes its turn.'
|
|
937
|
-
: `The runner is busy with ${this.maxSessions} other sessions — this one continues as soon as one of them finishes its turn
|
|
1246
|
+
: `The runner is busy with ${this.maxSessions} other sessions — this one continues as soon as one of them finishes its turn.`) +
|
|
1247
|
+
this.waitingForAnswerSuffix(running.descriptor.id),
|
|
938
1248
|
});
|
|
939
1249
|
// The message is already in the feed; keep it so the resume actually
|
|
940
1250
|
// carries it once a slot frees up, instead of the user's instruction
|
|
@@ -943,6 +1253,9 @@ export class Supervisor {
|
|
|
943
1253
|
running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
|
|
944
1254
|
return;
|
|
945
1255
|
}
|
|
1256
|
+
// A person typing into the session is the clearest signal that the work is
|
|
1257
|
+
// back on track, so the automatic-continuation allowance starts over.
|
|
1258
|
+
clearAutoResume(running.descriptor.id);
|
|
946
1259
|
if (this.launchAgent(running, text, running.descriptor.providerSessionId)) {
|
|
947
1260
|
settle();
|
|
948
1261
|
return;
|
|
@@ -994,6 +1307,12 @@ export class Supervisor {
|
|
|
994
1307
|
const running = this.sessions.get(sessionId);
|
|
995
1308
|
if (!running?.session)
|
|
996
1309
|
return;
|
|
1310
|
+
// The turn being interrupted is the turn the question belongs to — leaving
|
|
1311
|
+
// the ask parked would make the user's next message be swallowed as its
|
|
1312
|
+
// answer (QA-106 m7). The adapter reports each withdrawal itself; the local
|
|
1313
|
+
// set is cleared so the session can be parked again.
|
|
1314
|
+
running.session.cancelQuestions('turn_aborted');
|
|
1315
|
+
running.openQuestions.clear();
|
|
997
1316
|
await running.session.interrupt();
|
|
998
1317
|
this.sendEvent(running, 'notice', { level: 'info', text: 'Turn interrupted by the user' });
|
|
999
1318
|
const next = running.descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
|
|
@@ -1045,9 +1364,10 @@ export class Supervisor {
|
|
|
1045
1364
|
running.stopRequested = true;
|
|
1046
1365
|
this.clearBudgetTimers(running);
|
|
1047
1366
|
if (running.session) {
|
|
1048
|
-
running.session.stop(); // pumpEvents finishes the cleanup
|
|
1367
|
+
running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
|
|
1049
1368
|
}
|
|
1050
1369
|
else {
|
|
1370
|
+
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
1051
1371
|
this.reportStatus(sessionId, 'STOPPED', { activeMs: running.activeMs });
|
|
1052
1372
|
this.sessions.delete(sessionId);
|
|
1053
1373
|
if (this.ws.connected && running.journal.unacked().length === 0) {
|
|
@@ -1068,9 +1388,10 @@ export class Supervisor {
|
|
|
1068
1388
|
running.stopRequested = true;
|
|
1069
1389
|
this.clearBudgetTimers(running);
|
|
1070
1390
|
if (running.session) {
|
|
1071
|
-
running.session.stop(); // pumpEvents finishes the cleanup
|
|
1391
|
+
running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
|
|
1072
1392
|
}
|
|
1073
1393
|
else {
|
|
1394
|
+
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
1074
1395
|
this.sessions.delete(sessionId);
|
|
1075
1396
|
}
|
|
1076
1397
|
}
|
|
@@ -1120,8 +1441,13 @@ export class Supervisor {
|
|
|
1120
1441
|
// Only replay a terminal status from THIS life of the session. A
|
|
1121
1442
|
// resumed session carries a higher epoch, and replaying the FAILED it
|
|
1122
1443
|
// was resumed from would kill it again the moment the runner reconnects.
|
|
1444
|
+
// A journal written by a runner from before session 13 could hold a
|
|
1445
|
+
// `DONE` — it is no longer a status this runner may report, and
|
|
1446
|
+
// replaying one would close the session for the user. Drop it and let
|
|
1447
|
+
// the session be picked back up like any other.
|
|
1123
1448
|
if (last &&
|
|
1124
1449
|
isTerminal(last.status) &&
|
|
1450
|
+
last.status !== 'DONE' &&
|
|
1125
1451
|
(last.epoch ?? 0) >= descriptor.epoch) {
|
|
1126
1452
|
this.ws.send({
|
|
1127
1453
|
type: 'session_status',
|
|
@@ -1160,6 +1486,7 @@ export class Supervisor {
|
|
|
1160
1486
|
activeMs: descriptor.activeMsBase,
|
|
1161
1487
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
1162
1488
|
epoch: descriptor.epoch,
|
|
1489
|
+
openQuestions: new Set(),
|
|
1163
1490
|
mode: descriptor.mode,
|
|
1164
1491
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
1165
1492
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -1170,9 +1497,16 @@ export class Supervisor {
|
|
|
1170
1497
|
running.pendingMessages.push(...running.journal.pending());
|
|
1171
1498
|
this.sessions.set(descriptor.id, running);
|
|
1172
1499
|
try {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1500
|
+
// A session being restored after a runner restart already has its
|
|
1501
|
+
// branch, so the API sends `CONTINUE` — the NEW guard inside would
|
|
1502
|
+
// otherwise fire on the runner's own previous work.
|
|
1503
|
+
const prepared = await this.prepareWorkspace(descriptor);
|
|
1504
|
+
running.branch = prepared.branch;
|
|
1505
|
+
running.worktreePath = prepared.worktreePath;
|
|
1506
|
+
if (prepared.baseSha)
|
|
1507
|
+
running.baseSha = prepared.baseSha;
|
|
1508
|
+
if (prepared.baseBranch)
|
|
1509
|
+
running.baseBranch = prepared.baseBranch;
|
|
1176
1510
|
}
|
|
1177
1511
|
catch (error) {
|
|
1178
1512
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
@@ -1181,9 +1515,43 @@ export class Supervisor {
|
|
|
1181
1515
|
this.sessions.delete(descriptor.id);
|
|
1182
1516
|
continue;
|
|
1183
1517
|
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Was a turn actually in flight when the process died?
|
|
1520
|
+
*
|
|
1521
|
+
* `RUNNING` and `WAITING_PERMISSION` are the mid-turn statuses; the
|
|
1522
|
+
* others mean the agent was already waiting for a human, and there is
|
|
1523
|
+
* nothing to continue. REVIEW is deliberately excluded — the work is
|
|
1524
|
+
* finished and waiting to be looked at.
|
|
1525
|
+
*/
|
|
1526
|
+
const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
|
|
1527
|
+
const resumeId = descriptor.providerSessionId;
|
|
1528
|
+
const willContinue = wasMidTurn && claimAutoResume(descriptor.id);
|
|
1529
|
+
// The note stays either way (owner's call): an interruption is a fact
|
|
1530
|
+
// about the session and must not disappear just because we recovered
|
|
1531
|
+
// from it. Only the instruction at the end changes — telling someone to
|
|
1532
|
+
// send a message while the agent is already working again would be a lie.
|
|
1184
1533
|
this.sendEvent(running, 'system_note', {
|
|
1185
|
-
text:
|
|
1534
|
+
text: willContinue
|
|
1535
|
+
? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
|
|
1536
|
+
: 'Runner reconnected. The session was resumed — send a message to continue.',
|
|
1186
1537
|
});
|
|
1538
|
+
if (willContinue) {
|
|
1539
|
+
// Resumed through the PROVIDER session, so the agent keeps its whole
|
|
1540
|
+
// conversation; the prompt is only the nudge a human would otherwise
|
|
1541
|
+
// have to type. Exactly what «продолжай» did by hand — no new class of
|
|
1542
|
+
// risk, and the same ceiling protects against a crash loop doing it
|
|
1543
|
+
// forever (see `auto-resume.ts`).
|
|
1544
|
+
if (this.launchAgent(running, AUTO_RESUME_PROMPT, resumeId)) {
|
|
1545
|
+
this.reportStatus(descriptor.id, 'RUNNING', {});
|
|
1546
|
+
this.flushPendingMessages(running);
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
// Could not start (an exhausted budget is the only way here). Fall
|
|
1550
|
+
// through to the old behaviour and say so honestly.
|
|
1551
|
+
this.sendEvent(running, 'system_note', {
|
|
1552
|
+
text: 'Could not continue automatically — send a message to pick the work back up.',
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1187
1555
|
// REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
|
|
1188
1556
|
// mid-turn statuses are downgraded to "waiting for the user".
|
|
1189
1557
|
if (descriptor.status !== 'REVIEW') {
|
|
@@ -1214,6 +1582,7 @@ export class Supervisor {
|
|
|
1214
1582
|
*/
|
|
1215
1583
|
pruneJournals() {
|
|
1216
1584
|
try {
|
|
1585
|
+
pruneAutoResume(new Set(this.sessions.keys()));
|
|
1217
1586
|
const removed = this.journals.prune({
|
|
1218
1587
|
maxAgeMs: Supervisor.JOURNAL_TTL_MS,
|
|
1219
1588
|
hardMaxAgeMs: Supervisor.JOURNAL_HARD_TTL_MS,
|
|
@@ -1237,6 +1606,10 @@ export class Supervisor {
|
|
|
1237
1606
|
if (!path)
|
|
1238
1607
|
return void reply({ ok: false, error: 'path argument is required' });
|
|
1239
1608
|
const validation = await validateWorkspacePath(path);
|
|
1609
|
+
// Remembered so `devbridge-runner doctor` can check the permissions
|
|
1610
|
+
// of the real projects without being told which they are.
|
|
1611
|
+
if (validation.ok)
|
|
1612
|
+
rememberWorkspacePath(path);
|
|
1240
1613
|
return void reply({
|
|
1241
1614
|
ok: validation.ok,
|
|
1242
1615
|
result: validation,
|
|
@@ -1300,7 +1673,17 @@ export class Supervisor {
|
|
|
1300
1673
|
});
|
|
1301
1674
|
return void reply({
|
|
1302
1675
|
ok: true,
|
|
1303
|
-
result: await gitStatus(
|
|
1676
|
+
result: await gitStatus({
|
|
1677
|
+
worktreePath: paths.worktreePath,
|
|
1678
|
+
workspacePath: paths.workspacePath,
|
|
1679
|
+
sessionBranch: paths.branch,
|
|
1680
|
+
...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
|
|
1681
|
+
...optionalSha(frame.args?.['appliedBranchSha'], 'appliedBranchSha'),
|
|
1682
|
+
...optionalSha(frame.args?.['pushedSha'], 'pushedSha'),
|
|
1683
|
+
// Session 16: a DIRECT session has no base to be measured
|
|
1684
|
+
// against — the folder's own branch is where the work is.
|
|
1685
|
+
...(frame.args?.['direct'] === true ? { direct: true } : {}),
|
|
1686
|
+
}),
|
|
1304
1687
|
});
|
|
1305
1688
|
}
|
|
1306
1689
|
case 'git_diff': {
|
|
@@ -1314,7 +1697,10 @@ export class Supervisor {
|
|
|
1314
1697
|
}
|
|
1315
1698
|
return void reply({
|
|
1316
1699
|
ok: true,
|
|
1317
|
-
result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath),
|
|
1700
|
+
result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath, str(frame.args?.['baseBranch']) ?? undefined,
|
|
1701
|
+
// Session 16: the Source Control panel asks for one side of the
|
|
1702
|
+
// index at a time. Anything else keeps the historical meaning.
|
|
1703
|
+
diffMode(frame.args?.['mode'])),
|
|
1318
1704
|
});
|
|
1319
1705
|
}
|
|
1320
1706
|
// Session 11: history. Read-only, so no repo lock and no busy check —
|
|
@@ -1324,20 +1710,32 @@ export class Supervisor {
|
|
|
1324
1710
|
case 'git_log': {
|
|
1325
1711
|
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1326
1712
|
const branch = str(frame.args?.['branch']);
|
|
1327
|
-
|
|
1328
|
-
|
|
1713
|
+
const selector = refSelector(frame.args);
|
|
1714
|
+
// Session 14: the repository graph is reached from the WORKSPACE, so
|
|
1715
|
+
// a branch is no longer required — but then a ref selector is, or the
|
|
1716
|
+
// call would have nothing to walk and git would quietly default to
|
|
1717
|
+
// HEAD.
|
|
1718
|
+
if (!workspacePath || (!branch && Object.keys(selector).length === 0)) {
|
|
1719
|
+
return void reply({
|
|
1720
|
+
ok: false,
|
|
1721
|
+
error: 'workspacePath and either branch or a ref selection are required',
|
|
1722
|
+
});
|
|
1329
1723
|
}
|
|
1330
1724
|
const scope = logScope(frame.args?.['scope']);
|
|
1331
1725
|
const limit = num(frame.args?.['limit']);
|
|
1332
1726
|
const skip = num(frame.args?.['skip']);
|
|
1727
|
+
const cursor = str(frame.args?.['cursor']);
|
|
1333
1728
|
return void reply({
|
|
1334
1729
|
ok: true,
|
|
1335
1730
|
result: await gitLog({
|
|
1336
1731
|
workspacePath,
|
|
1337
|
-
branch,
|
|
1732
|
+
...(branch ? { branch } : {}),
|
|
1338
1733
|
...(scope ? { scope } : {}),
|
|
1339
1734
|
...(limit !== null ? { limit } : {}),
|
|
1340
1735
|
...(skip !== null ? { skip } : {}),
|
|
1736
|
+
...(cursor ? { cursor } : {}),
|
|
1737
|
+
...selector,
|
|
1738
|
+
...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
|
|
1341
1739
|
}),
|
|
1342
1740
|
});
|
|
1343
1741
|
}
|
|
@@ -1349,11 +1747,45 @@ export class Supervisor {
|
|
|
1349
1747
|
}
|
|
1350
1748
|
const filePath = str(frame.args?.['path']);
|
|
1351
1749
|
const showBranch = str(frame.args?.['branch']);
|
|
1750
|
+
const selector = refSelector(frame.args);
|
|
1352
1751
|
return void reply({
|
|
1353
1752
|
ok: true,
|
|
1354
|
-
result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined),
|
|
1753
|
+
result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined, str(frame.args?.['baseBranch']) ?? undefined,
|
|
1754
|
+
// The visibility set is widened by exactly the refs the LOG was
|
|
1755
|
+
// allowed to walk. Passing it only when the caller sent one keeps
|
|
1756
|
+
// every session read on its original two refs.
|
|
1757
|
+
Object.keys(selector).length > 0 ? selector : undefined),
|
|
1355
1758
|
});
|
|
1356
1759
|
}
|
|
1760
|
+
// Session 14: the ref list behind the graph's branch picker. Read-only,
|
|
1761
|
+
// same call as git_log — `for-each-ref` cannot move anything.
|
|
1762
|
+
case 'git_refs': {
|
|
1763
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1764
|
+
if (!workspacePath) {
|
|
1765
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
1766
|
+
}
|
|
1767
|
+
return void reply({ ok: true, result: await gitRefs(workspacePath) });
|
|
1768
|
+
}
|
|
1769
|
+
// Session 14: what the project folder is ACTUALLY on. The product used
|
|
1770
|
+
// to collect this during path validation and throw it away, which is
|
|
1771
|
+
// why it could never answer «is the running build yours».
|
|
1772
|
+
case 'workspace_state': {
|
|
1773
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1774
|
+
if (!workspacePath) {
|
|
1775
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
1776
|
+
}
|
|
1777
|
+
return void reply({ ok: true, result: await workspaceState(workspacePath) });
|
|
1778
|
+
}
|
|
1779
|
+
// Session 13: the branch list the «continue an existing branch» picker
|
|
1780
|
+
// reads. Read-only — `for-each-ref` and `worktree list` cannot move a
|
|
1781
|
+
// ref — so no lock and no busy check, same call as git_log above.
|
|
1782
|
+
case 'git_branches': {
|
|
1783
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1784
|
+
if (!workspacePath) {
|
|
1785
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
1786
|
+
}
|
|
1787
|
+
return void reply({ ok: true, result: await gitBranches(workspacePath) });
|
|
1788
|
+
}
|
|
1357
1789
|
case 'git_commit': {
|
|
1358
1790
|
const worktreePath = str(frame.args?.['worktreePath']);
|
|
1359
1791
|
const message = str(frame.args?.['message']);
|
|
@@ -1366,9 +1798,14 @@ export class Supervisor {
|
|
|
1366
1798
|
error: 'The agent is still working — wait for the turn to finish',
|
|
1367
1799
|
});
|
|
1368
1800
|
}
|
|
1801
|
+
// Session 16: `all: false` commits the INDEX — what the person
|
|
1802
|
+
// staged, and nothing else. Absent stays `git add -A` + commit, which
|
|
1803
|
+
// is what the agent's own auto-commit has always meant and what every
|
|
1804
|
+
// API older than this release will keep asking for.
|
|
1805
|
+
const all = frame.args?.['all'] !== false;
|
|
1369
1806
|
return void reply({
|
|
1370
1807
|
ok: true,
|
|
1371
|
-
result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message)),
|
|
1808
|
+
result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message, { all })),
|
|
1372
1809
|
});
|
|
1373
1810
|
}
|
|
1374
1811
|
case 'apply_session': {
|
|
@@ -1386,22 +1823,181 @@ export class Supervisor {
|
|
|
1386
1823
|
error: 'The agent is still working — wait for the turn to finish',
|
|
1387
1824
|
});
|
|
1388
1825
|
}
|
|
1389
|
-
const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession(
|
|
1390
|
-
|
|
1826
|
+
const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession({
|
|
1827
|
+
workspacePath: paths.workspacePath,
|
|
1828
|
+
worktreePath: paths.worktreePath,
|
|
1829
|
+
sessionBranch: paths.branch,
|
|
1830
|
+
message,
|
|
1831
|
+
...optionalBranch(frame.args?.['expectedBase'], 'expectedBase'),
|
|
1832
|
+
...optionalSha(frame.args?.['sinceSha'], 'sinceSha'),
|
|
1833
|
+
}));
|
|
1834
|
+
// «Nothing new to apply», «the folder is on another branch» and «the
|
|
1835
|
+
// folder has uncommitted changes» are answers, not failures: the API
|
|
1836
|
+
// turns each into a sentence with a way forward, and an `ok: false`
|
|
1837
|
+
// here would surface as a raw error toast instead.
|
|
1838
|
+
//
|
|
1839
|
+
// `workspaceDirty` joining that list is safe for an API older than
|
|
1840
|
+
// session 15: it does not know the flag, so it falls through to the
|
|
1841
|
+
// same `AppError.conflict(result.error)` it raises today, with the
|
|
1842
|
+
// same wording.
|
|
1843
|
+
return void reply(applied.applied ||
|
|
1844
|
+
applied.conflict ||
|
|
1845
|
+
applied.noChanges ||
|
|
1846
|
+
applied.drifted ||
|
|
1847
|
+
applied.workspaceDirty
|
|
1391
1848
|
? { ok: true, result: applied }
|
|
1392
1849
|
: { ok: false, error: applied.error ?? 'Apply failed', result: applied });
|
|
1393
1850
|
}
|
|
1851
|
+
// Session 13: merge the base branch INTO the session branch. Merge and
|
|
1852
|
+
// never rebase — rebase rewrites commits the History tab already showed
|
|
1853
|
+
// a human. Takes the worktree busy check and the repo lock, because it
|
|
1854
|
+
// writes into the session's index just like a commit does.
|
|
1855
|
+
case 'update_from_base': {
|
|
1856
|
+
const paths = gitCommandPaths(frame.args);
|
|
1857
|
+
if (!paths) {
|
|
1858
|
+
return void reply({
|
|
1859
|
+
ok: false,
|
|
1860
|
+
error: 'worktreePath/workspacePath/branch are required',
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
if (this.isWorktreeBusy(paths.worktreePath)) {
|
|
1864
|
+
return void reply({
|
|
1865
|
+
ok: false,
|
|
1866
|
+
error: 'The agent is still working — wait for the turn to finish',
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
const updated = await this.withRepoLockFor(paths.worktreePath, () => updateFromBase({
|
|
1870
|
+
workspacePath: paths.workspacePath,
|
|
1871
|
+
worktreePath: paths.worktreePath,
|
|
1872
|
+
sessionBranch: paths.branch,
|
|
1873
|
+
...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
|
|
1874
|
+
}));
|
|
1875
|
+
return void reply(updated.updated || updated.conflict
|
|
1876
|
+
? { ok: true, result: updated }
|
|
1877
|
+
: { ok: false, error: updated.error ?? 'Update failed', result: updated });
|
|
1878
|
+
}
|
|
1879
|
+
// Session 13: the one outgoing write DevBridge performs. Never reached
|
|
1880
|
+
// by an agent — layer 1 denies `git push` outright — only by a human
|
|
1881
|
+
// pressing the button, and only for this session's own branch.
|
|
1882
|
+
case 'git_push': {
|
|
1883
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1884
|
+
const branch = str(frame.args?.['branch']);
|
|
1885
|
+
const remote = str(frame.args?.['remote']);
|
|
1886
|
+
if (!workspacePath || !branch || !remote) {
|
|
1887
|
+
return void reply({
|
|
1888
|
+
ok: false,
|
|
1889
|
+
error: 'workspacePath/branch/remote are required',
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
const pushed = await this.withRepoLockFor(workspacePath, () => gitPush({ workspacePath, branch, remote }));
|
|
1893
|
+
return void reply(pushed.pushed
|
|
1894
|
+
? { ok: true, result: pushed }
|
|
1895
|
+
: { ok: false, error: pushed.error ?? 'Push failed', result: pushed });
|
|
1896
|
+
}
|
|
1394
1897
|
case 'revert_apply': {
|
|
1395
1898
|
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1396
1899
|
const commitSha = str(frame.args?.['commitSha']);
|
|
1397
1900
|
if (!workspacePath || !commitSha) {
|
|
1398
1901
|
return void reply({ ok: false, error: 'workspacePath and commitSha are required' });
|
|
1399
1902
|
}
|
|
1400
|
-
const
|
|
1401
|
-
|
|
1903
|
+
const expected = optionalBranch(frame.args?.['expectedBase'], 'expectedBase');
|
|
1904
|
+
const reverted = await this.withRepoLockFor(workspacePath, () => revertApply(workspacePath, commitSha, expected.expectedBase));
|
|
1905
|
+
// Same reasoning as `apply_session`: a dirty project folder is a
|
|
1906
|
+
// state the API can explain and offer a way out of, not an error.
|
|
1907
|
+
return void reply(reverted.reverted || reverted.conflict || reverted.workspaceDirty
|
|
1402
1908
|
? { ok: true, result: reverted }
|
|
1403
1909
|
: { ok: false, error: reverted.error ?? 'Revert failed', result: reverted });
|
|
1404
1910
|
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Session 16 — the Source Control panel's four verbs.
|
|
1913
|
+
*
|
|
1914
|
+
* All of them WRITE into the index or the working tree of the session's
|
|
1915
|
+
* folder, so all of them take the repo lock and the worktree busy check:
|
|
1916
|
+
* staging a file underneath an agent that is mid-edit produces a commit
|
|
1917
|
+
* of half a thought, and «the agent is still working» is a sentence a
|
|
1918
|
+
* person can act on where a git error is not.
|
|
1919
|
+
*
|
|
1920
|
+
* They are deliberately four commands and not one `git` passthrough. A
|
|
1921
|
+
* passthrough would be a shell on somebody's server behind a web button;
|
|
1922
|
+
* these take a list of paths, and every one of those paths is checked
|
|
1923
|
+
* before it reaches an argv slot.
|
|
1924
|
+
*/
|
|
1925
|
+
case 'git_stage':
|
|
1926
|
+
case 'git_unstage':
|
|
1927
|
+
case 'git_discard': {
|
|
1928
|
+
const worktreePath = str(frame.args?.['worktreePath']);
|
|
1929
|
+
if (!worktreePath) {
|
|
1930
|
+
return void reply({ ok: false, error: 'worktreePath is required' });
|
|
1931
|
+
}
|
|
1932
|
+
const all = frame.args?.['all'] === true;
|
|
1933
|
+
const paths = Array.isArray(frame.args?.['paths']) ? frame.args?.['paths'] : null;
|
|
1934
|
+
if (!all && !paths) {
|
|
1935
|
+
return void reply({ ok: false, error: 'paths or all is required' });
|
|
1936
|
+
}
|
|
1937
|
+
if (this.isWorktreeBusy(worktreePath)) {
|
|
1938
|
+
return void reply({
|
|
1939
|
+
ok: false,
|
|
1940
|
+
error: 'The agent is still working — wait for the turn to finish',
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
const request = { worktreePath, ...(all ? { all } : { paths: paths }) };
|
|
1944
|
+
const run = frame.name === 'git_stage'
|
|
1945
|
+
? () => gitStage(request)
|
|
1946
|
+
: frame.name === 'git_unstage'
|
|
1947
|
+
? () => gitUnstage(request)
|
|
1948
|
+
: () => gitDiscard(request);
|
|
1949
|
+
try {
|
|
1950
|
+
const result = await this.withRepoLockFor(worktreePath, run);
|
|
1951
|
+
return void reply({ ok: true, result });
|
|
1952
|
+
}
|
|
1953
|
+
catch (error) {
|
|
1954
|
+
// A refused path is a sentence the panel shows verbatim («Path must
|
|
1955
|
+
// be relative to the repository: …»), not a stack trace.
|
|
1956
|
+
return void reply({ ok: false, error: maskSecretText(error) });
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Session 16: `git pull`, and the way back out of one that conflicted.
|
|
1961
|
+
*
|
|
1962
|
+
* The only command in this group that reaches the network. It can leave
|
|
1963
|
+
* a conflicted tree behind on purpose — that is what git does, and the
|
|
1964
|
+
* panel draws it as «Merge Changes» rather than pretending the pull did
|
|
1965
|
+
* not happen.
|
|
1966
|
+
*/
|
|
1967
|
+
case 'git_pull': {
|
|
1968
|
+
const worktreePath = str(frame.args?.['worktreePath']);
|
|
1969
|
+
if (!worktreePath) {
|
|
1970
|
+
return void reply({ ok: false, error: 'worktreePath is required' });
|
|
1971
|
+
}
|
|
1972
|
+
if (this.isWorktreeBusy(worktreePath)) {
|
|
1973
|
+
return void reply({
|
|
1974
|
+
ok: false,
|
|
1975
|
+
error: 'The agent is still working — wait for the turn to finish',
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
const pulled = await this.withRepoLockFor(worktreePath, () => gitPull({ worktreePath }));
|
|
1979
|
+
// A conflict is an outcome with a state attached, not a failure: the
|
|
1980
|
+
// API turns it into a sentence and the panel into a file group.
|
|
1981
|
+
return void reply(pulled.pulled || pulled.conflict
|
|
1982
|
+
? { ok: true, result: pulled }
|
|
1983
|
+
: { ok: false, error: pulled.error ?? 'Pull failed', result: pulled });
|
|
1984
|
+
}
|
|
1985
|
+
case 'git_merge_abort': {
|
|
1986
|
+
const worktreePath = str(frame.args?.['worktreePath']);
|
|
1987
|
+
if (!worktreePath) {
|
|
1988
|
+
return void reply({ ok: false, error: 'worktreePath is required' });
|
|
1989
|
+
}
|
|
1990
|
+
if (this.isWorktreeBusy(worktreePath)) {
|
|
1991
|
+
return void reply({
|
|
1992
|
+
ok: false,
|
|
1993
|
+
error: 'The agent is still working — wait for the turn to finish',
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
const aborted = await this.withRepoLockFor(worktreePath, () => gitMergeAbort(worktreePath));
|
|
1997
|
+
return void reply(aborted.aborted
|
|
1998
|
+
? { ok: true, result: aborted }
|
|
1999
|
+
: { ok: false, error: aborted.reason ?? 'Nothing to abort', result: aborted });
|
|
2000
|
+
}
|
|
1405
2001
|
case 'fs_view': {
|
|
1406
2002
|
const root = str(frame.args?.['root']);
|
|
1407
2003
|
if (!root)
|
|
@@ -1451,6 +2047,185 @@ export class Supervisor {
|
|
|
1451
2047
|
this.opts.onRestartRequested?.(outcome);
|
|
1452
2048
|
return;
|
|
1453
2049
|
}
|
|
2050
|
+
// ─── Session 14: the project recipe ──────────────────────────
|
|
2051
|
+
//
|
|
2052
|
+
// Read-only, always available even when verification is switched off:
|
|
2053
|
+
// «this machine will not run it» is a different answer from «this
|
|
2054
|
+
// project has no recipe», and the card has to be able to say both.
|
|
2055
|
+
case 'recipe_state': {
|
|
2056
|
+
const root = str(frame.args?.['root']) ?? str(frame.args?.['workspacePath']);
|
|
2057
|
+
if (!root)
|
|
2058
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
2059
|
+
return void reply({
|
|
2060
|
+
ok: true,
|
|
2061
|
+
result: { ...readRecipeProposal(root), verifyEnabled: this.verify.enabled },
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
case 'verify_start': {
|
|
2065
|
+
const parsed = parseVerifyStart(frame.args);
|
|
2066
|
+
if ('error' in parsed)
|
|
2067
|
+
return void reply({ ok: false, error: parsed.error });
|
|
2068
|
+
const started = this.verify.start(parsed.input);
|
|
2069
|
+
return void reply(started.started
|
|
2070
|
+
? { ok: true, result: { started: true, runId: parsed.input.runId } }
|
|
2071
|
+
: { ok: false, error: started.error ?? 'Could not start the verification' });
|
|
2072
|
+
}
|
|
2073
|
+
case 'verify_status': {
|
|
2074
|
+
const runId = str(frame.args?.['runId']);
|
|
2075
|
+
if (!runId)
|
|
2076
|
+
return void reply({ ok: false, error: 'runId is required' });
|
|
2077
|
+
const status = this.verify.status(runId, num(frame.args?.['offset']) ?? 0);
|
|
2078
|
+
return void reply(status
|
|
2079
|
+
? { ok: true, result: status }
|
|
2080
|
+
: { ok: false, error: 'This runner does not know that verification run' });
|
|
2081
|
+
}
|
|
2082
|
+
case 'verify_cancel': {
|
|
2083
|
+
const runId = str(frame.args?.['runId']);
|
|
2084
|
+
if (!runId)
|
|
2085
|
+
return void reply({ ok: false, error: 'runId is required' });
|
|
2086
|
+
const cancelled = this.verify.cancel(runId);
|
|
2087
|
+
return void reply(cancelled.cancelled
|
|
2088
|
+
? { ok: true, result: cancelled }
|
|
2089
|
+
: { ok: false, error: cancelled.error ?? 'Could not cancel' });
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Session 14: show a branch WITHOUT touching the project folder.
|
|
2093
|
+
*
|
|
2094
|
+
* A second, detached worktree plus the recipe's own `preview` command —
|
|
2095
|
+
* which has its own docker project and its own ports. Without that
|
|
2096
|
+
* command there is nothing honest to do: rebuilding in the project
|
|
2097
|
+
* folder would replace the one copy the machine is running.
|
|
2098
|
+
*/
|
|
2099
|
+
case 'preview_checkout': {
|
|
2100
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
2101
|
+
const branch = str(frame.args?.['branch']);
|
|
2102
|
+
const parsed = parseVerifyStart(frame.args);
|
|
2103
|
+
if (!workspacePath || !branch) {
|
|
2104
|
+
return void reply({ ok: false, error: 'workspacePath and branch are required' });
|
|
2105
|
+
}
|
|
2106
|
+
if ('error' in parsed)
|
|
2107
|
+
return void reply({ ok: false, error: parsed.error });
|
|
2108
|
+
if (!parsed.input.recipe.preview?.run) {
|
|
2109
|
+
return void reply({
|
|
2110
|
+
ok: false,
|
|
2111
|
+
error: 'Previewing this branch would rebuild the only copy this machine is running. Add a `preview` command to the recipe — with its own docker project name and its own ports — and the preview gets its own stack.',
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
const checkout = await this.withRepoLockFor(workspacePath, () => ensurePreviewWorktree({
|
|
2115
|
+
workspacePath,
|
|
2116
|
+
workspaceKey: frame.workspaceId ?? workspacePath,
|
|
2117
|
+
branch,
|
|
2118
|
+
}));
|
|
2119
|
+
const started = this.verify.start({
|
|
2120
|
+
...parsed.input,
|
|
2121
|
+
target: 'PREVIEW',
|
|
2122
|
+
preview: true,
|
|
2123
|
+
cwd: checkout.worktreePath,
|
|
2124
|
+
branch: checkout.branch,
|
|
2125
|
+
commitSha: checkout.sha,
|
|
2126
|
+
dirty: false,
|
|
2127
|
+
});
|
|
2128
|
+
return void reply(started.started
|
|
2129
|
+
? {
|
|
2130
|
+
ok: true,
|
|
2131
|
+
result: {
|
|
2132
|
+
started: true,
|
|
2133
|
+
runId: parsed.input.runId,
|
|
2134
|
+
worktreePath: checkout.worktreePath,
|
|
2135
|
+
branch: checkout.branch,
|
|
2136
|
+
sha: checkout.sha,
|
|
2137
|
+
url: parsed.input.recipe.preview.url ?? null,
|
|
2138
|
+
},
|
|
2139
|
+
}
|
|
2140
|
+
: { ok: false, error: started.error ?? 'Could not start the preview' });
|
|
2141
|
+
}
|
|
2142
|
+
case 'preview_stop': {
|
|
2143
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
2144
|
+
if (!workspacePath)
|
|
2145
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
2146
|
+
const key = frame.workspaceId ?? workspacePath;
|
|
2147
|
+
const stopCommand = str(frame.args?.['stop']);
|
|
2148
|
+
const dockerProject = str(frame.args?.['project']);
|
|
2149
|
+
// Cancel the preview's own run FIRST (session 15).
|
|
2150
|
+
//
|
|
2151
|
+
// «Stop preview» used to run the stop command and delete the
|
|
2152
|
+
// worktree while `preview.run` was still executing inside it — so a
|
|
2153
|
+
// half-built `docker compose up --build` lost its directory mid-flight
|
|
2154
|
+
// and went on holding the machine's single verification slot until it
|
|
2155
|
+
// timed out, with the dashboard already showing the preview as
|
|
2156
|
+
// stopped. Only a PREVIEW run is ours to cancel: a build somebody
|
|
2157
|
+
// started for a session is a different thing entirely.
|
|
2158
|
+
if (this.verify.activeTarget === 'PREVIEW') {
|
|
2159
|
+
const runId = this.verify.activeRunId;
|
|
2160
|
+
if (runId)
|
|
2161
|
+
this.verify.cancel(runId);
|
|
2162
|
+
}
|
|
2163
|
+
if (stopCommand) {
|
|
2164
|
+
// The one place layer 1 lifts `docker … down`, and only for the
|
|
2165
|
+
// compose project the recipe named as its own.
|
|
2166
|
+
const decision = evaluateRecipeCommand(stopCommand, {
|
|
2167
|
+
isPreviewStop: true,
|
|
2168
|
+
...(dockerProject ? { dockerProject } : {}),
|
|
2169
|
+
});
|
|
2170
|
+
if (!decision.allowed) {
|
|
2171
|
+
return void reply({
|
|
2172
|
+
ok: false,
|
|
2173
|
+
error: `The stop command is refused: ${decision.reason}`,
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
await runPreviewStop(stopCommand, previewWorktreePath(key));
|
|
2177
|
+
}
|
|
2178
|
+
const removed = await this.withRepoLockFor(workspacePath, () => removePreviewWorktree(key));
|
|
2179
|
+
return void reply({ ok: true, result: { stopped: true, removed } });
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Session 14: a commit message written by an agent, in a one-shot run.
|
|
2183
|
+
*
|
|
2184
|
+
* Never `send()` into the live session — that would fill the feed with a
|
|
2185
|
+
* request nobody made, spend the session's context on the whole diff and
|
|
2186
|
+
* fight the worktree lock the agent holds mid-turn.
|
|
2187
|
+
*/
|
|
2188
|
+
case 'propose_commit_message': {
|
|
2189
|
+
const paths = gitCommandPaths(frame.args);
|
|
2190
|
+
if (!paths) {
|
|
2191
|
+
return void reply({
|
|
2192
|
+
ok: false,
|
|
2193
|
+
error: 'worktreePath/workspacePath/branch are required',
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
const ticketsRaw = frame.args?.['tickets'];
|
|
2197
|
+
const subjectsRaw = frame.args?.['commitSubjects'];
|
|
2198
|
+
const propose = this.opts.proposeCommitMessage ?? proposeCommitMessage;
|
|
2199
|
+
const result = await propose({
|
|
2200
|
+
worktreePath: paths.worktreePath,
|
|
2201
|
+
workspacePath: paths.workspacePath,
|
|
2202
|
+
branch: paths.branch,
|
|
2203
|
+
...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
|
|
2204
|
+
...(Array.isArray(ticketsRaw)
|
|
2205
|
+
? {
|
|
2206
|
+
tickets: ticketsRaw
|
|
2207
|
+
.filter((n) => typeof n === 'number')
|
|
2208
|
+
.slice(0, 20),
|
|
2209
|
+
}
|
|
2210
|
+
: {}),
|
|
2211
|
+
...(Array.isArray(subjectsRaw)
|
|
2212
|
+
? {
|
|
2213
|
+
commitSubjects: subjectsRaw
|
|
2214
|
+
.filter((s) => typeof s === 'string')
|
|
2215
|
+
.slice(0, 20),
|
|
2216
|
+
}
|
|
2217
|
+
: {}),
|
|
2218
|
+
...(str(frame.args?.['language'])
|
|
2219
|
+
? { language: str(frame.args?.['language']) }
|
|
2220
|
+
: {}),
|
|
2221
|
+
...(str(frame.args?.['convention'])
|
|
2222
|
+
? { convention: str(frame.args?.['convention']) }
|
|
2223
|
+
: {}),
|
|
2224
|
+
});
|
|
2225
|
+
return void reply(result.ok
|
|
2226
|
+
? { ok: true, result }
|
|
2227
|
+
: { ok: false, error: result.error ?? 'Could not write a commit message', result });
|
|
2228
|
+
}
|
|
1454
2229
|
case 'auth_status':
|
|
1455
2230
|
return void reply({ ok: true, result: await agentAuthStatuses() });
|
|
1456
2231
|
case 'login_start': {
|
|
@@ -1465,6 +2240,9 @@ export class Supervisor {
|
|
|
1465
2240
|
if (!agent || !code)
|
|
1466
2241
|
return void reply({ ok: false, error: 'agent and code are required' });
|
|
1467
2242
|
const result = await this.authRelay.submitCode(agent, code);
|
|
2243
|
+
// A fresh credential outranks anything we remember about the old one.
|
|
2244
|
+
if (result.ok)
|
|
2245
|
+
clearAgentAuthFailure(agent);
|
|
1468
2246
|
return void reply({
|
|
1469
2247
|
ok: result.ok,
|
|
1470
2248
|
result,
|
|
@@ -1552,7 +2330,19 @@ export class Supervisor {
|
|
|
1552
2330
|
running.lastReported = status;
|
|
1553
2331
|
this.syncBudgetClock(running);
|
|
1554
2332
|
}
|
|
1555
|
-
|
|
2333
|
+
// The fork point rides along on the first frame after the branch was
|
|
2334
|
+
// created — once, because it never changes and the API pins the first
|
|
2335
|
+
// answer it hears. Sending it on every frame would invite a later, wrong
|
|
2336
|
+
// value to overwrite the right one if the pinning rule were ever relaxed.
|
|
2337
|
+
const base = running && !running.baseReported && (running.baseBranch || running.baseSha)
|
|
2338
|
+
? {
|
|
2339
|
+
...(running.baseBranch ? { baseBranch: running.baseBranch } : {}),
|
|
2340
|
+
...(running.baseSha ? { baseSha: running.baseSha } : {}),
|
|
2341
|
+
}
|
|
2342
|
+
: {};
|
|
2343
|
+
if (running && Object.keys(base).length > 0)
|
|
2344
|
+
running.baseReported = true;
|
|
2345
|
+
const compact = maskSecrets(Object.fromEntries(Object.entries({ ...extra, ...base }).filter(([, v]) => v !== undefined)));
|
|
1556
2346
|
// Journal the latest status for replay after a reconnect (QA-96 F1) —
|
|
1557
2347
|
// only for tracked sessions, so one-shot failure reports don't leave
|
|
1558
2348
|
// orphan journal files behind. The epoch stamp keeps a previous life's
|
|
@@ -1575,9 +2365,31 @@ export class Supervisor {
|
|
|
1575
2365
|
this.authRelay.cancel();
|
|
1576
2366
|
for (const running of this.sessions.values()) {
|
|
1577
2367
|
this.clearBudgetTimers(running);
|
|
1578
|
-
|
|
2368
|
+
// BEFORE stop(), and from here rather than from the adapter: the adapter
|
|
2369
|
+
// pushes its `question_resolved` into an async queue that `pumpEvents`
|
|
2370
|
+
// drains a microtask later, and every caller of shutdown() (SIGTERM,
|
|
2371
|
+
// «Update runner», `revoked`) calls `process.exit` in the same tick — so
|
|
2372
|
+
// that event would never be journaled and never reach the wire. This
|
|
2373
|
+
// path writes the journal synchronously, and an unacked event is
|
|
2374
|
+
// redelivered on the next connection (QA-106 M1).
|
|
2375
|
+
try {
|
|
2376
|
+
this.withdrawOpenQuestions(running, 'runner_restarted');
|
|
2377
|
+
}
|
|
2378
|
+
catch (error) {
|
|
2379
|
+
log.warn('supervisor: could not withdraw open questions on shutdown', {
|
|
2380
|
+
sessionId: running.descriptor.id,
|
|
2381
|
+
error: String(error),
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
// The reason matters here: this path also runs for «Update runner», and a
|
|
2385
|
+
// card that vanishes during an update must say why (session 12).
|
|
2386
|
+
running.session?.stop('runner_restarted');
|
|
1579
2387
|
}
|
|
1580
2388
|
this.sessions.clear();
|
|
2389
|
+
// A build that was mid-flight is abandoned, not judged: its row stays
|
|
2390
|
+
// RUNNING until the API's sweep turns it into LOST. A verdict nobody
|
|
2391
|
+
// observed must never become PASSED.
|
|
2392
|
+
this.verify.shutdown();
|
|
1581
2393
|
}
|
|
1582
2394
|
}
|
|
1583
2395
|
function str(value) {
|
|
@@ -1596,6 +2408,105 @@ function num(value) {
|
|
|
1596
2408
|
function logScope(value) {
|
|
1597
2409
|
return value === 'session' || value === 'branch' || value === 'all' ? value : null;
|
|
1598
2410
|
}
|
|
2411
|
+
/** Which side of the index a diff request means (session 16). */
|
|
2412
|
+
function diffMode(value) {
|
|
2413
|
+
return value === 'staged' || value === 'worktree' ? value : 'base';
|
|
2414
|
+
}
|
|
2415
|
+
/**
|
|
2416
|
+
* Optional command arguments that become git refs.
|
|
2417
|
+
*
|
|
2418
|
+
* They arrive from the API as `unknown` and end up in an argv slot, so they are
|
|
2419
|
+
* shaped here rather than passed through: a value that is not a ref name (or
|
|
2420
|
+
* not a hash) is dropped, and the callee falls back to what it did before the
|
|
2421
|
+
* argument existed. `exactOptionalPropertyTypes` is why these return a spread
|
|
2422
|
+
* object instead of `T | undefined`.
|
|
2423
|
+
*/
|
|
2424
|
+
function optionalBranch(value, key) {
|
|
2425
|
+
const raw = str(value);
|
|
2426
|
+
if (!raw || raw.length > 200 || !/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/.test(raw))
|
|
2427
|
+
return {};
|
|
2428
|
+
return { [key]: raw };
|
|
2429
|
+
}
|
|
2430
|
+
function optionalSha(value, key) {
|
|
2431
|
+
const raw = str(value);
|
|
2432
|
+
if (!raw || !/^[0-9a-f]{7,64}$/i.test(raw))
|
|
2433
|
+
return {};
|
|
2434
|
+
return { [key]: raw };
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Which refs a history request may walk (session 14).
|
|
2438
|
+
*
|
|
2439
|
+
* Present only when the caller actually asked for something: an empty object
|
|
2440
|
+
* means «no selector», which keeps every session-level call on exactly the
|
|
2441
|
+
* two-ref behaviour it had before this release. Names are re-validated inside
|
|
2442
|
+
* `gitLog` too — this is the cheap first gate, not the only one.
|
|
2443
|
+
*/
|
|
2444
|
+
function refSelector(args) {
|
|
2445
|
+
const raw = args?.['refs'];
|
|
2446
|
+
const refs = Array.isArray(raw)
|
|
2447
|
+
? raw.filter((value) => typeof value === 'string').slice(0, 64)
|
|
2448
|
+
: null;
|
|
2449
|
+
return {
|
|
2450
|
+
...(refs && refs.length > 0 ? { refs } : {}),
|
|
2451
|
+
...(args?.['includeRemotes'] === true ? { includeRemotes: true } : {}),
|
|
2452
|
+
...(args?.['includeTags'] === true ? { includeTags: true } : {}),
|
|
2453
|
+
...(args?.['all'] === true ? { all: true } : {}),
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* Everything `verify_start` / `preview_checkout` need, validated as one unit.
|
|
2458
|
+
*
|
|
2459
|
+
* The recipe travels on the frame rather than being read off disk here, and
|
|
2460
|
+
* that is the whole point of the design: the executable copy is the one a human
|
|
2461
|
+
* approved on the DevBridge side, not whatever the working tree says today. The
|
|
2462
|
+
* fingerprint is re-checked inside `VerifyRunner.start`, so a frame that lies
|
|
2463
|
+
* about which recipe it carries gets nowhere.
|
|
2464
|
+
*/
|
|
2465
|
+
function parseVerifyStart(args) {
|
|
2466
|
+
const runId = str(args?.['runId']);
|
|
2467
|
+
if (!runId || !/^[A-Za-z0-9_-]{8,64}$/.test(runId))
|
|
2468
|
+
return { error: 'A valid runId is required' };
|
|
2469
|
+
const recipeSha = str(args?.['recipeSha']);
|
|
2470
|
+
if (!recipeSha)
|
|
2471
|
+
return { error: 'recipeSha is required' };
|
|
2472
|
+
const cwd = str(args?.['cwd']);
|
|
2473
|
+
if (!cwd || !path.isAbsolute(cwd) || cwd.split('/').includes('..')) {
|
|
2474
|
+
return { error: 'cwd must be an absolute path without ".." segments' };
|
|
2475
|
+
}
|
|
2476
|
+
const parsed = parseProjectRecipe(args?.['recipe']);
|
|
2477
|
+
if (!parsed.ok)
|
|
2478
|
+
return { error: `The approved recipe is not valid: ${parsed.error}` };
|
|
2479
|
+
const targetRaw = args?.['target'];
|
|
2480
|
+
const target = targetRaw === 'BASE' || targetRaw === 'SESSION' || targetRaw === 'PREVIEW'
|
|
2481
|
+
? targetRaw
|
|
2482
|
+
: 'SESSION';
|
|
2483
|
+
const stepsRaw = args?.['steps'];
|
|
2484
|
+
const steps = Array.isArray(stepsRaw)
|
|
2485
|
+
? RECIPE_STEP_NAMES.filter((name) => stepsRaw.includes(name))
|
|
2486
|
+
: RECIPE_STEP_NAMES.filter((name) => name !== 'deploy');
|
|
2487
|
+
return {
|
|
2488
|
+
input: {
|
|
2489
|
+
runId,
|
|
2490
|
+
target,
|
|
2491
|
+
recipe: parsed.recipe,
|
|
2492
|
+
recipeSha,
|
|
2493
|
+
cwd,
|
|
2494
|
+
steps: [...steps],
|
|
2495
|
+
branch: str(args?.['branch']),
|
|
2496
|
+
commitSha: str(args?.['commitSha']),
|
|
2497
|
+
dirty: args?.['dirty'] === true,
|
|
2498
|
+
},
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
/** Stop a preview's own stack, inside its own worktree. */
|
|
2502
|
+
async function runPreviewStop(command, cwd) {
|
|
2503
|
+
if (!fs.existsSync(cwd))
|
|
2504
|
+
return;
|
|
2505
|
+
const result = await runOneOffCommand(command, cwd);
|
|
2506
|
+
if (!result.ok) {
|
|
2507
|
+
log.warn('preview: the stop command failed', { exitCode: result.exitCode });
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
1599
2510
|
function gitCommandPaths(args) {
|
|
1600
2511
|
const worktreePath = str(args?.['worktreePath']);
|
|
1601
2512
|
const workspacePath = str(args?.['workspacePath']);
|
|
@@ -1607,6 +2518,15 @@ function statusForReport(running) {
|
|
|
1607
2518
|
? 'RUNNING'
|
|
1608
2519
|
: running.lastReported;
|
|
1609
2520
|
}
|
|
2521
|
+
/**
|
|
2522
|
+
* What we say to an agent whose turn a runner restart cut off (session 18).
|
|
2523
|
+
*
|
|
2524
|
+
* Deliberately the same thing a human would type — the provider session is
|
|
2525
|
+
* resumed, so the agent still has the whole conversation and only needs to be
|
|
2526
|
+
* told to carry on. Spelling out WHY matters: without it the agent tends to
|
|
2527
|
+
* summarise what it had done instead of finishing it.
|
|
2528
|
+
*/
|
|
2529
|
+
const AUTO_RESUME_PROMPT = 'Your previous turn was cut off because the runner process restarted — not by me, and not because anything was wrong with the work. Continue from where you stopped. Re-check the state of anything you were in the middle of before redoing it.';
|
|
1610
2530
|
const AGENT_LABELS = { CLAUDE: 'Claude Code', CODEX: 'Codex' };
|
|
1611
2531
|
/** Only Claude reports USD — Codex sessions are bounded by time instead. */
|
|
1612
2532
|
function reportsCost(agent) {
|
|
@@ -1631,18 +2551,86 @@ function isTerminal(status) {
|
|
|
1631
2551
|
function isSettled(status) {
|
|
1632
2552
|
return isTerminal(status) || status === 'REVIEW';
|
|
1633
2553
|
}
|
|
2554
|
+
/**
|
|
2555
|
+
* The first message the agent gets.
|
|
2556
|
+
*
|
|
2557
|
+
* Two shapes, and the difference is whether tickets were handed over
|
|
2558
|
+
* (session 16, owner's decision):
|
|
2559
|
+
*
|
|
2560
|
+
* - **No tickets** — exactly what the human typed, and an empty prompt stays
|
|
2561
|
+
* empty. That is how you get a session that boots and waits for you to talk
|
|
2562
|
+
* first; it is a feature, not an oversight.
|
|
2563
|
+
* - **Tickets handed to a TICKET session** — the assignment is always sent and
|
|
2564
|
+
* cannot be removed: «Implement ticket #28.» Handing a ticket over IS the
|
|
2565
|
+
* instruction, so making the human retype it was ceremony. Anything they
|
|
2566
|
+
* typed follows underneath.
|
|
2567
|
+
*
|
|
2568
|
+
* What is NOT here any more: eight lines about which MCP tools to call and
|
|
2569
|
+
* which statuses to move through. That is a standing convention of the project,
|
|
2570
|
+
* true on the twentieth turn as much as the first, so it moved to the system
|
|
2571
|
+
* prompt beside `CLAUDE.md` — which is read last and therefore overrides ours.
|
|
2572
|
+
*/
|
|
1634
2573
|
export function composeInitialPrompt(descriptor) {
|
|
1635
|
-
|
|
2574
|
+
// A free chat may carry tickets as CONTEXT. Assigning them would put an agent
|
|
2575
|
+
// nobody asked onto somebody else's ticket (session 13).
|
|
2576
|
+
if (descriptor.kind === 'CHAT' || descriptor.tickets.length === 0)
|
|
1636
2577
|
return descriptor.prompt;
|
|
1637
|
-
const
|
|
1638
|
-
|
|
1639
|
-
`
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2578
|
+
const numbers = descriptor.tickets.map((t) => `#${t.number}`).join(', ');
|
|
2579
|
+
const assignment = descriptor.tickets.length === 1
|
|
2580
|
+
? `Implement ticket ${numbers}.`
|
|
2581
|
+
: `Implement tickets ${numbers}.`;
|
|
2582
|
+
return descriptor.prompt ? `${assignment}\n\n${descriptor.prompt}` : assignment;
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* Extra system-prompt material: where this session is in git, and how tickets
|
|
2586
|
+
* are meant to move (session 13).
|
|
2587
|
+
*
|
|
2588
|
+
* Facts about THIS SESSION, and nothing else. They exist in no file on disk —
|
|
2589
|
+
* which branch the agent is on, that it must not push, where a plan belongs,
|
|
2590
|
+
* what to do with the tickets it was given — so somebody has to say them, and
|
|
2591
|
+
* that somebody is us.
|
|
2592
|
+
*
|
|
2593
|
+
* The project's own documentation is deliberately NOT here. Both agents read
|
|
2594
|
+
* their own file natively, verified live: Claude picks up `CLAUDE.md` through
|
|
2595
|
+
* its memory mechanism (since `settingSources` includes `'project'`), and Codex
|
|
2596
|
+
* picks up `AGENTS.md` even under the runner's isolated `CODEX_HOME`. Pasting a
|
|
2597
|
+
* copy on top of that was work we were doing for no one — and when it was
|
|
2598
|
+
* switched off for Claude alone it briefly left repositories that carry only
|
|
2599
|
+
* `AGENTS.md` with nothing at all, which is precisely the kind of hole a
|
|
2600
|
+
* half-measure digs.
|
|
2601
|
+
*
|
|
2602
|
+
* A repository that wants both agents equipped ships both files, or symlinks
|
|
2603
|
+
* one to the other. That is a repository convention and not something a runner
|
|
2604
|
+
* should paper over.
|
|
2605
|
+
*/
|
|
2606
|
+
export function composeWorkspaceContext(descriptor) {
|
|
2607
|
+
const sections = [];
|
|
2608
|
+
const plan = descriptor.branchPlan;
|
|
2609
|
+
if (plan) {
|
|
2610
|
+
const forked = plan.baseBranch ? ` It was branched from \`${plan.baseBranch}\`.` : '';
|
|
2611
|
+
sections.push([
|
|
2612
|
+
'Git in this session:',
|
|
2613
|
+
`- You are in a dedicated worktree on branch \`${plan.branch}\`.${forked}`,
|
|
2614
|
+
'- Commit to this branch. Do not switch branches and do not merge into the base branch yourself — a human presses «Apply» in DevBridge, which squash-merges your branch for them.',
|
|
2615
|
+
'- You cannot push: `git push` is refused. A human presses «Push» when they want the branch on the remote.',
|
|
2616
|
+
`- Put a plan or working notes in \`docs/devbridge/${plan.branch.replace(/\//g, '-')}.md\`, unless this repository already has its own convention for where such documents live — if it does, follow that.`,
|
|
2617
|
+
].join('\n'));
|
|
2618
|
+
}
|
|
2619
|
+
// The ticket convention lives HERE, not in the first chat message (session
|
|
2620
|
+
// 16). It is a standing rule of the project — true on the twentieth turn as
|
|
2621
|
+
// much as the first — and putting it in the visible prompt only trained the
|
|
2622
|
+
// reader to skip past it. `CLAUDE.md` is appended after this section, so a
|
|
2623
|
+
// repository that states its own rule overrides ours by being read last.
|
|
2624
|
+
if (descriptor.kind !== 'CHAT' && descriptor.tickets.length > 0) {
|
|
2625
|
+
sections.push([
|
|
2626
|
+
'DevBridge tickets in this session:',
|
|
2627
|
+
'- Read the full ticket with the DevBridge MCP tools (`mcp__devbridge__*`) before starting — the title alone is never the whole task.',
|
|
2628
|
+
'- Move it to IN_PROGRESS while you work and to READY_FOR_REVIEW with a short summary comment when you are done.',
|
|
2629
|
+
].join('\n'));
|
|
2630
|
+
}
|
|
2631
|
+
else if (descriptor.tickets.length > 0) {
|
|
2632
|
+
sections.push('DevBridge tickets are attached to this chat for CONTEXT only. Read them with the DevBridge MCP tools; do not change their status — this session is not assigned to them.');
|
|
2633
|
+
}
|
|
2634
|
+
return sections.join('\n\n');
|
|
1647
2635
|
}
|
|
1648
2636
|
//# sourceMappingURL=supervisor.js.map
|