@adhdev/daemon-core 0.9.82-rc.486 → 0.9.82-rc.488

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,12 +13,21 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
13
13
  forwarded: number;
14
14
  suppressed: boolean;
15
15
  autoApprovingWorkerApproval: boolean;
16
+ staleDispatchRejected?: undefined;
17
+ error?: undefined;
18
+ } | {
19
+ success: boolean;
20
+ forwarded: number;
21
+ suppressed: boolean;
22
+ staleDispatchRejected: boolean;
23
+ autoApprovingWorkerApproval?: undefined;
16
24
  error?: undefined;
17
25
  } | {
18
26
  success: boolean;
19
27
  forwarded: number;
20
28
  suppressed?: undefined;
21
29
  autoApprovingWorkerApproval?: undefined;
30
+ staleDispatchRejected?: undefined;
22
31
  error?: undefined;
23
32
  } | {
24
33
  success: boolean;
@@ -11,6 +11,7 @@
11
11
  * keep working. `CommandRouterResult` is imported type-only from router.ts
12
12
  * (erased at compile time — no runtime import cycle).
13
13
  */
14
+ import type { ChangedPackageClassification } from '../git/git-status.js';
14
15
  import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
15
16
  import type { CommandRouterResult } from '../commands/router.js';
16
17
  export type MeshCoordinatorConfigFormat = 'claude_mcp_json' | 'hermes_config_yaml';
@@ -53,6 +54,19 @@ type MeshRefineValidationSummary = {
53
54
  };
54
55
  /** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
55
56
  deprecationWarnings?: string[];
57
+ /**
58
+ * Coarse daemon-vs-web change-impact used to scope the validation command set.
59
+ * When `isDaemonAffecting === false`, daemon-scoped commands are recorded in
60
+ * `commandsRun` with `skipped: true, skipReason: 'unaffected_daemon_scope'`
61
+ * rather than executed; web + typecheck commands always run. Absent when no
62
+ * change-impact was threaded in (legacy: full command set runs).
63
+ */
64
+ changeImpact?: {
65
+ isDaemonAffecting: boolean;
66
+ affectedPackages: string[];
67
+ /** displayCommands skipped because the daemon scope is unaffected. */
68
+ skippedDaemonCommands?: string[];
69
+ };
56
70
  };
57
71
  type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
