@adhdev/daemon-core 0.9.82-rc.455 → 0.9.82-rc.457
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/commands/med-family/mesh-crud.d.ts +19 -0
- package/dist/config/config.d.ts +14 -0
- package/dist/config/registry-resolver.d.ts +54 -0
- package/dist/index.js +369 -67
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +369 -67
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/preview-freshness.d.ts +11 -1
- package/dist/mesh/worktree-bootstrap-config.d.ts +9 -0
- package/dist/providers/approval-utils.d.ts +25 -0
- package/dist/providers/cli-provider-instance.d.ts +30 -1
- package/dist/providers/manual-attendance.d.ts +16 -0
- package/dist/providers/provider-instance.d.ts +8 -1
- package/dist/providers/provider-loader.d.ts +17 -2
- package/dist/providers/spec/fsm-driver.d.ts +5 -0
- package/package.json +3 -3
- package/src/boot/daemon-lifecycle.ts +2 -0
- package/src/commands/handler.ts +9 -5
- package/src/commands/low-family/daemon-lifecycle.ts +14 -1
- package/src/commands/med-family/mesh-crud.ts +83 -49
- package/src/config/config.ts +18 -0
- package/src/config/registry-resolver.ts +100 -0
- package/src/mesh/preview-freshness.ts +46 -1
- package/src/mesh/worktree-bootstrap-config.ts +1 -1
- package/src/providers/approval-utils.ts +42 -0
- package/src/providers/cli-provider-instance.ts +246 -13
- package/src/providers/manual-attendance.ts +20 -0
- package/src/providers/provider-instance.ts +6 -1
- package/src/providers/provider-loader.ts +36 -9
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +13 -2
- package/src/providers/spec/fsm-driver.ts +49 -2
- package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +0 -198
|
@@ -37,6 +37,11 @@ import {
|
|
|
37
37
|
resolveActiveSource,
|
|
38
38
|
} from './external-sources.js';
|
|
39
39
|
import type { ProviderSourceMode } from '../config/config.js';
|
|
40
|
+
import {
|
|
41
|
+
resolveRegistryBaseUrl,
|
|
42
|
+
resolveProviderTarballUrl,
|
|
43
|
+
resolveProviderTarballTarget,
|
|
44
|
+
} from '../config/registry-resolver.js';
|
|
40
45
|
import type { ProviderSourceConfigSnapshot, ProviderUserDirSource } from '../config/provider-source-config.js';
|
|
41
46
|
import { executeNativeHistory } from './spec/native-history-executor.js';
|
|
42
47
|
import { createNativeHistoryDispatcher, type ReaderId } from './native-history/dispatcher.js';
|
|
@@ -186,13 +191,19 @@ export class ProviderLoader {
|
|
|
186
191
|
private versionArchive: VersionArchive | null = null;
|
|
187
192
|
private scriptsCache = new Map<string, Partial<ProviderScripts>>();
|
|
188
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Resolved registry base URL and provider tarball URL. Resolution order:
|
|
196
|
+
* explicit config field (constructor option) → env var → vendor default.
|
|
197
|
+
* See `config/registry-resolver.ts`.
|
|
198
|
+
*/
|
|
199
|
+
private readonly registryBaseUrl: string;
|
|
200
|
+
private readonly providerTarballUrl: string;
|
|
201
|
+
|
|
189
202
|
/** Inject VersionArchive so resolve() can auto-detect installed versions */
|
|
190
203
|
setVersionArchive(archive: VersionArchive): void {
|
|
191
204
|
this.versionArchive = archive;
|
|
192
205
|
}
|
|
193
206
|
|
|
194
|
-
private static readonly GITHUB_TARBALL_URL = 'https://github.com/vilmire/adhdev-providers/archive/refs/heads/main.tar.gz';
|
|
195
|
-
private static readonly REGISTRY_BASE_URL = 'https://api.adhf.dev/api/v1/registry';
|
|
196
207
|
private static readonly META_FILE = '.meta.json';
|
|
197
208
|
private static readonly REGISTRY_META_FILE = '.registry-meta.json';
|
|
198
209
|
private static readonly REPO_PROVIDER_DIRNAME = 'adhdev-providers';
|
|
@@ -278,9 +289,21 @@ export class ProviderLoader {
|
|
|
278
289
|
* probing; production code should leave this unset.
|
|
279
290
|
*/
|
|
280
291
|
probeStarts?: string[];
|
|
292
|
+
/**
|
|
293
|
+
* Explicit provider registry base URL override (config.registryUrl).
|
|
294
|
+
* Highest-priority resolver source, ahead of ADHDEV_REGISTRY_URL + default.
|
|
295
|
+
*/
|
|
296
|
+
registryUrl?: string;
|
|
297
|
+
/**
|
|
298
|
+
* Explicit provider tarball URL override (config.providerTarballUrl).
|
|
299
|
+
* Highest-priority resolver source, ahead of ADHDEV_PROVIDER_TARBALL_URL + default.
|
|
300
|
+
*/
|
|
301
|
+
providerTarballUrl?: string;
|
|
281
302
|
}) {
|
|
282
303
|
this.logFn = options?.logFn || LOG.forComponent('Provider').asLogFn();
|
|
283
304
|
this.probeStarts = options?.probeStarts ?? [process.cwd(), __dirname];
|
|
305
|
+
this.registryBaseUrl = resolveRegistryBaseUrl(options?.registryUrl);
|
|
306
|
+
this.providerTarballUrl = resolveProviderTarballUrl(options?.providerTarballUrl);
|
|
284
307
|
|
|
285
308
|
// Default directory for auto-downloads
|
|
286
309
|
this.defaultProvidersDir = path.join(os.homedir(), '.adhdev', 'providers');
|
|
@@ -1554,7 +1577,7 @@ export class ProviderLoader {
|
|
|
1554
1577
|
this.log('Registry sync skipped (sourceMode=no-upstream)');
|
|
1555
1578
|
return { updated: false };
|
|
1556
1579
|
}
|
|
1557
|
-
this.log(`Registry sync starting (${
|
|
1580
|
+
this.log(`Registry sync starting (${this.registryBaseUrl})...`);
|
|
1558
1581
|
|
|
1559
1582
|
const https = require('https') as typeof import('https');
|
|
1560
1583
|
const regMetaPath = path.join(this.upstreamDir, ProviderLoader.REGISTRY_META_FILE);
|
|
@@ -1569,7 +1592,7 @@ export class ProviderLoader {
|
|
|
1569
1592
|
|
|
1570
1593
|
try {
|
|
1571
1594
|
// 1. Fetch provider list
|
|
1572
|
-
const listUrl = `${
|
|
1595
|
+
const listUrl = `${this.registryBaseUrl}/providers`;
|
|
1573
1596
|
const listBody = await new Promise<string>((resolve, reject) => {
|
|
1574
1597
|
const req = https.get(listUrl, { headers: { 'User-Agent': 'adhdev-daemon', 'Accept': 'application/json' }, timeout: 10000 }, (res) => {
|
|
1575
1598
|
if (res.statusCode !== 200) { reject(new Error(`registry list HTTP ${res.statusCode}`)); return; }
|
|
@@ -1592,7 +1615,7 @@ export class ProviderLoader {
|
|
|
1592
1615
|
if (cachedChecksums[cacheKey] === checksum) continue; // already current
|
|
1593
1616
|
|
|
1594
1617
|
// Download this provider's manifest
|
|
1595
|
-
const dlUrl = `${
|
|
1618
|
+
const dlUrl = `${this.registryBaseUrl}/providers/${type}/${version}/download`;
|
|
1596
1619
|
const manifestBody = await new Promise<string>((resolve, reject) => {
|
|
1597
1620
|
const req = https.get(dlUrl, { headers: { 'User-Agent': 'adhdev-daemon', 'Accept': 'application/json' }, timeout: 30000 }, (res) => {
|
|
1598
1621
|
if (res.statusCode !== 200) { reject(new Error(`registry download HTTP ${res.statusCode} for ${type}@${version}`)); return; }
|
|
@@ -1667,13 +1690,17 @@ export class ProviderLoader {
|
|
|
1667
1690
|
return { updated: false };
|
|
1668
1691
|
}
|
|
1669
1692
|
|
|
1693
|
+
// Resolve the tarball target (config → env → vendor default) once so the
|
|
1694
|
+
// HEAD probe and the download below hit the same (possibly self-hosted) URL.
|
|
1695
|
+
const tarballTarget = resolveProviderTarballTarget(this.providerTarballUrl);
|
|
1696
|
+
|
|
1670
1697
|
try {
|
|
1671
1698
|
// Step 1: HEAD request to check ETag
|
|
1672
1699
|
const etag = await new Promise<string>((resolve, reject) => {
|
|
1673
1700
|
const options = {
|
|
1674
1701
|
method: 'HEAD',
|
|
1675
|
-
hostname:
|
|
1676
|
-
path:
|
|
1702
|
+
hostname: tarballTarget.hostname,
|
|
1703
|
+
path: tarballTarget.path,
|
|
1677
1704
|
headers: { 'User-Agent': 'adhdev-launcher' },
|
|
1678
1705
|
timeout: 10000,
|
|
1679
1706
|
};
|
|
@@ -1718,7 +1745,7 @@ export class ProviderLoader {
|
|
|
1718
1745
|
const tmpExtract = path.join(os.tmpdir(), `adhdev-providers-extract-${Date.now()}`);
|
|
1719
1746
|
|
|
1720
1747
|
// Download tarball
|
|
1721
|
-
await this.downloadFile(
|
|
1748
|
+
await this.downloadFile(tarballTarget.url, tmpTar);
|
|
1722
1749
|
|
|
1723
1750
|
// Extract
|
|
1724
1751
|
fs.mkdirSync(tmpExtract, { recursive: true });
|
|
@@ -1825,7 +1852,7 @@ export class ProviderLoader {
|
|
|
1825
1852
|
etag,
|
|
1826
1853
|
timestamp,
|
|
1827
1854
|
lastCheck: new Date(timestamp).toISOString(),
|
|
1828
|
-
source:
|
|
1855
|
+
source: this.providerTarballUrl,
|
|
1829
1856
|
}, null, 2));
|
|
1830
1857
|
} catch { }
|
|
1831
1858
|
}
|
|
@@ -63,7 +63,12 @@ export interface ModalTuiSpec {
|
|
|
63
63
|
|
|
64
64
|
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
// A horizontal rule line. Covers solid box-drawing rules (─ ━ ═) AND the dashed
|
|
67
|
+
// variants (╌ ╍ ┄ ┅ ┈ ┉) that Claude Code draws as the INNER separators around a
|
|
68
|
+
// Write/Edit diff body. This matches the coverage of the claude-cli v4 FSM spec
|
|
69
|
+
// anchor `^[─╌]+$` (issue #137) so the SDK-v1 parser recognizes the same modal
|
|
70
|
+
// frames the FSM does — a dashed rule is a separator, not modal content.
|
|
71
|
+
const SEPARATOR_RE = /^[─━═╌╍┄┅┈┉]{10,}\s*$/;
|
|
67
72
|
|
|
68
73
|
function compile(re: string, flags?: string): RegExp {
|
|
69
74
|
try {
|
|
@@ -119,7 +124,13 @@ function scopeLines(
|
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
}
|
|
122
|
-
|
|
127
|
+
// Only scope to the separator frame when it actually BRACKETS the question
|
|
128
|
+
// line. Claude Write/Edit modals draw dashed (╌) inner rules around the diff
|
|
129
|
+
// body, so the last two separators can enclose the file diff while the
|
|
130
|
+
// question + button block sit BELOW the lower dashed rule. Scoping to that
|
|
131
|
+
// inner frame would drop every button (→ null → missed auto-approve, #137).
|
|
132
|
+
// When the question is outside the frame, fall through to a window around it.
|
|
133
|
+
if (lastSep >= 0 && prevSep >= 0 && questionIndex >= prevSep && questionIndex < lastSep + 1) {
|
|
123
134
|
return { start: prevSep, end: lastSep + 1 };
|
|
124
135
|
}
|
|
125
136
|
// Fallback: window around question line.
|
|
@@ -38,6 +38,8 @@ import { loadFsmSpec } from './fsm-loader.js';
|
|
|
38
38
|
import { applyPreLaunchTrust } from './pre-launch-trust.js';
|
|
39
39
|
import type { Control, DelegateTrigger } from './types.js';
|
|
40
40
|
import { LOG } from '../../logging/logger.js';
|
|
41
|
+
import { recordDebugTrace } from '../../logging/debug-trace.js';
|
|
42
|
+
import { shouldCollectTraceCategory } from '../../logging/debug-config.js';
|
|
41
43
|
import {
|
|
42
44
|
WIN32_PTY_WRITE_CHUNK_CHARS,
|
|
43
45
|
WIN32_PTY_WRITE_CHUNK_GAP_MS,
|
|
@@ -300,6 +302,11 @@ export class FsmDriver implements ISpecDriver {
|
|
|
300
302
|
* (−1 = whole screen), or a `section:<id>` / `<region>#ignore:<pat>` string
|
|
301
303
|
* when the clause scopes to a section or declares an ignore_lines filter. */
|
|
302
304
|
private regionLastChangedAt = new Map<number | string, number>();
|
|
305
|
+
/** COMPLETION-EARLYNOTIFY stable-eval trace: last stable/not-stable verdict
|
|
306
|
+
* recorded per stable region, so the trace fires only when the verdict FLIPS
|
|
307
|
+
* (not every quiet frame). Cleared on every transition alongside
|
|
308
|
+
* regionLastChangedAt. Diagnostic-only — never consulted by the FSM. */
|
|
309
|
+
private stableVerdictCache = new Map<number | string, boolean>();
|
|
303
310
|
/** Timer that re-runs evaluate() when a time-condition would flip true
|
|
304
311
|
* with no PTY frame to trigger it. */
|
|
305
312
|
private wakeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -684,6 +691,7 @@ export class FsmDriver implements ISpecDriver {
|
|
|
684
691
|
// Region change timestamps are relative to the previous state's
|
|
685
692
|
// activity; reset so stable_ms in the new state measures from entry.
|
|
686
693
|
this.regionLastChangedAt.clear();
|
|
694
|
+
this.stableVerdictCache.clear();
|
|
687
695
|
this.pushHistory(fired.to, stateById(this.spec, fired.to)?.label ?? fired.to, {
|
|
688
696
|
reason: 'transition',
|
|
689
697
|
via: `${from}→${fired.to}`,
|
|
@@ -818,6 +826,13 @@ export class FsmDriver implements ISpecDriver {
|
|
|
818
826
|
private trackRegionChanges(currentLines: string[], cursor: { row: number; col: number }, now: number): void {
|
|
819
827
|
if (this.prevScreenLines.length === 0) return;
|
|
820
828
|
const descs = this.stableRegionDescriptors();
|
|
829
|
+
// COMPLETION-EARLYNOTIFY hook 4: record the stable/not-stable verdict for each
|
|
830
|
+
// tracked region, but ONLY when the verdict flips (see stableVerdictCache) so a
|
|
831
|
+
// quiet screen does not spam the ring buffer. This is the case-b diagnostic — an
|
|
832
|
+
// ignore_lines-scoped stable clause declaring a tool-execution screen "stable-idle"
|
|
833
|
+
// shows up here as verdict:true with a short fingerprint. Payload carries lengths
|
|
834
|
+
// and the pattern SOURCE only — never screen text.
|
|
835
|
+
const stableTraceOn = shouldCollectTraceCategory('fsm-transition');
|
|
821
836
|
// Section ranges depend on screen content, so resolve per-frame for both
|
|
822
837
|
// frames — but only when some tracked region is actually section-scoped.
|
|
823
838
|
const needsSections = descs.some(d => !!d.section);
|
|
@@ -839,6 +854,28 @@ export class FsmDriver implements ISpecDriver {
|
|
|
839
854
|
const cur = filterIgnoredLines(curLines, d.ignoreRe).join('\n');
|
|
840
855
|
const prev = filterIgnoredLines(prevLines, d.ignoreRe).join('\n');
|
|
841
856
|
if (cur !== prev) this.regionLastChangedAt.set(d.key, now);
|
|
857
|
+
if (stableTraceOn && typeof d.holdMs === 'number') {
|
|
858
|
+
const lastChanged = this.regionLastChangedAt.get(d.key) ?? this.stateEnteredAt;
|
|
859
|
+
const ageMs = now - lastChanged;
|
|
860
|
+
const verdict = ageMs >= d.holdMs;
|
|
861
|
+
if (this.stableVerdictCache.get(d.key) !== verdict) {
|
|
862
|
+
this.stableVerdictCache.set(d.key, verdict);
|
|
863
|
+
recordDebugTrace({
|
|
864
|
+
category: 'fsm-transition',
|
|
865
|
+
stage: 'stable-eval',
|
|
866
|
+
level: 'debug',
|
|
867
|
+
payload: {
|
|
868
|
+
state: this.currentStateId,
|
|
869
|
+
regionKey: String(d.key),
|
|
870
|
+
ignorePattern: d.ignoreRe?.source ?? null,
|
|
871
|
+
fingerprintLen: cur.length,
|
|
872
|
+
ageMs,
|
|
873
|
+
holdMs: d.holdMs,
|
|
874
|
+
verdict,
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
842
879
|
}
|
|
843
880
|
}
|
|
844
881
|
|
|
@@ -1440,6 +1477,11 @@ interface StableRegionDescriptor {
|
|
|
1440
1477
|
section?: string;
|
|
1441
1478
|
cursor_above?: number;
|
|
1442
1479
|
ignoreRe?: RegExp;
|
|
1480
|
+
/** The stable_ms threshold the FIRST clause on this region declares. Used
|
|
1481
|
+
* only by the COMPLETION-EARLYNOTIFY stable-eval trace to report the
|
|
1482
|
+
* stable/not-stable verdict; the FSM decision itself is owned by the
|
|
1483
|
+
* evaluator against the live clause. */
|
|
1484
|
+
holdMs?: number;
|
|
1443
1485
|
}
|
|
1444
1486
|
|
|
1445
1487
|
function collectStableDescriptors(when: FsmTransition['when'], byKey: Map<number | string, StableRegionDescriptor>): void {
|
|
@@ -1447,14 +1489,19 @@ function collectStableDescriptors(when: FsmTransition['when'], byKey: Map<number
|
|
|
1447
1489
|
const w = when as any;
|
|
1448
1490
|
if ('stable_ms' in w) {
|
|
1449
1491
|
const key = stableRegionKey(w);
|
|
1450
|
-
|
|
1492
|
+
const existing = byKey.get(key);
|
|
1493
|
+
if (!existing) {
|
|
1451
1494
|
let ignoreRe: RegExp | undefined;
|
|
1452
1495
|
if (w.ignore_lines) {
|
|
1453
1496
|
// Compile once here; a bad pattern is validated at load time, so
|
|
1454
1497
|
// this is best-effort and simply skips the filter if it throws.
|
|
1455
1498
|
try { ignoreRe = new RegExp(w.ignore_lines, 'm'); } catch { /* validated at load */ }
|
|
1456
1499
|
}
|
|
1457
|
-
byKey.set(key, { key, section: w.section, cursor_above: w.cursor_above, ignoreRe });
|
|
1500
|
+
byKey.set(key, { key, section: w.section, cursor_above: w.cursor_above, ignoreRe, holdMs: typeof w.stable_ms === 'number' ? w.stable_ms : undefined });
|
|
1501
|
+
} else if (existing.holdMs === undefined && typeof w.stable_ms === 'number') {
|
|
1502
|
+
// Enrich the -1 whole-screen seed (or an earlier clause) with a threshold
|
|
1503
|
+
// so its verdict can be traced. Geometry/ignoreRe from the first set win.
|
|
1504
|
+
existing.holdMs = w.stable_ms;
|
|
1458
1505
|
}
|
|
1459
1506
|
return;
|
|
1460
1507
|
}
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
# Windows 자동 업그레이드 실패 — 원인 분석 및 패치 명세
|
|
2
|
-
|
|
3
|
-
> 대상 파일: `oss/packages/daemon-core/src/commands/upgrade-helper.ts`
|
|
4
|
-
> 관련 가드: `packages/daemon-cloud/package.json`, `oss/packages/daemon-standalone/package.json` 의 `preinstall`
|
|
5
|
-
> 작성 근거: 2026-06-23 실 사용자(Windows 11, nvm-windows) 환경에서 `adhdev@0.9.82-rc.357 → rc.358` 자동 업그레이드가 반복 실패한 실 사례.
|
|
6
|
-
|
|
7
|
-
## TL;DR (English)
|
|
8
|
-
|
|
9
|
-
The Windows self-upgrade fails when a process **other than** the parent CLI or the
|
|
10
|
-
known `session-host-daemon` keeps node-pty's `conpty.node` memory-mapped. The
|
|
11
|
-
helper only knows how to stop the single pid in `~/.adhdev/<app>-session-host.pid`,
|
|
12
|
-
so any *foreign* holder (here: three orphaned `pty_*probe*.cjs` scripts left in
|
|
13
|
-
`%TEMP%`) survives all 3 retries and the install dies with `EBUSY`. Two additional
|
|
14
|
-
weaknesses compound it: retry budget is far too small for a never-exiting holder,
|
|
15
|
-
and on failure the user gets no actionable message (only a log file). A separate,
|
|
16
|
-
independently-confirmed failure mode is the Node-24 `preinstall` guard firing when
|
|
17
|
-
the lifecycle-script `node` resolves to an unsupported version on a multi-node
|
|
18
|
-
`PATH`.
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## 1. 배경 / Context
|
|
23
|
-
|
|
24
|
-
adhdev 데몬은 새 버전을 감지하면 detached 헬퍼 프로세스를 띄워
|
|
25
|
-
(`spawnDetachedDaemonUpgradeHelper`) `npm install -g adhdev@<target> --prefix <pinned>`
|
|
26
|
-
를 실행한다 (`runDaemonUpgradeHelper`). Windows에서는 네이티브 애드온
|
|
27
|
-
`node-pty/prebuilds/win32-x64/conpty.node` 가 **메모리 매핑된 채 프로세스가
|
|
28
|
-
완전히 종료될 때까지 배타적 잠금**된다. npm은 기존 설치본을 스테이징 디렉터리
|
|
29
|
-
(`node_modules/.adhdev-<hash>`)로 **복사**한 뒤 새 버전으로 교체하므로, 잠긴
|
|
30
|
-
`conpty.node`를 복사하려다 `EBUSY`로 실패한다.
|
|
31
|
-
|
|
32
|
-
소스에는 이미 이 문제를 겨냥한 방어가 들어 있다:
|
|
33
|
-
- `stopSessionHostProcesses()` — `~/.adhdev/<app>-session-host.pid`의 pid를
|
|
34
|
-
죽이고 종료를 기다림(`waitForPidExit`).
|
|
35
|
-
- `buildInstallEnvWithNodeOnPath()` — lifecycle 스크립트가 올바른 node를 쓰도록
|
|
36
|
-
`PATH` 앞에 현재 node 디렉터리를 prepend.
|
|
37
|
-
- 설치 재시도 루프 (Windows에서 `maxInstallAttempts = 3`, 백오프 `attempt*1500ms`).
|
|
38
|
-
|
|
39
|
-
**그런데 이번 실패는 이 방어들이 전부 적용된 상태에서도 발생한다.**
|
|
40
|
-
|
|
41
|
-
## 2. 실제 실패 타임라인 (증거)
|
|
42
|
-
|
|
43
|
-
`~/.adhdev/daemon-upgrade.log`:
|
|
44
|
-
|
|
45
|
-
```
|
|
46
|
-
[01:52:18] Upgrade helper started for adhdev@0.9.82-rc.358
|
|
47
|
-
[01:52:18] Using npm executable: C:\nvm4w\nodejs\node.exe
|
|
48
|
-
[01:52:18] Pinned install prefix: C:\Users\kjs0116\AppData\Local\nvm\v22.14.0
|
|
49
|
-
[01:52:18] Waiting for parent pid 38744 to exit
|
|
50
|
-
[01:52:21] Skipped locked stale entry (EPERM): ...\.adhdev-dTz6t6GZ — ...conpty.node
|
|
51
|
-
[01:52:35] Install attempt 1 hit a file lock (lock); cleaning staging and retrying after backoff
|
|
52
|
-
[01:52:57] Install attempt 2 hit a file lock (lock); ...
|
|
53
|
-
[01:53:16] Upgrade helper failed: EBUSY ... copyfile '...\adhdev\node_modules\node-pty\prebuilds\win32-x64\conpty.node'
|
|
54
|
-
-> '...\.adhdev-dTz6t6GZ\...\conpty.node'
|
|
55
|
-
```
|
|
56
|
-
(01:59 에 동일 패턴으로 한 번 더 실패.)
|
|
57
|
-
|
|
58
|
-
수동 진단으로 밝혀낸 **실제 잠금 보유자** (`Get-Process node | %{ $_.Modules | ? ModuleName -match conpty }`):
|
|
59
|
-
|
|
60
|
-
| PID | CommandLine | 시작 |
|
|
61
|
-
|-----|-------------|------|
|
|
62
|
-
| 56396 | `node %TEMP%\pty_cr_probe.cjs` | 2026-06-21 |
|
|
63
|
-
| 37304 | `node %TEMP%\pty_probe_parent.cjs` | 2026-06-21 |
|
|
64
|
-
| 34316 | `node %TEMP%\pty_probe2_parent.cjs` | 2026-06-21 |
|
|
65
|
-
|
|
66
|
-
세 프로세스 모두
|
|
67
|
-
`...\AppData\Local\nvm\v22.14.0\node_modules\adhdev\node_modules\node-pty\prebuilds\win32-x64\conpty.node`
|
|
68
|
-
를 로드한 채 **이틀째 살아 있었다.** 세 개를 종료한 직후 잠금이 풀렸고
|
|
69
|
-
(`LOCK CLEARED`), Node 22로 설치가 정상 완료됐다.
|
|
70
|
-
|
|
71
|
-
> 참고: `pty_*probe*.cjs` 는 현재 adhdev 소스 트리에 존재하지 않는다(grep 결과 0건).
|
|
72
|
-
> 즉 개발 중 임시로 `%TEMP%`에 떨군 PTY/ConPTY 진단 스크립트가 고아로 남은 것이다.
|
|
73
|
-
> 핵심은 "출처가 무엇이든, **세션 호스트가 아닌 임의의 프로세스가 conpty.node를
|
|
74
|
-
> 쥘 수 있다**"는 점이며, 현재 헬퍼는 이를 처리하지 못한다.
|
|
75
|
-
|
|
76
|
-
## 3. 근본 원인
|
|
77
|
-
|
|
78
|
-
### RC1 — (핵심) 세션 호스트가 아닌 임의의 `conpty.node` 보유자를 못 다룸
|
|
79
|
-
`stopSessionHostProcesses()` 는 **딱 하나의 pid**(`<app>-session-host.pid`)만,
|
|
80
|
-
그것도 커맨드라인이 `/session-host-daemon/i` 에 매칭될 때만 죽인다
|
|
81
|
-
(`isManagedSessionHostPid`, upgrade-helper.ts:278-281, 295-313). 위 probe
|
|
82
|
-
프로세스처럼 PID 파일에 없고 커맨드라인도 매칭 안 되는 보유자는 **전혀 감지/정리
|
|
83
|
-
대상이 아니다.** 결과적으로 재시도 루프는 매번 같은 `EBUSY`를 다시 맞고 포기한다.
|
|
84
|
-
|
|
85
|
-
### RC2 — 재시도/백오프가 "절대 안 죽는 보유자"에 무력
|
|
86
|
-
`maxInstallAttempts = 3`, 백오프 `attempt*1500ms`(1.5s, 3s) (upgrade-helper.ts:449,469).
|
|
87
|
-
2일째 떠 있는 고아 프로세스에는 의미가 없다. 게다가 최종 실패 시 사용자에게
|
|
88
|
-
가는 신호는 로그 파일 한 줄(`Upgrade helper failed: ...`)뿐 — **어떤 프로세스가
|
|
89
|
-
막고 있는지, 어떻게 복구하는지** 알려주지 않는다.
|
|
90
|
-
|
|
91
|
-
### RC3 — Node 24 preinstall 가드 ↔ 멀티-node PATH (별도로 재현 확인됨)
|
|
92
|
-
`preinstall` 가드(아래)는 Windows에서 lifecycle 스크립트를 실행하는 `node`가
|
|
93
|
-
24+ 면 설치를 중단한다:
|
|
94
|
-
```jsonc
|
|
95
|
-
// packages/daemon-cloud/package.json, oss/packages/daemon-standalone/package.json
|
|
96
|
-
"preinstall": "node -e \"... if (win32 && major>=24 && !ADHDEV_BOOTSTRAP && !CI) { process.exit(1) }\""
|
|
97
|
-
```
|
|
98
|
-
npm은 preinstall을 `cmd /c node -e ...` 로, **PATH에서 찾은 bare `node`**로
|
|
99
|
-
실행한다(npm을 띄운 node가 아님). 이 머신은 `C:\Program Files\nodejs`(Node 24)가
|
|
100
|
-
nvm node보다 PATH 앞에 있어, 일반 `npm i -g adhdev` 는 이 가드에서 바로 죽는다
|
|
101
|
-
(본 사례에서 수동으로 재현됨).
|
|
102
|
-
|
|
103
|
-
`buildInstallEnvWithNodeOnPath()`(upgrade-helper.ts:185-198)가 `dirname(process.execPath)`
|
|
104
|
-
를 PATH 앞에 붙여 이를 완화하지만, **이는 "헬퍼 자신을 실행한 node가 지원 버전"이라는
|
|
105
|
-
가정에 의존**한다. 헬퍼가 nvm 심볼릭 링크(`C:\nvm4w\nodejs\node.exe`)로 떴고 그게
|
|
106
|
-
현재 Node 24를 가리키면, prepend되는 것도 Node 24라 가드가 그대로 발동한다.
|
|
107
|
-
실제 설치 타깃 node는 `--prefix`(v22.14.0)로 이미 고정돼 있는데도 그렇다.
|
|
108
|
-
|
|
109
|
-
### RC4 — 스테이징/잔여물 누적
|
|
110
|
-
잠금이 유지되는 동안 `safeRemoveStaleEntry`는 항상 `EPERM`으로 스킵되어
|
|
111
|
-
`.adhdev-<hash>` 스테이징이 **여러 실행에 걸쳐 그대로 쌓인다**. 보유자가 죽은
|
|
112
|
-
뒤에 GC하는 경로가 없다. (부수적으로, 헬퍼 밖에서 사용자가 `npm i -g adhdev`를
|
|
113
|
-
기본 prefix로 돌리면 `AppData\Roaming\npm`에 **두 번째 깨진 설치본**이 생겨
|
|
114
|
-
shadowing 혼란을 유발 — 헬퍼 책임은 아니나 진단 문서엔 남겨둠.)
|
|
115
|
-
|
|
116
|
-
## 4. 패치 권고
|
|
117
|
-
|
|
118
|
-
### P1 (필수) — 임의의 네이티브-애드온 보유자 감지·종료
|
|
119
|
-
설치 직전(및 각 재시도 전)에, **설치 대상 경로의** `conpty.node`(및
|
|
120
|
-
`ghostty-vt.dll`)를 로드 중인 프로세스를 열거해 self/parent를 제외하고 종료한 뒤
|
|
121
|
-
종료를 기다린다. 이번에 동작 확인된 PowerShell 패턴:
|
|
122
|
-
|
|
123
|
-
```powershell
|
|
124
|
-
Get-Process node -ErrorAction SilentlyContinue | ForEach-Object {
|
|
125
|
-
$p = $_
|
|
126
|
-
try {
|
|
127
|
-
if ($p.Modules | Where-Object { $_.FileName -ieq $targetConptyPath }) { $p.Id }
|
|
128
|
-
} catch {}
|
|
129
|
-
}
|
|
130
|
-
```
|
|
131
|
-
- `stopSessionHostProcesses()` 옆에 `stopForeignNativeAddonHolders(installRoot)`
|
|
132
|
-
형태로 추가. `installCommand.surface.packageRoot` 기준으로 정확한
|
|
133
|
-
`node_modules/node-pty/prebuilds/<plat-arch>/conpty.node` 절대경로를 만들어
|
|
134
|
-
**그 경로를 매핑한 프로세스만** 대상으로 한다(과잉 종료 방지).
|
|
135
|
-
- 종료한 pid + commandLine을 `appendUpgradeLog` 로 남긴다(진단성 확보).
|
|
136
|
-
- `taskkill /T /F`(기존 `killPid`) 재사용 + `waitForPidExit` 로 매핑 해제 대기.
|
|
137
|
-
- 안전장치: 경로 매칭이 모호하면 죽이지 말고 로그만(아래 P2의 사용자 안내로 위임).
|
|
138
|
-
|
|
139
|
-
### P2 (필수) — 복구 가능한 실패 신호
|
|
140
|
-
최종 실패 시(또는 보유자 종료 실패 시) 로그뿐 아니라 **사용자에게 보이는 메시지**를
|
|
141
|
-
남긴다: 막고 있는 pid/commandLine 목록 + 그대로 붙여넣어 복구할 수 있는 수동 명령
|
|
142
|
-
(`Stop-Process -Id ... ; <pinned-node> <npm-cli> install -g adhdev@<v> --prefix <prefix>`).
|
|
143
|
-
재시도 예산도 현실화(예: 보유자 능동 정리 후 1~2회면 충분하므로, "정리 → 확인 →
|
|
144
|
-
설치" 순서로 바꾸고 맹목적 백오프 의존을 줄인다).
|
|
145
|
-
|
|
146
|
-
### P3 (권장) — Node 가드와의 상호작용 견고화
|
|
147
|
-
`buildInstallEnvWithNodeOnPath()` 가 만드는 install env에 **`ADHDEV_BOOTSTRAP=1`
|
|
148
|
-
을 함께 설정**한다. 자동 업그레이드 경로에서는 실제 런타임 node가 `--prefix`로
|
|
149
|
-
이미 고정/검증돼 있으므로, lifecycle 가드를 PATH 순서에만 의존해 우회하는 것은
|
|
150
|
-
취약하다. 더 견고하게는: `process.execPath`의 major가 지원 범위(예: 22) 밖이면
|
|
151
|
-
`installPrefix` 기준으로 지원되는 node를 명시적으로 찾아 npm 실행과 lifecycle
|
|
152
|
-
스크립트 양쪽에 쓰도록 한다.
|
|
153
|
-
> 주의: 가드 자체를 약화시키지 말 것. 가드는 "사용자 수동 설치"를 막는 용도로
|
|
154
|
-
> 유지하고, **자동 헬퍼 경로에서만** bootstrap 우회를 적용한다.
|
|
155
|
-
|
|
156
|
-
### P4 (권장) — 스테이징 GC 시점 추가
|
|
157
|
-
보유자가 모두 사라진 것을 확인한 뒤 `cleanupStaleGlobalInstallDirs` 를 한 번 더
|
|
158
|
-
돌리고, 가능하면 **CLI 정상 기동 시점**(잠금 없는 상태)에도 1회 GC를 수행해
|
|
159
|
-
누적된 `.adhdev-<hash>` 를 청소한다.
|
|
160
|
-
|
|
161
|
-
## 5. 변경 대상 파일
|
|
162
|
-
|
|
163
|
-
- `oss/packages/daemon-core/src/commands/upgrade-helper.ts` — P1~P4 핵심.
|
|
164
|
-
- 신규 `stopForeignNativeAddonHolders()` (P1), `runDaemonUpgradeHelper` 흐름에
|
|
165
|
-
`stopSessionHostProcesses` 직후 호출.
|
|
166
|
-
- 최종 실패 메시지/예산 조정 (P2): `runDaemonUpgradeHelper` 의 설치 루프 +
|
|
167
|
-
`maybeRunDaemonUpgradeHelperFromEnv` 의 catch.
|
|
168
|
-
- install env에 `ADHDEV_BOOTSTRAP` 주입 (P3): `buildInstallEnvWithNodeOnPath`.
|
|
169
|
-
- (가드는 변경 불필요 — 자동 경로에서 env로 우회하는 것이 P3.)
|
|
170
|
-
|
|
171
|
-
## 6. 검증
|
|
172
|
-
|
|
173
|
-
1. **재현 픽스처:** `oss/packages/daemon-core/test/commands/daemon-upgrade-runtime-version.test.ts`
|
|
174
|
-
에 "세션 호스트가 **아닌** 프로세스가 대상 `conpty.node`를 매핑 중" 케이스 추가.
|
|
175
|
-
P1이 그 holder를 감지·종료 대상에 포함하는지 단위 테스트.
|
|
176
|
-
2. **수동 E2E (Windows):**
|
|
177
|
-
- 대상 설치본의 `conpty.node`를 로드하는 더미 node 프로세스를 띄워 둔다.
|
|
178
|
-
- 구버전에서 자동 업그레이드를 트리거.
|
|
179
|
-
- 기대: 헬퍼가 더미 holder를 로그에 남기고 종료 → 설치 성공, 잔여 staging 없음.
|
|
180
|
-
- 실패 주입(더미를 못 죽이게)했을 때: 사용자에게 pid/commandLine + 수동 복구
|
|
181
|
-
명령이 표시되는지 확인.
|
|
182
|
-
3. **회귀:** `buildInstallEnvWithNodeOnPath` + `ADHDEV_BOOTSTRAP` 조합에서
|
|
183
|
-
Node 24가 PATH 앞에 있어도 preinstall 가드가 자동 경로에선 통과하는지 확인.
|
|
184
|
-
|
|
185
|
-
## 7. 부록 — 이번 사례 수동 복구에 실제로 통한 명령
|
|
186
|
-
|
|
187
|
-
```powershell
|
|
188
|
-
# 1) conpty.node 보유자 식별
|
|
189
|
-
Get-Process node | ? { try { $_.Modules | ? ModuleName -match 'conpty' } catch {} } | % Id
|
|
190
|
-
# 2) 보유자 종료 (이번엔 34316,37304,56396)
|
|
191
|
-
Stop-Process -Id 34316,37304,56396 -Force
|
|
192
|
-
# 3) 고정 node(22) + 올바른 prefix로 설치 (PATH 앞에 Node22 → preinstall 가드 통과)
|
|
193
|
-
$pfx="C:\Users\kjs0116\AppData\Local\nvm\v22.14.0"; $env:PATH="$pfx;$env:PATH"
|
|
194
|
-
& "$pfx\node.exe" "$pfx\node_modules\npm\bin\npm-cli.js" install -g adhdev@0.9.82-rc.358 --prefix $pfx
|
|
195
|
-
# 4) 잔여 스테이징/엉뚱한 prefix 설치본 정리
|
|
196
|
-
Remove-Item -Recurse -Force "$pfx\node_modules\.adhdev-*" -EA SilentlyContinue
|
|
197
|
-
Remove-Item -Recurse -Force "$env:APPDATA\npm\node_modules\adhdev" -EA SilentlyContinue
|
|
198
|
-
```
|