@linxin666/dsh-client-ui-task-board 0.2.5 → 0.2.6

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/lib/client.js CHANGED
@@ -2908,7 +2908,7 @@ window.__ModuleLoader__.load({
2908
2908
  }
2909
2909
  //#endregion
2910
2910
  //#region \0dsh-css:packages/dsh-task-board/src/client/settings-card.module.css.mjs
2911
- const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_selectWrap{position:relative}.Jh0q7G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Jh0q7G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Jh0q7G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_selectChevronOpen{transform:rotate(180deg)}.Jh0q7G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Jh0q7G_selectPopupOpen{opacity:1;transform:none}.Jh0q7G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Jh0q7G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Jh0q7G_selectOption:hover,.Jh0q7G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Jh0q7G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save,.Jh0q7G_selectChevron,.Jh0q7G_selectChevronOpen,.Jh0q7G_selectPopup{transition:none}}";
2911
+ const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_selectWrap{position:relative}.Jh0q7G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Jh0q7G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Jh0q7G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_selectChevronOpen{transform:rotate(180deg)}.Jh0q7G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Jh0q7G_selectPopupOpen{opacity:1;transform:none}.Jh0q7G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Jh0q7G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex-shrink:0;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Jh0q7G_selectOption:hover,.Jh0q7G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Jh0q7G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save,.Jh0q7G_selectChevron,.Jh0q7G_selectChevronOpen,.Jh0q7G_selectPopup{transition:none}}";
2912
2912
  const tagId = "@linxin666/dsh-client-ui-task-board/settings-card.module.css";
2913
2913
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2914
2914
  const tag = document.createElement("style");
package/lib/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
2
  import z from "schemastery";
3
+ import { spawn, spawnSync } from "node:child_process";
3
4
  import { createHash, timingSafeEqual } from "node:crypto";
4
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
6
  import { dirname, isAbsolute, join, win32 } from "node:path";
6
7
  import { homedir } from "node:os";
7
- import { spawn, spawnSync } from "node:child_process";
8
8
  //#region src/core/schedule.ts
9
9
  /** Inclusive ranges per field, in cron order. */
10
10
  const FIELD_RANGES = [
@@ -861,6 +861,69 @@ function processIsAlive(pid) {
861
861
  return error.code !== "ESRCH";
862
862
  }
863
863
  }