58
72
  type MeshRefinePatchEquivalenceSummary = {
@@ -277,6 +291,13 @@ export interface RefineContext {
277
291
  baseBranch: string;
278
292
  baseHead: string;
279
293
  branchHead: string;
294
+ /**
295
+ * Coarse daemon-vs-web change-impact for baseHead..branchHead, resolved in the
296
+ * resolve_refs stage and threaded into the validation gate to scope its command
297
+ * set. `undefined` means "could not classify" → the gate fails open and runs ALL
298
+ * commands (never skip on uncertainty).
299
+ */
300
+ changeImpact?: ChangedPackageClassification;
280
301
  validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
281
302
  patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
282
303
  submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
@@ -424,5 +445,13 @@ export declare function runMeshRefineValidationGate(mesh: any, workspace: string
424
445
  persistedBootstrapState?: WorktreeBootstrapState | null;
425
446
  /** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
426
447
  onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
448
+ /**
449
+ * Coarse daemon-vs-web change-impact for the branch (resolve_refs computes it
450
+ * over baseHead..branchHead). When provided and `isDaemonAffecting === false`,
451
+ * daemon-scoped validation commands are skipped (web + typecheck still run).
452
+ * When omitted or `isDaemonAffecting === true`, the full command set runs —
453
+ * fail-open to full validation on any uncertainty.
454
+ */
455
+ changeImpact?: ChangedPackageClassification;
427
456
  }): Promise<MeshRefineValidationSummary>;
428
457
  export {};
@@ -179,6 +179,17 @@ export interface MeshWorkQueueEntry {
179
179
  };
180
180
  /** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
181
181
  dispatchTimestamp?: string;
182
+ /**
183
+ * REDRIVE-DUP: monotonic per-task dispatch nonce. Bumped on every (re)dispatch of
184
+ * this task (assignQueueTask) AND on every reclaim (reclaimStrandedAssignedTask), and
185
+ * carried to the worker in meshContext.dispatchNonce. The worker echoes it back on
186
+ * agent:generating_started (metadataEvent.dispatchNonce). When a delivered-not-consumed
187
+ * task is reclaimed and re-dispatched to a different node, the ORIGINAL inject to the
188
+ * first node still carries the now-stale nonce; the coordinator rejects that node's
189
+ * generating_started ack (and stops it) so the SAME taskId is never executed twice.
190
+ * Absent on legacy rows → the coordinator skips the stale-nonce guard (backward safe).
191
+ */
192
+ dispatchNonce?: number;
182
193
  /**
183
194
  * (3) The ORIGINATING coordinator session that enqueued this task. Stamped onto the
184
195
  * worker at dispatch (meshCoordinatorSessionId) so the task's completion routes back to
@@ -201,6 +201,7 @@ export declare class CliProviderInstance implements ProviderInstance {
201
201
  meshId: string;
202
202
  nodeId?: string;
203
203
  taskId?: string;
204
+ dispatchNonce?: number;
204
205
  coordinatorDaemonId?: string;
205
206
  coordinatorSessionId?: string;
206
207
  }): void;
@@ -92,6 +92,7 @@ export declare class ProviderInstanceManager {
92
92
  meshId: string;
93
93
  nodeId?: string;
94
94
  taskId?: string;
95
+ dispatchNonce?: number;
95
96
  coordinatorDaemonId?: string;
96
97
  coordinatorSessionId?: string;
97
98
  }): {
@@ -188,6 +188,7 @@ export interface ProviderInstance {
188
188
  meshId: string;
189
189
  nodeId?: string;
190
190
  taskId?: string;
191
+ dispatchNonce?: number;
191
192
  coordinatorDaemonId?: string;
192
193
  coordinatorSessionId?: string;
193
194
  }): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.486",
3
+ "version": "0.9.82-rc.488",
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",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.486",
51
- "@adhdev/session-host-core": "0.9.82-rc.486",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.488",
51
+ "@adhdev/session-host-core": "0.9.82-rc.488",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -1725,6 +1725,10 @@ export class DaemonCliManager {
1725
1725
  meshId: meshContext.meshId,
1726
1726
  ...(typeof meshContext.nodeId === 'string' && meshContext.nodeId ? { nodeId: meshContext.nodeId } : {}),
1727
1727
  ...(typeof meshContext.taskId === 'string' && meshContext.taskId ? { taskId: meshContext.taskId } : {}),
1728
+ // REDRIVE-DUP: carry the dispatch nonce onto the worker session so
1729
+ // its generating_started event echoes it back for the coordinator's
1730
+ // stale-nonce guard.
1731
+ ...(typeof meshContext.dispatchNonce === 'number' ? { dispatchNonce: meshContext.dispatchNonce } : {}),
1728
1732
  ...(typeof meshContext.coordinatorDaemonId === 'string' && meshContext.coordinatorDaemonId ? { coordinatorDaemonId: meshContext.coordinatorDaemonId } : {}),
1729
1733
  });
1730
1734
  } catch { /* best-effort — stamping is a routing aid, not a hard requirement */ }
@@ -17,6 +17,8 @@ import { handleMeshForwardEvent, queuePendingMeshCoordinatorEvent } from '../mes
17
17
  import { analyzeMeshRefineNodeChangeArea, orderMeshRefineBatchNodes } from '../mesh/mesh-refine-batch.js';
18
18
  import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
19
19
  import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
20
+ import { classifyChangedPackages } from '../git/git-status.js';
21
+ import type { ChangedPackageClassification } from '../git/git-status.js';
20
22
  import { readStringValue } from '../mesh/mesh-node-identity.js';
