@linxin666/dsh-client-ui-task-board 0.2.6 → 0.2.8
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/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/index.js +89 -6
- package/lib/types/host-ledger.d.ts +9 -0
- package/package.json +17 -14
- package/src/host-ledger.ts +105 -12
- package/src/index.ts +2 -2
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# node scripts/verify-docs.mjs --write <dir>
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: d65b9b3213fd504715bffb3a00be17074703e8c6
|
|
6
|
+
README.zh.md: 09705ff5ff6cfdc3df7896cd0af6489c35fbe174
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-task-board
|
|
|
52
52
|
| Key | Default | Behavior |
|
|
53
53
|
| --- | --- | --- |
|
|
54
54
|
| `enabled` | `true` | Enables the Host service and browser board. |
|
|
55
|
-
| `announceToAgent` | `
|
|
55
|
+
| `announceToAgent` | `false` | Opt-in: when true, adds the task-board guidance section to agent system prompts. |
|
|
56
56
|
| `preventIdleSleep` | `false` | Holds one system idle-sleep assertion while any DSH session runs, any schedule is enabled, or session state is unknown. |
|
|
57
57
|
| `trustedProxyHosts` | `[]` | Canonical `host[:port]` authorities accepted only through the authenticated loopback reverse-proxy path. |
|
|
58
58
|
| `proxyTokenEnv` | `DSH_TASK_BOARD_PROXY_TOKEN` | Environment variable containing the reverse-proxy token; the token itself is never stored in plugin config. |
|
package/README.zh.md
CHANGED
|
@@ -52,7 +52,7 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-task-board
|
|
|
52
52
|
| 键 | 默认值 | 行为 |
|
|
53
53
|
| --- | --- | --- |
|
|
54
54
|
| `enabled` | `true` | 启用 Host 服务与浏览器看板。 |
|
|
55
|
-
| `announceToAgent` | `
|
|
55
|
+
| `announceToAgent` | `false` | 按需开启:开启后向 agent 系统提示加入任务看板说明。 |
|
|
56
56
|
| `preventIdleSleep` | `false` | 存在运行中的 DSH 会话、已启用计划或未知会话状态时,持有一个系统空闲睡眠断言。 |
|
|
57
57
|
| `trustedProxyHosts` | `[]` | 仅通过已认证 loopback 反向代理路径接受的规范 `host[:port]` authority 白名单。 |
|
|
58
58
|
| `proxyTokenEnv` | `DSH_TASK_BOARD_PROXY_TOKEN` | 保存反向代理 token 的环境变量名;token 本身不会写入插件配置。 |
|
package/lib/index.js
CHANGED
|
@@ -852,8 +852,48 @@ function cloneTasks(tasks) {
|
|
|
852
852
|
function hasOpenExecution(task) {
|
|
853
853
|
return task.executions.some((execution) => execution.endedAt === void 0);
|
|
854
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* Process states that are dead but still occupy the PID table: `Z` (zombie)
|
|
857
|
+
* and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
|
|
858
|
+
* alive, so a crash leftover whose child was never reaped would otherwise be
|
|
859
|
+
* mistaken for a live owner and block ledger startup forever.
|
|
860
|
+
*/
|
|
861
|
+
const DEAD_STATES = /* @__PURE__ */ new Set(["Z", "X"]);
|
|
862
|
+
/**
|
|
863
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
864
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
865
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
866
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
867
|
+
* is authoritative there.
|
|
868
|
+
*/
|
|
869
|
+
function processState(pid) {
|
|
870
|
+
if (process.platform === "linux") try {
|
|
871
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
872
|
+
const end = stat.lastIndexOf(")");
|
|
873
|
+
if (end === -1) return void 0;
|
|
874
|
+
return stat.slice(end + 2).split(" ")[0] || void 0;
|
|
875
|
+
} catch {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (process.platform === "win32") return void 0;
|
|
879
|
+
try {
|
|
880
|
+
const probe = spawnSync("ps", [
|
|
881
|
+
"-o",
|
|
882
|
+
"stat=",
|
|
883
|
+
"-p",
|
|
884
|
+
String(pid)
|
|
885
|
+
], { timeout: PROCESS_PROBE_TIMEOUT_MS });
|
|
886
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
|
|
887
|
+
const state = probe.stdout.toString("utf8").trim();
|
|
888
|
+
return state.length > 0 ? state[0] : void 0;
|
|
889
|
+
} catch {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
855
893
|
function processIsAlive(pid) {
|
|
856
894
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
895
|
+
const state = processState(pid);
|
|
896
|
+
if (state !== void 0 && DEAD_STATES.has(state)) return false;
|
|
857
897
|
try {
|
|
858
898
|
process.kill(pid, 0);
|
|
859
899
|
return true;
|
|
@@ -865,6 +905,28 @@ const PROCESS_PROBE_TIMEOUT_MS = 3e3;
|
|
|
865
905
|
let ownStartTime;
|
|
866
906
|
let ownStartTimeResolved = false;
|
|
867
907
|
/**
|
|
908
|
+
* Exact process start time (Unix epoch ms) on Linux, read straight from
|
|
909
|
+
* /proc (field 22 = start ticks since boot, btime = boot epoch seconds).
|
|
910
|
+
* No subprocess and no rounding, so the recorded `startedAt` from a previous
|
|
911
|
+
* boot compares exactly against the live process identity.
|
|
912
|
+
*/
|
|
913
|
+
function linuxStartTimeMs(pid) {
|
|
914
|
+
try {
|
|
915
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
916
|
+
const end = stat.lastIndexOf(")");
|
|
917
|
+
if (end === -1) return void 0;
|
|
918
|
+
const ticks = Number(stat.slice(end + 2).split(" ")[19]);
|
|
919
|
+
if (!Number.isFinite(ticks)) return void 0;
|
|
920
|
+
const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync("/proc/stat", "utf8"));
|
|
921
|
+
if (bootMatch === null) return void 0;
|
|
922
|
+
const btime = Number(bootMatch[1]);
|
|
923
|
+
if (!Number.isFinite(btime)) return void 0;
|
|
924
|
+
return btime * 1e3 + ticks * 1e3 / 100;
|
|
925
|
+
} catch {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
868
930
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
869
931
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
870
932
|
* crash leftover whose PID was reused by an unrelated process (issue #786)
|
|
@@ -872,6 +934,7 @@ let ownStartTimeResolved = false;
|
|
|
872
934
|
* undefined when the platform probe is unavailable; callers fail closed.
|
|
873
935
|
*/
|
|
874
936
|
function processStartTimeMs(pid) {
|
|
937
|
+
if (process.platform === "linux") return linuxStartTimeMs(pid);
|
|
875
938
|
if (process.platform === "win32") {
|
|
876
939
|
const probe = spawnSync("powershell", [
|
|
877
940
|
"-NoProfile",
|
|
@@ -924,6 +987,21 @@ function ownProcessStartTimeMs() {
|
|
|
924
987
|
}
|
|
925
988
|
return ownStartTime;
|
|
926
989
|
}
|
|
990
|
+
/**
|
|
991
|
+
* Bounded tolerance for legacy lock records. Locks written before the
|
|
992
|
+
* ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second
|
|
993
|
+
* resolution; probing the SAME live process exactly (via /proc) then differs
|
|
994
|
+
* in the sub-second remainder. Treating that as PID reuse would steal a live
|
|
995
|
+
* owner's lock during a rolling upgrade and start a second ledger writer.
|
|
996
|
+
* Records written by the ms-precise probe carry `probe: 'exact'` and are
|
|
997
|
+
* compared strictly; anything else (older locks, second-granularity probes)
|
|
998
|
+
* falls back to this bounded tolerance.
|
|
999
|
+
*/
|
|
1000
|
+
const LEGACY_START_TOLERANCE_MS = 2e3;
|
|
1001
|
+
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
1002
|
+
function startTimeMismatch(recorded, actual, exact) {
|
|
1003
|
+
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS;
|
|
1004
|
+
}
|
|
927
1005
|
function betterExecution(a, b) {
|
|
928
1006
|
if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
|
|
929
1007
|
if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
|
|
@@ -1369,10 +1447,13 @@ var HostTaskLedger = class {
|
|
|
1369
1447
|
acquireLock() {
|
|
1370
1448
|
for (let attempt = 0; attempt < 2; attempt += 1) try {
|
|
1371
1449
|
const fd = openSync(this.lockFile, "wx", 384);
|
|
1450
|
+
const startedAt = ownProcessStartTimeMs();
|
|
1451
|
+
const probe = process.platform === "linux" || process.platform === "win32" ? "exact" : "legacy";
|
|
1372
1452
|
writeFileSync(fd, JSON.stringify({
|
|
1373
1453
|
pid: process.pid,
|
|
1374
1454
|
token: this.lockToken,
|
|
1375
|
-
startedAt
|
|
1455
|
+
startedAt,
|
|
1456
|
+
probe
|
|
1376
1457
|
}), { encoding: "utf8" });
|
|
1377
1458
|
fsyncSync(fd);
|
|
1378
1459
|
try {
|
|
@@ -1383,23 +1464,25 @@ var HostTaskLedger = class {
|
|
|
1383
1464
|
if (error.code !== "EEXIST") throw error;
|
|
1384
1465
|
let pid;
|
|
1385
1466
|
let ownerStartedAt;
|
|
1467
|
+
let ownerExact = false;
|
|
1386
1468
|
try {
|
|
1387
1469
|
const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
|
|
1388
1470
|
if (typeof owner.pid === "number") pid = owner.pid;
|
|
1389
1471
|
if (typeof owner.startedAt === "number") ownerStartedAt = owner.startedAt;
|
|
1472
|
+
ownerExact = owner.probe === "exact";
|
|
1390
1473
|
} catch {
|
|
1391
|
-
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
|
|
1474
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`);
|
|
1392
1475
|
}
|
|
1393
1476
|
if (pid !== void 0 && processIsAlive(pid)) {
|
|
1394
1477
|
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid);
|
|
1395
|
-
if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? actualStartedAt
|
|
1478
|
+
if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) : (() => {
|
|
1396
1479
|
try {
|
|
1397
1480
|
return statSync(this.lockFile).mtimeMs < actualStartedAt;
|
|
1398
1481
|
} catch {
|
|
1399
1482
|
return true;
|
|
1400
1483
|
}
|
|
1401
1484
|
})()))) {
|
|
1402
|
-
const hint = ownerStartedAt !== void 0 && actualStartedAt
|
|
1485
|
+
const hint = ownerStartedAt !== void 0 && actualStartedAt !== void 0 && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) ? "" : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`;
|
|
1403
1486
|
throw new Error(`task-board ledger is already owned by process ${pid}${hint}`);
|
|
1404
1487
|
}
|
|
1405
1488
|
}
|
|
@@ -2364,7 +2447,7 @@ const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI
|
|
|
2364
2447
|
*/
|
|
2365
2448
|
const TASK_BOARD_SETTINGS_NAMESPACE = settingsNamespace("task-board");
|
|
2366
2449
|
const Config = z.object({
|
|
2367
|
-
announceToAgent: z.boolean().default(
|
|
2450
|
+
announceToAgent: z.boolean().default(false),
|
|
2368
2451
|
enabled: z.boolean().default(true),
|
|
2369
2452
|
preventIdleSleep: z.boolean().default(false),
|
|
2370
2453
|
trustedProxyHosts: z.array(z.string()).default([]),
|
|
@@ -2384,7 +2467,7 @@ function resolveProxyAccess(config, env = process.env) {
|
|
|
2384
2467
|
};
|
|
2385
2468
|
}
|
|
2386
2469
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
2387
|
-
const DEFAULT_ANNOUNCE =
|
|
2470
|
+
const DEFAULT_ANNOUNCE = false;
|
|
2388
2471
|
/**
|
|
2389
2472
|
* Register the board's announcement section, gated on the composition entry's
|
|
2390
2473
|
* `announceToAgent` (and the live settings value once the web settings
|
|
@@ -9,6 +9,15 @@ export interface OpenedRun {
|
|
|
9
9
|
task: TaskRecord;
|
|
10
10
|
execution: ExecutionRecord;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
14
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
15
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
16
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
17
|
+
* is authoritative there.
|
|
18
|
+
*/
|
|
19
|
+
export declare function processState(pid: number): string | undefined;
|
|
20
|
+
export declare function processIsAlive(pid: number): boolean;
|
|
12
21
|
export declare class HostTaskLedger {
|
|
13
22
|
private readonly now;
|
|
14
23
|
private document;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.8",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"./package.json": "./package.json"
|
|
23
23
|
},
|
|
24
24
|
"dsh": {
|
|
25
|
+
"engines": {
|
|
26
|
+
"dsh": ">=0.1.1-rc.1"
|
|
27
|
+
},
|
|
25
28
|
"bundle": {
|
|
26
29
|
"patch": "./cordis.patch.yml"
|
|
27
30
|
},
|
|
@@ -35,8 +38,8 @@
|
|
|
35
38
|
}
|
|
36
39
|
},
|
|
37
40
|
"peerDependencies": {
|
|
38
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
39
|
-
"@deepseek-ai/dsh-commands": "^0.1.
|
|
41
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
42
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
40
43
|
"react": "^18.2.0"
|
|
41
44
|
},
|
|
42
45
|
"dependencies": {
|
|
@@ -44,17 +47,17 @@
|
|
|
44
47
|
},
|
|
45
48
|
"devDependencies": {
|
|
46
49
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
47
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
48
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.
|
|
49
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
50
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.
|
|
51
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.
|
|
52
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
53
|
-
"@deepseek-ai/dsh-commands": "^0.1.
|
|
54
|
-
"@deepseek-ai/dsh-host-apiproxy": "^0.1.
|
|
55
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
56
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
57
|
-
"@deepseek-ai/dsh-system-prompt": "^0.1.
|
|
50
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
|
|
58
61
|
"@types/react": "~18.3.1",
|
|
59
62
|
"@types/react-dom": "^18.3.5",
|
|
60
63
|
"@types/node": "^22.20.0",
|
package/src/host-ledger.ts
CHANGED
|
@@ -59,8 +59,47 @@ function hasOpenExecution(task: TaskRecord): boolean {
|
|
|
59
59
|
return task.executions.some(execution => execution.endedAt === undefined)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Process states that are dead but still occupy the PID table: `Z` (zombie)
|
|
64
|
+
* and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
|
|
65
|
+
* alive, so a crash leftover whose child was never reaped would otherwise be
|
|
66
|
+
* mistaken for a live owner and block ledger startup forever.
|
|
67
|
+
*/
|
|
68
|
+
const DEAD_STATES = new Set(['Z', 'X'])
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
72
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
73
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
74
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
75
|
+
* is authoritative there.
|
|
76
|
+
*/
|
|
77
|
+
export function processState(pid: number): string | undefined {
|
|
78
|
+
if (process.platform === 'linux') {
|
|
79
|
+
try {
|
|
80
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
81
|
+
const end = stat.lastIndexOf(')')
|
|
82
|
+
if (end === -1) return undefined
|
|
83
|
+
return stat.slice(end + 2).split(' ')[0] || undefined
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined // no such process (or unreadable)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (process.platform === 'win32') return undefined
|
|
89
|
+
try {
|
|
90
|
+
const probe = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS })
|
|
91
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
92
|
+
const state = probe.stdout.toString('utf8').trim()
|
|
93
|
+
return state.length > 0 ? state[0] : undefined
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function processIsAlive(pid: number): boolean {
|
|
63
100
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
|
101
|
+
const state = processState(pid)
|
|
102
|
+
if (state !== undefined && DEAD_STATES.has(state)) return false
|
|
64
103
|
try {
|
|
65
104
|
process.kill(pid, 0)
|
|
66
105
|
return true
|
|
@@ -74,6 +113,29 @@ const PROCESS_PROBE_TIMEOUT_MS = 3000
|
|
|
74
113
|
let ownStartTime: number | undefined
|
|
75
114
|
let ownStartTimeResolved = false
|
|
76
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Exact process start time (Unix epoch ms) on Linux, read straight from
|
|
118
|
+
* /proc (field 22 = start ticks since boot, btime = boot epoch seconds).
|
|
119
|
+
* No subprocess and no rounding, so the recorded `startedAt` from a previous
|
|
120
|
+
* boot compares exactly against the live process identity.
|
|
121
|
+
*/
|
|
122
|
+
function linuxStartTimeMs(pid: number): number | undefined {
|
|
123
|
+
try {
|
|
124
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
125
|
+
const end = stat.lastIndexOf(')')
|
|
126
|
+
if (end === -1) return undefined
|
|
127
|
+
const ticks = Number(stat.slice(end + 2).split(' ')[19])
|
|
128
|
+
if (!Number.isFinite(ticks)) return undefined
|
|
129
|
+
const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync('/proc/stat', 'utf8'))
|
|
130
|
+
if (bootMatch === null) return undefined
|
|
131
|
+
const btime = Number(bootMatch[1])
|
|
132
|
+
if (!Number.isFinite(btime)) return undefined
|
|
133
|
+
return btime * 1000 + (ticks * 1000) / 100 // USER_HZ is 100 on Linux
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
77
139
|
/**
|
|
78
140
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
79
141
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
@@ -82,6 +144,7 @@ let ownStartTimeResolved = false
|
|
|
82
144
|
* undefined when the platform probe is unavailable; callers fail closed.
|
|
83
145
|
*/
|
|
84
146
|
function processStartTimeMs(pid: number): number | undefined {
|
|
147
|
+
if (process.platform === 'linux') return linuxStartTimeMs(pid)
|
|
85
148
|
if (process.platform === 'win32') {
|
|
86
149
|
const probe = spawnSync(
|
|
87
150
|
'powershell',
|
|
@@ -93,8 +156,8 @@ function processStartTimeMs(pid: number): number | undefined {
|
|
|
93
156
|
const started = Number(probe.stdout.toString('utf8').trim())
|
|
94
157
|
return Number.isFinite(started) ? started : undefined
|
|
95
158
|
}
|
|
96
|
-
// POSIX: ps lstart with a forced English locale, falling
|
|
97
|
-
// elapsed-seconds column when lstart cannot be parsed.
|
|
159
|
+
// Other POSIX (macOS...): ps lstart with a forced English locale, falling
|
|
160
|
+
// back to the elapsed-seconds column when lstart cannot be parsed.
|
|
98
161
|
const env = { ...process.env, LC_ALL: 'C' }
|
|
99
162
|
const probe = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
|
|
100
163
|
if (probe.status === 0 && probe.stdout.length > 0) {
|
|
@@ -116,6 +179,23 @@ function ownProcessStartTimeMs(): number | undefined {
|
|
|
116
179
|
return ownStartTime
|
|
117
180
|
}
|
|
118
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Bounded tolerance for legacy lock records. Locks written before the
|
|
184
|
+
* ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second
|
|
185
|
+
* resolution; probing the SAME live process exactly (via /proc) then differs
|
|
186
|
+
* in the sub-second remainder. Treating that as PID reuse would steal a live
|
|
187
|
+
* owner's lock during a rolling upgrade and start a second ledger writer.
|
|
188
|
+
* Records written by the ms-precise probe carry `probe: 'exact'` and are
|
|
189
|
+
* compared strictly; anything else (older locks, second-granularity probes)
|
|
190
|
+
* falls back to this bounded tolerance.
|
|
191
|
+
*/
|
|
192
|
+
const LEGACY_START_TOLERANCE_MS = 2000
|
|
193
|
+
|
|
194
|
+
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
195
|
+
function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean {
|
|
196
|
+
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS
|
|
197
|
+
}
|
|
198
|
+
|
|
119
199
|
function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord {
|
|
120
200
|
if (a.endedAt === undefined && b.endedAt !== undefined) return b
|
|
121
201
|
if (b.endedAt === undefined && a.endedAt !== undefined) return a
|
|
@@ -560,7 +640,13 @@ export class HostTaskLedger {
|
|
|
560
640
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
561
641
|
try {
|
|
562
642
|
const fd = openSync(this.lockFile, 'wx', 0o600)
|
|
563
|
-
|
|
643
|
+
const startedAt = ownProcessStartTimeMs()
|
|
644
|
+
// Linux /proc and Windows PowerShell probes are ms-precise; locks they
|
|
645
|
+
// write are compared strictly. Other POSIX probes (ps) stay
|
|
646
|
+
// second-granularity, so their records are compared with the bounded
|
|
647
|
+
// legacy tolerance.
|
|
648
|
+
const probe = process.platform === 'linux' || process.platform === 'win32' ? 'exact' : 'legacy'
|
|
649
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt, probe }), { encoding: 'utf8' })
|
|
564
650
|
fsyncSync(fd)
|
|
565
651
|
try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
|
|
566
652
|
return fd
|
|
@@ -569,29 +655,36 @@ export class HostTaskLedger {
|
|
|
569
655
|
if (code !== 'EEXIST') throw error
|
|
570
656
|
let pid: number | undefined
|
|
571
657
|
let ownerStartedAt: number | undefined
|
|
658
|
+
let ownerExact = false
|
|
572
659
|
try {
|
|
573
|
-
const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown }
|
|
660
|
+
const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown; probe?: unknown }
|
|
574
661
|
if (typeof owner.pid === 'number') pid = owner.pid
|
|
575
662
|
if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
|
|
663
|
+
ownerExact = owner.probe === 'exact'
|
|
576
664
|
} catch {
|
|
577
|
-
|
|
665
|
+
// A power-loss mid-write can leave a truncated lock; the same event
|
|
666
|
+
// killed the writer, so fail closed but explain the recovery.
|
|
667
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`)
|
|
578
668
|
}
|
|
579
669
|
if (pid !== undefined && processIsAlive(pid)) {
|
|
580
670
|
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
|
|
581
671
|
// A reused PID is exposed when the live process identity no longer
|
|
582
|
-
// matches the recorded one: either the recorded start time differs
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
672
|
+
// matches the recorded one: either the recorded start time differs
|
|
673
|
+
// beyond the probe's resolution (strict for ms-precise 'exact'
|
|
674
|
+
// records, a bounded legacy tolerance for old second-granularity
|
|
675
|
+
// records written by ps), or (legacy locks without a start time)
|
|
676
|
+
// the lock file predates the live process and therefore cannot
|
|
677
|
+
// have been written by it. Takeover is safe in both cases — the
|
|
678
|
+
// original owner is gone.
|
|
586
679
|
const staleReuse = actualStartedAt !== undefined && (
|
|
587
680
|
ownerStartedAt !== undefined
|
|
588
|
-
? actualStartedAt
|
|
681
|
+
? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact)
|
|
589
682
|
: (() => {
|
|
590
683
|
try { return statSync(this.lockFile).mtimeMs < actualStartedAt } catch { return true }
|
|
591
684
|
})()
|
|
592
685
|
)
|
|
593
686
|
if (!staleReuse) {
|
|
594
|
-
const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt
|
|
687
|
+
const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt !== undefined && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact)
|
|
595
688
|
const hint = confirmedOwner
|
|
596
689
|
? ''
|
|
597
690
|
: `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`
|
package/src/index.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface Config {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export const Config: z<Config> = z.object({
|
|
58
|
-
announceToAgent: z.boolean().default(
|
|
58
|
+
announceToAgent: z.boolean().default(false),
|
|
59
59
|
enabled: z.boolean().default(true),
|
|
60
60
|
preventIdleSleep: z.boolean().default(false),
|
|
61
61
|
trustedProxyHosts: z.array(z.string()).default([]),
|
|
@@ -76,7 +76,7 @@ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.Proce
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
79
|
-
const DEFAULT_ANNOUNCE =
|
|
79
|
+
const DEFAULT_ANNOUNCE = false
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Register the board's announcement section, gated on the composition entry's
|