harnery 0.45.1 → 0.45.2
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/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +6 -45
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +0 -18
- package/dist/core/agents/state/pidmap.js +2 -2
- package/dist/core/agents/state/proc-start.d.ts +20 -31
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +37 -70
- package/dist/core/agents/state/service-process.d.ts +61 -0
- package/dist/core/agents/state/service-process.d.ts.map +1 -0
- package/dist/core/agents/state/service-process.js +143 -0
- package/dist/core/context/index.d.ts +6 -4
- package/dist/core/context/index.d.ts.map +1 -1
- package/dist/core/context/index.js +194 -256
- package/dist/core/context/observation.d.ts +9 -9
- package/dist/core/context/observation.d.ts.map +1 -1
- package/dist/core/context/observation.js +37 -6
- package/dist/core/events/v3/runtime-build.d.ts +14 -0
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +44 -0
- package/dist/core/governor/index.d.ts +2 -1
- package/dist/core/governor/index.d.ts.map +1 -1
- package/dist/core/governor/index.js +2 -1
- package/dist/core/governor/service-read.d.ts +3 -10
- package/dist/core/governor/service-read.d.ts.map +1 -1
- package/dist/core/governor/service-read.js +10 -94
- package/dist/core/governor/service.d.ts +3 -7
- package/dist/core/governor/service.d.ts.map +1 -1
- package/dist/core/governor/service.js +17 -182
- package/dist/core/hooks/resolve/owner.js +2 -2
- package/dist/core/resources/sampler.js +2 -2
- package/dist/core/resources/service-status.d.ts +2 -14
- package/dist/core/resources/service-status.d.ts.map +1 -1
- package/dist/core/resources/service-status.js +3 -23
- package/dist/core/resources/service.d.ts +3 -7
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +12 -101
- package/dist/core/resources/status.d.ts.map +1 -1
- package/dist/core/resources/status.js +3 -2
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +9 -2
- package/dist/core/servers/index.d.ts +0 -2
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +3 -18
- package/dist/core/supervisor/service.d.ts +4 -7
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/supervisor/service.js +23 -122
- package/dist/core/supervisor/services.js +2 -2
- package/dist/core/supervisor/status.d.ts +3 -17
- package/dist/core/supervisor/status.d.ts.map +1 -1
- package/dist/core/supervisor/status.js +7 -70
- package/dist/core/workflow/engine.js +2 -1
- package/dist/core/workflow/index.d.ts +3 -4
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -2
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +30 -74
- package/dist/core/workflow/resume-lock.d.ts +17 -0
- package/dist/core/workflow/resume-lock.d.ts.map +1 -0
- package/dist/core/workflow/resume-lock.js +158 -0
- package/dist/core/workflow/run-state.d.ts +0 -21
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +3 -174
- package/dist/core/workflow/workspaces/leases.d.ts +4 -0
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +1 -1
- package/dist/core/workflow/workspaces/local-git.js +91 -210
- package/dist/core/workflow/workspaces/ownership.d.ts +0 -7
- package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/ownership.js +3 -130
- package/dist/core/workflow/workspaces/reclaim.d.ts +4 -7
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/reclaim.js +0 -4
- package/dist/core/workflow/workspaces/recovery.d.ts +1 -1
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +1 -1
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/state.js +1 -4
- package/package.json +1 -1
- package/src/commands/workflow.ts +8 -58
- package/src/core/agents/coord-client.ts +0 -20
- package/src/core/agents/state/pidmap.ts +2 -2
- package/src/core/agents/state/proc-start.ts +41 -69
- package/src/core/agents/state/service-process.ts +217 -0
- package/src/core/context/index.ts +207 -272
- package/src/core/context/observation.ts +40 -9
- package/src/core/events/v3/runtime-build.ts +62 -0
- package/src/core/governor/index.ts +1 -2
- package/src/core/governor/service-read.ts +15 -114
- package/src/core/governor/service.ts +36 -220
- package/src/core/hooks/resolve/owner.ts +2 -2
- package/src/core/resources/sampler.ts +3 -2
- package/src/core/resources/service-status.ts +6 -35
- package/src/core/resources/service.ts +23 -133
- package/src/core/resources/status.ts +3 -2
- package/src/core/semantic/service.ts +8 -2
- package/src/core/servers/index.ts +3 -23
- package/src/core/supervisor/service.ts +33 -155
- package/src/core/supervisor/services.ts +2 -2
- package/src/core/supervisor/status.ts +10 -85
- package/src/core/workflow/engine.ts +1 -1
- package/src/core/workflow/index.ts +2 -13
- package/src/core/workflow/proof.ts +35 -83
- package/src/core/workflow/resume-lock.ts +179 -0
- package/src/core/workflow/run-state.ts +3 -207
- package/src/core/workflow/workspaces/leases.ts +1 -1
- package/src/core/workflow/workspaces/local-git.ts +120 -239
- package/src/core/workflow/workspaces/ownership.ts +3 -158
- package/src/core/workflow/workspaces/reclaim.ts +3 -9
- package/src/core/workflow/workspaces/recovery.ts +1 -1
- package/src/core/workflow/workspaces/state.ts +1 -5
- package/dist/core/agents/state/process-handle.d.ts +0 -26
- package/dist/core/agents/state/process-handle.d.ts.map +0 -1
- package/dist/core/agents/state/process-handle.js +0 -121
- package/dist/core/workflow/recovery-audit.d.ts +0 -30
- package/dist/core/workflow/recovery-audit.d.ts.map +0 -1
- package/dist/core/workflow/recovery-audit.js +0 -116
- package/src/core/agents/state/process-handle.ts +0 -165
- package/src/core/workflow/recovery-audit.ts +0 -181
|
@@ -51,7 +51,6 @@ export function prepareReclaim(input) {
|
|
|
51
51
|
// cannot see. Running clean first would leave staged deletions behind.
|
|
52
52
|
input.beforeEffect?.();
|
|
53
53
|
git(input.worktreePath, ["reset", "--hard"]);
|
|
54
|
-
input.beforeEffect?.();
|
|
55
54
|
git(input.worktreePath, ["clean", "-fd"]);
|
|
56
55
|
return { action: "discarded", detail: "uncommitted changes were discarded on request" };
|
|
57
56
|
}
|
|
@@ -71,13 +70,10 @@ export function prepareReclaim(input) {
|
|
|
71
70
|
const before = git(input.worktreePath, ["rev-parse", "HEAD"]).trim();
|
|
72
71
|
input.beforeEffect?.();
|
|
73
72
|
git(input.worktreePath, ["add", "-A"]);
|
|
74
|
-
input.beforeEffect?.();
|
|
75
73
|
git(input.worktreePath, ["commit", "--no-verify", "-m", salvageMessage(input.runId)]);
|
|
76
74
|
const commit = git(input.worktreePath, ["rev-parse", "HEAD"]).trim();
|
|
77
75
|
const branch = salvageBranch(input.runId);
|
|
78
|
-
input.beforeEffect?.();
|
|
79
76
|
git(input.worktreePath, ["branch", "--force", branch, commit]);
|
|
80
|
-
input.beforeEffect?.();
|
|
81
77
|
git(input.worktreePath, ["reset", "--hard", before]);
|
|
82
78
|
return {
|
|
83
79
|
action: "salvaged",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type WorkflowLiveness = "active" | "inactive" | "unknown";
|
|
2
|
-
/** Corruption must not hide a readable active marker from
|
|
2
|
+
/** Corruption must not hide a readable active marker from a forced reclaim. */
|
|
3
3
|
export declare function hasActiveWorkflowEvidence(coordRoot: string, runId: string): boolean;
|
|
4
4
|
export declare function readWorkflowLiveness(coordRoot: string, runId: string): WorkflowLiveness;
|
|
5
5
|
//# sourceMappingURL=recovery.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"recovery.d.ts","sourceRoot":"","sources":["../../../../src/core/workflow/workspaces/recovery.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC;AAEjE
|
|
1
|
+
{"version":3,"file":"recovery.d.ts","sourceRoot":"","sources":["../../../../src/core/workflow/workspaces/recovery.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC;AAEjE,+EAA+E;AAC/E,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBnF;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,gBAAgB,CA6DvF"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { readWorkflowProof } from "../proof.js";
|
|
4
|
-
/** Corruption must not hide a readable active marker from
|
|
4
|
+
/** Corruption must not hide a readable active marker from a forced reclaim. */
|
|
5
5
|
export function hasActiveWorkflowEvidence(coordRoot, runId) {
|
|
6
6
|
const path = join(resolve(coordRoot), ".harnery", "workflows", runId, "transcript.jsonl");
|
|
7
7
|
if (!existsSync(path))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../../src/core/workflow/workspaces/state.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,6BAA6B,EAAE,MAAM,kBAAkB,CAAC;AAEjE,OAAO,KAAK,EACV,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,EACvB,wBAAwB,EACxB,eAAe,EACf,uBAAuB,EACvB,0BAA0B,EAC1B,gBAAgB,EAChB,4BAA4B,EAC5B,uBAAuB,EACvB,sBAAsB,EACtB,uBAAuB,EACvB,sBAAsB,EACvB,MAAM,YAAY,CAAC;AAMpB,MAAM,WAAW,cAAc;IAC7B,cAAc,EAAE,CAAC,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,0BAA0B,CAAC;IACpC,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,gBAAgB,CAAC,eAAe,CAAC,CAAC;IACjD,UAAU,EAAE;QACV,WAAW,EAAE,oBAAoB,CAAC,aAAa,CAAC,CAAC;QACjD,UAAU,EAAE,oBAAoB,CAAC,YAAY,CAAC,CAAC;QAC/C,WAAW,EAAE,MAAM,CAAC;QACpB,aAAa,EAAE,MAAM,CAAC;QACtB,UAAU,EAAE,MAAM,CAAC;QACnB,aAAa,EAAE,MAAM,CAAC;QACtB,gBAAgB,EAAE,MAAM,CAAC;KAC1B,CAAC;CACH;AAED,MAAM,WAAW,sBAAsB;IACrC,cAAc,EAAE,CAAC,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAGvE;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,MAAM,CAIR;AAED,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,0BAA0B,GAClC,MAAM,CAIR;AAED,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,0BAA0B,GAAG,SAAS,CAKxC;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,GAAG,OAAO,
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../../src/core/workflow/workspaces/state.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readd
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { stateDirMode, stateFileMode } from "../../storage/modes.js";
|
|
4
4
|
import { canonicalJson, fsyncParentDirectory, readJsonRecord, stableDigest, writeImmutableJson, } from "../durable-record.js";
|
|
5
|
-
import { assertClaimAuthority
|
|
5
|
+
import { assertClaimAuthority } from "./ownership.js";
|
|
6
6
|
export { appendWorkflowTranscriptEvent } from "../transcript.js";
|
|
7
7
|
const JSON_LIMIT = 512 * 1024;
|
|
8
8
|
const WORKSPACE_EVENT_BYTES = 32 * 1024;
|
|
@@ -40,9 +40,6 @@ export function readWorkspaceRequest(coordRoot, runId) {
|
|
|
40
40
|
: undefined;
|
|
41
41
|
}
|
|
42
42
|
export function writeWorkspaceClaim(coordRoot, claim) {
|
|
43
|
-
return publishWorkspaceClaim(coordRoot, claim, () => writeClaim(coordRoot, claim));
|
|
44
|
-
}
|
|
45
|
-
function writeClaim(coordRoot, claim) {
|
|
46
43
|
const path = workspaceClaimPath(coordRoot, claim.provider_id, claim.binding_id);
|
|
47
44
|
try {
|
|
48
45
|
return writeImmutableJson(path, claim);
|
package/package.json
CHANGED
package/src/commands/workflow.ts
CHANGED
|
@@ -73,15 +73,7 @@ interface WorkflowConfirmedMutationOpts {
|
|
|
73
73
|
|
|
74
74
|
interface WorkflowReclaimOpts extends WorkflowConfirmedMutationOpts {
|
|
75
75
|
discard?: boolean;
|
|
76
|
-
|
|
77
|
-
actor?: string;
|
|
78
|
-
reason?: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface WorkflowUnlockOpts extends WorkflowConfirmedMutationOpts {
|
|
82
|
-
lockSha256?: string;
|
|
83
|
-
actor?: string;
|
|
84
|
-
reason?: string;
|
|
76
|
+
force?: boolean;
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
|
|
@@ -632,11 +624,9 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
632
624
|
.option("--yes", "Confirm that this commits or discards uncommitted work, then releases")
|
|
633
625
|
.option("--discard", "Throw the uncommitted work away instead of salvaging it")
|
|
634
626
|
.option(
|
|
635
|
-
"--
|
|
636
|
-
"
|
|
627
|
+
"--force",
|
|
628
|
+
"With --yes, admit missing or corrupt liveness evidence (active evidence still refuses) and record it",
|
|
637
629
|
)
|
|
638
|
-
.option("--actor <id>", "Actor responsible for an audited recovery")
|
|
639
|
-
.option("--reason <text>", "Reason for an audited recovery")
|
|
640
630
|
.option("--json", "Emit the reclaim preview or confirmed result as JSON")
|
|
641
631
|
.action(async (runId: string, opts: WorkflowReclaimOpts) => {
|
|
642
632
|
const coordRoot = resolveCoordRoot();
|
|
@@ -663,13 +653,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
663
653
|
const result = await provider.reclaim(binding, {
|
|
664
654
|
mode: opts.discard ? "discard" : "salvage",
|
|
665
655
|
yes: opts.yes,
|
|
666
|
-
|
|
667
|
-
? {
|
|
668
|
-
evidenceSha256: opts.unknownLiveness,
|
|
669
|
-
actor: opts.actor!,
|
|
670
|
-
reason: opts.reason!,
|
|
671
|
-
}
|
|
672
|
-
: undefined,
|
|
656
|
+
force: opts.force,
|
|
673
657
|
});
|
|
674
658
|
if (opts.json) {
|
|
675
659
|
emit.config({ format: "json" });
|
|
@@ -684,11 +668,13 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
684
668
|
emit.text(
|
|
685
669
|
`reclaim preview: ${result.run_id}\n` +
|
|
686
670
|
`mode: ${result.mode}\nliveness: ${result.liveness}\nownership: ${result.ownership}\n` +
|
|
687
|
-
`evidence sha256: ${result.evidence_sha256}\n` +
|
|
688
671
|
`lease holder: ${holders.length > 0 ? holders.join(", ") : "none observed"}\n` +
|
|
689
672
|
`dirty paths: ${result.dirty_paths === null ? "unknown" : result.dirty_paths.length > 0 ? result.dirty_paths.join(", ") : "none"}\n` +
|
|
690
673
|
(result.ownership_detail ? `ownership detail: ${result.ownership_detail}\n` : "") +
|
|
691
|
-
`Pass --yes to confirm ${result.mode} and release after all checks pass.\n
|
|
674
|
+
`Pass --yes to confirm ${result.mode} and release after all checks pass.\n` +
|
|
675
|
+
(result.liveness === "unknown"
|
|
676
|
+
? "Liveness is unknown; add --force with --yes only if the run is known to be stopped.\n"
|
|
677
|
+
: ""),
|
|
692
678
|
);
|
|
693
679
|
return;
|
|
694
680
|
}
|
|
@@ -708,42 +694,6 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
708
694
|
}
|
|
709
695
|
});
|
|
710
696
|
|
|
711
|
-
workflow
|
|
712
|
-
.command("unlock <run-id>")
|
|
713
|
-
.description(
|
|
714
|
-
"Preview or explicitly retire one exact resume lock whose local owner is proven dead.",
|
|
715
|
-
)
|
|
716
|
-
.option("--lock-sha256 <sha256>", "Exact lock identity digest returned by the preview")
|
|
717
|
-
.option("--actor <id>", "Actor responsible for the recovery")
|
|
718
|
-
.option("--reason <text>", "Reason for the recovery")
|
|
719
|
-
.option("--yes", "Confirm removal after dead-owner and exact-lock checks")
|
|
720
|
-
.option("--json", "Emit the recovery preview or result as JSON")
|
|
721
|
-
.action(async (runId: string, opts: WorkflowUnlockOpts) => {
|
|
722
|
-
try {
|
|
723
|
-
const coordRoot = resolveCoordRoot();
|
|
724
|
-
if (!coordRoot) throw new Error("no .harnery/ coordination root found");
|
|
725
|
-
const { recoverWorkflowResumeLease } = await import("../core/workflow/index.ts");
|
|
726
|
-
const result = recoverWorkflowResumeLease(coordRoot, runId, opts);
|
|
727
|
-
if (opts.json) {
|
|
728
|
-
emit.config({ format: "json" });
|
|
729
|
-
emit.data(result);
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
emit.text(
|
|
733
|
-
`resume lock ${result.action}: ${runId}\nowner: ${result.owner_state}\nlock sha256: ${result.lock_sha256 ?? "absent"}\n` +
|
|
734
|
-
(result.action === "preview"
|
|
735
|
-
? "Pass --lock-sha256, --actor, --reason and --yes to confirm exact dead-owner recovery.\n"
|
|
736
|
-
: `recovery: ${result.recovery_id}\n`),
|
|
737
|
-
);
|
|
738
|
-
} catch (error) {
|
|
739
|
-
emit.error({
|
|
740
|
-
code: "workflow_resume_unlock_failed",
|
|
741
|
-
message: error instanceof Error ? error.message : String(error),
|
|
742
|
-
});
|
|
743
|
-
process.exit(1);
|
|
744
|
-
}
|
|
745
|
-
});
|
|
746
|
-
|
|
747
697
|
const approvals = program
|
|
748
698
|
.command("approval")
|
|
749
699
|
.description("Inspect and resolve durable run-policy approvals.");
|
|
@@ -265,27 +265,7 @@ export function parsePidmapRowStartToken(row: string): string | undefined {
|
|
|
265
265
|
* a test can hold this one against `processStartToken` and fail on drift.
|
|
266
266
|
*/
|
|
267
267
|
export function pidStartToken(pid: number): string | null {
|
|
268
|
-
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
269
268
|
const forced = process.env.HARNERY_PID_PROBE;
|
|
270
|
-
if (process.platform === "win32" && forced !== "procfs" && forced !== "ps") {
|
|
271
|
-
try {
|
|
272
|
-
const out = spawnSync(
|
|
273
|
-
"powershell.exe",
|
|
274
|
-
[
|
|
275
|
-
"-NoLogo",
|
|
276
|
-
"-NoProfile",
|
|
277
|
-
"-NonInteractive",
|
|
278
|
-
"-Command",
|
|
279
|
-
`$process = [Diagnostics.Process]::GetProcessById(${pid}); try { 'w' + $process.StartTime.ToFileTimeUtc().ToString([Globalization.CultureInfo]::InvariantCulture) } finally { $process.Dispose() }`,
|
|
280
|
-
],
|
|
281
|
-
{ encoding: "utf8", timeout: 10_000, windowsHide: true },
|
|
282
|
-
);
|
|
283
|
-
const token = String(out.stdout ?? "").trim();
|
|
284
|
-
return out.status === 0 && /^w\d+$/.test(token) ? token : null;
|
|
285
|
-
} catch {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
269
|
const useProcfs = forced === "procfs" || (forced !== "ps" && existsSync("/proc/self/stat"));
|
|
290
270
|
// One machine, one probe. Falling back to the other on a read failure would
|
|
291
271
|
// answer in the wrong dialect and read as a recycled pid.
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
writeFileSync,
|
|
25
25
|
} from "node:fs";
|
|
26
26
|
import { dirname, join } from "node:path";
|
|
27
|
-
import {
|
|
27
|
+
import { processStartToken, verifyProcessGeneration } from "./proc-start.ts";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Row count that triggers a dead-row sweep on the next write.
|
|
@@ -59,7 +59,7 @@ export function parsePidmapRow(row: string): {
|
|
|
59
59
|
*/
|
|
60
60
|
function rowStillNamesItsProcess(pid: number, startToken: string | undefined): boolean {
|
|
61
61
|
if (!pidIsAlive(pid)) return false;
|
|
62
|
-
return
|
|
62
|
+
return verifyProcessGeneration(pid, startToken) !== "different";
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
function atomicWrite(path: string, content: string): void {
|
|
@@ -34,10 +34,9 @@
|
|
|
34
34
|
import { spawnSync } from "node:child_process";
|
|
35
35
|
import { existsSync, readFileSync } from "node:fs";
|
|
36
36
|
import { coordEnv } from "../../../lib/env.ts";
|
|
37
|
-
import { signalProcessWithHandle, windowsProcessStartToken } from "./process-handle.ts";
|
|
38
37
|
|
|
39
38
|
/** Which probe answers "when did this process start?" on this machine. */
|
|
40
|
-
export type StartTokenProbe = "procfs" | "ps"
|
|
39
|
+
export type StartTokenProbe = "procfs" | "ps";
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
42
|
* The `ps` probe is text formatted by `strftime("%c", localtime(...))`, so its
|
|
@@ -61,7 +60,6 @@ let cachedBootId: string | null | undefined;
|
|
|
61
60
|
export function startTokenProbe(): StartTokenProbe {
|
|
62
61
|
const override = coordEnv("PID_PROBE");
|
|
63
62
|
if (override === "procfs" || override === "ps") return override;
|
|
64
|
-
if (process.platform === "win32") return "windows";
|
|
65
63
|
if (cachedProbe === undefined) {
|
|
66
64
|
cachedProbe = existsSync("/proc/self/stat") ? "procfs" : "ps";
|
|
67
65
|
}
|
|
@@ -104,7 +102,7 @@ export function resetStartTokenCaches(): void {
|
|
|
104
102
|
* than by splitting the whole line, because comm is the executable name in
|
|
105
103
|
* parentheses and may itself contain spaces and parens. The boot segment is
|
|
106
104
|
* dropped rather than faked when the kernel will not name its boot, which
|
|
107
|
-
* yields the pre-boot-id shape that `
|
|
105
|
+
* yields the pre-boot-id shape that `verifyProcessGeneration` compares by ticks.
|
|
108
106
|
*/
|
|
109
107
|
export function procfsStartToken(pid: number): string | null {
|
|
110
108
|
let stat: string;
|
|
@@ -159,19 +157,26 @@ function defaultPsRun(pid: number): { status: number | null; stdout: string } {
|
|
|
159
157
|
*/
|
|
160
158
|
export function processStartToken(pid: number): string | null {
|
|
161
159
|
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
162
|
-
|
|
163
|
-
return probe === "windows"
|
|
164
|
-
? windowsProcessStartToken(pid)
|
|
165
|
-
: probe === "procfs"
|
|
166
|
-
? procfsStartToken(pid)
|
|
167
|
-
: psStartToken(pid);
|
|
160
|
+
return startTokenProbe() === "procfs" ? procfsStartToken(pid) : psStartToken(pid);
|
|
168
161
|
}
|
|
169
162
|
|
|
170
163
|
export type ProcessGenerationVerdict = "same" | "different" | "unknown";
|
|
171
164
|
|
|
172
165
|
type ReadProcessStartToken = (pid: number) => string | null;
|
|
173
166
|
|
|
174
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Does `pid` still name the run that wrote `recordedToken`?
|
|
169
|
+
*
|
|
170
|
+
* - `"same"`: both tokens present and equal.
|
|
171
|
+
* - `"different"`: the pid was recycled. A liveness check cannot see this,
|
|
172
|
+
* because a recycled pid is alive.
|
|
173
|
+
* - `"unknown"`: a token is missing, malformed, or unreadable. Never treat
|
|
174
|
+
* this as proof of identity.
|
|
175
|
+
*
|
|
176
|
+
* A Linux token written without a boot segment cannot identify a run across
|
|
177
|
+
* reboots, so it is compared on its tick count: a different count proves a
|
|
178
|
+
* different run, and an equal count stays unknown.
|
|
179
|
+
*/
|
|
175
180
|
export function verifyProcessGeneration(
|
|
176
181
|
pid: number,
|
|
177
182
|
recordedToken: string | null | undefined,
|
|
@@ -184,26 +189,38 @@ export function verifyProcessGeneration(
|
|
|
184
189
|
!recordedToken.trim()
|
|
185
190
|
)
|
|
186
191
|
return "unknown";
|
|
187
|
-
|
|
188
|
-
if (/^l\d+$/.test(recordedToken)) return "unknown";
|
|
192
|
+
let currentToken: string | null;
|
|
189
193
|
try {
|
|
190
|
-
|
|
191
|
-
if (typeof currentToken !== "string" || !currentToken.trim()) return "unknown";
|
|
192
|
-
if (/^l\d+$/.test(currentToken)) return "unknown";
|
|
193
|
-
return currentToken === recordedToken ? "same" : "different";
|
|
194
|
+
currentToken = readToken(pid);
|
|
194
195
|
} catch {
|
|
195
196
|
return "unknown";
|
|
196
197
|
}
|
|
198
|
+
if (typeof currentToken !== "string" || !currentToken.trim()) return "unknown";
|
|
199
|
+
const bootless = (token: string) => /^l\d+$/.test(token);
|
|
200
|
+
if (
|
|
201
|
+
recordedToken[0] === "l" &&
|
|
202
|
+
currentToken[0] === "l" &&
|
|
203
|
+
(bootless(recordedToken) || bootless(currentToken))
|
|
204
|
+
)
|
|
205
|
+
return ticksOf(recordedToken) === ticksOf(currentToken) ? "unknown" : "different";
|
|
206
|
+
return currentToken === recordedToken ? "same" : "different";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function ticksOf(token: string): string {
|
|
210
|
+
const dot = token.indexOf(".");
|
|
211
|
+
return dot === -1 ? token.slice(1) : token.slice(dot + 1);
|
|
197
212
|
}
|
|
198
213
|
|
|
199
214
|
export interface ProcessGenerationSignalDependencies {
|
|
200
215
|
readToken?: ReadProcessStartToken;
|
|
201
|
-
|
|
216
|
+
kill?: (pid: number, signal: NodeJS.Signals | number) => unknown;
|
|
202
217
|
}
|
|
203
218
|
|
|
204
219
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
220
|
+
* Recheck the process generation immediately before sending the signal.
|
|
221
|
+
*
|
|
222
|
+
* The process can still exit and its PID can be reused between the token read
|
|
223
|
+
* and the signal. Eliminating that race requires a generation-bound OS handle.
|
|
207
224
|
*/
|
|
208
225
|
export function signalIfSameGeneration(
|
|
209
226
|
pid: number,
|
|
@@ -211,58 +228,13 @@ export function signalIfSameGeneration(
|
|
|
211
228
|
signal: NodeJS.Signals | number,
|
|
212
229
|
dependencies: ProcessGenerationSignalDependencies = {},
|
|
213
230
|
): { sent: boolean; verdict: ProcessGenerationVerdict } {
|
|
231
|
+
const kill = dependencies.kill ?? process.kill;
|
|
214
232
|
const verdict = verifyProcessGeneration(pid, recordedToken, dependencies.readToken);
|
|
215
233
|
if (verdict !== "same") return { sent: false, verdict };
|
|
216
234
|
try {
|
|
217
|
-
|
|
235
|
+
kill(pid, signal);
|
|
236
|
+
return { sent: true, verdict };
|
|
218
237
|
} catch {
|
|
219
|
-
return { sent: false, verdict
|
|
238
|
+
return { sent: false, verdict };
|
|
220
239
|
}
|
|
221
240
|
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Do two tokens describe the same run?
|
|
225
|
-
*
|
|
226
|
-
* Equality, plus one concession: a row written before tokens carried a boot
|
|
227
|
-
* segment recorded the tick count alone, so it is compared on the part it
|
|
228
|
-
* actually recorded. Refusing that would call every pre-upgrade row a recycled
|
|
229
|
-
* pid and prune a working machine's live rows the first time the new code ran.
|
|
230
|
-
* Such a row loses its reboot protection until the next write rewrites it,
|
|
231
|
-
* which is a strictly better position than the one it came from.
|
|
232
|
-
*/
|
|
233
|
-
export function tokensAgree(recorded: string, current: string): boolean {
|
|
234
|
-
if (recorded === current) return true;
|
|
235
|
-
// Only the Linux form has a boot segment to disagree about, and only when
|
|
236
|
-
// exactly one side carries one is this a shape difference rather than two
|
|
237
|
-
// genuinely different start instants.
|
|
238
|
-
if (recorded[0] !== "l" || current[0] !== "l") return false;
|
|
239
|
-
if (recorded.includes(".") === current.includes(".")) return false;
|
|
240
|
-
return ticksOf(recorded) === ticksOf(current);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function ticksOf(token: string): string {
|
|
244
|
-
const dot = token.indexOf(".");
|
|
245
|
-
return dot === -1 ? token.slice(1) : token.slice(dot + 1);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Does `pid` still name the process a row recorded?
|
|
250
|
-
*
|
|
251
|
-
* Three answers, and the difference between the last two matters:
|
|
252
|
-
*
|
|
253
|
-
* - `"match"` — recorded token, current token, same run. The row is trustworthy.
|
|
254
|
-
* - `"mismatch"` — both present and different. The pid was recycled. This is
|
|
255
|
-
* the case a liveness check cannot see, because a recycled pid is alive.
|
|
256
|
-
* - `"unverified"` — no recorded token (a row written before tokens existed) or
|
|
257
|
-
* no current one (an unsupported platform). Callers treat this the way they
|
|
258
|
-
* treated every row previously: trust it if the pid is alive.
|
|
259
|
-
*/
|
|
260
|
-
export function checkPidToken(
|
|
261
|
-
pid: number,
|
|
262
|
-
recorded: string | undefined,
|
|
263
|
-
): "match" | "mismatch" | "unverified" {
|
|
264
|
-
if (!recorded) return "unverified";
|
|
265
|
-
const current = processStartToken(pid);
|
|
266
|
-
if (!current) return "unverified";
|
|
267
|
-
return tokensAgree(recorded, current) ? "match" : "mismatch";
|
|
268
|
-
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One owner check for every singleton background service (governor, local
|
|
3
|
+
* supervisor, legacy resource observer): is the recorded process still the
|
|
4
|
+
* run that wrote the record, and how is a stop requested?
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
closeSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
openSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { hostname } from "node:os";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
import { writePrivateJsonAtomic } from "../../storage/atomic-json.ts";
|
|
19
|
+
import { stateDirMode, stateFileMode } from "../../storage/modes.ts";
|
|
20
|
+
import {
|
|
21
|
+
type ProcessGenerationSignalDependencies,
|
|
22
|
+
type ProcessGenerationVerdict,
|
|
23
|
+
processStartToken,
|
|
24
|
+
signalIfSameGeneration,
|
|
25
|
+
verifyProcessGeneration,
|
|
26
|
+
} from "./proc-start.ts";
|
|
27
|
+
|
|
28
|
+
export interface ServiceProcessDependencies extends ProcessGenerationSignalDependencies {
|
|
29
|
+
/** Throws ESRCH, or returns false, when the process is gone. */
|
|
30
|
+
probePid?: (pid: number) => unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ServiceLiveness = "running" | "stopped" | "unknown";
|
|
34
|
+
|
|
35
|
+
export interface ServiceProcessOwner {
|
|
36
|
+
pid: number;
|
|
37
|
+
host: string;
|
|
38
|
+
start_token?: string;
|
|
39
|
+
state?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A stale heartbeat does not prove that a process has exited. Another host, a
|
|
44
|
+
* probe error other than ESRCH, or an unverifiable token answers unknown. A
|
|
45
|
+
* recorded `stopped` state counts only while the generation still matches.
|
|
46
|
+
*/
|
|
47
|
+
export function observeServiceProcess(
|
|
48
|
+
owner: ServiceProcessOwner,
|
|
49
|
+
dependencies: ServiceProcessDependencies = {},
|
|
50
|
+
): { generation: ProcessGenerationVerdict; liveness: ServiceLiveness } {
|
|
51
|
+
if (owner.host !== hostname()) return { generation: "unknown", liveness: "unknown" };
|
|
52
|
+
try {
|
|
53
|
+
if ((dependencies.probePid ?? ((pid) => process.kill(pid, 0)))(owner.pid) === false)
|
|
54
|
+
return { generation: "unknown", liveness: "stopped" };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return {
|
|
57
|
+
generation: "unknown",
|
|
58
|
+
liveness: (error as NodeJS.ErrnoException).code === "ESRCH" ? "stopped" : "unknown",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const generation = verifyProcessGeneration(owner.pid, owner.start_token, dependencies.readToken);
|
|
62
|
+
return {
|
|
63
|
+
generation,
|
|
64
|
+
liveness:
|
|
65
|
+
generation === "same"
|
|
66
|
+
? owner.state === "stopped"
|
|
67
|
+
? "stopped"
|
|
68
|
+
: "running"
|
|
69
|
+
: generation === "different"
|
|
70
|
+
? "stopped"
|
|
71
|
+
: "unknown",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Refuse a start while another owner runs or cannot be identified. */
|
|
76
|
+
export function serviceStartRefusal(
|
|
77
|
+
label: string,
|
|
78
|
+
stopCommand: string,
|
|
79
|
+
pid: number | undefined,
|
|
80
|
+
liveness: ServiceLiveness,
|
|
81
|
+
): Error {
|
|
82
|
+
return new Error(
|
|
83
|
+
liveness === "running"
|
|
84
|
+
? `${label} is already running under pid ${pid}`
|
|
85
|
+
: `${label} process generation is unknown${pid ? ` for pid ${pid}` : ""}; run \`${stopCommand}\` to write a durable stop request before starting it again`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ServiceLease {
|
|
90
|
+
pid: number;
|
|
91
|
+
start_token?: string;
|
|
92
|
+
host: string;
|
|
93
|
+
nonce: string;
|
|
94
|
+
created_at?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function readServiceLease(path: string): ServiceLease | undefined {
|
|
98
|
+
try {
|
|
99
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as Partial<ServiceLease>;
|
|
100
|
+
if (
|
|
101
|
+
!Number.isSafeInteger(value.pid) ||
|
|
102
|
+
(value.pid ?? 0) <= 0 ||
|
|
103
|
+
typeof value.host !== "string" ||
|
|
104
|
+
!value.host ||
|
|
105
|
+
typeof value.nonce !== "string" ||
|
|
106
|
+
!value.nonce
|
|
107
|
+
)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
...value,
|
|
111
|
+
start_token: typeof value.start_token === "string" ? value.start_token : undefined,
|
|
112
|
+
} as ServiceLease;
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** An absent lease is stopped; an unreadable one cannot be identified. */
|
|
119
|
+
export function serviceLeaseOwner(path: string): {
|
|
120
|
+
lease?: ServiceLease;
|
|
121
|
+
liveness: ServiceLiveness;
|
|
122
|
+
} {
|
|
123
|
+
if (!existsSync(path)) return { liveness: "stopped" };
|
|
124
|
+
const lease = readServiceLease(path);
|
|
125
|
+
return { lease, liveness: lease ? observeServiceProcess(lease).liveness : "unknown" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Take the singleton lease with an exclusive create. A lease whose owner is
|
|
130
|
+
* proven stopped is replaced; any other owner refuses the start.
|
|
131
|
+
*/
|
|
132
|
+
export function acquireServiceLease(
|
|
133
|
+
path: string,
|
|
134
|
+
refuse: (pid: number | undefined, liveness: ServiceLiveness) => Error,
|
|
135
|
+
fields: Record<string, unknown> = {},
|
|
136
|
+
): { nonce: string; release: () => void } {
|
|
137
|
+
mkdirSync(dirname(path), { recursive: true, mode: stateDirMode() });
|
|
138
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
139
|
+
const startToken = processStartToken(process.pid);
|
|
140
|
+
const lease = {
|
|
141
|
+
...fields,
|
|
142
|
+
pid: process.pid,
|
|
143
|
+
...(startToken ? { start_token: startToken } : {}),
|
|
144
|
+
host: hostname(),
|
|
145
|
+
nonce: randomUUID(),
|
|
146
|
+
created_at: new Date().toISOString(),
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
const fd = openSync(path, "wx", stateFileMode());
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(fd, `${JSON.stringify(lease)}\n`, "utf8");
|
|
152
|
+
} finally {
|
|
153
|
+
closeSync(fd);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
nonce: lease.nonce,
|
|
157
|
+
release: () => {
|
|
158
|
+
if (readServiceLease(path)?.nonce !== lease.nonce) return;
|
|
159
|
+
try {
|
|
160
|
+
unlinkSync(path);
|
|
161
|
+
} catch {
|
|
162
|
+
// A missing lease is already released.
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
168
|
+
const owner = serviceLeaseOwner(path);
|
|
169
|
+
if (owner.liveness !== "stopped") throw refuse(owner.lease?.pid, owner.liveness);
|
|
170
|
+
try {
|
|
171
|
+
unlinkSync(path);
|
|
172
|
+
} catch {
|
|
173
|
+
// A concurrent starter may have replaced or removed the stale lease.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`could not acquire the singleton lease at ${path}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ServiceStopFields {
|
|
181
|
+
signal_sent: boolean;
|
|
182
|
+
signal_verdict: ProcessGenerationVerdict;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Write the durable stop request, then SIGTERM the owner `status` observed as
|
|
187
|
+
* running on this host, after a fresh generation check. The request stays for
|
|
188
|
+
* the daemon either way; `read` reports the state afterward.
|
|
189
|
+
*/
|
|
190
|
+
export function requestServiceStop<
|
|
191
|
+
S extends {
|
|
192
|
+
liveness: ServiceLiveness;
|
|
193
|
+
generation: ProcessGenerationVerdict;
|
|
194
|
+
record?: ServiceProcessOwner;
|
|
195
|
+
},
|
|
196
|
+
>(
|
|
197
|
+
status: S,
|
|
198
|
+
read: () => S,
|
|
199
|
+
stopPath: string,
|
|
200
|
+
dependencies: ServiceProcessDependencies = {},
|
|
201
|
+
options: { signal?: boolean } = {},
|
|
202
|
+
): S & ServiceStopFields {
|
|
203
|
+
writePrivateJsonAtomic(stopPath, {
|
|
204
|
+
requested_at: new Date().toISOString(),
|
|
205
|
+
requested_by_pid: process.pid,
|
|
206
|
+
});
|
|
207
|
+
const signal =
|
|
208
|
+
options.signal !== false && status.liveness === "running" && status.record?.host === hostname()
|
|
209
|
+
? signalIfSameGeneration(
|
|
210
|
+
status.record.pid,
|
|
211
|
+
status.record.start_token,
|
|
212
|
+
"SIGTERM",
|
|
213
|
+
dependencies,
|
|
214
|
+
)
|
|
215
|
+
: { sent: false, verdict: status.generation };
|
|
216
|
+
return { ...read(), signal_sent: signal.sent, signal_verdict: signal.verdict };
|
|
217
|
+
}
|