@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,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { getGitRepoStatus } from '../git/git-status.js';
16
+ import type { ChangedPackageClassification } from '../git/git-status.js';
16
17
  import * as yaml from 'js-yaml';
17
18
  import { loadMeshRefineConfig, resolveMeshRefineValidationPlan } from '../mesh/refine-config.js';
18
19
  import type { MeshRefineValidationCommandPlan } from '../mesh/refine-config.js';
@@ -74,6 +75,19 @@ type MeshRefineValidationSummary = {
74
75
  };
75
76
  /** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
76
77
  deprecationWarnings?: string[];
78
+ /**
79
+ * Coarse daemon-vs-web change-impact used to scope the validation command set.
80
+ * When `isDaemonAffecting === false`, daemon-scoped commands are recorded in
81
+ * `commandsRun` with `skipped: true, skipReason: 'unaffected_daemon_scope'`
82
+ * rather than executed; web + typecheck commands always run. Absent when no
83
+ * change-impact was threaded in (legacy: full command set runs).
84
+ */
85
+ changeImpact?: {
86
+ isDaemonAffecting: boolean;
87
+ affectedPackages: string[];
88
+ /** displayCommands skipped because the daemon scope is unaffected. */
89
+ skippedDaemonCommands?: string[];
90
+ };
77
91
  };
78
92
 
79
93
  type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
@@ -343,6 +357,13 @@ export interface RefineContext {
343
357
  baseBranch: string;
344
358
  baseHead: string;
345
359
  branchHead: string;
360
+ /**
361
+ * Coarse daemon-vs-web change-impact for baseHead..branchHead, resolved in the
362
+ * resolve_refs stage and threaded into the validation gate to scope its command
363
+ * set. `undefined` means "could not classify" → the gate fails open and runs ALL
364
+ * commands (never skip on uncertainty).
365
+ */
366
+ changeImpact?: ChangedPackageClassification;
346
367
  validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
347
368
  patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
348
369
  submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
@@ -1477,6 +1498,14 @@ export async function runMeshRefineValidationGate(
1477
1498
  persistedBootstrapState?: WorktreeBootstrapState | null;
1478
1499
  /** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
1479
1500
  onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
1501
+ /**
1502
+ * Coarse daemon-vs-web change-impact for the branch (resolve_refs computes it
1503
+ * over baseHead..branchHead). When provided and `isDaemonAffecting === false`,
1504
+ * daemon-scoped validation commands are skipped (web + typecheck still run).
1505
+ * When omitted or `isDaemonAffecting === true`, the full command set runs —
1506
+ * fail-open to full validation on any uncertainty.
1507
+ */
1508
+ changeImpact?: ChangedPackageClassification;
1480
1509
  },
1481
1510
  ): Promise<MeshRefineValidationSummary> {
1482
1511
  const { execFile } = await import('node:child_process');
@@ -1574,6 +1603,63 @@ export async function runMeshRefineValidationGate(
1574
1603
  return ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'bun.lock']
1575
1604
  .some(lock => fs.existsSync(pathJoin(cwd, lock)));
1576
1605
  };
