@bridge4dev/runner 0.51.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 +103 -26
- package/dist/adapters/types.d.ts +54 -3
- 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 +287 -34
- 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 /
|
|
@@ -2456,7 +2502,13 @@ export class Supervisor {
|
|
|
2456
2502
|
case 'settings':
|
|
2457
2503
|
if (event.mode)
|
|
2458
2504
|
running.mode = event.mode;
|
|
2459
|
-
|
|
2505
|
+
// null = the agent dropped the pinned MODEL, because the installed CLI
|
|
2506
|
+
// stopped offering it (#374). Dropped from the local record too: this
|
|
2507
|
+
// object is what a relaunch inside the same runner process starts from,
|
|
2508
|
+
// so keeping it would re-pin the model the adapter just refused.
|
|
2509
|
+
if (event.model === null)
|
|
2510
|
+
delete running.model;
|
|
2511
|
+
else if (event.model)
|
|
2460
2512
|
running.model = event.model;
|
|
2461
2513
|
// null = the agent dropped the pinned level (model without it) — the
|
|
2462
2514
|
// API must clear its column, so the value is forwarded as-is.
|
|
@@ -2465,7 +2517,7 @@ export class Supervisor {
|
|
|
2465
2517
|
else if (event.effort)
|
|
2466
2518
|
running.effort = event.effort;
|
|
2467
2519
|
this.sendEvent(running, 'settings', {
|
|
2468
|
-
model: event.model,
|
|
2520
|
+
...(event.model === undefined ? {} : { model: event.model }),
|
|
2469
2521
|
mode: event.mode,
|
|
2470
2522
|
...(event.effort === undefined ? {} : { effort: event.effort }),
|
|
2471
2523
|
});
|
|
@@ -3100,17 +3152,42 @@ export class Supervisor {
|
|
|
3100
3152
|
* Three reasons it declines, and each of them is a state in which a snapshot
|
|
3101
3153
|
* would be a lie rather than a restore point:
|
|
3102
3154
|
* - the machine's owner switched checkpoints off;
|
|
3103
|
-
* -
|
|
3155
|
+
* - THIS session is mid-turn, so its own tree is being written as we read it;
|
|
3104
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.
|
|
3105
3167
|
*/
|
|
3106
3168
|
async captureCheckpoint(running, kind, messageSeq) {
|
|
3107
3169
|
const worktreePath = running.worktreePath;
|
|
3108
|
-
if (!worktreePath
|
|
3170
|
+
if (!worktreePath)
|
|
3109
3171
|
return;
|
|
3110
|
-
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
|
+
}
|
|
3111
3181
|
return;
|
|
3112
|
-
|
|
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.');
|
|
3113
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
|
+
}
|
|
3114
3191
|
// The anchor and the conversation it names travel TOGETHER: a point
|
|
3115
3192
|
// recorded against an older provider session stays usable, because the
|
|
3116
3193
|
// rewind resumes the session the point names rather than whichever one the
|
|
@@ -3122,6 +3199,10 @@ export class Supervisor {
|
|
|
3122
3199
|
kind,
|
|
3123
3200
|
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
3124
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),
|
|
3125
3206
|
});
|
|
3126
3207
|
if (this.isStale(running))
|
|
3127
3208
|
return;
|
|
@@ -3134,6 +3215,16 @@ export class Supervisor {
|
|
|
3134
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.',
|
|
3135
3216
|
});
|
|
3136
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
|
+
}
|
|
3137
3228
|
return;
|
|
3138
3229
|
}
|
|
3139
3230
|
this.sendEvent(running, 'checkpoint', {
|
|
@@ -3144,8 +3235,36 @@ export class Supervisor {
|
|
|
3144
3235
|
canRewindContext: Boolean(result.record.agentAnchor),
|
|
3145
3236
|
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
3146
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
|
+
: {}),
|
|
3147
3245
|
});
|
|
3148
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
|
+
}
|
|
3149
3268
|
/**
|
|
3150
3269
|
* Deliver messages that raced session start (already journaled).
|
|
3151
3270
|
*
|
|
@@ -3219,7 +3338,7 @@ export class Supervisor {
|
|
|
3219
3338
|
return;
|
|
3220
3339
|
}
|
|
3221
3340
|
const open = running.stopCycle;
|
|
3222
|
-
if (open &&
|
|
3341
|
+
if (open && Supervisor.stopInFlight(running)) {
|
|
3223
3342
|
// #357 part 2, and invariant 2 of the pause plan. Ten frames arrive while
|
|
3224
3343
|
// the CLI is waiting out a provider retry — from a phone and a laptop at
|
|
3225
3344
|
// once, or from a person pressing a button that gave them no sign it had
|
|
@@ -3618,7 +3737,7 @@ export class Supervisor {
|
|
|
3618
3737
|
// could not tell a tool RESULT from a tool starting, so the tail of the stop
|
|
3619
3738
|
// read as a turn beginning.
|
|
3620
3739
|
const cycle = running.stopCycle;
|
|
3621
|
-
if (cycle &&
|
|
3740
|
+
if (cycle && Supervisor.stopInFlight(running)) {
|
|
3622
3741
|
log.info('supervisor: output under a pause belongs to the stop in flight', {
|
|
3623
3742
|
sessionId: running.descriptor.id,
|
|
3624
3743
|
phase: cycle.phase,
|
|
@@ -3740,12 +3859,17 @@ export class Supervisor {
|
|
|
3740
3859
|
}
|
|
3741
3860
|
await this.applyLiveSettings(running, model, mode, effort);
|
|
3742
3861
|
}
|
|
3743
|
-
/**
|
|
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
|
+
*/
|
|
3744
3871
|
isMidTurn(running) {
|
|
3745
|
-
return (running.lastReported
|
|
3746
|
-
running.lastReported === 'STARTING' ||
|
|
3747
|
-
running.lastReported === 'WAITING_PERMISSION' ||
|
|
3748
|
-
running.openQuestions.size > 0);
|
|
3872
|
+
return (Supervisor.MID_TURN_STATUSES.includes(running.lastReported) || running.openQuestions.size > 0);
|
|
3749
3873
|
}
|
|
3750
3874
|
/** The three live setters, in the order that lets an explicit pick win. */
|
|
3751
3875
|
async applyLiveSettings(running, model, mode, effort) {
|
|
@@ -4090,10 +4214,12 @@ export class Supervisor {
|
|
|
4090
4214
|
running.baseBranch = prepared.baseBranch;
|
|
4091
4215
|
}
|
|
4092
4216
|
catch (error) {
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
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');
|
|
4097
4223
|
continue;
|
|
4098
4224
|
}
|
|
4099
4225
|
/**
|
|
@@ -4247,7 +4373,24 @@ export class Supervisor {
|
|
|
4247
4373
|
}
|
|
4248
4374
|
// ─── Commands ──────────────────────────────────────────────────────
|
|
4249
4375
|
async runCommand(frame) {
|
|
4250
|
-
|
|
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
|
+
};
|
|
4251
4394
|
try {
|
|
4252
4395
|
switch (frame.name) {
|
|
4253
4396
|
case 'validate_path': {
|
|
@@ -4440,7 +4583,9 @@ export class Supervisor {
|
|
|
4440
4583
|
if (!running.worktreePath) {
|
|
4441
4584
|
return void reply({ ok: false, error: 'The session has no working folder yet' });
|
|
4442
4585
|
}
|
|
4443
|
-
|
|
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)) {
|
|
4444
4589
|
return void reply({ ok: false, error: AGENT_BUSY });
|
|
4445
4590
|
}
|
|
4446
4591
|
const before = (await listCheckpoints(running.worktreePath, running.descriptor.id))
|
|
@@ -4471,6 +4616,12 @@ export class Supervisor {
|
|
|
4471
4616
|
// conversation is still on disk.
|
|
4472
4617
|
canRewindContext: Boolean(item.agentAnchor),
|
|
4473
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
|
+
: {}),
|
|
4474
4625
|
})),
|
|
4475
4626
|
},
|
|
4476
4627
|
});
|
|
@@ -4501,8 +4652,18 @@ export class Supervisor {
|
|
|
4501
4652
|
if (ordinal === null || !running.worktreePath) {
|
|
4502
4653
|
return void reply({ ok: false, error: 'ordinal is required' });
|
|
4503
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).
|
|
4504
4659
|
if (this.isWorktreeBusy(running.worktreePath)) {
|
|
4505
|
-
|
|
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
|
+
});
|
|
4506
4667
|
}
|
|
4507
4668
|
const confirmRaw = frame.args?.['confirmDeletes'];
|
|
4508
4669
|
const confirmDeletes = Array.isArray(confirmRaw)
|
|
@@ -4516,6 +4677,10 @@ export class Supervisor {
|
|
|
4516
4677
|
sessionId: running.descriptor.id,
|
|
4517
4678
|
ordinal,
|
|
4518
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),
|
|
4519
4684
|
...(typeof expectedTreeOid === 'string' && expectedTreeOid
|
|
4520
4685
|
? { expectedTreeOid }
|
|
4521
4686
|
: {}),
|
|
@@ -4526,12 +4691,20 @@ export class Supervisor {
|
|
|
4526
4691
|
createdAt: result.safety.createdAt,
|
|
4527
4692
|
fileCount: result.safety.fileCount,
|
|
4528
4693
|
canRewindContext: Boolean(result.safety.agentAnchor),
|
|
4694
|
+
...(result.safety.busySessions?.length
|
|
4695
|
+
? { busySessions: result.safety.busySessions.slice(0, MAX_BUSY_SESSIONS) }
|
|
4696
|
+
: {}),
|
|
4529
4697
|
// This frame IS the browser's offer of «Undo rewind» — the point it
|
|
4530
4698
|
// names belongs to no message, so there is nowhere else the offer
|
|
4531
4699
|
// could come from. Withheld when the rewind was itself an undo:
|
|
4532
4700
|
// going back again is a redo, and a button that quietly changes
|
|
4533
4701
|
// meaning after one press is worse than no button.
|
|
4534
|
-
|
|
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),
|
|
4535
4708
|
});
|
|
4536
4709
|
this.sendEvent(running, 'system_note', {
|
|
4537
4710
|
code: 'files_rewound',
|
|
@@ -4547,7 +4720,11 @@ export class Supervisor {
|
|
|
4547
4720
|
if (ordinal === null || !running.worktreePath) {
|
|
4548
4721
|
return void reply({ ok: false, error: 'ordinal is required' });
|
|
4549
4722
|
}
|
|
4550
|
-
|
|
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)) {
|
|
4551
4728
|
return void reply({ ok: false, error: AGENT_BUSY });
|
|
4552
4729
|
}
|
|
4553
4730
|
const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
|
|
@@ -5451,19 +5628,90 @@ export class Supervisor {
|
|
|
5451
5628
|
return 'sessionId is required';
|
|
5452
5629
|
return this.sessions.get(sessionId) ?? 'Unknown session';
|
|
5453
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
|
+
}
|
|
5454
5662
|
/** A session actively mid-turn in this worktree — git writes must wait. */
|
|
5455
5663
|
isWorktreeBusy(worktreePath) {
|
|
5456
5664
|
for (const running of this.sessions.values()) {
|
|
5457
|
-
if (running.worktreePath !== worktreePath
|
|
5665
|
+
if (running.worktreePath !== worktreePath)
|
|
5458
5666
|
continue;
|
|
5459
|
-
if (running
|
|
5460
|
-
running.lastReported === 'RUNNING' ||
|
|
5461
|
-
running.lastReported === 'WAITING_PERMISSION') {
|
|
5667
|
+
if (Supervisor.holdsTheTree(running))
|
|
5462
5668
|
return true;
|
|
5463
|
-
}
|
|
5464
5669
|
}
|
|
5465
5670
|
return false;
|
|
5466
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
|
+
}
|
|
5467
5715
|
// ─── Outbound helpers ──────────────────────────────────────────────
|
|
5468
5716
|
// Hard cap far below the API's 128KB Zod limit — an oversized payload would
|
|
5469
5717
|
// be rejected forever and wedge the journal (QA-96 F2).
|
|
@@ -5505,6 +5753,11 @@ export class Supervisor {
|
|
|
5505
5753
|
const running = this.sessions.get(sessionId);
|
|
5506
5754
|
if (running) {
|
|
5507
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
|
+
}
|
|
5508
5761
|
this.syncBudgetClock(running);
|
|
5509
5762
|
}
|
|
5510
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