@bridge4dev/runner 0.52.0 → 0.53.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.js +15 -32
- package/dist/adapters/codex.js +10 -26
- package/dist/adapters/types.d.ts +45 -1
- package/dist/adapters/types.js +53 -0
- package/dist/checkpoints.d.ts +62 -0
- package/dist/checkpoints.js +50 -1
- package/dist/git.d.ts +64 -0
- package/dist/git.js +487 -36
- package/dist/gitops.d.ts +5 -0
- package/dist/gitops.js +7 -8
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +228 -7
- package/dist/protocol.d.ts +17 -17
- package/dist/supervisor.d.ts +99 -3
- package/dist/supervisor.js +279 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -8,7 +8,7 @@ import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryD
|
|
|
8
8
|
import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
|
|
9
9
|
import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
|
|
10
10
|
import { JournalStore } from './journal.js';
|
|
11
|
-
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
|
|
11
|
+
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
|
|
12
12
|
import { readRecipeProposal } from './recipe.js';
|
|
13
13
|
import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
|
|
14
14
|
import { proposeCommitMessage } from './commit-message.js';
|
|
@@ -26,7 +26,7 @@ import { agentByDbValue } from './agent-registry.js';
|
|
|
26
26
|
import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
|
|
27
27
|
import { rememberWorkspacePath } from './environment.js';
|
|
28
28
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
29
|
-
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
29
|
+
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, MAX_BUSY_SESSIONS, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
30
30
|
import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
|
|
31
31
|
import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
|
|
32
32
|
/** Refusals shared by every checkpoint command (ticket #126). */
|
|
@@ -839,10 +839,7 @@ export class Supervisor {
|
|
|
839
839
|
running.baseBranch = prepared.baseBranch;
|
|
840
840
|
}
|
|
841
841
|
catch (error) {
|
|
842
|
-
this.
|
|
843
|
-
errorMessage: `Failed to prepare git worktree: ${maskSecretText(error)}`,
|
|
844
|
-
});
|
|
845
|
-
this.sessions.delete(descriptor.id);
|
|
842
|
+
this.workspacePrepareFailed(running, error, 'Failed to prepare git worktree');
|
|
846
843
|
return;
|
|
847
844
|
}
|
|
848
845
|
// A session that is already past STARTING (re-sent because this runner
|
|
@@ -922,6 +919,50 @@ export class Supervisor {
|
|
|
922
919
|
...(descriptor.branchPlan ? { plan: descriptor.branchPlan } : {}),
|
|
923
920
|
}));
|
|
924
921
|
}
|
|
922
|
+
/**
|
|
923
|
+
* The working folder could not be built — say so in the feed, pin the fork
|
|
924
|
+
* point, and let the session go (#360).
|
|
925
|
+
*
|
|
926
|
+
* Both doors into `prepareWorkspace` end here. Three things happen in an
|
|
927
|
+
* order that is not free:
|
|
928
|
+
*
|
|
929
|
+
* 1. **The fork point is recorded first.** `git.ts` resolves it before it
|
|
930
|
+
* writes anything, precisely so that a failure still knows it. Without it
|
|
931
|
+
* the API keeps `base_sha = null` for the session's whole life, and
|
|
932
|
+
* «Continue» can never prove the leftover branch is the empty one this
|
|
933
|
+
* session created — which is the trap the ticket is about.
|
|
934
|
+
* 2. **The feed event goes out before the map entry is dropped**, because
|
|
935
|
+
* `sendEvent` needs it. Until now neither door sent one: a failed start
|
|
936
|
+
* was visible only as an `errorMessage` on the session row, and pressing
|
|
937
|
+
* «Continue» wipes that.
|
|
938
|
+
* 3. **Then the status, then the entry.** A FAILED session is over, and a
|
|
939
|
+
* leftover entry would hold one of the runner's few slots.
|
|
940
|
+
*/
|
|
941
|
+
workspacePrepareFailed(running, error, prefix) {
|
|
942
|
+
const { descriptor } = running;
|
|
943
|
+
if (error instanceof WorktreePrepareError) {
|
|
944
|
+
if (error.baseSha && !running.baseSha)
|
|
945
|
+
running.baseSha = error.baseSha;
|
|
946
|
+
if (error.baseBranch && !running.baseBranch)
|
|
947
|
+
running.baseBranch = error.baseBranch;
|
|
948
|
+
}
|
|
949
|
+
const message = maskSecretText(error);
|
|
950
|
+
log.error('supervisor: the working folder could not be prepared', {
|
|
951
|
+
sessionId: descriptor.id,
|
|
952
|
+
workMode: descriptor.workMode ?? 'BRANCH',
|
|
953
|
+
code: error instanceof WorktreePrepareError ? error.code : 'unknown',
|
|
954
|
+
error: message,
|
|
955
|
+
});
|
|
956
|
+
this.sendEvent(running, 'error', {
|
|
957
|
+
message,
|
|
958
|
+
// Not an enum on either side (`RunnerEventPayloadSchema` is a record, and
|
|
959
|
+
// the dashboard carries whatever code it is given), so this stays a plain
|
|
960
|
+
// string and a new one costs nobody a deployment.
|
|
961
|
+
code: error instanceof WorktreePrepareError ? error.code : 'worktree_prepare_failed',
|
|
962
|
+
});
|
|
963
|
+
this.reportStatus(descriptor.id, 'FAILED', { errorMessage: `${prefix}: ${message}` });
|
|
964
|
+
this.sessions.delete(descriptor.id);
|
|
965
|
+
}
|
|
925
966
|
/**
|
|
926
967
|
* Spin the adapter up — for a fresh session, a resume-on-next-message, or a
|
|
927
968
|
* free CHAT session with no prompt at all (the agent boots, reports its
|
|
@@ -1040,6 +1081,10 @@ export class Supervisor {
|
|
|
1040
1081
|
// (`resolveGitPolicy` gives both the restrictive reading), and passing it
|
|
1041
1082
|
// unconditionally keeps one code path instead of two.
|
|
1042
1083
|
gitPolicy: gitPolicyOf(descriptor),
|
|
1084
|
+
// #361 п. 5: whether this session shares its folder with everybody else.
|
|
1085
|
+
// Rides down for the same reason as `gitPolicy` — it lands in
|
|
1086
|
+
// `PolicyContext` and it decides one line of the system prompt.
|
|
1087
|
+
...(descriptor.workMode ? { workMode: descriptor.workMode } : {}),
|
|
1043
1088
|
mode: running.mode,
|
|
1044
1089
|
...(running.model ? { model: running.model } : {}),
|
|
1045
1090
|
...(running.effort ? { effort: running.effort } : {}),
|
|
@@ -1726,7 +1771,8 @@ export class Supervisor {
|
|
|
1726
1771
|
/**
|
|
1727
1772
|
* Make room for one more agent process.
|
|
1728
1773
|
*
|
|
1729
|
-
* Up to `maxSessions` agents run side by side
|
|
1774
|
+
* Up to `maxSessions` agents run side by side — since session 16 most of them
|
|
1775
|
+
* share the project folder rather than each having a worktree of its own. Over
|
|
1730
1776
|
* that, idle-but-resumable sessions (REVIEW / WAITING_INPUT) are parked —
|
|
1731
1777
|
* their provider session survives on disk and relaunches on the next message,
|
|
1732
1778
|
* so parking costs context nothing. Only mid-turn sessions (RUNNING /
|
|
@@ -3106,17 +3152,42 @@ export class Supervisor {
|
|
|
3106
3152
|
* Three reasons it declines, and each of them is a state in which a snapshot
|
|
3107
3153
|
* would be a lie rather than a restore point:
|
|
3108
3154
|
* - the machine's owner switched checkpoints off;
|
|
3109
|
-
* -
|
|
3155
|
+
* - THIS session is mid-turn, so its own tree is being written as we read it;
|
|
3110
3156
|
* - a repo-mutating command holds the repository.
|
|
3157
|
+
*
|
|
3158
|
+
* «A neighbour in the same folder is working» used to be a fourth reason, and
|
|
3159
|
+
* it was the wrong one (#310): in DIRECT mode the folder is shared by design,
|
|
3160
|
+
* so that rule silently switched restore points off for everybody the moment
|
|
3161
|
+
* a second session opened. The neighbours are recorded on the point instead —
|
|
3162
|
+
* the conversation can always be rewound to it, the files cannot.
|
|
3163
|
+
*
|
|
3164
|
+
* Every refusal is now audible. A restore point that was never taken is
|
|
3165
|
+
* invisible until the day somebody reaches for it, and «the button is not
|
|
3166
|
+
* there» is not a sentence anybody can act on.
|
|
3111
3167
|
*/
|
|
3112
3168
|
async captureCheckpoint(running, kind, messageSeq) {
|
|
3113
3169
|
const worktreePath = running.worktreePath;
|
|
3114
|
-
if (!worktreePath
|
|
3170
|
+
if (!worktreePath)
|
|
3115
3171
|
return;
|
|
3116
|
-
if (
|
|
3172
|
+
if (this.opts.checkpointsEnabled === false) {
|
|
3173
|
+
// Once per session: this is a machine-wide setting, not an event.
|
|
3174
|
+
if (!running.checkpointsOffSaid) {
|
|
3175
|
+
running.checkpointsOffSaid = true;
|
|
3176
|
+
this.sendEvent(running, 'notice', {
|
|
3177
|
+
level: 'info',
|
|
3178
|
+
text: 'Restore points are switched off on this dev server, so this session cannot be rewound. A server administrator turns them back on.',
|
|
3179
|
+
});
|
|
3180
|
+
}
|
|
3117
3181
|
return;
|
|
3118
|
-
|
|
3182
|
+
}
|
|
3183
|
+
if (kind === 'TURN' && this.isSessionMidTurn(running)) {
|
|
3184
|
+
this.noticeOncePerTurn(running, 'checkpoint-self-busy', 'No restore point was taken for this step: this session was still answering when it was due.');
|
|
3119
3185
|
return;
|
|
3186
|
+
}
|
|
3187
|
+
if (await this.isRepoLocked(worktreePath)) {
|
|
3188
|
+
this.noticeOncePerTurn(running, 'checkpoint-repo-locked', 'No restore point was taken for this step: another git command holds this repository. It will be taken on the next one.');
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3120
3191
|
// The anchor and the conversation it names travel TOGETHER: a point
|
|
3121
3192
|
// recorded against an older provider session stays usable, because the
|
|
3122
3193
|
// rewind resumes the session the point names rather than whichever one the
|
|
@@ -3128,6 +3199,10 @@ export class Supervisor {
|
|
|
3128
3199
|
kind,
|
|
3129
3200
|
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
3130
3201
|
...(anchor ? { agentAnchor: anchor.anchor, agentSession: anchor.agentSession } : {}),
|
|
3202
|
+
// A getter, not a value: taking the snapshot is not instantaneous, and
|
|
3203
|
+
// «who was working while it was taken» is a question about the whole of
|
|
3204
|
+
// that interval (#310).
|
|
3205
|
+
busySessions: () => this.busyNeighbours(running),
|
|
3131
3206
|
});
|
|
3132
3207
|
if (this.isStale(running))
|
|
3133
3208
|
return;
|
|
@@ -3140,6 +3215,16 @@ export class Supervisor {
|
|
|
3140
3215
|
text: 'This working tree is too large to take a restore point — rewind is unavailable for this step. Check that build output is in .gitignore.',
|
|
3141
3216
|
});
|
|
3142
3217
|
}
|
|
3218
|
+
else {
|
|
3219
|
+
// Not a repository, or git refused: every time, because unlike the two
|
|
3220
|
+
// conditions above this one does not clear itself.
|
|
3221
|
+
this.sendEvent(running, 'notice', {
|
|
3222
|
+
level: 'warn',
|
|
3223
|
+
text: result.reason === 'not-a-repo'
|
|
3224
|
+
? 'No restore point could be taken: this folder is not a git repository, so there is nothing to rewind to.'
|
|
3225
|
+
: 'No restore point could be taken for this step, so it cannot be rewound. The session itself is unaffected.',
|
|
3226
|
+
});
|
|
3227
|
+
}
|
|
3143
3228
|
return;
|
|
3144
3229
|
}
|
|
3145
3230
|
this.sendEvent(running, 'checkpoint', {
|
|
@@ -3150,8 +3235,36 @@ export class Supervisor {
|
|
|
3150
3235
|
canRewindContext: Boolean(result.record.agentAnchor),
|
|
3151
3236
|
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
3152
3237
|
...(result.skippedFiles.length ? { skippedFiles: result.skippedFiles.slice(0, 20) } : {}),
|
|
3238
|
+
// Only when non-empty (#310, gotcha 433): on this path the DASHBOARD
|
|
3239
|
+
// reads an empty list as «the folder was quiet», which is the opposite
|
|
3240
|
+
// convention to the rewind preview, where the presence of the key is the
|
|
3241
|
+
// warning. Two paths, two conventions, both deliberate.
|
|
3242
|
+
...(result.record.busySessions?.length
|
|
3243
|
+
? { busySessions: result.record.busySessions.slice(0, MAX_BUSY_SESSIONS) }
|
|
3244
|
+
: {}),
|
|
3153
3245
|
});
|
|
3154
3246
|
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Say something once per BUSY PERIOD, not once per message (#310).
|
|
3249
|
+
*
|
|
3250
|
+
* A folder held by a neighbour stays held for minutes, and a session mid-turn
|
|
3251
|
+
* can be sent three follow-up notes inside one answer. Keying this on the
|
|
3252
|
+
* message seq would have counted each of those as its own turn and said the
|
|
3253
|
+
* same sentence three times — the noise the frequency policy exists to
|
|
3254
|
+
* prevent. The set is cleared when the session next comes to rest
|
|
3255
|
+
* (`reportStatus`), which is exactly when the reason stops being true.
|
|
3256
|
+
*
|
|
3257
|
+
* A SET of keys, not the last one said: two different reasons can both come
|
|
3258
|
+
* up inside one period, and remembering only the most recent would let them
|
|
3259
|
+
* take turns re-announcing each other.
|
|
3260
|
+
*/
|
|
3261
|
+
noticeOncePerTurn(running, key, text) {
|
|
3262
|
+
running.noticesThisTurn ??= new Set();
|
|
3263
|
+
if (running.noticesThisTurn.has(key))
|
|
3264
|
+
return;
|
|
3265
|
+
running.noticesThisTurn.add(key);
|
|
3266
|
+
this.sendEvent(running, 'notice', { level: 'info', text });
|
|
3267
|
+
}
|
|
3155
3268
|
/**
|
|
3156
3269
|
* Deliver messages that raced session start (already journaled).
|
|
3157
3270
|
*
|
|
@@ -3225,7 +3338,7 @@ export class Supervisor {
|
|
|
3225
3338
|
return;
|
|
3226
3339
|
}
|
|
3227
3340
|
const open = running.stopCycle;
|
|
3228
|
-
if (open &&
|
|
3341
|
+
if (open && Supervisor.stopInFlight(running)) {
|
|
3229
3342
|
// #357 part 2, and invariant 2 of the pause plan. Ten frames arrive while
|
|
3230
3343
|
// the CLI is waiting out a provider retry — from a phone and a laptop at
|
|
3231
3344
|
// once, or from a person pressing a button that gave them no sign it had
|
|
@@ -3624,7 +3737,7 @@ export class Supervisor {
|
|
|
3624
3737
|
// could not tell a tool RESULT from a tool starting, so the tail of the stop
|
|
3625
3738
|
// read as a turn beginning.
|
|
3626
3739
|
const cycle = running.stopCycle;
|
|
3627
|
-
if (cycle &&
|
|
3740
|
+
if (cycle && Supervisor.stopInFlight(running)) {
|
|
3628
3741
|
log.info('supervisor: output under a pause belongs to the stop in flight', {
|
|
3629
3742
|
sessionId: running.descriptor.id,
|
|
3630
3743
|
phase: cycle.phase,
|
|
@@ -3746,12 +3859,17 @@ export class Supervisor {
|
|
|
3746
3859
|
}
|
|
3747
3860
|
await this.applyLiveSettings(running, model, mode, effort);
|
|
3748
3861
|
}
|
|
3749
|
-
/**
|
|
3862
|
+
/**
|
|
3863
|
+
* Is a turn (or a question the agent is parked on) in flight right now?
|
|
3864
|
+
*
|
|
3865
|
+
* The same status list as `holdsTheTree` plus one term: an open question
|
|
3866
|
+
* parks the session without any of those statuses, and a live settings change
|
|
3867
|
+
* must not land under it. Reading the shared array rather than spelling the
|
|
3868
|
+
* statuses out again is the whole point of Р8 — a fourth waiting status has
|
|
3869
|
+
* to arrive in one place, not four.
|
|
3870
|
+
*/
|
|
3750
3871
|
isMidTurn(running) {
|
|
3751
|
-
return (running.lastReported
|
|
3752
|
-
running.lastReported === 'STARTING' ||
|
|
3753
|
-
running.lastReported === 'WAITING_PERMISSION' ||
|
|
3754
|
-
running.openQuestions.size > 0);
|
|
3872
|
+
return (Supervisor.MID_TURN_STATUSES.includes(running.lastReported) || running.openQuestions.size > 0);
|
|
3755
3873
|
}
|
|
3756
3874
|
/** The three live setters, in the order that lets an explicit pick win. */
|
|
3757
3875
|
async applyLiveSettings(running, model, mode, effort) {
|
|
@@ -4096,10 +4214,12 @@ export class Supervisor {
|
|
|
4096
4214
|
running.baseBranch = prepared.baseBranch;
|
|
4097
4215
|
}
|
|
4098
4216
|
catch (error) {
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4217
|
+
// The same treatment as the start door (#360). Two doors doing one
|
|
4218
|
+
// thing is how the pause bug of #373 stayed half-fixed for a
|
|
4219
|
+
// release; this one used to mask its text differently AND emit no
|
|
4220
|
+
// feed event at all, so a session that could not be restored went
|
|
4221
|
+
// FAILED with nothing to read anywhere.
|
|
4222
|
+
this.workspacePrepareFailed(running, error, 'Failed to restore session worktree');
|
|
4103
4223
|
continue;
|
|
4104
4224
|
}
|
|
4105
4225
|
/**
|
|
@@ -4253,7 +4373,24 @@ export class Supervisor {
|
|
|
4253
4373
|
}
|
|
4254
4374
|
// ─── Commands ──────────────────────────────────────────────────────
|
|
4255
4375
|
async runCommand(frame) {
|
|
4256
|
-
|
|
4376
|
+
// How long a command actually took on THIS machine. Nothing measured it
|
|
4377
|
+
// before, which is why «the branch probe timed out after 4 s» could never be
|
|
4378
|
+
// answered with «it takes 9 s on that server» or «it never arrived» — the
|
|
4379
|
+
// two failures #316 turned out to be. One line, at INFO, per command.
|
|
4380
|
+
const startedAt = Date.now();
|
|
4381
|
+
let answered = false;
|
|
4382
|
+
const reply = (result) => {
|
|
4383
|
+
if (!answered) {
|
|
4384
|
+
answered = true;
|
|
4385
|
+
log.info('supervisor: command handled', {
|
|
4386
|
+
command: frame.name,
|
|
4387
|
+
...(frame.sessionId ? { sessionId: frame.sessionId } : {}),
|
|
4388
|
+
elapsedMs: Date.now() - startedAt,
|
|
4389
|
+
ok: result.ok,
|
|
4390
|
+
});
|
|
4391
|
+
}
|
|
4392
|
+
this.ws.send({ type: 'command_result', requestId: frame.requestId, ...result });
|
|
4393
|
+
};
|
|
4257
4394
|
try {
|
|
4258
4395
|
switch (frame.name) {
|
|
4259
4396
|
case 'validate_path': {
|
|
@@ -4446,7 +4583,9 @@ export class Supervisor {
|
|
|
4446
4583
|
if (!running.worktreePath) {
|
|
4447
4584
|
return void reply({ ok: false, error: 'The session has no working folder yet' });
|
|
4448
4585
|
}
|
|
4449
|
-
|
|
4586
|
+
// #310: this session's own turn, not the folder's. A neighbour
|
|
4587
|
+
// working next door marks the point; it does not forbid it.
|
|
4588
|
+
if (this.isSessionMidTurn(running)) {
|
|
4450
4589
|
return void reply({ ok: false, error: AGENT_BUSY });
|
|
4451
4590
|
}
|
|
4452
4591
|
const before = (await listCheckpoints(running.worktreePath, running.descriptor.id))
|
|
@@ -4477,6 +4616,12 @@ export class Supervisor {
|
|
|
4477
4616
|
// conversation is still on disk.
|
|
4478
4617
|
canRewindContext: Boolean(item.agentAnchor),
|
|
4479
4618
|
...(item.messageSeq === undefined ? {} : { messageSeq: item.messageSeq }),
|
|
4619
|
+
// The mark travels with the LIST as well as with the live event
|
|
4620
|
+
// (#310): the list is how a reloaded page rebuilds state, and a
|
|
4621
|
+
// mark that only ever rode the event would be lost there.
|
|
4622
|
+
...(item.busySessions?.length
|
|
4623
|
+
? { busySessions: item.busySessions.slice(0, MAX_BUSY_SESSIONS) }
|
|
4624
|
+
: {}),
|
|
4480
4625
|
})),
|
|
4481
4626
|
},
|
|
4482
4627
|
});
|
|
@@ -4507,8 +4652,18 @@ export class Supervisor {
|
|
|
4507
4652
|
if (ordinal === null || !running.worktreePath) {
|
|
4508
4653
|
return void reply({ ok: false, error: 'ordinal is required' });
|
|
4509
4654
|
}
|
|
4655
|
+
// The folder check STAYS here: this one writes files, and files are
|
|
4656
|
+
// shared. What changes is the sentence — `AGENT_BUSY` talks about
|
|
4657
|
+
// «the agent», meaning this session's own, and names nobody. When it
|
|
4658
|
+
// is a neighbour holding the folder, say so (#310).
|
|
4510
4659
|
if (this.isWorktreeBusy(running.worktreePath)) {
|
|
4511
|
-
|
|
4660
|
+
const neighbours = this.busyNeighbours(running);
|
|
4661
|
+
return void reply({
|
|
4662
|
+
ok: false,
|
|
4663
|
+
error: neighbours.length > 0
|
|
4664
|
+
? `Another session in this folder is still working (${neighbours.join(', ')}), so the files cannot be put back until it stops. Only the conversation can be rewound.`
|
|
4665
|
+
: AGENT_BUSY,
|
|
4666
|
+
});
|
|
4512
4667
|
}
|
|
4513
4668
|
const confirmRaw = frame.args?.['confirmDeletes'];
|
|
4514
4669
|
const confirmDeletes = Array.isArray(confirmRaw)
|
|
@@ -4522,6 +4677,10 @@ export class Supervisor {
|
|
|
4522
4677
|
sessionId: running.descriptor.id,
|
|
4523
4678
|
ordinal,
|
|
4524
4679
|
confirmDeletes,
|
|
4680
|
+
// The safety point is a restore point like any other and gets the
|
|
4681
|
+
// same question asked of it. Non-empty only through the #373
|
|
4682
|
+
// window — the folder check above has already passed.
|
|
4683
|
+
busySessions: () => this.busyNeighbours(running),
|
|
4525
4684
|
...(typeof expectedTreeOid === 'string' && expectedTreeOid
|
|
4526
4685
|
? { expectedTreeOid }
|
|
4527
4686
|
: {}),
|
|
@@ -4532,12 +4691,20 @@ export class Supervisor {
|
|
|
4532
4691
|
createdAt: result.safety.createdAt,
|
|
4533
4692
|
fileCount: result.safety.fileCount,
|
|
4534
4693
|
canRewindContext: Boolean(result.safety.agentAnchor),
|
|
4694
|
+
...(result.safety.busySessions?.length
|
|
4695
|
+
? { busySessions: result.safety.busySessions.slice(0, MAX_BUSY_SESSIONS) }
|
|
4696
|
+
: {}),
|
|
4535
4697
|
// This frame IS the browser's offer of «Undo rewind» — the point it
|
|
4536
4698
|
// names belongs to no message, so there is nowhere else the offer
|
|
4537
4699
|
// could come from. Withheld when the rewind was itself an undo:
|
|
4538
4700
|
// going back again is a redo, and a button that quietly changes
|
|
4539
4701
|
// meaning after one press is worse than no button.
|
|
4540
|
-
|
|
4702
|
+
//
|
|
4703
|
+
// Also withheld when the safety point itself came out marked (#310).
|
|
4704
|
+
// That happens through the #373 window — a neighbour woke between
|
|
4705
|
+
// the folder check and the snapshot — and `applyRewind` refuses a
|
|
4706
|
+
// marked point, so the offer would be a button that cannot work.
|
|
4707
|
+
undoable: result.rewoundToKind !== 'SAFETY' && !(result.safety.busySessions?.length ?? 0),
|
|
4541
4708
|
});
|
|
4542
4709
|
this.sendEvent(running, 'system_note', {
|
|
4543
4710
|
code: 'files_rewound',
|
|
@@ -4553,7 +4720,11 @@ export class Supervisor {
|
|
|
4553
4720
|
if (ordinal === null || !running.worktreePath) {
|
|
4554
4721
|
return void reply({ ok: false, error: 'ordinal is required' });
|
|
4555
4722
|
}
|
|
4556
|
-
|
|
4723
|
+
// #310: the conversation can be rewound whatever the neighbours are
|
|
4724
|
+
// doing — it touches no file of theirs. This session's OWN unfinished
|
|
4725
|
+
// turn still has to stop first: the rewind parks and relaunches this
|
|
4726
|
+
// very process, and the button is hidden mid-turn anyway.
|
|
4727
|
+
if (this.isSessionMidTurn(running)) {
|
|
4557
4728
|
return void reply({ ok: false, error: AGENT_BUSY });
|
|
4558
4729
|
}
|
|
4559
4730
|
const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
|
|
@@ -5457,19 +5628,90 @@ export class Supervisor {
|
|
|
5457
5628
|
return 'sessionId is required';
|
|
5458
5629
|
return this.sessions.get(sessionId) ?? 'Unknown session';
|
|
5459
5630
|
}
|
|
5631
|
+
/**
|
|
5632
|
+
* The statuses in which a session is holding its working tree (#310).
|
|
5633
|
+
*
|
|
5634
|
+
* One array, read by all three predicates below. There used to be one
|
|
5635
|
+
* literal, then two would have been needed, and a third would have been
|
|
5636
|
+
* written the day somebody added a waiting state — which is how «the agent
|
|
5637
|
+
* is busy» and «the folder is busy» come to disagree about what busy means.
|
|
5638
|
+
* (`rewindActionsLive` in the dashboard is a fourth reader and a deliberate
|
|
5639
|
+
* mirror: the runner does not depend on `@devbridge/shared`.)
|
|
5640
|
+
*/
|
|
5641
|
+
static MID_TURN_STATUSES = [
|
|
5642
|
+
'STARTING',
|
|
5643
|
+
'RUNNING',
|
|
5644
|
+
'WAITING_PERMISSION',
|
|
5645
|
+
];
|
|
5646
|
+
/**
|
|
5647
|
+
* Is a stop still in flight over this session's live process?
|
|
5648
|
+
*
|
|
5649
|
+
* Three terms, written out in three places before this: an open cycle, that
|
|
5650
|
+
* cycle owning the process that is running NOW, and the cycle not yet
|
|
5651
|
+
* settled. #373 is what makes it load-bearing — the resting status is
|
|
5652
|
+
* published before the process dies, so this is the difference between «the
|
|
5653
|
+
* session is quiet» and «the session is still writing files».
|
|
5654
|
+
*/
|
|
5655
|
+
static stopInFlight(running) {
|
|
5656
|
+
const cycle = running.stopCycle;
|
|
5657
|
+
return cycle !== undefined && ownsProcess(cycle, running.session) && !cycle.settled;
|
|
5658
|
+
}
|
|
5659
|
+
static holdsTheTree(running) {
|
|
5660
|
+
return Boolean(running.session) && Supervisor.MID_TURN_STATUSES.includes(running.lastReported);
|
|
5661
|
+
}
|
|
5460
5662
|
/** A session actively mid-turn in this worktree — git writes must wait. */
|
|
5461
5663
|
isWorktreeBusy(worktreePath) {
|
|
5462
5664
|
for (const running of this.sessions.values()) {
|
|
5463
|
-
if (running.worktreePath !== worktreePath
|
|
5665
|
+
if (running.worktreePath !== worktreePath)
|
|
5464
5666
|
continue;
|
|
5465
|
-
if (running
|
|
5466
|
-
running.lastReported === 'RUNNING' ||
|
|
5467
|
-
running.lastReported === 'WAITING_PERMISSION') {
|
|
5667
|
+
if (Supervisor.holdsTheTree(running))
|
|
5468
5668
|
return true;
|
|
5469
|
-
}
|
|
5470
5669
|
}
|
|
5471
5670
|
return false;
|
|
5472
5671
|
}
|
|
5672
|
+
/**
|
|
5673
|
+
* Is THIS session mid-turn? (#310)
|
|
5674
|
+
*
|
|
5675
|
+
* The question `isWorktreeBusy` was answering in three places where the right
|
|
5676
|
+
* question was this one. They are the same question only when a folder holds
|
|
5677
|
+
* exactly one session — and DIRECT mode, the default since session 16, is
|
|
5678
|
+
* precisely the arrangement in which it holds several. A restore point is a
|
|
5679
|
+
* snapshot of this session's own conversation; a neighbour typing in the same
|
|
5680
|
+
* folder is a reason to MARK it, not to refuse to take it.
|
|
5681
|
+
*/
|
|
5682
|
+
isSessionMidTurn(running) {
|
|
5683
|
+
return Supervisor.holdsTheTree(running);
|
|
5684
|
+
}
|
|
5685
|
+
/**
|
|
5686
|
+
* Everybody else who is working in this folder right now (#310).
|
|
5687
|
+
*
|
|
5688
|
+
* Includes a neighbour whose stop is still running: after #373 the resting
|
|
5689
|
+
* status is published BEFORE the process is actually gone, so a session that
|
|
5690
|
+
* reports IDLE while its stop cycle still owns a live process is still
|
|
5691
|
+
* writing files. Reading `lastReported` alone would call that folder quiet.
|
|
5692
|
+
*/
|
|
5693
|
+
busyNeighbours(running) {
|
|
5694
|
+
const worktreePath = running.worktreePath;
|
|
5695
|
+
if (!worktreePath)
|
|
5696
|
+
return [];
|
|
5697
|
+
const ids = [];
|
|
5698
|
+
for (const other of this.sessions.values()) {
|
|
5699
|
+
// By id as well as by identity: a session that was re-started under a
|
|
5700
|
+
// higher epoch is a NEW entry for the SAME session, and it must not end
|
|
5701
|
+
// up marking its own restore point with its own id.
|
|
5702
|
+
if (other === running ||
|
|
5703
|
+
other.descriptor.id === running.descriptor.id ||
|
|
5704
|
+
other.worktreePath !== worktreePath) {
|
|
5705
|
+
continue;
|
|
5706
|
+
}
|
|
5707
|
+
if (Supervisor.holdsTheTree(other) || Supervisor.stopInFlight(other)) {
|
|
5708
|
+
ids.push(other.descriptor.id);
|
|
5709
|
+
}
|
|
5710
|
+
if (ids.length >= MAX_BUSY_SESSIONS)
|
|
5711
|
+
break;
|
|
5712
|
+
}
|
|
5713
|
+
return ids;
|
|
5714
|
+
}
|
|
5473
5715
|
// ─── Outbound helpers ──────────────────────────────────────────────
|
|
5474
5716
|
// Hard cap far below the API's 128KB Zod limit — an oversized payload would
|
|
5475
5717
|
// be rejected forever and wedge the journal (QA-96 F2).
|
|
@@ -5511,6 +5753,11 @@ export class Supervisor {
|
|
|
5511
5753
|
const running = this.sessions.get(sessionId);
|
|
5512
5754
|
if (running) {
|
|
5513
5755
|
running.lastReported = status;
|
|
5756
|
+
// Coming to rest ends the busy period the once-per-turn notices were
|
|
5757
|
+
// limited to: the next one is about a new answer and deserves saying.
|
|
5758
|
+
if (!Supervisor.MID_TURN_STATUSES.includes(status)) {
|
|
5759
|
+
delete running.noticesThisTurn;
|
|
5760
|
+
}
|
|
5514
5761
|
this.syncBudgetClock(running);
|
|
5515
5762
|
}
|
|
5516
5763
|
// The fork point rides along on the first frame after the branch was
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.53.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED