@adhdev/daemon-core 1.0.18-rc.1 → 1.0.18-rc.11

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.
Files changed (56) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  3. package/dist/commands/process-lifecycle.d.ts +43 -0
  4. package/dist/commands/upgrade-helper.d.ts +7 -0
  5. package/dist/commands/windows-atomic-upgrade.d.ts +63 -0
  6. package/dist/index.js +6513 -4536
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +6539 -4556
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  11. package/dist/mesh/mesh-active-work.d.ts +1 -1
  12. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  13. package/dist/mesh/mesh-ledger.d.ts +28 -1
  14. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  15. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  16. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  17. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  18. package/dist/providers/cli-provider-instance-types.d.ts +4 -0
  19. package/dist/providers/cli-provider-instance.d.ts +2 -0
  20. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  21. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  22. package/package.json +3 -3
  23. package/src/boot/daemon-lifecycle.ts +10 -0
  24. package/src/cli-adapters/cli-state-engine.ts +204 -3
  25. package/src/cli-adapters/provider-cli-adapter.ts +39 -5
  26. package/src/cli-adapters/pty-transport.d.ts +2 -1
  27. package/src/cli-adapters/pty-transport.ts +1 -1
  28. package/src/cli-adapters/session-host-transport.ts +8 -3
  29. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  30. package/src/commands/high-family/mesh-events.ts +13 -2
  31. package/src/commands/med-family/mesh-queue.ts +74 -1
  32. package/src/commands/process-lifecycle.ts +248 -0
  33. package/src/commands/router-refine.ts +71 -4
  34. package/src/commands/router.ts +8 -0
  35. package/src/commands/upgrade-helper.ts +146 -79
  36. package/src/commands/windows-atomic-upgrade.ts +631 -0
  37. package/src/config/mesh-json-config.ts +8 -0
  38. package/src/mesh/coordinator-prompt.ts +258 -11
  39. package/src/mesh/mesh-active-work.ts +11 -1
  40. package/src/mesh/mesh-disk-retention.ts +370 -0
  41. package/src/mesh/mesh-event-classify.ts +19 -0
  42. package/src/mesh/mesh-event-forwarding.ts +46 -4
  43. package/src/mesh/mesh-events-utils.ts +42 -0
  44. package/src/mesh/mesh-ledger.ts +77 -0
  45. package/src/mesh/mesh-node-identity.ts +49 -0
  46. package/src/mesh/mesh-queue-assignment.ts +144 -2
  47. package/src/mesh/mesh-reconcile-loop.ts +55 -1
  48. package/src/mesh/mesh-refine-gates.ts +300 -0
  49. package/src/mesh/mesh-runtime-store.ts +21 -0
  50. package/src/mesh/mesh-work-queue.ts +85 -2
  51. package/src/providers/cli-provider-instance-types.ts +53 -0
  52. package/src/providers/cli-provider-instance.ts +193 -2
  53. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  54. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  55. package/src/providers/types/interactive-prompt.ts +77 -0
  56. package/src/session-host/managed-host.ts +34 -0