864
+ const PROCESS_PROBE_TIMEOUT_MS = 3e3;
865
+ let ownStartTime;
866
+ let ownStartTimeResolved = false;
867
+ /**
868
+ * Best-effort start time (Unix epoch ms) of a live process. Used to prove
869
+ * whether the ledger lock really belongs to the PID recorded in it, so a
870
+ * crash leftover whose PID was reused by an unrelated process (issue #786)
871
+ * is detected as stale instead of blocking startup forever. Returns
872
+ * undefined when the platform probe is unavailable; callers fail closed.
873
+ */
874
+ function processStartTimeMs(pid) {
875
+ if (process.platform === "win32") {
876
+ const probe = spawnSync("powershell", [
877
+ "-NoProfile",
878
+ "-NonInteractive",
879
+ "-Command",
880
+ "[DateTimeOffset]::FromFileTime((Get-Process -Id " + String(pid) + " -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()"
881
+ ], {
882
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
883
+ windowsHide: true
884
+ });
885
+ if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
886
+ const started = Number(probe.stdout.toString("utf8").trim());
887
+ return Number.isFinite(started) ? started : void 0;
888
+ }
889
+ const env = {
890
+ ...process.env,
891
+ LC_ALL: "C"
892
+ };
893
+ const probe = spawnSync("ps", [
894
+ "-o",
895
+ "lstart=",
896
+ "-p",
897
+ String(pid)
898
+ ], {
899
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
900
+ env
901
+ });
902
+ if (probe.status === 0 && probe.stdout.length > 0) {
903
+ const started = Date.parse(probe.stdout.toString("utf8").trim());
904
+ if (Number.isFinite(started)) return started;
905
+ }
906
+ const elapsed = spawnSync("ps", [
907
+ "-o",
908
+ "etimes=",
909
+ "-p",
910
+ String(pid)
911
+ ], {
912
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
913
+ env
914
+ });
915
+ if (elapsed.status !== 0 || elapsed.stdout.length === 0) return void 0;
916
+ const seconds = Number(elapsed.stdout.toString("utf8").trim());
917
+ if (!Number.isFinite(seconds)) return void 0;
918
+ return Date.now() - seconds * 1e3;
919
+ }
920
+ function ownProcessStartTimeMs() {
921
+ if (!ownStartTimeResolved) {
922
+ ownStartTimeResolved = true;
923
+ ownStartTime = processStartTimeMs(process.pid);
924
+ }
925
+ return ownStartTime;
926
+ }
864
927
  function betterExecution(a, b) {
865
928
  if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
866
929
  if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
@@ -1308,7 +1371,8 @@ var HostTaskLedger = class {
1308
1371
  const fd = openSync(this.lockFile, "wx", 384);
1309
1372
  writeFileSync(fd, JSON.stringify({
1310
1373
  pid: process.pid,
1311
- token: this.lockToken
1374
+ token: this.lockToken,
1375
+ startedAt: ownProcessStartTimeMs()
1312
1376
  }), { encoding: "utf8" });
1313
1377
  fsyncSync(fd);
1314
1378
  try {
@@ -1318,13 +1382,27 @@ var HostTaskLedger = class {
1318
1382
  } catch (error) {
1319
1383
  if (error.code !== "EEXIST") throw error;
1320
1384
  let pid;
1385
+ let ownerStartedAt;
1321
1386
  try {
1322
1387
  const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
1323
1388
  if (typeof owner.pid === "number") pid = owner.pid;
1389
+ if (typeof owner.startedAt === "number") ownerStartedAt = owner.startedAt;
1324
1390
  } catch {
1325
1391
  throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
1326
1392
  }
1327
- if (pid !== void 0 && processIsAlive(pid)) throw new Error(`task-board ledger is already owned by process ${pid}`);
1393
+ if (pid !== void 0 && processIsAlive(pid)) {
1394
+ const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid);
1395
+ if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? actualStartedAt !== ownerStartedAt : (() => {
1396
+ try {
1397
+ return statSync(this.lockFile).mtimeMs < actualStartedAt;
1398
+ } catch {
1399
+ return true;
1400
+ }
1401
+ })()))) {
1402
+ const hint = ownerStartedAt !== void 0 && actualStartedAt === ownerStartedAt ? "" : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`;
1403
+ throw new Error(`task-board ledger is already owned by process ${pid}${hint}`);
1404
+ }
1405
+ }
1328
1406
  try {
1329
1407
  unlinkSync(this.lockFile);
1330
1408
  } catch (unlinkError) {
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.5",
4
+ "version": "0.2.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -354,6 +354,8 @@
354
354
  }
355
355
 
356
356
  .selectOption {
357
+ /* 弹层是 max-height 受限的 flex 列:选项必须保持自然行高,超出交给弹层滚动 */
358
+ flex-shrink: 0;
357
359
  border-radius: 6px;
358
360
  padding: 6px 10px;
359
361
  font-size: 13px;
@@ -1,5 +1,6 @@
1
+ import { spawnSync } from 'node:child_process'
1
2
  import { createHash } from 'node:crypto'
2
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
4
  import { dirname, join } from 'node:path'
4
5
  import { dshHome } from './dsh-home.ts'
5
6
  import { isValidCron, nextRunAtMs } from './core/schedule.ts'
@@ -68,6 +69,53 @@ function processIsAlive(pid: number): boolean {
68
69
  }
69
70
  }
70
71
 
72
+ const PROCESS_PROBE_TIMEOUT_MS = 3000
73
+
74
+ let ownStartTime: number | undefined
75
+ let ownStartTimeResolved = false
76
+
77
+ /**
78
+ * Best-effort start time (Unix epoch ms) of a live process. Used to prove
79
+ * whether the ledger lock really belongs to the PID recorded in it, so a
80
+ * crash leftover whose PID was reused by an unrelated process (issue #786)
81
+ * is detected as stale instead of blocking startup forever. Returns
82
+ * undefined when the platform probe is unavailable; callers fail closed.
83
+ */
84
+ function processStartTimeMs(pid: number): number | undefined {
85
+ if (process.platform === 'win32') {
86
+ const probe = spawnSync(
87
+ 'powershell',
88
+ ['-NoProfile', '-NonInteractive', '-Command',
89
+ '[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid) + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'],
90
+ { timeout: PROCESS_PROBE_TIMEOUT_MS, windowsHide: true },
91
+ )
92
+ if (probe.status !== 0 || probe.stdout.length === 0) return undefined
93
+ const started = Number(probe.stdout.toString('utf8').trim())
94
+ return Number.isFinite(started) ? started : undefined
95
+ }
96
+ // POSIX: ps lstart with a forced English locale, falling back to the
97
+ // elapsed-seconds column when lstart cannot be parsed.
98
+ const env = { ...process.env, LC_ALL: 'C' }
99
+ const probe = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
100
+ if (probe.status === 0 && probe.stdout.length > 0) {
101
+ const started = Date.parse(probe.stdout.toString('utf8').trim())
102
+ if (Number.isFinite(started)) return started
103
+ }
104
+ const elapsed = spawnSync('ps', ['-o', 'etimes=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
105
+ if (elapsed.status !== 0 || elapsed.stdout.length === 0) return undefined
106
+ const seconds = Number(elapsed.stdout.toString('utf8').trim())
107
+ if (!Number.isFinite(seconds)) return undefined
108
+ return Date.now() - seconds * 1000
109
+ }
110
+
111
+ function ownProcessStartTimeMs(): number | undefined {
112
+ if (!ownStartTimeResolved) {
113
+ ownStartTimeResolved = true
114
+ ownStartTime = processStartTimeMs(process.pid)
115
+ }
116
+ return ownStartTime
117
+ }
118
+
71
119
  function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord {
72
120
  if (a.endedAt === undefined && b.endedAt !== undefined) return b
73
121
  if (b.endedAt === undefined && a.endedAt !== undefined) return a
@@ -512,7 +560,7 @@ export class HostTaskLedger {
512
560
  for (let attempt = 0; attempt < 2; attempt += 1) {
513
561
  try {
514
562
  const fd = openSync(this.lockFile, 'wx', 0o600)
515
- writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken }), { encoding: 'utf8' })
563
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt: ownProcessStartTimeMs() }), { encoding: 'utf8' })
516
564
  fsyncSync(fd)
517
565
  try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
518
566
  return fd
@@ -520,14 +568,35 @@ export class HostTaskLedger {
520
568
  const code = (error as NodeJS.ErrnoException).code
521
569
  if (code !== 'EEXIST') throw error
522
570
  let pid: number | undefined
571
+ let ownerStartedAt: number | undefined
523
572
  try {
524
- const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown }
573
+ const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown }
525
574
  if (typeof owner.pid === 'number') pid = owner.pid
575
+ if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
526
576
  } catch {
527
577
  throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`)
528
578
  }
529
579
  if (pid !== undefined && processIsAlive(pid)) {
530
- throw new Error(`task-board ledger is already owned by process ${pid}`)
580
+ const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
581
+ // A reused PID is exposed when the live process identity no longer
582
+ // matches the recorded one: either the recorded start time differs,
583
+ // or (legacy locks without one) the lock file predates the live
584
+ // process and therefore cannot have been written by it. Takeover is
585
+ // safe in both cases — the original owner is gone.
586
+ const staleReuse = actualStartedAt !== undefined && (
587
+ ownerStartedAt !== undefined
588
+ ? actualStartedAt !== ownerStartedAt
589
+ : (() => {
590
+ try { return statSync(this.lockFile).mtimeMs < actualStartedAt } catch { return true }
591
+ })()
592
+ )
593
+ if (!staleReuse) {
594
+ const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt === ownerStartedAt
595
+ const hint = confirmedOwner
596
+ ? ''
597
+ : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`
598
+ throw new Error(`task-board ledger is already owned by process ${pid}${hint}`)
599
+ }
531
600
  }
532
601
  try { unlinkSync(this.lockFile) } catch (unlinkError) {
533
602
  if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError