@logicseek/hello-cc 1.0.4 → 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 +35 -0
- package/docs/dsh.md +3 -1
- package/docs/dsh.zh-CN.md +3 -1
- 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 +33 -18
- package/lib/runtime/buffer-evidence.mjs +7 -1
- package/lib/runtime/state.mjs +5 -1
- package/package.json +1 -1
- package/scripts/regression.mjs +43 -2
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,41 @@ 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
|
+
|
|
19
54
|
## 1.0.4
|
|
20
55
|
|
|
21
56
|
### 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:
|
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 时以该目录为准。保留文件中已有设置,只批准候选包使用的固定版本:
|
|
@@ -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,17 +84,20 @@ 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
103
|
function parseMacStateStart(text) {
|
|
@@ -106,9 +109,11 @@ function parseMacStateStart(text) {
|
|
|
106
109
|
|
|
107
110
|
function macObservation(pid) {
|
|
108
111
|
const unknown = { state: 'unknown', identity: null };
|
|
109
|
-
|
|
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']);
|
|
110
115
|
// Launchers often omit /usr/sbin from PATH; macOS still provides sysctl here.
|
|
111
|
-
if (boot.error?.code === 'ENOENT') boot = run('/usr/sbin/sysctl', ['-n', 'kern.
|
|
116
|
+
if (boot.error?.code === 'ENOENT') boot = run('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid']);
|
|
112
117
|
if (boot.error || boot.status !== 0) return unknown;
|
|
113
118
|
const bootToken = parseMacBootToken(boot.stdout);
|
|
114
119
|
if (!bootToken) return unknown;
|
|
@@ -140,7 +145,7 @@ function macObservation(pid) {
|
|
|
140
145
|
state: 'live',
|
|
141
146
|
identity: {
|
|
142
147
|
pid,
|
|
143
|
-
startToken:
|
|
148
|
+
startToken: `darwin:${bootToken}:${parsed.startToken}`,
|
|
144
149
|
commandHash: hashCommand(parsed.command)
|
|
145
150
|
}
|
|
146
151
|
};
|
|
@@ -187,10 +192,9 @@ export async function waitForProcessIdentityExit(value, options = {}) {
|
|
|
187
192
|
const observed = inspect(pid);
|
|
188
193
|
if (observed?.state === 'dead') return { state: 'dead', identity: null };
|
|
189
194
|
if (observed?.state === 'live' && isCompleteIdentity(observed.identity)) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
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 };
|
|
194
198
|
} else {
|
|
195
199
|
lastObservation = { state: 'unknown', identity: null };
|
|
196
200
|
}
|
|
@@ -238,8 +242,19 @@ export function inspectProcessIdentity(value) {
|
|
|
238
242
|
return { state: 'live', identity };
|
|
239
243
|
}
|
|
240
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
|
+
|
|
241
251
|
export function compareProcessIdentity(stored, current) {
|
|
242
252
|
if (!isCompleteIdentity(stored) || !isCompleteIdentity(current)) return 'unknown';
|
|
243
|
-
|
|
244
|
-
|
|
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';
|
|
245
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/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/package.json
CHANGED
package/scripts/regression.mjs
CHANGED
|
@@ -158,9 +158,50 @@ export async function waitForFixtureOwnerExit(owner, timeoutMs, {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
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
|
+
|
|
161
201
|
export function fixtureDownResult(result) {
|
|
162
|
-
|
|
163
|
-
|
|
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);
|
|
164
205
|
const extra = payload?.error?.extra || payload?.error || {};
|
|
165
206
|
const code = payload?.error?.code || result.error?.code;
|
|
166
207
|
const numeric = ['pid', 'elapsedMs', 'timeoutMs'];
|