@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/dist/git/git-types.d.ts +10 -0
- package/dist/index.js +104 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +104 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/commands/router.ts +74 -2
- package/src/git/git-status.ts +93 -3
- package/src/git/git-types.ts +10 -0
package/package.json
CHANGED
package/src/commands/router.ts
CHANGED
|
@@ -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:
|
|
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({
|
|
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
|
|
package/src/git/git-status.ts
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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)
|
package/src/git/git-types.ts
CHANGED
|
@@ -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
|
|