@aragon-agent/cli 0.6.29 → 0.6.32
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/CHANGELOG.md +2223 -2103
- package/README.md +2957 -2759
- package/dist/agent/controller.d.ts +46 -0
- package/dist/agent/controller.js +175 -7
- package/dist/agent/job-notices.d.ts +143 -0
- package/dist/agent/job-notices.js +182 -0
- package/dist/agent/project-guidance-prompt.js +20 -20
- package/dist/agent/reducer.d.ts +21 -1
- package/dist/agent/reducer.js +7 -0
- package/dist/agent/system-prompt.d.ts +12 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +399 -62
- package/dist/commands/builtins.js +27 -8
- package/dist/commands/perf.d.ts +79 -2
- package/dist/commands/perf.js +51 -3
- package/dist/commands/registry.d.ts +6 -0
- package/dist/config/cli-commands.d.ts +27 -0
- package/dist/config/cli-commands.js +54 -1
- package/dist/config/env.js +42 -0
- package/dist/config/load.d.ts +3 -1
- package/dist/config/load.js +61 -1
- package/dist/config/schema.d.ts +100 -0
- package/dist/config/schema.js +43 -0
- package/dist/config/store.js +14 -1
- package/dist/config/ui-state.d.ts +9 -0
- package/dist/config/ui-state.js +9 -0
- package/dist/exec/events.d.ts +29 -3
- package/dist/exec/events.js +3 -12
- package/dist/exec/runner.d.ts +24 -0
- package/dist/exec/runner.js +79 -0
- package/dist/input/cursor-report.d.ts +61 -0
- package/dist/input/cursor-report.js +97 -0
- package/dist/input/stdin-filter.d.ts +22 -0
- package/dist/input/stdin-filter.js +48 -3
- package/dist/proc/limits.d.ts +76 -2
- package/dist/proc/limits.js +83 -2
- package/dist/proc/log-mirror.d.ts +59 -0
- package/dist/proc/log-mirror.js +203 -0
- package/dist/proc/log-ring.d.ts +22 -0
- package/dist/proc/log-ring.js +20 -1
- package/dist/proc/notices.d.ts +57 -0
- package/dist/proc/notices.js +101 -0
- package/dist/proc/progress.d.ts +40 -0
- package/dist/proc/progress.js +135 -0
- package/dist/proc/prompt.d.ts +24 -2
- package/dist/proc/prompt.js +51 -2
- package/dist/proc/supervisor.d.ts +35 -2
- package/dist/proc/supervisor.js +142 -43
- package/dist/proc/types.d.ts +89 -1
- package/dist/proc/wait.d.ts +57 -0
- package/dist/proc/wait.js +133 -0
- package/dist/session/persist.js +3 -1
- package/dist/session/validate-session.js +17 -0
- package/dist/tools/bash-tool.d.ts +12 -1
- package/dist/tools/bash-tool.js +100 -13
- package/dist/tools/index.d.ts +26 -2
- package/dist/tools/index.js +43 -2
- package/dist/tools/job-tools.d.ts +54 -0
- package/dist/tools/job-tools.js +329 -0
- package/dist/tools/proc-tools.d.ts +1 -1
- package/dist/tools/proc-tools.js +49 -17
- package/dist/ui/ActivityLine.js +7 -0
- package/dist/ui/App.d.ts +11 -1
- package/dist/ui/App.js +181 -63
- package/dist/ui/ScrollbackShell.d.ts +54 -0
- package/dist/ui/ScrollbackShell.js +15 -0
- package/dist/ui/StatusBar.d.ts +5 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/Transcript.d.ts +14 -0
- package/dist/ui/Transcript.js +14 -2
- package/dist/ui/entries/ServiceCard.d.ts +17 -1
- package/dist/ui/entries/ServiceCard.js +39 -4
- package/dist/ui/frame-differ.d.ts +12 -0
- package/dist/ui/layout/status-layout.d.ts +13 -1
- package/dist/ui/layout/status-layout.js +10 -0
- package/dist/ui/layout/virtual-window.js +14 -1
- package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
- package/dist/ui/overlays/HelpOverlay.js +21 -3
- package/dist/ui/run-status-row.d.ts +12 -0
- package/dist/ui/screen.d.ts +29 -0
- package/dist/ui/screen.js +40 -0
- package/dist/ui/scrollback/LiveTail.d.ts +64 -0
- package/dist/ui/scrollback/LiveTail.js +27 -0
- package/dist/ui/scrollback/band.d.ts +105 -0
- package/dist/ui/scrollback/band.js +109 -0
- package/dist/ui/scrollback/budget.d.ts +72 -0
- package/dist/ui/scrollback/budget.js +81 -0
- package/dist/ui/scrollback/canvas.d.ts +99 -0
- package/dist/ui/scrollback/canvas.js +113 -0
- package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
- package/dist/ui/scrollback/cursor-probe.js +133 -0
- package/dist/ui/scrollback/drain.d.ts +32 -0
- package/dist/ui/scrollback/drain.js +64 -0
- package/dist/ui/scrollback/exit-summary.d.ts +23 -0
- package/dist/ui/scrollback/exit-summary.js +25 -0
- package/dist/ui/scrollback/limits.d.ts +74 -0
- package/dist/ui/scrollback/limits.js +74 -0
- package/dist/ui/scrollback/live-preview.d.ts +26 -0
- package/dist/ui/scrollback/live-preview.js +106 -0
- package/dist/ui/scrollback/renderer.d.ts +38 -0
- package/dist/ui/scrollback/renderer.js +158 -0
- package/dist/ui/scrollback/scrollback-writer.d.ts +162 -0
- package/dist/ui/scrollback/scrollback-writer.js +549 -0
- package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
- package/dist/ui/scrollback/settled-boundary.js +106 -0
- package/dist/ui/scrollback/use-scrollback-view.d.ts +103 -0
- package/dist/ui/scrollback/use-scrollback-view.js +294 -0
- package/dist/ui/scrollback/writer-shapes.d.ts +127 -0
- package/dist/ui/scrollback/writer-shapes.js +159 -0
- package/dist/ui/stdout-frame-writer.js +12 -2
- package/dist/ui/terminal-plan.d.ts +52 -0
- package/dist/ui/terminal-plan.js +49 -0
- package/dist/ui/transcript-text.d.ts +11 -0
- package/dist/ui/transcript-text.js +21 -7
- package/dist/ui/use-startup-notices.d.ts +12 -1
- package/dist/ui/use-startup-notices.js +28 -2
- package/package.json +3 -3
- package/skills/project-indexer/SKILL.md +163 -163
- package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
- package/skills/project-indexer/templates/cleancode-template.md +94 -94
- package/skills/project-indexer/templates/config-template.md +69 -69
- package/skills/project-indexer/templates/index-template.md +129 -129
- package/skills/skill-creator/SKILL.md +96 -96
|
@@ -246,6 +246,31 @@ export declare class AgentController {
|
|
|
246
246
|
/** Session changes must not make a delayed receipt match a new message. */
|
|
247
247
|
private readonly steeringPrefix;
|
|
248
248
|
private steeringSequence;
|
|
249
|
+
/**
|
|
250
|
+
* OPEN TOOL CALLS IN THIS BATCH (long-task-jobs D-5).
|
|
251
|
+
*
|
|
252
|
+
* COUNTED HERE, NOT BORROWED FROM `FastWiring`'s `TurnFrameRing`: `fast` can be
|
|
253
|
+
* switched off entirely, and a completion notice must not go quiet with it. The
|
|
254
|
+
* cost is a second counter over the same two events, which AC-19 pins with a
|
|
255
|
+
* replay rather than with trust.
|
|
256
|
+
*/
|
|
257
|
+
private activeToolCalls;
|
|
258
|
+
/** The completion-notice queue both the push and the pull channel take from. */
|
|
259
|
+
private readonly jobNotices;
|
|
260
|
+
/** The optional on-disk log mirror; `undefined` when `jobs.persistLog` is false. */
|
|
261
|
+
private jobMirror;
|
|
262
|
+
/** The relay: terminal events in, ledger across `steering_accepted`, safe-point push. */
|
|
263
|
+
private jobRelay;
|
|
264
|
+
/** `cfg.jobs.enabled` AND `cfg.bash.background`, fixed at construction. */
|
|
265
|
+
private jobsRegistered;
|
|
266
|
+
/**
|
|
267
|
+
* One mirror directory per PROCESS.
|
|
268
|
+
*
|
|
269
|
+
* The logs are a debugging aid, not a record with an identity: two sessions
|
|
270
|
+
* writing to one directory would interleave the tails of two different runs, and
|
|
271
|
+
* the retention sweep in `log-mirror.ts` keeps the newest few directories.
|
|
272
|
+
*/
|
|
273
|
+
private readonly jobSessionTag;
|
|
249
274
|
constructor(config: CliConfig, deps?: ControllerDeps);
|
|
250
275
|
private resolveKey;
|
|
251
276
|
/**
|
|
@@ -308,6 +333,16 @@ export declare class AgentController {
|
|
|
308
333
|
private waitForEngineIdle;
|
|
309
334
|
/** Prepare before clearing the plan, then enter the engine without yielding. */
|
|
310
335
|
private startPrompt;
|
|
336
|
+
/**
|
|
337
|
+
* Deliver undelivered job notices, if any and if it is safe to steer.
|
|
338
|
+
*
|
|
339
|
+
* ONE METHOD, TWO CALL SITES, AND NOT INSIDE `prepareUserTurn` (RV-P1-7).
|
|
340
|
+
* `prepareUserTurn` is called from `startPrompt` AND from `continue` (with the
|
|
341
|
+
* CONCATENATED queued text), and it does three specific things for the engine's
|
|
342
|
+
* benefit. Hanging a delivery path off it would make `fast.setGoal`'s input
|
|
343
|
+
* semantics depend on whether a notice happened to be pending.
|
|
344
|
+
*/
|
|
345
|
+
private flushPendingNotices;
|
|
311
346
|
private prepareUserTurn;
|
|
312
347
|
/** Cancel pending startup as well as any engine run already in progress. */
|
|
313
348
|
abort(): void;
|
|
@@ -403,6 +438,17 @@ export declare class AgentController {
|
|
|
403
438
|
hasPendingUserMessages(): boolean;
|
|
404
439
|
private compactionBusyOutcome;
|
|
405
440
|
isRunning(): boolean;
|
|
441
|
+
/**
|
|
442
|
+
* The one condition under which automated text may be injected (I-5).
|
|
443
|
+
*
|
|
444
|
+
* `activeToolCalls === 0` is the whole safety argument: `steer()` has exactly
|
|
445
|
+
* one safe checkpoint - the top of the loop, before the LLM call - so injecting
|
|
446
|
+
* text in the middle of a tool batch replaces the remaining `tool_use` blocks
|
|
447
|
+
* with "skipped due to steering interrupt" and the next request is malformed.
|
|
448
|
+
*/
|
|
449
|
+
private isAtSafeSteerPoint;
|
|
450
|
+
/** How many completion notices are waiting for the model. */
|
|
451
|
+
pendingJobNotices(): number;
|
|
406
452
|
getAgentMode(): AgentMode;
|
|
407
453
|
/**
|
|
408
454
|
* Request a mode change and return what was ACTUALLY ADOPTED.
|
package/dist/agent/controller.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createModelSettingsDraft, saveModelSettings, reloadModelSettings, } from './model-profile-settings.js';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
3
4
|
import { verifyCompactionIdentity } from '../compaction/memory-identity.js';
|
|
4
5
|
import { COMPACTION_LIMITS } from '../compaction/limits.js';
|
|
5
6
|
import { Agent, initProviders, ModelRegistry, } from '@aragon-agent/core';
|
|
@@ -20,9 +21,14 @@ import { resolveSubagentModel } from '../team/model.js';
|
|
|
20
21
|
import { buildTeamBlock } from '../team/prompt.js';
|
|
21
22
|
import { buildFastBlock } from '../fast/prompt.js';
|
|
22
23
|
import { ProcSupervisor } from '../proc/supervisor.js';
|
|
23
|
-
import { buildBackgroundServicesBlock } from '../proc/prompt.js';
|
|
24
|
+
import { buildBackgroundJobsBlock, buildBackgroundServicesBlock } from '../proc/prompt.js';
|
|
24
25
|
import { PROC_LIMITS } from '../proc/limits.js';
|
|
26
|
+
import { JobLogMirror } from '../proc/log-mirror.js';
|
|
27
|
+
import { JobNoticeQueue } from '../proc/notices.js';
|
|
25
28
|
import { makeBashKill, makeBashOutput } from '../tools/proc-tools.js';
|
|
29
|
+
import { makeJobList, makeJobWait } from '../tools/job-tools.js';
|
|
30
|
+
import { JobNoticeRelay } from './job-notices.js';
|
|
31
|
+
import { getHomeRoot } from '../config/app-paths.js';
|
|
26
32
|
import { addSignalHook } from '../logging/install.js';
|
|
27
33
|
import { describeFastTierProblem, fastProviderOf, resolveFastTier } from '../fast/resolve.js';
|
|
28
34
|
import { FastWiring, offFastStatus } from '../fast/wiring.js';
|
|
@@ -271,6 +277,31 @@ export class AgentController {
|
|
|
271
277
|
/** Session changes must not make a delayed receipt match a new message. */
|
|
272
278
|
steeringPrefix = randomUUID();
|
|
273
279
|
steeringSequence = 0;
|
|
280
|
+
/**
|
|
281
|
+
* OPEN TOOL CALLS IN THIS BATCH (long-task-jobs D-5).
|
|
282
|
+
*
|
|
283
|
+
* COUNTED HERE, NOT BORROWED FROM `FastWiring`'s `TurnFrameRing`: `fast` can be
|
|
284
|
+
* switched off entirely, and a completion notice must not go quiet with it. The
|
|
285
|
+
* cost is a second counter over the same two events, which AC-19 pins with a
|
|
286
|
+
* replay rather than with trust.
|
|
287
|
+
*/
|
|
288
|
+
activeToolCalls = 0;
|
|
289
|
+
/** The completion-notice queue both the push and the pull channel take from. */
|
|
290
|
+
jobNotices = new JobNoticeQueue();
|
|
291
|
+
/** The optional on-disk log mirror; `undefined` when `jobs.persistLog` is false. */
|
|
292
|
+
jobMirror;
|
|
293
|
+
/** The relay: terminal events in, ledger across `steering_accepted`, safe-point push. */
|
|
294
|
+
jobRelay;
|
|
295
|
+
/** `cfg.jobs.enabled` AND `cfg.bash.background`, fixed at construction. */
|
|
296
|
+
jobsRegistered = false;
|
|
297
|
+
/**
|
|
298
|
+
* One mirror directory per PROCESS.
|
|
299
|
+
*
|
|
300
|
+
* The logs are a debugging aid, not a record with an identity: two sessions
|
|
301
|
+
* writing to one directory would interleave the tails of two different runs, and
|
|
302
|
+
* the retention sweep in `log-mirror.ts` keeps the newest few directories.
|
|
303
|
+
*/
|
|
304
|
+
jobSessionTag = Date.now().toString(36);
|
|
274
305
|
constructor(config, deps = {}) {
|
|
275
306
|
this.config = config;
|
|
276
307
|
this.cwd = config.cwd;
|
|
@@ -411,7 +442,22 @@ export class AgentController {
|
|
|
411
442
|
// comment. `readyTimeoutMs` is read through a closure rather than captured,
|
|
412
443
|
// so a live config change reaches the next service rather than the next
|
|
413
444
|
// launch of the CLI.
|
|
414
|
-
|
|
445
|
+
// `jobs` is the FIRST section whose registration depends on two keys: the
|
|
446
|
+
// tools need a supervisor to talk to (`bash.background`) and their own switch.
|
|
447
|
+
// With either off, no `job_*` tool, no prompt block, no mirror - which is what
|
|
448
|
+
// makes I-3 (byte-identical when off) measurable rather than asserted.
|
|
449
|
+
this.jobsRegistered = config.jobs.enabled && config.bash.background;
|
|
450
|
+
this.jobMirror =
|
|
451
|
+
this.jobsRegistered && config.jobs.persistLog
|
|
452
|
+
? new JobLogMirror({ root: () => join(getHomeRoot(), 'jobs', this.jobSessionTag) })
|
|
453
|
+
: undefined;
|
|
454
|
+
this.procs = new ProcSupervisor({
|
|
455
|
+
readyTimeoutMs: () => this.config.bash.readyTimeoutMs,
|
|
456
|
+
// Live closures, not captured values: a `config set` must reach the card of
|
|
457
|
+
// the job already running.
|
|
458
|
+
stallMs: () => this.config.jobs.stallSeconds * 1000,
|
|
459
|
+
...(this.jobMirror ? { mirror: this.jobMirror } : {}),
|
|
460
|
+
});
|
|
415
461
|
this.notifyHostFn = deps.notify;
|
|
416
462
|
this.bashBackgroundRegistered = config.bash.background;
|
|
417
463
|
// THE EXIT REAPER (G5 / P1-2 / R-10). An APPEND-ONLY hook rather than a
|
|
@@ -420,6 +466,22 @@ export class AgentController {
|
|
|
420
466
|
// that leaves the user staring at a blank alternate screen. `reapSync` is
|
|
421
467
|
// the only entry point it calls, and it is synchronous end to end (I-9).
|
|
422
468
|
this.releaseSignalHook = addSignalHook(() => this.procs.reapSync());
|
|
469
|
+
// THE RELAY IS BUILT HERE, BEFORE THE TOOLS, because the tools take its
|
|
470
|
+
// `drain` as the PULL port and the supervisor's stream as their source. Its
|
|
471
|
+
// `steer` is the same receipt-less path `FastWiring` already uses, and its
|
|
472
|
+
// safe-point test is this class's own batch counter (D-5).
|
|
473
|
+
this.jobRelay = new JobNoticeRelay({
|
|
474
|
+
queue: this.jobNotices,
|
|
475
|
+
steer: (text) => this.enqueueSteering(text, false),
|
|
476
|
+
isAtSafeSteerPoint: () => this.isAtSafeSteerPoint(),
|
|
477
|
+
// The FILL channel precondition: no tool batch open, nothing being aborted -
|
|
478
|
+
// and deliberately no isRunning(), because this path runs just BEFORE
|
|
479
|
+
// agent.prompt() / agent.continue(), where the engine is idle by contract.
|
|
480
|
+
// The dep documents why that one missing clause is the whole difference.
|
|
481
|
+
canSteerBeforeTurn: () => this.activeToolCalls === 0 && !this.abortRequested,
|
|
482
|
+
steerEnabled: () => this.config.jobs.noticeSteer,
|
|
483
|
+
});
|
|
484
|
+
this.jobRelay.attach((listener) => this.procs.subscribe(listener));
|
|
423
485
|
this.tools = createBuiltinTools({
|
|
424
486
|
getCwd: () => this.cwd,
|
|
425
487
|
// OWNED HERE, NOT IN `cli.tsx` (D-18): `createBuiltinTools` is called from
|
|
@@ -503,6 +565,35 @@ export class AgentController {
|
|
|
503
565
|
procTools: this.bashBackgroundRegistered
|
|
504
566
|
? [makeBashOutput({ supervisor: this.procs }), makeBashKill({ supervisor: this.procs })]
|
|
505
567
|
: [],
|
|
568
|
+
// THROUGH THE FACTORY for the reason above, and GATED ON BOTH KEYS:
|
|
569
|
+
// `job_wait` with no supervisor to read is a tool that can only say "no
|
|
570
|
+
// jobs", and `jobs.enabled: false` must leave the array byte-identical to
|
|
571
|
+
// the baseline (I-3).
|
|
572
|
+
jobTools: this.jobsRegistered
|
|
573
|
+
? [
|
|
574
|
+
makeJobWait({
|
|
575
|
+
supervisor: this.procs,
|
|
576
|
+
waitSeconds: () => this.config.jobs.waitSeconds,
|
|
577
|
+
stallSeconds: () => this.config.jobs.stallSeconds,
|
|
578
|
+
toolTimeoutMs: () => this.config.toolTimeoutMs,
|
|
579
|
+
maxConsecutiveWaits: PROC_LIMITS.maxConsecutiveWaits,
|
|
580
|
+
terminalRecord: (id) => this.jobRelay?.terminalRecord(id),
|
|
581
|
+
}),
|
|
582
|
+
makeJobList({
|
|
583
|
+
supervisor: this.procs,
|
|
584
|
+
waitSeconds: () => this.config.jobs.waitSeconds,
|
|
585
|
+
stallSeconds: () => this.config.jobs.stallSeconds,
|
|
586
|
+
toolTimeoutMs: () => this.config.toolTimeoutMs,
|
|
587
|
+
maxConsecutiveWaits: PROC_LIMITS.maxConsecutiveWaits,
|
|
588
|
+
terminalRecord: (id) => this.jobRelay?.terminalRecord(id),
|
|
589
|
+
}),
|
|
590
|
+
]
|
|
591
|
+
: [],
|
|
592
|
+
// The PULL port. Absent when jobs are off, which leaves every tool object
|
|
593
|
+
// untouched and provable by identity.
|
|
594
|
+
...(this.jobsRegistered
|
|
595
|
+
? { jobNotices: { drain: () => this.jobRelay?.drainForTail() ?? [] } }
|
|
596
|
+
: {}),
|
|
506
597
|
// THROUGH THE FACTORY, NEVER APPENDED AFTERWARDS (D-15 / P0-1).
|
|
507
598
|
// `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals what
|
|
508
599
|
// `createBuiltinTools` produces, so appending `task` here would turn that
|
|
@@ -720,13 +811,32 @@ export class AgentController {
|
|
|
720
811
|
if (event.type === 'steering_accepted') {
|
|
721
812
|
for (const id of event.ids)
|
|
722
813
|
this.pendingUserSteering.delete(id);
|
|
814
|
+
// A receipt means Core took a batch out of the queue, which is the only
|
|
815
|
+
// proof a steered notice has that it was not discarded (§4.6.1).
|
|
816
|
+
this.jobRelay?.onSteeringAccepted();
|
|
723
817
|
}
|
|
724
|
-
//
|
|
725
|
-
// the
|
|
726
|
-
|
|
727
|
-
|
|
818
|
+
// THE BATCH COUNTER (D-5 / I-5). Decremented BEFORE the push attempt below,
|
|
819
|
+
// because "the batch is empty" is only true after the last one has ended.
|
|
820
|
+
if (event.type === 'tool_execution_start')
|
|
821
|
+
this.activeToolCalls += 1;
|
|
728
822
|
if (event.type === 'tool_execution_end') {
|
|
729
|
-
this.
|
|
823
|
+
this.activeToolCalls = Math.max(0, this.activeToolCalls - 1);
|
|
824
|
+
// THE ONE PUSH SITE. A notice that could not be pushed here is picked up
|
|
825
|
+
// by the next tool result (the pull channel) or by the next user turn
|
|
826
|
+
// (the fill channel), so nothing is lost by declining.
|
|
827
|
+
this.jobRelay?.tryPush();
|
|
828
|
+
}
|
|
829
|
+
if (event.type === 'turn_start') {
|
|
830
|
+
// A new turn's first checkpoint is a safe point, and anything still
|
|
831
|
+
// steered-but-unproven must go back in the queue first: `fast`'s
|
|
832
|
+
// `recoverStranded()` may have wiped it at the end of the last run.
|
|
833
|
+
this.jobRelay?.requeueUndelivered();
|
|
834
|
+
}
|
|
835
|
+
if (event.type === 'agent_end') {
|
|
836
|
+
// The same reconciliation at the other end of a run. Both ends are
|
|
837
|
+
// required: `turn_start` covers the next turn of the same run, and this
|
|
838
|
+
// covers a run that ended with a notice stranded in the queue.
|
|
839
|
+
this.jobRelay?.requeueUndelivered();
|
|
730
840
|
}
|
|
731
841
|
});
|
|
732
842
|
if (this.fast) {
|
|
@@ -818,6 +928,11 @@ export class AgentController {
|
|
|
818
928
|
// `todo.enabled` rule). So the block is present exactly when the tools are,
|
|
819
929
|
// and can never advertise a capability the model does not have.
|
|
820
930
|
backgroundBlock: this.bashBackgroundRegistered ? buildBackgroundServicesBlock() : '',
|
|
931
|
+
// ONE FLAG, like `backgroundBlock` and for the same reason: `jobs.enabled`
|
|
932
|
+
// is read once at construction, so the block is present exactly when the
|
|
933
|
+
// tools that make its promises achievable are registered. With the flag off
|
|
934
|
+
// the prompt is byte-identical to the pre-feature text (I-3).
|
|
935
|
+
jobsBlock: this.jobsRegistered ? buildBackgroundJobsBlock() : '',
|
|
821
936
|
// Read from the FIELD on every rebuild, for the reason the field's own
|
|
822
937
|
// comment gives. Empty everywhere but `aragon exec`, and `buildSystemPrompt`
|
|
823
938
|
// splices it conditionally, so every existing prompt is byte-identical.
|
|
@@ -977,9 +1092,30 @@ export class AgentController {
|
|
|
977
1092
|
return { status: 'not-started', reason: 'cancelled' };
|
|
978
1093
|
}
|
|
979
1094
|
this.todos?.beginUserTurn(options.todoPolicy);
|
|
1095
|
+
// BEFORE the engine is entered, so the loop's first checkpoint (the top of
|
|
1096
|
+
// the loop, before any LLM call) merges them into history. Anything left over
|
|
1097
|
+
// from the previous run - a notice `fast` wiped, or one the model never saw
|
|
1098
|
+
// because it answered and stopped - is delivered here (RV-P1-7).
|
|
1099
|
+
this.flushPendingNotices();
|
|
980
1100
|
await this.agent.prompt(text);
|
|
981
1101
|
return { status: 'finished' };
|
|
982
1102
|
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Deliver undelivered job notices, if any and if it is safe to steer.
|
|
1105
|
+
*
|
|
1106
|
+
* ONE METHOD, TWO CALL SITES, AND NOT INSIDE `prepareUserTurn` (RV-P1-7).
|
|
1107
|
+
* `prepareUserTurn` is called from `startPrompt` AND from `continue` (with the
|
|
1108
|
+
* CONCATENATED queued text), and it does three specific things for the engine's
|
|
1109
|
+
* benefit. Hanging a delivery path off it would make `fast.setGoal`'s input
|
|
1110
|
+
* semantics depend on whether a notice happened to be pending.
|
|
1111
|
+
*/
|
|
1112
|
+
flushPendingNotices() {
|
|
1113
|
+
// FLUSH, NOT PUSH. The push channel requires `isRunning()`, and the engine is
|
|
1114
|
+
// idle on this path by contract - reusing it here would leave every stranded
|
|
1115
|
+
// notice waiting for a tool batch that may never come, which is exactly the
|
|
1116
|
+
// failure AC-25 exists to prevent.
|
|
1117
|
+
this.jobRelay?.flushForTurn();
|
|
1118
|
+
}
|
|
983
1119
|
prepareUserTurn(text) {
|
|
984
1120
|
if (this.skillsEnabled)
|
|
985
1121
|
this.skills.beginUserTurn();
|
|
@@ -1167,6 +1303,10 @@ export class AgentController {
|
|
|
1167
1303
|
}
|
|
1168
1304
|
this.todos?.beginUserTurn('new-task');
|
|
1169
1305
|
}
|
|
1306
|
+
// The `continue()` path needs its own delivery for the reason RV-P1-7 gives:
|
|
1307
|
+
// a run that ended with a notice still queued resumes through here, and this
|
|
1308
|
+
// is the most common way a stranded notice gets its second chance.
|
|
1309
|
+
this.flushPendingNotices();
|
|
1170
1310
|
await this.agent.continue();
|
|
1171
1311
|
return { status: 'finished' };
|
|
1172
1312
|
}
|
|
@@ -1179,6 +1319,21 @@ export class AgentController {
|
|
|
1179
1319
|
isRunning() {
|
|
1180
1320
|
return this.agent.state.isRunning;
|
|
1181
1321
|
}
|
|
1322
|
+
/**
|
|
1323
|
+
* The one condition under which automated text may be injected (I-5).
|
|
1324
|
+
*
|
|
1325
|
+
* `activeToolCalls === 0` is the whole safety argument: `steer()` has exactly
|
|
1326
|
+
* one safe checkpoint - the top of the loop, before the LLM call - so injecting
|
|
1327
|
+
* text in the middle of a tool batch replaces the remaining `tool_use` blocks
|
|
1328
|
+
* with "skipped due to steering interrupt" and the next request is malformed.
|
|
1329
|
+
*/
|
|
1330
|
+
isAtSafeSteerPoint() {
|
|
1331
|
+
return this.activeToolCalls === 0 && this.isRunning() && !this.abortRequested;
|
|
1332
|
+
}
|
|
1333
|
+
/** How many completion notices are waiting for the model. */
|
|
1334
|
+
pendingJobNotices() {
|
|
1335
|
+
return this.jobNotices.pending();
|
|
1336
|
+
}
|
|
1182
1337
|
// -----------------------------------------------------------------------
|
|
1183
1338
|
// Plan mode (§3.1 / §3.2)
|
|
1184
1339
|
// -----------------------------------------------------------------------
|
|
@@ -1373,6 +1528,12 @@ export class AgentController {
|
|
|
1373
1528
|
this.procs.reapSync();
|
|
1374
1529
|
this.releaseSignalHook();
|
|
1375
1530
|
this.procs.dispose();
|
|
1531
|
+
// THE MIRROR FLUSHES BEFORE THE RELAY DETACHES: whatever the last job wrote is
|
|
1532
|
+
// already on disk in the ring, and the file is the only copy that survives
|
|
1533
|
+
// this process.
|
|
1534
|
+
this.jobRelay?.detach();
|
|
1535
|
+
this.jobMirror?.dispose();
|
|
1536
|
+
this.jobNotices.clear();
|
|
1376
1537
|
}
|
|
1377
1538
|
// -----------------------------------------------------------------------
|
|
1378
1539
|
// Todo planning (todo-plan-execution §3.6a)
|
|
@@ -1840,6 +2001,13 @@ export class AgentController {
|
|
|
1840
2001
|
clearAllQueues() {
|
|
1841
2002
|
this.agent.clearAllQueues();
|
|
1842
2003
|
this.pendingUserSteering.clear();
|
|
2004
|
+
// A WIPE OF THE STEERING QUEUE MUST NOT TAKE A NOTICE WITH IT (I-11).
|
|
2005
|
+
// `fast/reviewer.ts` clears this queue when `userSteerCount() === 0`, on the
|
|
2006
|
+
// stated premise that `AgentController.steer()` is the only other writer - and
|
|
2007
|
+
// a steered job notice is invisible to that counter because it carries no
|
|
2008
|
+
// receipt id. Anything still unproven goes straight back into the notice
|
|
2009
|
+
// queue, so the wipe costs a delay instead of a fact the model never learns.
|
|
2010
|
+
this.jobRelay?.requeueUndelivered();
|
|
1843
2011
|
}
|
|
1844
2012
|
// -----------------------------------------------------------------------
|
|
1845
2013
|
// Model / provider info
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JobNoticeRelay — the controller-side owner of "a job finished, and the model
|
|
3
|
+
* has to be told exactly once" (long-task-jobs §4.6 / §4.6.1).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* THREE CHANNELS, ONE QUEUE, AND A LEDGER. The queue guarantees "at most once"
|
|
8
|
+
* because every channel takes from it with `drain()`. That alone is NOT enough,
|
|
9
|
+
* and this file exists because of the exception: Core's steering queue has a
|
|
10
|
+
* fourth consumer that DESTROYS rather than drains.
|
|
11
|
+
*
|
|
12
|
+
* `fast/reviewer.ts` calls `clearAllQueues()` when `userSteerCount() === 0`,
|
|
13
|
+
* and its stated safety argument is "`AgentController.steer()` is the only
|
|
14
|
+
* other writer in the package". A job notice queued through
|
|
15
|
+
* `enqueueSteering(text, false)` returns no receipt id, never enters
|
|
16
|
+
* `pendingUserSteering`, and is therefore INVISIBLE to that counter. With
|
|
17
|
+
* `fast` on, the last notice of a run could be wiped with no log line, no UI
|
|
18
|
+
* change and no failing test.
|
|
19
|
+
*
|
|
20
|
+
* THE FIX IS A LEDGER, NOT A COUPLING. A notice that has been steered is held in
|
|
21
|
+
* `pendingSteered` until a `steering_accepted` receipt proves the queue was
|
|
22
|
+
* drained; anything still held at `turn_start` or at the end of a run is pushed
|
|
23
|
+
* back into the queue. `JobNoticeQueue.push` is idempotent by id, so a re-queue
|
|
24
|
+
* can only ever cause a DELAY, never a duplicate.
|
|
25
|
+
*
|
|
26
|
+
* NOTHING HERE IS PUSHED WHILE A TOOL BATCH IS OPEN. The controller owns the
|
|
27
|
+
* batch count and calls `tryPush()` only from the one safe point; this class
|
|
28
|
+
* re-checks the same condition so a future call site cannot forget it (I-5).
|
|
29
|
+
*/
|
|
30
|
+
import { type JobNotice, type JobNoticeQueue } from '../proc/notices.js';
|
|
31
|
+
import { type JobKind, type ProcEvent, type ServiceStatus } from '../proc/types.js';
|
|
32
|
+
export interface JobNoticeRelayDeps {
|
|
33
|
+
queue: JobNoticeQueue;
|
|
34
|
+
/** `enqueueSteering(text, false)` - the existing receipt-less path (§4.6). */
|
|
35
|
+
steer: (text: string) => void;
|
|
36
|
+
/** `activeToolCalls === 0 && isRunning() && !abortRequested` (I-5). */
|
|
37
|
+
isAtSafeSteerPoint: () => boolean;
|
|
38
|
+
/**
|
|
39
|
+
* `activeToolCalls === 0 && !abortRequested` - the FILL channel's precondition.
|
|
40
|
+
*
|
|
41
|
+
* DELIBERATELY NOT `isAtSafeSteerPoint()`, and the difference is one clause.
|
|
42
|
+
* The fill channel runs immediately BEFORE `agent.prompt()` / `agent.continue()`,
|
|
43
|
+
* where `isRunning()` is FALSE by contract (Core throws
|
|
44
|
+
* `Agent is already running` if it were true), so reusing the push predicate
|
|
45
|
+
* would make this channel dead - every stranded notice would wait for the next
|
|
46
|
+
* tool batch instead of going out with the user's message.
|
|
47
|
+
*
|
|
48
|
+
* What I-5 protects is "never inject text while a tool batch is open", and
|
|
49
|
+
* `activeToolCalls === 0` is exactly that clause; the `isRunning()` half of the
|
|
50
|
+
* push predicate exists for a different reason (a push is worthless if no run
|
|
51
|
+
* will ever drain it). Steering just before a run starts is the case the
|
|
52
|
+
* `steering_accepted` receipt was designed for: the loop's first checkpoint is
|
|
53
|
+
* the top of the loop, before any `tool_use` exists, so the text is merged into
|
|
54
|
+
* history structurally safely.
|
|
55
|
+
*/
|
|
56
|
+
canSteerBeforeTurn: () => boolean;
|
|
57
|
+
/** `cfg.jobs.noticeSteer`, read LIVE: `false` disables the PUSH channel only. */
|
|
58
|
+
steerEnabled: () => boolean;
|
|
59
|
+
}
|
|
60
|
+
/** A terminal id this session has already reported (RV-P2-7's third state). */
|
|
61
|
+
interface RecalledJob {
|
|
62
|
+
kind: JobKind;
|
|
63
|
+
status: ServiceStatus;
|
|
64
|
+
}
|
|
65
|
+
export declare class JobNoticeRelay {
|
|
66
|
+
private readonly deps;
|
|
67
|
+
/**
|
|
68
|
+
* Notices that have been steered but not yet proven delivered, keyed by
|
|
69
|
+
* `${id}:${status}:${at}` - the same identity `JobNoticeQueue` uses.
|
|
70
|
+
*/
|
|
71
|
+
private readonly pendingSteered;
|
|
72
|
+
/**
|
|
73
|
+
* Terminal ids this session has seen, so `job_wait` can answer "it finished and
|
|
74
|
+
* was reclaimed, do not re-run it" instead of "no such job".
|
|
75
|
+
*
|
|
76
|
+
* Bounded by `maxNotices`, oldest out: it answers a question about the recent
|
|
77
|
+
* past, and an unbounded map in a long session is a leak with a helpful name.
|
|
78
|
+
*/
|
|
79
|
+
private readonly recalled;
|
|
80
|
+
private unsubscribe;
|
|
81
|
+
constructor(deps: JobNoticeRelayDeps);
|
|
82
|
+
/** Subscribe to the supervisor's stream. Returns the release function. */
|
|
83
|
+
attach(subscribe: (listener: (event: ProcEvent) => void) => () => void): void;
|
|
84
|
+
detach(): void;
|
|
85
|
+
/** Terminal ids this session has already reported, for `job_wait`'s message. */
|
|
86
|
+
terminalRecord(id: string): RecalledJob | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Move everything queued into the steering queue, if this is a safe moment.
|
|
89
|
+
*
|
|
90
|
+
* Returns whether anything was pushed. Called from exactly one place in the
|
|
91
|
+
* controller - the `tool_execution_end` listener, after the batch count reaches
|
|
92
|
+
* zero.
|
|
93
|
+
*/
|
|
94
|
+
tryPush(): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Deliver the queue just BEFORE a run starts (the FILL channel, §4.6).
|
|
97
|
+
*
|
|
98
|
+
* TWO DIFFERENCES FROM `tryPush()`, both deliberate:
|
|
99
|
+
*
|
|
100
|
+
* - the precondition is `canSteerBeforeTurn()` (no `isRunning()` term), because
|
|
101
|
+
* this runs before `agent.prompt()` / `agent.continue()`, where the engine is
|
|
102
|
+
* not running yet - see the dep's own comment;
|
|
103
|
+
* - `noticeSteer` does NOT gate it. That key switches the PUSH channel only
|
|
104
|
+
* ("noticeSteer=false is not a notification off switch", §6.5), so a user who
|
|
105
|
+
* declines mid-turn interruptions must still learn that the job finished
|
|
106
|
+
* before their next message is sent.
|
|
107
|
+
*/
|
|
108
|
+
flushForTurn(): boolean;
|
|
109
|
+
/** The shared half: take the queue, record the ledger, steer one block. */
|
|
110
|
+
private steerQueued;
|
|
111
|
+
/**
|
|
112
|
+
* A receipt means Core accepted a batch of steering text, which means the queue
|
|
113
|
+
* it came from has been drained.
|
|
114
|
+
*
|
|
115
|
+
* Notices carry no receipt id (`steer(text, id?)`'s `id` is optional and the
|
|
116
|
+
* receipt-less path passes none), so an arriving receipt is the only available
|
|
117
|
+
* proof. Treating "a receipt arrived" as "everything we steered was delivered"
|
|
118
|
+
* is correct in the only direction that matters: an over-eager release costs a
|
|
119
|
+
* MISSING notice, so the release is deferred to the next `steering_accepted`
|
|
120
|
+
* cycle rather than assumed at `steer()` time.
|
|
121
|
+
*/
|
|
122
|
+
onSteeringAccepted(): void;
|
|
123
|
+
/**
|
|
124
|
+
* Put undelivered notices back at `turn_start` and at the end of a run.
|
|
125
|
+
*
|
|
126
|
+
* THIS IS WHAT TURNS "swallowed by clearAllQueues()" INTO "delivered one turn
|
|
127
|
+
* later". `push` deduplicates by id, so a notice that WAS delivered and is also
|
|
128
|
+
* re-queued can only be re-rendered once - and it is not re-queued at all,
|
|
129
|
+
* because a delivered notice was released by its receipt.
|
|
130
|
+
*/
|
|
131
|
+
requeueUndelivered(): void;
|
|
132
|
+
/**
|
|
133
|
+
* Drain for the PULL channel.
|
|
134
|
+
*
|
|
135
|
+
* NOTHING IS RECORDED HERE, and that is the point: `drain()` hands ownership to
|
|
136
|
+
* the tool result that is about to carry the block, so there is no pending
|
|
137
|
+
* state to reconcile - the text is in the conversation as soon as the result
|
|
138
|
+
* returns.
|
|
139
|
+
*/
|
|
140
|
+
drainForTail(): JobNotice[];
|
|
141
|
+
private onProcEvent;
|
|
142
|
+
}
|
|
143
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JobNoticeRelay — the controller-side owner of "a job finished, and the model
|
|
3
|
+
* has to be told exactly once" (long-task-jobs §4.6 / §4.6.1).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* THREE CHANNELS, ONE QUEUE, AND A LEDGER. The queue guarantees "at most once"
|
|
8
|
+
* because every channel takes from it with `drain()`. That alone is NOT enough,
|
|
9
|
+
* and this file exists because of the exception: Core's steering queue has a
|
|
10
|
+
* fourth consumer that DESTROYS rather than drains.
|
|
11
|
+
*
|
|
12
|
+
* `fast/reviewer.ts` calls `clearAllQueues()` when `userSteerCount() === 0`,
|
|
13
|
+
* and its stated safety argument is "`AgentController.steer()` is the only
|
|
14
|
+
* other writer in the package". A job notice queued through
|
|
15
|
+
* `enqueueSteering(text, false)` returns no receipt id, never enters
|
|
16
|
+
* `pendingUserSteering`, and is therefore INVISIBLE to that counter. With
|
|
17
|
+
* `fast` on, the last notice of a run could be wiped with no log line, no UI
|
|
18
|
+
* change and no failing test.
|
|
19
|
+
*
|
|
20
|
+
* THE FIX IS A LEDGER, NOT A COUPLING. A notice that has been steered is held in
|
|
21
|
+
* `pendingSteered` until a `steering_accepted` receipt proves the queue was
|
|
22
|
+
* drained; anything still held at `turn_start` or at the end of a run is pushed
|
|
23
|
+
* back into the queue. `JobNoticeQueue.push` is idempotent by id, so a re-queue
|
|
24
|
+
* can only ever cause a DELAY, never a duplicate.
|
|
25
|
+
*
|
|
26
|
+
* NOTHING HERE IS PUSHED WHILE A TOOL BATCH IS OPEN. The controller owns the
|
|
27
|
+
* batch count and calls `tryPush()` only from the one safe point; this class
|
|
28
|
+
* re-checks the same condition so a future call site cannot forget it (I-5).
|
|
29
|
+
*/
|
|
30
|
+
import { renderJobNotices } from '../proc/notices.js';
|
|
31
|
+
import { isTerminalStatus } from '../proc/types.js';
|
|
32
|
+
export class JobNoticeRelay {
|
|
33
|
+
deps;
|
|
34
|
+
/**
|
|
35
|
+
* Notices that have been steered but not yet proven delivered, keyed by
|
|
36
|
+
* `${id}:${status}:${at}` - the same identity `JobNoticeQueue` uses.
|
|
37
|
+
*/
|
|
38
|
+
pendingSteered = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Terminal ids this session has seen, so `job_wait` can answer "it finished and
|
|
41
|
+
* was reclaimed, do not re-run it" instead of "no such job".
|
|
42
|
+
*
|
|
43
|
+
* Bounded by `maxNotices`, oldest out: it answers a question about the recent
|
|
44
|
+
* past, and an unbounded map in a long session is a leak with a helpful name.
|
|
45
|
+
*/
|
|
46
|
+
recalled = new Map();
|
|
47
|
+
unsubscribe;
|
|
48
|
+
constructor(deps) {
|
|
49
|
+
this.deps = deps;
|
|
50
|
+
}
|
|
51
|
+
/** Subscribe to the supervisor's stream. Returns the release function. */
|
|
52
|
+
attach(subscribe) {
|
|
53
|
+
this.unsubscribe?.();
|
|
54
|
+
this.unsubscribe = subscribe((event) => this.onProcEvent(event));
|
|
55
|
+
}
|
|
56
|
+
detach() {
|
|
57
|
+
this.unsubscribe?.();
|
|
58
|
+
this.unsubscribe = undefined;
|
|
59
|
+
}
|
|
60
|
+
/** Terminal ids this session has already reported, for `job_wait`'s message. */
|
|
61
|
+
terminalRecord(id) {
|
|
62
|
+
return this.recalled.get(id);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Move everything queued into the steering queue, if this is a safe moment.
|
|
66
|
+
*
|
|
67
|
+
* Returns whether anything was pushed. Called from exactly one place in the
|
|
68
|
+
* controller - the `tool_execution_end` listener, after the batch count reaches
|
|
69
|
+
* zero.
|
|
70
|
+
*/
|
|
71
|
+
tryPush() {
|
|
72
|
+
if (!this.deps.steerEnabled())
|
|
73
|
+
return false;
|
|
74
|
+
if (!this.deps.isAtSafeSteerPoint())
|
|
75
|
+
return false;
|
|
76
|
+
return this.steerQueued();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Deliver the queue just BEFORE a run starts (the FILL channel, §4.6).
|
|
80
|
+
*
|
|
81
|
+
* TWO DIFFERENCES FROM `tryPush()`, both deliberate:
|
|
82
|
+
*
|
|
83
|
+
* - the precondition is `canSteerBeforeTurn()` (no `isRunning()` term), because
|
|
84
|
+
* this runs before `agent.prompt()` / `agent.continue()`, where the engine is
|
|
85
|
+
* not running yet - see the dep's own comment;
|
|
86
|
+
* - `noticeSteer` does NOT gate it. That key switches the PUSH channel only
|
|
87
|
+
* ("noticeSteer=false is not a notification off switch", §6.5), so a user who
|
|
88
|
+
* declines mid-turn interruptions must still learn that the job finished
|
|
89
|
+
* before their next message is sent.
|
|
90
|
+
*/
|
|
91
|
+
flushForTurn() {
|
|
92
|
+
if (!this.deps.canSteerBeforeTurn())
|
|
93
|
+
return false;
|
|
94
|
+
return this.steerQueued();
|
|
95
|
+
}
|
|
96
|
+
/** The shared half: take the queue, record the ledger, steer one block. */
|
|
97
|
+
steerQueued() {
|
|
98
|
+
const notices = this.deps.queue.drain();
|
|
99
|
+
if (notices.length === 0)
|
|
100
|
+
return false;
|
|
101
|
+
for (const notice of notices)
|
|
102
|
+
this.pendingSteered.set(noticeKey(notice), notice);
|
|
103
|
+
this.deps.steer(renderJobNotices(notices));
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A receipt means Core accepted a batch of steering text, which means the queue
|
|
108
|
+
* it came from has been drained.
|
|
109
|
+
*
|
|
110
|
+
* Notices carry no receipt id (`steer(text, id?)`'s `id` is optional and the
|
|
111
|
+
* receipt-less path passes none), so an arriving receipt is the only available
|
|
112
|
+
* proof. Treating "a receipt arrived" as "everything we steered was delivered"
|
|
113
|
+
* is correct in the only direction that matters: an over-eager release costs a
|
|
114
|
+
* MISSING notice, so the release is deferred to the next `steering_accepted`
|
|
115
|
+
* cycle rather than assumed at `steer()` time.
|
|
116
|
+
*/
|
|
117
|
+
onSteeringAccepted() {
|
|
118
|
+
this.pendingSteered.clear();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Put undelivered notices back at `turn_start` and at the end of a run.
|
|
122
|
+
*
|
|
123
|
+
* THIS IS WHAT TURNS "swallowed by clearAllQueues()" INTO "delivered one turn
|
|
124
|
+
* later". `push` deduplicates by id, so a notice that WAS delivered and is also
|
|
125
|
+
* re-queued can only be re-rendered once - and it is not re-queued at all,
|
|
126
|
+
* because a delivered notice was released by its receipt.
|
|
127
|
+
*/
|
|
128
|
+
requeueUndelivered() {
|
|
129
|
+
if (this.pendingSteered.size === 0)
|
|
130
|
+
return;
|
|
131
|
+
const stranded = [...this.pendingSteered.values()];
|
|
132
|
+
this.pendingSteered.clear();
|
|
133
|
+
for (const notice of stranded)
|
|
134
|
+
this.deps.queue.push(notice);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Drain for the PULL channel.
|
|
138
|
+
*
|
|
139
|
+
* NOTHING IS RECORDED HERE, and that is the point: `drain()` hands ownership to
|
|
140
|
+
* the tool result that is about to carry the block, so there is no pending
|
|
141
|
+
* state to reconcile - the text is in the conversation as soon as the result
|
|
142
|
+
* returns.
|
|
143
|
+
*/
|
|
144
|
+
drainForTail() {
|
|
145
|
+
return this.deps.queue.drain();
|
|
146
|
+
}
|
|
147
|
+
onProcEvent(event) {
|
|
148
|
+
const job = event.service;
|
|
149
|
+
if (!isTerminalStatus(job.status))
|
|
150
|
+
return;
|
|
151
|
+
const kind = job.kind;
|
|
152
|
+
this.recalled.set(job.id, { kind, status: job.status });
|
|
153
|
+
while (this.recalled.size > 32) {
|
|
154
|
+
const oldest = this.recalled.keys().next();
|
|
155
|
+
if (oldest.done === true)
|
|
156
|
+
break;
|
|
157
|
+
this.recalled.delete(oldest.value);
|
|
158
|
+
}
|
|
159
|
+
// One notice per terminal transition. `exited`/`stopped` are single events per
|
|
160
|
+
// job (the supervisor's own guards enforce that), so no de-duplication is
|
|
161
|
+
// needed here beyond the queue's own.
|
|
162
|
+
if (job.status !== 'exited' && job.status !== 'failed' && job.status !== 'stopped')
|
|
163
|
+
return;
|
|
164
|
+
this.deps.queue.push({
|
|
165
|
+
id: job.id,
|
|
166
|
+
kind,
|
|
167
|
+
status: job.status,
|
|
168
|
+
exitCode: job.exitCode,
|
|
169
|
+
signal: job.signal,
|
|
170
|
+
elapsedMs: (job.endedAt ?? Date.now()) - job.startedAt,
|
|
171
|
+
...(job.progress?.text ? { progressText: job.progress.text } : {}),
|
|
172
|
+
// A FAILURE EARNS A TAIL; a clean exit does not (the block stays bounded).
|
|
173
|
+
tailRows: job.status === 'failed' || (job.exitCode !== null && job.exitCode !== 0)
|
|
174
|
+
? job.rows.slice(-2)
|
|
175
|
+
: [],
|
|
176
|
+
at: Date.now(),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function noticeKey(notice) {
|
|
181
|
+
return `${notice.id}:${notice.status}:${notice.at}`;
|
|
182
|
+
}
|