@bridge4dev/runner 0.52.0 → 0.54.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.
@@ -4,6 +4,7 @@ import { selfUpdate, type SelfUpdateOutcome } from './self-update.js';
4
4
  import { installAgent } from './agent-install.js';
5
5
  import { pruneNativeClaudeVersions } from './agent-cleanup.js';
6
6
  import { type AgentVersionsMeasurement } from './agent-versions.js';
7
+ import { type HostLoadFrame } from './host-load.js';
7
8
  import type { RunnerWsClient } from './ws-client.js';
8
9
  import type { SessionDescriptor } from './protocol.js';
9
10
  import type { AgentAdapter } from './adapters/types.js';
@@ -122,6 +123,29 @@ export interface SupervisorOptions {
122
123
  * in production.
123
124
  */
124
125
  stopSettleMs?: number;
126
+ /**
127
+ * Test seam for the host-load measurement (§5.3).
128
+ *
129
+ * A seam because the real one reads THIS machine's `/proc`, and a test that
130
+ * had to arrange the kernel into «load 12.4, swap 98 % used» would not be
131
+ * written — so the rule this frame exists for (send only what moved) would
132
+ * go unverified on the one machine shape that matters.
133
+ */
134
+ readHostLoad?: () => HostLoadFrame | null;
135
+ /**
136
+ * How often `/proc` is sampled, in ms — a test seam over
137
+ * `HOST_LOAD_SAMPLE_INTERVAL_MS`, for the same reason `agentCleanupMs`
138
+ * exists: the real cadence is 30 s and this suite runs on real timers (fake
139
+ * ones fire the WS client's liveness watchdog and kill the socket). Never
140
+ * set in production.
141
+ */
142
+ hostLoadSampleMs?: number;
143
+ /**
144
+ * The heartbeat window, in ms — a test seam over `HOST_LOAD_HEARTBEAT_MS`.
145
+ * The real one is five minutes; without this seam «an unchanged machine
146
+ * still reports» would be an untested promise. Never set in production.
147
+ */
148
+ hostLoadHeartbeatMs?: number;
125
149
  }