21
23
  import {
22
24
  alignRefinerySubmodulesAfterMerge,
@@ -80,7 +82,65 @@ export function buildRefineJobHandle(self: DaemonCommandRouter, args: {
80
82
  };
81
83
  }
82
84
 
85
+ /**
86
+ * Slim the terminal-stage refine result down to the fields a coordinator needs to
87
+ * decide next-step, dropping the heavy per-command / per-entry detail.
88
+ *
89
+ * The full `CommandRouterResult` (with `validationSummary.commandsRun[]` carrying
90
+ * per-command stdout/stderr, `rejectedCommands`, `suggestions`, `suggestedConfig`,
91
+ * the full `patchEquivalence`, and `submoduleReachability.entries[]`/`.unreachable[]`)
92
+ * routinely exceeds 70KB and overflows the coordinator token limit when it rides on a
93
+ * `mesh_wait_events` payload. The full detail is still persisted verbatim to the ledger
94
+ * (`appendRefineJobLedger`) and `terminalRefineJobs`, so slimming only the EVENT loses
95
+ * nothing — the coordinator can pull the full record on demand via
96
+ * `evidence.ledgerCommand` / `taskHistoryKind`.
97
+ */
98
+ export function slimRefineEventResult(result: Record<string, unknown>): Record<string, unknown> {
99
+ const slim: Record<string, unknown> = {};
100
+ // Top-level scalars the coordinator branches on.
101
+ for (const key of [
102
+ 'success', 'code', 'error', 'convergenceStatus', 'blockedReason',
103
+ 'branch', 'into', 'terminalKind', 'nextStep', 'finalBranchConvergenceState',
104
+ ] as const) {
105
+ if (result[key] !== undefined) slim[key] = result[key];
106
+ }
107
+ // Mapped subset of the unreachable-submodule commits (path + autoPublishAllowed),
108
+ // not the full commit records.
109
+ if (Array.isArray(result.unreachableSubmoduleCommits)) {
110
+ slim.unreachableSubmoduleCommits = (result.unreachableSubmoduleCommits as Array<Record<string, unknown>>)
111
+ .map(e => ({ path: e?.path, autoPublishAllowed: e?.autoPublishAllowed }));
112
+ }
113
+ // Reduced validation summary — status + failure classification + config source
114
+ // + a count of commands run (drop the full commandsRun/rejectedCommands/
115
+ // suggestions/suggestedConfig detail).
116
+ if (result.validationSummary && typeof result.validationSummary === 'object') {
117
+ const vs = result.validationSummary as Record<string, unknown>;
118
+ slim.validationSummary = {
119
+ status: vs.status,
120
+ failureCode: vs.failureCode,
121
+ configSource: vs.configSource,
122
+ configSourceType: vs.configSourceType,
123
+ commandsRunCount: Array.isArray(vs.commandsRun) ? vs.commandsRun.length : undefined,
124
+ };
125
+ }
126
+ // Reduce patch-equivalence to just its verdict.
127
+ if (result.patchEquivalence && typeof result.patchEquivalence === 'object') {
128
+ const pe = result.patchEquivalence as Record<string, unknown>;
129
+ slim.patchEquivalence = { status: pe.status, equivalent: pe.equivalent };
130
+ }
131
+ // Reduce submodule reachability to counts; drop the full entries/unreachable arrays.
132
+ if (result.submoduleReachability && typeof result.submoduleReachability === 'object') {
133
+ const sr = result.submoduleReachability as Record<string, unknown>;
134
+ slim.submoduleReachability = {
135
+ checked: Array.isArray(sr.entries) ? sr.entries.length : undefined,
136
+ unreachable: Array.isArray(sr.unreachable) ? sr.unreachable.length : undefined,
137
+ };
138
+ }
139
+ return slim;
140
+ }
141
+
83
142
  export function queueRefineJobEvent(self: DaemonCommandRouter, event: 'refine:accepted' | 'refine:completed' | 'refine:failed', handle: MeshRefineJobHandle, result?: Record<string, unknown>): void {
143
+ const slimResult = result ? slimRefineEventResult(result) : undefined;
84
144
  const metadataEvent = {
85
145
  source: 'refine_mesh_node_async_job',
86
146
  jobId: handle.jobId,
@@ -93,7 +153,7 @@ export function queueRefineJobEvent(self: DaemonCommandRouter, event: 'refine:ac
93
153
  startedAt: handle.startedAt,
94
154
  completedAt: handle.completedAt,
95
155
  retryOfJobId: handle.retryOfJobId,
96
- ...(result ? { result } : {}),
156
+ ...(slimResult ? { result: slimResult } : {}),
97
157
  };
98
158
  const eventPayload = {
99
159
  event,
@@ -120,7 +180,7 @@ export function queueRefineJobEvent(self: DaemonCommandRouter, event: 'refine:ac
120
180
  startedAt: handle.startedAt,
121
181
  completedAt: handle.completedAt,
122
182
  retryOfJobId: handle.retryOfJobId,
123
- ...(result ? { result } : {}),
183
+ ...(slimResult ? { result: slimResult } : {}),
124
184
  },
125
185
  );
126
186
  if (forwarded?.success === true) return;
@@ -312,7 +372,24 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
312
372
  const { stdout: branchHeadStdout } = await execFileAsync('git', ['rev-parse', branch], { cwd: node.workspace, encoding: 'utf8' });
313
373
  const baseHead = baseHeadRaw;
314
374
  const branchHead = branchHeadStdout.trim();
315
- recordMeshRefineStage(refineStages, 'resolve_refs', 'passed', resolveStarted, { branch, baseBranch, baseHead, branchHead, ...(fetchWarning ? { fetchWarning } : {}) });
375
+
376
+ // Coarse daemon-vs-web change-impact for baseHead..branchHead, computed
377
+ // against the worktree so the same policy (.adhdev/change-impact.*) as the
378
+ // stale-build detector applies. Threaded onto ctx so the validation gate
379
+ // can scope its command set: a web-only branch skips daemon-scoped commands.
380
+ // FAIL-OPEN: any classification error leaves changeImpact undefined → the
381
+ // gate runs the full command set (never skip on uncertainty).
382
+ let changeImpact: ChangedPackageClassification | undefined;
383
+ try {
384
+ changeImpact = await classifyChangedPackages(node.workspace, baseHead, branchHead);
385
+ } catch {
386
+ changeImpact = undefined;
387
+ }
388
+ recordMeshRefineStage(refineStages, 'resolve_refs', 'passed', resolveStarted, {
389
+ branch, baseBranch, baseHead, branchHead,
390
+ ...(changeImpact ? { changeImpact } : {}),
391
+ ...(fetchWarning ? { fetchWarning } : {}),
392
+ });
316
393
 
317
394
  return {
318
395
  kind: 'continue',
@@ -330,6 +407,7 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
330
407
  baseBranch,
331
408
  baseHead,
332
409
  branchHead,
410
+ changeImpact,
333
411
  validationSummary: undefined as any,
334
412
  patchEquivalence: undefined as any,
335
413
  submoduleReachability: undefined as any,
@@ -346,6 +424,9 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
346
424
  const { mesh, node, branch, baseBranch, refineStages } = ctx;
347
425
  const validationStarted = Date.now();
348
426
  const validationSummary = await runMeshRefineValidationGate(mesh, node.workspace, {
427
+ // (a) Scope the validation command set by coarse change-impact (resolved
428
+ // in resolve_refs). Undefined → gate runs the full command set (fail-open).
429
+ changeImpact: ctx.changeImpact,
349
430
  // M2-2: consume the node's persisted bootstrap state; persist re-runs.
350
431
  persistedBootstrapState: (node as any).worktreeBootstrap as WorktreeBootstrapState | undefined,
351
432
  onBootstrapStateChange: (state) => {
@@ -369,7 +450,9 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
369
450
  : undefined;
370
451
  const buildValidationFailedError = (): string => {
371
452
  const base = validationSummary.failureCode === 'missing_dependencies'
372
- ? 'Refinery validation dependencies are missing; merge/refine was not attempted. Configure validation.bootstrapCommands if Refinery should bootstrap dependencies before validation.'
453
+ ? 'Refinery validation dependencies are missing for a change-affected package; merge/refine was not attempted. '
454
+ + 'To make this self-service, either (1) configure .adhdev/worktree_bootstrap.json (or validation.bootstrapCommands in .adhdev/refine.json) so Refinery installs deps before validation, '
455
+ + 'or (2) converge the branch via the documented manual fast-forward-only bypass (rebase onto the fetched base, verify strict ancestry, then push ff-only) instead of the refine gate.'
373
456
  : validationSummary.failureCode === 'dependency_bootstrap_failed'
374
457
  ? 'Refinery dependency/bootstrap command failed; merge/refine was not attempted.'
375
458
  : validationSummary.failureCode === 'spawn_resolution_failed'
@@ -438,6 +438,54 @@ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy):
438
438
  return false;
439
439
  }
440
440
 
441
+ /** Coarse change-impact verdict produced from a changed-file list. */
442
+ export interface ChangedPackageClassification {
443
+ isDaemonAffecting: boolean;
444
+ affectedPackages: string[];
445
+ }
446
+
447
+ /**
448
+ * Pure bucketer: classify an already-collected changed-file list into the coarse
449
+ * daemon-vs-web verdict, per the resolved policy. Shared by classifyDaemonBuildChange
450
+ * (buildCommit..HEAD) and classifyChangedPackages (arbitrary ref range) so the
451
+ * daemon/web boundary logic lives in exactly one place. An empty list stays
452
+ * conservative (daemon-affecting) so an actionable warning is never suppressed.
453
+ */
454
+ function classifyChangedFileList(
455
+ files: string[],
456
+ policy: ResolvedChangeImpactPolicy,
457
+ ): ChangedPackageClassification {
458
+ if (files.length === 0) {
459
+ // No file diff (e.g. only merge metadata) — nothing actionable, but stay
460
+ // conservative and treat as daemon-affecting so we don't suppress a real warning.
461
+ return { isDaemonAffecting: true, affectedPackages: [] };
462
+ }
463
+ const pkgs = new Set<string>();
464
+ // A non-package path that is NOT a recognized benign root file (marker/doc).
465
+ // Only these force daemon-affecting; benign markers/docs are ignored so a
466
+ // gitlink-moving root commit over a marker-only oss commit no longer over-warns.
467
+ let sawRuntimeAmbiguousNonPackage = false;
468
+ for (const file of files) {
469
+ const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
470
+ if (!match) {
471
+ if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
472
+ continue;
473
+ }
474
+ pkgs.add(match[1]);
475
+ }
476
+ const affectedPackages = [...pkgs].sort();
477
+ // Daemon-affecting if: any runtime-ambiguous non-package file changed, any
478
+ // unknown package changed, or any explicit daemon-runtime package changed.
479
+ // The daemon is unaffected only when every changed file is either a known
480
+ // web-only package or a recognized benign root file (and at least one such
481
+ // file changed) — i.e. nothing runtime-ambiguous remains. Unlisted/new packages
482
+ // therefore stay daemon-affecting (fail-safe default preserved).
483
+ const allBenign =
484
+ !sawRuntimeAmbiguousNonPackage &&
485
+ affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
486
+ return { isDaemonAffecting: !allBenign, affectedPackages };
487
+ }
488
+
441
489
  /**
442
490
  * Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
443
491
  * package, per the resolved policy. Returns isDaemonAffecting:true conservatively
@@ -449,47 +497,54 @@ async function classifyDaemonBuildChange(
449
497
  buildCommit: string,
450
498
  options: GitStatusOptions,
451
499
  policy: ResolvedChangeImpactPolicy,
452
- ): Promise<{ isDaemonAffecting: boolean; affectedPackages: string[] }> {
500
+ ): Promise<ChangedPackageClassification> {
453
501
  try {
454
502
  const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
455
503
  const files = diff.stdout
456
504
  .split('\n')
457
505
  .map((line) => line.trim())
458
506
  .filter(Boolean);
459
- if (files.length === 0) {
460
- // No file diff (e.g. only merge metadata) — nothing actionable, but stay
461
- // conservative and treat as daemon-affecting so we don't suppress a real warning.
462
- return { isDaemonAffecting: true, affectedPackages: [] };
463
- }
464
- const pkgs = new Set<string>();
465
- // A non-package path that is NOT a recognized benign root file (marker/doc).
466
- // Only these force daemon-affecting; benign markers/docs are ignored so a
467
- // gitlink-moving root commit over a marker-only oss commit no longer over-warns.
468
- let sawRuntimeAmbiguousNonPackage = false;
469
- for (const file of files) {
470
- const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
471
- if (!match) {
472
- if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
473
- continue;
474
- }
475
- pkgs.add(match[1]);
476
- }
477
- const affectedPackages = [...pkgs].sort();
478
- // Daemon-affecting if: any runtime-ambiguous non-package file changed, any
479
- // unknown package changed, or any explicit daemon-runtime package changed.
480
- // The daemon is unaffected only when every changed file is either a known
481
- // web-only package or a recognized benign root file (and at least one such
482
- // file changed) — i.e. nothing runtime-ambiguous remains.
483
- const allBenign =
484
- !sawRuntimeAmbiguousNonPackage &&
485
- affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
486
- return { isDaemonAffecting: !allBenign, affectedPackages };
507
+ return classifyChangedFileList(files, policy);
487
508
  } catch {
488
509
  // diff probe failed → can't prove web-only; stay conservative.
489
510
  return { isDaemonAffecting: true, affectedPackages: [] };
490
511
  }
491
512
  }
492
513
 
514
+ /**
515
+ * Ref-parameterized change-impact classification for a repo/worktree, reusing the
516
+ * exact daemon-vs-web bucketing that the stale-build detector uses — but over an
517
+ * arbitrary `fromRef..toRef` range (e.g. a refine base head → branch head) instead
518
+ * of the live daemon's build commit → HEAD, and WITHOUT any daemonBuildInfo caching.
519
+ *
520
+ * Policy is resolved the same way as getGitRepoStatus: an explicit
521
+ * `options.changeImpactConfig` wins; otherwise the repo's `.adhdev/change-impact.*`
522
+ * is auto-loaded; otherwise the built-in ADHDev default policy applies. The
523
+ * classification uses `git diff --name-only fromRef..toRef`.
524
+ *
525
+ * FAIL-OPEN on error: if the diff can't be collected (bad ref, not a repo), the
526
+ * caller should treat "no verdict" as "run everything" — so we throw rather than
527
+ * returning a misleading benign verdict. Callers wrap this in try/catch and leave
528
+ * changeImpact undefined on failure. Unclassified/new packages still default to
529
+ * isDaemonAffecting:true (never silently skipped).
530
+ */
531
+ export async function classifyChangedPackages(
532
+ repoPath: string,
533
+ fromRef: string,
534
+ toRef: string,
535
+ options: GitStatusOptions = {},
536
+ ): Promise<ChangedPackageClassification> {
537
+ const repo = await resolveGitRepository(repoPath, options);
538
+ const { config } = resolveChangeImpactConfigForRepo(repo.repoRoot, options);
539
+ const policy = resolveChangeImpactPolicy(config);
540
+ const diff = await runGit(repoPath, ['diff', '--name-only', `${fromRef}..${toRef}`], options);
541
+ const files = diff.stdout
542
+ .split('\n')
543
+ .map((line) => line.trim())
544
+ .filter(Boolean);
545
+ return classifyChangedFileList(files, policy);
546
+ }
547
+
493
548
  /**
494
549
  * Resolve the Change Impact config to apply for this status read. Priority:
495
550
  * - options.changeImpactConfig === null → force built-in default policy (no load).
@@ -24,6 +24,7 @@ import {
24
24
  buildNoProgressCompletionReconciliation,
25
25
  } from './mesh-events-stale.js';
26
26
  import { endTaskDispatchInFlight } from './mesh-task-inflight.js';
27
+ import { readMeshNodeDaemonId } from './mesh-node-identity.js';
27
28
  import {
28
29
  buildMeshSystemMessage,
29
30
  readNonEmptyString,
@@ -716,6 +717,58 @@ function sourceWorkerAutoApproves(components: DaemonComponents, sessionId: strin
716
717
  }
717
718
  }
718
719
 
720
+ /**
721
+ * REDRIVE-DUP: stop a worker session that started a STALE (reclaimed) mesh dispatch,
722
+ * so it discards the reclaimed task before it double-executes it. Prefers the local
723
+ * transport when the session's adapter lives on this daemon; otherwise forwards a
724
+ * `stop_cli` to the worker node's daemon over P2P (best-effort — a failed stop only
725
+ * loses the belt-and-suspenders stop; the ack was already rejected, so the coordinator
726
+ * never treats the stale run as the authoritative execution).
727
+ */
728
+ function stopStaleMeshWorker(
729
+ components: DaemonComponents,
730
+ args: { meshId: string; sessionId: string; nodeId?: string; providerType?: string; daemonId?: string },
731
+ ): void {
732
+ const { meshId, sessionId, providerType } = args;
733
+ const stopArgs: Record<string, unknown> = {
734
+ targetSessionId: sessionId,
735
+ ...(providerType ? { cliType: providerType } : {}),
736
+ mode: 'hard',
737
+ reason: 'stale_mesh_dispatch_reclaimed',
738
+ };
739
+ try {
740
+ const isLocal = components.cliManager?.adapters?.has?.(sessionId) === true;
741
+ if (isLocal) {
742
+ // cliType is required by stop_cli; resolve it from the local adapter when the
743
+ // event carried no providerType.
744
+ if (!stopArgs.cliType) {
745
+ const localType = components.cliManager?.adapters?.get?.(sessionId)?.cliType;
746
+ if (localType) stopArgs.cliType = localType;
747
+ }
748
+ Promise.resolve(components.cliManager?.handleCliCommand?.('stop_cli', stopArgs))
749
+ .catch((e: any) => LOG.warn('MeshQueue', `Local stop of stale worker ${sessionId} failed: ${e?.message || e}`));
750
+ return;
751
+ }
752
+ // Remote: resolve the worker node's daemon id (event metadata first, then the mesh node).
753
+ let daemonId = args.daemonId;
754
+ if (!daemonId && args.nodeId) {
755
+ try {
756
+ const mesh = getMeshWithCache(components, meshId);
757
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, args.nodeId!));
758
+ daemonId = node ? readMeshNodeDaemonId(node) || undefined : undefined;
759
+ } catch { /* best-effort */ }
760
+ }
761
+ if (daemonId && components.dispatchMeshCommand) {
762
+ Promise.resolve(components.dispatchMeshCommand(daemonId, 'stop_cli', stopArgs))
763
+ .catch((e: any) => LOG.warn('MeshQueue', `Remote stop of stale worker ${sessionId} on daemon ${daemonId} failed: ${e?.message || e}`));
764
+ } else {
765
+ LOG.warn('MeshQueue', `Cannot stop stale worker ${sessionId}: no local adapter and no resolvable remote daemon id (node ${args.nodeId ?? '?'}). Ack already rejected — task will re-strand-and-fail if the worker completes.`);
766
+ }
767
+ } catch (e: any) {
768
+ LOG.warn('MeshQueue', `stopStaleMeshWorker error for ${sessionId}: ${e?.message || e}`);
769
+ }
770
+ }
771
+
719
772
  function injectMeshSystemMessage(components: DaemonComponents, args: {
720
773
  meshId: string;
721
774
  sourceInstanceId?: string;
@@ -1115,6 +1168,45 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1115
1168
  // sibling must keep that row 'dispatched' so its own confirm can match it; acking
1116
1169
  // by session would mark it 'acked' prematurely and hide a genuine non-delivery.
1117
1170
  const startedTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
1171
+ // REDRIVE-DUP: reject a STALE dispatch. When a delivered-but-unconsumed task is
1172
+ // reclaimed (reclaimStrandedAssignedTask) and re-dispatched to another node, the
1173
+ // ORIGINAL inject to the first node is not cancelled — it can still fire and make
1174
+ // that worker start the SAME taskId, double-executing it. The reclaim bumped the
1175
+ // task row's dispatchNonce, so the stranded inject's generating_started echoes a
1176
+ // nonce STRICTLY LESS than the row's current value. Detect that here: skip the ack
1177
+ // (do NOT resurrect the row onto this stale session) and stop the worker so it
1178
+ // discards the reclaimed task. A matching/greater nonce, or an absent nonce
1179
+ // (legacy worker), falls through to the normal ack — backward safe.
1180
+ const startedNonce = typeof args.metadataEvent.dispatchNonce === 'number'
1181
+ ? args.metadataEvent.dispatchNonce
1182
+ : undefined;
1183
+ if (startedTaskId && startedNonce !== undefined) {
1184
+ const currentRow = (() => {
1185
+ try { return MeshRuntimeStore.getInstance().findQueueEntryById(args.meshId, startedTaskId); }
1186
+ catch { return null; }
1187
+ })();
1188
+ const currentNonce = typeof currentRow?.dispatchNonce === 'number' ? currentRow.dispatchNonce : undefined;
1189
+ if (currentNonce !== undefined && startedNonce < currentNonce) {
1190
+ LOG.warn('MeshQueue', `Rejecting stale mesh dispatch: task ${startedTaskId} generating_started from session ${sessionId} `
1191
+ + `(node ${nodeId ?? '?'}) carries dispatchNonce ${startedNonce} < current ${currentNonce} — the task was reclaimed and `
1192
+ + `re-dispatched; stopping this worker to prevent duplicate execution.`);
1193
+ traceMeshEventDrop('stale_dispatch_nonce_rejected', {
1194
+ taskId: startedTaskId,
1195
+ sessionId,
1196
+ nodeId,
1197
+ meshId: args.meshId,
1198
+ event: 'agent:generating_started',
1199
+ }, `nonce ${startedNonce} < ${currentNonce}`);
1200
+ stopStaleMeshWorker(components, {
1201
+ meshId: args.meshId,
1202
+ sessionId,
1203
+ nodeId,
1204
+ providerType: readNonEmptyString(args.metadataEvent.providerType) || readNonEmptyString(args.metadataEvent.cliType),
1205
+ daemonId: readNonEmptyString(args.metadataEvent.sourceDaemonId) || readNonEmptyString(args.metadataEvent.daemonId),
1206
+ });
1207
+ return { success: true, forwarded: 0, suppressed: true, staleDispatchRejected: true };
1208
+ }
1209
+ }
1118
1210
  // WARMUPGAP: only ack a dispatch row when the event names its task, or the session
1119
1211
  // currently holds an active assignment. A no-taskId generating_started from an
1120
1212
  // unassigned session is a pre-assignment warmup — the session_id fallback would ack a
@@ -588,6 +588,10 @@ export function tryAssignQueueTask(
588
588
  meshId,
589
589
  nodeId,
590
590
  taskId: task.id,
591
+ // REDRIVE-DUP: carry the current dispatch nonce so the worker can echo it
592
+ // back on generating_started; a reclaim bumps this row's nonce, making an
593
+ // already-in-flight stale inject rejectable on arrival.
594
+ ...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
591
595
  ...(localDaemonIdForDispatch ? { coordinatorDaemonId: localDaemonIdForDispatch } : {}),
592
596
  ...(sourceCoordinatorSessionId ? { coordinatorSessionId: sourceCoordinatorSessionId } : {}),
593
597
  },
@@ -668,6 +672,8 @@ export function tryAssignQueueTask(
668
672
  meshId,
669
673
  nodeId,
670
674
  taskId: task.id,
675
+ // REDRIVE-DUP: carry the current dispatch nonce (see remote branch above).
676
+ ...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
671
677
  ...(localCoordinatorDaemonId() ? { coordinatorDaemonId: localCoordinatorDaemonId() } : {}),
672
678
  ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { coordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
673
679
  },