@bridge4dev/runner 0.35.0 → 0.36.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/agent-prompt.d.ts +53 -2
- package/dist/agent-prompt.js +44 -8
- package/dist/index.js +14 -0
- package/dist/protocol.d.ts +70 -0
- package/dist/protocol.js +36 -0
- package/dist/supervisor.d.ts +34 -1
- package/dist/supervisor.js +221 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent-prompt.d.ts
CHANGED
|
@@ -23,7 +23,26 @@
|
|
|
23
23
|
* runner is the process that opens the file, and it is the only side that can
|
|
24
24
|
* see what the path actually resolves to on this disk.
|
|
25
25
|
*/
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Big enough for a real process document; small enough to stay a prompt.
|
|
28
|
+
*
|
|
29
|
+
* Raised from 32 KB to 64 KB in 0.36.0 (ticket #192). 32 KB was picked when the
|
|
30
|
+
* only prompts in existence were 20–25 KB, and the number was never visible
|
|
31
|
+
* anywhere: a project whose file grew past it simply stopped having a system
|
|
32
|
+
* prompt, and said so once per session in a line nobody was watching. DevBridge's
|
|
33
|
+
* own `dev-prompt.md` had been over the line for two releases.
|
|
34
|
+
*
|
|
35
|
+
* It stays a real ceiling rather than becoming a setting, and the reason is the
|
|
36
|
+
* price. This text is not read once — it is in `system[]` on EVERY request the
|
|
37
|
+
* agent makes, for the whole life of the process, so a prompt is paid for by the
|
|
38
|
+
* turn. 64 KB is roughly 16k tokens, which is already a lot to carry for
|
|
39
|
+
* twenty turns; anything beyond it is a document the agent should be asked to
|
|
40
|
+
* read, not a rule it must never forget.
|
|
41
|
+
*
|
|
42
|
+
* The number is on the wire (`agent_prompt_state` announces it), so the settings
|
|
43
|
+
* card shows the ceiling of the runner that would actually read the file —
|
|
44
|
+
* never a copy of this constant that could be a version behind.
|
|
45
|
+
*/
|
|
27
46
|
export declare const AGENT_PROMPT_MAX_BYTES: number;
|
|
28
47
|
export type AgentPromptResult = {
|
|
29
48
|
ok: true;
|
|
@@ -33,10 +52,18 @@ export type AgentPromptResult = {
|
|
|
33
52
|
bytes: number;
|
|
34
53
|
sha: string;
|
|
35
54
|
}
|
|
36
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Already phrased for a human and safe to show — no raw paths beyond the one
|
|
57
|
+
* they typed.
|
|
58
|
+
*
|
|
59
|
+
* `bytes` is carried only by the one refusal where the size IS the answer: a
|
|
60
|
+
* file over the ceiling. «Too big» without the number leaves the person to
|
|
61
|
+
* guess how much has to go, which is how #192 sat unnoticed for two releases.
|
|
62
|
+
*/
|
|
37
63
|
| {
|
|
38
64
|
ok: false;
|
|
39
65
|
reason: string;
|
|
66
|
+
bytes?: number;
|
|
40
67
|
};
|
|
41
68
|
/**
|
|
42
69
|
* Read the project's prompt file, or explain why it cannot be read.
|
|
@@ -62,6 +89,30 @@ export type AgentPromptResult = {
|
|
|
62
89
|
export declare function readAgentPrompt(projectRoot: string, relPath: string): AgentPromptResult;
|
|
63
90
|
/** How the prompt is announced in the session feed and in the journal. */
|
|
64
91
|
export declare function agentPromptSizeLabel(bytes: number): string;
|
|
92
|
+
/**
|
|
93
|
+
* What the settings card asks before anybody starts a session: would this file
|
|
94
|
+
* reach the agent, and how big is it right now (ticket #192).
|
|
95
|
+
*
|
|
96
|
+
* Deliberately the SAME function the launch uses rather than a second, lighter
|
|
97
|
+
* check. A settings screen that says «fine» over a file the launch would refuse
|
|
98
|
+
* is worse than a screen that says nothing — and the two would drift the first
|
|
99
|
+
* time a rule was added to only one of them.
|
|
100
|
+
*
|
|
101
|
+
* The text itself never leaves the machine: this answers about the file, and a
|
|
102
|
+
* project's standing rules are not something the dashboard needs a copy of.
|
|
103
|
+
*/
|
|
104
|
+
export interface AgentPromptState {
|
|
105
|
+
/** Would this file reach the agent's system prompt, as things stand now? */
|
|
106
|
+
loaded: boolean;
|
|
107
|
+
/** The ceiling THIS runner enforces — so the far side never assumes its own. */
|
|
108
|
+
limit: number;
|
|
109
|
+
/** Present whenever it is known: on success always, on refusal when size is the reason. */
|
|
110
|
+
bytes?: number;
|
|
111
|
+
sha?: string;
|
|
112
|
+
/** Already phrased for a human, when `loaded` is false. */
|
|
113
|
+
reason?: string;
|
|
114
|
+
}
|
|
115
|
+
export declare function inspectAgentPrompt(projectRoot: string, relPath: string): AgentPromptState;
|
|
65
116
|
/**
|
|
66
117
|
* A configured path, safe to put in a line a person reads.
|
|
67
118
|
*
|
package/dist/agent-prompt.js
CHANGED
|
@@ -27,10 +27,29 @@ import { isGitInternalPath, isSecretPath } from './policy.js';
|
|
|
27
27
|
* runner is the process that opens the file, and it is the only side that can
|
|
28
28
|
* see what the path actually resolves to on this disk.
|
|
29
29
|
*/
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Big enough for a real process document; small enough to stay a prompt.
|
|
32
|
+
*
|
|
33
|
+
* Raised from 32 KB to 64 KB in 0.36.0 (ticket #192). 32 KB was picked when the
|
|
34
|
+
* only prompts in existence were 20–25 KB, and the number was never visible
|
|
35
|
+
* anywhere: a project whose file grew past it simply stopped having a system
|
|
36
|
+
* prompt, and said so once per session in a line nobody was watching. DevBridge's
|
|
37
|
+
* own `dev-prompt.md` had been over the line for two releases.
|
|
38
|
+
*
|
|
39
|
+
* It stays a real ceiling rather than becoming a setting, and the reason is the
|
|
40
|
+
* price. This text is not read once — it is in `system[]` on EVERY request the
|
|
41
|
+
* agent makes, for the whole life of the process, so a prompt is paid for by the
|
|
42
|
+
* turn. 64 KB is roughly 16k tokens, which is already a lot to carry for
|
|
43
|
+
* twenty turns; anything beyond it is a document the agent should be asked to
|
|
44
|
+
* read, not a rule it must never forget.
|
|
45
|
+
*
|
|
46
|
+
* The number is on the wire (`agent_prompt_state` announces it), so the settings
|
|
47
|
+
* card shows the ceiling of the runner that would actually read the file —
|
|
48
|
+
* never a copy of this constant that could be a version behind.
|
|
49
|
+
*/
|
|
50
|
+
export const AGENT_PROMPT_MAX_BYTES = 64 * 1024;
|
|
51
|
+
function deny(reason, bytes) {
|
|
52
|
+
return { ok: false, reason, ...(bytes === undefined ? {} : { bytes }) };
|
|
34
53
|
}
|
|
35
54
|
/**
|
|
36
55
|
* Read the project's prompt file, or explain why it cannot be read.
|
|
@@ -140,17 +159,20 @@ export function readAgentPrompt(projectRoot, relPath) {
|
|
|
140
159
|
// this, `ln /etc/hostname docs/prompt.md` walks past both «inside the
|
|
141
160
|
// project folder» and «not on the protected list», because both were
|
|
142
161
|
// decided about the NAME.
|
|
143
|
-
let
|
|
162
|
+
let read;
|
|
144
163
|
try {
|
|
145
|
-
|
|
164
|
+
read = readCapped(realPath, AGENT_PROMPT_MAX_BYTES + 1, link);
|
|
146
165
|
}
|
|
147
166
|
catch (error) {
|
|
148
167
|
if (error instanceof PromptFileRefused)
|
|
149
168
|
return deny(error.reason);
|
|
150
169
|
return deny(describeFsError(error));
|
|
151
170
|
}
|
|
171
|
+
const buffer = read.buffer;
|
|
152
172
|
if (buffer.length > AGENT_PROMPT_MAX_BYTES) {
|
|
153
|
-
|
|
173
|
+
// The size comes off the descriptor that was just read, not off a second
|
|
174
|
+
// `stat` of the name — it is the size of the file this refusal is about.
|
|
175
|
+
return deny(`the file is larger than ${Math.floor(AGENT_PROMPT_MAX_BYTES / 1024)} KB`, read.size);
|
|
154
176
|
}
|
|
155
177
|
if (buffer.includes(0))
|
|
156
178
|
return deny('the file is not text');
|
|
@@ -180,6 +202,9 @@ class PromptFileRefused extends Error {
|
|
|
180
202
|
*
|
|
181
203
|
* @param expected The `lstat` taken before the path checks — the identity every
|
|
182
204
|
* rule above was decided about.
|
|
205
|
+
* @returns The bytes actually read, capped at `limit`, and the file's own size
|
|
206
|
+
* as the descriptor reports it. The two differ exactly when the file is over
|
|
207
|
+
* the ceiling, which is the case that needs the real number to explain itself.
|
|
183
208
|
*/
|
|
184
209
|
function readCapped(file, limit, expected) {
|
|
185
210
|
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
@@ -203,7 +228,7 @@ function readCapped(file, limit, expected) {
|
|
|
203
228
|
break;
|
|
204
229
|
read += n;
|
|
205
230
|
}
|
|
206
|
-
return buffer.subarray(0, read);
|
|
231
|
+
return { buffer: buffer.subarray(0, read), size: opened.size };
|
|
207
232
|
}
|
|
208
233
|
finally {
|
|
209
234
|
fs.closeSync(fd);
|
|
@@ -234,6 +259,17 @@ function describeFsError(error) {
|
|
|
234
259
|
export function agentPromptSizeLabel(bytes) {
|
|
235
260
|
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
236
261
|
}
|
|
262
|
+
export function inspectAgentPrompt(projectRoot, relPath) {
|
|
263
|
+
const result = readAgentPrompt(projectRoot, relPath);
|
|
264
|
+
return result.ok
|
|
265
|
+
? { loaded: true, limit: AGENT_PROMPT_MAX_BYTES, bytes: result.bytes, sha: result.sha }
|
|
266
|
+
: {
|
|
267
|
+
loaded: false,
|
|
268
|
+
limit: AGENT_PROMPT_MAX_BYTES,
|
|
269
|
+
reason: result.reason,
|
|
270
|
+
...(result.bytes === undefined ? {} : { bytes: result.bytes }),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
237
273
|
/**
|
|
238
274
|
* A configured path, safe to put in a line a person reads.
|
|
239
275
|
*
|
package/dist/index.js
CHANGED
|
@@ -261,6 +261,16 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
261
261
|
*/
|
|
262
262
|
contextRewind: true,
|
|
263
263
|
contextCompaction: true,
|
|
264
|
+
/**
|
|
265
|
+
* Ticket #196: understands `session_pause`, and therefore that a pause is a
|
|
266
|
+
* STATE rather than a single interrupt.
|
|
267
|
+
*
|
|
268
|
+
* Announced because the API has to know which of the two it is talking to:
|
|
269
|
+
* a runner without this drops the frame unread, and the pause has to fall
|
|
270
|
+
* back to the old best-effort interrupt rather than silently holding
|
|
271
|
+
* nothing at all.
|
|
272
|
+
*/
|
|
273
|
+
sessionPause: true,
|
|
264
274
|
/**
|
|
265
275
|
* Reads the project's own prompt file and hands it to the agent as
|
|
266
276
|
* SYSTEM-prompt text, for both Claude and Codex.
|
|
@@ -373,6 +383,10 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
373
383
|
'git_pull',
|
|
374
384
|
'git_merge_abort',
|
|
375
385
|
'recipe_state',
|
|
386
|
+
// Ticket #192: the project settings card asks whether the prompt file
|
|
387
|
+
// would load and how big it is. Announced as a command rather than as a
|
|
388
|
+
// flag, because that is the list `runCommand` actually dispatches on.
|
|
389
|
+
'agent_prompt_state',
|
|
376
390
|
'propose_commit_message',
|
|
377
391
|
'recall_message',
|
|
378
392
|
'compact_context',
|
package/dist/protocol.d.ts
CHANGED
|
@@ -14,6 +14,22 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
14
14
|
epoch: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
15
15
|
activeMsBase: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
16
16
|
extraBudgetMinutes: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
|
17
|
+
/**
|
|
18
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
19
|
+
*
|
|
20
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
21
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
22
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
23
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
24
|
+
* else about the session.
|
|
25
|
+
*
|
|
26
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
27
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
28
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
29
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
30
|
+
* behaviour every runner had before this release.
|
|
31
|
+
*/
|
|
32
|
+
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
17
33
|
workspace: z.ZodObject<{
|
|
18
34
|
id: z.ZodString;
|
|
19
35
|
path: z.ZodString;
|
|
@@ -201,6 +217,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
201
217
|
url: string;
|
|
202
218
|
token: string;
|
|
203
219
|
} | undefined;
|
|
220
|
+
pausedUntil?: string | null | undefined;
|
|
204
221
|
skipAgentPrompt?: boolean | undefined;
|
|
205
222
|
branchHint?: string | undefined;
|
|
206
223
|
branchPlan?: {
|
|
@@ -248,6 +265,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
248
265
|
costUsd?: number | undefined;
|
|
249
266
|
activeMsBase?: number | undefined;
|
|
250
267
|
extraBudgetMinutes?: number | null | undefined;
|
|
268
|
+
pausedUntil?: unknown;
|
|
251
269
|
skipAgentPrompt?: unknown;
|
|
252
270
|
branchHint?: unknown;
|
|
253
271
|
branchPlan?: unknown;
|
|
@@ -274,6 +292,22 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
274
292
|
epoch: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
275
293
|
activeMsBase: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
276
294
|
extraBudgetMinutes: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
|
295
|
+
/**
|
|
296
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
297
|
+
*
|
|
298
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
299
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
300
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
301
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
302
|
+
* else about the session.
|
|
303
|
+
*
|
|
304
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
305
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
306
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
307
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
308
|
+
* behaviour every runner had before this release.
|
|
309
|
+
*/
|
|
310
|
+
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
277
311
|
workspace: z.ZodObject<{
|
|
278
312
|
id: z.ZodString;
|
|
279
313
|
path: z.ZodString;
|
|
@@ -461,6 +495,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
461
495
|
url: string;
|
|
462
496
|
token: string;
|
|
463
497
|
} | undefined;
|
|
498
|
+
pausedUntil?: string | null | undefined;
|
|
464
499
|
skipAgentPrompt?: boolean | undefined;
|
|
465
500
|
branchHint?: string | undefined;
|
|
466
501
|
branchPlan?: {
|
|
@@ -508,6 +543,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
508
543
|
costUsd?: number | undefined;
|
|
509
544
|
activeMsBase?: number | undefined;
|
|
510
545
|
extraBudgetMinutes?: number | null | undefined;
|
|
546
|
+
pausedUntil?: unknown;
|
|
511
547
|
skipAgentPrompt?: unknown;
|
|
512
548
|
branchHint?: unknown;
|
|
513
549
|
branchPlan?: unknown;
|
|
@@ -555,6 +591,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
555
591
|
url: string;
|
|
556
592
|
token: string;
|
|
557
593
|
} | undefined;
|
|
594
|
+
pausedUntil?: string | null | undefined;
|
|
558
595
|
skipAgentPrompt?: boolean | undefined;
|
|
559
596
|
branchHint?: string | undefined;
|
|
560
597
|
branchPlan?: {
|
|
@@ -608,6 +645,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
608
645
|
costUsd?: number | undefined;
|
|
609
646
|
activeMsBase?: number | undefined;
|
|
610
647
|
extraBudgetMinutes?: number | null | undefined;
|
|
648
|
+
pausedUntil?: unknown;
|
|
611
649
|
skipAgentPrompt?: unknown;
|
|
612
650
|
branchHint?: unknown;
|
|
613
651
|
branchPlan?: unknown;
|
|
@@ -658,6 +696,22 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
658
696
|
epoch: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
659
697
|
activeMsBase: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
660
698
|
extraBudgetMinutes: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
|
699
|
+
/**
|
|
700
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
701
|
+
*
|
|
702
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
703
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
704
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
705
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
706
|
+
* else about the session.
|
|
707
|
+
*
|
|
708
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
709
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
710
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
711
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
712
|
+
* behaviour every runner had before this release.
|
|
713
|
+
*/
|
|
714
|
+
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
661
715
|
workspace: z.ZodObject<{
|
|
662
716
|
id: z.ZodString;
|
|
663
717
|
path: z.ZodString;
|
|
@@ -845,6 +899,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
845
899
|
url: string;
|
|
846
900
|
token: string;
|
|
847
901
|
} | undefined;
|
|
902
|
+
pausedUntil?: string | null | undefined;
|
|
848
903
|
skipAgentPrompt?: boolean | undefined;
|
|
849
904
|
branchHint?: string | undefined;
|
|
850
905
|
branchPlan?: {
|
|
@@ -892,6 +947,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
892
947
|
costUsd?: number | undefined;
|
|
893
948
|
activeMsBase?: number | undefined;
|
|
894
949
|
extraBudgetMinutes?: number | null | undefined;
|
|
950
|
+
pausedUntil?: unknown;
|
|
895
951
|
skipAgentPrompt?: unknown;
|
|
896
952
|
branchHint?: unknown;
|
|
897
953
|
branchPlan?: unknown;
|
|
@@ -937,6 +993,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
937
993
|
url: string;
|
|
938
994
|
token: string;
|
|
939
995
|
} | undefined;
|
|
996
|
+
pausedUntil?: string | null | undefined;
|
|
940
997
|
skipAgentPrompt?: boolean | undefined;
|
|
941
998
|
branchHint?: string | undefined;
|
|
942
999
|
branchPlan?: {
|
|
@@ -987,6 +1044,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
987
1044
|
costUsd?: number | undefined;
|
|
988
1045
|
activeMsBase?: number | undefined;
|
|
989
1046
|
extraBudgetMinutes?: number | null | undefined;
|
|
1047
|
+
pausedUntil?: unknown;
|
|
990
1048
|
skipAgentPrompt?: unknown;
|
|
991
1049
|
branchHint?: unknown;
|
|
992
1050
|
branchPlan?: unknown;
|
|
@@ -1109,6 +1167,18 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1109
1167
|
}, {
|
|
1110
1168
|
sessionId: string;
|
|
1111
1169
|
type: "session_interrupt";
|
|
1170
|
+
}>, z.ZodObject<{
|
|
1171
|
+
type: z.ZodLiteral<"session_pause">;
|
|
1172
|
+
sessionId: z.ZodString;
|
|
1173
|
+
pausedUntil: z.ZodNullable<z.ZodString>;
|
|
1174
|
+
}, "strip", z.ZodTypeAny, {
|
|
1175
|
+
sessionId: string;
|
|
1176
|
+
type: "session_pause";
|
|
1177
|
+
pausedUntil: string | null;
|
|
1178
|
+
}, {
|
|
1179
|
+
sessionId: string;
|
|
1180
|
+
type: "session_pause";
|
|
1181
|
+
pausedUntil: string | null;
|
|
1112
1182
|
}>, z.ZodObject<{
|
|
1113
1183
|
type: z.ZodLiteral<"server_settings">;
|
|
1114
1184
|
maxSessions: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
package/dist/protocol.js
CHANGED
|
@@ -40,6 +40,22 @@ export const SessionDescriptorSchema = z.object({
|
|
|
40
40
|
activeMsBase: z.number().int().min(0).optional().default(0),
|
|
41
41
|
// Extra minutes granted by «Продолжить», on top of the workspace budget.
|
|
42
42
|
extraBudgetMinutes: z.number().int().min(0).nullable().optional().default(null),
|
|
43
|
+
/**
|
|
44
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
45
|
+
*
|
|
46
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
47
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
48
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
49
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
50
|
+
* else about the session.
|
|
51
|
+
*
|
|
52
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
53
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
54
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
55
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
56
|
+
* behaviour every runner had before this release.
|
|
57
|
+
*/
|
|
58
|
+
pausedUntil: z.string().max(40).nullable().optional().catch(undefined),
|
|
43
59
|
workspace: z.object({
|
|
44
60
|
id: z.string().uuid(),
|
|
45
61
|
path: z.string(),
|
|
@@ -237,6 +253,26 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
|
|
|
237
253
|
}),
|
|
238
254
|
z.object({ type: z.literal('session_stop'), sessionId: z.string().uuid() }),
|
|
239
255
|
z.object({ type: z.literal('session_interrupt'), sessionId: z.string().uuid() }),
|
|
256
|
+
/**
|
|
257
|
+
* The session is held under a clock, or the clock is off — ticket #196.
|
|
258
|
+
*
|
|
259
|
+
* ONE frame carrying the whole state, not a pause/resume pair: two frames are
|
|
260
|
+
* two ways for a runner to end up believing something the row does not say,
|
|
261
|
+
* and `pausedUntil: null` already IS «resumed». The API's own SSE frame is
|
|
262
|
+
* built the same way and for the same reason.
|
|
263
|
+
*
|
|
264
|
+
* Until this existed a pause was a single `session_interrupt` — and an
|
|
265
|
+
* interrupt is an event, not a state. It could arrive before the turn it was
|
|
266
|
+
* meant to stop (both agents drop an interrupt with no active turn), and it
|
|
267
|
+
* said nothing about the minutes that followed: queued messages flushed,
|
|
268
|
+
* auto-resume relaunched, and a background subagent's report woke a whole new
|
|
269
|
+
* turn, all while every screen said «На паузе».
|
|
270
|
+
*/
|
|
271
|
+
z.object({
|
|
272
|
+
type: z.literal('session_pause'),
|
|
273
|
+
sessionId: z.string().uuid(),
|
|
274
|
+
pausedUntil: z.string().max(40).nullable(),
|
|
275
|
+
}),
|
|
240
276
|
// Server-wide settings changed mid-connection (session 8) — today just the
|
|
241
277
|
// parallel-session ceiling.
|
|
242
278
|
z.object({
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -371,8 +371,41 @@ export declare class Supervisor {
|
|
|
371
371
|
* fire and forget — a failure here is logged, and the records stay on disk.
|
|
372
372
|
*/
|
|
373
373
|
private flushPendingMessages;
|
|
374
|
-
/**
|
|
374
|
+
/**
|
|
375
|
+
* Stop the current turn without ending the session (VS-Code-style Stop).
|
|
376
|
+
*
|
|
377
|
+
* @param reason `'pause'` when a clock did it rather than a person. Only the
|
|
378
|
+
* words in the feed differ — «Turn interrupted by the user» over a turn the
|
|
379
|
+
* user did not touch is the kind of small lie that costs an hour of
|
|
380
|
+
* debugging later.
|
|
381
|
+
* @param announce Say it in the feed. The pause watchdog passes `false`: the
|
|
382
|
+
* line was already written when the clock landed, and one per background
|
|
383
|
+
* turn it stops would bury the feed under a fact nobody asked about.
|
|
384
|
+
*/
|
|
375
385
|
private interruptSession;
|
|
386
|
+
/** Is this session held under a clock right now (ticket #196)? */
|
|
387
|
+
private static isPaused;
|
|
388
|
+
/**
|
|
389
|
+
* Set or clear the clock this session is held under (ticket #196).
|
|
390
|
+
*
|
|
391
|
+
* `null` means the pause is over. Releasing does NOT start anything by
|
|
392
|
+
* itself: the API sends the messages that were waiting through the ordinary
|
|
393
|
+
* message path, and anything this runner was holding is flushed here.
|
|
394
|
+
*/
|
|
395
|
+
private applyPause;
|
|
396
|
+
/**
|
|
397
|
+
* The turn started anyway — stop it (ticket #196).
|
|
398
|
+
*
|
|
399
|
+
* The last line of defence, and the only one that can catch a turn nobody
|
|
400
|
+
* outside the agent process asked for: a background subagent's report wakes a
|
|
401
|
+
* new turn inside the CLI (gotcha #244), and an interrupted Codex turn starts
|
|
402
|
+
* the next queued one by itself. Neither passes through any frame this runner
|
|
403
|
+
* could refuse, so the only place to notice them is the events they produce.
|
|
404
|
+
*
|
|
405
|
+
* Deliberately quiet: the notice was already written when the pause landed,
|
|
406
|
+
* and one per interrupted background turn would bury the feed.
|
|
407
|
+
*/
|
|
408
|
+
private stopWorkUnderPause;
|
|
376
409
|
/** Live model / interaction-mode switch (persisted for the next relaunch). */
|
|
377
410
|
private applySettings;
|
|
378
411
|
/** Is a turn (or a question the agent is parked on) in flight right now? */
|
package/dist/supervisor.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { log } from './log.js';
|
|
4
4
|
import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
|
|
5
5
|
import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
|
|
6
|
-
import { agentPromptSizeLabel, quotePath, readAgentPrompt } from './agent-prompt.js';
|
|
6
|
+
import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
|
|
7
7
|
import { JournalStore } from './journal.js';
|
|
8
8
|
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
|
|
9
9
|
import { readRecipeProposal } from './recipe.js';
|
|
@@ -220,6 +220,9 @@ export class Supervisor {
|
|
|
220
220
|
case 'session_interrupt':
|
|
221
221
|
await this.interruptSession(frame.sessionId);
|
|
222
222
|
break;
|
|
223
|
+
case 'session_pause':
|
|
224
|
+
await this.applyPause(frame.sessionId, frame.pausedUntil);
|
|
225
|
+
break;
|
|
223
226
|
case 'session_settings':
|
|
224
227
|
await this.applySettings(frame.sessionId, frame.model, frame.mode, frame.effort);
|
|
225
228
|
break;
|
|
@@ -263,6 +266,11 @@ export class Supervisor {
|
|
|
263
266
|
existing.stopRequested = true;
|
|
264
267
|
existing.session?.stop('session_stopped');
|
|
265
268
|
}
|
|
269
|
+
// The descriptor is newer than what this session was built from, so its
|
|
270
|
+
// clock is too (QA-149 MAJOR-1). Applied on the way out of every early
|
|
271
|
+
// return, not just the reconnect one — a descriptor re-sent for any
|
|
272
|
+
// reason is the freshest thing this runner will see about the pause.
|
|
273
|
+
await this.applyPause(descriptor.id, descriptor.pausedUntil ?? null);
|
|
266
274
|
return;
|
|
267
275
|
}
|
|
268
276
|
// Up to `maxSessions` agents at once (session 8). Sessions idling after a
|
|
@@ -301,6 +309,11 @@ export class Supervisor {
|
|
|
301
309
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
302
310
|
epoch: descriptor.epoch,
|
|
303
311
|
openQuestions: new Set(),
|
|
312
|
+
// Ticket #196: a pause is part of what a session IS, so it is read off
|
|
313
|
+
// the descriptor rather than waiting for a frame. Without this a runner
|
|
314
|
+
// that restarted mid-pause would come back knowing nothing and pick the
|
|
315
|
+
// work straight back up.
|
|
316
|
+
...pausedUntilOf(descriptor),
|
|
304
317
|
mode: descriptor.mode,
|
|
305
318
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
306
319
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -512,6 +525,19 @@ export class Supervisor {
|
|
|
512
525
|
branch: running.branch,
|
|
513
526
|
worktreePath: running.worktreePath,
|
|
514
527
|
});
|
|
528
|
+
// Ticket #196: a Stop or a pause that arrived while this process was coming
|
|
529
|
+
// up was dropped on the floor — `interruptSession` returns early when there
|
|
530
|
+
// is no adapter yet, and the window covers preparing the worktree and
|
|
531
|
+
// hashing the whole tree for a checkpoint. The turn it was aimed at is
|
|
532
|
+
// exactly the one starting now, so it is answered here, at the first moment
|
|
533
|
+
// there is something to answer it with.
|
|
534
|
+
if (running.interruptWhenReady || Supervisor.isPaused(running)) {
|
|
535
|
+
delete running.interruptWhenReady;
|
|
536
|
+
void this.interruptSession(descriptor.id, Supervisor.isPaused(running) ? 'pause' : 'user').catch((error) => log.warn('supervisor: could not honour an interrupt requested during startup', {
|
|
537
|
+
sessionId: descriptor.id,
|
|
538
|
+
error: String(error),
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
515
541
|
void this.pumpEvents(running);
|
|
516
542
|
return true;
|
|
517
543
|
}
|
|
@@ -959,6 +985,9 @@ export class Supervisor {
|
|
|
959
985
|
for (const waiting of this.sessions.values()) {
|
|
960
986
|
if (waiting.session || waiting.stopRequested || waiting.budgetSpent)
|
|
961
987
|
continue;
|
|
988
|
+
// A freed slot is not a reason to start work somebody put on hold (#196).
|
|
989
|
+
if (Supervisor.isPaused(waiting))
|
|
990
|
+
continue;
|
|
962
991
|
if (waiting.pendingMessages.length === 0 || !waiting.worktreePath)
|
|
963
992
|
continue;
|
|
964
993
|
if (this.liveSessionCount(waiting.descriptor.id) >= this.maxSessions)
|
|
@@ -1125,8 +1154,19 @@ export class Supervisor {
|
|
|
1125
1154
|
// Anything below that is the agent talking means the agent is working. Read
|
|
1126
1155
|
// before the switch so every such case gets it, including the ones added
|
|
1127
1156
|
// after this line was written.
|
|
1128
|
-
if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type))
|
|
1129
|
-
|
|
1157
|
+
if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type)) {
|
|
1158
|
+
// Ticket #196. The same signal, read twice for opposite reasons: when the
|
|
1159
|
+
// session is free it proves the agent is working (#185), and when the
|
|
1160
|
+
// session is held under a clock it proves something started that should
|
|
1161
|
+
// not have. The second is the ONLY way to catch a turn nobody outside the
|
|
1162
|
+
// agent process asked for — a background subagent's report (gotcha #244)
|
|
1163
|
+
// and Codex's own «interrupted, so start the next queued turn» both come
|
|
1164
|
+
// through no frame this runner could refuse.
|
|
1165
|
+
if (Supervisor.isPaused(running))
|
|
1166
|
+
this.stopWorkUnderPause(running);
|
|
1167
|
+
else
|
|
1168
|
+
this.noteAgentIsWorking(running);
|
|
1169
|
+
}
|
|
1130
1170
|
switch (event.type) {
|
|
1131
1171
|
case 'provider_session': {
|
|
1132
1172
|
running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
|
|
@@ -1495,10 +1535,16 @@ export class Supervisor {
|
|
|
1495
1535
|
...(attachments?.length ? { attachments } : {}),
|
|
1496
1536
|
});
|
|
1497
1537
|
const originSeq = echoed.seq;
|
|
1498
|
-
if (!running.worktreePath) {
|
|
1538
|
+
if (!running.worktreePath || Supervisor.isPaused(running)) {
|
|
1499
1539
|
// Session is still being prepared — deliver after launch (QA-96 F3).
|
|
1500
1540
|
// Attachments travel as metadata and are downloaded at delivery time,
|
|
1501
1541
|
// which is the first moment the worktree is guaranteed to exist.
|
|
1542
|
+
//
|
|
1543
|
+
// Or the session is held under a clock (#196). The API refuses live
|
|
1544
|
+
// messages for a paused session, but not every path goes through that
|
|
1545
|
+
// check — the outbox flushes on reconnect, and the git service posts its
|
|
1546
|
+
// own conflict tasks. Held here rather than delivered, so «на паузе»
|
|
1547
|
+
// means the same thing whichever door the words came through.
|
|
1502
1548
|
this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
|
|
1503
1549
|
return;
|
|
1504
1550
|
}
|
|
@@ -1780,6 +1826,11 @@ export class Supervisor {
|
|
|
1780
1826
|
* fire and forget — a failure here is logged, and the records stay on disk.
|
|
1781
1827
|
*/
|
|
1782
1828
|
flushPendingMessages(running) {
|
|
1829
|
+
// Ticket #196: six places call this — a launch, a mode relaunch, a freed
|
|
1830
|
+
// slot, a delivery, and twice on reconnect — and none of them knew about a
|
|
1831
|
+
// pause. Held work stays held until the clock is off.
|
|
1832
|
+
if (Supervisor.isPaused(running))
|
|
1833
|
+
return;
|
|
1783
1834
|
const pending = running.pendingMessages.splice(0);
|
|
1784
1835
|
if (pending.length === 0)
|
|
1785
1836
|
return;
|
|
@@ -1816,11 +1867,29 @@ export class Supervisor {
|
|
|
1816
1867
|
this.deliverMessage(running, parts.join('\n\n'), pending);
|
|
1817
1868
|
});
|
|
1818
1869
|
}
|
|
1819
|
-
/**
|
|
1820
|
-
|
|
1870
|
+
/**
|
|
1871
|
+
* Stop the current turn without ending the session (VS-Code-style Stop).
|
|
1872
|
+
*
|
|
1873
|
+
* @param reason `'pause'` when a clock did it rather than a person. Only the
|
|
1874
|
+
* words in the feed differ — «Turn interrupted by the user» over a turn the
|
|
1875
|
+
* user did not touch is the kind of small lie that costs an hour of
|
|
1876
|
+
* debugging later.
|
|
1877
|
+
* @param announce Say it in the feed. The pause watchdog passes `false`: the
|
|
1878
|
+
* line was already written when the clock landed, and one per background
|
|
1879
|
+
* turn it stops would bury the feed under a fact nobody asked about.
|
|
1880
|
+
*/
|
|
1881
|
+
async interruptSession(sessionId, reason = 'user', announce = true) {
|
|
1821
1882
|
const running = this.sessions.get(sessionId);
|
|
1822
|
-
if (!running
|
|
1883
|
+
if (!running)
|
|
1884
|
+
return;
|
|
1885
|
+
if (!running.session) {
|
|
1886
|
+
// The process is still coming up. Remembered rather than dropped: the
|
|
1887
|
+
// launch honours this the moment the adapter exists, so a Stop or a pause
|
|
1888
|
+
// pressed during startup stops the turn it was aimed at instead of
|
|
1889
|
+
// vanishing (#196).
|
|
1890
|
+
running.interruptWhenReady = true;
|
|
1823
1891
|
return;
|
|
1892
|
+
}
|
|
1824
1893
|
// The turn being interrupted is the turn the question belongs to — leaving
|
|
1825
1894
|
// the ask parked would make the user's next message be swallowed as its
|
|
1826
1895
|
// answer (QA-106 m7). The adapter reports each withdrawal itself; the local
|
|
@@ -1828,13 +1897,98 @@ export class Supervisor {
|
|
|
1828
1897
|
running.session.cancelQuestions('turn_aborted');
|
|
1829
1898
|
running.openQuestions.clear();
|
|
1830
1899
|
await running.session.interrupt();
|
|
1831
|
-
|
|
1900
|
+
if (announce) {
|
|
1901
|
+
this.sendEvent(running, 'notice', {
|
|
1902
|
+
level: 'info',
|
|
1903
|
+
text: reason === 'pause'
|
|
1904
|
+
? 'Paused — the turn was stopped. Nothing else will start until the clock runs out.'
|
|
1905
|
+
: 'Turn interrupted by the user',
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1832
1908
|
const next = running.descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
|
|
1833
1909
|
this.reportStatus(sessionId, next, {
|
|
1834
1910
|
costUsd: running.costUsd,
|
|
1835
1911
|
activeMs: Supervisor.spentMs(running),
|
|
1836
1912
|
});
|
|
1837
1913
|
}
|
|
1914
|
+
/** Is this session held under a clock right now (ticket #196)? */
|
|
1915
|
+
static isPaused(running) {
|
|
1916
|
+
return running.pausedUntil !== undefined && running.pausedUntil > Date.now();
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Set or clear the clock this session is held under (ticket #196).
|
|
1920
|
+
*
|
|
1921
|
+
* `null` means the pause is over. Releasing does NOT start anything by
|
|
1922
|
+
* itself: the API sends the messages that were waiting through the ordinary
|
|
1923
|
+
* message path, and anything this runner was holding is flushed here.
|
|
1924
|
+
*/
|
|
1925
|
+
async applyPause(sessionId, pausedUntil) {
|
|
1926
|
+
const running = this.sessions.get(sessionId);
|
|
1927
|
+
if (!running)
|
|
1928
|
+
return;
|
|
1929
|
+
const until = pausedUntil ? Date.parse(pausedUntil) : Number.NaN;
|
|
1930
|
+
if (Number.isFinite(until) && until > Date.now()) {
|
|
1931
|
+
const first = !Supervisor.isPaused(running);
|
|
1932
|
+
running.pausedUntil = until;
|
|
1933
|
+
// Interrupting on every pause frame, not only the first: the frame is
|
|
1934
|
+
// also how a MOVED pause arrives, and a turn that slipped through in
|
|
1935
|
+
// between has to be stopped too. `interruptSession` is idempotent — an
|
|
1936
|
+
// adapter with nothing to interrupt does nothing.
|
|
1937
|
+
if (first || running.lastReported === 'RUNNING' || running.lastReported === 'STARTING') {
|
|
1938
|
+
await this.interruptSession(sessionId, 'pause');
|
|
1939
|
+
}
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (running.pausedUntil === undefined)
|
|
1943
|
+
return; // already free
|
|
1944
|
+
delete running.pausedUntil;
|
|
1945
|
+
// Both of these belong to the clock and must go with it (QA-149 MINOR-1).
|
|
1946
|
+
// A pause set while the process was still coming up and cancelled a second
|
|
1947
|
+
// later used to leave `interruptWhenReady` standing — and the launch then
|
|
1948
|
+
// killed the session's first turn and signed it «Turn interrupted by the
|
|
1949
|
+
// user», over a turn the user never touched. Exactly the lie #196 removed.
|
|
1950
|
+
delete running.interruptWhenReady;
|
|
1951
|
+
delete running.pauseInterruptAt;
|
|
1952
|
+
// Whatever was held while the clock ran goes now, in the order it arrived.
|
|
1953
|
+
this.flushPendingMessages(running);
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* The turn started anyway — stop it (ticket #196).
|
|
1957
|
+
*
|
|
1958
|
+
* The last line of defence, and the only one that can catch a turn nobody
|
|
1959
|
+
* outside the agent process asked for: a background subagent's report wakes a
|
|
1960
|
+
* new turn inside the CLI (gotcha #244), and an interrupted Codex turn starts
|
|
1961
|
+
* the next queued one by itself. Neither passes through any frame this runner
|
|
1962
|
+
* could refuse, so the only place to notice them is the events they produce.
|
|
1963
|
+
*
|
|
1964
|
+
* Deliberately quiet: the notice was already written when the pause landed,
|
|
1965
|
+
* and one per interrupted background turn would bury the feed.
|
|
1966
|
+
*/
|
|
1967
|
+
stopWorkUnderPause(running) {
|
|
1968
|
+
if (!Supervisor.isPaused(running) || !running.session)
|
|
1969
|
+
return;
|
|
1970
|
+
// One interrupt per burst, not per event. An abort is not instant: a turn
|
|
1971
|
+
// being stopped still emits whatever was already in flight, and without
|
|
1972
|
+
// this every one of those lines would fire another `interrupt()` — a
|
|
1973
|
+
// hundred round trips into the CLI to stop something that is already
|
|
1974
|
+
// stopping. Two seconds is far shorter than the gap before a genuinely NEW
|
|
1975
|
+
// turn (a background subagent finishing), which still gets its own.
|
|
1976
|
+
const now = Date.now();
|
|
1977
|
+
if (running.pauseInterruptAt !== undefined && now - running.pauseInterruptAt < 2_000)
|
|
1978
|
+
return;
|
|
1979
|
+
running.pauseInterruptAt = now;
|
|
1980
|
+
// Through the ordinary path rather than straight at the adapter (QA-149
|
|
1981
|
+
// MAJOR-2): it also withdraws the cards this turn had opened. A turn killed
|
|
1982
|
+
// with a question still parked leaves the person's next message to be
|
|
1983
|
+
// swallowed as an answer to it — the QA-106 m7 trap, which the main
|
|
1984
|
+
// interrupt path has guarded against since it was found.
|
|
1985
|
+
void this.interruptSession(running.descriptor.id, 'pause', false).catch((error) => {
|
|
1986
|
+
log.warn('supervisor: could not stop a turn that started under a pause', {
|
|
1987
|
+
sessionId: running.descriptor.id,
|
|
1988
|
+
error: String(error),
|
|
1989
|
+
});
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1838
1992
|
/** Live model / interaction-mode switch (persisted for the next relaunch). */
|
|
1839
1993
|
async applySettings(sessionId, model, mode, effort) {
|
|
1840
1994
|
const running = this.sessions.get(sessionId);
|
|
@@ -2026,6 +2180,19 @@ export class Supervisor {
|
|
|
2026
2180
|
tracked.epoch = descriptor.epoch;
|
|
2027
2181
|
tracked.descriptor = { ...tracked.descriptor, epoch: descriptor.epoch };
|
|
2028
2182
|
}
|
|
2183
|
+
// Ticket #196, QA-149 MAJOR-1. The pause is re-established HERE, and
|
|
2184
|
+
// this is the case that matters most: a dropped socket leaves the agent
|
|
2185
|
+
// process running, so «reconnect» is precisely when a session is
|
|
2186
|
+
// `tracked`. The first cut of #196 read `pausedUntil` only in the two
|
|
2187
|
+
// constructors of a NEW `RunningSession`, which meant it survived a
|
|
2188
|
+
// runner RESTART and not a reconnect — and a pause set while the socket
|
|
2189
|
+
// was down never arrived at all, because `session_pause` is
|
|
2190
|
+
// fire-and-forget with no outbox behind it.
|
|
2191
|
+
//
|
|
2192
|
+
// Both directions matter: the row may have gained a clock (hold now) or
|
|
2193
|
+
// lost one (release and send what was held). `applyPause` does both, and
|
|
2194
|
+
// it runs BEFORE `flushSessionOutbox` arrives from the API side.
|
|
2195
|
+
await this.applyPause(descriptor.id, descriptor.pausedUntil ?? null);
|
|
2029
2196
|
this.reportStatus(descriptor.id, statusForReport(tracked), {
|
|
2030
2197
|
costUsd: tracked.costUsd,
|
|
2031
2198
|
...(tracked.branch ? { branch: tracked.branch } : {}),
|
|
@@ -2091,6 +2258,7 @@ export class Supervisor {
|
|
|
2091
2258
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
2092
2259
|
epoch: descriptor.epoch,
|
|
2093
2260
|
openQuestions: new Set(),
|
|
2261
|
+
...pausedUntilOf(descriptor),
|
|
2094
2262
|
mode: descriptor.mode,
|
|
2095
2263
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
2096
2264
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -2135,7 +2303,15 @@ export class Supervisor {
|
|
|
2135
2303
|
// middle of" — would be addressed to an agent that remembers none of
|
|
2136
2304
|
// it. A process killed before it reported its session id (the SIGABRT
|
|
2137
2305
|
// this ticket came from) leaves the row in exactly that state.
|
|
2138
|
-
|
|
2306
|
+
// Ticket #196: a paused session is never continued automatically. The
|
|
2307
|
+
// row still says RUNNING — a pause interrupts the turn but is not a
|
|
2308
|
+
// status — so without this the reconnect would read «mid-turn» and
|
|
2309
|
+
// relaunch the agent with «continue from where you stopped», which is
|
|
2310
|
+
// the exact opposite of what the clock was set for.
|
|
2311
|
+
const willContinue = wasMidTurn &&
|
|
2312
|
+
!Supervisor.isPaused(running) &&
|
|
2313
|
+
Boolean(resumeId) &&
|
|
2314
|
+
claimAutoResume(descriptor.id);
|
|
2139
2315
|
// The note stays either way (owner's call): an interruption is a fact
|
|
2140
2316
|
// about the session and must not disappear just because we recovered
|
|
2141
2317
|
// from it. Only the instruction at the end changes — telling someone to
|
|
@@ -2918,6 +3094,28 @@ export class Supervisor {
|
|
|
2918
3094
|
this.opts.onRestartRequested?.(outcome);
|
|
2919
3095
|
return;
|
|
2920
3096
|
}
|
|
3097
|
+
/**
|
|
3098
|
+
* «Would this file reach the agent, and how big is it» (ticket #192).
|
|
3099
|
+
*
|
|
3100
|
+
* The settings card asks before a session exists, so the answer has to
|
|
3101
|
+
* come from the machine that would do the reading: the file is on this
|
|
3102
|
+
* disk, and the ceiling belongs to THIS build of the runner. A dashboard
|
|
3103
|
+
* carrying its own copy of either would be a screen that can be a
|
|
3104
|
+
* version wrong about a rule it is stating.
|
|
3105
|
+
*
|
|
3106
|
+
* Answers `ok: true` even when the file would be refused — «too large»
|
|
3107
|
+
* is an answer about the file, not a failure of the command, and the
|
|
3108
|
+
* card has to be able to draw it.
|
|
3109
|
+
*/
|
|
3110
|
+
case 'agent_prompt_state': {
|
|
3111
|
+
const root = str(frame.args?.['root']) ?? str(frame.args?.['workspacePath']);
|
|
3112
|
+
if (!root)
|
|
3113
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
3114
|
+
const path = str(frame.args?.['path']);
|
|
3115
|
+
if (!path)
|
|
3116
|
+
return void reply({ ok: false, error: 'path argument is required' });
|
|
3117
|
+
return void reply({ ok: true, result: inspectAgentPrompt(root, path) });
|
|
3118
|
+
}
|
|
2921
3119
|
// ─── Session 14: the project recipe ──────────────────────────
|
|
2922
3120
|
//
|
|
2923
3121
|
// Read-only, always available even when verification is switched off:
|
|
@@ -3297,6 +3495,20 @@ export class Supervisor {
|
|
|
3297
3495
|
this.verify.shutdown();
|
|
3298
3496
|
}
|
|
3299
3497
|
}
|
|
3498
|
+
/**
|
|
3499
|
+
* The pause carried by a descriptor, as a field that can be spread (#196).
|
|
3500
|
+
*
|
|
3501
|
+
* A clock already in the past is read as no clock at all: the API clears it on
|
|
3502
|
+
* release, but a runner that was down when the release happened would otherwise
|
|
3503
|
+
* hold the session for ever on a number nobody is going to update.
|
|
3504
|
+
*/
|
|
3505
|
+
function pausedUntilOf(descriptor) {
|
|
3506
|
+
const raw = descriptor.pausedUntil;
|
|
3507
|
+
if (!raw)
|
|
3508
|
+
return {};
|
|
3509
|
+
const until = Date.parse(raw);
|
|
3510
|
+
return Number.isFinite(until) && until > Date.now() ? { pausedUntil: until } : {};
|
|
3511
|
+
}
|
|
3300
3512
|
function str(value) {
|
|
3301
3513
|
return typeof value === 'string' && value ? value : null;
|
|
3302
3514
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.36.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED