@adhdev/daemon-core 0.9.82-rc.263 → 0.9.82-rc.264

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.263",
3
+ "version": "0.9.82-rc.264",
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",
@@ -4527,10 +4527,46 @@ export class DaemonCommandRouter {
4527
4527
  }
4528
4528
 
4529
4529
  const cleanupStarted = Date.now();
4530
+ // Honor the mesh policy for delegated-session cleanup on the auto-removed
4531
+ // worktree node (previously hardcoded to 'preserve', which orphaned the
4532
+ // delegate session as an idle record on the coordinator daemon). Fall back
4533
+ // to 'preserve' when no policy is set.
4534
+ const refineSessionCleanupMode = this.normalizeMeshSessionCleanupMode(
4535
+ mesh?.policy?.sessionCleanupOnNodeRemove,
4536
+ );
4537
+ // The delegate session launched for a clone worktree is frequently matched
4538
+ // by workspace ONLY (no meta.meshNodeId binding), which remove_mesh_node's
4539
+ // shared-daemon guard skips. Since refine knows exactly which workspace it
4540
+ // just merged, collect that workspace's live session ids explicitly and pass
4541
+ // them through — explicit sessionIds bypass the workspace-only-match guard so
4542
+ // the policy-driven stop/delete actually runs.
4543
+ let refineSessionIds: string[] | undefined;
4544
+ if (refineSessionCleanupMode !== 'preserve' && this.deps.sessionHostControl) {
4545
+ try {
4546
+ const liveSessions = await this.deps.sessionHostControl.listSessions();
4547
+ const workspace = typeof node.workspace === 'string' ? node.workspace : '';
4548
+ refineSessionIds = liveSessions
4549
+ .filter((record: any) => {
4550
+ const sid = typeof record?.sessionId === 'string' ? record.sessionId : '';
4551
+ if (!sid) return false;
4552
+ // Never sweep the coordinator's own session for this mesh.
4553
+ if (readStringValue(record?.meta?.meshCoordinatorFor) === meshId) return false;
4554
+ const boundToNode = readStringValue(record?.meta?.meshNodeId) === nodeId;
4555
+ const matchedByWorkspace = !!workspace && record?.workspace === workspace;
4556
+ return boundToNode || matchedByWorkspace;
4557
+ })
4558
+ .map((record: any) => String(record.sessionId));
4559
+ } catch {
4560
+ // listSessions failure is non-fatal — fall back to the policy-mode
4561
+ // cleanup without explicit ids (still better than hardcoded preserve).
4562
+ refineSessionIds = undefined;
4563
+ }
4564
+ }
4530
4565
  const removeResult = await this.execute('remove_mesh_node', {
4531
4566
  meshId,
4532
4567
  nodeId,
4533
- sessionCleanupMode: 'preserve',
4568
+ sessionCleanupMode: refineSessionCleanupMode,
4569
+ ...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
4534
4570
  inlineMesh: args?.inlineMesh,
4535
4571
  });
4536
4572
  recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
@@ -6976,6 +7012,29 @@ export class DaemonCommandRouter {
6976
7012
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
6977
7013
  const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
6978
7014
  if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
7015
+ // Dry-run (plan-only) is the default and stays synchronous: it does no
7016
+ // validation/merge/push and returns the plan instantly. Only execute=true
7017
+ // (and not dry_run) goes through the async refine job that actually
7018
+ // validates → merges → pushes → cleans up. Mirrors the
7019
+ // batch_refine_mesh_nodes / fast_forward_mesh_node dry_run/execute contract.
7020
+ const isDryRun = args?.dryRun !== false && args?.execute !== true;
7021
+ if (isDryRun) {
7022
+ // preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
7023
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
7024
+ const mesh = meshRecord?.mesh;
7025
+ const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
7026
+ if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
7027
+ return {
7028
+ success: true,
7029
+ dryRun: true,
7030
+ nodeId,
7031
+ workspace: node.workspace,
7032
+ validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
7033
+ mergeWillRun: false,
7034
+ cleanupWillRun: false,
7035
+ hint: 'Dry-run only — no merge/push/cleanup performed. Re-invoke with execute:true to converge this node.',
7036
+ };
7037
+ }
6979
7038
  return this.startMeshRefineJob(meshId, nodeId, args);
6980
7039
  }
6981
7040
 
@@ -7008,9 +7067,22 @@ export class DaemonCommandRouter {
7008
7067
  const sessionCleanupMode = this.normalizeMeshSessionCleanupMode(
7009
7068
  args?.sessionCleanupMode ?? args?.session_cleanup_mode ?? mesh?.policy?.sessionCleanupOnNodeRemove,
7010
7069
  );
7070
+ // Explicit sessionIds (e.g. supplied by refine auto-cleanup) bypass the
7071
+ // workspace-only-match guard so a delegate session that lacks a
7072
+ // meta.meshNodeId binding can still be stopped/deleted.
7073
+ const explicitSessionIds = Array.isArray(args?.sessionIds)
7074
+ ? (args.sessionIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
7075
+ : undefined;
7011
7076
  let sessionCleanup: Record<string, unknown> | undefined;
7012
7077
  if (node && sessionCleanupMode !== 'preserve') {
7013
- sessionCleanup = await this.cleanupMeshSessions({ meshId, nodeId, node, mode: sessionCleanupMode, source: 'mesh_remove_node' });
7078
+ sessionCleanup = await this.cleanupMeshSessions({
7079
+ meshId,
7080
+ nodeId,
7081
+ node,
7082
+ mode: sessionCleanupMode,
7083
+ ...(explicitSessionIds && explicitSessionIds.length > 0 ? { sessionIds: explicitSessionIds } : {}),
7084
+ source: 'mesh_remove_node',
7085
+ });
7014
7086
  if (sessionCleanup.success === false) return { success: false, removed: false, sessionCleanup };
7015
7087
  }
7016
7088
 
@@ -116,6 +116,80 @@ export async function getGitRepoStatus(
116
116
  * - build commit NOT an ancestor of HEAD (daemon ahead / diverged) → undefined
117
117
  * Any git error is swallowed (no warning) so a flaky probe never over-warns.
118
118
  */
119
+ /**
120
+ * Package names that, when changed, mean the daemon runtime is stale and must be
121
+ * rebuilt/redeployed + restarted. Everything NOT in this set (web-core,
122
+ * web-standalone, web-devconsole, terminal-render-web) is web-only — a daemon
123
+ * restart is not required for those, only a web redeploy. mcp-server runs in the
124
+ * same process surface as the daemon tooling, so it is classified as
125
+ * daemon-affecting (conservative). Unknown package → daemon-affecting.
126
+ */
127
+ const DAEMON_RUNTIME_PACKAGES = new Set([
128
+ 'daemon-core',
129
+ 'daemon-standalone',
130
+ 'session-host-core',
131
+ 'session-host-daemon',
132
+ 'terminal-mux-core',
133
+ 'terminal-mux-control',
134
+ 'terminal-mux-cli',
135
+ 'ghostty-vt-node',
136
+ 'mcp-server',
137
+ ]);
138
+
139
+ const WEB_ONLY_PACKAGES = new Set([
140
+ 'web-core',
141
+ 'web-standalone',
142
+ 'web-devconsole',
143
+ 'terminal-render-web',
144
+ ]);
145
+
146
+ /**
147
+ * Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
148
+ * package. Returns isDaemonAffecting:true conservatively when the changed-file set
149
+ * can't be obtained or any changed path is outside the known web-only package set
150
+ * (including root-level / non-package files).
151
+ */
152
+ async function classifyDaemonBuildChange(
153
+ repoPath: string,
154
+ buildCommit: string,
155
+ options: GitStatusOptions,
156
+ ): Promise<{ isDaemonAffecting: boolean; affectedPackages: string[] }> {
157
+ try {
158
+ const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
159
+ const files = diff.stdout
160
+ .split('\n')
161
+ .map((line) => line.trim())
162
+ .filter(Boolean);
163
+ if (files.length === 0) {
164
+ // No file diff (e.g. only merge metadata) — nothing actionable, but stay
165
+ // conservative and treat as daemon-affecting so we don't suppress a real warning.
166
+ return { isDaemonAffecting: true, affectedPackages: [] };
167
+ }
168
+ const pkgs = new Set<string>();
169
+ let sawNonPackageOrUnknown = false;
170
+ for (const file of files) {
171
+ const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
172
+ if (!match) {
173
+ sawNonPackageOrUnknown = true;
174
+ continue;
175
+ }
176
+ pkgs.add(match[1]);
177
+ }
178
+ const affectedPackages = [...pkgs].sort();
179
+ // Daemon-affecting if: any non-package/root file changed, any unknown package
180
+ // changed, or any explicit daemon-runtime package changed. Only when EVERY
181
+ // changed file maps to a known web-only package is the daemon unaffected.
182
+ const allWebOnly =
183
+ !sawNonPackageOrUnknown &&
184
+ affectedPackages.length > 0 &&
185
+ affectedPackages.every((p) => WEB_ONLY_PACKAGES.has(p) && !DAEMON_RUNTIME_PACKAGES.has(p));
186
+ return { isDaemonAffecting: !allWebOnly, affectedPackages };
187
+ } catch {
188
+ // diff probe failed → can't prove web-only; stay conservative.
189
+ return { isDaemonAffecting: true, affectedPackages: [] };
190
+ }
191
+ }
192
+
119
193
  async function detectDaemonBuildBehind(
120
194
  repo: ResolvedGitRepo,
121
195
  submodules: GitSubmoduleStatus[] | undefined,
@@ -145,14 +219,30 @@ async function detectDaemonBuildBehind(
145
219
  // Strict ancestor: build commit is reachable from HEAD but is not HEAD.
146
220
  await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
147
221
  // No throw → build commit IS an ancestor of HEAD → daemon is behind.
222
+ // Inspect WHICH packages changed in buildCommit..HEAD. A daemon rebuild/restart
223
+ // is only actually required when a daemon-runtime package changed; if only web /
224
+ // render packages changed, the daemon is unaffected and just the web deploy is
225
+ // pending. Conservative: any probe failure → treat as daemon-affecting.
226
+ const { isDaemonAffecting, affectedPackages } = await classifyDaemonBuildChange(
227
+ repoPath,
228
+ build.commit,
229
+ options,
230
+ );
231
+ const scopeLabel = scope === 'root' ? 'workspace' : scope;
232
+ const warning = isDaemonAffecting
233
+ ? `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}. ` +
234
+ `Merged code is NOT live until the daemon is rebuilt/redeployed and restarted — a local dist rebuild alone does not update a cloud daemon.`
235
+ : `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}, ` +
236
+ `but only web packages changed (${(affectedPackages || []).join(', ') || 'web'}). ` +
237
+ `Daemon restart NOT required — redeploy the web app to reflect the change.`;
148
238
  return {
149
239
  buildCommit: build.commit,
150
240
  buildCommitShort: build.commitShort,
151
241
  head,
152
242
  scope,
153
- warning:
154
- `Live daemon was built from ${build.commitShort} which is behind ${scope === 'root' ? 'workspace' : scope} HEAD ${head.slice(0, 7)}. ` +
155
- `Merged code is NOT live until the daemon is rebuilt/redeployed and restarted — a local dist rebuild alone does not update a cloud daemon.`,
243
+ isDaemonAffecting,
244
+ ...(affectedPackages && affectedPackages.length > 0 ? { affectedPackages } : {}),
245
+ warning,
156
246
  };
157
247
  } catch {
158
248
  // cat-file / merge-base non-zero exit (commit absent or not an ancestor)
@@ -89,6 +89,16 @@ export interface DaemonBuildBehind {
89
89
  head: string;
90
90
  /** Where the comparison matched: 'root' or the submodule path. */
91
91
  scope: string;
92
+ /**
93
+ * Whether any package changed between buildCommit..HEAD affects the daemon
94
+ * runtime (daemon-core, standalone, session-host, terminal-mux, ghostty,
95
+ * mcp-server). When false, only web/render packages changed — the daemon does
96
+ * NOT need a rebuild/restart; only the web deploy is pending. Conservative:
97
+ * when the changed-package set can't be determined it defaults to true.
98
+ */
99
+ isDaemonAffecting: boolean;
100
+ /** Distinct package names changed between buildCommit..HEAD (best-effort). */
101
+ affectedPackages?: string[];
92
102
  warning: string;
93
103
  }
94
104