1606
+ // A validation command needs installed node_modules to run. Only these can hit
1607
+ // the missing-deps hard-block; non-package-manager commands (e.g. a plain
1608
+ // `node scripts/check-vendor-drift.mjs`) need no deps and must never be aborted
1609
+ // by a preceding command's missing-deps.
1610
+ const needsNodeModules = (candidate: MeshRefineValidationCommand, cwd: string): boolean =>
1611
+ isPackageManagerValidation(candidate) && dependenciesLikelyMissing(cwd);
1612
+
1613
+ // (a) Coarse change-impact scoping. When the branch is web-only
1614
+ // (changeImpact.isDaemonAffecting === false), daemon-scoped validation commands
1615
+ // are pointless — and often un-runnable in a web-only worktree that never
1616
+ // bootstrapped daemon deps. Identify daemon-scoped commands ONLY by the coarse
1617
+ // daemon-vs-web bucket: a command whose script/args reference a daemon package
1618
+ // (daemon-core / daemon-cloud) or the vendor-drift check. web-side commands
1619
+ // (test:web-core / test:web-cloud) and `typecheck` ALWAYS run — the daemon/web
1620
+ // boundary is the human-curated safe line; we deliberately do NOT do fine
1621
+ // per-package skipping (web-cloud consumes web-core, so it must still run).
1622
+ const isDaemonScopedCommand = (candidate: MeshRefineValidationCommand): boolean => {
1623
+ const haystack = [candidate.command, ...(candidate.args || []), candidate.displayCommand || '']
1624
+ .join(' ')
1625
+ .toLowerCase();
1626
+ // Never treat a typecheck or an explicit web-side command as daemon-scoped.
1627
+ if (candidate.category === 'typecheck') return false;
1628
+ if (/\btypecheck\b/.test(haystack)) return false;
1629
+ if (/\bweb-core\b|\bweb-cloud\b|\bweb-standalone\b|\btest:web\b/.test(haystack)) return false;
1630
+ // Daemon-scoped signals: a daemon package name, a daemon test script, or the
1631
+ // vendor-drift check (which validates the daemon vendor bundle).
1632
+ return /\bdaemon-core\b|\bdaemon-cloud\b|\btest:daemon\b|check-vendor-drift/.test(haystack);
1633
+ };
1634
+
1635
+ const scopeUnaffectedDaemon = opts?.changeImpact?.isDaemonAffecting === false;
1636
+ const skippedDaemonCommands: string[] = [];
1637
+ const commandsToRun: MeshRefineValidationCommand[] = [];
1638
+ for (const candidate of selection.commands) {
1639
+ if (scopeUnaffectedDaemon && isDaemonScopedCommand(candidate)) {
1640
+ skippedDaemonCommands.push(candidate.displayCommand);
1641
+ // Record the skip so it's visible in the summary, never silently dropped.
1642
+ summary.commandsRun.push({
1643
+ command: candidate.command,
1644
+ args: candidate.args,
1645
+ displayCommand: candidate.displayCommand,
1646
+ category: candidate.category,
1647
+ source: candidate.source,
1648
+ passed: true,
1649
+ skipped: true,
1650
+ skipReason: 'unaffected_daemon_scope',
1651
+ });
1652
+ continue;
1653
+ }
1654
+ commandsToRun.push(candidate);
1655
+ }
1656
+ if (opts?.changeImpact) {
1657
+ summary.changeImpact = {
1658
+ isDaemonAffecting: opts.changeImpact.isDaemonAffecting,
1659
+ affectedPackages: opts.changeImpact.affectedPackages,
1660
+ ...(skippedDaemonCommands.length ? { skippedDaemonCommands } : {}),
1661
+ };
1662
+ }
1577
1663
 
1578
1664
  if (runLegacyBootstrapCommands) {
1579
1665
  summary.bootstrap = { stage: 'legacy' };
@@ -1619,23 +1705,31 @@ export async function runMeshRefineValidationGate(
1619
1705
  }
1620
1706
  }
1621
1707
 
1622
- for (const candidate of selection.commands) {
1708
+ // (b) Track a genuine missing-deps block for an AFFECTED command. Instead of
1709
+ // aborting the whole gate at the first missing-deps hit (which also killed
1710
+ // trailing no-dep commands like check-vendor-drift.mjs), we mark the blocked
1711
+ // command and CONTINUE evaluating the rest: commands whose deps are present, or
1712
+ // which need no deps at all, still run. missing_dependencies only becomes the
1713
+ // gate failure if at least one command that truly needed deps could not run.
1714
+ let missingDepsBlocked = false;
1715
+ for (const candidate of commandsToRun) {
1623
1716
  const startedAt = Date.now();
1624
1717
  const cwd = candidate.cwd ? pathResolve(workspace, candidate.cwd) : workspace;
1625
1718
  const timeout = candidate.timeoutMs || REFINE_VALIDATION_TIMEOUT_MS;
1626
1719
  const bootstrapProvidedDependencies = summary.bootstrap?.stage === 'cached' || summary.bootstrap?.stage === 'ran' || summary.bootstrap?.stage === 'legacy';
1627
- if (!bootstrapProvidedDependencies && isPackageManagerValidation(candidate) && dependenciesLikelyMissing(cwd)) {
1720
+ if (!bootstrapProvidedDependencies && needsNodeModules(candidate, cwd)) {
1721
+ // This command genuinely needs node_modules that are absent. Mark it
1722
+ // blocked, but do NOT abort — a following no-dep command (or one in a
1723
+ // different cwd that DOES have deps) must still get its chance to run.
1628
1724
  summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, {
1629
- stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands in repo mesh/refine config if Refinery should install/bootstrap before validation.',
1725
+ stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands (or .adhdev/worktree_bootstrap.json) in repo mesh/refine config if Refinery should install/bootstrap before validation.',
1630
1726
  }, false, {
1631
1727
  exitCode: null,
1632
1728
  skipped: true,
1633
1729
  failureKind: 'missing_dependencies',
1634
1730
  }));
1635
- summary.status = 'failed';
1636
- summary.failureKind = 'missing_dependencies';
1637
- summary.failureCode = 'missing_dependencies';
1638
- return summary;
1731
+ missingDepsBlocked = true;
1732
+ continue;
1639
1733
  }
1640
1734
  // See the bootstrap loop above: resolve the win32 .cmd shim to an
1641
1735
  // absolute path before handing it to the spawn boundary.
@@ -1681,6 +1775,18 @@ export async function runMeshRefineValidationGate(
1681
1775
  }
1682
1776
  }
1683
1777
 
1778
+ // (b) A command that genuinely needed deps could not run. Surface it as the
1779
+ // gate failure now (after letting no-dep / deps-present commands run), so the
1780
+ // caller can classify it blocked_review and emit a self-service hint. Every
1781
+ // daemon-scoped command in a web-only branch was already filtered above, so a
1782
+ // missing-deps block here is a real affected-command block.
1783
+ if (missingDepsBlocked) {
1784
+ summary.status = 'failed';
1785
+ summary.failureKind = 'missing_dependencies';
1786
+ summary.failureCode = 'missing_dependencies';
1787
+ return summary;
1788
+ }
1789
+
1684
1790
  summary.status = 'passed';
1685
1791
  return summary;
1686
1792
  }
@@ -1043,6 +1043,11 @@ export class MeshRuntimeStore {
1043
1043
  entry.assignedSessionId = sessionId;
1044
1044
  if (providerType) entry.assignedProviderType = providerType;
1045
1045
  entry.dispatchTimestamp = now;
1046
+ // REDRIVE-DUP: bump the per-task dispatch nonce on every claim so this dispatch
1047
+ // carries a nonce strictly greater than any prior (reclaimed) dispatch of the same
1048
+ // task. The worker echoes it on agent:generating_started; the coordinator rejects a
1049
+ // stale-nonce ack so a reclaimed+re-dispatched task's original inject cannot execute.
1050
+ entry.dispatchNonce = (entry.dispatchNonce || 0) + 1;
1046
1051
  entry.updatedAt = now;
1047
1052
 
1048
1053
  this.db.prepare(`
@@ -644,6 +644,17 @@ export interface MeshWorkQueueEntry {
644
644
  };
645
645
  /** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
646
646
  dispatchTimestamp?: string;
647
+ /**
648
+ * REDRIVE-DUP: monotonic per-task dispatch nonce. Bumped on every (re)dispatch of
649
+ * this task (assignQueueTask) AND on every reclaim (reclaimStrandedAssignedTask), and
650
+ * carried to the worker in meshContext.dispatchNonce. The worker echoes it back on
651
+ * agent:generating_started (metadataEvent.dispatchNonce). When a delivered-not-consumed
652
+ * task is reclaimed and re-dispatched to a different node, the ORIGINAL inject to the
653
+ * first node still carries the now-stale nonce; the coordinator rejects that node's
654
+ * generating_started ack (and stops it) so the SAME taskId is never executed twice.
655
+ * Absent on legacy rows → the coordinator skips the stale-nonce guard (backward safe).
656
+ */
657
+ dispatchNonce?: number;
647
658
  /**
648
659
  * (3) The ORIGINATING coordinator session that enqueued this task. Stamped onto the
649
660
  * worker at dispatch (meshCoordinatorSessionId) so the task's completion routes back to
@@ -1386,6 +1397,13 @@ export function reclaimStrandedAssignedTask(
1386
1397
  delete entry.assignedSessionId;
1387
1398
  delete entry.assignedProviderType;
1388
1399
  delete entry.dispatchTimestamp;
1400
+ // REDRIVE-DUP: bump the dispatch nonce so the ORIGINAL inject to prevNode/prevSession
1401
+ // (which is delivered-but-unconsumed and about to be re-dispatched elsewhere) now
1402
+ // carries a stale nonce. When that stranded inject finally fires and the worker emits
1403
+ // agent:generating_started echoing the old nonce, the coordinator's stale-nonce guard
1404
+ // rejects the ack and stops that worker — so the reclaimed+re-dispatched task is never
1405
+ // executed by the originally-assigned session (no duplicate execution).
1406
+ entry.dispatchNonce = (entry.dispatchNonce || 0) + 1;
1389
1407
  entry.strandedReclaimCount = reclaims;
1390
1408
  entry.updatedAt = now;
1391
1409
  // The stranded assignment is being torn down (→ pending or failed); end its
@@ -825,7 +825,7 @@ export class CliProviderInstance implements ProviderInstance {
825
825
  * completion events silently drop because the forwarder has nothing to
826
826
  * match against.
827
827
  */
828
- attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void {
828
+ attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; dispatchNonce?: number; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void {
829
829
  if (!assignment?.meshId) return;
830
830
  this.settings = {
831
831
  ...this.settings,
@@ -838,6 +838,10 @@ export class CliProviderInstance implements ProviderInstance {
838
838
  // shares this daemon. See isMeshOwnedDelegateSession's post-detach gate.
839
839
  ...(assignment.nodeId ? { meshNodeId: assignment.nodeId, meshLastNodeId: assignment.nodeId } : {}),
840
840
  ...(assignment.taskId ? { meshActiveTaskId: assignment.taskId } : {}),
841
+ // REDRIVE-DUP: task-level dispatch nonce, echoed on generating_started so the
842
+ // coordinator can reject a stale (reclaimed) dispatch. Cleared with meshActiveTaskId
843
+ // on detach so a subsequent unrelated turn never re-echoes a prior task's nonce.
844
+ ...(typeof assignment.dispatchNonce === 'number' ? { meshActiveDispatchNonce: assignment.dispatchNonce } : {}),
841
845
  ...(assignment.coordinatorDaemonId ? { meshCoordinatorDaemonId: assignment.coordinatorDaemonId } : {}),
842
846
  // Session-level routing anchor: the originating coordinator session, so this
843
847
  // worker's completion events route back to the exact session that dispatched it.
@@ -872,17 +876,18 @@ export class CliProviderInstance implements ProviderInstance {
872
876
  */
873
877
  detachMeshAssignment(): void {
874
878
  if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
875
- // Session-level member: keep membership, drop only the task-level marker.
879
+ // Session-level member: keep membership, drop only the task-level markers.
876
880
  if (this.settings.launchedByCoordinator === true) {
877
881
  if (!this.settings.meshActiveTaskId) return;
878
- const { meshActiveTaskId, ...rest } = this.settings;
879
- void meshActiveTaskId;
882
+ // REDRIVE-DUP: clear the task-level dispatch nonce with the task marker.
883
+ const { meshActiveTaskId, meshActiveDispatchNonce, ...rest } = this.settings;
884
+ void meshActiveTaskId; void meshActiveDispatchNonce;
880
885
  this.settings = rest;
881
886
  this.adapter.updateRuntimeSettings?.(this.settings);
882
887
  return;
883
888
  }
884
- const { meshNodeFor, meshNodeId, meshActiveTaskId, ...rest } = this.settings;
885
- void meshNodeFor; void meshActiveTaskId;
889
+ const { meshNodeFor, meshNodeId, meshActiveTaskId, meshActiveDispatchNonce, ...rest } = this.settings;
890
+ void meshNodeFor; void meshActiveTaskId; void meshActiveDispatchNonce;
886
891
  // WTCLAIM (A): clear the active binding but PRESERVE the last bound node id
887
892
  // (meshLastNodeId) so a later sessionless dispatch can re-adopt this idle
888
893
  // session ONLY for the node it last served. Carry the id being cleared, or
@@ -3189,6 +3194,12 @@ export class CliProviderInstance implements ProviderInstance {
3189
3194
  const resolved = this.completingTurnTaskId();
3190
3195
  if (resolved) enrichedEvent.taskId = resolved;
3191
3196
  }
3197
+ // REDRIVE-DUP: echo the dispatch nonce this session's active task was stamped with
3198
+ // so the coordinator's generating_started handler can reject a stale (reclaimed)
3199
+ // dispatch and stop this worker before it double-executes the reclaimed task.
3200
+ if (enrichedEvent.dispatchNonce === undefined && typeof this.settings.meshActiveDispatchNonce === 'number') {
3201
+ enrichedEvent.dispatchNonce = this.settings.meshActiveDispatchNonce;
3202
+ }
3192
3203
  }
3193
3204
  if (this.context?.emitProviderEvent) {
3194
3205
  this.context.emitProviderEvent(enrichedEvent);
@@ -312,7 +312,7 @@ export class ProviderInstanceManager {
312
312
  * applied, or `{ stamped: false, reason }` when it was refused — the instance
313
313
  * was missing / has no attach method, or the DOUBLE-DISPATCH idempotence guard
314
314
  * fired (the same task is already running on another live session here). */
315
- attachMeshAssignmentToInstance(instanceId: string, assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): { stamped: boolean; reason?: string } {
315
+ attachMeshAssignmentToInstance(instanceId: string, assignment: { meshId: string; nodeId?: string; taskId?: string; dispatchNonce?: number; coordinatorDaemonId?: string; coordinatorSessionId?: string }): { stamped: boolean; reason?: string } {
316
316
  const inst = this.instances.get(instanceId);
317
317
  if (!inst || typeof inst.attachMeshAssignment !== 'function') {
318
318
  LOG.warn('MeshDispatch', `attachMeshAssignment skipped: instance ${instanceId} ${inst ? 'has no attach method' : 'not found'}`);
@@ -220,7 +220,7 @@ export interface ProviderInstance {
220
220
  /** Stamp a direct-dispatch mesh task assignment so generating_completed
221
221
  * events route back to the originating coordinator. Cleared by
222
222
  * detachMeshAssignment when the task reaches a terminal state. */
223
- attachMeshAssignment?(assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void;
223
+ attachMeshAssignment?(assignment: { meshId: string; nodeId?: string; taskId?: string; dispatchNonce?: number; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void;
224
224
  detachMeshAssignment?(): void;
225
225
 
226
226
  /** Refresh static provider definition/scripts without restarting the live runtime. */