126
150
  export declare class Supervisor {
127
151
  private readonly ws;
@@ -346,6 +370,39 @@ export declare class Supervisor {
346
370
  * it had room. Both were caught by the independent QA review of this change.
347
371
  */
348
372
  private publishSlots;
373
+ /** The heartbeat window actually used — the constant, or a test's own. */
374
+ private readonly hostLoadHeartbeatMs;
375
+ private readonly hostLoadTimer;
376
+ /** The last measurement the API actually took from us, or `null` for «nothing yet». */
377
+ private lastPublishedHostLoad;
378
+ /** When that frame went out, by this machine's clock. `0` = never. */
379
+ private lastHostLoadSentAt;
380
+ /**
381
+ * Tell the API what this machine's load looks like — when it is worth telling.
382
+ *
383
+ * Three ways a frame goes out, and each covers a hole the others leave:
384
+ *
385
+ * - the value MOVED past a threshold (`hostLoadChangedEnough`) — the reason
386
+ * the frame exists, and the only one that makes the card timely;
387
+ * - the heartbeat came due — an idle machine still has to say «still here,
388
+ * still idle», because the API expires the measurement after two minutes
389
+ * and silence would otherwise turn «nothing is happening» into «we have no
390
+ * idea» on a perfectly healthy card;
391
+ * - nothing was ever sent on this connection (`hello_ack` clears the memory)
392
+ * — a fresh socket knows nothing about what the last one was told.
393
+ *
394
+ * `null` from the sampler is a complete answer: not Linux, `/proc` masked, a
395
+ * kernel too old for `MemAvailable`. Nothing is sent and nothing is logged —
396
+ * a machine that cannot measure itself leaves the card saying «no data»,
397
+ * which is exactly what is true.
398
+ *
399
+ * Recorded ONLY when the socket took it, same as `publishSlots`: a frame
400
+ * dropped by a dead socket must not be remembered as sent, or a machine whose
401
+ * load never moves again would go silent until the heartbeat — and on a
402
+ * reconnect the API would have no measurement at all while this side believed
403
+ * it had one.
404
+ */
405
+ private publishHostLoad;
349
406
  private onFrame;
350
407
  private startSession;
351
408
  /**
@@ -359,6 +416,26 @@ export declare class Supervisor {
359
416
  * would be a lock bought for nothing.
360
417
  */
361
418
  private prepareWorkspace;
419
+ /**
420
+ * The working folder could not be built — say so in the feed, pin the fork
421
+ * point, and let the session go (#360).
422
+ *
423
+ * Both doors into `prepareWorkspace` end here. Three things happen in an
424
+ * order that is not free:
425
+ *
426
+ * 1. **The fork point is recorded first.** `git.ts` resolves it before it
427
+ * writes anything, precisely so that a failure still knows it. Without it
428
+ * the API keeps `base_sha = null` for the session's whole life, and
429
+ * «Continue» can never prove the leftover branch is the empty one this
430
+ * session created — which is the trap the ticket is about.
431
+ * 2. **The feed event goes out before the map entry is dropped**, because
432
+ * `sendEvent` needs it. Until now neither door sent one: a failed start
433
+ * was visible only as an `errorMessage` on the session row, and pressing
434
+ * «Continue» wipes that.
435
+ * 3. **Then the status, then the entry.** A FAILED session is over, and a
436
+ * leftover entry would hold one of the runner's few slots.
437
+ */
438
+ private workspacePrepareFailed;
362
439
  /**
363
440
  * Spin the adapter up — for a fresh session, a resume-on-next-message, or a
364
441
  * free CHAT session with no prompt at all (the agent boots, reports its
@@ -526,7 +603,8 @@ export declare class Supervisor {
526
603
  /**
527
604
  * Make room for one more agent process.
528
605
  *
529
- * Up to `maxSessions` agents run side by side, each in its own worktree. Over
606
+ * Up to `maxSessions` agents run side by side — since session 16 most of them
607
+ * share the project folder rather than each having a worktree of its own. Over
530
608
  * that, idle-but-resumable sessions (REVIEW / WAITING_INPUT) are parked —
531
609
  * their provider session survives on disk and relaunches on the next message,
532
610
  * so parking costs context nothing. Only mid-turn sessions (RUNNING /
@@ -754,10 +832,35 @@ export declare class Supervisor {
754
832
  * Three reasons it declines, and each of them is a state in which a snapshot
755
833
  * would be a lie rather than a restore point:
756
834
  * - the machine's owner switched checkpoints off;
757
- * - the agent is mid-turn, so the tree is being written to as we read it;
835
+ * - THIS session is mid-turn, so its own tree is being written as we read it;
758
836
  * - a repo-mutating command holds the repository.
837
+ *
838
+ * «A neighbour in the same folder is working» used to be a fourth reason, and
839
+ * it was the wrong one (#310): in DIRECT mode the folder is shared by design,
840
+ * so that rule silently switched restore points off for everybody the moment
841
+ * a second session opened. The neighbours are recorded on the point instead —
842
+ * the conversation can always be rewound to it, the files cannot.
843
+ *
844
+ * Every refusal is now audible. A restore point that was never taken is
845
+ * invisible until the day somebody reaches for it, and «the button is not
846
+ * there» is not a sentence anybody can act on.
759
847
  */
760
848
  private captureCheckpoint;
849
+ /**
850
+ * Say something once per BUSY PERIOD, not once per message (#310).
851
+ *
852
+ * A folder held by a neighbour stays held for minutes, and a session mid-turn
853
+ * can be sent three follow-up notes inside one answer. Keying this on the
854
+ * message seq would have counted each of those as its own turn and said the
855
+ * same sentence three times — the noise the frequency policy exists to
856
+ * prevent. The set is cleared when the session next comes to rest
857
+ * (`reportStatus`), which is exactly when the reason stops being true.
858
+ *
859
+ * A SET of keys, not the last one said: two different reasons can both come
860
+ * up inside one period, and remembering only the most recent would let them
861
+ * take turns re-announcing each other.
862
+ */
863
+ private noticeOncePerTurn;
761
864
  /**
762
865
  * Deliver messages that raced session start (already journaled).
763
866
  *
@@ -887,7 +990,15 @@ export declare class Supervisor {
887
990
  private stopWorkUnderPause;
888
991
  /** Live model / interaction-mode switch (persisted for the next relaunch). */
889
992
  private applySettings;
890
- /** Is a turn (or a question the agent is parked on) in flight right now? */
993
+ /**
994
+ * Is a turn (or a question the agent is parked on) in flight right now?
995
+ *
996
+ * The same status list as `holdsTheTree` plus one term: an open question
997
+ * parks the session without any of those statuses, and a live settings change
998
+ * must not land under it. Reading the shared array rather than spelling the
999
+ * statuses out again is the whole point of Р8 — a fourth waiting status has
1000
+ * to arrive in one place, not four.
1001
+ */
891
1002
  private isMidTurn;
892
1003
  /** The three live setters, in the order that lets an explicit pick win. */
893
1004
  private applyLiveSettings;
@@ -976,8 +1087,50 @@ export declare class Supervisor {
976
1087
  * has to branch on the type, so "I did not check" cannot compile.
977
1088
  */
978
1089
  private requireSession;
1090
+ /**
1091
+ * The statuses in which a session is holding its working tree (#310).
1092
+ *
1093
+ * One array, read by all three predicates below. There used to be one
1094
+ * literal, then two would have been needed, and a third would have been
1095
+ * written the day somebody added a waiting state — which is how «the agent
1096
+ * is busy» and «the folder is busy» come to disagree about what busy means.
1097
+ * (`rewindActionsLive` in the dashboard is a fourth reader and a deliberate
1098
+ * mirror: the runner does not depend on `@devbridge/shared`.)
1099
+ */
1100
+ private static readonly MID_TURN_STATUSES;
1101
+ /**
1102
+ * Is a stop still in flight over this session's live process?
1103
+ *
1104
+ * Three terms, written out in three places before this: an open cycle, that
1105
+ * cycle owning the process that is running NOW, and the cycle not yet
1106
+ * settled. #373 is what makes it load-bearing — the resting status is
1107
+ * published before the process dies, so this is the difference between «the
1108
+ * session is quiet» and «the session is still writing files».
1109
+ */
1110
+ private static stopInFlight;
1111
+ private static holdsTheTree;
979
1112
  /** A session actively mid-turn in this worktree — git writes must wait. */
980
1113
  private isWorktreeBusy;
1114
+ /**
1115
+ * Is THIS session mid-turn? (#310)
1116
+ *
1117
+ * The question `isWorktreeBusy` was answering in three places where the right
1118
+ * question was this one. They are the same question only when a folder holds
1119
+ * exactly one session — and DIRECT mode, the default since session 16, is
1120
+ * precisely the arrangement in which it holds several. A restore point is a
1121
+ * snapshot of this session's own conversation; a neighbour typing in the same
1122
+ * folder is a reason to MARK it, not to refuse to take it.
1123
+ */
1124
+ private isSessionMidTurn;
1125
+ /**
1126
+ * Everybody else who is working in this folder right now (#310).
1127
+ *
1128
+ * Includes a neighbour whose stop is still running: after #373 the resting
1129
+ * status is published BEFORE the process is actually gone, so a session that
1130
+ * reports IDLE while its stop cycle still owns a live process is still
1131
+ * writing files. Reading `lastReported` alone would call that folder quiet.
1132
+ */
1133
+ private busyNeighbours;
981
1134
  private static readonly EVENT_PAYLOAD_CAP;
982
1135
  /**
983
1136
  * Journal an event, put it on the wire, and return it.