@logicseek/hello-cc 1.0.3 → 1.0.5
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/CHANGELOG.md +64 -0
- package/docs/dsh.md +4 -2
- package/docs/dsh.zh-CN.md +4 -2
- package/docs/macos-process-identity.md +13 -0
- package/lib/cli/app.mjs +3 -2
- package/lib/cli/commands/lock.mjs +3 -1
- package/lib/cli/commands/misc.mjs +74 -9
- package/lib/core/coordination/gc-plan.mjs +5 -0
- package/lib/core/coordination/locks.mjs +3 -1
- package/lib/core/peers/evidence.mjs +21 -1
- package/lib/core/peers/tmux-safety.mjs +4 -0
- package/lib/integrations/dsh-collaboration.mjs +3 -4
- package/lib/process/identity.mjs +68 -32
- package/lib/runtime/buffer-evidence.mjs +7 -1
- package/lib/runtime/client.mjs +9 -1
- package/lib/runtime/state.mjs +5 -1
- package/lib/web/runtime-main.mjs +4 -4
- package/lib/web/tmux-client-snapshot.mjs +85 -0
- package/package.json +1 -1
- package/scripts/regression.mjs +245 -46
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,70 @@ the release description from the current changelog section. Use
|
|
|
16
16
|
`npm run release:github` with `GH_TOKEN` or `GITHUB_TOKEN` only for local
|
|
17
17
|
backfills.
|
|
18
18
|
|
|
19
|
+
## 1.0.5
|
|
20
|
+
|
|
21
|
+
### Summary
|
|
22
|
+
|
|
23
|
+
macOS process identities use the kernel boot session UUID so changes to the
|
|
24
|
+
reported boot time cannot make a running process appear to have been replaced.
|
|
25
|
+
The issue was observed during post-release sustained acceptance of 1.0.4.
|
|
26
|
+
|
|
27
|
+
### Highlights
|
|
28
|
+
|
|
29
|
+
- Preserve process identity across changes to the wall-clock-derived boot time.
|
|
30
|
+
- Keep live processes with an older Darwin identity format protected while their
|
|
31
|
+
ownership cannot be compared; a format change does not prove that an owner exited.
|
|
32
|
+
- Retain strict process state, start time and zombie checks, with unknown results
|
|
33
|
+
when a stable boot session identifier cannot be read.
|
|
34
|
+
- Keep the existing npm `latest` release channel.
|
|
35
|
+
- Confirm the original local runtime has exited when its stop response is lost,
|
|
36
|
+
while preserving unknown owners and replacement runtime pointers.
|
|
37
|
+
- Retain structured shutdown error codes when Node warnings share stderr.
|
|
38
|
+
|
|
39
|
+
### Validation
|
|
40
|
+
|
|
41
|
+
Targeted regression tests cover boot identity drift, boot-session changes,
|
|
42
|
+
malformed observations and conservative handling of existing runtime ownership.
|
|
43
|
+
Full source, package, current-device and sustained-run validation are recorded
|
|
44
|
+
separately. The interrupted 1.0.4 sustained run remains a failed attempt.
|
|
45
|
+
The first 1.0.5 candidate passed the current-Mac sustained check; a subsequent
|
|
46
|
+
CI transport timeout exposed the separate stop-confirmation failure. Its
|
|
47
|
+
targeted verification and replacement candidate retain separate evidence.
|
|
48
|
+
|
|
49
|
+
Existing processes keep the implementation they started with. Quit and reopen
|
|
50
|
+
Harness normally to activate the update. An older active owned runtime should
|
|
51
|
+
finish and stop normally before it is reclaimed; do not force takeover based
|
|
52
|
+
on an incompatible identity marker.
|
|
53
|
+
|
|
54
|
+
## 1.0.4
|
|
55
|
+
|
|
56
|
+
### Summary
|
|
57
|
+
|
|
58
|
+
Session lists no longer run synchronous tmux client probes for each displayed
|
|
59
|
+
terminal. A bounded asynchronous snapshot supplies display information while
|
|
60
|
+
live process and terminal observations continue to guard control actions.
|
|
61
|
+
|
|
62
|
+
### Highlights
|
|
63
|
+
|
|
64
|
+
- Share one short-lived tmux display snapshot across sessions. Cold, stale or
|
|
65
|
+
failed observations remain unknown, and pending probes stop with the runtime.
|
|
66
|
+
- Preserve live checks for terminal control, rebind, stop and ownership.
|
|
67
|
+
- Treat exited macOS zombie processes as dead, so shutdown waits can complete
|
|
68
|
+
while a parent has yet to reap the PID; malformed observations remain unknown.
|
|
69
|
+
- Include the HTTP method, request path, deadline and elapsed time in runtime
|
|
70
|
+
request failures, without request bodies, query parameters or credentials.
|
|
71
|
+
- Keep DeepSeek Harness on the existing npm `latest` channel.
|
|
72
|
+
|
|
73
|
+
### Validation
|
|
74
|
+
|
|
75
|
+
Targeted tests cover slow probes, stale and failed snapshots, refresh coalescing,
|
|
76
|
+
shutdown and request diagnostics. Full regression, official installed Harness,
|
|
77
|
+
registry readback and current-Mac model checks have separate verification records.
|
|
78
|
+
The earlier 1.0.3 macOS CI timeout remains historical evidence: removing a known
|
|
79
|
+
blocking path does not identify the specific cause of that earlier timeout.
|
|
80
|
+
Regression shutdown tracks the exact fixture process identity, distinguishes
|
|
81
|
+
reused PIDs and zombies, and retains bounded failure diagnostics before cleanup.
|
|
82
|
+
|
|
19
83
|
## 1.0.3
|
|
20
84
|
|
|
21
85
|
### Summary
|
package/docs/dsh.md
CHANGED
|
@@ -18,6 +18,8 @@ node /absolute/path/to/hello-cc/bin/hcc.mjs --root /path/to/project dsh web -- -
|
|
|
18
18
|
|
|
19
19
|
Below, `hcc` means a CLI containing these changes. An older global installation may not include them.
|
|
20
20
|
|
|
21
|
+
macOS 1.0.5 upgrades use a stable boot-session identity. Follow the [process identity migration notes](macos-process-identity.md) and restart owned runtimes normally.
|
|
22
|
+
|
|
21
23
|
## Choose An Entry Point
|
|
22
24
|
|
|
23
25
|
| Entry | Use | Lifecycle owner |
|
|
@@ -113,7 +115,7 @@ Project overlays work directly from source. The managed manifest records the Cor
|
|
|
113
115
|
|
|
114
116
|
```bash
|
|
115
117
|
npm pack --ignore-scripts
|
|
116
|
-
dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.
|
|
118
|
+
dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.5.tgz
|
|
117
119
|
```
|
|
118
120
|
|
|
119
121
|
Harness requires explicit approval for native dependency builds. If add returns `ERR_PNPM_IGNORED_BUILDS`, merge the following into the selected profile's `pnpm-workspace.yaml` and rerun the same add command. The default path is `~/.dsh/profiles/web/pnpm-workspace.yaml`; use the configured DSH_HOME when different. Preserve existing settings and approve only the candidate's pinned native dependency:
|
|
@@ -123,7 +125,7 @@ allowBuilds:
|
|
|
123
125
|
node-pty@1.2.0-beta.15: true
|
|
124
126
|
```
|
|
125
127
|
|
|
126
|
-
Harness selects bundles through the profile's `dsh.profile.bundles`; do not also load the project Cordis/hooks overlay. Actual npm installation, official add/repeated add/remove, profile resolution and real-model collaboration calls have passed. The actual macOS desktop profile was installed and hot-loaded on 2026-10-02. Its paired ACP worker completed real collaboration calls using the existing DeepSeek route. The initially configured desktop sub2api model returned HTTP 403 because that account only accepts official Codex clients. After the desktop default was observed to have changed to official DeepSeek, a new session completed hcc_state, hcc_message_send and a real completed turn with that existing selection. The original sub2api route restriction remains.
|
|
128
|
+
Harness selects bundles through the profile's `dsh.profile.bundles`; do not also load the project Cordis/hooks overlay. Actual npm installation, official add/repeated add/remove, profile resolution and real-model collaboration calls have passed. The actual macOS desktop profile was installed and hot-loaded on 2026-10-02. Its paired ACP worker completed real collaboration calls using the existing DeepSeek route. The initially configured desktop sub2api model returned HTTP 403 because that account only accepts official Codex clients. After the desktop default was observed to have changed to official DeepSeek, a new session completed hcc_state, hcc_message_send and a real completed turn with that existing selection. The original sub2api route restriction remains. Maintenance releases use the normal npm latest channel. Harness manages profile package removal; project-overlay integration is disabled with `setup --mode off`.
|
|
127
129
|
|
|
128
130
|
See Cordis/native acceptance (source checkout: `docs/verification/2026-10-02-dsh-cordis-native.md`), bridge acceptance (source checkout: `docs/verification/2026-10-02-dsh-official-bridge.md`), and the integration plan (source checkout: `docs/plans/2026-10-02-dsh-integration.md`). Real-model checks used temporary projects and owned processes, without replacing existing user sessions. The isolated order fixture passed artifact/evidence/handoff/lock-release/task-completion checks. See the Mac device receipt (source checkout: `docs/verification/2026-10-02-dsh-device-install.md`). Publication, deployment, other-device installation and genuine business acceptance remain separate delivery stages.
|
|
129
131
|
|
package/docs/dsh.zh-CN.md
CHANGED
|
@@ -18,6 +18,8 @@ node /absolute/path/to/hello-cc/bin/hcc.mjs --root /path/to/project dsh web -- -
|
|
|
18
18
|
|
|
19
19
|
下文 `hcc` 指包含这些代码的 CLI。已有的全局 `hcc` 未必包含此接入。
|
|
20
20
|
|
|
21
|
+
macOS 从 1.0.5 起使用稳定的启动周期标记。升级时按[进程身份迁移说明](macos-process-identity.md)正常重启所管理的运行时,保留身份无法比较的旧进程。
|
|
22
|
+
|
|
21
23
|
## 选择入口
|
|
22
24
|
|
|
23
25
|
| 入口 | 适用场景 | 会话由谁持有 |
|
|
@@ -115,7 +117,7 @@ hcc native start --peer dsh-reviewer --provider dsh --binary /absolute/path/to/d
|
|
|
115
117
|
|
|
116
118
|
```bash
|
|
117
119
|
npm pack --ignore-scripts
|
|
118
|
-
dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.
|
|
120
|
+
dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.5.tgz
|
|
119
121
|
```
|
|
120
122
|
|
|
121
123
|
Harness 的 pnpm 策略要求明确允许原生依赖构建。若首次 add 返回 `ERR_PNPM_IGNORED_BUILDS`,在所选 profile 的 `pnpm-workspace.yaml` 中合并以下配置,然后重跑同一 add 命令;默认位置是 `~/.dsh/profiles/web/pnpm-workspace.yaml`,自定义 DSH_HOME 时以该目录为准。保留文件中已有设置,只批准候选包使用的固定版本:
|
|
@@ -125,7 +127,7 @@ allowBuilds:
|
|
|
125
127
|
node-pty@1.2.0-beta.15: true
|
|
126
128
|
```
|
|
127
129
|
|
|
128
|
-
安装后由 Harness profile 的 `dsh.profile.bundles` 选择包;不要再同时加载项目 Cordis/hooks overlay。bundle 已通过实际 npm 安装、官方 add/重复 add/remove、隔离 profile 加载和真实模型工具调用。2026-10-02 已安装到本机真实 desktop profile 并热加载,配套 ACP 使用既有 DeepSeek 路由完成真实工具调用;初次桌面 sub2api 模型返回账户仅允许 Codex 官方客户端的 403;随后桌面默认模型已变为官方 DeepSeek,沿用当时配置的新建会话完成 hcc_state、hcc_message_send 和真实 completed 回合。原 sub2api
|
|
130
|
+
安装后由 Harness profile 的 `dsh.profile.bundles` 选择包;不要再同时加载项目 Cordis/hooks overlay。bundle 已通过实际 npm 安装、官方 add/重复 add/remove、隔离 profile 加载和真实模型工具调用。2026-10-02 已安装到本机真实 desktop profile 并热加载,配套 ACP 使用既有 DeepSeek 路由完成真实工具调用;初次桌面 sub2api 模型返回账户仅允许 Codex 官方客户端的 403;随后桌面默认模型已变为官方 DeepSeek,沿用当时配置的新建会话完成 hcc_state、hcc_message_send 和真实 completed 回合。原 sub2api 路由限制仍存在。后续维护版本沿用 npm latest 标签发布。profile 插件安装/移除由 Harness 管理,项目 overlay 则使用 `setup --mode off` 关闭。
|
|
129
131
|
|
|
130
132
|
验证记录见 Cordis/native 验收 (源码目录: `docs/verification/2026-10-02-dsh-cordis-native.md`)、兼容桥记录 (源码目录: `docs/verification/2026-10-02-dsh-official-bridge.md`) 和详细接入计划 (源码目录: `docs/plans/2026-10-02-dsh-integration.md`)。真实模型验收在临时项目和自有进程中完成;既有会话与模型凭据保留;本机实际安装记录见 Mac 接入验收 (源码目录: `docs/verification/2026-10-02-dsh-device-install.md`)。Linux 容器已验证,实际 WSL/员工设备和真实业务签收尚未完成;隔离订单样例已完成报告、证据、交接、解锁与任务结束验证。
|
|
131
133
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# macOS 进程身份与升级
|
|
2
|
+
|
|
3
|
+
macOS 的 `kern.boottime` 是墙上时钟时间,同一次开机期间也可能因时钟校正改变。以它的秒、微秒字段识别进程,会把仍在运行的进程误判为新实例。
|
|
4
|
+
|
|
5
|
+
Darwin 身份改为 `darwin:<kern.bootsessionuuid>:<UTC/C ps lstart>`。开机 UUID 严格验证并统一小写;缺失、查询失败或格式错误时返回 `unknown`,不会退回 `kern.boottime`。进程开始时间仍前后读取两次,状态中的 zombie 仍视为已退出。PID、进程开始时间和命令摘要的所有权校验没有放宽。
|
|
6
|
+
|
|
7
|
+
旧版本保存的 `<boot 秒>:<boot 微秒>:<ps lstart>` 无法可靠转换成新标记。对同一存活 PID,旧、新格式之间返回 `unknown`,既不授予所有权,也不证明旧进程已经退出。其 runtime 指针不因超过普通未知状态宽限期而被回收;Cordis 恢复和 native 启动也必须保留仍存活的旧 owner。与真实进程退出或同格式 PID 复用有关的判定保持原有规则。
|
|
8
|
+
|
|
9
|
+
这种专用 `unknown` 同样保留 peer/binding、过期锁和缓冲文件,阻止过期任务被接管;它不会赋予 `live` 身份、续租或写权限。普通未知状态的超时策略不变。
|
|
10
|
+
|
|
11
|
+
升级前,先用原版本正常关闭所属 native worker/runtime,正常退出 Harness Desktop,再安装并启动新版本,使运行中的 owner 重新建立身份。保留账号配置、会话与协作数据库;不要手工改写旧身份、删除仍存活 runtime 的指针,或仅凭 PID 强行接管。若旧进程已经退出,OS 的死亡证据仍允许正常恢复。没有完整身份的其他未知状态继续使用原有策略。
|
|
12
|
+
|
|
13
|
+
回归覆盖同一次开机期间 `kern.boottime` 漂移、不同 boot UUID、错误和缺失 UUID、PATH 缺失时的固定系统路径、语言环境、PID 复用、exec、真实自有 zombie,以及跨格式 runtime 指针、native 启动和 Cordis 恢复边界。
|
package/lib/cli/app.mjs
CHANGED
|
@@ -54,7 +54,8 @@ import { readPackageMeta } from '../package-meta.mjs';
|
|
|
54
54
|
import { removeGuidanceBlocks as removeGuidanceBlocksForRoot, writeGuidance as writeGuidanceForRoot } from '../guidance.mjs';
|
|
55
55
|
import { globalRuntimePath, runtimePath } from '../runtime/paths.mjs';
|
|
56
56
|
import { readRuntime, reclaimRuntimePointerFiles } from '../runtime/state.mjs';
|
|
57
|
-
import { waitForProcessIdentityExit } from '../process/identity.mjs';
|
|
57
|
+
import { waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity } from '../process/identity.mjs';
|
|
58
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
58
59
|
|
|
59
60
|
import { runtimeRequest } from '../runtime/client.mjs';
|
|
60
61
|
|
|
@@ -319,7 +320,7 @@ const { cmdDown, cmdFindRoot, cmdWhichReal } = createMiscCommands({
|
|
|
319
320
|
path, fs, process, CliError, parseOpts, printResult,
|
|
320
321
|
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
321
322
|
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
322
|
-
waitForProcessIdentityExit,
|
|
323
|
+
waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity, withFileLock,
|
|
323
324
|
PRODUCT_NAME
|
|
324
325
|
});
|
|
325
326
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Factory pattern with injected deps.
|
|
3
3
|
|
|
4
4
|
import { readTx } from '../../db/schema.mjs';
|
|
5
|
+
import { isPeerEvidenceCompatibilityProtected } from '../../core/peers/evidence.mjs';
|
|
5
6
|
import {
|
|
6
7
|
findRequestedLock,
|
|
7
8
|
upsertRequestedLock,
|
|
@@ -101,7 +102,8 @@ export function createLockCommands(deps) {
|
|
|
101
102
|
!(Number(row.expires_at) <= t && evidenceByOwner.get(row.owner)?.state === 'dead'))
|
|
102
103
|
: subject.locks.filter((row) =>
|
|
103
104
|
Number(row.expires_at) > t ||
|
|
104
|
-
evidenceByOwner.get(row.owner)?.state === 'live'
|
|
105
|
+
evidenceByOwner.get(row.owner)?.state === 'live' ||
|
|
106
|
+
isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(row.owner))
|
|
105
107
|
);
|
|
106
108
|
const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
|
|
107
109
|
if (conflict) {
|
|
@@ -2,13 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
export function createMiscCommands(deps) {
|
|
4
4
|
const {
|
|
5
|
-
path, process, CliError, parseOpts, printResult,
|
|
5
|
+
path, fs, process, CliError, parseOpts, printResult,
|
|
6
6
|
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
7
7
|
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
8
|
-
waitForProcessIdentityExit,
|
|
8
|
+
waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity, withFileLock,
|
|
9
9
|
PRODUCT_NAME
|
|
10
10
|
} = deps;
|
|
11
11
|
|
|
12
|
+
function sameStopPointer(left, right) {
|
|
13
|
+
return left.raw === right.raw && ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs']
|
|
14
|
+
.every((key) => left.stat[key] === right.stat[key]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readStopPointer(file) {
|
|
18
|
+
const stat = fs.lstatSync(file);
|
|
19
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('invalid runtime pointer');
|
|
20
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
21
|
+
const current = fs.lstatSync(file);
|
|
22
|
+
if (current.isSymbolicLink() || !current.isFile() ||
|
|
23
|
+
!sameStopPointer({ raw, stat }, { raw, stat: current })) {
|
|
24
|
+
throw new Error('runtime pointer changed');
|
|
25
|
+
}
|
|
26
|
+
return { raw, stat };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stopOwnerEvidence(runtime, pointerFiles) {
|
|
30
|
+
try {
|
|
31
|
+
if (runtime.source === 'env') return null;
|
|
32
|
+
const source = pointerFiles.find((file) => path.resolve(file) === path.resolve(runtime.source || ''));
|
|
33
|
+
const url = new URL(runtime.base_url);
|
|
34
|
+
if (!source || !['http:', 'https:'].includes(url.protocol) ||
|
|
35
|
+
!['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) return null;
|
|
36
|
+
const owner = runtime.process_identity || runtime.processIdentity;
|
|
37
|
+
if (Object.hasOwn(runtime, 'process_identity') && Object.hasOwn(runtime, 'processIdentity')) return null;
|
|
38
|
+
if (owner?.pid !== Number(runtime.pid) || compareProcessIdentity(owner, owner) !== 'live') return null;
|
|
39
|
+
const observed = inspectProcessIdentity(owner.pid);
|
|
40
|
+
if (observed.state !== 'live' || compareProcessIdentity(owner, observed.identity) !== 'live' ||
|
|
41
|
+
owner.commandHash !== observed.identity.commandHash) return null;
|
|
42
|
+
const pointer = readStopPointer(source);
|
|
43
|
+
const recorded = JSON.parse(pointer.raw);
|
|
44
|
+
if (Object.hasOwn(recorded, 'process_identity') && Object.hasOwn(recorded, 'processIdentity')) return null;
|
|
45
|
+
const recordedOwner = recorded.process_identity || recorded.processIdentity;
|
|
46
|
+
if (Number(recorded.pid) !== owner.pid ||
|
|
47
|
+
compareProcessIdentity(owner, recordedOwner) !== 'live' || owner.commandHash !== recordedOwner.commandHash ||
|
|
48
|
+
['base_url', 'token', 'tls_ca_file'].some((key) => recorded[key] !== runtime[key])) return null;
|
|
49
|
+
return { source, owner, pointer };
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
12
55
|
async function cmdDown(ctx, args) {
|
|
13
56
|
if (args[0] === '--help' || args[0] === '-h') return helpDown();
|
|
14
57
|
const pointerFiles = [runtimePath(ctx), globalRuntimePath()];
|
|
@@ -23,17 +66,39 @@ export function createMiscCommands(deps) {
|
|
|
23
66
|
`${PRODUCT_NAME} stale runtime pointer removed${result.pointers === 1 ? '' : 's'}`);
|
|
24
67
|
return;
|
|
25
68
|
}
|
|
69
|
+
const stopEvidence = stopOwnerEvidence(runtime, pointerFiles);
|
|
26
70
|
try {
|
|
27
71
|
await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, runtime);
|
|
28
72
|
} catch (err) {
|
|
29
73
|
if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
|
|
30
|
-
if (
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
74
|
+
if (!stopEvidence) throw err;
|
|
75
|
+
const stopped = await waitForProcessIdentityExit(stopEvidence.owner, {
|
|
76
|
+
timeoutMs: 5_000,
|
|
77
|
+
intervalMs: 25
|
|
78
|
+
});
|
|
79
|
+
if (stopped.state !== 'dead') throw err;
|
|
80
|
+
let cleanup;
|
|
81
|
+
try {
|
|
82
|
+
cleanup = withFileLock(stopEvidence.source, (file) => {
|
|
83
|
+
let current;
|
|
84
|
+
try { current = readStopPointer(file); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code === 'ENOENT') return { reclaimed: 0, blocked: false };
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (!sameStopPointer(stopEvidence.pointer, current)) throw err;
|
|
90
|
+
// Hold the pointer's existing lock across comparison and reclamation.
|
|
91
|
+
// A replacement owner must never be removed by this stop request.
|
|
92
|
+
return reclaimRuntimePointerFiles([file], {
|
|
93
|
+
reclaimUnknown: false,
|
|
94
|
+
withLock: (_file, operation) => operation(file)
|
|
95
|
+
});
|
|
96
|
+
}, { createParent: false });
|
|
97
|
+
} catch { throw err; }
|
|
98
|
+
if (cleanup.blocked || cleanup.reclaimed > 1) throw err;
|
|
99
|
+
printResult(ctx, { runtime: stopEvidence.source }, () => cleanup.reclaimed === 1
|
|
100
|
+
? `${PRODUCT_NAME} stale runtime pointer removed`
|
|
101
|
+
: `${PRODUCT_NAME} runtime stopped`);
|
|
37
102
|
return;
|
|
38
103
|
}
|
|
39
104
|
if (runtime.source !== 'env') {
|
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
import { readTx, tx } from '../../db/schema.mjs';
|
|
4
4
|
import { aggregateCleanupFailure } from '../../shared/cleanup-error.mjs';
|
|
5
|
+
import { isPeerEvidenceCompatibilityProtected } from '../peers/evidence.mjs';
|
|
5
6
|
import { DURABLE_RUNTIME_EVENTS } from './event-retention.mjs';
|
|
6
7
|
|
|
7
8
|
export const HISTORY_GC_BATCH_SIZE = 256;
|
|
@@ -59,6 +60,10 @@ export function finalizeGcLockSubjects(db, subjects, evidenceByOwner, options =
|
|
|
59
60
|
result.live += 1;
|
|
60
61
|
continue;
|
|
61
62
|
}
|
|
63
|
+
if (isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(owner))) {
|
|
64
|
+
result.deferred += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
62
67
|
if (dryRun || deleteExactRow(db, 'locks', planned.lock)) result.deleted += 1;
|
|
63
68
|
else result.deferred += 1;
|
|
64
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
import { isPeerEvidenceCompatibilityProtected } from '../peers/evidence.mjs';
|
|
2
3
|
|
|
3
4
|
export const WHOLE_LOCK_SCOPE = '*';
|
|
4
5
|
|
|
@@ -87,5 +88,6 @@ export function visibleLocksWithEvidence(locks, nowSec, {
|
|
|
87
88
|
evidenceByOwner = new Map()
|
|
88
89
|
} = {}) {
|
|
89
90
|
return (locks || []).filter((lock) => graceActive || Number(lock.expires_at) > nowSec ||
|
|
90
|
-
evidenceByOwner.get(lock.owner)?.state === 'live'
|
|
91
|
+
evidenceByOwner.get(lock.owner)?.state === 'live' ||
|
|
92
|
+
isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(lock.owner)));
|
|
91
93
|
}
|
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import { compareProcessIdentity } from '../../process/identity.mjs';
|
|
1
|
+
import { compareProcessIdentity, isProcessIdentityIncompatible } from '../../process/identity.mjs';
|
|
2
2
|
|
|
3
3
|
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
4
|
+
const INCOMPATIBLE_PROCESS_REASON = 'process_identity_incompatible';
|
|
5
|
+
|
|
6
|
+
export function isPeerEvidenceCompatibilityProtected(evidence = {}) {
|
|
7
|
+
return evidence.state === 'unknown' && evidence.reason === INCOMPATIBLE_PROCESS_REASON;
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
export function classifyPeerActivity(peer = {}, options = {}) {
|
|
6
11
|
const activePeerTtl = Number(options.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
|
|
7
12
|
const graceActive = Boolean(options.graceActive);
|
|
8
13
|
const evidenceState = peer.evidence_state || peer.evidence?.state || 'unknown';
|
|
14
|
+
const evidenceReason = peer.evidence_reason || peer.evidence?.reason;
|
|
9
15
|
const age = Number(peer.age_sec);
|
|
10
16
|
const active = evidenceState === 'live' ||
|
|
17
|
+
isPeerEvidenceCompatibilityProtected({ state: evidenceState, reason: evidenceReason }) ||
|
|
11
18
|
(evidenceState !== 'dead' && (graceActive || (Number.isFinite(age) && age <= activePeerTtl)));
|
|
12
19
|
return { active, stale: !active };
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export function peerEvidenceAllowsReap(evidence = {}, options = {}) {
|
|
23
|
+
if (isPeerEvidenceCompatibilityProtected(evidence)) return false;
|
|
16
24
|
if (evidence.state === 'dead') return true;
|
|
17
25
|
if (evidence.state !== 'unknown') return false;
|
|
18
26
|
const nowSec = Number(options.nowSec);
|
|
@@ -37,6 +45,9 @@ function processState(evidence) {
|
|
|
37
45
|
if (evidence.current?.state === 'unknown') return 'unknown';
|
|
38
46
|
if (evidence.current?.state === 'dead') return 'dead';
|
|
39
47
|
if (evidence.current?.state !== 'live') return 'unknown';
|
|
48
|
+
if (isProcessIdentityIncompatible(evidence.storedIdentity, evidence.current.identity)) {
|
|
49
|
+
return 'incompatible';
|
|
50
|
+
}
|
|
40
51
|
return compareProcessIdentity(evidence.storedIdentity, evidence.current.identity);
|
|
41
52
|
}
|
|
42
53
|
|
|
@@ -48,6 +59,9 @@ function aggregateProcesses(processes) {
|
|
|
48
59
|
if (states.includes('live')) {
|
|
49
60
|
return { state: 'live', reason: 'process_identity_match' };
|
|
50
61
|
}
|
|
62
|
+
if (states.includes('incompatible')) {
|
|
63
|
+
return { state: 'unknown', reason: INCOMPATIBLE_PROCESS_REASON };
|
|
64
|
+
}
|
|
51
65
|
if (states.every((state) => state === 'dead')) {
|
|
52
66
|
const missing = processes.every((item) => item?.current?.state === 'dead');
|
|
53
67
|
return {
|
|
@@ -81,8 +95,14 @@ function tmuxVerifiedLive(tmux) {
|
|
|
81
95
|
export function resolvePeerEvidence(input = {}) {
|
|
82
96
|
const ordinaryProcess = aggregateProcesses(input.processes);
|
|
83
97
|
if (ordinaryProcess.state === 'live') return ordinaryProcess;
|
|
98
|
+
// A live PID with incomparable Darwin generations proves neither ownership
|
|
99
|
+
// nor exit. Retain that distinction through persisted status and TTL gates.
|
|
100
|
+
if (isPeerEvidenceCompatibilityProtected(ordinaryProcess)) return ordinaryProcess;
|
|
84
101
|
|
|
85
102
|
const tmux = input.tmux?.managed ? input.tmux : null;
|
|
103
|
+
if (ordinaryProcess.state === 'unknown' && tmux && tmuxProcessState(tmux) === 'incompatible') {
|
|
104
|
+
return { state: 'unknown', reason: INCOMPATIBLE_PROCESS_REASON };
|
|
105
|
+
}
|
|
86
106
|
if (ordinaryProcess.state === 'unknown' && tmux && tmuxVerifiedLive(tmux)) {
|
|
87
107
|
return { state: 'live', reason: 'tmux_identity_match' };
|
|
88
108
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { compareProcessIdentity } from '../../process/identity.mjs';
|
|
2
2
|
import { tx } from '../../db/schema.mjs';
|
|
3
3
|
import { CliError } from '../../shared/errors.mjs';
|
|
4
|
+
import { isPeerEvidenceCompatibilityProtected } from './evidence.mjs';
|
|
4
5
|
|
|
5
6
|
function completeIdentity(identity) {
|
|
6
7
|
return Boolean(
|
|
@@ -84,6 +85,9 @@ export function validateTmuxGcBindingEvidence(subject = {}, observed = {}, optio
|
|
|
84
85
|
if (subject.owner_evidence?.state === 'live') {
|
|
85
86
|
return { ok: false, reason: 'tmux_owner_process_live' };
|
|
86
87
|
}
|
|
88
|
+
if (isPeerEvidenceCompatibilityProtected(subject.owner_evidence)) {
|
|
89
|
+
return { ok: false, reason: 'tmux_owner_process_incompatible' };
|
|
90
|
+
}
|
|
87
91
|
|
|
88
92
|
const dead = validateTmuxGcDeadProcessEvidence(authority, observed, options);
|
|
89
93
|
if (dead.ok) return { ok: true, mode: 'dead_process' };
|
|
@@ -16,7 +16,7 @@ import { createCoordinationState } from '../coordination-state.mjs';
|
|
|
16
16
|
import { createScopedMcpTools } from '../mcp/tools.mjs';
|
|
17
17
|
import { resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
18
18
|
import { registerProjectActivity } from '../runtime/projects.mjs';
|
|
19
|
-
import { inspectProcessIdentity } from '../process/identity.mjs';
|
|
19
|
+
import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
|
|
20
20
|
|
|
21
21
|
const fail = (message) => { throw new CliError('DSH_COLLABORATION_CONFLICT', message); };
|
|
22
22
|
|
|
@@ -29,9 +29,8 @@ function verifiedDeadOwner(peer) {
|
|
|
29
29
|
// A recycled PID has a different start token. A changed command alone does
|
|
30
30
|
// not prove the original process exited (a live process may change its title).
|
|
31
31
|
return observed.state === 'live' && observed.identity?.pid === peer.pid &&
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
observed.identity.startToken !== peer.pid_start_token;
|
|
32
|
+
compareProcessIdentity({ pid: peer.pid, startToken: peer.pid_start_token,
|
|
33
|
+
commandHash: peer.pid_command_hash }, observed.identity) === 'dead';
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
// One in-process authority per Agent. All mutations still go through the same
|
package/lib/process/identity.mjs
CHANGED
|
@@ -84,48 +84,70 @@ function run(command, args, environment) {
|
|
|
84
84
|
return spawnSync(command, args, options);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
const MAC_BOOT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
88
|
+
|
|
87
89
|
function parseMacBootToken(text) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (!match) return null;
|
|
90
|
+
const uuid = String(text ?? '').trim();
|
|
91
|
+
return MAC_BOOT_UUID.test(uuid) ? uuid.toLowerCase() : null;
|
|
92
|
+
}
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
function macTokenFormat(token) {
|
|
95
|
+
const start = '(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) [A-Z][a-z]{2}\\s+\\d{1,2} \\d{2}:\\d{2}:\\d{2} \\d{4}';
|
|
96
|
+
const legacy = new RegExp(`^(\\d+):(\\d+):${start}$`).exec(token);
|
|
97
|
+
if (legacy && BigInt(legacy[1]) > 0n && Number(legacy[2]) >= 0 && Number(legacy[2]) <= 999999) return 'legacy-wall-clock';
|
|
98
|
+
const current = /^darwin:([^:]+):(.+)$/.exec(token);
|
|
99
|
+
if (current && MAC_BOOT_UUID.test(current[1]) && new RegExp(`^${start}$`).test(current[2])) return 'boot-session';
|
|
100
|
+
return null;
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
function
|
|
101
|
-
|
|
103
|
+
function parseMacStateStart(text) {
|
|
104
|
+
const match = /^(\S+)\s+([^\r\n]+)$/.exec(String(text ?? '').trim());
|
|
105
|
+
// Darwin ps uses X only as a traced/debugged modifier; Z is its exit state.
|
|
106
|
+
if (!match || !/^[IRSTUZ][+<>AELNSsVWX]*$/.test(match[1])) return null;
|
|
107
|
+
return { state: match[1][0], startToken: match[2].trim() };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function macObservation(pid) {
|
|
111
|
+
const unknown = { state: 'unknown', identity: null };
|
|
112
|
+
// kern.boottime is a wall-clock timestamp and can change during one boot.
|
|
113
|
+
// The boot-session UUID remains stable across those clock corrections.
|
|
114
|
+
let boot = run('sysctl', ['-n', 'kern.bootsessionuuid']);
|
|
102
115
|
// Launchers often omit /usr/sbin from PATH; macOS still provides sysctl here.
|
|
103
|
-
if (boot.error?.code === 'ENOENT') boot = run('/usr/sbin/sysctl', ['-n', 'kern.
|
|
104
|
-
if (boot.error || boot.status !== 0) return
|
|
116
|
+
if (boot.error?.code === 'ENOENT') boot = run('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid']);
|
|
117
|
+
if (boot.error || boot.status !== 0) return unknown;
|
|
105
118
|
const bootToken = parseMacBootToken(boot.stdout);
|
|
106
|
-
if (!bootToken) return
|
|
119
|
+
if (!bootToken) return unknown;
|
|
107
120
|
|
|
108
121
|
const psEnvironment = { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' };
|
|
109
|
-
|
|
122
|
+
// kill(pid, 0) also succeeds for an unreaped zombie on Darwin. Read state
|
|
123
|
+
// alongside each existing start-time probe, without adding subprocesses.
|
|
124
|
+
const started = run('ps', ['-p', String(pid), '-o', 'stat=', '-o', 'lstart='], psEnvironment);
|
|
125
|
+
const first = parseMacStateStart(started.stdout);
|
|
126
|
+
if (started.error || started.status !== 0 || !first) return unknown;
|
|
127
|
+
if (first.state === 'Z') return { state: 'dead', identity: null };
|
|
110
128
|
const command = run('ps', ['-p', String(pid), '-o', 'command='], psEnvironment);
|
|
111
|
-
const confirmedStarted = run('ps', ['-p', String(pid), '-o', 'lstart='], psEnvironment);
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
129
|
+
const confirmedStarted = run('ps', ['-p', String(pid), '-o', 'stat=', '-o', 'lstart='], psEnvironment);
|
|
130
|
+
const last = parseMacStateStart(confirmedStarted.stdout);
|
|
131
|
+
if (confirmedStarted.error || confirmedStarted.status !== 0 || !last) return unknown;
|
|
132
|
+
if (last.state === 'Z') return { state: 'dead', identity: null };
|
|
133
|
+
if (command.error || command.status !== 0) return unknown;
|
|
115
134
|
|
|
116
135
|
const commandOutput = String(command.stdout ?? '').trim();
|
|
117
136
|
const parsed = parsePsStartIdentity(
|
|
118
|
-
`${
|
|
137
|
+
`${first.startToken}\t${commandOutput}\n`
|
|
119
138
|
);
|
|
120
139
|
const confirmed = parsePsStartIdentity(
|
|
121
|
-
`${
|
|
140
|
+
`${last.startToken}\t${commandOutput}\n`
|
|
122
141
|
);
|
|
123
|
-
if (!parsed || !confirmed || confirmed.startToken !== parsed.startToken) return
|
|
142
|
+
if (!parsed || !confirmed || confirmed.startToken !== parsed.startToken) return unknown;
|
|
124
143
|
|
|
125
144
|
return {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
145
|
+
state: 'live',
|
|
146
|
+
identity: {
|
|
147
|
+
pid,
|
|
148
|
+
startToken: `darwin:${bootToken}:${parsed.startToken}`,
|
|
149
|
+
commandHash: hashCommand(parsed.command)
|
|
150
|
+
}
|
|
129
151
|
};
|
|
130
152
|
}
|
|
131
153
|
|
|
@@ -170,10 +192,9 @@ export async function waitForProcessIdentityExit(value, options = {}) {
|
|
|
170
192
|
const observed = inspect(pid);
|
|
171
193
|
if (observed?.state === 'dead') return { state: 'dead', identity: null };
|
|
172
194
|
if (observed?.state === 'live' && isCompleteIdentity(observed.identity)) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
lastObservation = observed;
|
|
195
|
+
const comparison = storedIdentity ? compareProcessIdentity(storedIdentity, observed.identity) : 'live';
|
|
196
|
+
if (comparison === 'dead') return { state: 'dead', identity: null };
|
|
197
|
+
lastObservation = comparison === 'live' ? observed : { state: 'unknown', identity: null };
|
|
177
198
|
} else {
|
|
178
199
|
lastObservation = { state: 'unknown', identity: null };
|
|
179
200
|
}
|
|
@@ -204,7 +225,11 @@ export function inspectProcessIdentity(value) {
|
|
|
204
225
|
if (observed.state === 'dead') return observed;
|
|
205
226
|
identity = observed.identity;
|
|
206
227
|
}
|
|
207
|
-
else if (process.platform === 'darwin')
|
|
228
|
+
else if (process.platform === 'darwin') {
|
|
229
|
+
const observed = macObservation(pid);
|
|
230
|
+
if (observed.state === 'dead') return observed;
|
|
231
|
+
identity = observed.identity;
|
|
232
|
+
}
|
|
208
233
|
} catch {
|
|
209
234
|
identity = null;
|
|
210
235
|
}
|
|
@@ -217,8 +242,19 @@ export function inspectProcessIdentity(value) {
|
|
|
217
242
|
return { state: 'live', identity };
|
|
218
243
|
}
|
|
219
244
|
|
|
245
|
+
export function isProcessIdentityIncompatible(stored, current) {
|
|
246
|
+
if (!isCompleteIdentity(stored) || !isCompleteIdentity(current) || stored.pid !== current.pid) return false;
|
|
247
|
+
const storedFormat = macTokenFormat(stored.startToken), currentFormat = macTokenFormat(current.startToken);
|
|
248
|
+
return Boolean(storedFormat && currentFormat && storedFormat !== currentFormat);
|
|
249
|
+
}
|
|
250
|
+
|
|
220
251
|
export function compareProcessIdentity(stored, current) {
|
|
221
252
|
if (!isCompleteIdentity(stored) || !isCompleteIdentity(current)) return 'unknown';
|
|
222
|
-
|
|
223
|
-
|
|
253
|
+
if (stored.pid !== current.pid) return 'dead';
|
|
254
|
+
if (stored.startToken === current.startToken) return 'live';
|
|
255
|
+
// An upgrade cannot translate wall-clock boot evidence to a boot UUID.
|
|
256
|
+
// Preserve a still-present legacy owner until it exits normally; a format
|
|
257
|
+
// change proves neither ownership nor process exit.
|
|
258
|
+
if (isProcessIdentityIncompatible(stored, current)) return 'unknown';
|
|
259
|
+
return 'dead';
|
|
224
260
|
}
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { performance } from 'node:perf_hooks';
|
|
4
4
|
|
|
5
|
-
import { resolvePeerEvidence } from '../core/peers/evidence.mjs';
|
|
5
|
+
import { isPeerEvidenceCompatibilityProtected, resolvePeerEvidence } from '../core/peers/evidence.mjs';
|
|
6
6
|
import { inspectProcessIdentity } from '../process/identity.mjs';
|
|
7
7
|
|
|
8
8
|
function canonicalDirectories(values) {
|
|
@@ -261,6 +261,9 @@ export function collectBufferEvidence({
|
|
|
261
261
|
if (evidence.state === 'live') {
|
|
262
262
|
recordKnownEvidence(unknownTracker, file, 'live', monotonicNowMs);
|
|
263
263
|
addExternalGroup(protectedPaths, directory, id);
|
|
264
|
+
} else if (isPeerEvidenceCompatibilityProtected(evidence)) {
|
|
265
|
+
recordKnownEvidence(unknownTracker, file, 'incompatible', monotonicNowMs);
|
|
266
|
+
addExternalGroup(protectedPaths, directory, id);
|
|
264
267
|
} else if (evidence.state === 'unknown' &&
|
|
265
268
|
boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
|
|
266
269
|
addExternalGroup(unknownPaths, directory, id);
|
|
@@ -302,6 +305,9 @@ export function collectBufferEvidence({
|
|
|
302
305
|
if (evidence.state === 'live') {
|
|
303
306
|
recordKnownEvidence(unknownTracker, file, 'live', monotonicNowMs);
|
|
304
307
|
protectedPaths.add(file);
|
|
308
|
+
} else if (isPeerEvidenceCompatibilityProtected(evidence)) {
|
|
309
|
+
recordKnownEvidence(unknownTracker, file, 'incompatible', monotonicNowMs);
|
|
310
|
+
protectedPaths.add(file);
|
|
305
311
|
} else if (evidence.state === 'unknown' &&
|
|
306
312
|
boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
|
|
307
313
|
unknownPaths.add(file);
|
package/lib/runtime/client.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { CliError } from '../shared/errors.mjs';
|
|
|
2
2
|
import { readRuntime } from './state.mjs';
|
|
3
3
|
import { runtimeApiUrl, runtimeHttpRequest } from '../web/runtime.mjs';
|
|
4
4
|
import { withRuntimeApiVersionHeader } from '../web/api-version.mjs';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_CLI_NAME = 'hcc';
|
|
7
8
|
export const RUNTIME_SESSION_START_TIMEOUT_MS = 60_000;
|
|
@@ -56,7 +57,9 @@ export async function runtimeRequest(ctx, method, route, body = null, runtime =
|
|
|
56
57
|
let res;
|
|
57
58
|
let text;
|
|
58
59
|
const abortScope = requestAbortScope(opts.signal, opts.timeoutMs);
|
|
60
|
+
const startedAt = performance.now();
|
|
59
61
|
try {
|
|
62
|
+
abortScope.signal.throwIfAborted();
|
|
60
63
|
res = await waitForAbort(runtimeHttpRequest(rt, route, {
|
|
61
64
|
method,
|
|
62
65
|
headers,
|
|
@@ -68,7 +71,12 @@ export async function runtimeRequest(ctx, method, route, body = null, runtime =
|
|
|
68
71
|
} catch (err) {
|
|
69
72
|
throw new CliError('RUNTIME_UNREACHABLE', `Runtime is not reachable at ${rt.base_url}. Start ${cliName} web again.`, {
|
|
70
73
|
runtime: rt.source || rt.base_url,
|
|
71
|
-
message: err.message
|
|
74
|
+
message: err.message,
|
|
75
|
+
method,
|
|
76
|
+
path: url.pathname,
|
|
77
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
78
|
+
timeoutMs: opts.timeoutMs === undefined ? 8000 : opts.timeoutMs,
|
|
79
|
+
errorName: err.name || 'Error'
|
|
72
80
|
});
|
|
73
81
|
} finally {
|
|
74
82
|
abortScope.cleanup();
|
package/lib/runtime/state.mjs
CHANGED
|
@@ -50,7 +50,11 @@ export function classifyRuntimePointer(runtime, {
|
|
|
50
50
|
if (current?.state !== 'live' || !completeProcessIdentity(current.identity)) {
|
|
51
51
|
return { state: 'unknown', reclaimable: ageMs >= unknownGraceMs };
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
const comparison = compareProcessIdentity(stored, current.identity);
|
|
54
|
+
// A live PID observed with an incompatible identity format is not an aged
|
|
55
|
+
// missing/unknown owner. In particular, do not reap a pre-upgrade Mac owner.
|
|
56
|
+
if (comparison === 'unknown') return { state: 'unknown', reclaimable: false };
|
|
57
|
+
return comparison === 'live'
|
|
54
58
|
? { state: 'alive', reclaimable: false }
|
|
55
59
|
: { state: 'dead', reclaimable: true };
|
|
56
60
|
}
|
package/lib/web/runtime-main.mjs
CHANGED
|
@@ -123,6 +123,7 @@ import { createAutoAttach } from '../web/auto-attach.mjs';
|
|
|
123
123
|
import { createPtySessions } from '../web/pty-sessions.mjs';
|
|
124
124
|
import { createHttpRoutes } from '../web/http-routes.mjs';
|
|
125
125
|
import { createSessionSerialize } from '../web/session-serialize.mjs';
|
|
126
|
+
import { createTmuxClientSnapshot } from '../web/tmux-client-snapshot.mjs';
|
|
126
127
|
import { createTmuxStream } from '../web/tmux-stream.mjs';
|
|
127
128
|
import {
|
|
128
129
|
ensureTmuxAvailable,
|
|
@@ -231,6 +232,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
231
232
|
const { WebSocketServer } = await import('ws');
|
|
232
233
|
const pty = ptyModule.default || ptyModule;
|
|
233
234
|
const sessions = new Map();
|
|
235
|
+
const tmuxClientSnapshot = createTmuxClientSnapshot();
|
|
234
236
|
|
|
235
237
|
const {
|
|
236
238
|
sessionKey, sessionsForProject,
|
|
@@ -238,10 +240,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
238
240
|
serializeBindingSummary, serializeSession,
|
|
239
241
|
broadcast, hasOpenClients, closeSessionClients: closeSerializedSessionClients
|
|
240
242
|
} = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath,
|
|
241
|
-
localClientObservation:
|
|
242
|
-
try { return tmuxClientObservation(tmuxSessionNameForPane(session.pane)); }
|
|
243
|
-
catch { return { state: 'unknown', count: null }; }
|
|
244
|
-
}
|
|
243
|
+
localClientObservation: tmuxClientSnapshot.observe
|
|
245
244
|
});
|
|
246
245
|
const controlLease = createControlLease({ broadcast: (session) => {
|
|
247
246
|
for (const [actionToken, socket] of session.actionTokenSockets || []) {
|
|
@@ -638,6 +637,7 @@ const {
|
|
|
638
637
|
let cleanupPromise = null;
|
|
639
638
|
let shutdownExitCode = 0;
|
|
640
639
|
async function performRuntimeCleanup() {
|
|
640
|
+
tmuxClientSnapshot.close();
|
|
641
641
|
clearRuntime(ctx);
|
|
642
642
|
// rob-07: clear this runtime's per-project pointers for every registered
|
|
643
643
|
// project, not just the primary ctx, so sibling projects don't keep
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
|
|
4
|
+
const UNKNOWN = Object.freeze({ state: 'unknown', count: null });
|
|
5
|
+
const MAX_AGE_MS = 5_000;
|
|
6
|
+
const REFRESH_AFTER_MS = 2_000;
|
|
7
|
+
|
|
8
|
+
function parseSnapshot(stdout) {
|
|
9
|
+
const rows = new Map();
|
|
10
|
+
for (const line of String(stdout).split(/\r?\n/).filter(Boolean)) {
|
|
11
|
+
const match = /^(%\d+)\|(\$\d+)\|(\d+)$/.exec(line);
|
|
12
|
+
if (!match || !Number.isSafeInteger(Number(match[3]))) throw new Error('Invalid tmux client snapshot');
|
|
13
|
+
const [, pane, session, countText] = match;
|
|
14
|
+
const count = Number(countText);
|
|
15
|
+
const previous = rows.get(pane);
|
|
16
|
+
// Linked windows may repeat a pane within or across sessions. A display
|
|
17
|
+
// hint must not guess which session's local clients control that pane.
|
|
18
|
+
if (rows.has(pane) && (!previous || previous.session !== session || previous.count !== count)) {
|
|
19
|
+
rows.set(pane, null);
|
|
20
|
+
} else if (!rows.has(pane)) {
|
|
21
|
+
rows.set(pane, { session, count });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return rows;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Display-only observations. Destructive actions must continue to query live
|
|
28
|
+
// clients through strictTmuxClientObservation and verify their ownership.
|
|
29
|
+
// One asynchronous read covers every pane; serialization never waits for tmux.
|
|
30
|
+
export function createTmuxClientSnapshot({ run = execFile, now = () => performance.now() } = {}) {
|
|
31
|
+
let rows = new Map();
|
|
32
|
+
let observedAt = -Infinity;
|
|
33
|
+
let nextRefreshAt = -Infinity;
|
|
34
|
+
let pending = null;
|
|
35
|
+
let closed = false;
|
|
36
|
+
|
|
37
|
+
function refresh(at) {
|
|
38
|
+
if (closed || pending || at < nextRefreshAt) return;
|
|
39
|
+
const operation = { controller: new globalThis.AbortController() };
|
|
40
|
+
pending = operation;
|
|
41
|
+
nextRefreshAt = at + REFRESH_AFTER_MS;
|
|
42
|
+
const complete = (error, stdout) => {
|
|
43
|
+
if (pending !== operation) return;
|
|
44
|
+
pending = null;
|
|
45
|
+
if (closed || operation.controller.signal.aborted) return;
|
|
46
|
+
nextRefreshAt = now() + REFRESH_AFTER_MS;
|
|
47
|
+
try {
|
|
48
|
+
if (error) throw error;
|
|
49
|
+
rows = parseSnapshot(stdout);
|
|
50
|
+
observedAt = at;
|
|
51
|
+
} catch {
|
|
52
|
+
rows = new Map();
|
|
53
|
+
observedAt = -Infinity;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
run('tmux', ['list-panes', '-a', '-F', '#{pane_id}|#{session_id}|#{session_attached}'], {
|
|
58
|
+
encoding: 'utf8', timeout: 1_000, maxBuffer: 1024 * 1024,
|
|
59
|
+
signal: operation.controller.signal
|
|
60
|
+
}, complete);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
complete(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function observe(session) {
|
|
67
|
+
if (closed || session?.type !== 'tmux' || !/^%\d+$/.test(session.pane || '')) return UNKNOWN;
|
|
68
|
+
const at = now();
|
|
69
|
+
refresh(at);
|
|
70
|
+
const row = rows.get(session.pane);
|
|
71
|
+
if (!row || at - observedAt >= MAX_AGE_MS) return UNKNOWN;
|
|
72
|
+
return { state: 'known', count: row.count };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function close() {
|
|
76
|
+
closed = true;
|
|
77
|
+
rows = new Map();
|
|
78
|
+
observedAt = -Infinity;
|
|
79
|
+
const operation = pending;
|
|
80
|
+
pending = null;
|
|
81
|
+
operation?.controller.abort();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { observe, close };
|
|
85
|
+
}
|
package/package.json
CHANGED
package/scripts/regression.mjs
CHANGED
|
@@ -8,12 +8,14 @@ import path from 'node:path';
|
|
|
8
8
|
import process from 'node:process';
|
|
9
9
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
|
+
import { performance } from 'node:perf_hooks';
|
|
11
12
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
12
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
13
14
|
import { Readable } from 'node:stream';
|
|
14
15
|
import { pathToFileURL } from 'node:url';
|
|
15
16
|
import WebSocket from 'ws';
|
|
16
|
-
import { inspectProcessIdentity } from '../lib/process/identity.mjs';
|
|
17
|
+
import { inspectProcessIdentity, compareProcessIdentity } from '../lib/process/identity.mjs';
|
|
18
|
+
import { redactSecrets } from '../lib/shared/redact.mjs';
|
|
17
19
|
import { applyBufferPlan, planBufferFiles } from '../lib/runtime/buffer-gc.mjs';
|
|
18
20
|
|
|
19
21
|
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
@@ -29,14 +31,16 @@ const secondProjectRoot = path.join(root, 'second-project');
|
|
|
29
31
|
const TERMINAL_WEBSOCKET_TIMEOUT_MS = 15_000;
|
|
30
32
|
const realHome = process.env.HOME || os.homedir();
|
|
31
33
|
const realRegistryFile = path.join(realHome, '.hello-cc', 'projects.json');
|
|
32
|
-
|
|
33
|
-
encoding: 'utf8',
|
|
34
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
35
|
-
}).stdout.trim();
|
|
34
|
+
let realTmuxBin = '';
|
|
36
35
|
const port = 22000 + (process.pid % 10000);
|
|
37
36
|
|
|
38
37
|
let tmuxStarted = false;
|
|
39
38
|
let runtimePid = null;
|
|
39
|
+
let runtimeOwner = null;
|
|
40
|
+
let currentStage = 'startup';
|
|
41
|
+
let lastStopDiagnostic = null;
|
|
42
|
+
const diagnosticSecrets = new Set();
|
|
43
|
+
const fixtureFailures = new WeakSet();
|
|
40
44
|
const managedTmuxSessions = new Set();
|
|
41
45
|
|
|
42
46
|
const env = {
|
|
@@ -53,18 +57,194 @@ delete env.TMUX;
|
|
|
53
57
|
delete env.TMUX_PANE;
|
|
54
58
|
|
|
55
59
|
function log(message) {
|
|
60
|
+
currentStage = String(message);
|
|
56
61
|
process.stdout.write(`${message}\n`);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
function fail(message) {
|
|
65
|
+
const error = fixtureFailureError(message);
|
|
60
66
|
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
61
|
-
const escaped =
|
|
67
|
+
const escaped = fixtureDiagnosticText({ stage: currentStage, failure: fixtureFailureDiagnostic(error) }, diagnosticSecrets)
|
|
62
68
|
.replace(/%/g, '%25')
|
|
63
69
|
.replace(/\r/g, '%0D')
|
|
64
70
|
.replace(/\n/g, '%0A');
|
|
65
71
|
process.stderr.write(`::error::${escaped}\n`);
|
|
66
72
|
}
|
|
67
|
-
throw
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function fixtureFailureError(message) {
|
|
77
|
+
const error = new Error(String(message));
|
|
78
|
+
Object.defineProperty(error, 'fixtureStack', {
|
|
79
|
+
value: Object.freeze(new Error('fixture failure').stack.split('\n').slice(1, 7))
|
|
80
|
+
});
|
|
81
|
+
fixtureFailures.add(error);
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fixtureFailureDiagnostic(error) {
|
|
86
|
+
const names = ['Error', 'TypeError', 'RangeError', 'SyntaxError', 'AssertionError', 'AbortError'];
|
|
87
|
+
return {
|
|
88
|
+
name: names.includes(error?.name) ? error.name : 'Error',
|
|
89
|
+
stack: fixtureFailures.has(error)
|
|
90
|
+
? error.fixtureStack
|
|
91
|
+
: new Error('regression failure handler').stack.split('\n').slice(1, 7)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function fixtureDiagnosticText(value, secrets = []) {
|
|
96
|
+
function scrub(text) {
|
|
97
|
+
for (const secret of secrets) {
|
|
98
|
+
if (typeof secret === 'string' && secret.length >= 8) text = text.split(secret).join('[REDACTED]');
|
|
99
|
+
}
|
|
100
|
+
return redactSecrets(text)
|
|
101
|
+
.replace(/("(?:[^"\\]*token|authorization|cookie|[^"\\]*secret|password)"\s*:\s*)"(?:\\.|[^"\\])*"/gi, '$1"[REDACTED]"');
|
|
102
|
+
}
|
|
103
|
+
function visit(entry) {
|
|
104
|
+
if (typeof entry === 'string') return scrub(entry);
|
|
105
|
+
if (Array.isArray(entry)) return entry.map(visit);
|
|
106
|
+
if (entry && typeof entry === 'object') return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, visit(item)]));
|
|
107
|
+
return entry;
|
|
108
|
+
}
|
|
109
|
+
return typeof value === 'string' ? scrub(value) : JSON.stringify(visit(redactSecrets(value)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fixturePsState(pid) {
|
|
113
|
+
const result = spawnSync('ps', ['-p', String(pid), '-o', 'stat='], {
|
|
114
|
+
encoding: 'utf8', timeout: 1000, stdio: ['ignore', 'pipe', 'ignore']
|
|
115
|
+
});
|
|
116
|
+
const state = String(result.stdout || '').trim();
|
|
117
|
+
return result.status === 0 && /^[A-Za-z+<>NslLWE-]{1,16}$/.test(state) ? state : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function observeFixtureOwner(owner, { inspect = inspectProcessIdentity, psState = fixturePsState } = {}) {
|
|
121
|
+
if (compareProcessIdentity(owner, owner) !== 'live') return { state: 'unknown', reason: 'missing_owner_identity' };
|
|
122
|
+
const observed = inspect(owner.pid);
|
|
123
|
+
const state = psState(owner.pid);
|
|
124
|
+
const details = { pid: owner.pid, psState: state, observedState: observed.state };
|
|
125
|
+
if (observed.state === 'dead') return { ...details, state: 'exited', reason: 'process_dead' };
|
|
126
|
+
if (/^[ZX]/i.test(state || '')) return { ...details, state: 'exited', reason: 'zombie_or_dead' };
|
|
127
|
+
if (observed.state !== 'live' || compareProcessIdentity(observed.identity, observed.identity) !== 'live') {
|
|
128
|
+
return { ...details, state: 'unknown', reason: 'identity_unavailable' };
|
|
129
|
+
}
|
|
130
|
+
details.birthHash = crypto.createHash('sha256').update(observed.identity.startToken).digest('hex');
|
|
131
|
+
details.commandHash = observed.identity.commandHash;
|
|
132
|
+
if (compareProcessIdentity(owner, observed.identity) === 'dead') return { ...details, state: 'exited', reason: 'pid_reused' };
|
|
133
|
+
if (owner.commandHash !== observed.identity.commandHash) return { ...details, state: 'unknown', reason: 'command_changed' };
|
|
134
|
+
return { ...details, state: 'same-owner', reason: 'identity_match' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function signalFixtureOwner(owner, signal, { observe = observeFixtureOwner, send = process.kill.bind(process) } = {}) {
|
|
138
|
+
const observation = observe(owner);
|
|
139
|
+
if (observation.state !== 'same-owner') return { signal, sent: false, observation };
|
|
140
|
+
try { send(owner.pid, signal); return { signal, sent: true, observation }; }
|
|
141
|
+
catch (error) { return { signal, sent: false, observation, code: error.code || 'SIGNAL_FAILED' }; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function waitForFixtureOwnerExit(owner, timeoutMs, {
|
|
145
|
+
observe = observeFixtureOwner, monotonicNow = () => performance.now(), wait = sleep
|
|
146
|
+
} = {}) {
|
|
147
|
+
const deadline = monotonicNow() + timeoutMs;
|
|
148
|
+
for (;;) {
|
|
149
|
+
const observation = observe(owner);
|
|
150
|
+
if (observation.state === 'exited') return observation;
|
|
151
|
+
const remaining = deadline - monotonicNow();
|
|
152
|
+
if (remaining <= 0) {
|
|
153
|
+
const error = new Error('runtime fixture owner did not exit before deadline');
|
|
154
|
+
error.observation = observation;
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
await wait(Math.min(100, remaining));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fixtureErrorPayload(output) {
|
|
162
|
+
const lines = String(output || '').split(/\r?\n/);
|
|
163
|
+
let start = null, standalone = false, quoted = false, escaped = false;
|
|
164
|
+
const stack = [];
|
|
165
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
166
|
+
const line = lines[lineIndex];
|
|
167
|
+
for (let offset = 0; offset < line.length; offset += 1) {
|
|
168
|
+
const char = line[offset];
|
|
169
|
+
if (start === null) {
|
|
170
|
+
if (char !== '{' && char !== '[') continue;
|
|
171
|
+
start = lineIndex;
|
|
172
|
+
standalone = line.slice(0, offset).trim() === '';
|
|
173
|
+
}
|
|
174
|
+
if (quoted) {
|
|
175
|
+
if (escaped) escaped = false;
|
|
176
|
+
else if (char === '\\') escaped = true;
|
|
177
|
+
else if (char === '"') quoted = false;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (char === '"') quoted = true;
|
|
181
|
+
else if (char === '{' || char === '[') stack.push(char);
|
|
182
|
+
else if (char === '}' || char === ']') {
|
|
183
|
+
if (stack.pop() !== (char === '}' ? '{' : '[')) return null;
|
|
184
|
+
if (stack.length) continue;
|
|
185
|
+
// Consume arrays and prefixed JSON blocks as a unit, so their nested
|
|
186
|
+
// objects can never masquerade as independent CLI error documents.
|
|
187
|
+
if (standalone && line.slice(offset + 1).trim() === '') {
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(lines.slice(start, lineIndex + 1).join('\n'));
|
|
190
|
+
if (parsed && !Array.isArray(parsed) && parsed.error &&
|
|
191
|
+
typeof parsed.error === 'object' && !Array.isArray(parsed.error)) return parsed;
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
start = null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function fixtureDownResult(result) {
|
|
202
|
+
// Public --json failures share stderr with Node warnings. Accept only whole
|
|
203
|
+
// independent JSON objects; ordinary output never enters the diagnostic.
|
|
204
|
+
const payload = fixtureErrorPayload(result.stdout) || fixtureErrorPayload(result.stderr);
|
|
205
|
+
const extra = payload?.error?.extra || payload?.error || {};
|
|
206
|
+
const code = payload?.error?.code || result.error?.code;
|
|
207
|
+
const numeric = ['pid', 'elapsedMs', 'timeoutMs'];
|
|
208
|
+
return {
|
|
209
|
+
status: Number.isSafeInteger(result.status) ? result.status : null,
|
|
210
|
+
signal: /^SIG[A-Z0-9]{1,12}$/.test(result.signal || '') ? result.signal : null,
|
|
211
|
+
code: typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,79}$/.test(code) ? code : null,
|
|
212
|
+
extra: Object.fromEntries(numeric.filter(key => typeof extra[key] === 'number' &&
|
|
213
|
+
Number.isFinite(extra[key]) && extra[key] >= 0).map(key => [key, extra[key]]))
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function fixtureStopEnvironment(base) {
|
|
218
|
+
const local = { ...base, HCC_RUNTIME_LOCAL_ONLY: '1' };
|
|
219
|
+
delete local.HCC_RUNTIME_URL;
|
|
220
|
+
delete local.HCC_RUNTIME_TOKEN;
|
|
221
|
+
return local;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function rememberRuntimePid(pid) {
|
|
225
|
+
runtimePid = pid;
|
|
226
|
+
runtimeOwner = null;
|
|
227
|
+
try {
|
|
228
|
+
const pointer = currentRuntime();
|
|
229
|
+
if (pointer.token) {
|
|
230
|
+
diagnosticSecrets.add(pointer.token);
|
|
231
|
+
for (const part of pointer.token.split(/\s+/)) if (part.length >= 8) diagnosticSecrets.add(part);
|
|
232
|
+
}
|
|
233
|
+
if (pointer.process_identity?.pid === pid && compareProcessIdentity(pointer.process_identity, pointer.process_identity) === 'live') {
|
|
234
|
+
runtimeOwner = { ...pointer.process_identity };
|
|
235
|
+
}
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function collectRuntimeDiagnostic(extra = {}) {
|
|
240
|
+
const diagnostic = { stage: currentStage, pid: runtimePid,
|
|
241
|
+
owner: observeFixtureOwner(runtimeOwner), pointerExists: fs.existsSync(path.join(root, '.hello-cc', 'runtime.json')), ...extra };
|
|
242
|
+
try {
|
|
243
|
+
const db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'), { readOnly: true, timeout: 200 });
|
|
244
|
+
try { diagnostic.events = db.prepare('SELECT id, type FROM events ORDER BY id DESC LIMIT 12').all(); }
|
|
245
|
+
finally { db.close(); }
|
|
246
|
+
} catch {}
|
|
247
|
+
return fixtureDiagnosticText(diagnostic, diagnosticSecrets);
|
|
68
248
|
}
|
|
69
249
|
|
|
70
250
|
function commandText(command, args) {
|
|
@@ -1587,15 +1767,7 @@ function assertHtmlCsp(response, html, label) {
|
|
|
1587
1767
|
}
|
|
1588
1768
|
|
|
1589
1769
|
function processIsAlive(pid) {
|
|
1590
|
-
|
|
1591
|
-
process.kill(pid, 0);
|
|
1592
|
-
// Linux keeps an exited child addressable until PID 1 reaps its zombie.
|
|
1593
|
-
// Product liveness treats that state as dead, and the regression wait must
|
|
1594
|
-
// use the same semantic boundary instead of waiting forever on kill(0).
|
|
1595
|
-
return process.platform !== 'linux' || inspectProcessIdentity(pid).state !== 'dead';
|
|
1596
|
-
} catch {
|
|
1597
|
-
return false;
|
|
1598
|
-
}
|
|
1770
|
+
return inspectProcessIdentity(pid).state !== 'dead' && !/^[ZX]/i.test(fixturePsState(pid) || '');
|
|
1599
1771
|
}
|
|
1600
1772
|
|
|
1601
1773
|
async function waitForProcessExit(pid, label, timeoutMs = 5000) {
|
|
@@ -2546,7 +2718,7 @@ function startRuntime(options = {}) {
|
|
|
2546
2718
|
const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], { env: runtimeEnv });
|
|
2547
2719
|
const match = output.match(/^pid:\s*(\d+)/m);
|
|
2548
2720
|
if (!match) fail(`hcc web did not print background pid:\n${output}`);
|
|
2549
|
-
|
|
2721
|
+
rememberRuntimePid(Number.parseInt(match[1], 10));
|
|
2550
2722
|
if (!output.includes('web started in background')) fail(`hcc web did not report background start:\n${output}`);
|
|
2551
2723
|
}
|
|
2552
2724
|
|
|
@@ -2644,7 +2816,7 @@ async function webSecretRedactionWorkflow() {
|
|
|
2644
2816
|
]);
|
|
2645
2817
|
const match = output.match(/^pid:\s*(\d+)/m);
|
|
2646
2818
|
if (!match) fail(`redaction web did not print background pid`);
|
|
2647
|
-
|
|
2819
|
+
rememberRuntimePid(Number.parseInt(match[1], 10));
|
|
2648
2820
|
|
|
2649
2821
|
try {
|
|
2650
2822
|
await waitRuntime();
|
|
@@ -2738,6 +2910,7 @@ async function assertWebWrapperParentSurvives() {
|
|
|
2738
2910
|
const wrapperRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-wrapper-root-${testId}-`));
|
|
2739
2911
|
const wrapperPort = port + 101;
|
|
2740
2912
|
let wrapperPid = null;
|
|
2913
|
+
let wrapperOwner = null;
|
|
2741
2914
|
try {
|
|
2742
2915
|
const script = [
|
|
2743
2916
|
'set -e',
|
|
@@ -2752,14 +2925,15 @@ async function assertWebWrapperParentSurvives() {
|
|
|
2752
2925
|
if (!match) fail(`wrapper hcc web did not print background pid:\n${result.stdout}`);
|
|
2753
2926
|
wrapperPid = Number.parseInt(match[1], 10);
|
|
2754
2927
|
ensureFile(path.join(wrapperRoot, '.hello-cc', 'runtime.json'));
|
|
2928
|
+
wrapperOwner = JSON.parse(fs.readFileSync(path.join(wrapperRoot, '.hello-cc', 'runtime.json'), 'utf8')).process_identity;
|
|
2755
2929
|
} finally {
|
|
2756
|
-
runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env });
|
|
2930
|
+
runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env: fixtureStopEnvironment(env) });
|
|
2757
2931
|
if (wrapperPid) {
|
|
2758
2932
|
try {
|
|
2759
|
-
await
|
|
2933
|
+
await waitForFixtureOwnerExit(wrapperOwner, 5000);
|
|
2760
2934
|
} catch {
|
|
2761
|
-
|
|
2762
|
-
try { await
|
|
2935
|
+
signalFixtureOwner(wrapperOwner, 'SIGTERM');
|
|
2936
|
+
try { await waitForFixtureOwnerExit(wrapperOwner, 2000); } catch {}
|
|
2763
2937
|
}
|
|
2764
2938
|
}
|
|
2765
2939
|
try { fs.rmSync(wrapperRoot, { recursive: true, force: true }); } catch {}
|
|
@@ -2768,28 +2942,42 @@ async function assertWebWrapperParentSurvives() {
|
|
|
2768
2942
|
|
|
2769
2943
|
async function stopRuntime() {
|
|
2770
2944
|
if (!runtimePid) return;
|
|
2771
|
-
const
|
|
2772
|
-
|
|
2945
|
+
const owner = runtimeOwner;
|
|
2946
|
+
const before = observeFixtureOwner(owner);
|
|
2947
|
+
const down = fixtureDownResult(stopFixtureCli());
|
|
2948
|
+
lastStopDiagnostic = { before, down, stack: new Error('stopRuntime').stack.split('\n').slice(1, 7), signals: [] };
|
|
2773
2949
|
try {
|
|
2774
|
-
await
|
|
2950
|
+
await waitForFixtureOwnerExit(owner, 5000);
|
|
2775
2951
|
} catch (err) {
|
|
2776
|
-
|
|
2952
|
+
lastStopDiagnostic.atDeadline = collectRuntimeDiagnostic({ before, down });
|
|
2953
|
+
lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGTERM'));
|
|
2777
2954
|
try {
|
|
2778
|
-
await
|
|
2955
|
+
await waitForFixtureOwnerExit(owner, 2000);
|
|
2779
2956
|
} catch {
|
|
2780
|
-
|
|
2781
|
-
try { await
|
|
2782
|
-
throw err;
|
|
2957
|
+
lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGKILL'));
|
|
2958
|
+
try { await waitForFixtureOwnerExit(owner, 2000); } catch {}
|
|
2783
2959
|
}
|
|
2960
|
+
fail(`runtime stop failed: ${collectRuntimeDiagnostic(lastStopDiagnostic)}`);
|
|
2784
2961
|
}
|
|
2962
|
+
if (down.status !== 0) fail(`runtime down command failed: ${collectRuntimeDiagnostic(lastStopDiagnostic)}`);
|
|
2785
2963
|
runtimePid = null;
|
|
2964
|
+
runtimeOwner = null;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
function stopFixtureCli() {
|
|
2968
|
+
try {
|
|
2969
|
+
const pointer = currentRuntime();
|
|
2970
|
+
if (compareProcessIdentity(runtimeOwner, pointer.process_identity) === 'live' &&
|
|
2971
|
+
runtimeOwner.commandHash === pointer.process_identity.commandHash) {
|
|
2972
|
+
return hccMaybe(['--json', 'down'], { env: fixtureStopEnvironment(env) });
|
|
2973
|
+
}
|
|
2974
|
+
} catch {}
|
|
2975
|
+
return { status: null, error: { code: 'FIXTURE_OWNER_POINTER_UNAVAILABLE' } };
|
|
2786
2976
|
}
|
|
2787
2977
|
|
|
2788
2978
|
function cleanup() {
|
|
2789
|
-
try {
|
|
2790
|
-
if (runtimePid)
|
|
2791
|
-
try { process.kill(runtimePid, 'SIGTERM'); } catch {}
|
|
2792
|
-
}
|
|
2979
|
+
try { stopFixtureCli(); } catch {}
|
|
2980
|
+
if (runtimePid) signalFixtureOwner(runtimeOwner, 'SIGTERM');
|
|
2793
2981
|
const managedPrefixes = [
|
|
2794
2982
|
tmuxManagedSessionPrefix(root),
|
|
2795
2983
|
tmuxManagedSessionPrefix(secondProjectRoot)
|
|
@@ -2877,7 +3065,7 @@ async function setupRegression() {
|
|
|
2877
3065
|
const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
2878
3066
|
const match = output.match(/^pid:\s*(\d+)/m);
|
|
2879
3067
|
if (!match) fail(`hcc web did not print background pid during bootstrap:\n${output}`);
|
|
2880
|
-
|
|
3068
|
+
rememberRuntimePid(Number.parseInt(match[1], 10));
|
|
2881
3069
|
await waitRuntime();
|
|
2882
3070
|
for (const file of liveBufferFiles) ensureFile(file);
|
|
2883
3071
|
for (const file of liveBufferFiles) fs.rmSync(file, { force: true });
|
|
@@ -2957,7 +3145,7 @@ async function setupRegression() {
|
|
|
2957
3145
|
const directTlsOutput = hcc(['web', '--local', '--tls', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
2958
3146
|
const directTlsMatch = directTlsOutput.match(/^pid:\s*(\d+)/m);
|
|
2959
3147
|
if (!directTlsMatch) fail(`direct TLS web did not print background pid:\n${directTlsOutput}`);
|
|
2960
|
-
|
|
3148
|
+
rememberRuntimePid(Number.parseInt(directTlsMatch[1], 10));
|
|
2961
3149
|
await waitFor(async () => {
|
|
2962
3150
|
const runtimeFile = path.join(root, '.hello-cc', 'runtime.json');
|
|
2963
3151
|
if (!fs.existsSync(runtimeFile)) return false;
|
|
@@ -3016,7 +3204,7 @@ async function setupRegression() {
|
|
|
3016
3204
|
const tokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
3017
3205
|
const tokenMatch = tokenOutput.match(/^pid:\s*(\d+)/m);
|
|
3018
3206
|
if (!tokenMatch) fail(`token web did not print background pid:\n${tokenOutput}`);
|
|
3019
|
-
|
|
3207
|
+
rememberRuntimePid(Number.parseInt(tokenMatch[1], 10));
|
|
3020
3208
|
if (!tokenOutput.includes('token=') || !tokenOutput.includes('open: http://<machine-ip>:')) {
|
|
3021
3209
|
fail(`default web output did not include remote token URL:\n${tokenOutput}`);
|
|
3022
3210
|
}
|
|
@@ -3200,7 +3388,7 @@ async function setupRegression() {
|
|
|
3200
3388
|
]);
|
|
3201
3389
|
const proxyPidMatch = proxyOutput.match(/^pid:\s*(\d+)/m);
|
|
3202
3390
|
if (!proxyPidMatch) fail(`trusted-proxy web did not print background pid:\n${proxyOutput}`);
|
|
3203
|
-
|
|
3391
|
+
rememberRuntimePid(Number.parseInt(proxyPidMatch[1], 10));
|
|
3204
3392
|
await waitRuntime();
|
|
3205
3393
|
const proxyRuntime = currentRuntime();
|
|
3206
3394
|
if (proxyRuntime.proxy_origin !== proxyOrigin || proxyRuntime.trust_proxy !== true) {
|
|
@@ -3286,7 +3474,7 @@ async function setupRegression() {
|
|
|
3286
3474
|
]);
|
|
3287
3475
|
const restoredPidMatch = restoredOutput.match(/^pid:\s*(\d+)/m);
|
|
3288
3476
|
if (!restoredPidMatch) fail(`post-proxy web did not print background pid:\n${restoredOutput}`);
|
|
3289
|
-
|
|
3477
|
+
rememberRuntimePid(Number.parseInt(restoredPidMatch[1], 10));
|
|
3290
3478
|
await waitRuntime();
|
|
3291
3479
|
|
|
3292
3480
|
const badJsonResponse = await runtimeFetch('/api/projects', {
|
|
@@ -3313,7 +3501,7 @@ async function setupRegression() {
|
|
|
3313
3501
|
const stableTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
3314
3502
|
const stableTokenMatch = stableTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
3315
3503
|
if (!stableTokenMatch) fail(`stable-token web did not print background pid:\n${stableTokenOutput}`);
|
|
3316
|
-
|
|
3504
|
+
rememberRuntimePid(Number.parseInt(stableTokenMatch[1], 10));
|
|
3317
3505
|
await waitRuntime();
|
|
3318
3506
|
const stableTokenRuntime = currentRuntime();
|
|
3319
3507
|
if (stableTokenRuntime.token === tokenRuntime.token) {
|
|
@@ -3327,7 +3515,7 @@ async function setupRegression() {
|
|
|
3327
3515
|
});
|
|
3328
3516
|
const fixedTokenMatch = fixedTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
3329
3517
|
if (!fixedTokenMatch) fail(`fixed-token web did not print background pid:\n${fixedTokenOutput}`);
|
|
3330
|
-
|
|
3518
|
+
rememberRuntimePid(Number.parseInt(fixedTokenMatch[1], 10));
|
|
3331
3519
|
await waitRuntime();
|
|
3332
3520
|
const fixedTokenRuntime = currentRuntime();
|
|
3333
3521
|
if (fixedTokenRuntime.token !== fixedToken || !fixedTokenOutput.includes(`token=${encodeURIComponent(fixedToken)}`)) {
|
|
@@ -3338,7 +3526,7 @@ async function setupRegression() {
|
|
|
3338
3526
|
const postFixedOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
3339
3527
|
const postFixedMatch = postFixedOutput.match(/^pid:\s*(\d+)/m);
|
|
3340
3528
|
if (!postFixedMatch) fail(`post-fixed-token web did not print background pid:\n${postFixedOutput}`);
|
|
3341
|
-
|
|
3529
|
+
rememberRuntimePid(Number.parseInt(postFixedMatch[1], 10));
|
|
3342
3530
|
await waitRuntime();
|
|
3343
3531
|
const postFixedRuntime = currentRuntime();
|
|
3344
3532
|
if (postFixedRuntime.token === fixedToken || fs.existsSync(tokenFile)) {
|
|
@@ -3351,7 +3539,7 @@ async function setupRegression() {
|
|
|
3351
3539
|
const noTokenOutput = hcc(['web', '--local', '--port', String(port), '--no-token', '--no-discover', '--no-guidance']);
|
|
3352
3540
|
const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
3353
3541
|
if (!noTokenMatch) fail(`explicit no-token web did not print background pid:\n${noTokenOutput}`);
|
|
3354
|
-
|
|
3542
|
+
rememberRuntimePid(Number.parseInt(noTokenMatch[1], 10));
|
|
3355
3543
|
if (noTokenOutput.includes('token=')) {
|
|
3356
3544
|
fail(`explicit no-token web output included token:\n${noTokenOutput}`);
|
|
3357
3545
|
}
|
|
@@ -11158,11 +11346,16 @@ function gcCoverageWorkflow() {
|
|
|
11158
11346
|
}
|
|
11159
11347
|
|
|
11160
11348
|
async function main() {
|
|
11349
|
+
realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
|
|
11350
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
|
|
11351
|
+
}).stdout.trim();
|
|
11161
11352
|
process.once('SIGINT', () => { cleanup(); process.exit(130); });
|
|
11162
11353
|
process.once('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
11163
11354
|
|
|
11164
11355
|
await setupRegression();
|
|
11356
|
+
currentStage = 'webSecretRedactionWorkflow';
|
|
11165
11357
|
await webSecretRedactionWorkflow();
|
|
11358
|
+
currentStage = 'cookieSessionExpiryWorkflow';
|
|
11166
11359
|
await cookieSessionExpiryWorkflow();
|
|
11167
11360
|
log('[2/13] runtime');
|
|
11168
11361
|
startRuntime();
|
|
@@ -11194,12 +11387,18 @@ async function main() {
|
|
|
11194
11387
|
log('FULL_REGRESSION_OK');
|
|
11195
11388
|
}
|
|
11196
11389
|
|
|
11197
|
-
main().catch((err) => {
|
|
11390
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) main().catch((err) => {
|
|
11391
|
+
const failure = fixtureFailureDiagnostic(err);
|
|
11392
|
+
const diagnostic = collectRuntimeDiagnostic({ failure, stop: lastStopDiagnostic });
|
|
11393
|
+
process.stderr.write(`[regression failure diagnostic] ${diagnostic}\n`);
|
|
11394
|
+
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
11395
|
+
process.stderr.write(`::error::${diagnostic.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')}\n`);
|
|
11396
|
+
}
|
|
11198
11397
|
try { assertNoRealProjectRegistryLeak(); } catch (leakErr) {
|
|
11199
|
-
process.stderr.write(`${leakErr.stack || leakErr.message}\n`);
|
|
11398
|
+
process.stderr.write(`${fixtureDiagnosticText(leakErr.stack || leakErr.message, diagnosticSecrets)}\n`);
|
|
11200
11399
|
}
|
|
11201
11400
|
cleanup();
|
|
11202
|
-
process.stderr.write(`${err.stack || err.message}\n`);
|
|
11401
|
+
process.stderr.write(`${fixtureDiagnosticText(err.stack || err.message, diagnosticSecrets)}\n`);
|
|
11203
11402
|
process.exit(1);
|
|
11204
11403
|
}).finally(() => {
|
|
11205
11404
|
try {
|