@thehammer/danx-dashboard-mcp 0.1.334 → 0.1.335

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.
@@ -13,6 +13,11 @@ import { DIGEST_PREFIX } from "./digest-hold.js";
13
13
  import { HEARTBEAT_SILENT_FAILURE_THRESHOLD, HEARTBEAT_SUSTAINED_OUTAGE_MS, sendHeartbeat, } from "./session-stream.js";
14
14
  import { OperatorSessionStats } from "./session-stats.js";
15
15
  import { TranscriptTitleReader } from "./session-title.js";
16
+ /** DX-5415: how long a stop waits for the final settle (drain + one stats flush) before giving up; shutdown must stay bounded. */
17
+ export const FINAL_SETTLE_BOUND_MS = 10_000;
18
+ function settleBound() {
19
+ return AbortSignal.timeout(FINAL_SETTLE_BOUND_MS);
20
+ }
16
21
  export class HeartbeatReporter {
17
22
  deps;
18
23
  sessionStats;
@@ -42,21 +47,38 @@ export class HeartbeatReporter {
42
47
  */
43
48
  useTranscript(path) {
44
49
  if (path === undefined || path === this.transcriptPath)
45
- return;
46
- this.sessionStats.stop();
50
+ return null;
51
+ const previous = this.sessionStats;
47
52
  this.transcriptPath = path;
48
53
  this.sessionStats = new OperatorSessionStats(path, this.deps.makeStatsWatcher);
49
54
  this.titleReader = new TranscriptTitleReader(path);
50
55
  this.dashboardTitle = undefined;
51
56
  this.lastTitleError = null;
57
+ // DX-5415: the old transcript's held finals are stored before this returns; the swap above is synchronous so a second call sees the new path.
58
+ return previous.stop(this.send(undefined), settleBound());
52
59
  }
53
60
  /** A fresh connect cycle earns a clean slate for the silent-failure count. */
54
61
  resetFailureStreak() {
55
62
  this.failureStreak = 0;
56
63
  }
57
- /** Stop tailing the transcript. Idempotent. */
58
- stop() {
59
- this.sessionStats.stop();
64
+ /** DX-5415: settle the collector's held sub-agent finals (bounded), then stop tailing the transcript. Idempotent. */
65
+ async stop() {
66
+ await this.sessionStats.stop(this.send(undefined), settleBound());
67
+ }
68
+ /** One heartbeat as the reporter sends it: the carried answer's title and revocation are applied here. */
69
+ send(title) {
70
+ return async (stats) => {
71
+ const answer = await sendHeartbeat(this.deps.auth(), this.deps.http, this.deps.connection(), stats, title);
72
+ if (answer.landed)
73
+ this.dashboardTitle = answer.title ?? undefined;
74
+ // DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once. Its next
75
+ // dashboard call says the same.
76
+ if (answer.revocation !== null && !this.revocationAnnounced) {
77
+ this.revocationAnnounced = true;
78
+ this.deps.announce(keyRevokedHalt(answer.revocation, false), "key_revoked");
79
+ }
80
+ return answer;
81
+ };
60
82
  }
61
83
  titleToReport() {
62
84
  if (this.titleReader === null || this.dashboardTitle === undefined)
@@ -80,18 +102,7 @@ export class HeartbeatReporter {
80
102
  tick() {
81
103
  const title = this.titleToReport();
82
104
  void this.sessionStats
83
- .tick(async (stats) => {
84
- const answer = await sendHeartbeat(this.deps.auth(), this.deps.http, this.deps.connection(), stats, title);
85
- if (answer.landed)
86
- this.dashboardTitle = answer.title ?? undefined;
87
- // DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once. Its next
88
- // dashboard call says the same.
89
- if (answer.revocation !== null && !this.revocationAnnounced) {
90
- this.revocationAnnounced = true;
91
- this.deps.announce(keyRevokedHalt(answer.revocation, false), "key_revoked");
92
- }
93
- return answer;
94
- })
105
+ .tick(this.send(title))
95
106
  .then((landed) => {
96
107
  const now = this.deps.now();
97
108
  if (landed) {
package/dist/index.js CHANGED
@@ -868,7 +868,7 @@ async function main() {
868
868
  stdin: process.stdin,
869
869
  stop: () => {
870
870
  permissionWatch.stop();
871
- planEventRelay.close();
871
+ return planEventRelay.close();
872
872
  },
873
873
  });
874
874
  const transport = new StdioServerTransport();
@@ -115,6 +115,8 @@ export class PlanEventRelay {
115
115
  lastActivityAt = 0;
116
116
  heartbeat = null;
117
117
  reporter = null;
118
+ /** DX-5415: the stop of the last suspended reporter (its final settle); resolved when none is running. */
119
+ settling = null;
118
120
  digest = new DigestHold((event) => this.accept(event));
119
121
  constructor(deps) {
120
122
  this.deps = deps;
@@ -126,6 +128,12 @@ export class PlanEventRelay {
126
128
  // DX-4233: a request already cancelled (a plugin reload) touches nothing and starts nothing.
127
129
  if (signal?.aborted)
128
130
  return { events: [] };
131
+ // DX-5415: a suspend still storing the old collector's held finals ends before this call starts a new one (bounded inside).
132
+ if (this.settling !== null) {
133
+ await this.settling;
134
+ if (signal?.aborted)
135
+ return { events: [] };
136
+ }
129
137
  // DX-4233: one wait at a time; the older call answers empty so its caller is never left hanging.
130
138
  this.supersedeWaiter();
131
139
  // DX-4233: a plan move starts clean (the very first call has no last plan; after a suspend the last plan is still known, and `dropPlanScoped` already ran).
@@ -144,7 +152,9 @@ export class PlanEventRelay {
144
152
  this.buffer = this.buffer.map((r) => ({ ...r, recent: boundedIds([...cursor.ids, ...r.recent]) }));
145
153
  }
146
154
  this.touch();
147
- this.ensureReporter(args.transcriptPath);
155
+ const switching = this.ensureReporter(args.transcriptPath);
156
+ if (switching !== null)
157
+ await switching;
148
158
  this.buffer = this.buffer.filter((r) => !acknowledged(r.seq, cursor));
149
159
  if (this.pendingStop !== null) {
150
160
  const stopped = this.pendingStop;
@@ -176,7 +186,7 @@ export class PlanEventRelay {
176
186
  /** Stop everything: the pending call, the stream, the heartbeat, the transcript tailer. For the server's shutdown. */
177
187
  close() {
178
188
  this.supersedeWaiter();
179
- this.suspend();
189
+ return this.suspend();
180
190
  }
181
191
  // ---------------------------------------------------------------------------------------------------------------- calls
182
192
  finishCall(waiter, answer) {
@@ -267,10 +277,8 @@ export class PlanEventRelay {
267
277
  }
268
278
  /** Creates the heartbeat reporter on the first call; a later call's transcript path (a resumed session) is handed to it. */
269
279
  ensureReporter(transcriptPath) {
270
- if (this.reporter !== null) {
271
- this.reporter.useTranscript(transcriptPath);
272
- return;
273
- }
280
+ if (this.reporter !== null)
281
+ return this.reporter.useTranscript(transcriptPath);
274
282
  this.reporter = new HeartbeatReporter({
275
283
  auth: () => {
276
284
  const auth = this.deps.auth();
@@ -287,6 +295,7 @@ export class PlanEventRelay {
287
295
  announce: (text, source) => this.digest.route({ type: "event", id: null, text, origin: null, scope: "session", urgent: isUrgent({ source }) }),
288
296
  forceReconnect: () => this.run?.forceReconnect?.(),
289
297
  }, transcriptPath ?? null);
298
+ return null;
290
299
  }
291
300
  /** Mark the relay in use and make sure the heartbeat is scheduled. */
292
301
  touch() {
@@ -298,7 +307,7 @@ export class PlanEventRelay {
298
307
  if (this.waiter === null && this.deps.now() - this.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS) {
299
308
  // Nobody is reading this session's events any more: stop heartbeating (the dashboard then reads the listener not
300
309
  // healthy) and let go of the stream. The next call starts it again from its cursor; the dashboard replays what it missed.
301
- this.suspend();
310
+ void this.suspend();
302
311
  return;
303
312
  }
304
313
  const auth = this.deps.auth();
@@ -328,7 +337,15 @@ export class PlanEventRelay {
328
337
  this.heartbeat = null;
329
338
  this.run?.abort.abort();
330
339
  this.run = null;
331
- this.reporter?.stop();
340
+ // DX-5415: the reporter's stop settles its collector's held sub-agent finals first; callers that can wait (shutdown, the next call) do.
341
+ const stopping = this.reporter?.stop() ?? null;
342
+ if (stopping !== null) {
343
+ const settling = Promise.all([this.settling, stopping]).then(() => {
344
+ if (this.settling === settling)
345
+ this.settling = null;
346
+ });
347
+ this.settling = settling;
348
+ }
332
349
  this.reporter = null;
333
350
  this.digest.reset();
334
351
  this.dropPlanScoped();
@@ -339,6 +356,7 @@ export class PlanEventRelay {
339
356
  this.connectionState = "reconnecting";
340
357
  this.stoppedReason = null;
341
358
  this.lastInventoryError = null;
359
+ return this.settling ?? Promise.resolve();
342
360
  }
343
361
  // ------------------------------------------------------------------------------------------------- the mint / stream loop
344
362
  start(cursor) {
@@ -85,19 +85,32 @@ export class OperatorSessionStats {
85
85
  this.begin(answer.stats);
86
86
  return answer.landed;
87
87
  }
88
- let carried = false;
89
- let landed = true;
90
- let planless = false;
88
+ const { carried, landed, planless } = await this.flushVia(send);
89
+ if (planless) {
90
+ // DX-4892: re-queueing would resend this batch on every tick for as long as the session stays planless. Drop the collector instead:
91
+ // the plain heartbeats that follow start collection again from the stored cursors once an answer carries stats (the session is bound).
92
+ this.watcher?.stop();
93
+ this.watcher = null;
94
+ this.usage = newCollector();
95
+ return landed;
96
+ }
97
+ if (carried)
98
+ return landed;
99
+ return (await send(undefined)).landed;
100
+ }
101
+ /** One collector flush carried by `send`; a refused or unlanded batch throws inside the collector, which re-queues it. */
102
+ async flushVia(send) {
103
+ const outcome = { carried: false, landed: true, planless: false };
91
104
  await this.usage.flush({
92
105
  applyStats: async (_dispatchId, payload) => {
93
- carried = true;
106
+ outcome.carried = true;
94
107
  const answer = await send(payload);
95
108
  if (!answer.landed) {
96
- landed = false;
109
+ outcome.landed = false;
97
110
  throw new Error("stats heartbeat did not reach the dashboard");
98
111
  }
99
112
  if (answer.statsNotStored === "no_plan") {
100
- planless = true;
113
+ outcome.planless = true;
101
114
  throw new Error("stats heartbeat refused: the session is on no plan, so the dashboard stores no stats for it");
102
115
  }
103
116
  if (answer.stats === null) {
@@ -106,22 +119,46 @@ export class OperatorSessionStats {
106
119
  return answer.stats;
107
120
  },
108
121
  }, OPERATOR_DISPATCH_REF);
109
- if (planless) {
110
- // DX-4892: re-queueing would resend this batch on every tick for as long as the session stays planless. Drop the collector instead:
111
- // the plain heartbeats that follow start collection again from the stored cursors once an answer carries stats (the session is bound).
112
- this.watcher?.stop();
113
- this.watcher = null;
114
- this.usage = newCollector();
115
- return landed;
116
- }
117
- if (carried)
118
- return landed;
119
- return (await send(undefined)).landed;
122
+ return outcome;
120
123
  }
121
- /** Stop tailing. Idempotent; a stopped collector never starts again. */
122
- stop() {
124
+ /**
125
+ * Stop tailing. Idempotent; a stopped collector never starts again.
126
+ *
127
+ * DX-5415: a sub-agent's final report is held until its end signal is read, and confirmed only by a LATER poll (DX-5413), so a bare
128
+ * `watcher.stop()` lost any final still held or whose signal was read in the last poll. A collecting watcher is therefore settled first:
129
+ * `drainFinal()`, then one flush carried by `send`, and only then stopped. The settle is bounded by `bound` (shutdown must not hang
130
+ * on a slow dashboard); a drain or flush that fails or is cut off costs only what it had not yet delivered, said on stderr.
131
+ */
132
+ async stop(send, bound) {
133
+ if (this.stopped)
134
+ return;
123
135
  this.stopped = true;
124
- this.watcher?.stop();
136
+ const watcher = this.watcher;
137
+ if (watcher === null)
138
+ return;
139
+ const cutOff = new Promise((resolve) => {
140
+ if (bound.aborted)
141
+ resolve("cut");
142
+ else
143
+ bound.addEventListener("abort", () => resolve("cut"), { once: true });
144
+ });
145
+ try {
146
+ const settled = (async () => {
147
+ await watcher.drainFinal();
148
+ await this.flushVia(send);
149
+ return "settled";
150
+ })();
151
+ settled.catch(() => undefined); // a cut-off settle's late failure is already reported by the race below
152
+ if ((await Promise.race([settled, cutOff])) === "cut") {
153
+ process.stderr.write("operator session stats: the final settle was cut off by its bound; held sub-agent finals may be lost\n");
154
+ }
155
+ }
156
+ catch (err) {
157
+ process.stderr.write(`operator session stats: final settle failed: ${err instanceof Error ? err.message : String(err)}\n`);
158
+ }
159
+ finally {
160
+ watcher.stop();
161
+ }
125
162
  }
126
163
  begin(stored) {
127
164
  // `this.watcher !== null`: the relay fires `tick` without awaiting it, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.334",
3
+ "version": "0.1.335",
4
4
  "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",