@@ -0,0 +1,248 @@
1
+ import { execFileSync, type ExecFileSyncOptions } from 'child_process';
2
+ import * as path from 'path';
3
+
4
+ export interface ProcessLifecycleOptions {
5
+ platform?: NodeJS.Platform;
6
+ execFileSync?: typeof import('child_process').execFileSync;
7
+ }
8
+
9
+ export interface OwnedProcessInfo {
10
+ pid: number;
11
+ commandLine: string | null;
12
+ }
13
+
14
+ function defaultExecFileSync(): typeof import('child_process').execFileSync {
15
+ return execFileSync;
16
+ }
17
+
18
+ function getWindowsProcessCommandLine(
19
+ pid: number,
20
+ exec: typeof import('child_process').execFileSync,
21
+ ): string | null {
22
+ const pidFilter = `ProcessId=${pid}`;
23
+
24
+ try {
25
+ const psOut = exec('powershell.exe', [
26
+ '-NoProfile',
27
+ '-NonInteractive',
28
+ '-ExecutionPolicy', 'Bypass',
29
+ '-Command',
30
+ `(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
31
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
32
+ const text = String(psOut).trim();
33
+ if (text) return text;
34
+ } catch {
35
+ // fall through to wmic fallback
36
+ }
37
+
38
+ try {
39
+ const wmicOut = exec('wmic', [
40
+ 'process', 'where', pidFilter, 'get', 'CommandLine',
41
+ ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
42
+ const text = String(wmicOut).trim();
43
+ if (text) return text;
44
+ } catch {
45
+ // noop
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ export function getProcessCommandLine(
52
+ pid: number,
53
+ options: ProcessLifecycleOptions = {},
54
+ ): string | null {
55
+ if (!Number.isFinite(pid) || pid <= 0) return null;
56
+ const exec = options.execFileSync ?? defaultExecFileSync();
57
+ const platform = options.platform ?? process.platform;
58
+
59
+ if (platform === 'win32') {
60
+ return getWindowsProcessCommandLine(pid, exec);
61
+ }
62
+
63
+ try {
64
+ const text = String(exec('ps', ['-o', 'command=', '-p', String(pid)], {
65
+ encoding: 'utf8',
66
+ timeout: 3000,
67
+ stdio: ['ignore', 'pipe', 'ignore'],
68
+ })).trim();
69
+ return text || null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Extract the script argument (the token after the node executable) from a
77
+ * command line. Handles both quoted and unquoted executables/scripts.
78
+ */
79
+ export function parseNodeScriptPath(commandLine: string | null): string | null {
80
+ if (!commandLine) return null;
81
+ let rest = commandLine.trim();
82
+
83
+ // Skip the leading executable token.
84
+ if (rest.startsWith('"')) {
85
+ const end = rest.indexOf('"', 1);
86
+ if (end === -1) return null;
87
+ rest = rest.slice(end + 1).trim();
88
+ } else {
89
+ const idx = rest.search(/\s/);
90
+ if (idx === -1) return null;
91
+ rest = rest.slice(idx + 1).trim();
92
+ }
93
+ if (!rest) return null;
94
+
95
+ if (rest.startsWith('"')) {
96
+ const end = rest.indexOf('"', 1);
97
+ return end === -1 ? rest.slice(1) : rest.slice(1, end);
98
+ }
99
+ const idx = rest.search(/\s/);
100
+ return idx === -1 ? rest : rest.slice(0, idx);
101
+ }
102
+
103
+ function normalizeWindowsPath(value: string): string {
104
+ return value.toLowerCase().replace(/\//g, '\\').replace(/\\+$/, '');
105
+ }
106
+
107
+ function isCommandLineUnderPrefix(commandLine: string | null, prefix: string): boolean {
108
+ if (!commandLine) return false;
109
+ const needle = normalizeWindowsPath(prefix);
110
+ // Command-line paths may quote Windows separators as either single or
111
+ // doubled backslashes; collapse doubles before matching.
112
+ const haystack = commandLine.split('\\\\').join('\\').toLowerCase();
113
+ return haystack.includes(needle);
114
+ }
115
+
116
+ export function killProcess(pid: number, options: ProcessLifecycleOptions = {}): boolean {
117
+ if (!Number.isFinite(pid) || pid <= 0) return false;
118
+ const exec = options.execFileSync ?? defaultExecFileSync();
119
+ const platform = options.platform ?? process.platform;
120
+
121
+ try {
122
+ if (platform === 'win32') {
123
+ exec('taskkill', ['/PID', String(pid), '/T', '/F'], {
124
+ stdio: 'ignore',
125
+ windowsHide: true,
126
+ });
127
+ } else {
128
+ process.kill(pid, 'SIGTERM');
129
+ }
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
137
+ const start = Date.now();
138
+ while (Date.now() - start < timeoutMs) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ await new Promise((resolve) => setTimeout(resolve, 250));
142
+ } catch {
143
+ return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * List Node processes whose command line places them under any of the supplied
151
+ * prefixes. Windows-only — the versioned-prefix lifecycle this supports does not
152
+ * exist on POSIX, so the helper is a no-op there.
153
+ */
154
+ export function listOwnedNodeProcesses(options: {
155
+ prefixes: readonly string[];
156
+ excludePids?: readonly number[];
157
+ markers?: readonly string[];
158
+ } & ProcessLifecycleOptions): OwnedProcessInfo[] {
159
+ const platform = options.platform ?? process.platform;
160
+ if (platform !== 'win32') return [];
161
+
162
+ const exec = options.execFileSync ?? defaultExecFileSync();
163
+ const prefixes = options.prefixes.map((p) => normalizeWindowsPath(p));
164
+ const exclude = new Set((options.excludePids ?? []).filter((n) => Number.isFinite(n) && n > 0));
165
+ // Command-line paths use Windows separators; normalize marker separators the
166
+ // same way so a marker like "dist/cli/index.js" matches "dist\cli\index.js".
167
+ const markers = (options.markers ?? []).map((m) => m.toLowerCase().replace(/\//g, '\\'));
168
+
169
+ let pids: number[] = [];
170
+ try {
171
+ const out = String(exec('powershell.exe', [
172
+ '-NoProfile',
173
+ '-NonInteractive',
174
+ '-ExecutionPolicy', 'Bypass',
175
+ '-Command',
176
+ 'Get-Process node -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id | ConvertTo-Json -Compress',
177
+ ], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })).trim();
178
+ if (out) {
179
+ const parsed = JSON.parse(out);
180
+ pids = Array.isArray(parsed) ? parsed : [parsed];
181
+ }
182
+ } catch {
183
+ return [];
184
+ }
185
+
186
+ const results: OwnedProcessInfo[] = [];
187
+ for (const pid of pids) {
188
+ if (!Number.isFinite(pid) || pid <= 0 || exclude.has(pid)) continue;
189
+ const commandLine = getProcessCommandLine(pid, { platform, execFileSync: exec });
190
+ if (!commandLine) continue;
191
+ const lower = commandLine.toLowerCase();
192
+ const underPrefix = prefixes.some((prefix) => lower.includes(prefix));
193
+ if (!underPrefix) continue;
194
+ if (markers.length > 0 && !markers.some((marker) => lower.includes(marker))) continue;
195
+ results.push({ pid, commandLine });
196
+ }
197
+ return results;
198
+ }
199
+
200
+ export async function stopOwnedProcesses(options: {
201
+ processes: readonly OwnedProcessInfo[];
202
+ waitMs?: number;
203
+ } & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
204
+ const waitMs = options.waitMs ?? 15_000;
205
+ const killed = new Set<number>();
206
+
207
+ for (const p of options.processes) {
208
+ if (killProcess(p.pid, options)) killed.add(p.pid);
209
+ }
210
+
211
+ const survivors: OwnedProcessInfo[] = [];
212
+ for (const p of options.processes) {
213
+ if (!killed.has(p.pid)) {
214
+ survivors.push(p);
215
+ continue;
216
+ }
217
+ const exited = await waitForPidExit(p.pid, waitMs);
218
+ if (!exited) survivors.push(p);
219
+ }
220
+
221
+ // A process counts as stopped only when it left the process table; a failed
222
+ // kill or a post-kill timeout both land it in `survivors`.
223
+ return { stopped: options.processes.length - survivors.length, survivors };
224
+ }
225
+
226
+ export async function stopOwnedProcessesForPrefixes(options: {
227
+ prefixes: readonly string[];
228
+ excludePids?: readonly number[];
229
+ markers?: readonly string[];
230
+ waitMs?: number;
231
+ log?: (message: string) => void;
232
+ } & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
233
+ const processes = listOwnedNodeProcesses(options);
234
+ if (processes.length === 0) return { stopped: 0, survivors: [] };
235
+
236
+ options.log?.(`Stopping ${processes.length} owned process(es) under prefixes: ${options.prefixes.join(', ')}`);
237
+ const result = await stopOwnedProcesses({
238
+ processes,
239
+ waitMs: options.waitMs,
240
+ platform: options.platform,
241
+ execFileSync: options.execFileSync,
242
+ });
243
+
244
+ if (result.survivors.length > 0) {
245
+ options.log?.(`Could not stop ${result.survivors.length} owned process(es): ${result.survivors.map((s) => s.pid).join(', ')}`);
246
+ }
247
+ return result;
248
+ }
@@ -36,8 +36,10 @@ import {
36
36
  RefineExecFileAsync,
37
37
  RefineStageOutcome,
38
38
  classifyPatchEquivalenceFailure,
39
+ convergeDivergedSubmoduleGitlinks,
39
40
  recordMeshRefineStage,
40
41
  resolveRefineryAutoPublishSubmoduleMainCommits,
42
+ rootRebaseResolvingGitlinks,
41
43
  runMeshRefineEffectiveDiffGate,
42
44
  runMeshRefinePatchEquivalenceGate,
43
45
  runMeshRefineSubmoduleReachabilityGate,
@@ -539,6 +541,9 @@ async function computeBranchBaseDivergence(
539
541
  export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
540
542
  const { repoRoot, baseHead, node, branch, baseBranch, refineStages, execFileAsync } = ctx;
541
543
  let branchHead = ctx.branchHead;
544
+ // Converged submodule gitlink resolutions (path → rebased commit) from STEP 1;
545
+ // consumed by the gitlink-aware root rebase (STEP 2) to resolve gitlink conflicts.
546
+ let gitlinkResolutions: Array<{ path: string; rebasedCommit: string }> = [];
542
547
  const syncStarted = Date.now();
543
548
  const divergence = await computeBranchBaseDivergence(execFileAsync, node.workspace, baseHead, branchHead);
544
549
 
@@ -571,25 +576,87 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
571
576
  const preRebasePe = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
572
577
  const alreadyMerged = !preRebasePe.actualPatchId && !!preRebasePe.expectedPatchId;
573
578
  const submoduleConflict = preRebasePe.actionableHint?.kind === 'submodule_conflict';
574
- if (alreadyMerged || submoduleConflict) {
579
+ if (alreadyMerged) {
575
580
  recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
576
581
  ahead: divergence.ahead,
577
582
  behind: divergence.behind,
578
583
  rebased: false,
579
- reason: alreadyMerged ? 'already_merged_via_other_path_skip_rebase' : 'submodule_conflict_defer_to_patch_equivalence',
584
+ reason: 'already_merged_via_other_path_skip_rebase',
580
585
  });
581
586
  return { kind: 'continue', ctx };
582
587
  }
588
+ if (submoduleConflict) {
589
+ // DS3: a "submodule conflict" here means base and branch advanced the
590
+ // SAME submodule to DIVERGED sibling commits (neither an ancestor of the
591
+ // other), so the gitlink stays in the diff and patch-equivalence fails.
592
+ // Attempt to auto-converge it (STEP 1): rebase the branch-side submodule
593
+ // commit onto the base-side commit INSIDE the worktree submodule, so the
594
+ // base-side commit becomes a strict ancestor of the rebased tip. The root
595
+ // rebase below (STEP 2) then resolves the gitlink conflict to that rebased
596
+ // commit. Together this automates the documented manual strict-ff bypass
597
+ // and keeps the landed oss history linear. On any real submodule content
598
+ // conflict it backs out cleanly → we FALL BACK to the historical
599
+ // defer→patch_equivalence path below.
600
+ const converge = convergeDivergedSubmoduleGitlinks(node.workspace, repoRoot, baseHead, branchHead);
601
+ if (converge.converged) {
602
+ gitlinkResolutions = converge.resolutions;
603
+ recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'passed', syncStarted, {
604
+ reason: 'submodule_diverged_auto_rebased',
605
+ gitlinks: converge.gitlinks,
606
+ });
607
+ LOG.info('Mesh', `[Refinery] Auto-converged diverged submodule gitlink(s) onto base for node ${node.id}: `
608
+ + converge.resolutions.map(r => `${r.path}→${r.rebasedCommit.slice(0, 12)}`).join(', '));
609
+ // Fall through (do NOT return) → the gitlink-aware root rebase below runs.
610
+ } else {
611
+ // Fail-safe: convergence declined (conflict / unreachable / not a real
612
+ // divergence) → preserve the historical defer→blocked_review behavior.
613
+ recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'skipped', syncStarted, {
614
+ reason: 'submodule_conflict_defer_to_patch_equivalence',
615
+ convergeReason: converge.reason,
616
+ gitlinks: converge.gitlinks,
617
+ });
618
+ recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
619
+ ahead: divergence.ahead,
620
+ behind: divergence.behind,
621
+ rebased: false,
622
+ reason: 'submodule_conflict_defer_to_patch_equivalence',
623
+ });
624
+ return { kind: 'continue', ctx };
625
+ }
626
+ }
583
627
  } catch { /* fail-open: on gate error, fall through to the rebase */ }
584
628
 
585
629
  // behind>0: strictly-behind OR diverged. Rebase the branch onto the pinned
586
630
  // baseHead. A conflict aborts and terminates blocked_review (retryable=false —
587
631
  // a real content conflict needs human resolution, not a base-movement retry).
632
+ //
633
+ // When STEP 1 converged a diverged submodule gitlink, use the gitlink-aware
634
+ // root rebase (STEP 2): git's recursive merge refuses to auto-merge the still-
635
+ // diverged intermediate gitlink, so we drive the rebase and resolve each
636
+ // submodule-gitlink conflict to the converged commit. A non-gitlink conflict
637
+ // aborts and falls through to the same blocked_review handling as a plain
638
+ // rebase conflict below (via the thrown gitlinkRebaseError).
588
639
  const rebaseStarted = Date.now();
589
640
  try {
590
- execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
641
+ if (gitlinkResolutions.length > 0) {
642
+ const gitlinkRebase = rootRebaseResolvingGitlinks(node.workspace, baseHead, gitlinkResolutions);
643
+ if (!gitlinkRebase.ok) {
644
+ // Surface as a rebase failure so the shared blocked_review handling
645
+ // (submodule-hint recovery included) runs — nothing was left mid-rebase
646
+ // (rootRebaseResolvingGitlinks aborts on failure).
647
+ const err: any = new Error(`gitlink-aware rebase aborted: ${gitlinkRebase.reason || 'unknown'}`);
648
+ err.gitlinkRebaseReason = gitlinkRebase.reason;
649
+ err.gitlinkRebaseConflicts = gitlinkRebase.conflictPaths;
650
+ err.alreadyAborted = true;
651
+ throw err;
652
+ }
653
+ } else {
654
+ execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
655
+ }
591
656
  } catch (rebaseErr: any) {
592
- try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
657
+ if (!rebaseErr?.alreadyAborted) {
658
+ try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
659
+ }
593
660
  // A rebase conflict on a submodule/gitlink divergence is a SPECIAL case the
594
661
  // patch-equivalence gate describes with a rich actionable hint (which
595
662
  // submodule, base vs branch commit, how to resolve). Run that gate against
@@ -227,6 +227,14 @@ const MESH_FORWARDABLE_SESSION_COMMANDS = new Set([
227
227
  // MUTATES the worker PTY, so forwarding to the real owner (not a wrong local session) is
228
228
  // doubly important. The daemon re-enforces the destructive-key confirm gate after the forward.
229
229
  'send_keys',
230
+ // interactive_prompt_response (mesh_answer_question, mission f1d25e11): the coordinator
231
+ // answers a REMOTE worker's AskUserQuestion (waiting_choice). The answer must reach the
232
+ // OWNING worker session's live instance — its activeInteractivePrompt (the authoritative
233
+ // prompt the labels/indexes resolve against) and its adapter.setInteractivePromptResponse
234
+ // live only there. Without forwarding, the coordinator's local high-family handler returns
235
+ // 'No running instance for session …' and the question is never answered — the exact
236
+ // remote-worker forwarding gap of mission 6938892f, now closed for questions too.
237
+ 'interactive_prompt_response',
230
238
  ]);
231
239
 
232
240
  function normalizeCommandSource(source: string): CommandLogEntry['source'] {