harnery 0.43.0 → 0.45.0
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.md +3 -3
- package/dist/commander.js +1 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +41 -48
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +38 -11
- package/dist/commands/diagnostics.d.ts.map +1 -1
- package/dist/commands/diagnostics.js +10 -0
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +22 -7
- package/dist/commands/files.d.ts +2 -2
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +7 -4
- package/dist/commands/governor.js +22 -10
- package/dist/commands/inbox.d.ts +1 -1
- package/dist/commands/inbox.d.ts.map +1 -1
- package/dist/commands/inbox.js +55 -2
- package/dist/commands/rm.d.ts.map +1 -1
- package/dist/commands/rm.js +28 -4
- package/dist/commands/servers.d.ts.map +1 -1
- package/dist/commands/servers.js +26 -17
- package/dist/commands/supervisor.d.ts.map +1 -1
- package/dist/commands/supervisor.js +7 -2
- package/dist/commands/toc.d.ts +14 -0
- package/dist/commands/toc.d.ts.map +1 -1
- package/dist/commands/toc.js +7 -4
- package/dist/commands/tunnel.d.ts +15 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +453 -153
- package/dist/commands/turn-rollback.d.ts +5 -0
- package/dist/commands/turn-rollback.d.ts.map +1 -0
- package/dist/commands/turn-rollback.js +76 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +68 -30
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +18 -0
- package/dist/core/agents/live-authority-v3.d.ts +1 -0
- package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
- package/dist/core/agents/live-authority-v3.js +11 -0
- package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
- package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
- package/dist/core/agents/live-lifecycle-v3.js +28 -7
- package/dist/core/agents/mailbox.d.ts +9 -8
- package/dist/core/agents/mailbox.d.ts.map +1 -1
- package/dist/core/agents/mailbox.js +24 -17
- package/dist/core/agents/render/prompt-context.d.ts +2 -0
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +8 -12
- package/dist/core/agents/render/session-context.d.ts +2 -8
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +7 -30
- package/dist/core/agents/rules/stop-hook.js +3 -3
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +25 -14
- package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-writer.js +5 -0
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +5 -0
- package/dist/core/agents/state/proc-start.d.ts +19 -1
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +46 -1
- package/dist/core/agents/state/process-handle.d.ts +26 -0
- package/dist/core/agents/state/process-handle.d.ts.map +1 -0
- package/dist/core/agents/state/process-handle.js +121 -0
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +180 -70
- package/dist/core/agents/state/unchanged-file.d.ts +44 -0
- package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
- package/dist/core/agents/state/unchanged-file.js +390 -0
- package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
- package/dist/core/artifacts/delivery-card.js +2 -1
- package/dist/core/context/index.d.ts +22 -10
- package/dist/core/context/index.d.ts.map +1 -1
- package/dist/core/context/index.js +381 -119
- package/dist/core/context/observation.d.ts +26 -0
- package/dist/core/context/observation.d.ts.map +1 -0
- package/dist/core/context/observation.js +11 -0
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +25 -14
- package/dist/core/diagnostics/path-identity.d.ts +3 -0
- package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
- package/dist/core/diagnostics/path-identity.js +12 -0
- package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
- package/dist/core/diagnostics/sanitize.js +4 -3
- package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
- package/dist/core/events/v3/authority-outbox.js +29 -0
- package/dist/core/events/v3/generation-custody.d.ts +5 -0
- package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
- package/dist/core/events/v3/generation-custody.js +50 -0
- package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
- package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +77 -64
- package/dist/core/governor/planning.d.ts.map +1 -1
- package/dist/core/governor/planning.js +14 -1
- package/dist/core/governor/service-read.d.ts +10 -2
- package/dist/core/governor/service-read.d.ts.map +1 -1
- package/dist/core/governor/service-read.js +105 -22
- package/dist/core/governor/service.d.ts +20 -2
- package/dist/core/governor/service.d.ts.map +1 -1
- package/dist/core/governor/service.js +72 -47
- package/dist/core/hooks/cli.js +33 -5
- package/dist/core/inbox/contract.d.ts +2 -1
- package/dist/core/inbox/contract.d.ts.map +1 -1
- package/dist/core/inbox/index.d.ts +2 -0
- package/dist/core/inbox/index.d.ts.map +1 -1
- package/dist/core/inbox/index.js +2 -0
- package/dist/core/inbox/peer-messages.d.ts +37 -0
- package/dist/core/inbox/peer-messages.d.ts.map +1 -0
- package/dist/core/inbox/peer-messages.js +72 -0
- package/dist/core/inbox/service.d.ts +8 -1
- package/dist/core/inbox/service.d.ts.map +1 -1
- package/dist/core/inbox/service.js +67 -21
- package/dist/core/inbox/targets.d.ts +22 -0
- package/dist/core/inbox/targets.d.ts.map +1 -0
- package/dist/core/inbox/targets.js +108 -0
- package/dist/core/inbox/watcher.js +7 -3
- package/dist/core/journal/index.d.ts.map +1 -1
- package/dist/core/journal/index.js +17 -2
- package/dist/core/resources/contract.d.ts +3 -0
- package/dist/core/resources/contract.d.ts.map +1 -1
- package/dist/core/resources/service-status.d.ts +14 -1
- package/dist/core/resources/service-status.d.ts.map +1 -1
- package/dist/core/resources/service-status.js +23 -13
- package/dist/core/resources/service.d.ts +7 -13
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +34 -91
- package/dist/core/resources/status.d.ts +2 -5
- package/dist/core/resources/status.d.ts.map +1 -1
- package/dist/core/resources/status.js +36 -7
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +2 -9
- package/dist/core/servers/index.d.ts +34 -22
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +297 -144
- package/dist/core/servers/tunnels.d.ts +7 -7
- package/dist/core/servers/tunnels.d.ts.map +1 -1
- package/dist/core/servers/tunnels.js +47 -10
- package/dist/core/supervisor/contract.d.ts +12 -0
- package/dist/core/supervisor/contract.d.ts.map +1 -1
- package/dist/core/supervisor/explanations.d.ts.map +1 -1
- package/dist/core/supervisor/explanations.js +2 -1
- package/dist/core/supervisor/findings.d.ts.map +1 -1
- package/dist/core/supervisor/findings.js +54 -9
- package/dist/core/supervisor/service.d.ts +7 -1
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/supervisor/service.js +59 -29
- package/dist/core/supervisor/services.d.ts.map +1 -1
- package/dist/core/supervisor/services.js +22 -1
- package/dist/core/supervisor/status.d.ts +17 -2
- package/dist/core/supervisor/status.d.ts.map +1 -1
- package/dist/core/supervisor/status.js +70 -12
- package/dist/core/supervisor/timeline.d.ts.map +1 -1
- package/dist/core/supervisor/timeline.js +2 -1
- package/dist/core/turn-rollback/content.d.ts +53 -0
- package/dist/core/turn-rollback/content.d.ts.map +1 -0
- package/dist/core/turn-rollback/content.js +396 -0
- package/dist/core/turn-rollback/index.d.ts +72 -0
- package/dist/core/turn-rollback/index.d.ts.map +1 -0
- package/dist/core/turn-rollback/index.js +443 -0
- package/dist/core/work/state.js +7 -7
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +105 -32
- package/dist/core/workflow/failure-evidence.d.ts +16 -0
- package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
- package/dist/core/workflow/failure-evidence.js +114 -0
- package/dist/core/workflow/index.d.ts +5 -3
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +3 -2
- package/dist/core/workflow/live-session-v3.d.ts +6 -0
- package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
- package/dist/core/workflow/live-session-v3.js +126 -5
- package/dist/core/workflow/proof.d.ts +5 -5
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +115 -20
- package/dist/core/workflow/recovery-audit.d.ts +30 -0
- package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
- package/dist/core/workflow/recovery-audit.js +116 -0
- package/dist/core/workflow/run-state.d.ts +20 -1
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +141 -32
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +19 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +13 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +19 -0
- package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
- package/dist/core/workflow/spawn-opencode.js +14 -0
- package/dist/core/workflow/types.d.ts +64 -17
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.d.ts +0 -1
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.js +0 -1
- package/dist/core/workflow/workspaces/leases.d.ts +3 -1
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +30 -16
- package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +328 -8
- package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
- package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/ownership.js +194 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts +40 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/reclaim.js +14 -0
- package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +42 -10
- package/dist/core/workflow/workspaces/state.d.ts +1 -0
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/state.js +21 -1
- package/dist/lib/browser/windows-chrome.d.ts +100 -12
- package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
- package/dist/lib/browser/windows-chrome.js +262 -73
- package/dist/lib/docs-links.d.ts.map +1 -1
- package/dist/lib/docs-links.js +45 -2
- package/dist/lib/guarded-remove.d.ts +7 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -1
- package/dist/lib/guarded-remove.js +106 -12
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +4 -0
- package/dist/lib/tunnel/state.d.ts +13 -6
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +58 -12
- package/dist/lib/zip/index.d.ts +41 -0
- package/dist/lib/zip/index.d.ts.map +1 -0
- package/dist/lib/zip/index.js +398 -0
- package/package.json +12 -1
- package/src/commander.ts +10 -0
- package/src/commands/agents.ts +54 -63
- package/src/commands/browse.ts +50 -14
- package/src/commands/diagnostics.ts +10 -0
- package/src/commands/doctor.ts +26 -7
- package/src/commands/files.ts +13 -5
- package/src/commands/governor.ts +23 -10
- package/src/commands/inbox.ts +52 -2
- package/src/commands/rm.ts +43 -4
- package/src/commands/servers.ts +27 -26
- package/src/commands/supervisor.ts +7 -2
- package/src/commands/toc.ts +14 -6
- package/src/commands/tunnel.ts +533 -166
- package/src/commands/turn-rollback.ts +111 -0
- package/src/commands/workflow.ts +89 -30
- package/src/core/agents/coord-client.ts +20 -0
- package/src/core/agents/live-authority-v3.ts +17 -0
- package/src/core/agents/live-lifecycle-v3.ts +52 -14
- package/src/core/agents/mailbox.ts +26 -17
- package/src/core/agents/render/prompt-context.ts +10 -12
- package/src/core/agents/render/session-context.ts +9 -32
- package/src/core/agents/rules/stop-hook.ts +3 -3
- package/src/core/agents/state/heartbeat-writer.ts +29 -12
- package/src/core/agents/state/live-coordination-writer.ts +29 -0
- package/src/core/agents/state/pidmap.ts +13 -0
- package/src/core/agents/state/proc-start.ts +62 -2
- package/src/core/agents/state/process-handle.ts +165 -0
- package/src/core/agents/state/stale-sweep.ts +247 -82
- package/src/core/agents/state/unchanged-file.ts +456 -0
- package/src/core/artifacts/delivery-card.ts +7 -1
- package/src/core/context/index.ts +446 -136
- package/src/core/context/observation.ts +29 -0
- package/src/core/diagnostics/bundle.ts +23 -18
- package/src/core/diagnostics/path-identity.ts +15 -0
- package/src/core/diagnostics/sanitize.ts +4 -3
- package/src/core/events/v3/authority-outbox.ts +56 -0
- package/src/core/events/v3/generation-custody.ts +59 -0
- package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
- package/src/core/events/v3/producers/recorder.ts +94 -68
- package/src/core/governor/planning.ts +20 -8
- package/src/core/governor/service-read.ts +128 -20
- package/src/core/governor/service.ts +119 -42
- package/src/core/hooks/cli.ts +33 -5
- package/src/core/inbox/contract.ts +2 -1
- package/src/core/inbox/index.ts +2 -0
- package/src/core/inbox/peer-messages.ts +94 -0
- package/src/core/inbox/service.ts +88 -22
- package/src/core/inbox/targets.ts +148 -0
- package/src/core/inbox/watcher.ts +8 -9
- package/src/core/journal/index.ts +19 -2
- package/src/core/resources/contract.ts +4 -0
- package/src/core/resources/service-status.ts +35 -12
- package/src/core/resources/service.ts +57 -95
- package/src/core/resources/status.ts +57 -8
- package/src/core/semantic/service.ts +2 -8
- package/src/core/servers/index.ts +376 -141
- package/src/core/servers/tunnels.ts +57 -13
- package/src/core/supervisor/contract.ts +13 -0
- package/src/core/supervisor/explanations.ts +4 -1
- package/src/core/supervisor/findings.ts +68 -10
- package/src/core/supervisor/service.ts +87 -26
- package/src/core/supervisor/services.ts +26 -8
- package/src/core/supervisor/status.ts +89 -12
- package/src/core/supervisor/timeline.ts +2 -1
- package/src/core/turn-rollback/content.ts +499 -0
- package/src/core/turn-rollback/index.ts +580 -0
- package/src/core/work/state.ts +7 -7
- package/src/core/workflow/engine.ts +131 -49
- package/src/core/workflow/failure-evidence.ts +141 -0
- package/src/core/workflow/index.ts +12 -2
- package/src/core/workflow/live-session-v3.ts +146 -5
- package/src/core/workflow/proof.ts +134 -26
- package/src/core/workflow/recovery-audit.ts +181 -0
- package/src/core/workflow/run-state.ts +165 -34
- package/src/core/workflow/spawn-claude.ts +18 -0
- package/src/core/workflow/spawn-codex.ts +13 -0
- package/src/core/workflow/spawn-cursor.ts +18 -0
- package/src/core/workflow/spawn-opencode.ts +14 -0
- package/src/core/workflow/types.ts +51 -17
- package/src/core/workflow/workspaces/index.ts +0 -1
- package/src/core/workflow/workspaces/leases.ts +48 -17
- package/src/core/workflow/workspaces/local-git.ts +388 -7
- package/src/core/workflow/workspaces/ownership.ts +232 -0
- package/src/core/workflow/workspaces/reclaim.ts +62 -0
- package/src/core/workflow/workspaces/recovery.ts +45 -10
- package/src/core/workflow/workspaces/state.ts +20 -0
- package/src/lib/browser/windows-chrome.ts +346 -86
- package/src/lib/docs-links.ts +49 -2
- package/src/lib/guarded-remove.ts +132 -14
- package/src/lib/identities/assume.ts +4 -0
- package/src/lib/tunnel/state.ts +70 -15
- package/src/lib/zip/index.ts +483 -0
|
@@ -14,21 +14,25 @@
|
|
|
14
14
|
* for `gc` once its starting agent has ended or been unobserved for a long
|
|
15
15
|
* time AND nobody has used the server for the idle window.
|
|
16
16
|
*
|
|
17
|
-
* Fail-closed rules: a record whose pid was recycled is
|
|
18
|
-
*
|
|
17
|
+
* Fail-closed rules: a record whose pid was recycled is never signalled;
|
|
18
|
+
* an unreadable start token or coordination view is `unknown`,
|
|
19
19
|
* which `gc` never stops; a record without an owner is never stopped
|
|
20
20
|
* automatically.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
import { createHash } from "node:crypto";
|
|
23
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
24
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
26
25
|
import { hostname } from "node:os";
|
|
27
26
|
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
28
|
-
import { listStates } from "../../lib/tunnel/state.ts";
|
|
29
27
|
import { resolveCoordRoot } from "../agents/coord-client.ts";
|
|
30
28
|
import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
|
|
31
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
type ProcessGenerationSignalDependencies,
|
|
31
|
+
type ProcessGenerationVerdict,
|
|
32
|
+
processStartToken,
|
|
33
|
+
signalIfSameGeneration,
|
|
34
|
+
verifyProcessGeneration,
|
|
35
|
+
} from "../agents/state/proc-start.ts";
|
|
32
36
|
import { serverPortRange } from "../config.ts";
|
|
33
37
|
import {
|
|
34
38
|
readCoordinationViewV3,
|
|
@@ -37,6 +41,7 @@ import {
|
|
|
37
41
|
import { liveInstanceIdV3 } from "../events/v3/live-route-observer.ts";
|
|
38
42
|
import { resolveOwner } from "../hooks/resolve/owner.ts";
|
|
39
43
|
import { writePrivateJsonAtomic } from "../storage/atomic-json.ts";
|
|
44
|
+
import { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
|
|
40
45
|
import {
|
|
41
46
|
ancestry,
|
|
42
47
|
establishedConnectionCounts,
|
|
@@ -47,8 +52,13 @@ import {
|
|
|
47
52
|
type ScanSupport,
|
|
48
53
|
scanSupport,
|
|
49
54
|
} from "./net.ts";
|
|
50
|
-
import { tunnelServerId, tunnelServerInput } from "./tunnels.ts";
|
|
51
55
|
|
|
56
|
+
export {
|
|
57
|
+
processStartToken,
|
|
58
|
+
signalIfSameGeneration,
|
|
59
|
+
verifyProcessGeneration,
|
|
60
|
+
} from "../agents/state/proc-start.ts";
|
|
61
|
+
export { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
|
|
52
62
|
export {
|
|
53
63
|
ancestry,
|
|
54
64
|
establishedConnectionCounts,
|
|
@@ -64,9 +74,9 @@ export {
|
|
|
64
74
|
readProcessInfo,
|
|
65
75
|
scanSupport,
|
|
66
76
|
} from "./net.ts";
|
|
67
|
-
export { tunnelServerId, tunnelServerInput } from "./tunnels.ts";
|
|
77
|
+
export { createTunnelReadinessVerifier, tunnelServerId, tunnelServerInput } from "./tunnels.ts";
|
|
68
78
|
|
|
69
|
-
export const SERVER_RECORD_SCHEMA_VERSION =
|
|
79
|
+
export const SERVER_RECORD_SCHEMA_VERSION = 2;
|
|
70
80
|
export const SERVER_DEFAULT_IDLE_HOURS = 2;
|
|
71
81
|
export const SERVER_DEFAULT_OWNER_STALE_HOURS = 24;
|
|
72
82
|
const TOUCH_INTERVAL_MS = 60_000;
|
|
@@ -76,6 +86,7 @@ export type ServerKind = "service" | "session";
|
|
|
76
86
|
export interface ServerRecord {
|
|
77
87
|
schema_version: typeof SERVER_RECORD_SCHEMA_VERSION;
|
|
78
88
|
id: string;
|
|
89
|
+
registration_id: string;
|
|
79
90
|
kind: ServerKind;
|
|
80
91
|
/** Free-form family name: `preview`, `tunnel`, `dashboard`, ... */
|
|
81
92
|
type: string;
|
|
@@ -87,13 +98,13 @@ export interface ServerRecord {
|
|
|
87
98
|
start_token?: string;
|
|
88
99
|
/** Further processes that belong to this server (helpers, children). */
|
|
89
100
|
pids?: number[];
|
|
101
|
+
/** Generation witnesses captured when the further processes registered. */
|
|
102
|
+
pids_start_tokens?: Record<string, string>;
|
|
90
103
|
host: string;
|
|
91
104
|
/** What the server serves, usually a directory. */
|
|
92
105
|
scope?: string;
|
|
93
106
|
cwd?: string;
|
|
94
107
|
log?: string;
|
|
95
|
-
/** Graceful stop command; signals are the fallback. */
|
|
96
|
-
stop_argv?: string[];
|
|
97
108
|
owner?: { instance_id: string; name?: string };
|
|
98
109
|
started_at: string;
|
|
99
110
|
registered_at: string;
|
|
@@ -108,11 +119,12 @@ export interface RegisterServerInput {
|
|
|
108
119
|
url?: string;
|
|
109
120
|
port?: number;
|
|
110
121
|
pid?: number;
|
|
122
|
+
/** Preserve an observed witness when adopting, rather than probing a new generation. */
|
|
123
|
+
start_token?: string;
|
|
111
124
|
pids?: number[];
|
|
112
125
|
scope?: string;
|
|
113
126
|
cwd?: string;
|
|
114
127
|
log?: string;
|
|
115
|
-
stop_argv?: string[];
|
|
116
128
|
started_at?: string;
|
|
117
129
|
/** `null` records no owner; omitted resolves the calling agent session. */
|
|
118
130
|
owner?: { instance_id: string; name?: string } | null;
|
|
@@ -122,7 +134,7 @@ export interface ServerOptions {
|
|
|
122
134
|
coordRoot?: string | null;
|
|
123
135
|
}
|
|
124
136
|
|
|
125
|
-
export type ServerState = "running" | "dead" | "other-host";
|
|
137
|
+
export type ServerState = "running" | "dead" | "other-host" | "unknown";
|
|
126
138
|
export type OwnerState = "live" | "abandoned" | "ended" | "unknown" | "none";
|
|
127
139
|
|
|
128
140
|
export interface ServerView {
|
|
@@ -136,6 +148,8 @@ export interface ServerView {
|
|
|
136
148
|
|
|
137
149
|
export interface UnregisteredListener {
|
|
138
150
|
pid: number;
|
|
151
|
+
/** Captured during the scan, before a stop can be authorized. */
|
|
152
|
+
start_token?: string;
|
|
139
153
|
port: number;
|
|
140
154
|
address: string;
|
|
141
155
|
command: string;
|
|
@@ -203,34 +217,57 @@ export function registerServer(
|
|
|
203
217
|
)
|
|
204
218
|
throw new Error(`invalid server port: ${input.port}`);
|
|
205
219
|
const id = input.id ?? serverId(input.type, input.scope);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
220
|
+
return withServerMutation(coordRoot, id, () => {
|
|
221
|
+
const path = recordPath(coordRoot, id);
|
|
222
|
+
const now = new Date().toISOString();
|
|
223
|
+
const startToken = input.start_token ?? processStartToken(pid);
|
|
224
|
+
const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
|
|
225
|
+
const pidsStartTokens: Record<string, string> = {};
|
|
226
|
+
for (const auxiliaryPid of pids) {
|
|
227
|
+
const token = processStartToken(auxiliaryPid);
|
|
228
|
+
if (token) pidsStartTokens[auxiliaryPid] = token;
|
|
229
|
+
}
|
|
230
|
+
const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
|
|
231
|
+
const record: ServerRecord = {
|
|
232
|
+
schema_version: SERVER_RECORD_SCHEMA_VERSION,
|
|
233
|
+
id,
|
|
234
|
+
registration_id: randomUUID(),
|
|
235
|
+
kind: input.kind,
|
|
236
|
+
type: input.type,
|
|
237
|
+
label: input.label ?? id,
|
|
238
|
+
...(input.url ? { url: input.url } : {}),
|
|
239
|
+
...(input.port ? { port: input.port } : {}),
|
|
240
|
+
pid,
|
|
241
|
+
...(startToken ? { start_token: startToken } : {}),
|
|
242
|
+
...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
|
|
243
|
+
host: hostname(),
|
|
244
|
+
...(input.scope ? { scope: resolve(input.scope) } : {}),
|
|
245
|
+
...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
|
|
246
|
+
...(input.log ? { log: resolve(input.log) } : {}),
|
|
247
|
+
...(owner ? { owner } : {}),
|
|
248
|
+
started_at: input.started_at ?? now,
|
|
249
|
+
registered_at: now,
|
|
250
|
+
last_active_at: now,
|
|
251
|
+
};
|
|
252
|
+
if (
|
|
253
|
+
input.start_token !== undefined &&
|
|
254
|
+
verifyProcessGeneration(pid, input.start_token) !== "same"
|
|
255
|
+
)
|
|
256
|
+
throw new Error(`pid ${pid} generation could not be verified before registration`);
|
|
257
|
+
if (existsSync(path)) {
|
|
258
|
+
const existing = readRecord(path);
|
|
259
|
+
if (!existing)
|
|
260
|
+
throw new Error(
|
|
261
|
+
`registration ${id} custody is unknown: invalid or older server record retained`,
|
|
262
|
+
);
|
|
263
|
+
if (sameProcessCustody(existing, record) && serverState(existing) === "running")
|
|
264
|
+
return existing;
|
|
265
|
+
if (existing.host !== hostname() || !allProcessesAbsent(existing))
|
|
266
|
+
throw new Error(`registration ${id} custody is held by a live or unverifiable server`);
|
|
267
|
+
}
|
|
268
|
+
writePrivateJsonAtomic(path, record);
|
|
269
|
+
return record;
|
|
270
|
+
});
|
|
234
271
|
}
|
|
235
272
|
|
|
236
273
|
/** Best-effort registration that never throws (for server start paths). */
|
|
@@ -256,39 +293,132 @@ function callingOwner(coordRoot: string): ServerRecord["owner"] {
|
|
|
256
293
|
}
|
|
257
294
|
}
|
|
258
295
|
|
|
259
|
-
/**
|
|
260
|
-
|
|
261
|
-
* is removed, so a replacement that re-registered the same id survives.
|
|
262
|
-
*/
|
|
263
|
-
export function unregisterServer(id: string, options?: ServerOptions & { pid?: number }): boolean {
|
|
296
|
+
/** Remove only the exact registration receipt observed by the caller. */
|
|
297
|
+
export function unregisterServer(record: ServerRecord, options?: ServerOptions): boolean {
|
|
264
298
|
const coordRoot = rootOf(options);
|
|
265
|
-
if (!coordRoot || !isValidServerId(id)) return false;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
299
|
+
if (!coordRoot || !record || !isValidServerId(record.id)) return false;
|
|
300
|
+
return withServerMutation(coordRoot, record.id, () => removeServerReceipt(coordRoot, record));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Hold the exact registration receipt until a synchronous or asynchronous effect settles. */
|
|
304
|
+
export function withServerRegistrationCustody<T>(
|
|
305
|
+
record: ServerRecord,
|
|
306
|
+
options: ServerOptions,
|
|
307
|
+
effect: () => PromiseLike<T>,
|
|
308
|
+
): Promise<T>;
|
|
309
|
+
export function withServerRegistrationCustody<T>(
|
|
310
|
+
record: ServerRecord,
|
|
311
|
+
options: ServerOptions,
|
|
312
|
+
effect: () => T,
|
|
313
|
+
): T;
|
|
314
|
+
export function withServerRegistrationCustody<T>(
|
|
315
|
+
record: ServerRecord,
|
|
316
|
+
options: ServerOptions,
|
|
317
|
+
effect: () => T | PromiseLike<T>,
|
|
318
|
+
): T | Promise<T> {
|
|
319
|
+
const coordRoot = rootOf(options);
|
|
320
|
+
if (!coordRoot) throw new Error("registration custody root is unavailable");
|
|
321
|
+
const lease = serverMutationLease(coordRoot, record.id);
|
|
322
|
+
try {
|
|
323
|
+
const current = readServer(record.id, { coordRoot });
|
|
324
|
+
if (
|
|
325
|
+
!current ||
|
|
326
|
+
current.registration_id !== record.registration_id ||
|
|
327
|
+
!sameProcessCustody(current, record)
|
|
328
|
+
)
|
|
329
|
+
throw new Error(`registration ${record.id} custody changed`);
|
|
330
|
+
const result = effect();
|
|
331
|
+
if (
|
|
332
|
+
result !== null &&
|
|
333
|
+
(typeof result === "object" || typeof result === "function") &&
|
|
334
|
+
typeof (result as PromiseLike<T>).then === "function"
|
|
335
|
+
)
|
|
336
|
+
return Promise.resolve(result).finally(() => lease.release());
|
|
337
|
+
lease.release();
|
|
338
|
+
return result as T;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
lease.release();
|
|
341
|
+
throw error;
|
|
270
342
|
}
|
|
271
|
-
|
|
272
|
-
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function removeServerReceipt(coordRoot: string, record: ServerRecord): boolean {
|
|
346
|
+
const path = recordPath(coordRoot, record.id);
|
|
347
|
+
const current = readRecord(path);
|
|
348
|
+
if (
|
|
349
|
+
!current ||
|
|
350
|
+
current.registration_id !== record.registration_id ||
|
|
351
|
+
!sameProcessCustody(current, record)
|
|
352
|
+
)
|
|
353
|
+
return false;
|
|
354
|
+
rmSync(path);
|
|
273
355
|
return true;
|
|
274
356
|
}
|
|
275
357
|
|
|
358
|
+
function serverMutationLease(coordRoot: string, id: string) {
|
|
359
|
+
recordPath(coordRoot, id);
|
|
360
|
+
const scope = `server-registration-${id}`;
|
|
361
|
+
return acquireNoClobberLease({
|
|
362
|
+
path: join(serversDir(coordRoot), ".custody", id),
|
|
363
|
+
scope,
|
|
364
|
+
authoritySha256: createHash("sha256").update(scope).digest("hex"),
|
|
365
|
+
staleAfterMs: 1,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function withServerMutation<T>(coordRoot: string, id: string, operation: () => T): T {
|
|
370
|
+
const lease = serverMutationLease(coordRoot, id);
|
|
371
|
+
try {
|
|
372
|
+
return operation();
|
|
373
|
+
} finally {
|
|
374
|
+
lease.release();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function sameProcessCustody(a: ServerRecord, b: ServerRecord): boolean {
|
|
379
|
+
const helpers = (record: ServerRecord) =>
|
|
380
|
+
[...new Set(record.pids ?? [])]
|
|
381
|
+
.sort((x, y) => x - y)
|
|
382
|
+
.map((pid) => [pid, record.pids_start_tokens?.[pid] ?? null]);
|
|
383
|
+
return (
|
|
384
|
+
a.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
|
|
385
|
+
b.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
|
|
386
|
+
a.id === b.id &&
|
|
387
|
+
a.pid === b.pid &&
|
|
388
|
+
a.start_token === b.start_token &&
|
|
389
|
+
a.host === b.host &&
|
|
390
|
+
a.kind === b.kind &&
|
|
391
|
+
a.type === b.type &&
|
|
392
|
+
a.scope === b.scope &&
|
|
393
|
+
a.owner?.instance_id === b.owner?.instance_id &&
|
|
394
|
+
JSON.stringify(helpers(a)) === JSON.stringify(helpers(b))
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
276
398
|
export function readServer(id: string, options?: ServerOptions): ServerRecord | null {
|
|
277
399
|
const coordRoot = rootOf(options);
|
|
278
400
|
if (!coordRoot || !isValidServerId(id)) return null;
|
|
279
|
-
|
|
401
|
+
const path = recordPath(coordRoot, id);
|
|
402
|
+
const record = readRecord(path);
|
|
403
|
+
if (!record && existsSync(path))
|
|
404
|
+
throw new Error(
|
|
405
|
+
`server record ${id} is invalid or uses an older schema; retained without custody`,
|
|
406
|
+
);
|
|
407
|
+
return record;
|
|
280
408
|
}
|
|
281
409
|
|
|
282
410
|
/** Mark a server as used now. */
|
|
283
411
|
export function touchServer(id: string, options?: ServerOptions & { now?: Date }): void {
|
|
284
412
|
const coordRoot = rootOf(options);
|
|
285
413
|
if (!coordRoot || !isValidServerId(id)) return;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
414
|
+
withServerMutation(coordRoot, id, () => {
|
|
415
|
+
const path = recordPath(coordRoot, id);
|
|
416
|
+
const record = readRecord(path);
|
|
417
|
+
if (!record) return;
|
|
418
|
+
writePrivateJsonAtomic(path, {
|
|
419
|
+
...record,
|
|
420
|
+
last_active_at: (options?.now ?? new Date()).toISOString(),
|
|
421
|
+
});
|
|
292
422
|
});
|
|
293
423
|
}
|
|
294
424
|
|
|
@@ -317,6 +447,8 @@ function readRecord(path: string): ServerRecord | null {
|
|
|
317
447
|
if (
|
|
318
448
|
value?.schema_version !== SERVER_RECORD_SCHEMA_VERSION ||
|
|
319
449
|
typeof value.id !== "string" ||
|
|
450
|
+
typeof value.registration_id !== "string" ||
|
|
451
|
+
!/^[0-9a-f-]{36}$/.test(value.registration_id) ||
|
|
320
452
|
!Number.isSafeInteger(value.pid) ||
|
|
321
453
|
(value.kind !== "service" && value.kind !== "session")
|
|
322
454
|
)
|
|
@@ -334,8 +466,14 @@ export function readServerRecords(options?: ServerOptions): ServerRecord[] {
|
|
|
334
466
|
if (!existsSync(dir)) return [];
|
|
335
467
|
return readdirSync(dir)
|
|
336
468
|
.filter((name) => name.endsWith(".json"))
|
|
337
|
-
.map((name) =>
|
|
338
|
-
|
|
469
|
+
.map((name) => {
|
|
470
|
+
const record = readRecord(join(dir, name));
|
|
471
|
+
if (!record)
|
|
472
|
+
throw new Error(
|
|
473
|
+
`server record ${name} is invalid or uses an older schema; retained without custody`,
|
|
474
|
+
);
|
|
475
|
+
return record;
|
|
476
|
+
})
|
|
339
477
|
.sort(
|
|
340
478
|
(a, b) =>
|
|
341
479
|
a.kind.localeCompare(b.kind) || a.type.localeCompare(b.type) || a.id.localeCompare(b.id),
|
|
@@ -343,18 +481,40 @@ export function readServerRecords(options?: ServerOptions): ServerRecord[] {
|
|
|
343
481
|
}
|
|
344
482
|
|
|
345
483
|
export function pidAlive(pid: number): boolean {
|
|
484
|
+
return processPresence(pid) !== "absent";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function processPresence(
|
|
488
|
+
pid: number,
|
|
489
|
+
probePid?: (pid: number) => boolean,
|
|
490
|
+
): "present" | "absent" | "unknown" {
|
|
346
491
|
try {
|
|
492
|
+
if (probePid) return probePid(pid) ? "present" : "absent";
|
|
347
493
|
process.kill(pid, 0);
|
|
348
|
-
return
|
|
494
|
+
return "present";
|
|
349
495
|
} catch (error) {
|
|
350
|
-
|
|
496
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
497
|
+
return code === "ESRCH" ? "absent" : code === "EPERM" ? "present" : "unknown";
|
|
351
498
|
}
|
|
352
499
|
}
|
|
353
500
|
|
|
354
|
-
|
|
501
|
+
function allProcessesAbsent(record: ServerRecord, probePid?: (pid: number) => boolean): boolean {
|
|
502
|
+
return [record.pid, ...(record.pids ?? [])].every(
|
|
503
|
+
(pid) => processPresence(pid, probePid) === "absent",
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function serverState(
|
|
508
|
+
record: ServerRecord,
|
|
509
|
+
readToken?: (pid: number) => string | null,
|
|
510
|
+
probePid?: (pid: number) => boolean,
|
|
511
|
+
): ServerState {
|
|
355
512
|
if (record.host !== hostname()) return "other-host";
|
|
356
|
-
|
|
357
|
-
return
|
|
513
|
+
const presence = processPresence(record.pid, probePid);
|
|
514
|
+
if (presence === "absent") return allProcessesAbsent(record, probePid) ? "dead" : "unknown";
|
|
515
|
+
if (presence === "unknown") return "unknown";
|
|
516
|
+
const verdict = verifyProcessGeneration(record.pid, record.start_token, readToken);
|
|
517
|
+
return verdict === "same" ? "running" : verdict === "different" ? "dead" : "unknown";
|
|
358
518
|
}
|
|
359
519
|
|
|
360
520
|
type OwnerReader = (instanceId: string) => OwnerState;
|
|
@@ -395,7 +555,6 @@ export function listServers(options?: ListServersOptions): ServersReport {
|
|
|
395
555
|
const now = options?.now ?? new Date();
|
|
396
556
|
const nowMs = now.getTime();
|
|
397
557
|
const ownerStaleMs = hours(options?.ownerStaleHours ?? SERVER_DEFAULT_OWNER_STALE_HOURS);
|
|
398
|
-
adoptRunningTunnels(coordRoot);
|
|
399
558
|
const records = readServerRecords({ coordRoot });
|
|
400
559
|
const ownerOf = ownerReader(coordRoot, ownerStaleMs, nowMs);
|
|
401
560
|
const states = records.map((record) => serverState(record));
|
|
@@ -407,8 +566,8 @@ export function listServers(options?: ListServersOptions): ServersReport {
|
|
|
407
566
|
const servers: ServerView[] = [];
|
|
408
567
|
records.forEach((record, i) => {
|
|
409
568
|
const state = states[i]!;
|
|
410
|
-
if (state === "dead" && options?.prune !== false) {
|
|
411
|
-
unregisterServer(record
|
|
569
|
+
if (state === "dead" && allProcessesAbsent(record) && options?.prune !== false) {
|
|
570
|
+
unregisterServer(record, { coordRoot });
|
|
412
571
|
pruned.push(record.id);
|
|
413
572
|
return;
|
|
414
573
|
}
|
|
@@ -446,24 +605,6 @@ export function listServers(options?: ListServersOptions): ServersReport {
|
|
|
446
605
|
};
|
|
447
606
|
}
|
|
448
607
|
|
|
449
|
-
/** Register running tunnels that have no current record. Returns adopted ids. */
|
|
450
|
-
export function adoptRunningTunnels(coordRoot: string): string[] {
|
|
451
|
-
const adopted: string[] = [];
|
|
452
|
-
let states: ReturnType<typeof listStates>;
|
|
453
|
-
try {
|
|
454
|
-
states = listStates(coordRoot);
|
|
455
|
-
} catch {
|
|
456
|
-
return adopted;
|
|
457
|
-
}
|
|
458
|
-
for (const state of states) {
|
|
459
|
-
if (!state.gate_pid || !pidAlive(state.gate_pid)) continue;
|
|
460
|
-
const id = tunnelServerId(state.name);
|
|
461
|
-
if (readServer(id, { coordRoot })?.pid === state.gate_pid) continue;
|
|
462
|
-
if (tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot })) adopted.push(id);
|
|
463
|
-
}
|
|
464
|
-
return adopted;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
608
|
/** Processes that browser automation and editors start; never project servers. */
|
|
468
609
|
const IGNORED_LISTENER_COMMANDS = [
|
|
469
610
|
/(^|\/)(chrome|chromium|chrome-headless-shell|headless_shell|msedge|firefox)( |$)/i,
|
|
@@ -481,10 +622,10 @@ export function findUnregisteredListeners(
|
|
|
481
622
|
coordRoot: string,
|
|
482
623
|
servers: Array<Pick<ServerView, "record" | "state">>,
|
|
483
624
|
): UnregisteredListener[] {
|
|
484
|
-
const
|
|
625
|
+
const registered = servers.filter((server) => server.record.host === hostname());
|
|
485
626
|
const covered = new Set<number>();
|
|
486
627
|
const ports = new Set<number>();
|
|
487
|
-
for (const { record } of
|
|
628
|
+
for (const { record } of registered) {
|
|
488
629
|
covered.add(record.pid);
|
|
489
630
|
for (const pid of record.pids ?? []) covered.add(pid);
|
|
490
631
|
if (record.port) ports.add(record.port);
|
|
@@ -492,7 +633,8 @@ export function findUnregisteredListeners(
|
|
|
492
633
|
const results: UnregisteredListener[] = [];
|
|
493
634
|
const seenPids = new Map<number, boolean>();
|
|
494
635
|
for (const socket of listListeningSockets()) {
|
|
495
|
-
if (ports.has(socket.port) || socket.pid === process.pid) continue;
|
|
636
|
+
if (ports.has(socket.port) || covered.has(socket.pid) || socket.pid === process.pid) continue;
|
|
637
|
+
const startToken = processStartToken(socket.pid);
|
|
496
638
|
let inside = seenPids.get(socket.pid);
|
|
497
639
|
const info = readProcessInfo(socket.pid);
|
|
498
640
|
if (!info) continue;
|
|
@@ -506,6 +648,7 @@ export function findUnregisteredListeners(
|
|
|
506
648
|
if (!inside) continue;
|
|
507
649
|
results.push({
|
|
508
650
|
pid: socket.pid,
|
|
651
|
+
...(startToken ? { start_token: startToken } : {}),
|
|
509
652
|
port: socket.port,
|
|
510
653
|
address: socket.address,
|
|
511
654
|
command: info.command.slice(0, 300),
|
|
@@ -568,77 +711,156 @@ export function planServerGc(views: ServerView[], policy?: GcPolicy): GcDecision
|
|
|
568
711
|
export interface StopResult {
|
|
569
712
|
id: string;
|
|
570
713
|
stopped: boolean;
|
|
571
|
-
method: "not-running" | "
|
|
714
|
+
method: "not-running" | "signal" | "refused";
|
|
715
|
+
generation?: ProcessGenerationVerdict;
|
|
572
716
|
detail?: string;
|
|
573
717
|
}
|
|
574
718
|
|
|
719
|
+
export interface ServerProcessDependencies extends ProcessGenerationSignalDependencies {
|
|
720
|
+
probePid?: (pid: number) => boolean;
|
|
721
|
+
}
|
|
722
|
+
|
|
575
723
|
/**
|
|
576
|
-
* Stop
|
|
577
|
-
*
|
|
578
|
-
*
|
|
724
|
+
* Stop a verified server with SIGTERM, then SIGKILL after the grace period.
|
|
725
|
+
* Every signal targets one retained process handle, including auxiliary processes.
|
|
726
|
+
* Each target requires a fresh generation check. Unknown or different generations retain the record.
|
|
579
727
|
*/
|
|
580
728
|
export async function stopServer(
|
|
581
729
|
record: ServerRecord,
|
|
582
|
-
options?: ServerOptions & {
|
|
730
|
+
options?: ServerOptions & {
|
|
731
|
+
graceMs?: number;
|
|
732
|
+
signalDependencies?: ServerProcessDependencies;
|
|
733
|
+
},
|
|
583
734
|
): Promise<StopResult> {
|
|
584
735
|
const coordRoot = rootOf(options);
|
|
736
|
+
if (!coordRoot)
|
|
737
|
+
return {
|
|
738
|
+
id: record.id,
|
|
739
|
+
stopped: false,
|
|
740
|
+
method: "refused",
|
|
741
|
+
generation: "unknown",
|
|
742
|
+
detail: "registration custody root is unavailable",
|
|
743
|
+
};
|
|
744
|
+
let lease: ReturnType<typeof serverMutationLease>;
|
|
745
|
+
try {
|
|
746
|
+
lease = serverMutationLease(coordRoot, record.id);
|
|
747
|
+
} catch {
|
|
748
|
+
return {
|
|
749
|
+
id: record.id,
|
|
750
|
+
stopped: false,
|
|
751
|
+
method: "refused",
|
|
752
|
+
generation: "unknown",
|
|
753
|
+
detail: "registration custody is unavailable",
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
const current = readServer(record.id, { coordRoot });
|
|
758
|
+
if (!current || current.registration_id !== record.registration_id)
|
|
759
|
+
return {
|
|
760
|
+
id: record.id,
|
|
761
|
+
stopped: false,
|
|
762
|
+
method: "refused",
|
|
763
|
+
generation: "unknown",
|
|
764
|
+
detail: "registration custody changed; retained current server",
|
|
765
|
+
};
|
|
766
|
+
return await stopServerWithCustody(record, coordRoot, options);
|
|
767
|
+
} finally {
|
|
768
|
+
lease.release();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function stopServerWithCustody(
|
|
773
|
+
record: ServerRecord,
|
|
774
|
+
coordRoot: string,
|
|
775
|
+
options?: ServerOptions & { graceMs?: number; signalDependencies?: ServerProcessDependencies },
|
|
776
|
+
): Promise<StopResult> {
|
|
585
777
|
const graceMs = options?.graceMs ?? 5_000;
|
|
778
|
+
const dependencies = options?.signalDependencies ?? {};
|
|
779
|
+
const targets = [
|
|
780
|
+
{ pid: record.pid, token: record.start_token },
|
|
781
|
+
...[...new Set(record.pids ?? [])]
|
|
782
|
+
.filter((pid) => pid !== record.pid)
|
|
783
|
+
.map((pid) => ({ pid, token: record.pids_start_tokens?.[pid] })),
|
|
784
|
+
];
|
|
785
|
+
const refuse = (pid: number, generation: ProcessGenerationVerdict): StopResult => ({
|
|
786
|
+
id: record.id,
|
|
787
|
+
stopped: false,
|
|
788
|
+
method: "refused",
|
|
789
|
+
generation,
|
|
790
|
+
detail: `pid ${pid} generation is ${generation}; left the server record in place`,
|
|
791
|
+
});
|
|
586
792
|
const finish = (result: StopResult): StopResult => {
|
|
587
|
-
if (result.stopped || result.method === "not-running")
|
|
588
|
-
unregisterServer(record.id, { coordRoot, pid: record.pid });
|
|
793
|
+
if (result.stopped || result.method === "not-running") removeServerReceipt(coordRoot, record);
|
|
589
794
|
return result;
|
|
590
795
|
};
|
|
591
|
-
|
|
592
|
-
if (state === "other-host")
|
|
796
|
+
if (record.host !== hostname())
|
|
593
797
|
return { id: record.id, stopped: false, method: "refused", detail: `runs on ${record.host}` };
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
798
|
+
const leaderPresence = processPresence(record.pid, dependencies.probePid);
|
|
799
|
+
if (
|
|
800
|
+
leaderPresence === "unknown" ||
|
|
801
|
+
(leaderPresence === "absent" && !allProcessesAbsent(record, dependencies.probePid))
|
|
802
|
+
)
|
|
803
|
+
return refuse(record.pid, "unknown");
|
|
804
|
+
// Check every recorded live process before sending the first signal.
|
|
805
|
+
for (const target of targets) {
|
|
806
|
+
const presence = processPresence(target.pid, dependencies.probePid);
|
|
807
|
+
if (presence === "absent") continue;
|
|
808
|
+
if (presence === "unknown") return refuse(target.pid, "unknown");
|
|
809
|
+
const verdict = verifyProcessGeneration(target.pid, target.token, dependencies.readToken);
|
|
810
|
+
if (verdict !== "same") return refuse(target.pid, verdict);
|
|
601
811
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
if (
|
|
607
|
-
|
|
608
|
-
for (const
|
|
812
|
+
if (targets.every((target) => processPresence(target.pid, dependencies.probePid) === "absent"))
|
|
813
|
+
return finish({ id: record.id, stopped: false, method: "not-running" });
|
|
814
|
+
|
|
815
|
+
const current = readServer(record.id, { coordRoot });
|
|
816
|
+
if (!current || !sameProcessCustody(current, record)) return refuse(record.pid, "unknown");
|
|
817
|
+
for (const name of ["SIGTERM", "SIGKILL"] as const) {
|
|
818
|
+
for (const target of targets) {
|
|
819
|
+
const presence = processPresence(target.pid, dependencies.probePid);
|
|
820
|
+
if (presence === "absent") continue;
|
|
821
|
+
if (presence === "unknown") return refuse(target.pid, "unknown");
|
|
822
|
+
const result = signalIfSameGeneration(target.pid, target.token, name, dependencies);
|
|
823
|
+
if (!result.sent || result.verdict !== "same")
|
|
824
|
+
return refuse(target.pid, result.verdict === "same" ? "unknown" : result.verdict);
|
|
825
|
+
}
|
|
826
|
+
const observed = await waitForExit(targets, name === "SIGTERM" ? graceMs : 2_000, dependencies);
|
|
827
|
+
if (observed.refused) return refuse(observed.refused.pid, observed.refused.verdict);
|
|
828
|
+
if (observed.exited)
|
|
829
|
+
return finish({ id: record.id, stopped: true, method: "signal", generation: "same" });
|
|
609
830
|
}
|
|
610
|
-
|
|
611
|
-
return finish({
|
|
831
|
+
return {
|
|
612
832
|
id: record.id,
|
|
613
|
-
stopped,
|
|
833
|
+
stopped: false,
|
|
614
834
|
method: "signal",
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function signal(pid: number, name: NodeJS.Signals): void {
|
|
620
|
-
// Detached servers lead their own process group; signal the group so a
|
|
621
|
-
// wrapper (npm, bunx) does not orphan the real listener.
|
|
622
|
-
try {
|
|
623
|
-
process.kill(-pid, name);
|
|
624
|
-
return;
|
|
625
|
-
} catch {
|
|
626
|
-
// Not a group leader, or already gone.
|
|
627
|
-
}
|
|
628
|
-
try {
|
|
629
|
-
process.kill(pid, name);
|
|
630
|
-
} catch {
|
|
631
|
-
// Already gone.
|
|
632
|
-
}
|
|
835
|
+
generation: "same",
|
|
836
|
+
detail: "process survived SIGKILL",
|
|
837
|
+
};
|
|
633
838
|
}
|
|
634
839
|
|
|
635
|
-
async function
|
|
840
|
+
async function waitForExit(
|
|
841
|
+
targets: Array<{ pid: number; token: string | undefined }>,
|
|
842
|
+
waitMs: number,
|
|
843
|
+
dependencies: ServerProcessDependencies,
|
|
844
|
+
): Promise<{
|
|
845
|
+
exited: boolean;
|
|
846
|
+
refused?: { pid: number; verdict: ProcessGenerationVerdict };
|
|
847
|
+
}> {
|
|
636
848
|
const deadline = Date.now() + waitMs;
|
|
637
|
-
while (
|
|
638
|
-
|
|
849
|
+
while (true) {
|
|
850
|
+
let live = false;
|
|
851
|
+
for (const target of targets) {
|
|
852
|
+
const presence = processPresence(target.pid, dependencies.probePid);
|
|
853
|
+
if (presence === "absent") continue;
|
|
854
|
+
if (presence === "unknown")
|
|
855
|
+
return { exited: false, refused: { pid: target.pid, verdict: "unknown" } };
|
|
856
|
+
const verdict = verifyProcessGeneration(target.pid, target.token, dependencies.readToken);
|
|
857
|
+
if (verdict !== "same") return { exited: false, refused: { pid: target.pid, verdict } };
|
|
858
|
+
live = true;
|
|
859
|
+
}
|
|
860
|
+
if (!live) return { exited: true };
|
|
861
|
+
if (Date.now() >= deadline) return { exited: false };
|
|
639
862
|
await new Promise((r) => setTimeout(r, 100));
|
|
640
863
|
}
|
|
641
|
-
return serverState(record) !== "running";
|
|
642
864
|
}
|
|
643
865
|
|
|
644
866
|
/**
|
|
@@ -653,7 +875,8 @@ export function allocateServerPort(type: string, options?: ServerOptions): numbe
|
|
|
653
875
|
if (!range) return null;
|
|
654
876
|
const taken = listListeningPorts();
|
|
655
877
|
for (const record of readServerRecords({ coordRoot })) {
|
|
656
|
-
|
|
878
|
+
const state = serverState(record);
|
|
879
|
+
if (record.port && (state === "running" || state === "unknown")) taken.add(record.port);
|
|
657
880
|
}
|
|
658
881
|
for (let port = range[0]; port <= range[1]; port++) if (!taken.has(port)) return port;
|
|
659
882
|
throw new Error(`no free port for ${type} in ${range[0]}-${range[1]}`);
|
|
@@ -666,6 +889,10 @@ export interface AdoptOptions extends ServerOptions {
|
|
|
666
889
|
scope?: string;
|
|
667
890
|
/** `self` (default) records the adopting agent as owner; `none` records no owner. */
|
|
668
891
|
owner?: "self" | "none";
|
|
892
|
+
dependencies?: {
|
|
893
|
+
listServers?: typeof listServers;
|
|
894
|
+
readToken?: (pid: number) => string | null;
|
|
895
|
+
};
|
|
669
896
|
}
|
|
670
897
|
|
|
671
898
|
/**
|
|
@@ -675,12 +902,19 @@ export interface AdoptOptions extends ServerOptions {
|
|
|
675
902
|
export function adoptServer(pid: number, options?: AdoptOptions): ServerRecord {
|
|
676
903
|
const coordRoot = rootOf(options);
|
|
677
904
|
if (!coordRoot) throw new Error("no Harnery project found (coordination root did not resolve)");
|
|
678
|
-
const report = listServers({ coordRoot, sample: false });
|
|
905
|
+
const report = (options?.dependencies?.listServers ?? listServers)({ coordRoot, sample: false });
|
|
679
906
|
const listeners = report.unregistered.filter((item) => item.pid === pid);
|
|
680
907
|
if (!listeners.length)
|
|
681
908
|
throw new Error(`pid ${pid} is not an unregistered listener inside this project`);
|
|
682
909
|
const ports = listeners.map((item) => item.port).sort((a, b) => a - b);
|
|
683
910
|
const first = listeners[0]!;
|
|
911
|
+
const generation = verifyProcessGeneration(
|
|
912
|
+
pid,
|
|
913
|
+
first.start_token,
|
|
914
|
+
options?.dependencies?.readToken,
|
|
915
|
+
);
|
|
916
|
+
if (generation !== "same")
|
|
917
|
+
throw new Error(`pid ${pid} generation is ${generation}; adoption refused`);
|
|
684
918
|
const type = options?.type ?? "adopted";
|
|
685
919
|
const scope = options?.scope ?? first.cwd ?? undefined;
|
|
686
920
|
const started = processStartedAt(pid);
|
|
@@ -693,6 +927,7 @@ export function adoptServer(pid: number, options?: AdoptOptions): ServerRecord {
|
|
|
693
927
|
url: `http://127.0.0.1:${ports[0]}/`,
|
|
694
928
|
port: ports[0],
|
|
695
929
|
pid,
|
|
930
|
+
start_token: first.start_token,
|
|
696
931
|
...(scope ? { scope } : {}),
|
|
697
932
|
...(first.cwd ? { cwd: first.cwd } : {}),
|
|
698
933
|
...(options?.owner === "none" ? { owner: null } : {}),
|