@bridge4dev/runner 0.13.1 → 0.22.1
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/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/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +402 -4
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -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 +7 -0
- package/dist/self-update.js +28 -1
- package/dist/service-unit.d.ts +48 -1
- package/dist/service-unit.js +109 -4
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- 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,8 +1,16 @@
|
|
|
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
15
|
import { agentAuthStatuses, AuthRelay } from './auth-relay.js';
|
|
8
16
|
import { selfUpdate } from './self-update.js';
|
|
@@ -30,14 +38,46 @@ export class Supervisor {
|
|
|
30
38
|
repoLocks = new Map();
|
|
31
39
|
/** An update is installing right now — a second one would fight it. */
|
|
32
40
|
selfUpdateInFlight = false;
|
|
41
|
+
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
42
|
+
verify;
|
|
43
|
+
verifyReports = new VerifyReportQueue();
|
|
33
44
|
constructor(ws, opts) {
|
|
34
45
|
this.ws = ws;
|
|
35
46
|
this.opts = opts;
|
|
36
47
|
this.journals = opts.journals ?? new JournalStore();
|
|
48
|
+
this.verify = new VerifyRunner({
|
|
49
|
+
enabled: opts.verifyEnabled !== false,
|
|
50
|
+
onReport: (report) => {
|
|
51
|
+
// Queue FIRST, then try the wire. The other order loses the verdict of
|
|
52
|
+
// every build that finishes while the socket happens to be down — and
|
|
53
|
+
// a build finishing during a reconnect is not a rare case, it is a
|
|
54
|
+
// twenty-minute window.
|
|
55
|
+
this.verifyReports.add(report);
|
|
56
|
+
this.flushVerifyReports();
|
|
57
|
+
},
|
|
58
|
+
});
|
|
37
59
|
ws.on('frame', (frame) => {
|
|
38
60
|
void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
|
|
39
61
|
});
|
|
40
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Push every unacked verdict at the API.
|
|
65
|
+
*
|
|
66
|
+
* Called on each reconnect and whenever a run finishes. Sending a report the
|
|
67
|
+
* API already has is harmless — it is keyed by `runId` and stored idempotently
|
|
68
|
+
* — while not sending one is a verdict that never existed.
|
|
69
|
+
*/
|
|
70
|
+
flushVerifyReports() {
|
|
71
|
+
for (const report of this.verifyReports.pending()) {
|
|
72
|
+
const sent = this.ws.send({
|
|
73
|
+
type: 'verify_report',
|
|
74
|
+
report: report,
|
|
75
|
+
});
|
|
76
|
+
if (!sent)
|
|
77
|
+
break;
|
|
78
|
+
this.verifyReports.markAttempted(report.runId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
41
81
|
get activeSessionIds() {
|
|
42
82
|
return [...this.sessions.keys()];
|
|
43
83
|
}
|
|
@@ -46,10 +86,42 @@ export class Supervisor {
|
|
|
46
86
|
case 'hello_ack':
|
|
47
87
|
this.setMaxSessions(frame.maxSessions);
|
|
48
88
|
await this.reconcile(frame.sessions);
|
|
89
|
+
// A build that finished while the socket was down has its verdict
|
|
90
|
+
// sitting on disk. This is the moment it can be delivered.
|
|
91
|
+
this.flushVerifyReports();
|
|
92
|
+
break;
|
|
93
|
+
case 'verify_report_ack':
|
|
94
|
+
this.verifyReports.ack(frame.runId);
|
|
49
95
|
break;
|
|
50
96
|
case 'server_settings':
|
|
51
97
|
this.setMaxSessions(frame.maxSessions);
|
|
52
98
|
break;
|
|
99
|
+
/**
|
|
100
|
+
* Session 15: the project's trust level or auto-commit switch changed
|
|
101
|
+
* while sessions are running. Applied live — both are read on every tool
|
|
102
|
+
* call — because the direction that matters is the TIGHTENING one: a
|
|
103
|
+
* manager switching a project to STRICT and being told it is STRICT while
|
|
104
|
+
* an agent keeps running under AUTO is the worst version of this control.
|
|
105
|
+
*/
|
|
106
|
+
case 'workspace_settings': {
|
|
107
|
+
for (const running of this.sessions.values()) {
|
|
108
|
+
if (running.descriptor.workspace.id !== frame.workspaceId)
|
|
109
|
+
continue;
|
|
110
|
+
if (frame.trustMode !== undefined) {
|
|
111
|
+
running.descriptor.workspace.trustMode = frame.trustMode;
|
|
112
|
+
}
|
|
113
|
+
if (frame.agentAutoCommit !== undefined) {
|
|
114
|
+
running.descriptor.workspace.agentAutoCommit = frame.agentAutoCommit;
|
|
115
|
+
}
|
|
116
|
+
running.session?.setWorkspacePolicy({
|
|
117
|
+
...(frame.trustMode !== undefined ? { trustMode: frame.trustMode } : {}),
|
|
118
|
+
...(frame.agentAutoCommit !== undefined
|
|
119
|
+
? { agentAutoCommit: frame.agentAutoCommit }
|
|
120
|
+
: {}),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
53
125
|
case 'session_start':
|
|
54
126
|
await this.startSession(frame.session);
|
|
55
127
|
break;
|
|
@@ -61,6 +133,9 @@ export class Supervisor {
|
|
|
61
133
|
running?.session?.answerPermission(frame.requestId, frame.allow, frame.note);
|
|
62
134
|
break;
|
|
63
135
|
}
|
|
136
|
+
case 'question_answer':
|
|
137
|
+
this.onQuestionAnswer(frame);
|
|
138
|
+
break;
|
|
64
139
|
case 'session_stop':
|
|
65
140
|
this.stopSession(frame.sessionId);
|
|
66
141
|
break;
|
|
@@ -108,7 +183,7 @@ export class Supervisor {
|
|
|
108
183
|
if (descriptor.epoch > existing.epoch) {
|
|
109
184
|
existing.pendingRestart = descriptor;
|
|
110
185
|
existing.stopRequested = true;
|
|
111
|
-
existing.session?.stop();
|
|
186
|
+
existing.session?.stop('session_stopped');
|
|
112
187
|
}
|
|
113
188
|
return;
|
|
114
189
|
}
|
|
@@ -118,9 +193,13 @@ export class Supervisor {
|
|
|
118
193
|
if (!this.ensureCapacity(descriptor.id)) {
|
|
119
194
|
const limit = this.maxSessions;
|
|
120
195
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
121
|
-
errorMessage: limit === 1
|
|
196
|
+
errorMessage: (limit === 1
|
|
122
197
|
? 'Runner already has an active session'
|
|
123
|
-
: `Runner is already running ${limit} active sessions — stop one first
|
|
198
|
+
: `Runner is already running ${limit} active sessions — stop one first`) +
|
|
199
|
+
// Since session 12 a session with an open question holds its slot on
|
|
200
|
+
// purpose — so "finish the turn" is not something waiting will fix,
|
|
201
|
+
// and the message has to say what actually frees it.
|
|
202
|
+
this.waitingForAnswerSuffix(descriptor.id),
|
|
124
203
|
});
|
|
125
204
|
return;
|
|
126
205
|
}
|
|
@@ -143,6 +222,7 @@ export class Supervisor {
|
|
|
143
222
|
activeMs: descriptor.activeMsBase,
|
|
144
223
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
145
224
|
epoch: descriptor.epoch,
|
|
225
|
+
openQuestions: new Set(),
|
|
146
226
|
mode: descriptor.mode,
|
|
147
227
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
148
228
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -166,13 +246,15 @@ export class Supervisor {
|
|
|
166
246
|
}
|
|
167
247
|
}
|
|
168
248
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
249
|
+
const prepared = await this.prepareWorkspace(descriptor);
|
|
250
|
+
running.branch = prepared.branch;
|
|
251
|
+
running.worktreePath = prepared.worktreePath;
|
|
252
|
+
// Only when WE created the branch: the fork point is a fact the API can
|
|
253
|
+
// learn nowhere else, and it pins the first answer it gets.
|
|
254
|
+
if (prepared.baseSha)
|
|
255
|
+
running.baseSha = prepared.baseSha;
|
|
256
|
+
if (prepared.baseBranch)
|
|
257
|
+
running.baseBranch = prepared.baseBranch;
|
|
176
258
|
}
|
|
177
259
|
catch (error) {
|
|
178
260
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
@@ -204,6 +286,25 @@ export class Supervisor {
|
|
|
204
286
|
}
|
|
205
287
|
this.flushPendingMessages(running);
|
|
206
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Where this session is going to work — a worktree of its own, or the project
|
|
291
|
+
* folder itself (session 16).
|
|
292
|
+
*
|
|
293
|
+
* The BRANCH path takes the repo lock because `worktree add` writes into the
|
|
294
|
+
* shared `.git` (registration + prune), exactly like commit/apply/revert. The
|
|
295
|
+
* DIRECT path takes none: it only READS which branch a folder is on, and
|
|
296
|
+
* queueing every session start behind whatever merge happens to be running
|
|
297
|
+
* would be a lock bought for nothing.
|
|
298
|
+
*/
|
|
299
|
+
async prepareWorkspace(descriptor) {
|
|
300
|
+
if (descriptor.workMode === 'DIRECT') {
|
|
301
|
+
return prepareDirectWorkspace(descriptor.workspace.path);
|
|
302
|
+
}
|
|
303
|
+
return this.withRepoLockFor(descriptor.workspace.path, () => ensureSessionWorktree(descriptor.workspace.path, descriptor.id, descriptor.branchHint, {
|
|
304
|
+
requireExistingBranch: hasWorkToResume(descriptor),
|
|
305
|
+
...(descriptor.branchPlan ? { plan: descriptor.branchPlan } : {}),
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
207
308
|
/**
|
|
208
309
|
* Spin the adapter up — for a fresh session, a resume-on-next-message, or a
|
|
209
310
|
* free CHAT session with no prompt at all (the agent boots, reports its
|
|
@@ -246,16 +347,23 @@ export class Supervisor {
|
|
|
246
347
|
if (running.budgetSpent) {
|
|
247
348
|
this.sendEvent(running, 'notice', {
|
|
248
349
|
level: 'warn',
|
|
249
|
-
text: 'The time budget is used up — press
|
|
350
|
+
text: 'The time budget is used up — press «Continue» to give the agent more time.',
|
|
250
351
|
});
|
|
251
352
|
return false;
|
|
252
353
|
}
|
|
253
354
|
running.lastPrompt = prompt;
|
|
355
|
+
// Facts about this session only. The project's own documentation is read by
|
|
356
|
+
// each agent itself — see `composeWorkspaceContext`.
|
|
357
|
+
const workspaceContext = composeWorkspaceContext(descriptor);
|
|
254
358
|
running.session = adapter.startSession({
|
|
255
359
|
sessionId: descriptor.id,
|
|
256
360
|
cwd: running.worktreePath,
|
|
257
361
|
...(prompt ? { prompt } : {}),
|
|
362
|
+
...(workspaceContext ? { workspaceContext } : {}),
|
|
258
363
|
trustMode: descriptor.workspace.trustMode,
|
|
364
|
+
...(descriptor.workspace.agentAutoCommit === undefined
|
|
365
|
+
? {}
|
|
366
|
+
: { agentAutoCommit: descriptor.workspace.agentAutoCommit }),
|
|
259
367
|
mode: running.mode,
|
|
260
368
|
...(running.model ? { model: running.model } : {}),
|
|
261
369
|
...(running.effort ? { effort: running.effort } : {}),
|
|
@@ -395,16 +503,17 @@ export class Supervisor {
|
|
|
395
503
|
void Promise.resolve(running.session?.interrupt()).catch(() => undefined);
|
|
396
504
|
this.sendEvent(running, 'notice', {
|
|
397
505
|
level: 'warn',
|
|
398
|
-
text: `The agent worked for its full ${minutes} minutes and was paused. Press
|
|
506
|
+
text: `The agent worked for its full ${minutes} minutes and was paused. Press «Continue» to give it more time.`,
|
|
399
507
|
});
|
|
400
508
|
running.stopRequested = true;
|
|
509
|
+
this.withdrawOpenQuestions(running, 'budget_spent');
|
|
401
510
|
this.reportStatus(descriptor.id, 'STOPPED', {
|
|
402
511
|
costUsd: running.costUsd,
|
|
403
512
|
endReason: 'TIME_BUDGET',
|
|
404
513
|
activeMs: running.activeMs,
|
|
405
514
|
errorMessage: `Time budget (${minutes} min of agent work) reached`,
|
|
406
515
|
});
|
|
407
|
-
running.session?.stop();
|
|
516
|
+
running.session?.stop('budget_spent');
|
|
408
517
|
}
|
|
409
518
|
async pumpEvents(running) {
|
|
410
519
|
const session = running.session;
|
|
@@ -425,6 +534,21 @@ export class Supervisor {
|
|
|
425
534
|
// Stream ended — the agent process is gone.
|
|
426
535
|
if (running.session !== session)
|
|
427
536
|
return; // superseded (shouldn't happen in v0)
|
|
537
|
+
// Backstop for a process that died without going through `stop()` (a crash,
|
|
538
|
+
// an SDK error, an agent that exited mid-question): the adapter never got
|
|
539
|
+
// to withdraw its cards, and a card nobody can answer must not stay live.
|
|
540
|
+
// Guarded like the event loop above — a journal that cannot be written (a
|
|
541
|
+
// full disk, a daemon already shutting down) must not take the exit path
|
|
542
|
+
// with it, because everything below it is cleanup.
|
|
543
|
+
try {
|
|
544
|
+
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
log.warn('supervisor: could not withdraw open questions', {
|
|
548
|
+
sessionId: descriptor.id,
|
|
549
|
+
error: String(error),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
428
552
|
// The process is down, so nothing is billable any more regardless of the
|
|
429
553
|
// last status we reported.
|
|
430
554
|
if (running.activeSince !== undefined) {
|
|
@@ -515,6 +639,46 @@ export class Supervisor {
|
|
|
515
639
|
// A slot just came free — hand it to whoever was told to wait for one.
|
|
516
640
|
this.drainSessionsWaitingForCapacity();
|
|
517
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* "…and N of them are waiting for an answer from you."
|
|
644
|
+
*
|
|
645
|
+
* An open question pins its slot deliberately (`isParkable`), so a message
|
|
646
|
+
* that blames a running turn sends the user to wait for something that will
|
|
647
|
+
* never happen. Empty when nothing is waiting.
|
|
648
|
+
*/
|
|
649
|
+
waitingForAnswerSuffix(exceptSessionId) {
|
|
650
|
+
let waiting = 0;
|
|
651
|
+
for (const running of this.sessions.values()) {
|
|
652
|
+
if (running.descriptor.id === exceptSessionId)
|
|
653
|
+
continue;
|
|
654
|
+
if (running.openQuestions.size > 0)
|
|
655
|
+
waiting++;
|
|
656
|
+
}
|
|
657
|
+
if (waiting === 0)
|
|
658
|
+
return '';
|
|
659
|
+
return waiting === 1
|
|
660
|
+
? ' — one of them is waiting for your answer to a question'
|
|
661
|
+
: ` — ${waiting} of them are waiting for your answer to a question`;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Close out every ask this session still has open, with a stated cause.
|
|
665
|
+
*
|
|
666
|
+
* Idempotent: the adapter reports its own `question_resolved` when it can, and
|
|
667
|
+
* `forwardEvent` clears the id — so by the time this runs the set is usually
|
|
668
|
+
* already empty. What it catches is the path where the adapter never got the
|
|
669
|
+
* chance, and the alternative there is a card that stays clickable forever.
|
|
670
|
+
*/
|
|
671
|
+
withdrawOpenQuestions(running, reason) {
|
|
672
|
+
for (const askId of [...running.openQuestions]) {
|
|
673
|
+
running.openQuestions.delete(askId);
|
|
674
|
+
this.sendEvent(running, 'question_resolved', {
|
|
675
|
+
askId,
|
|
676
|
+
outcome: 'invalidated',
|
|
677
|
+
source: 'runner',
|
|
678
|
+
reason,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
518
682
|
/**
|
|
519
683
|
* Deliver messages that were held because every slot was taken.
|
|
520
684
|
*
|
|
@@ -612,9 +776,18 @@ export class Supervisor {
|
|
|
612
776
|
}
|
|
613
777
|
return this.liveSessionCount(exceptSessionId) < limit;
|
|
614
778
|
}
|
|
615
|
-
/**
|
|
779
|
+
/**
|
|
780
|
+
* Idle after a finished turn — safe to kill the process and resume later.
|
|
781
|
+
*
|
|
782
|
+
* An open question is the exception (session 12): WAITING_INPUT there does
|
|
783
|
+
* NOT mean "the turn is over", it means the agent's tool call is parked on a
|
|
784
|
+
* human. Parking such a session killed a live turn — and, worse, killed the
|
|
785
|
+
* card the user was about to answer — the moment another session wanted a
|
|
786
|
+
* slot.
|
|
787
|
+
*/
|
|
616
788
|
isParkable(running) {
|
|
617
789
|
return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
|
|
790
|
+
running.openQuestions.size === 0 &&
|
|
618
791
|
Boolean(running.descriptor.providerSessionId));
|
|
619
792
|
}
|
|
620
793
|
park(running) {
|
|
@@ -624,7 +797,7 @@ export class Supervisor {
|
|
|
624
797
|
this.sendEvent(running, 'system_note', {
|
|
625
798
|
text: 'Session parked — the runner switched to another session. Send a message to resume.',
|
|
626
799
|
});
|
|
627
|
-
running.session.stop();
|
|
800
|
+
running.session.stop('session_parked');
|
|
628
801
|
}
|
|
629
802
|
forwardEvent(running, event) {
|
|
630
803
|
const { descriptor } = running;
|
|
@@ -740,20 +913,66 @@ export class Supervisor {
|
|
|
740
913
|
return;
|
|
741
914
|
case 'question':
|
|
742
915
|
// The API maps `question` to WAITING_INPUT and notifies the user. The
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
|
|
916
|
+
// clock stops with it: since session 12 the agent's tool call is PARKED
|
|
917
|
+
// on this question, so the agent is genuinely idle — billing the wait
|
|
918
|
+
// is the bug session 7 removed (five of the first twelve prod sessions
|
|
919
|
+
// died having spent their budget waiting for a human).
|
|
920
|
+
running.openQuestions.add(event.askId);
|
|
921
|
+
this.sendEvent(running, 'question', {
|
|
922
|
+
askId: event.askId,
|
|
923
|
+
questions: event.questions,
|
|
924
|
+
// Mirror fields — an API/dashboard mid-deploy still draws a card.
|
|
925
|
+
text: event.text,
|
|
926
|
+
options: event.options,
|
|
927
|
+
});
|
|
747
928
|
running.lastReported = 'WAITING_INPUT';
|
|
748
929
|
this.syncBudgetClock(running);
|
|
749
930
|
return;
|
|
750
|
-
case '
|
|
931
|
+
case 'question_resolved':
|
|
932
|
+
running.openQuestions.delete(event.askId);
|
|
933
|
+
this.sendEvent(running, 'question_resolved', {
|
|
934
|
+
askId: event.askId,
|
|
935
|
+
outcome: event.outcome,
|
|
936
|
+
source: event.source,
|
|
937
|
+
reason: event.reason,
|
|
938
|
+
answers: event.answers,
|
|
939
|
+
summary: event.summary,
|
|
940
|
+
});
|
|
941
|
+
// The human answered — the parked tool call is released and the agent
|
|
942
|
+
// is working again, so the clock starts. Same shape as the permission
|
|
943
|
+
// path above, and guarded the same way so a terminal status wins.
|
|
944
|
+
// `openQuestions.size` matters: both agents can park several asks at
|
|
945
|
+
// once, and reporting RUNNING while another card is still waiting would
|
|
946
|
+
// bill a human's thinking time all over again (QA-106 M4).
|
|
947
|
+
if (event.source === 'user' &&
|
|
948
|
+
running.openQuestions.size === 0 &&
|
|
949
|
+
running.lastReported === 'WAITING_INPUT') {
|
|
950
|
+
running.lastReported = 'RUNNING';
|
|
951
|
+
this.reportStatus(descriptor.id, 'RUNNING', {});
|
|
952
|
+
}
|
|
953
|
+
return;
|
|
954
|
+
case 'capabilities': {
|
|
955
|
+
// The ADAPTER is the authority on which model is selected — it has the
|
|
956
|
+
// live catalogue and has already resolved the wire id the agent reports
|
|
957
|
+
// back to the row it belongs to (ticket #111). `running.model` is only
|
|
958
|
+
// what was asked for at launch, and it is routinely a name no row in
|
|
959
|
+
// that catalogue carries: sessions whose column was written from an
|
|
960
|
+
// older capabilities event hold a resolved id like `claude-opus-5[1m]`,
|
|
961
|
+
// which the dashboard can match to nothing — so it drew the raw id in
|
|
962
|
+
// the model picker and, because effort levels hang off the selected
|
|
963
|
+
// row, no effort control at all.
|
|
964
|
+
const currentModel = event.capabilities.currentModel ?? running.model;
|
|
965
|
+
// Adopt it, so the next descriptor and the session column carry a name
|
|
966
|
+
// the picker can find instead of re-poisoning them from the old value.
|
|
967
|
+
if (currentModel)
|
|
968
|
+
running.model = currentModel;
|
|
751
969
|
this.sendEvent(running, 'capabilities', {
|
|
752
970
|
...event.capabilities,
|
|
753
971
|
currentMode: running.mode,
|
|
754
|
-
...(
|
|
972
|
+
...(currentModel ? { currentModel } : {}),
|
|
755
973
|
});
|
|
756
974
|
return;
|
|
975
|
+
}
|
|
757
976
|
case 'settings':
|
|
758
977
|
if (event.mode)
|
|
759
978
|
running.mode = event.mode;
|
|
@@ -777,6 +996,16 @@ export class Supervisor {
|
|
|
777
996
|
maxTokens: event.maxTokens,
|
|
778
997
|
});
|
|
779
998
|
return;
|
|
999
|
+
case 'agent_tasks':
|
|
1000
|
+
// Ticket #113. A LEVEL signal: every frame carries the whole live set,
|
|
1001
|
+
// so the dashboard replaces rather than reconciles and a dropped frame
|
|
1002
|
+
// cannot leave a finished subagent spinning in the tray forever.
|
|
1003
|
+
this.sendEvent(running, 'agent_tasks', {
|
|
1004
|
+
tasks: event.tasks,
|
|
1005
|
+
done: event.done,
|
|
1006
|
+
total: event.total,
|
|
1007
|
+
});
|
|
1008
|
+
return;
|
|
780
1009
|
case 'notice': {
|
|
781
1010
|
// Only adapter notices are de-duplicated here. The supervisor's own
|
|
782
1011
|
// notices (turn interrupted, session parked, budget warnings) go
|
|
@@ -802,6 +1031,59 @@ export class Supervisor {
|
|
|
802
1031
|
return;
|
|
803
1032
|
}
|
|
804
1033
|
}
|
|
1034
|
+
/**
|
|
1035
|
+
* The dashboard's answer to a parked question (session 12).
|
|
1036
|
+
*
|
|
1037
|
+
* A miss is reported in the feed rather than swallowed: the three cases that
|
|
1038
|
+
* get here — a card from a previous life of the session, a second click, an
|
|
1039
|
+
* ask the runner already withdrew — all look identical to the user unless
|
|
1040
|
+
* somebody says so.
|
|
1041
|
+
*/
|
|
1042
|
+
onQuestionAnswer(frame) {
|
|
1043
|
+
const running = this.sessions.get(frame.sessionId);
|
|
1044
|
+
if (!running) {
|
|
1045
|
+
log.warn('supervisor: question answer for unknown session', { sessionId: frame.sessionId });
|
|
1046
|
+
this.ws.send({ type: 'session_unknown', sessionId: frame.sessionId });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const typed = frame.text?.trim();
|
|
1050
|
+
// Asked first, echoed second: the adapter resolves synchronously but its
|
|
1051
|
+
// own events reach the feed a microtask later, so the user's words still
|
|
1052
|
+
// land before the agent's reaction — and on a miss we can hand the text to
|
|
1053
|
+
// the ordinary delivery path, which does the echo itself.
|
|
1054
|
+
const accepted = running.session?.answerQuestion({
|
|
1055
|
+
askId: frame.askId,
|
|
1056
|
+
action: frame.action,
|
|
1057
|
+
...(frame.answers ? { answers: frame.answers } : {}),
|
|
1058
|
+
...(frame.text !== undefined ? { text: frame.text } : {}),
|
|
1059
|
+
});
|
|
1060
|
+
if (accepted) {
|
|
1061
|
+
// What the user typed belongs in the conversation, whichever exit they took.
|
|
1062
|
+
if (typed)
|
|
1063
|
+
this.sendEvent(running, 'message', { role: 'user', text: typed });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
running.openQuestions.delete(frame.askId);
|
|
1067
|
+
this.sendEvent(running, 'system_note', {
|
|
1068
|
+
text: typed
|
|
1069
|
+
? 'That question is no longer open — sending your reply as an ordinary message instead.'
|
|
1070
|
+
: 'That question is no longer open — the agent has already moved on.',
|
|
1071
|
+
});
|
|
1072
|
+
// The words must not be eaten. This is the ordinary case after a runner
|
|
1073
|
+
// restart: the card in the browser outlived the process that asked, and
|
|
1074
|
+
// showing the user's own message in the feed while nothing receives it is
|
|
1075
|
+
// the exact failure `onUserMessage` was hardened against (QA-106 M2).
|
|
1076
|
+
if (typed) {
|
|
1077
|
+
void this.onUserMessage(frame.sessionId, typed).catch((error) => log.error('supervisor: could not deliver a reply to a closed question', {
|
|
1078
|
+
sessionId: frame.sessionId,
|
|
1079
|
+
error: String(error),
|
|
1080
|
+
}));
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
// Nothing to deliver — but the API optimistically flipped the session to
|
|
1084
|
+
// RUNNING when it relayed the answer, so put the real status back.
|
|
1085
|
+
this.reportStatus(frame.sessionId, statusForReport(running), {});
|
|
1086
|
+
}
|
|
805
1087
|
async onUserMessage(sessionId, text, attachments) {
|
|
806
1088
|
const running = this.sessions.get(sessionId);
|
|
807
1089
|
if (!running) {
|
|
@@ -932,9 +1214,10 @@ export class Supervisor {
|
|
|
932
1214
|
// Parked session: the follow-up message becomes the resume prompt.
|
|
933
1215
|
if (!this.ensureCapacity(running.descriptor.id)) {
|
|
934
1216
|
this.sendEvent(running, 'system_note', {
|
|
935
|
-
text: this.maxSessions === 1
|
|
1217
|
+
text: (this.maxSessions === 1
|
|
936
1218
|
? '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
|
|
1219
|
+
: `The runner is busy with ${this.maxSessions} other sessions — this one continues as soon as one of them finishes its turn.`) +
|
|
1220
|
+
this.waitingForAnswerSuffix(running.descriptor.id),
|
|
938
1221
|
});
|
|
939
1222
|
// The message is already in the feed; keep it so the resume actually
|
|
940
1223
|
// carries it once a slot frees up, instead of the user's instruction
|
|
@@ -943,6 +1226,9 @@ export class Supervisor {
|
|
|
943
1226
|
running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
|
|
944
1227
|
return;
|
|
945
1228
|
}
|
|
1229
|
+
// A person typing into the session is the clearest signal that the work is
|
|
1230
|
+
// back on track, so the automatic-continuation allowance starts over.
|
|
1231
|
+
clearAutoResume(running.descriptor.id);
|
|
946
1232
|
if (this.launchAgent(running, text, running.descriptor.providerSessionId)) {
|
|
947
1233
|
settle();
|
|
948
1234
|
return;
|
|
@@ -994,6 +1280,12 @@ export class Supervisor {
|
|
|
994
1280
|
const running = this.sessions.get(sessionId);
|
|
995
1281
|
if (!running?.session)
|
|
996
1282
|
return;
|
|
1283
|
+
// The turn being interrupted is the turn the question belongs to — leaving
|
|
1284
|
+
// the ask parked would make the user's next message be swallowed as its
|
|
1285
|
+
// answer (QA-106 m7). The adapter reports each withdrawal itself; the local
|
|
1286
|
+
// set is cleared so the session can be parked again.
|
|
1287
|
+
running.session.cancelQuestions('turn_aborted');
|
|
1288
|
+
running.openQuestions.clear();
|
|
997
1289
|
await running.session.interrupt();
|
|
998
1290
|
this.sendEvent(running, 'notice', { level: 'info', text: 'Turn interrupted by the user' });
|
|
999
1291
|
const next = running.descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
|
|
@@ -1045,9 +1337,10 @@ export class Supervisor {
|
|
|
1045
1337
|
running.stopRequested = true;
|
|
1046
1338
|
this.clearBudgetTimers(running);
|
|
1047
1339
|
if (running.session) {
|
|
1048
|
-
running.session.stop(); // pumpEvents finishes the cleanup
|
|
1340
|
+
running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
|
|
1049
1341
|
}
|
|
1050
1342
|
else {
|
|
1343
|
+
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
1051
1344
|
this.reportStatus(sessionId, 'STOPPED', { activeMs: running.activeMs });
|
|
1052
1345
|
this.sessions.delete(sessionId);
|
|
1053
1346
|
if (this.ws.connected && running.journal.unacked().length === 0) {
|
|
@@ -1068,9 +1361,10 @@ export class Supervisor {
|
|
|
1068
1361
|
running.stopRequested = true;
|
|
1069
1362
|
this.clearBudgetTimers(running);
|
|
1070
1363
|
if (running.session) {
|
|
1071
|
-
running.session.stop(); // pumpEvents finishes the cleanup
|
|
1364
|
+
running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
|
|
1072
1365
|
}
|
|
1073
1366
|
else {
|
|
1367
|
+
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
1074
1368
|
this.sessions.delete(sessionId);
|
|
1075
1369
|
}
|
|
1076
1370
|
}
|
|
@@ -1120,8 +1414,13 @@ export class Supervisor {
|
|
|
1120
1414
|
// Only replay a terminal status from THIS life of the session. A
|
|
1121
1415
|
// resumed session carries a higher epoch, and replaying the FAILED it
|
|
1122
1416
|
// was resumed from would kill it again the moment the runner reconnects.
|
|
1417
|
+
// A journal written by a runner from before session 13 could hold a
|
|
1418
|
+
// `DONE` — it is no longer a status this runner may report, and
|
|
1419
|
+
// replaying one would close the session for the user. Drop it and let
|
|
1420
|
+
// the session be picked back up like any other.
|
|
1123
1421
|
if (last &&
|
|
1124
1422
|
isTerminal(last.status) &&
|
|
1423
|
+
last.status !== 'DONE' &&
|
|
1125
1424
|
(last.epoch ?? 0) >= descriptor.epoch) {
|
|
1126
1425
|
this.ws.send({
|
|
1127
1426
|
type: 'session_status',
|
|
@@ -1160,6 +1459,7 @@ export class Supervisor {
|
|
|
1160
1459
|
activeMs: descriptor.activeMsBase,
|
|
1161
1460
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
1162
1461
|
epoch: descriptor.epoch,
|
|
1462
|
+
openQuestions: new Set(),
|
|
1163
1463
|
mode: descriptor.mode,
|
|
1164
1464
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
1165
1465
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -1170,9 +1470,16 @@ export class Supervisor {
|
|
|
1170
1470
|
running.pendingMessages.push(...running.journal.pending());
|
|
1171
1471
|
this.sessions.set(descriptor.id, running);
|
|
1172
1472
|
try {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1473
|
+
// A session being restored after a runner restart already has its
|
|
1474
|
+
// branch, so the API sends `CONTINUE` — the NEW guard inside would
|
|
1475
|
+
// otherwise fire on the runner's own previous work.
|
|
1476
|
+
const prepared = await this.prepareWorkspace(descriptor);
|
|
1477
|
+
running.branch = prepared.branch;
|
|
1478
|
+
running.worktreePath = prepared.worktreePath;
|
|
1479
|
+
if (prepared.baseSha)
|
|
1480
|
+
running.baseSha = prepared.baseSha;
|
|
1481
|
+
if (prepared.baseBranch)
|
|
1482
|
+
running.baseBranch = prepared.baseBranch;
|
|
1176
1483
|
}
|
|
1177
1484
|
catch (error) {
|
|
1178
1485
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
@@ -1181,9 +1488,43 @@ export class Supervisor {
|
|
|
1181
1488
|
this.sessions.delete(descriptor.id);
|
|
1182
1489
|
continue;
|
|
1183
1490
|
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Was a turn actually in flight when the process died?
|
|
1493
|
+
*
|
|
1494
|
+
* `RUNNING` and `WAITING_PERMISSION` are the mid-turn statuses; the
|
|
1495
|
+
* others mean the agent was already waiting for a human, and there is
|
|
1496
|
+
* nothing to continue. REVIEW is deliberately excluded — the work is
|
|
1497
|
+
* finished and waiting to be looked at.
|
|
1498
|
+
*/
|
|
1499
|
+
const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
|
|
1500
|
+
const resumeId = descriptor.providerSessionId;
|
|
1501
|
+
const willContinue = wasMidTurn && claimAutoResume(descriptor.id);
|
|
1502
|
+
// The note stays either way (owner's call): an interruption is a fact
|
|
1503
|
+
// about the session and must not disappear just because we recovered
|
|
1504
|
+
// from it. Only the instruction at the end changes — telling someone to
|
|
1505
|
+
// send a message while the agent is already working again would be a lie.
|
|
1184
1506
|
this.sendEvent(running, 'system_note', {
|
|
1185
|
-
text:
|
|
1507
|
+
text: willContinue
|
|
1508
|
+
? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
|
|
1509
|
+
: 'Runner reconnected. The session was resumed — send a message to continue.',
|
|
1186
1510
|
});
|
|
1511
|
+
if (willContinue) {
|
|
1512
|
+
// Resumed through the PROVIDER session, so the agent keeps its whole
|
|
1513
|
+
// conversation; the prompt is only the nudge a human would otherwise
|
|
1514
|
+
// have to type. Exactly what «продолжай» did by hand — no new class of
|
|
1515
|
+
// risk, and the same ceiling protects against a crash loop doing it
|
|
1516
|
+
// forever (see `auto-resume.ts`).
|
|
1517
|
+
if (this.launchAgent(running, AUTO_RESUME_PROMPT, resumeId)) {
|
|
1518
|
+
this.reportStatus(descriptor.id, 'RUNNING', {});
|
|
1519
|
+
this.flushPendingMessages(running);
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
// Could not start (an exhausted budget is the only way here). Fall
|
|
1523
|
+
// through to the old behaviour and say so honestly.
|
|
1524
|
+
this.sendEvent(running, 'system_note', {
|
|
1525
|
+
text: 'Could not continue automatically — send a message to pick the work back up.',
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1187
1528
|
// REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
|
|
1188
1529
|
// mid-turn statuses are downgraded to "waiting for the user".
|
|
1189
1530
|
if (descriptor.status !== 'REVIEW') {
|
|
@@ -1214,6 +1555,7 @@ export class Supervisor {
|
|
|
1214
1555
|
*/
|
|
1215
1556
|
pruneJournals() {
|
|
1216
1557
|
try {
|
|
1558
|
+
pruneAutoResume(new Set(this.sessions.keys()));
|
|
1217
1559
|
const removed = this.journals.prune({
|
|
1218
1560
|
maxAgeMs: Supervisor.JOURNAL_TTL_MS,
|
|
1219
1561
|
hardMaxAgeMs: Supervisor.JOURNAL_HARD_TTL_MS,
|
|
@@ -1300,7 +1642,17 @@ export class Supervisor {
|
|
|
1300
1642
|
});
|
|
1301
1643
|
return void reply({
|
|
1302
1644
|
ok: true,
|
|
1303
|
-
result: await gitStatus(
|
|
1645
|
+
result: await gitStatus({
|
|
1646
|
+
worktreePath: paths.worktreePath,
|
|
1647
|
+
workspacePath: paths.workspacePath,
|
|
1648
|
+
sessionBranch: paths.branch,
|
|
1649
|
+
...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
|
|
1650
|
+
...optionalSha(frame.args?.['appliedBranchSha'], 'appliedBranchSha'),
|
|
1651
|
+
...optionalSha(frame.args?.['pushedSha'], 'pushedSha'),
|
|
1652
|
+
// Session 16: a DIRECT session has no base to be measured
|
|
1653
|
+
// against — the folder's own branch is where the work is.
|
|
1654
|
+
...(frame.args?.['direct'] === true ? { direct: true } : {}),
|
|
1655
|
+
}),
|
|
1304
1656
|
});
|
|
1305
1657
|
}
|
|
1306
1658
|
case 'git_diff': {
|
|
@@ -1314,7 +1666,10 @@ export class Supervisor {
|
|
|
1314
1666
|
}
|
|
1315
1667
|
return void reply({
|
|
1316
1668
|
ok: true,
|
|
1317
|
-
result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath),
|
|
1669
|
+
result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath, str(frame.args?.['baseBranch']) ?? undefined,
|
|
1670
|
+
// Session 16: the Source Control panel asks for one side of the
|
|
1671
|
+
// index at a time. Anything else keeps the historical meaning.
|
|
1672
|
+
diffMode(frame.args?.['mode'])),
|
|
1318
1673
|
});
|
|
1319
1674
|
}
|
|
1320
1675
|
// Session 11: history. Read-only, so no repo lock and no busy check —
|
|
@@ -1324,20 +1679,32 @@ export class Supervisor {
|
|
|
1324
1679
|
case 'git_log': {
|
|
1325
1680
|
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1326
1681
|
const branch = str(frame.args?.['branch']);
|
|
1327
|
-
|
|
1328
|
-
|
|
1682
|
+
const selector = refSelector(frame.args);
|
|
1683
|
+
// Session 14: the repository graph is reached from the WORKSPACE, so
|
|
1684
|
+
// a branch is no longer required — but then a ref selector is, or the
|
|
1685
|
+
// call would have nothing to walk and git would quietly default to
|
|
1686
|
+
// HEAD.
|
|
1687
|
+
if (!workspacePath || (!branch && Object.keys(selector).length === 0)) {
|
|
1688
|
+
return void reply({
|
|
1689
|
+
ok: false,
|
|
1690
|
+
error: 'workspacePath and either branch or a ref selection are required',
|
|
1691
|
+
});
|
|
1329
1692
|
}
|
|
1330
1693
|
const scope = logScope(frame.args?.['scope']);
|
|
1331
1694
|
const limit = num(frame.args?.['limit']);
|
|
1332
1695
|
const skip = num(frame.args?.['skip']);
|
|
1696
|
+
const cursor = str(frame.args?.['cursor']);
|
|
1333
1697
|
return void reply({
|
|
1334
1698
|
ok: true,
|
|
1335
1699
|
result: await gitLog({
|
|
1336
1700
|
workspacePath,
|
|
1337
|
-
branch,
|
|
1701
|
+
...(branch ? { branch } : {}),
|
|
1338
1702
|
...(scope ? { scope } : {}),
|
|
1339
1703
|
...(limit !== null ? { limit } : {}),
|
|
1340
1704
|
...(skip !== null ? { skip } : {}),
|
|
1705
|
+
...(cursor ? { cursor } : {}),
|
|
1706
|
+
...selector,
|
|
1707
|
+
...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
|
|
1341
1708
|
}),
|
|
1342
1709
|
});
|
|
1343
1710
|
}
|
|
@@ -1349,11 +1716,45 @@ export class Supervisor {
|
|
|
1349
1716
|
}
|
|
1350
1717
|
const filePath = str(frame.args?.['path']);
|
|
1351
1718
|
const showBranch = str(frame.args?.['branch']);
|
|
1719
|
+
const selector = refSelector(frame.args);
|
|
1352
1720
|
return void reply({
|
|
1353
1721
|
ok: true,
|
|
1354
|
-
result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined),
|
|
1722
|
+
result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined, str(frame.args?.['baseBranch']) ?? undefined,
|
|
1723
|
+
// The visibility set is widened by exactly the refs the LOG was
|
|
1724
|
+
// allowed to walk. Passing it only when the caller sent one keeps
|
|
1725
|
+
// every session read on its original two refs.
|
|
1726
|
+
Object.keys(selector).length > 0 ? selector : undefined),
|
|
1355
1727
|
});
|
|
1356
1728
|
}
|
|
1729
|
+
// Session 14: the ref list behind the graph's branch picker. Read-only,
|
|
1730
|
+
// same call as git_log — `for-each-ref` cannot move anything.
|
|
1731
|
+
case 'git_refs': {
|
|
1732
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1733
|
+
if (!workspacePath) {
|
|
1734
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
1735
|
+
}
|
|
1736
|
+
return void reply({ ok: true, result: await gitRefs(workspacePath) });
|
|
1737
|
+
}
|
|
1738
|
+
// Session 14: what the project folder is ACTUALLY on. The product used
|
|
1739
|
+
// to collect this during path validation and throw it away, which is
|
|
1740
|
+
// why it could never answer «is the running build yours».
|
|
1741
|
+
case 'workspace_state': {
|
|
1742
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1743
|
+
if (!workspacePath) {
|
|
1744
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
1745
|
+
}
|
|
1746
|
+
return void reply({ ok: true, result: await workspaceState(workspacePath) });
|
|
1747
|
+
}
|
|
1748
|
+
// Session 13: the branch list the «continue an existing branch» picker
|
|
1749
|
+
// reads. Read-only — `for-each-ref` and `worktree list` cannot move a
|
|
1750
|
+
// ref — so no lock and no busy check, same call as git_log above.
|
|
1751
|
+
case 'git_branches': {
|
|
1752
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1753
|
+
if (!workspacePath) {
|
|
1754
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
1755
|
+
}
|
|
1756
|
+
return void reply({ ok: true, result: await gitBranches(workspacePath) });
|
|
1757
|
+
}
|
|
1357
1758
|
case 'git_commit': {
|
|
1358
1759
|
const worktreePath = str(frame.args?.['worktreePath']);
|
|
1359
1760
|
const message = str(frame.args?.['message']);
|
|
@@ -1366,9 +1767,14 @@ export class Supervisor {
|
|
|
1366
1767
|
error: 'The agent is still working — wait for the turn to finish',
|
|
1367
1768
|
});
|
|
1368
1769
|
}
|
|
1770
|
+
// Session 16: `all: false` commits the INDEX — what the person
|
|
1771
|
+
// staged, and nothing else. Absent stays `git add -A` + commit, which
|
|
1772
|
+
// is what the agent's own auto-commit has always meant and what every
|
|
1773
|
+
// API older than this release will keep asking for.
|
|
1774
|
+
const all = frame.args?.['all'] !== false;
|
|
1369
1775
|
return void reply({
|
|
1370
1776
|
ok: true,
|
|
1371
|
-
result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message)),
|
|
1777
|
+
result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message, { all })),
|
|
1372
1778
|
});
|
|
1373
1779
|
}
|
|
1374
1780
|
case 'apply_session': {
|
|
@@ -1386,22 +1792,181 @@ export class Supervisor {
|
|
|
1386
1792
|
error: 'The agent is still working — wait for the turn to finish',
|
|
1387
1793
|
});
|
|
1388
1794
|
}
|
|
1389
|
-
const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession(
|
|
1390
|
-
|
|
1795
|
+
const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession({
|
|
1796
|
+
workspacePath: paths.workspacePath,
|
|
1797
|
+
worktreePath: paths.worktreePath,
|
|
1798
|
+
sessionBranch: paths.branch,
|
|
1799
|
+
message,
|
|
1800
|
+
...optionalBranch(frame.args?.['expectedBase'], 'expectedBase'),
|
|
1801
|
+
...optionalSha(frame.args?.['sinceSha'], 'sinceSha'),
|
|
1802
|
+
}));
|
|
1803
|
+
// «Nothing new to apply», «the folder is on another branch» and «the
|
|
1804
|
+
// folder has uncommitted changes» are answers, not failures: the API
|
|
1805
|
+
// turns each into a sentence with a way forward, and an `ok: false`
|
|
1806
|
+
// here would surface as a raw error toast instead.
|
|
1807
|
+
//
|
|
1808
|
+
// `workspaceDirty` joining that list is safe for an API older than
|
|
1809
|
+
// session 15: it does not know the flag, so it falls through to the
|
|
1810
|
+
// same `AppError.conflict(result.error)` it raises today, with the
|
|
1811
|
+
// same wording.
|
|
1812
|
+
return void reply(applied.applied ||
|
|
1813
|
+
applied.conflict ||
|
|
1814
|
+
applied.noChanges ||
|
|
1815
|
+
applied.drifted ||
|
|
1816
|
+
applied.workspaceDirty
|
|
1391
1817
|
? { ok: true, result: applied }
|
|
1392
1818
|
: { ok: false, error: applied.error ?? 'Apply failed', result: applied });
|
|
1393
1819
|
}
|
|
1820
|
+
// Session 13: merge the base branch INTO the session branch. Merge and
|
|
1821
|
+
// never rebase — rebase rewrites commits the History tab already showed
|
|
1822
|
+
// a human. Takes the worktree busy check and the repo lock, because it
|
|
1823
|
+
// writes into the session's index just like a commit does.
|
|
1824
|
+
case 'update_from_base': {
|
|
1825
|
+
const paths = gitCommandPaths(frame.args);
|
|
1826
|
+
if (!paths) {
|
|
1827
|
+
return void reply({
|
|
1828
|
+
ok: false,
|
|
1829
|
+
error: 'worktreePath/workspacePath/branch are required',
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
if (this.isWorktreeBusy(paths.worktreePath)) {
|
|
1833
|
+
return void reply({
|
|
1834
|
+
ok: false,
|
|
1835
|
+
error: 'The agent is still working — wait for the turn to finish',
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
const updated = await this.withRepoLockFor(paths.worktreePath, () => updateFromBase({
|
|
1839
|
+
workspacePath: paths.workspacePath,
|
|
1840
|
+
worktreePath: paths.worktreePath,
|
|
1841
|
+
sessionBranch: paths.branch,
|
|
1842
|
+
...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
|
|
1843
|
+
}));
|
|
1844
|
+
return void reply(updated.updated || updated.conflict
|
|
1845
|
+
? { ok: true, result: updated }
|
|
1846
|
+
: { ok: false, error: updated.error ?? 'Update failed', result: updated });
|
|
1847
|
+
}
|
|
1848
|
+
// Session 13: the one outgoing write DevBridge performs. Never reached
|
|
1849
|
+
// by an agent — layer 1 denies `git push` outright — only by a human
|
|
1850
|
+
// pressing the button, and only for this session's own branch.
|
|
1851
|
+
case 'git_push': {
|
|
1852
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1853
|
+
const branch = str(frame.args?.['branch']);
|
|
1854
|
+
const remote = str(frame.args?.['remote']);
|
|
1855
|
+
if (!workspacePath || !branch || !remote) {
|
|
1856
|
+
return void reply({
|
|
1857
|
+
ok: false,
|
|
1858
|
+
error: 'workspacePath/branch/remote are required',
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
const pushed = await this.withRepoLockFor(workspacePath, () => gitPush({ workspacePath, branch, remote }));
|
|
1862
|
+
return void reply(pushed.pushed
|
|
1863
|
+
? { ok: true, result: pushed }
|
|
1864
|
+
: { ok: false, error: pushed.error ?? 'Push failed', result: pushed });
|
|
1865
|
+
}
|
|
1394
1866
|
case 'revert_apply': {
|
|
1395
1867
|
const workspacePath = str(frame.args?.['workspacePath']);
|
|
1396
1868
|
const commitSha = str(frame.args?.['commitSha']);
|
|
1397
1869
|
if (!workspacePath || !commitSha) {
|
|
1398
1870
|
return void reply({ ok: false, error: 'workspacePath and commitSha are required' });
|
|
1399
1871
|
}
|
|
1400
|
-
const
|
|
1401
|
-
|
|
1872
|
+
const expected = optionalBranch(frame.args?.['expectedBase'], 'expectedBase');
|
|
1873
|
+
const reverted = await this.withRepoLockFor(workspacePath, () => revertApply(workspacePath, commitSha, expected.expectedBase));
|
|
1874
|
+
// Same reasoning as `apply_session`: a dirty project folder is a
|
|
1875
|
+
// state the API can explain and offer a way out of, not an error.
|
|
1876
|
+
return void reply(reverted.reverted || reverted.conflict || reverted.workspaceDirty
|
|
1402
1877
|
? { ok: true, result: reverted }
|
|
1403
1878
|
: { ok: false, error: reverted.error ?? 'Revert failed', result: reverted });
|
|
1404
1879
|
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Session 16 — the Source Control panel's four verbs.
|
|
1882
|
+
*
|
|
1883
|
+
* All of them WRITE into the index or the working tree of the session's
|
|
1884
|
+
* folder, so all of them take the repo lock and the worktree busy check:
|
|
1885
|
+
* staging a file underneath an agent that is mid-edit produces a commit
|
|
1886
|
+
* of half a thought, and «the agent is still working» is a sentence a
|
|
1887
|
+
* person can act on where a git error is not.
|
|
1888
|
+
*
|
|
1889
|
+
* They are deliberately four commands and not one `git` passthrough. A
|
|
1890
|
+
* passthrough would be a shell on somebody's server behind a web button;
|
|
1891
|
+
* these take a list of paths, and every one of those paths is checked
|
|
1892
|
+
* before it reaches an argv slot.
|
|
1893
|
+
*/
|
|
1894
|
+
case 'git_stage':
|
|
1895
|
+
case 'git_unstage':
|
|
1896
|
+
case 'git_discard': {
|
|
1897
|
+
const worktreePath = str(frame.args?.['worktreePath']);
|
|
1898
|
+
if (!worktreePath) {
|
|
1899
|
+
return void reply({ ok: false, error: 'worktreePath is required' });
|
|
1900
|
+
}
|
|
1901
|
+
const all = frame.args?.['all'] === true;
|
|
1902
|
+
const paths = Array.isArray(frame.args?.['paths']) ? frame.args?.['paths'] : null;
|
|
1903
|
+
if (!all && !paths) {
|
|
1904
|
+
return void reply({ ok: false, error: 'paths or all is required' });
|
|
1905
|
+
}
|
|
1906
|
+
if (this.isWorktreeBusy(worktreePath)) {
|
|
1907
|
+
return void reply({
|
|
1908
|
+
ok: false,
|
|
1909
|
+
error: 'The agent is still working — wait for the turn to finish',
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
const request = { worktreePath, ...(all ? { all } : { paths: paths }) };
|
|
1913
|
+
const run = frame.name === 'git_stage'
|
|
1914
|
+
? () => gitStage(request)
|
|
1915
|
+
: frame.name === 'git_unstage'
|
|
1916
|
+
? () => gitUnstage(request)
|
|
1917
|
+
: () => gitDiscard(request);
|
|
1918
|
+
try {
|
|
1919
|
+
const result = await this.withRepoLockFor(worktreePath, run);
|
|
1920
|
+
return void reply({ ok: true, result });
|
|
1921
|
+
}
|
|
1922
|
+
catch (error) {
|
|
1923
|
+
// A refused path is a sentence the panel shows verbatim («Path must
|
|
1924
|
+
// be relative to the repository: …»), not a stack trace.
|
|
1925
|
+
return void reply({ ok: false, error: maskSecretText(error) });
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* Session 16: `git pull`, and the way back out of one that conflicted.
|
|
1930
|
+
*
|
|
1931
|
+
* The only command in this group that reaches the network. It can leave
|
|
1932
|
+
* a conflicted tree behind on purpose — that is what git does, and the
|
|
1933
|
+
* panel draws it as «Merge Changes» rather than pretending the pull did
|
|
1934
|
+
* not happen.
|
|
1935
|
+
*/
|
|
1936
|
+
case 'git_pull': {
|
|
1937
|
+
const worktreePath = str(frame.args?.['worktreePath']);
|
|
1938
|
+
if (!worktreePath) {
|
|
1939
|
+
return void reply({ ok: false, error: 'worktreePath is required' });
|
|
1940
|
+
}
|
|
1941
|
+
if (this.isWorktreeBusy(worktreePath)) {
|
|
1942
|
+
return void reply({
|
|
1943
|
+
ok: false,
|
|
1944
|
+
error: 'The agent is still working — wait for the turn to finish',
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
const pulled = await this.withRepoLockFor(worktreePath, () => gitPull({ worktreePath }));
|
|
1948
|
+
// A conflict is an outcome with a state attached, not a failure: the
|
|
1949
|
+
// API turns it into a sentence and the panel into a file group.
|
|
1950
|
+
return void reply(pulled.pulled || pulled.conflict
|
|
1951
|
+
? { ok: true, result: pulled }
|
|
1952
|
+
: { ok: false, error: pulled.error ?? 'Pull failed', result: pulled });
|
|
1953
|
+
}
|
|
1954
|
+
case 'git_merge_abort': {
|
|
1955
|
+
const worktreePath = str(frame.args?.['worktreePath']);
|
|
1956
|
+
if (!worktreePath) {
|
|
1957
|
+
return void reply({ ok: false, error: 'worktreePath is required' });
|
|
1958
|
+
}
|
|
1959
|
+
if (this.isWorktreeBusy(worktreePath)) {
|
|
1960
|
+
return void reply({
|
|
1961
|
+
ok: false,
|
|
1962
|
+
error: 'The agent is still working — wait for the turn to finish',
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
const aborted = await this.withRepoLockFor(worktreePath, () => gitMergeAbort(worktreePath));
|
|
1966
|
+
return void reply(aborted.aborted
|
|
1967
|
+
? { ok: true, result: aborted }
|
|
1968
|
+
: { ok: false, error: aborted.reason ?? 'Nothing to abort', result: aborted });
|
|
1969
|
+
}
|
|
1405
1970
|
case 'fs_view': {
|
|
1406
1971
|
const root = str(frame.args?.['root']);
|
|
1407
1972
|
if (!root)
|
|
@@ -1451,6 +2016,185 @@ export class Supervisor {
|
|
|
1451
2016
|
this.opts.onRestartRequested?.(outcome);
|
|
1452
2017
|
return;
|
|
1453
2018
|
}
|
|
2019
|
+
// ─── Session 14: the project recipe ──────────────────────────
|
|
2020
|
+
//
|
|
2021
|
+
// Read-only, always available even when verification is switched off:
|
|
2022
|
+
// «this machine will not run it» is a different answer from «this
|
|
2023
|
+
// project has no recipe», and the card has to be able to say both.
|
|
2024
|
+
case 'recipe_state': {
|
|
2025
|
+
const root = str(frame.args?.['root']) ?? str(frame.args?.['workspacePath']);
|
|
2026
|
+
if (!root)
|
|
2027
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
2028
|
+
return void reply({
|
|
2029
|
+
ok: true,
|
|
2030
|
+
result: { ...readRecipeProposal(root), verifyEnabled: this.verify.enabled },
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
case 'verify_start': {
|
|
2034
|
+
const parsed = parseVerifyStart(frame.args);
|
|
2035
|
+
if ('error' in parsed)
|
|
2036
|
+
return void reply({ ok: false, error: parsed.error });
|
|
2037
|
+
const started = this.verify.start(parsed.input);
|
|
2038
|
+
return void reply(started.started
|
|
2039
|
+
? { ok: true, result: { started: true, runId: parsed.input.runId } }
|
|
2040
|
+
: { ok: false, error: started.error ?? 'Could not start the verification' });
|
|
2041
|
+
}
|
|
2042
|
+
case 'verify_status': {
|
|
2043
|
+
const runId = str(frame.args?.['runId']);
|
|
2044
|
+
if (!runId)
|
|
2045
|
+
return void reply({ ok: false, error: 'runId is required' });
|
|
2046
|
+
const status = this.verify.status(runId, num(frame.args?.['offset']) ?? 0);
|
|
2047
|
+
return void reply(status
|
|
2048
|
+
? { ok: true, result: status }
|
|
2049
|
+
: { ok: false, error: 'This runner does not know that verification run' });
|
|
2050
|
+
}
|
|
2051
|
+
case 'verify_cancel': {
|
|
2052
|
+
const runId = str(frame.args?.['runId']);
|
|
2053
|
+
if (!runId)
|
|
2054
|
+
return void reply({ ok: false, error: 'runId is required' });
|
|
2055
|
+
const cancelled = this.verify.cancel(runId);
|
|
2056
|
+
return void reply(cancelled.cancelled
|
|
2057
|
+
? { ok: true, result: cancelled }
|
|
2058
|
+
: { ok: false, error: cancelled.error ?? 'Could not cancel' });
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Session 14: show a branch WITHOUT touching the project folder.
|
|
2062
|
+
*
|
|
2063
|
+
* A second, detached worktree plus the recipe's own `preview` command —
|
|
2064
|
+
* which has its own docker project and its own ports. Without that
|
|
2065
|
+
* command there is nothing honest to do: rebuilding in the project
|
|
2066
|
+
* folder would replace the one copy the machine is running.
|
|
2067
|
+
*/
|
|
2068
|
+
case 'preview_checkout': {
|
|
2069
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
2070
|
+
const branch = str(frame.args?.['branch']);
|
|
2071
|
+
const parsed = parseVerifyStart(frame.args);
|
|
2072
|
+
if (!workspacePath || !branch) {
|
|
2073
|
+
return void reply({ ok: false, error: 'workspacePath and branch are required' });
|
|
2074
|
+
}
|
|
2075
|
+
if ('error' in parsed)
|
|
2076
|
+
return void reply({ ok: false, error: parsed.error });
|
|
2077
|
+
if (!parsed.input.recipe.preview?.run) {
|
|
2078
|
+
return void reply({
|
|
2079
|
+
ok: false,
|
|
2080
|
+
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.',
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
const checkout = await this.withRepoLockFor(workspacePath, () => ensurePreviewWorktree({
|
|
2084
|
+
workspacePath,
|
|
2085
|
+
workspaceKey: frame.workspaceId ?? workspacePath,
|
|
2086
|
+
branch,
|
|
2087
|
+
}));
|
|
2088
|
+
const started = this.verify.start({
|
|
2089
|
+
...parsed.input,
|
|
2090
|
+
target: 'PREVIEW',
|
|
2091
|
+
preview: true,
|
|
2092
|
+
cwd: checkout.worktreePath,
|
|
2093
|
+
branch: checkout.branch,
|
|
2094
|
+
commitSha: checkout.sha,
|
|
2095
|
+
dirty: false,
|
|
2096
|
+
});
|
|
2097
|
+
return void reply(started.started
|
|
2098
|
+
? {
|
|
2099
|
+
ok: true,
|
|
2100
|
+
result: {
|
|
2101
|
+
started: true,
|
|
2102
|
+
runId: parsed.input.runId,
|
|
2103
|
+
worktreePath: checkout.worktreePath,
|
|
2104
|
+
branch: checkout.branch,
|
|
2105
|
+
sha: checkout.sha,
|
|
2106
|
+
url: parsed.input.recipe.preview.url ?? null,
|
|
2107
|
+
},
|
|
2108
|
+
}
|
|
2109
|
+
: { ok: false, error: started.error ?? 'Could not start the preview' });
|
|
2110
|
+
}
|
|
2111
|
+
case 'preview_stop': {
|
|
2112
|
+
const workspacePath = str(frame.args?.['workspacePath']);
|
|
2113
|
+
if (!workspacePath)
|
|
2114
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
2115
|
+
const key = frame.workspaceId ?? workspacePath;
|
|
2116
|
+
const stopCommand = str(frame.args?.['stop']);
|
|
2117
|
+
const dockerProject = str(frame.args?.['project']);
|
|
2118
|
+
// Cancel the preview's own run FIRST (session 15).
|
|
2119
|
+
//
|
|
2120
|
+
// «Stop preview» used to run the stop command and delete the
|
|
2121
|
+
// worktree while `preview.run` was still executing inside it — so a
|
|
2122
|
+
// half-built `docker compose up --build` lost its directory mid-flight
|
|
2123
|
+
// and went on holding the machine's single verification slot until it
|
|
2124
|
+
// timed out, with the dashboard already showing the preview as
|
|
2125
|
+
// stopped. Only a PREVIEW run is ours to cancel: a build somebody
|
|
2126
|
+
// started for a session is a different thing entirely.
|
|
2127
|
+
if (this.verify.activeTarget === 'PREVIEW') {
|
|
2128
|
+
const runId = this.verify.activeRunId;
|
|
2129
|
+
if (runId)
|
|
2130
|
+
this.verify.cancel(runId);
|
|
2131
|
+
}
|
|
2132
|
+
if (stopCommand) {
|
|
2133
|
+
// The one place layer 1 lifts `docker … down`, and only for the
|
|
2134
|
+
// compose project the recipe named as its own.
|
|
2135
|
+
const decision = evaluateRecipeCommand(stopCommand, {
|
|
2136
|
+
isPreviewStop: true,
|
|
2137
|
+
...(dockerProject ? { dockerProject } : {}),
|
|
2138
|
+
});
|
|
2139
|
+
if (!decision.allowed) {
|
|
2140
|
+
return void reply({
|
|
2141
|
+
ok: false,
|
|
2142
|
+
error: `The stop command is refused: ${decision.reason}`,
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
await runPreviewStop(stopCommand, previewWorktreePath(key));
|
|
2146
|
+
}
|
|
2147
|
+
const removed = await this.withRepoLockFor(workspacePath, () => removePreviewWorktree(key));
|
|
2148
|
+
return void reply({ ok: true, result: { stopped: true, removed } });
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Session 14: a commit message written by an agent, in a one-shot run.
|
|
2152
|
+
*
|
|
2153
|
+
* Never `send()` into the live session — that would fill the feed with a
|
|
2154
|
+
* request nobody made, spend the session's context on the whole diff and
|
|
2155
|
+
* fight the worktree lock the agent holds mid-turn.
|
|
2156
|
+
*/
|
|
2157
|
+
case 'propose_commit_message': {
|
|
2158
|
+
const paths = gitCommandPaths(frame.args);
|
|
2159
|
+
if (!paths) {
|
|
2160
|
+
return void reply({
|
|
2161
|
+
ok: false,
|
|
2162
|
+
error: 'worktreePath/workspacePath/branch are required',
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
const ticketsRaw = frame.args?.['tickets'];
|
|
2166
|
+
const subjectsRaw = frame.args?.['commitSubjects'];
|
|
2167
|
+
const propose = this.opts.proposeCommitMessage ?? proposeCommitMessage;
|
|
2168
|
+
const result = await propose({
|
|
2169
|
+
worktreePath: paths.worktreePath,
|
|
2170
|
+
workspacePath: paths.workspacePath,
|
|
2171
|
+
branch: paths.branch,
|
|
2172
|
+
...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
|
|
2173
|
+
...(Array.isArray(ticketsRaw)
|
|
2174
|
+
? {
|
|
2175
|
+
tickets: ticketsRaw
|
|
2176
|
+
.filter((n) => typeof n === 'number')
|
|
2177
|
+
.slice(0, 20),
|
|
2178
|
+
}
|
|
2179
|
+
: {}),
|
|
2180
|
+
...(Array.isArray(subjectsRaw)
|
|
2181
|
+
? {
|
|
2182
|
+
commitSubjects: subjectsRaw
|
|
2183
|
+
.filter((s) => typeof s === 'string')
|
|
2184
|
+
.slice(0, 20),
|
|
2185
|
+
}
|
|
2186
|
+
: {}),
|
|
2187
|
+
...(str(frame.args?.['language'])
|
|
2188
|
+
? { language: str(frame.args?.['language']) }
|
|
2189
|
+
: {}),
|
|
2190
|
+
...(str(frame.args?.['convention'])
|
|
2191
|
+
? { convention: str(frame.args?.['convention']) }
|
|
2192
|
+
: {}),
|
|
2193
|
+
});
|
|
2194
|
+
return void reply(result.ok
|
|
2195
|
+
? { ok: true, result }
|
|
2196
|
+
: { ok: false, error: result.error ?? 'Could not write a commit message', result });
|
|
2197
|
+
}
|
|
1454
2198
|
case 'auth_status':
|
|
1455
2199
|
return void reply({ ok: true, result: await agentAuthStatuses() });
|
|
1456
2200
|
case 'login_start': {
|
|
@@ -1552,7 +2296,19 @@ export class Supervisor {
|
|
|
1552
2296
|
running.lastReported = status;
|
|
1553
2297
|
this.syncBudgetClock(running);
|
|
1554
2298
|
}
|
|
1555
|
-
|
|
2299
|
+
// The fork point rides along on the first frame after the branch was
|
|
2300
|
+
// created — once, because it never changes and the API pins the first
|
|
2301
|
+
// answer it hears. Sending it on every frame would invite a later, wrong
|
|
2302
|
+
// value to overwrite the right one if the pinning rule were ever relaxed.
|
|
2303
|
+
const base = running && !running.baseReported && (running.baseBranch || running.baseSha)
|
|
2304
|
+
? {
|
|
2305
|
+
...(running.baseBranch ? { baseBranch: running.baseBranch } : {}),
|
|
2306
|
+
...(running.baseSha ? { baseSha: running.baseSha } : {}),
|
|
2307
|
+
}
|
|
2308
|
+
: {};
|
|
2309
|
+
if (running && Object.keys(base).length > 0)
|
|
2310
|
+
running.baseReported = true;
|
|
2311
|
+
const compact = maskSecrets(Object.fromEntries(Object.entries({ ...extra, ...base }).filter(([, v]) => v !== undefined)));
|
|
1556
2312
|
// Journal the latest status for replay after a reconnect (QA-96 F1) —
|
|
1557
2313
|
// only for tracked sessions, so one-shot failure reports don't leave
|
|
1558
2314
|
// orphan journal files behind. The epoch stamp keeps a previous life's
|
|
@@ -1575,9 +2331,31 @@ export class Supervisor {
|
|
|
1575
2331
|
this.authRelay.cancel();
|
|
1576
2332
|
for (const running of this.sessions.values()) {
|
|
1577
2333
|
this.clearBudgetTimers(running);
|
|
1578
|
-
|
|
2334
|
+
// BEFORE stop(), and from here rather than from the adapter: the adapter
|
|
2335
|
+
// pushes its `question_resolved` into an async queue that `pumpEvents`
|
|
2336
|
+
// drains a microtask later, and every caller of shutdown() (SIGTERM,
|
|
2337
|
+
// «Update runner», `revoked`) calls `process.exit` in the same tick — so
|
|
2338
|
+
// that event would never be journaled and never reach the wire. This
|
|
2339
|
+
// path writes the journal synchronously, and an unacked event is
|
|
2340
|
+
// redelivered on the next connection (QA-106 M1).
|
|
2341
|
+
try {
|
|
2342
|
+
this.withdrawOpenQuestions(running, 'runner_restarted');
|
|
2343
|
+
}
|
|
2344
|
+
catch (error) {
|
|
2345
|
+
log.warn('supervisor: could not withdraw open questions on shutdown', {
|
|
2346
|
+
sessionId: running.descriptor.id,
|
|
2347
|
+
error: String(error),
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
// The reason matters here: this path also runs for «Update runner», and a
|
|
2351
|
+
// card that vanishes during an update must say why (session 12).
|
|
2352
|
+
running.session?.stop('runner_restarted');
|
|
1579
2353
|
}
|
|
1580
2354
|
this.sessions.clear();
|
|
2355
|
+
// A build that was mid-flight is abandoned, not judged: its row stays
|
|
2356
|
+
// RUNNING until the API's sweep turns it into LOST. A verdict nobody
|
|
2357
|
+
// observed must never become PASSED.
|
|
2358
|
+
this.verify.shutdown();
|
|
1581
2359
|
}
|
|
1582
2360
|
}
|
|
1583
2361
|
function str(value) {
|
|
@@ -1596,6 +2374,105 @@ function num(value) {
|
|
|
1596
2374
|
function logScope(value) {
|
|
1597
2375
|
return value === 'session' || value === 'branch' || value === 'all' ? value : null;
|
|
1598
2376
|
}
|
|
2377
|
+
/** Which side of the index a diff request means (session 16). */
|
|
2378
|
+
function diffMode(value) {
|
|
2379
|
+
return value === 'staged' || value === 'worktree' ? value : 'base';
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* Optional command arguments that become git refs.
|
|
2383
|
+
*
|
|
2384
|
+
* They arrive from the API as `unknown` and end up in an argv slot, so they are
|
|
2385
|
+
* shaped here rather than passed through: a value that is not a ref name (or
|
|
2386
|
+
* not a hash) is dropped, and the callee falls back to what it did before the
|
|
2387
|
+
* argument existed. `exactOptionalPropertyTypes` is why these return a spread
|
|
2388
|
+
* object instead of `T | undefined`.
|
|
2389
|
+
*/
|
|
2390
|
+
function optionalBranch(value, key) {
|
|
2391
|
+
const raw = str(value);
|
|
2392
|
+
if (!raw || raw.length > 200 || !/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/.test(raw))
|
|
2393
|
+
return {};
|
|
2394
|
+
return { [key]: raw };
|
|
2395
|
+
}
|
|
2396
|
+
function optionalSha(value, key) {
|
|
2397
|
+
const raw = str(value);
|
|
2398
|
+
if (!raw || !/^[0-9a-f]{7,64}$/i.test(raw))
|
|
2399
|
+
return {};
|
|
2400
|
+
return { [key]: raw };
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Which refs a history request may walk (session 14).
|
|
2404
|
+
*
|
|
2405
|
+
* Present only when the caller actually asked for something: an empty object
|
|
2406
|
+
* means «no selector», which keeps every session-level call on exactly the
|
|
2407
|
+
* two-ref behaviour it had before this release. Names are re-validated inside
|
|
2408
|
+
* `gitLog` too — this is the cheap first gate, not the only one.
|
|
2409
|
+
*/
|
|
2410
|
+
function refSelector(args) {
|
|
2411
|
+
const raw = args?.['refs'];
|
|
2412
|
+
const refs = Array.isArray(raw)
|
|
2413
|
+
? raw.filter((value) => typeof value === 'string').slice(0, 64)
|
|
2414
|
+
: null;
|
|
2415
|
+
return {
|
|
2416
|
+
...(refs && refs.length > 0 ? { refs } : {}),
|
|
2417
|
+
...(args?.['includeRemotes'] === true ? { includeRemotes: true } : {}),
|
|
2418
|
+
...(args?.['includeTags'] === true ? { includeTags: true } : {}),
|
|
2419
|
+
...(args?.['all'] === true ? { all: true } : {}),
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Everything `verify_start` / `preview_checkout` need, validated as one unit.
|
|
2424
|
+
*
|
|
2425
|
+
* The recipe travels on the frame rather than being read off disk here, and
|
|
2426
|
+
* that is the whole point of the design: the executable copy is the one a human
|
|
2427
|
+
* approved on the DevBridge side, not whatever the working tree says today. The
|
|
2428
|
+
* fingerprint is re-checked inside `VerifyRunner.start`, so a frame that lies
|
|
2429
|
+
* about which recipe it carries gets nowhere.
|
|
2430
|
+
*/
|
|
2431
|
+
function parseVerifyStart(args) {
|
|
2432
|
+
const runId = str(args?.['runId']);
|
|
2433
|
+
if (!runId || !/^[A-Za-z0-9_-]{8,64}$/.test(runId))
|
|
2434
|
+
return { error: 'A valid runId is required' };
|
|
2435
|
+
const recipeSha = str(args?.['recipeSha']);
|
|
2436
|
+
if (!recipeSha)
|
|
2437
|
+
return { error: 'recipeSha is required' };
|
|
2438
|
+
const cwd = str(args?.['cwd']);
|
|
2439
|
+
if (!cwd || !path.isAbsolute(cwd) || cwd.split('/').includes('..')) {
|
|
2440
|
+
return { error: 'cwd must be an absolute path without ".." segments' };
|
|
2441
|
+
}
|
|
2442
|
+
const parsed = parseProjectRecipe(args?.['recipe']);
|
|
2443
|
+
if (!parsed.ok)
|
|
2444
|
+
return { error: `The approved recipe is not valid: ${parsed.error}` };
|
|
2445
|
+
const targetRaw = args?.['target'];
|
|
2446
|
+
const target = targetRaw === 'BASE' || targetRaw === 'SESSION' || targetRaw === 'PREVIEW'
|
|
2447
|
+
? targetRaw
|
|
2448
|
+
: 'SESSION';
|
|
2449
|
+
const stepsRaw = args?.['steps'];
|
|
2450
|
+
const steps = Array.isArray(stepsRaw)
|
|
2451
|
+
? RECIPE_STEP_NAMES.filter((name) => stepsRaw.includes(name))
|
|
2452
|
+
: RECIPE_STEP_NAMES.filter((name) => name !== 'deploy');
|
|
2453
|
+
return {
|
|
2454
|
+
input: {
|
|
2455
|
+
runId,
|
|
2456
|
+
target,
|
|
2457
|
+
recipe: parsed.recipe,
|
|
2458
|
+
recipeSha,
|
|
2459
|
+
cwd,
|
|
2460
|
+
steps: [...steps],
|
|
2461
|
+
branch: str(args?.['branch']),
|
|
2462
|
+
commitSha: str(args?.['commitSha']),
|
|
2463
|
+
dirty: args?.['dirty'] === true,
|
|
2464
|
+
},
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
/** Stop a preview's own stack, inside its own worktree. */
|
|
2468
|
+
async function runPreviewStop(command, cwd) {
|
|
2469
|
+
if (!fs.existsSync(cwd))
|
|
2470
|
+
return;
|
|
2471
|
+
const result = await runOneOffCommand(command, cwd);
|
|
2472
|
+
if (!result.ok) {
|
|
2473
|
+
log.warn('preview: the stop command failed', { exitCode: result.exitCode });
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
1599
2476
|
function gitCommandPaths(args) {
|
|
1600
2477
|
const worktreePath = str(args?.['worktreePath']);
|
|
1601
2478
|
const workspacePath = str(args?.['workspacePath']);
|
|
@@ -1607,6 +2484,15 @@ function statusForReport(running) {
|
|
|
1607
2484
|
? 'RUNNING'
|
|
1608
2485
|
: running.lastReported;
|
|
1609
2486
|
}
|
|
2487
|
+
/**
|
|
2488
|
+
* What we say to an agent whose turn a runner restart cut off (session 18).
|
|
2489
|
+
*
|
|
2490
|
+
* Deliberately the same thing a human would type — the provider session is
|
|
2491
|
+
* resumed, so the agent still has the whole conversation and only needs to be
|
|
2492
|
+
* told to carry on. Spelling out WHY matters: without it the agent tends to
|
|
2493
|
+
* summarise what it had done instead of finishing it.
|
|
2494
|
+
*/
|
|
2495
|
+
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
2496
|
const AGENT_LABELS = { CLAUDE: 'Claude Code', CODEX: 'Codex' };
|
|
1611
2497
|
/** Only Claude reports USD — Codex sessions are bounded by time instead. */
|
|
1612
2498
|
function reportsCost(agent) {
|
|
@@ -1631,18 +2517,86 @@ function isTerminal(status) {
|
|
|
1631
2517
|
function isSettled(status) {
|
|
1632
2518
|
return isTerminal(status) || status === 'REVIEW';
|
|
1633
2519
|
}
|
|
2520
|
+
/**
|
|
2521
|
+
* The first message the agent gets.
|
|
2522
|
+
*
|
|
2523
|
+
* Two shapes, and the difference is whether tickets were handed over
|
|
2524
|
+
* (session 16, owner's decision):
|
|
2525
|
+
*
|
|
2526
|
+
* - **No tickets** — exactly what the human typed, and an empty prompt stays
|
|
2527
|
+
* empty. That is how you get a session that boots and waits for you to talk
|
|
2528
|
+
* first; it is a feature, not an oversight.
|
|
2529
|
+
* - **Tickets handed to a TICKET session** — the assignment is always sent and
|
|
2530
|
+
* cannot be removed: «Implement ticket #28.» Handing a ticket over IS the
|
|
2531
|
+
* instruction, so making the human retype it was ceremony. Anything they
|
|
2532
|
+
* typed follows underneath.
|
|
2533
|
+
*
|
|
2534
|
+
* What is NOT here any more: eight lines about which MCP tools to call and
|
|
2535
|
+
* which statuses to move through. That is a standing convention of the project,
|
|
2536
|
+
* true on the twentieth turn as much as the first, so it moved to the system
|
|
2537
|
+
* prompt beside `CLAUDE.md` — which is read last and therefore overrides ours.
|
|
2538
|
+
*/
|
|
1634
2539
|
export function composeInitialPrompt(descriptor) {
|
|
1635
|
-
|
|
2540
|
+
// A free chat may carry tickets as CONTEXT. Assigning them would put an agent
|
|
2541
|
+
// nobody asked onto somebody else's ticket (session 13).
|
|
2542
|
+
if (descriptor.kind === 'CHAT' || descriptor.tickets.length === 0)
|
|
1636
2543
|
return descriptor.prompt;
|
|
1637
|
-
const
|
|
1638
|
-
|
|
1639
|
-
`
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2544
|
+
const numbers = descriptor.tickets.map((t) => `#${t.number}`).join(', ');
|
|
2545
|
+
const assignment = descriptor.tickets.length === 1
|
|
2546
|
+
? `Implement ticket ${numbers}.`
|
|
2547
|
+
: `Implement tickets ${numbers}.`;
|
|
2548
|
+
return descriptor.prompt ? `${assignment}\n\n${descriptor.prompt}` : assignment;
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Extra system-prompt material: where this session is in git, and how tickets
|
|
2552
|
+
* are meant to move (session 13).
|
|
2553
|
+
*
|
|
2554
|
+
* Facts about THIS SESSION, and nothing else. They exist in no file on disk —
|
|
2555
|
+
* which branch the agent is on, that it must not push, where a plan belongs,
|
|
2556
|
+
* what to do with the tickets it was given — so somebody has to say them, and
|
|
2557
|
+
* that somebody is us.
|
|
2558
|
+
*
|
|
2559
|
+
* The project's own documentation is deliberately NOT here. Both agents read
|
|
2560
|
+
* their own file natively, verified live: Claude picks up `CLAUDE.md` through
|
|
2561
|
+
* its memory mechanism (since `settingSources` includes `'project'`), and Codex
|
|
2562
|
+
* picks up `AGENTS.md` even under the runner's isolated `CODEX_HOME`. Pasting a
|
|
2563
|
+
* copy on top of that was work we were doing for no one — and when it was
|
|
2564
|
+
* switched off for Claude alone it briefly left repositories that carry only
|
|
2565
|
+
* `AGENTS.md` with nothing at all, which is precisely the kind of hole a
|
|
2566
|
+
* half-measure digs.
|
|
2567
|
+
*
|
|
2568
|
+
* A repository that wants both agents equipped ships both files, or symlinks
|
|
2569
|
+
* one to the other. That is a repository convention and not something a runner
|
|
2570
|
+
* should paper over.
|
|
2571
|
+
*/
|
|
2572
|
+
export function composeWorkspaceContext(descriptor) {
|
|
2573
|
+
const sections = [];
|
|
2574
|
+
const plan = descriptor.branchPlan;
|
|
2575
|
+
if (plan) {
|
|
2576
|
+
const forked = plan.baseBranch ? ` It was branched from \`${plan.baseBranch}\`.` : '';
|
|
2577
|
+
sections.push([
|
|
2578
|
+
'Git in this session:',
|
|
2579
|
+
`- You are in a dedicated worktree on branch \`${plan.branch}\`.${forked}`,
|
|
2580
|
+
'- 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.',
|
|
2581
|
+
'- You cannot push: `git push` is refused. A human presses «Push» when they want the branch on the remote.',
|
|
2582
|
+
`- 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.`,
|
|
2583
|
+
].join('\n'));
|
|
2584
|
+
}
|
|
2585
|
+
// The ticket convention lives HERE, not in the first chat message (session
|
|
2586
|
+
// 16). It is a standing rule of the project — true on the twentieth turn as
|
|
2587
|
+
// much as the first — and putting it in the visible prompt only trained the
|
|
2588
|
+
// reader to skip past it. `CLAUDE.md` is appended after this section, so a
|
|
2589
|
+
// repository that states its own rule overrides ours by being read last.
|
|
2590
|
+
if (descriptor.kind !== 'CHAT' && descriptor.tickets.length > 0) {
|
|
2591
|
+
sections.push([
|
|
2592
|
+
'DevBridge tickets in this session:',
|
|
2593
|
+
'- Read the full ticket with the DevBridge MCP tools (`mcp__devbridge__*`) before starting — the title alone is never the whole task.',
|
|
2594
|
+
'- Move it to IN_PROGRESS while you work and to READY_FOR_REVIEW with a short summary comment when you are done.',
|
|
2595
|
+
].join('\n'));
|
|
2596
|
+
}
|
|
2597
|
+
else if (descriptor.tickets.length > 0) {
|
|
2598
|
+
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.');
|
|
2599
|
+
}
|
|
2600
|
+
return sections.join('\n\n');
|
|
1647
2601
|
}
|
|
1648
2602
|
//# sourceMappingURL=supervisor.js.map
|