@adhdev/daemon-core 0.9.82-rc.354 → 0.9.82-rc.356

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.
@@ -0,0 +1,21 @@
1
+ export interface MeshEventTraceCtx {
2
+ /** Primary correlation anchor — the mesh task id (meshActiveTaskId / metadataEvent.taskId). */
3
+ taskId?: unknown;
4
+ /** Optional per-event id when the producer assigns one. */
5
+ eventId?: unknown;
6
+ /** Worker session id — the fallback anchor when no task is attached. */
7
+ sessionId?: unknown;
8
+ nodeId?: unknown;
9
+ meshId?: unknown;
10
+ event?: unknown;
11
+ }
12
+ /**
13
+ * Stable, greppable correlation key. `task=` and `sess=` are ALWAYS rendered (as `-`
14
+ * when absent) so the key shape is uniform across stages and a single grep alternation
15
+ * (`task=<id>\|sess=<id>`) follows the event end-to-end.
16
+ */
17
+ export declare function meshEventTraceKey(ctx: MeshEventTraceCtx): string;
18
+ /** Lifecycle progress (INFO). One line per stage the event clears. */
19
+ export declare function traceMeshEventStage(stage: string, ctx: MeshEventTraceCtx, detail?: string): void;
20
+ /** Event did not advance — rejected / held / skipped / deduped (WARN). */
21
+ export declare function traceMeshEventDrop(reason: string, ctx: MeshEventTraceCtx, detail?: string): void;
@@ -129,7 +129,7 @@ export declare class MeshRuntimeStore {
129
129
  dispatchedAt: string;
130
130
  updatedAt: string;
131
131
  }>;
132
- updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void;
132
+ updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void;
133
133
  cleanupTerminalDirectDispatches(olderThanMs: number): void;
134
134
  deleteDirectDispatches(meshId: string): void;
135
135
  /**
@@ -320,7 +320,7 @@ export declare function insertDirectDispatch(meshId: string, data: {
320
320
  dispatchedAt: string;
321
321
  }): void;
322
322
  export declare function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[];
323
- export declare function updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void;
323
+ export declare function updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void;
324
324
  export declare function cleanupTerminalDirectDispatches(olderThanMs?: number): void;
325
325
  export declare function markStaleDirectDispatches(meshId: string, olderThanMs?: number): void;
326
326
  /**
@@ -184,6 +184,8 @@ export declare class CliProviderInstance implements ProviderInstance {
184
184
  private shouldSuppressStaleParsedBusyStatus;
185
185
  private getCompletedFinalizationBlock;
186
186
  private scheduleCompletedDebounceFlush;
187
+ private isMeshWorkerSession;
188
+ private meshTraceCtx;
187
189
  private flushCompletedDebounceIfFinalized;
188
190
  private maybeAutoApproveStatus;
189
191
  /**
@@ -33,6 +33,21 @@ export interface TerminalAdapterHandlers {
33
33
  }): void;
34
34
  tick?(): void;
35
35
  }
36
+ /**
37
+ * One entry in the PTY input/output event timeline (debug-only). Captured at
38
+ * the single common point every spec@4 provider funnels through — this adapter
39
+ * — so the Spec Debug Snapshot can answer "what input / output preceded a status
40
+ * transition?". Observation only; nothing here feeds the FSM decision.
41
+ */
42
+ export interface SpecPtyEvent {
43
+ /** Wall-clock ms. */
44
+ ts: number;
45
+ kind: 'spawn' | 'input' | 'output' | 'resize' | 'cursor' | 'exit';
46
+ /** Human-readable, control-char-escaped, length-capped preview. */
47
+ content: string;
48
+ /** Raw byte length before truncation (output/input only). */
49
+ bytes?: number;
50
+ }
36
51
  export declare class TerminalAdapter {
37
52
  private readonly opts;
38
53
  private readonly handlers;
@@ -46,6 +61,9 @@ export declare class TerminalAdapter {
46
61
  private screenTimer;
47
62
  private tickTimer;
48
63
  private lastScreen;
64
+ /** Debug-only ring buffer of PTY input/output/resize/cursor events. */
65
+ private events;
66
+ private lastCursorKey;
49
67
  constructor(opts: TerminalAdapterOpts, handlers: TerminalAdapterHandlers);
50
68
  start(): void;
51
69
  resize(cols: number, rows: number): void;
@@ -62,6 +80,10 @@ export declare class TerminalAdapter {
62
80
  col: number;
63
81
  };
64
82
  send_keys(text: string): void;
83
+ /** Debug-only: most-recent PTY input/output/resize/cursor events, oldest
84
+ * first. Pure observation — never consulted by the FSM. */
85
+ getEventTimeline(limit?: number): SpecPtyEvent[];
86
+ private recordEvent;
65
87
  kill(): void;
66
88
  private onChunk;
67
89
  private computeScreen;
@@ -121,6 +121,12 @@ export declare class SpecCliAdapter implements CliAdapter {
121
121
  * — not this code — decides how a selection is keyed for each CLI.
122
122
  */
123
123
  private selectPickerChoice;
124
+ /** Parse the picker choices only if the picker already appears rendered on
125
+ * the live screen (its `wait_for` condition currently matches and at least
126
+ * one choice parses). Returns the parsed choices when open, else null so
127
+ * the caller knows it must send the trigger to open it. Used to de-dup the
128
+ * picker open in {@link selectPickerChoice}. */
129
+ private extractPickerChoicesIfRendered;
124
130
  /** Poll the live screen until the picker's `wait_for` condition matches,
125
131
  * up to a short budget. Returns true if it rendered, false on timeout. */
126
132
  private waitForPickerRendered;
@@ -20,4 +20,5 @@ export declare function extractButtonsFromRule(rule: ExtractButtons, hay: string
20
20
  index: number;
21
21
  label: string;
22
22
  key: string;
23
+ current: boolean;
23
24
  }[];
@@ -1,3 +1,4 @@
1
+ import { type SpecPtyEvent } from './adapter.js';
1
2
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
2
3
  import { type TraceEntry } from './evaluator.js';
3
4
  import { type TransitionEval } from './fsm-evaluator.js';
@@ -139,6 +140,7 @@ export interface ISpecDriver {
139
140
  } | null;
140
141
  getFsmDebug?(): unknown;
141
142
  getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
143
+ getEventTimeline?(limit?: number): ReadonlyArray<SpecPtyEvent>;
142
144
  }
143
145
  export interface SpecDriverOpts {
144
146
  specPath: string;
@@ -152,6 +154,10 @@ export interface SpecDriverOpts {
152
154
  extraCliArgs?: string[];
153
155
  }
154
156
  export declare function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number;
157
+ /** Split `text` into chunks of at most `size` UTF-16 units without ever cutting
158
+ * between a high and low surrogate (which would corrupt an astral char — emoji,
159
+ * etc. — on the UTF-8 PTY write). */
160
+ export declare function chunkPreservingSurrogates(text: string, size: number): string[];
155
161
  export declare function guessExt(mime: string): string;
156
162
  type HistoryEntry = DriverHistoryEntry;
157
163
  export declare class FsmDriver implements ISpecDriver {
@@ -181,6 +187,16 @@ export declare class FsmDriver implements ISpecDriver {
181
187
  * WIN32_SUBMIT_* and scheduleWin32Submit). Re-arms itself until the FSM
182
188
  * leaves idle (submitted) or the resend budget is spent. */
183
189
  private win32SubmitTimer;
190
+ /** Wall-clock (ms) of the most recent raw PTY output chunk. Advances on every
191
+ * on_pty_data — including the echo of text written into the composer — so the
192
+ * win32 submit settle-gate can tell when input has finished landing. */
193
+ private lastPtyDataAt;
194
+ /** Wall-clock (ms) of the most recent win32 message-body input write. Bridges
195
+ * the gap between writing a chunk and its echo so the settle-gate does not
196
+ * declare "quiet" mid-write. */
197
+ private lastWin32WriteAt;
198
+ /** Pending paced chunk-write timer for a large win32 body (see writeWin32Body). */
199
+ private win32WriteTimer;
184
200
  private currentEval;
185
201
  private stateHistory;
186
202
  private prevStateAt;
@@ -230,6 +246,8 @@ export declare class FsmDriver implements ISpecDriver {
230
246
  };
231
247
  getStateHistory(): ReadonlyArray<HistoryEntry>;
232
248
  getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry>;
249
+ /** Debug-only PTY input/output/resize/cursor timeline from the adapter. */
250
+ getEventTimeline(limit?: number): ReadonlyArray<SpecPtyEvent>;
233
251
  getSections(): Array<{
234
252
  id: string;
235
253
  text: string;
@@ -300,14 +318,38 @@ export declare class FsmDriver implements ISpecDriver {
300
318
  private actuallySendMessage;
301
319
  /** The agent's current coarse status, derived from the FSM node we're in. */
302
320
  private currentStatus;
321
+ /** Record a win32 body write so the settle-gate counts it as input activity
322
+ * even before the echo arrives. */
323
+ private markWin32Write;
324
+ /** Most recent win32 input activity — a write we issued OR a PTY output chunk
325
+ * (echo). The submit settle-gate waits for this to go quiet. */
326
+ private lastWin32InputActivityAt;
303
327
  /**
304
- * win32 verification-based submit. Sends the submit key, waits a gap, and if
305
- * the FSM is still 'idle' (the prompt did not submit — the CR was absorbed as
306
- * a multiline-paste newline) resends, up to WIN32_SUBMIT_MAX_RESENDS. The
307
- * first CR always fires (so a stale/edge status never suppresses the submit);
308
- * subsequent resends are gated on still being idle, and stop the instant the
309
- * agent leaves idle (submitted generating / approval). This converges the
310
- * nondeterministic multiline window without spamming Enter into the next turn.
328
+ * Write the message body to the PTY for win32, paced into bounded chunks. A
329
+ * single unbounded ConPTY write can overflow the input pipe and drop leading
330
+ * bytes; splitting it with a short inter-chunk gap keeps the console input
331
+ * buffer from overflowing. Small bodies still go out in a single write. Each
332
+ * chunk advances lastWin32WriteAt so the submit settle-gate keeps waiting until
333
+ * the final chunk is out and echoed.
334
+ */
335
+ private writeWin32Body;
336
+ /**
337
+ * win32 submit. Two phases:
338
+ *
339
+ * Phase 1 (settle-gate): hold the first CR until the PTY output has been quiet
340
+ * for WIN32_SUBMIT_SETTLE_MS after the last input write — i.e. the full
341
+ * (possibly multi-KB / multiline) body has finished arriving in the composer
342
+ * and echoing. Honors an initial minimum delay and is bounded by
343
+ * WIN32_SUBMIT_MAX_SETTLE_WAIT_MS so a noisy screen can never hang the submit.
344
+ * This is what stops a long message from being submitted half-arrived (its
345
+ * leading lines lost). A short message settles almost immediately.
346
+ *
347
+ * Phase 2 (verified resend — unchanged): send the submit key, wait a gap, and
348
+ * if the FSM is still 'idle' (the CR was absorbed as a multiline-paste
349
+ * newline) resend, up to WIN32_SUBMIT_MAX_RESENDS. The first CR always fires
350
+ * (a stale/edge status never suppresses it); resends are gated on still being
351
+ * idle and stop the instant the agent leaves idle (submitted → generating /
352
+ * approval). This preserves the win32 lone-CR-swallow handling.
311
353
  */
312
354
  private scheduleWin32Submit;
313
355
  private handleClickControl;
@@ -19,6 +19,10 @@ export interface CondResult {
19
19
  /** Remaining ms until a time-based condition would flip to true. 0 if
20
20
  * already true or not applicable. Lets the UI show a countdown. */
21
21
  remainingMs?: number;
22
+ /** Debug-only: the actual substring a TRUE regex condition matched, so the
23
+ * snapshot shows WHAT text the rule fired on — not just which regex. Never
24
+ * read by the FSM; purely for the Spec Debug Snapshot. */
25
+ matchedText?: string;
22
26
  children?: CondResult[];
23
27
  }
24
28
  /** One evaluated transition with its full reasoning. */
@@ -19,6 +19,24 @@ export type ControlAction = {
19
19
  wait_for: WaitForCondition;
20
20
  extract_choices: SectionPattern;
21
21
  submit_key: string;
22
+ /**
23
+ * How a parsed choice is committed once the picker is open.
24
+ * 'index' (default) — type the on-screen number then `submit_key`
25
+ * (`{index}\r`). Correct for CLIs whose picker is number-selectable
26
+ * (codex-cli, hermes-cli).
27
+ * 'arrow_keys' — the picker is a cursor list that ignores number keys
28
+ * (claude-cli /model): move the cursor from its current row to the
29
+ * target row with up/down arrows, then confirm with the `submit_key`
30
+ * tail (the `\r` left after stripping `{index}`). Requires the
31
+ * extracted choices to flag the current cursor row.
32
+ */
33
+ select_mode?: 'index' | 'arrow_keys';
34
+ /** Arrow byte sequences for `select_mode: 'arrow_keys'`. Defaults to
35
+ * ANSI cursor up/down (`` / ``) when omitted. */
36
+ cursor_keys?: {
37
+ up: string;
38
+ down: string;
39
+ };
22
40
  } | {
23
41
  type: 'attach_image';
24
42
  method: 'tempfile_then_keys';
@@ -123,11 +141,15 @@ export interface SectionDef {
123
141
  until?: string;
124
142
  /**
125
143
  * Anchor regex(es). A single string anchors on the first/last matching line
126
- * (per `anchor_last`). An array is an OR-set: every candidate line is one
127
- * that matches ANY entry; with `anchor_last` the LAST such line across all
128
- * patterns wins, otherwise the FIRST. This lets one section capture two
129
- * different shapese.g. a box-divider modal AND a divider-less modal whose
130
- * only stable landmark is the question line above its numbered choices.
144
+ * (per `anchor_last`). An array is an OR-set of candidate shapes: each
145
+ * candidate resolves its OWN anchor line independently (anchor_last that
146
+ * candidate's last matching line, else its first), then the TOPMOST resolved
147
+ * line across candidates wins — a section's anchor marks the top of its
148
+ * block, so the highest recognized landmark bounds the whole block. This
149
+ * lets one section capture two shapes — e.g. a box-divider modal AND a
150
+ * divider-less modal whose only stable landmark is the question line above
151
+ * its numbered choices — while preventing a stray LOWER landmark (e.g. an
152
+ * input-box `────` rule below the choices) from clipping the block.
131
153
  */
132
154
  anchor?: string | string[];
133
155
  anchor_flags?: string;
@@ -177,4 +199,24 @@ export interface ExtractButtons {
177
199
  key_for_index: string;
178
200
  min_count?: number;
179
201
  continuation_lines?: boolean;
202
+ /**
203
+ * How a button is committed when its modal is resolved (auto-approve or an
204
+ * explicit dashboard click).
205
+ * 'index' (default) — send `key_for_index` with `{index}` filled in
206
+ * (`{index}\r` → `1\r`). Correct for modals whose buttons are
207
+ * number-selectable (codex, hermes, antigravity number rows).
208
+ * 'arrow_keys' — the modal is a cursor list that IGNORES number keys
209
+ * (claude-cli's new TUI approval modal): a typed `1` leaks into the
210
+ * composer as literal text and `\r` submits it. Instead move the cursor
211
+ * from its current row to the target row with up/down arrows, then
212
+ * confirm with the `key_for_index` tail (the `\r` left after stripping
213
+ * `{index}`). Mirrors the `open_picker` `select_mode` of the same name.
214
+ */
215
+ select_mode?: 'index' | 'arrow_keys';
216
+ /** Arrow byte sequences for `select_mode: 'arrow_keys'`. Defaults to ANSI
217
+ * cursor up/down (`[A` / `[B`) when omitted. */
218
+ cursor_keys?: {
219
+ up: string;
220
+ down: string;
221
+ };
180
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.354",
3
+ "version": "0.9.82-rc.356",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.354",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.356",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -8110,7 +8110,10 @@ export class DaemonCommandRouter {
8110
8110
  // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) when the stored
8111
8111
  // daemonId uses a legacy format that doesn't match the receiving daemon's identity.
8112
8112
  const selfDaemonId = this.deps.statusInstanceId;
8113
- const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
8113
+ // daemonIdsEquivalent: a legacy-form stored daemonId that resolves to THIS
8114
+ // machine's core must be treated as local (not remote) so it is not forwarded /
8115
+ // P2P self-dialed. Equivalent → local.
8116
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
8114
8117
  if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
8115
8118
  const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId!, 'fast_forward_mesh_node', {
8116
8119
  ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
@@ -8154,7 +8157,9 @@ export class DaemonCommandRouter {
8154
8157
  // once the call lands on the owning daemon — that daemon then reads
8155
8158
  // its own logs even if the stored daemonId uses a legacy form.
8156
8159
  const selfDaemonId = this.deps.statusInstanceId;
8157
- const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
8160
+ // daemonIdsEquivalent: a legacy-form daemonId resolving to this machine's core is
8161
+ // local — read locally instead of forwarding. Equivalent → local.
8162
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
8158
8163
  if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
8159
8164
  const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId!, 'get_mesh_node_logs', {
8160
8165
  ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
@@ -8230,7 +8235,9 @@ export class DaemonCommandRouter {
8230
8235
  const forwardNode = meshRecordForForward?.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
8231
8236
  const nodeDaemonId = typeof forwardNode?.daemonId === 'string' ? forwardNode.daemonId.trim() : undefined;
8232
8237
  const selfDaemonId = this.deps.statusInstanceId;
8233
- const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
8238
+ // daemonIdsEquivalent: a legacy-form daemonId resolving to this machine's core
8239
+ // is local — execute locally instead of forwarding. Equivalent → local.
8240
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
8234
8241
  if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
8235
8242
  const callerCoordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
8236
8243
  ? args.coordinatorDaemonId.trim()
@@ -8347,7 +8354,9 @@ export class DaemonCommandRouter {
8347
8354
  let worktreeCleanup: Record<string, unknown> | undefined;
8348
8355
  if (node?.isLocalWorktree) {
8349
8356
  const nodeDaemonId = typeof node.daemonId === 'string' ? node.daemonId.trim() : undefined;
8350
- const isRemoteWorktree = nodeDaemonId && nodeDaemonId !== this.deps.statusInstanceId && this.deps.dispatchMeshCommand
8357
+ // daemonIdsEquivalent: an equivalent-form daemonId is this machine
8358
+ // clean up locally, do not forward. Equivalent → local.
8359
+ const isRemoteWorktree = nodeDaemonId && !daemonIdsEquivalent(nodeDaemonId, this.deps.statusInstanceId) && this.deps.dispatchMeshCommand
8351
8360
  && !args?._meshDirectDispatch;
8352
8361
  if (isRemoteWorktree) {
8353
8362
  // Worktree lives on a different machine — ask that daemon to clean it up.
@@ -8473,7 +8482,9 @@ export class DaemonCommandRouter {
8473
8482
  // _meshDirectDispatch prevents infinite re-forwarding when the stored daemonId
8474
8483
  // uses a legacy format that doesn't match the receiving daemon's statusInstanceId.
8475
8484
  const sourceDaemonId = typeof sourceNode.daemonId === 'string' ? sourceNode.daemonId.trim() : undefined;
8476
- if (sourceDaemonId && sourceDaemonId !== this.deps.statusInstanceId && this.deps.dispatchMeshCommand
8485
+ // daemonIdsEquivalent: an equivalent-form source daemonId is this machine
8486
+ // clone locally, do not forward. Equivalent → local.
8487
+ if (sourceDaemonId && !daemonIdsEquivalent(sourceDaemonId, this.deps.statusInstanceId) && this.deps.dispatchMeshCommand
8477
8488
  && !args?._meshDirectDispatch) {
8478
8489
  const forwarded = await this.deps.dispatchMeshCommand(sourceDaemonId, 'clone_mesh_node', {
8479
8490
  ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
@@ -8748,7 +8759,9 @@ export class DaemonCommandRouter {
8748
8759
  // Bootstrap runs scripts in the worktree path — forward to the node's daemon if remote.
8749
8760
  // _meshDirectDispatch prevents re-forwarding when stored daemonId uses legacy format.
8750
8761
  const nodeDaemonId = typeof node.daemonId === 'string' ? node.daemonId.trim() : undefined;
8751
- if (nodeDaemonId && nodeDaemonId !== this.deps.statusInstanceId && this.deps.dispatchMeshCommand
8762
+ // daemonIdsEquivalent: an equivalent-form daemonId is this machine
8763
+ // bootstrap locally, do not forward. Equivalent → local.
8764
+ if (nodeDaemonId && !daemonIdsEquivalent(nodeDaemonId, this.deps.statusInstanceId) && this.deps.dispatchMeshCommand
8752
8765
  && !args?._meshDirectDispatch) {
8753
8766
  const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId, 'retry_mesh_node_bootstrap', {
8754
8767
  ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
@@ -0,0 +1,67 @@
1
+ /**
2
+ * EVTTRACE — observation-only lifecycle tracing for mesh completion events.
3
+ *
4
+ * Pure logging. This module adds NO decision logic: every call site is a bare log
5
+ * statement inserted ALONGSIDE (never replacing) the existing control flow. Its only
6
+ * job is to make a single completion event greppable across its whole lifecycle by a
7
+ * stable correlation key, and to mark — with one uniform anchor — every point where
8
+ * such an event is rejected / held / skipped / deduped.
9
+ *
10
+ * grep anchors:
11
+ * [EvtTrace] [stage:<name>] — lifecycle progressed a step (INFO)
12
+ * [EvtTrace] [drop:<reason>] — event did NOT advance here, with the reason (WARN)
13
+ *
14
+ * Follow one completion: grep the daemon log for its `task=<id>` (or `sess=<id>`)
15
+ * across the [stage:*] lines; the line with [drop:*] is where it died.
16
+ *
17
+ * Dependency-light on purpose (only the logger) so both providers/ and mesh/ can
18
+ * import it without any cycle risk.
19
+ */
20
+ import { LOG } from '../logging/logger.js';
21
+
22
+ const CAT = 'EvtTrace';
23
+
24
+ function s(v: unknown): string {
25
+ return typeof v === 'string' && v.trim() ? v.trim() : '';
26
+ }
27
+
28
+ export interface MeshEventTraceCtx {
29
+ /** Primary correlation anchor — the mesh task id (meshActiveTaskId / metadataEvent.taskId). */
30
+ taskId?: unknown;
31
+ /** Optional per-event id when the producer assigns one. */
32
+ eventId?: unknown;
33
+ /** Worker session id — the fallback anchor when no task is attached. */
34
+ sessionId?: unknown;
35
+ nodeId?: unknown;
36
+ meshId?: unknown;
37
+ event?: unknown;
38
+ }
39
+
40
+ /**
41
+ * Stable, greppable correlation key. `task=` and `sess=` are ALWAYS rendered (as `-`
42
+ * when absent) so the key shape is uniform across stages and a single grep alternation
43
+ * (`task=<id>\|sess=<id>`) follows the event end-to-end.
44
+ */
45
+ export function meshEventTraceKey(ctx: MeshEventTraceCtx): string {
46
+ const segs = [`task=${s(ctx.taskId) || '-'}`];
47
+ const eventId = s(ctx.eventId);
48
+ if (eventId) segs.push(`evt=${eventId}`);
49
+ segs.push(`sess=${s(ctx.sessionId) || '-'}`);
50
+ const nodeId = s(ctx.nodeId);
51
+ if (nodeId) segs.push(`node=${nodeId}`);
52
+ const meshId = s(ctx.meshId);
53
+ if (meshId) segs.push(`mesh=${meshId}`);
54
+ const event = s(ctx.event);
55
+ if (event) segs.push(`event=${event}`);
56
+ return segs.join(' ');
57
+ }
58
+
59
+ /** Lifecycle progress (INFO). One line per stage the event clears. */
60
+ export function traceMeshEventStage(stage: string, ctx: MeshEventTraceCtx, detail?: string): void {
61
+ LOG.info(CAT, `[stage:${stage}] ${meshEventTraceKey(ctx)}${detail ? ` — ${detail}` : ''}`);
62
+ }
63
+
64
+ /** Event did not advance — rejected / held / skipped / deduped (WARN). */
65
+ export function traceMeshEventDrop(reason: string, ctx: MeshEventTraceCtx, detail?: string): void {
66
+ LOG.warn(CAT, `[drop:${reason}] ${meshEventTraceKey(ctx)}${detail ? ` — ${detail}` : ''}`);
67
+ }