@adhdev/daemon-core 0.9.82-rc.350 → 0.9.82-rc.352

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.
@@ -7,7 +7,7 @@
7
7
  * 1. daemonLog(category, msg, level) — explicit per-category logging
8
8
  * 2. installGlobalInterceptor() — Auto-intercept console.log (once on daemon start)
9
9
  * 3. Recent log ring buffer — for remote transmission via P2P/WS
10
- * 4. File logging — ~/Library/Logs/adhdev/daemon.log (10MB rolling)
10
+ * 4. File logging — ~/.adhdev/logs/daemon-YYYY-MM-DD.log (date-based rolling)
11
11
  *
12
12
  * use:
13
13
  * import { daemonLog, LOG } from './daemon-logger';
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held';
17
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed';
18
18
  export interface MeshLedgerEntry {
19
19
  id: string;
20
20
  meshId: string;
@@ -6,6 +6,7 @@ export declare class MeshRuntimeStore {
6
6
  private readonly migratedMeshIds;
7
7
  private fingerprintSweepCounter;
8
8
  private walWriteCounter;
9
+ private toolCallLogCounter;
9
10
  private static readonly WAL_CHECK_INTERVAL;
10
11
  private static readonly WAL_MAX_BYTES;
11
12
  private constructor();
@@ -190,6 +191,15 @@ export declare class MeshRuntimeStore {
190
191
  createdAt: string;
191
192
  updatedAt: string;
192
193
  }>;
194
+ /**
195
+ * Bug B watchdog support: true when at least one delivery record for the task has
196
+ * reached a confirmed-handed-off status (delivered / acked / completed). The
197
+ * assigned-stranded watchdog uses this to distinguish a dispatch that was never
198
+ * confirmed (reclaimable) from one that WAS handed to the worker (a genuinely
199
+ * in-flight or completion-lost task, which is PHASE 4's responsibility, not this
200
+ * watchdog's). Indexed by (mesh_id, task_id).
201
+ */
202
+ taskHasConfirmedDelivery(meshId: string, taskId: string): boolean;
193
203
  expireStaleSessionDeliveries(meshId: string): void;
194
204
  deleteSessionDeliveries(meshId: string): void;
195
205
  recordCompletionConflict(entry: {
@@ -60,6 +60,14 @@ export interface MeshWorkQueueEntry {
60
60
  requeueCount?: number;
61
61
  /** Max automatic requeue attempts. When requeueCount reaches this, task is auto-failed. */
62
62
  maxRetries?: number;
63
+ /**
64
+ * Bug B: number of times the reconcile assigned-stranded watchdog has reclaimed this
65
+ * row from 'assigned' back to 'pending' because its dispatch was never confirmed
66
+ * delivered. Separate from requeueCount (operator/execution retries) and bounded by
67
+ * MAX_STRANDED_RECLAIMS so a permanently-undeliverable target auto-fails rather than
68
+ * cycling reclaim→re-dispatch→strand forever.
69
+ */
70
+ strandedReclaimCount?: number;
63
71
  /** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
64
72
  autoLaunch?: {
65
73
  status: 'skipped' | 'started' | 'failed' | 'completed';
@@ -230,6 +238,26 @@ export declare function requeueTask(meshId: string, taskId: string, opts?: {
230
238
  /** Per-task retry cap override. Falls back to mesh policy maxTaskRetries (default 1). */
231
239
  maxRetries?: number;
232
240
  } & MeshQueueMutationOptions): MeshWorkQueueEntry | null;
241
+ /**
242
+ * Bug B: reclaim a task stuck in 'assigned' because its dispatch was never confirmed.
243
+ *
244
+ * claimNextTask atomically marks a row 'assigned' BEFORE the fire-and-forget dispatch
245
+ * runs. If that dispatch neither rejects (→ no .catch requeue) nor is confirmed
246
+ * delivered — a relay that hangs without acking, or a confirm timer lost across a
247
+ * daemon restart — the row stays 'assigned' forever, contributing 0 pending so PHASE 3
248
+ * reconcile never re-examines it. This returns such a row to 'pending' and clears its
249
+ * dead assignment ownership (node / session / provider / dispatchTimestamp) — the same
250
+ * ownership-clear requeueTask applies — so PHASE 3 can re-dispatch it onto a fresh idle
251
+ * session.
252
+ *
253
+ * Guarded to 'assigned' rows only (a completion/cancel that already moved the row off
254
+ * 'assigned' must never be resurrected) and bounded by MAX_STRANDED_RECLAIMS (beyond
255
+ * which the task is failed so dependents unblock).
256
+ */
257
+ export declare function reclaimStrandedAssignedTask(meshId: string, taskId: string, opts?: {
258
+ reason?: string;
259
+ ageMs?: number;
260
+ } & MeshQueueMutationOptions): MeshWorkQueueEntry | null;
233
261
  /**
234
262
  * Update the status of the task currently assigned to a specific session.
235
263
  */
@@ -1,4 +1,11 @@
1
1
  import type { ProviderModule } from './contracts.js';
2
+ /**
3
+ * True when any of the given button labels reads as a decline/negative option
4
+ * (No / Deny / Cancel / Skip / …). Used as the second half of an approval-modal
5
+ * structural anchor: a real approval modal offers BOTH an affirmative and a
6
+ * decline, which distinguishes it from a generic numbered menu or prose list.
7
+ */
8
+ export declare function hasNegativeApprovalOption(buttons: string[] | null | undefined): boolean;
2
9
  export declare function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[];
3
10
  export declare function pickApprovalButton(buttons: string[] | null | undefined, provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): {
4
11
  index: number;
@@ -48,6 +48,20 @@ export declare class CliProviderInstance implements ProviderInstance {
48
48
  * keystroke until the modal *content* has settled.
49
49
  */
50
50
  private static readonly AUTO_APPROVE_SETTLE_MS;
51
+ /**
52
+ * Busy-side hysteresis for the settle gate. A momentary `generating` flip
53
+ * while the SAME approval modal's button block is still on screen (its
54
+ * question line scrolled out of the captured frame, only the buttons + a
55
+ * residual `esc to interrupt` spinner remain) briefly reports
56
+ * status!=waiting_approval. Without hysteresis that flip wipes the settle
57
+ * clock, and the modal→generating→modal flap restarts the 600ms window
58
+ * every time so auto-approve never fires. We keep the in-progress settle
59
+ * gate warm across an inactive blip up to this bound; only once the modal
60
+ * has genuinely stayed gone this long (a real resolution → idle) is the
61
+ * gate cleared. Bounded so a genuinely new, later approval still re-settles
62
+ * from scratch rather than firing on a stale timestamp.
63
+ */
64
+ private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS;
51
65
  private adapter;
52
66
  private context;
53
67
  private events;
@@ -64,6 +78,7 @@ export declare class CliProviderInstance implements ProviderInstance {
64
78
  private pendingAutoApprovalSignature;
65
79
  private pendingAutoApprovalSince;
66
80
  private autoApproveSettleTimer;
81
+ private autoApproveInactiveSince;
67
82
  private controlValues;
68
83
  private summaryMetadata;
69
84
  private appliedEffectKeys;
@@ -57,6 +57,7 @@ interface ModalSpec {
57
57
  }>;
58
58
  buttonPattern: string;
59
59
  buttonFlags?: string;
60
+ buttonLabelGroup?: number;
60
61
  }
61
62
  export type DispatchGroup = 'spinner' | 'modal' | 'settled-prompt' | 'cue-ordering' | 'error-detection' | 'approval-stitching';
62
63
  export interface DispatchOrderSpec {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.350",
3
+ "version": "0.9.82-rc.352",
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.350",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.352",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -292,13 +292,14 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
292
292
  }
293
293
  }
294
294
 
295
- export function stopSessionHostProcesses(appName: string): void {
295
+ export async function stopSessionHostProcesses(appName: string): Promise<void> {
296
296
  const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
297
+ let killedPid: number | null = null;
297
298
  try {
298
299
  if (fs.existsSync(pidFile)) {
299
300
  const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
300
301
  if (Number.isFinite(pid) && pid !== process.pid && isManagedSessionHostPid(pid)) {
301
- killPid(pid);
302
+ if (killPid(pid)) killedPid = pid;
302
303
  }
303
304
  }
304
305
  } catch {
@@ -310,6 +311,31 @@ export function stopSessionHostProcesses(appName: string): void {
310
311
  // noop
311
312
  }
312
313
  }
314
+
315
+ // The session-host process keeps node-pty's `conpty.node` memory-mapped. On
316
+ // Windows a mapped native addon stays EXCLUSIVELY locked until the process
317
+ // fully exits and tears down the mapping — and that teardown lags `taskkill`
318
+ // by an indeterminate interval. `taskkill` only *requests* termination, so
319
+ // returning immediately lets the caller run `npm install` while conpty.node
320
+ // is still locked, which makes npm's copy-to-staging fail with EBUSY (the
321
+ // intermittent Windows upgrade failure). Wait for the killed process to
322
+ // actually disappear — like we already do for the parent daemon pid — so the
323
+ // file handle is released before the install runs. (POSIX can replace an open
324
+ // file freely, so the wait is harmless there.)
325
+ if (killedPid !== null) {
326
+ await waitForPidExit(killedPid, 15000);
327
+ }
328
+ }
329
+
330
+ // npm copies the current install's files into a staging dir before swapping in
331
+ // the new version. On Windows that copy of `conpty.node` can still race a
332
+ // just-killed session-host whose mapping hasn't been released yet, surfacing as
333
+ // EBUSY/EPERM. Treat those as transient and retry with backoff.
334
+ function isRetriableInstallLockError(error: any): boolean {
335
+ const code = error?.code;
336
+ if (code === 'EBUSY' || code === 'EPERM') return true;
337
+ const text = `${error?.message || ''} ${error?.stderr || ''}`;
338
+ return /\bEBUSY\b|\bEPERM\b|resource busy or locked/i.test(text);
313
339
  }
314
340
 
315
341
  function removeDaemonPidFile(): void {
@@ -412,23 +438,40 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
412
438
  await waitForPidExit(payload.parentPid, 15000);
413
439
  }
414
440
 
415
- stopSessionHostProcesses(sessionHostAppName);
441
+ await stopSessionHostProcesses(sessionHostAppName);
416
442
  removeDaemonPidFile();
417
443
  cleanupStaleGlobalInstallDirs(payload.packageName, installCommand.surface);
418
444
 
419
445
  const spec = `${payload.packageName}@${payload.targetVersion || 'latest'}`;
420
446
  appendUpgradeLog(`Installing ${spec}`);
421
- const installOutput = execFileSync(
422
- installCommand.command,
423
- installCommand.args,
424
- {
425
- encoding: 'utf8',
426
- stdio: 'pipe',
427
- maxBuffer: 20 * 1024 * 1024,
428
- env: buildInstallEnvWithNodeOnPath(),
429
- ...installCommand.execOptions,
430
- },
431
- );
447
+ // Windows can still race a lingering conpty.node mapping even after the
448
+ // session-host exits, so retry the install on transient lock errors there.
449
+ const maxInstallAttempts = process.platform === 'win32' ? 3 : 1;
450
+ let installOutput = '';
451
+ for (let attempt = 1; attempt <= maxInstallAttempts; attempt++) {
452
+ try {
453
+ installOutput = String(execFileSync(
454
+ installCommand.command,
455
+ installCommand.args,
456
+ {
457
+ encoding: 'utf8',
458
+ stdio: 'pipe',
459
+ maxBuffer: 20 * 1024 * 1024,
460
+ env: buildInstallEnvWithNodeOnPath(),
461
+ ...installCommand.execOptions,
462
+ },
463
+ ));
464
+ break;
465
+ } catch (error: any) {
466
+ if (attempt < maxInstallAttempts && isRetriableInstallLockError(error)) {
467
+ appendUpgradeLog(`Install attempt ${attempt} hit a file lock (${error?.code || 'lock'}); cleaning staging and retrying after backoff`);
468
+ cleanupStaleGlobalInstallDirs(payload.packageName, installCommand.surface);
469
+ await new Promise((resolve) => setTimeout(resolve, attempt * 1500));
470
+ continue;
471
+ }
472
+ throw error;
473
+ }
474
+ }
432
475
  if (installOutput.trim()) {
433
476
  appendUpgradeLog(installOutput.trim());
434
477
  }
package/src/index.ts CHANGED
@@ -252,6 +252,11 @@ export { buildMeshHostRequiredFailure, createDefaultMeshHostMetadata, isMeshHost
252
252
  // ── Mesh Events ──
253
253
  export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, queuePendingMeshCoordinatorEvent, reconcileDirectDispatchCompletionFromTranscript } from './mesh/mesh-events.js';
254
254
  export type { PendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
255
+ // The coordinator-side preview surfaced from a worker's completion/status event
256
+ // (finalSummary / workerResult.summary / lastMessagePreview). Same data the mobile
257
+ // inbox is fed; reused by mesh_read_chat's cache fallback when the live P2P read path
258
+ // is unavailable (saturated/unreachable peer).
259
+ export { resolveMeshSurfacedSessionPreview, readMeshCompletionSummary } from './mesh/mesh-events-utils.js';
255
260
 
256
261
  // ── Mesh Delivery Policy ──
257
262
  export { resolveDeliveryDecision, createSessionDelivery, updateSessionDeliveryStatus, getActiveSessionDeliveries, markSessionDeliveriesTerminal, recordCompletionConflict, getRecentCompletionConflicts } from './mesh/mesh-delivery-policy.js';
@@ -15,11 +15,13 @@ import * as path from 'path';
15
15
  import * as os from 'os';
16
16
 
17
17
  // ─── Config ──────────────────────────────────
18
- const LOG_DIR = process.platform === 'win32'
19
- ? path.join(process.env.LOCALAPPDATA || process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'adhdev', 'logs')
20
- : process.platform === 'darwin'
21
- ? path.join(os.homedir(), 'Library', 'Logs', 'adhdev')
22
- : path.join(os.homedir(), '.local', 'share', 'adhdev', 'logs');
18
+ // Command history lives under the unified ADHDev home (~/.adhdev/logs/) next to
19
+ // the daemon log, on every platform. Honor ADHDEV_CONFIG_DIR for isolated homes.
20
+ // Keep this in sync with logger.ts LOG_DIR.
21
+ const ADHDEV_HOME = process.env.ADHDEV_CONFIG_DIR && process.env.ADHDEV_CONFIG_DIR.trim()
22
+ ? process.env.ADHDEV_CONFIG_DIR.trim()
23
+ : path.join(os.homedir(), '.adhdev');
24
+ const LOG_DIR = path.join(ADHDEV_HOME, 'logs');
23
25
 
24
26
  const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
25
27
  const MAX_DAYS = 7;
@@ -7,7 +7,7 @@
7
7
  * 1. daemonLog(category, msg, level) — explicit per-category logging
8
8
  * 2. installGlobalInterceptor() — Auto-intercept console.log (once on daemon start)
9
9
  * 3. Recent log ring buffer — for remote transmission via P2P/WS
10
- * 4. File logging — ~/Library/Logs/adhdev/daemon.log (10MB rolling)
10
+ * 4. File logging — ~/.adhdev/logs/daemon-YYYY-MM-DD.log (date-based rolling)
11
11
  *
12
12
  * use:
13
13
  * import { daemonLog, LOG } from './daemon-logger';
@@ -37,11 +37,17 @@ export function setLogLevel(level: LogLevel): void {
37
37
 
38
38
  export function getLogLevel(): LogLevel { return currentLevel; }
39
39
  // ─── File logging (date-based rolling) ──────────────────────────────
40
- const LOG_DIR = process.platform === 'win32'
41
- ? path.join(process.env.LOCALAPPDATA || process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'adhdev', 'logs')
42
- : process.platform === 'darwin'
43
- ? path.join(os.homedir(), 'Library', 'Logs', 'adhdev')
44
- : path.join(os.homedir(), '.local', 'share', 'adhdev', 'logs');
40
+ // Logs live under the unified ADHDev home (~/.adhdev/logs/) on every platform,
41
+ // alongside config.json, providers/, history/, daemon.pid and session-host.log.
42
+ // Earlier builds wrote to OS-specific dirs (~/Library/Logs/adhdev on macOS,
43
+ // ~/.local/share/adhdev/logs on Linux, %LOCALAPPDATA%/adhdev/logs on Windows),
44
+ // which made the daemon log undiscoverable next to everything else under
45
+ // ~/.adhdev and inconsistent with session-host.log. Honor ADHDEV_CONFIG_DIR so
46
+ // isolated/standalone namespaces keep their logs in their own home.
47
+ const ADHDEV_HOME = process.env.ADHDEV_CONFIG_DIR && process.env.ADHDEV_CONFIG_DIR.trim()
48
+ ? process.env.ADHDEV_CONFIG_DIR.trim()
49
+ : path.join(os.homedir(), '.adhdev');
50
+ const LOG_DIR = path.join(ADHDEV_HOME, 'logs');
45
51
 
46
52
  const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB per day
47
53
  const MAX_LOG_DAYS = 7; // 7-day retention
@@ -168,6 +168,46 @@ function statusFromTerminal(entry: MeshLedgerEntry): MeshActiveWorkStatus {
168
168
  return 'failed';
169
169
  }
170
170
 
171
+ const LEDGER_ONLY_STALE_REASON =
172
+ 'direct task dispatch has no provider acknowledgement, transcript append, or active runtime transition';
173
+
174
+ /**
175
+ * Single source of truth for classifying whether a direct dispatch is ledger-only
176
+ * stale (dispatched but never acknowledged: no provider ack, no transcript append,
177
+ * no runtime transition) and whether that staleness is "fresh unacknowledged"
178
+ * (target session still live) vs. an orphaned historical record.
179
+ *
180
+ * Previously this predicate was inlined in all three dispatch-classification blocks
181
+ * (MeshRuntimeStore path, remote-ledger path, full-ledger path) and had drifted —
182
+ * e.g. one block gated `isIdleUnacknowledged` on `!isTerminal` and another did not.
183
+ * Extracting it keeps the three callers byte-for-byte consistent. Status derivation
184
+ * stays per-block: it genuinely differs by data source and is passed in via `status`.
185
+ */
186
+ function classifyDirectDispatch(params: {
187
+ /** Final classified status of the dispatch. */
188
+ status: MeshActiveWorkStatus;
189
+ /** True when a real terminal row applies (completed/failed/stale); excludes approval_needed. Gates the stale verdict. */
190
+ isTerminalRow: boolean;
191
+ /** True when any terminal-derived status exists (incl. approval_needed). Used to detect "no transition". */
192
+ hasTerminalStatus: boolean;
193
+ /** Live session status from the mesh nodes, if any. */
194
+ liveStatus?: string;
195
+ /** Live stale reason from the mesh nodes, if any. */
196
+ liveStaleReason?: string;
197
+ /** Whether the dispatch targeted an already-idle session. */
198
+ dispatchedToIdleSession: boolean;
199
+ }): { ledgerOnlyStaleReason: string | undefined; isFreshUnacknowledged: boolean } {
200
+ const { status, isTerminalRow, hasTerminalStatus, liveStatus, liveStaleReason, dispatchedToIdleSession } = params;
201
+ const isNoTransition = !hasTerminalStatus && !liveStatus;
202
+ const isIdleUnacknowledged = status === 'idle';
203
+ const ledgerOnlyStaleReason =
204
+ !isTerminalRow && (isIdleUnacknowledged || isNoTransition || (dispatchedToIdleSession && isIdleUnacknowledged))
205
+ ? LEDGER_ONLY_STALE_REASON
206
+ : undefined;
207
+ const isFreshUnacknowledged = Boolean(ledgerOnlyStaleReason && !liveStaleReason);
208
+ return { ledgerOnlyStaleReason, isFreshUnacknowledged };
209
+ }
210
+
171
211
  export function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]): MeshActiveWorkSummary {
172
212
  const statusCounts: Record<MeshActiveWorkStatus, number> = {
173
213
  pending: 0,
@@ -239,12 +279,14 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
239
279
  const status: MeshActiveWorkStatus = isTerminal
240
280
  ? (dbStatus === 'completed' ? 'idle' : 'failed')
241
281
  : live.status || (dbStatus === 'acked' ? 'generating' : 'assigned');
242
- const isNoTransition = !isTerminal && !live.status;
243
- const isIdleUnacknowledged = status === 'idle' && !isTerminal;
244
- const ledgerOnlyStaleReason = !isTerminal && (isIdleUnacknowledged || isNoTransition || (dispatch.dispatchedToIdleSession && isIdleUnacknowledged))
245
- ? 'direct task dispatch has no provider acknowledgement, transcript append, or active runtime transition'
246
- : undefined;
247
- const isFreshUnacknowledged = Boolean(ledgerOnlyStaleReason && !live.staleReason);
282
+ const { ledgerOnlyStaleReason, isFreshUnacknowledged } = classifyDirectDispatch({
283
+ status,
284
+ isTerminalRow: isTerminal,
285
+ hasTerminalStatus: isTerminal,
286
+ liveStatus: live.status,
287
+ liveStaleReason: live.staleReason,
288
+ dispatchedToIdleSession: dispatch.dispatchedToIdleSession === true,
289
+ });
248
290
  const { title, summary } = summarizeMessage(dispatch.message || '');
249
291
  const record: MeshActiveWorkRecord = {
250
292
  taskId: dispatch.taskId,
@@ -291,15 +333,16 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
291
333
  const live = sessionStatusFromNodes(opts.nodes, dispatch.nodeId, dispatch.sessionId);
292
334
  const status = terminalStatus || live.status || 'assigned';
293
335
  const terminalRow = Boolean(terminal && terminal.kind !== 'task_approval_needed');
294
- const dispatchedToIdleSession = dispatch.payload?.dispatchedToIdleSession === true;
295
- const isNoTransition = !terminalStatus && !live.status;
296
- const isIdleUnacknowledged = status === 'idle';
297
- const ledgerOnlyStaleReason = !terminalRow && (isIdleUnacknowledged || isNoTransition || (dispatchedToIdleSession && isIdleUnacknowledged))
298
- ? 'direct task dispatch has no provider acknowledgement, transcript append, or active runtime transition'
299
- : undefined;
336
+ const { ledgerOnlyStaleReason, isFreshUnacknowledged } = classifyDirectDispatch({
337
+ status,
338
+ isTerminalRow: terminalRow,
339
+ hasTerminalStatus: Boolean(terminalStatus),
340
+ liveStatus: live.status,
341
+ liveStaleReason: live.staleReason,
342
+ dispatchedToIdleSession: dispatch.payload?.dispatchedToIdleSession === true,
343
+ });
300
344
  const message = readString(dispatch.payload?.message) || readString(dispatch.payload?.summary) || '';
301
345
  const { title, summary } = summarizeMessage(message);
302
- const isFreshUnacknowledged = Boolean(ledgerOnlyStaleReason && !live.staleReason);
303
346
  const record: MeshActiveWorkRecord = {
304
347
  taskId,
305
348
  source: 'direct',
@@ -344,15 +387,16 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
344
387
  const live = sessionStatusFromNodes(opts.nodes, dispatch.nodeId, dispatch.sessionId);
345
388
  const status = terminalStatus || live.status || 'assigned';
346
389
  const terminalRow = Boolean(terminal && terminal.kind !== 'task_approval_needed');
347
- const dispatchedToIdleSession = dispatch.payload?.dispatchedToIdleSession === true;
348
- const isNoTransition = !terminalStatus && !live.status;
349
- const isIdleUnacknowledged = status === 'idle';
350
- const ledgerOnlyStaleReason = !terminalRow && (isIdleUnacknowledged || isNoTransition || (dispatchedToIdleSession && isIdleUnacknowledged))
351
- ? 'direct task dispatch has no provider acknowledgement, transcript append, or active runtime transition'
352
- : undefined;
390
+ const { ledgerOnlyStaleReason, isFreshUnacknowledged } = classifyDirectDispatch({
391
+ status,
392
+ isTerminalRow: terminalRow,
393
+ hasTerminalStatus: Boolean(terminalStatus),
394
+ liveStatus: live.status,
395
+ liveStaleReason: live.staleReason,
396
+ dispatchedToIdleSession: dispatch.payload?.dispatchedToIdleSession === true,
397
+ });
353
398
  const message = readString(dispatch.payload?.message) || readString(dispatch.payload?.summary) || '';
354
399
  const { title, summary } = summarizeMessage(message);
355
- const isFreshUnacknowledged = Boolean(ledgerOnlyStaleReason && !live.staleReason);
356
400
  const record: MeshActiveWorkRecord = {
357
401
  taskId,
358
402
  source: 'direct',