@gajae-code/coding-agent 0.2.5 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/types/async/job-manager.d.ts +91 -2
- package/dist/types/cli/args.d.ts +1 -1
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/commands/harness.d.ts +37 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +10 -4
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/debug/crash-diagnostics.d.ts +45 -0
- package/dist/types/debug/runtime-gauges.d.ts +6 -0
- package/dist/types/deep-interview/render-middleware.d.ts +6 -0
- package/dist/types/eval/py/executor.d.ts +2 -0
- package/dist/types/eval/py/kernel.d.ts +2 -0
- package/dist/types/exec/bash-executor.d.ts +10 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +6 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
- package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
- package/dist/types/gjc-runtime/state-migrations.d.ts +33 -0
- package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
- package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +147 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
- package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
- package/dist/types/harness-control-plane/classifier.d.ts +13 -0
- package/dist/types/harness-control-plane/control-endpoint.d.ts +31 -0
- package/dist/types/harness-control-plane/finalize.d.ts +47 -0
- package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
- package/dist/types/harness-control-plane/operate.d.ts +35 -0
- package/dist/types/harness-control-plane/owner.d.ts +46 -0
- package/dist/types/harness-control-plane/preserve.d.ts +19 -0
- package/dist/types/harness-control-plane/receipts.d.ts +88 -0
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
- package/dist/types/harness-control-plane/seams.d.ts +21 -0
- package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
- package/dist/types/harness-control-plane/storage.d.ts +53 -0
- package/dist/types/harness-control-plane/types.d.ts +162 -0
- package/dist/types/hooks/skill-keywords.d.ts +2 -1
- package/dist/types/hooks/skill-state.d.ts +23 -29
- package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
- package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
- package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
- package/dist/types/internal-urls/types.d.ts +4 -0
- package/dist/types/lsp/index.d.ts +10 -10
- package/dist/types/modes/bridge/auth.d.ts +12 -0
- package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
- package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
- package/dist/types/modes/bridge/event-stream.d.ts +8 -0
- package/dist/types/modes/components/custom-editor.d.ts +6 -0
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
- package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
- package/dist/types/modes/index.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/jobs-observer.d.ts +57 -0
- package/dist/types/modes/rpc/host-tools.d.ts +1 -16
- package/dist/types/modes/rpc/host-uris.d.ts +1 -38
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
- package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
- package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
- package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
- package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
- package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
- package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
- package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +19 -1
- package/dist/types/skill-state/active-state.d.ts +2 -0
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
- package/dist/types/skill-state/workflow-state-contract.d.ts +25 -2
- package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/id.d.ts +7 -0
- package/dist/types/task/index.d.ts +5 -0
- package/dist/types/task/receipt.d.ts +85 -0
- package/dist/types/task/spawn-gate.d.ts +38 -0
- package/dist/types/task/types.d.ts +198 -14
- package/dist/types/tools/cron.d.ts +6 -0
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +26 -1
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +334 -6
- package/src/cli/args.ts +9 -2
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/config-cli.ts +10 -2
- package/src/cli.ts +2 -0
- package/src/commands/deep-interview.ts +1 -0
- package/src/commands/harness.ts +862 -0
- package/src/commands/launch.ts +2 -2
- package/src/commands/state.ts +2 -1
- package/src/commands/team.ts +54 -39
- package/src/config/keybindings.ts +6 -0
- package/src/config/settings-schema.ts +13 -3
- package/src/config/settings.ts +5 -0
- package/src/dap/client.ts +17 -3
- package/src/debug/crash-diagnostics.ts +223 -0
- package/src/debug/runtime-gauges.ts +20 -0
- package/src/deep-interview/render-middleware.ts +372 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
- package/src/defaults/gjc/skills/team/SKILL.md +47 -21
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +106 -13
- package/src/eval/py/executor.ts +21 -1
- package/src/eval/py/kernel.ts +15 -0
- package/src/exec/bash-executor.ts +41 -0
- package/src/extensibility/custom-tools/types.ts +1 -0
- package/src/extensibility/extensions/types.ts +6 -0
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/cli-write-receipt.ts +31 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +98 -42
- package/src/gjc-runtime/goal-mode-request.ts +11 -3
- package/src/gjc-runtime/ralplan-runtime.ts +235 -43
- package/src/gjc-runtime/state-graph.ts +86 -0
- package/src/gjc-runtime/state-migrations.ts +179 -0
- package/src/gjc-runtime/state-renderer.ts +345 -0
- package/src/gjc-runtime/state-runtime.ts +1155 -46
- package/src/gjc-runtime/state-schema.ts +192 -0
- package/src/gjc-runtime/state-validation.ts +49 -0
- package/src/gjc-runtime/state-writer.ts +749 -0
- package/src/gjc-runtime/team-runtime.ts +1255 -189
- package/src/gjc-runtime/ultragoal-runtime.ts +460 -43
- package/src/gjc-runtime/workflow-command-ref.ts +239 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +1601 -0
- package/src/gjc-runtime/workflow-manifest.ts +427 -0
- package/src/harness-control-plane/classifier.ts +128 -0
- package/src/harness-control-plane/control-endpoint.ts +148 -0
- package/src/harness-control-plane/finalize.ts +222 -0
- package/src/harness-control-plane/frame-mapper.ts +286 -0
- package/src/harness-control-plane/operate.ts +225 -0
- package/src/harness-control-plane/owner.ts +600 -0
- package/src/harness-control-plane/preserve.ts +102 -0
- package/src/harness-control-plane/receipts.ts +216 -0
- package/src/harness-control-plane/rpc-adapter.ts +276 -0
- package/src/harness-control-plane/seams.ts +39 -0
- package/src/harness-control-plane/session-lease.ts +388 -0
- package/src/harness-control-plane/state-machine.ts +98 -0
- package/src/harness-control-plane/storage.ts +257 -0
- package/src/harness-control-plane/types.ts +214 -0
- package/src/hooks/skill-keywords.ts +4 -2
- package/src/hooks/skill-state.ts +197 -64
- package/src/internal-urls/agent-protocol.ts +68 -21
- package/src/internal-urls/artifact-protocol.ts +12 -17
- package/src/internal-urls/docs-index.generated.ts +3 -2
- package/src/internal-urls/registry-helpers.ts +19 -16
- package/src/internal-urls/types.ts +4 -0
- package/src/lsp/client.ts +18 -2
- package/src/main.ts +21 -5
- package/src/modes/bridge/auth.ts +41 -0
- package/src/modes/bridge/bridge-client-bridge.ts +47 -0
- package/src/modes/bridge/bridge-mode.ts +520 -0
- package/src/modes/bridge/bridge-ui-context.ts +200 -0
- package/src/modes/bridge/event-stream.ts +70 -0
- package/src/modes/components/assistant-message.ts +5 -1
- package/src/modes/components/custom-editor.ts +101 -0
- package/src/modes/components/hook-selector.ts +133 -20
- package/src/modes/components/jobs-overlay-model.ts +109 -0
- package/src/modes/components/jobs-overlay.ts +172 -0
- package/src/modes/components/status-line/presets.ts +7 -5
- package/src/modes/components/status-line/segments.ts +25 -0
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line.ts +9 -1
- package/src/modes/controllers/event-controller.ts +71 -6
- package/src/modes/controllers/extension-ui-controller.ts +43 -1
- package/src/modes/controllers/input-controller.ts +105 -9
- package/src/modes/controllers/selector-controller.ts +31 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +28 -0
- package/src/modes/jobs-observer.ts +204 -0
- package/src/modes/rpc/host-tools.ts +1 -186
- package/src/modes/rpc/host-uris.ts +1 -235
- package/src/modes/rpc/rpc-client.ts +25 -10
- package/src/modes/rpc/rpc-mode.ts +12 -381
- package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
- package/src/modes/shared/agent-wire/command-validation.ts +131 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
- package/src/modes/shared/agent-wire/handshake.ts +117 -0
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
- package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
- package/src/modes/shared/agent-wire/protocol.ts +96 -0
- package/src/modes/shared/agent-wire/responses.ts +17 -0
- package/src/modes/shared/agent-wire/scopes.ts +89 -0
- package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
- package/src/modes/shared/agent-wire/ui-result.ts +48 -0
- package/src/modes/types.ts +2 -0
- package/src/prompts/agents/executor.md +13 -0
- package/src/prompts/tools/subagent.md +39 -4
- package/src/prompts/tools/task-summary.md +3 -9
- package/src/prompts/tools/task.md +5 -1
- package/src/sdk.ts +8 -0
- package/src/session/agent-session.ts +445 -71
- package/src/session/session-manager.ts +13 -1
- package/src/skill-state/active-state.ts +58 -65
- package/src/skill-state/deep-interview-mutation-guard.ts +114 -17
- package/src/skill-state/initial-phase.ts +2 -0
- package/src/skill-state/workflow-state-contract.ts +33 -4
- package/src/skill-state/workflow-state-version.ts +3 -0
- package/src/slash-commands/builtin-registry.ts +8 -0
- package/src/task/executor.ts +79 -13
- package/src/task/id.ts +33 -0
- package/src/task/index.ts +376 -74
- package/src/task/output-manager.ts +5 -4
- package/src/task/receipt.ts +297 -0
- package/src/task/render.ts +54 -134
- package/src/task/spawn-gate.ts +132 -0
- package/src/task/types.ts +104 -10
- package/src/tools/ask.ts +88 -27
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/cron.ts +48 -0
- package/src/tools/find.ts +4 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +1 -0
- package/src/tools/search.ts +1 -0
- package/src/tools/skill.ts +6 -1
- package/src/tools/subagent.ts +423 -79
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionLease — the construct that makes live control honest without a global daemon.
|
|
3
|
+
*
|
|
4
|
+
* Exactly one live `RuntimeOwner` holds the lease for a session; only the lease holder
|
|
5
|
+
* may append events or run the default router. A stale lease (owner dead OR expired) may
|
|
6
|
+
* be taken over, incrementing `leaseEpoch`, but a stale lease is NEVER permission for
|
|
7
|
+
* destructive recovery (that gate lives in the classifier/recovery layer).
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import * as fs from "node:fs/promises";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { controlSocketPath, sessionPaths } from "./storage";
|
|
13
|
+
|
|
14
|
+
export interface SessionLease {
|
|
15
|
+
ownerId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
leaseTokenHash: string;
|
|
19
|
+
endpoint: { kind: "unix-socket" | "fifo"; path: string } | null;
|
|
20
|
+
eventsPath: string;
|
|
21
|
+
heartbeatAt: string;
|
|
22
|
+
expiresAt: string;
|
|
23
|
+
leaseEpoch: number;
|
|
24
|
+
writer: { ownerId: string; leaseEpoch: number };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class LeaseError extends Error {
|
|
28
|
+
constructor(
|
|
29
|
+
message: string,
|
|
30
|
+
readonly code: string,
|
|
31
|
+
) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "LeaseError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export type LeaseStatus = "missing" | "live" | "expiredAlive" | "dead" | "epermAlive";
|
|
37
|
+
|
|
38
|
+
type PidStatus = "alive" | "dead" | "eperm";
|
|
39
|
+
|
|
40
|
+
function nowMs(clock?: () => number): number {
|
|
41
|
+
return clock ? clock() : Date.now();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hashToken(token: string): string {
|
|
45
|
+
return createHash("sha256").update(token).digest("hex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function writeLeaseAtomic(file: string, lease: SessionLease): Promise<void> {
|
|
49
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
50
|
+
const tmp = `${file}.tmp-${randomBytes(4).toString("hex")}`;
|
|
51
|
+
await fs.writeFile(tmp, `${JSON.stringify(lease, null, 2)}\n`, "utf8");
|
|
52
|
+
await fs.rename(tmp, file);
|
|
53
|
+
}
|
|
54
|
+
const LEASE_LOCK_RETRIES = 100;
|
|
55
|
+
const LEASE_LOCK_RETRY_DELAY_MS = 20;
|
|
56
|
+
const LEASE_LOCK_FILE_SUFFIX = ".json";
|
|
57
|
+
|
|
58
|
+
interface LeaseLockInfo {
|
|
59
|
+
pid: number;
|
|
60
|
+
token: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function leaseMutationLockPath(root: string, sessionId: string): string {
|
|
64
|
+
return `${sessionPaths(root, sessionId).lease}.lock`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function leaseMutationLockFile(lockPath: string, token: string): string {
|
|
68
|
+
return path.join(lockPath, `${token}${LEASE_LOCK_FILE_SUFFIX}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isOwnedUnixEndpointPath(root: string, sessionId: string, endpointPath: string, fallbackPath: string): boolean {
|
|
72
|
+
const resolvedEndpoint = path.resolve(endpointPath);
|
|
73
|
+
if (resolvedEndpoint === path.resolve(fallbackPath)) return true;
|
|
74
|
+
try {
|
|
75
|
+
return resolvedEndpoint === path.resolve(controlSocketPath(root, sessionId));
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isPathExistsError(error: unknown): boolean {
|
|
82
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
83
|
+
return code === "EEXIST" || code === "ENOTEMPTY" || code === "EISDIR" || code === "ENOTDIR";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isIgnorableLockDirRemoveError(error: unknown): boolean {
|
|
87
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
88
|
+
return code === "ENOENT" || code === "ENOTEMPTY" || code === "EEXIST";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function removeEmptyLockDir(lockPath: string): Promise<void> {
|
|
92
|
+
try {
|
|
93
|
+
await fs.rmdir(lockPath);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!isIgnorableLockDirRemoveError(error)) throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseLeaseLockInfo(raw: string): LeaseLockInfo | null {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
102
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
103
|
+
const info = parsed as Record<string, unknown>;
|
|
104
|
+
return typeof info.pid === "number" && typeof info.token === "string"
|
|
105
|
+
? { pid: info.pid, token: info.token }
|
|
106
|
+
: null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readLeaseLockInfo(lockFile: string): Promise<LeaseLockInfo | null> {
|
|
113
|
+
try {
|
|
114
|
+
return parseLeaseLockInfo(await fs.readFile(lockFile, "utf8"));
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function createLeaseMutationLock(lockPath: string, info: LeaseLockInfo): Promise<boolean> {
|
|
121
|
+
const tmpPath = `${lockPath}.tmp-${info.token}`;
|
|
122
|
+
await fs.rm(tmpPath, { recursive: true, force: true });
|
|
123
|
+
await fs.mkdir(tmpPath, { mode: 0o700 });
|
|
124
|
+
try {
|
|
125
|
+
await fs.writeFile(leaseMutationLockFile(tmpPath, info.token), `${JSON.stringify(info)}\n`, {
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
mode: 0o600,
|
|
128
|
+
});
|
|
129
|
+
await fs.rename(tmpPath, lockPath);
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
await fs.rm(tmpPath, { recursive: true, force: true });
|
|
133
|
+
if (isPathExistsError(error)) return false;
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function recoverLegacyStaleLeaseLock(lockPath: string): Promise<void> {
|
|
139
|
+
const info = await readLeaseLockInfo(lockPath);
|
|
140
|
+
if (!info || defaultPidStatusProbe(info.pid) !== "dead") return;
|
|
141
|
+
try {
|
|
142
|
+
await fs.unlink(lockPath);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
145
|
+
if (code !== "ENOENT" && code !== "EISDIR") throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function recoverStaleLeaseMutationLock(lockPath: string): Promise<void> {
|
|
150
|
+
let entries: string[];
|
|
151
|
+
try {
|
|
152
|
+
entries = await fs.readdir(lockPath);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
155
|
+
if (code === "ENOENT") return;
|
|
156
|
+
if (code === "ENOTDIR") {
|
|
157
|
+
await recoverLegacyStaleLeaseLock(lockPath);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
const lockFiles = entries.filter(entry => entry.endsWith(LEASE_LOCK_FILE_SUFFIX));
|
|
163
|
+
if (lockFiles.length === 0) {
|
|
164
|
+
await removeEmptyLockDir(lockPath);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (lockFiles.length !== 1) return;
|
|
168
|
+
const lockFile = lockFiles[0];
|
|
169
|
+
const info = await readLeaseLockInfo(path.join(lockPath, lockFile));
|
|
170
|
+
if (!info || lockFile !== `${info.token}${LEASE_LOCK_FILE_SUFFIX}`) return;
|
|
171
|
+
if (defaultPidStatusProbe(info.pid) !== "dead") return;
|
|
172
|
+
// Delete only the stale owner's token-named file; a fresh lock uses a different file name.
|
|
173
|
+
await fs.rm(path.join(lockPath, lockFile), { force: true });
|
|
174
|
+
await removeEmptyLockDir(lockPath);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function releaseLeaseMutationLock(lockPath: string, token: string): Promise<void> {
|
|
178
|
+
await fs.rm(leaseMutationLockFile(lockPath, token), { force: true });
|
|
179
|
+
await removeEmptyLockDir(lockPath);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function acquireLeaseMutationLock(root: string, sessionId: string): Promise<() => Promise<void>> {
|
|
183
|
+
const lockPath = leaseMutationLockPath(root, sessionId);
|
|
184
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
185
|
+
for (let attempt = 0; attempt < LEASE_LOCK_RETRIES; attempt++) {
|
|
186
|
+
const token = randomBytes(16).toString("hex");
|
|
187
|
+
const info: LeaseLockInfo = { pid: process.pid, token };
|
|
188
|
+
if (await createLeaseMutationLock(lockPath, info)) return () => releaseLeaseMutationLock(lockPath, token);
|
|
189
|
+
await recoverStaleLeaseMutationLock(lockPath);
|
|
190
|
+
await Bun.sleep(LEASE_LOCK_RETRY_DELAY_MS);
|
|
191
|
+
}
|
|
192
|
+
throw new LeaseError(`lease_lock_timeout:${sessionId}`, "lease_lock_timeout");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function withLeaseMutationLock<T>(root: string, sessionId: string, fn: () => Promise<T>): Promise<T> {
|
|
196
|
+
const release = await acquireLeaseMutationLock(root, sessionId);
|
|
197
|
+
try {
|
|
198
|
+
return await fn();
|
|
199
|
+
} finally {
|
|
200
|
+
await release();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function readLease(root: string, sessionId: string): Promise<SessionLease | null> {
|
|
205
|
+
try {
|
|
206
|
+
const raw = await fs.readFile(sessionPaths(root, sessionId).lease, "utf8");
|
|
207
|
+
return JSON.parse(raw) as SessionLease;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isExpired(lease: SessionLease, clock?: () => number): boolean {
|
|
215
|
+
return Date.parse(lease.expiresAt) <= nowMs(clock);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function defaultPidStatusProbe(pid: number): PidStatus {
|
|
219
|
+
try {
|
|
220
|
+
process.kill(pid, 0);
|
|
221
|
+
return "alive";
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
224
|
+
if (code === "ESRCH") return "dead";
|
|
225
|
+
if (code === "EPERM") return "eperm";
|
|
226
|
+
return "dead";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function classifyLeaseStatus(
|
|
231
|
+
lease: SessionLease | null,
|
|
232
|
+
opts?: { clock?: () => number; probe?: (pid: number) => PidStatus },
|
|
233
|
+
): LeaseStatus {
|
|
234
|
+
if (!lease) return "missing";
|
|
235
|
+
const status = (opts?.probe ?? defaultPidStatusProbe)(lease.pid);
|
|
236
|
+
if (status === "dead") return "dead";
|
|
237
|
+
if (status === "eperm") return "epermAlive";
|
|
238
|
+
return isExpired(lease, opts?.clock) ? "expiredAlive" : "live";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function classifyProbeFromBoolean(
|
|
242
|
+
probe?: (pid: number) => boolean | PidStatus,
|
|
243
|
+
): ((pid: number) => PidStatus) | undefined {
|
|
244
|
+
if (!probe) return undefined;
|
|
245
|
+
return pid => {
|
|
246
|
+
const status = probe(pid);
|
|
247
|
+
return typeof status === "boolean" ? (status ? "alive" : "dead") : status;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Liveness probe via signal 0. Defaults to the real process table; injectable for tests. */
|
|
252
|
+
export function isOwnerAlive(pid: number, probe?: (pid: number) => boolean): boolean {
|
|
253
|
+
if (probe) return probe(pid);
|
|
254
|
+
try {
|
|
255
|
+
process.kill(pid, 0);
|
|
256
|
+
return true;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// ESRCH = no such process; EPERM = exists but not ours (treat as alive).
|
|
259
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function isStale(
|
|
264
|
+
lease: SessionLease,
|
|
265
|
+
opts?: { clock?: () => number; probe?: (pid: number) => boolean },
|
|
266
|
+
): boolean {
|
|
267
|
+
return isExpired(lease, opts?.clock) || !isOwnerAlive(lease.pid, opts?.probe);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface AcquireOptions {
|
|
271
|
+
ownerId: string;
|
|
272
|
+
pid: number;
|
|
273
|
+
endpoint?: SessionLease["endpoint"];
|
|
274
|
+
eventsPath: string;
|
|
275
|
+
ttlMs: number;
|
|
276
|
+
clock?: () => number;
|
|
277
|
+
probe?: (pid: number) => boolean | PidStatus;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface AcquiredLease {
|
|
281
|
+
lease: SessionLease;
|
|
282
|
+
token: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Acquire (or take over a stale) lease. Fails closed with `lease_held` when a live,
|
|
287
|
+
* unexpired lease is held by a different owner. Re-acquiring as the current owner refreshes.
|
|
288
|
+
*/
|
|
289
|
+
export async function acquireLease(root: string, sessionId: string, opts: AcquireOptions): Promise<AcquiredLease> {
|
|
290
|
+
return await withLeaseMutationLock(root, sessionId, async () => {
|
|
291
|
+
const existing = await readLease(root, sessionId);
|
|
292
|
+
if (existing && existing.ownerId !== opts.ownerId) {
|
|
293
|
+
const classifiedOwnerId = existing.ownerId;
|
|
294
|
+
const classifiedEpoch = existing.leaseEpoch;
|
|
295
|
+
const status = classifyLeaseStatus(existing, {
|
|
296
|
+
clock: opts.clock,
|
|
297
|
+
probe: classifyProbeFromBoolean(opts.probe),
|
|
298
|
+
});
|
|
299
|
+
if (status !== "dead") {
|
|
300
|
+
throw new LeaseError(`lease_held:${sessionId}`, "lease_held");
|
|
301
|
+
}
|
|
302
|
+
const reread = await readLease(root, sessionId);
|
|
303
|
+
if (!reread || reread.ownerId !== classifiedOwnerId || reread.leaseEpoch !== classifiedEpoch) {
|
|
304
|
+
throw new LeaseError(`lease_held:${sessionId}`, "lease_held");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const priorEpoch = existing?.leaseEpoch ?? 0;
|
|
308
|
+
const epoch = existing && existing.ownerId === opts.ownerId ? priorEpoch : priorEpoch + 1;
|
|
309
|
+
const token = randomBytes(16).toString("hex");
|
|
310
|
+
const now = nowMs(opts.clock);
|
|
311
|
+
const lease: SessionLease = {
|
|
312
|
+
ownerId: opts.ownerId,
|
|
313
|
+
sessionId,
|
|
314
|
+
pid: opts.pid,
|
|
315
|
+
leaseTokenHash: hashToken(token),
|
|
316
|
+
endpoint: opts.endpoint ?? null,
|
|
317
|
+
eventsPath: opts.eventsPath,
|
|
318
|
+
heartbeatAt: new Date(now).toISOString(),
|
|
319
|
+
expiresAt: new Date(now + opts.ttlMs).toISOString(),
|
|
320
|
+
leaseEpoch: epoch,
|
|
321
|
+
writer: { ownerId: opts.ownerId, leaseEpoch: epoch },
|
|
322
|
+
};
|
|
323
|
+
await writeLeaseAtomic(sessionPaths(root, sessionId).lease, lease);
|
|
324
|
+
return { lease, token };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Refresh the lease expiry. Only the recorded owner may heartbeat (single-writer). */
|
|
329
|
+
export async function heartbeat(
|
|
330
|
+
root: string,
|
|
331
|
+
sessionId: string,
|
|
332
|
+
ownerId: string,
|
|
333
|
+
ttlMs: number,
|
|
334
|
+
clock?: () => number,
|
|
335
|
+
): Promise<SessionLease> {
|
|
336
|
+
return await withLeaseMutationLock(root, sessionId, async () => {
|
|
337
|
+
const lease = await readLease(root, sessionId);
|
|
338
|
+
if (!lease) throw new LeaseError(`no_lease:${sessionId}`, "no_lease");
|
|
339
|
+
if (lease.ownerId !== ownerId) throw new LeaseError(`not_lease_holder:${sessionId}`, "not_lease_holder");
|
|
340
|
+
const now = nowMs(clock);
|
|
341
|
+
const next: SessionLease = {
|
|
342
|
+
...lease,
|
|
343
|
+
heartbeatAt: new Date(now).toISOString(),
|
|
344
|
+
expiresAt: new Date(now + ttlMs).toISOString(),
|
|
345
|
+
};
|
|
346
|
+
await writeLeaseAtomic(sessionPaths(root, sessionId).lease, next);
|
|
347
|
+
return next;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Whether `ownerId` is the live, unexpired single writer permitted to append events. */
|
|
352
|
+
export function canWriteEvents(lease: SessionLease, ownerId: string, clock?: () => number): boolean {
|
|
353
|
+
return lease.ownerId === ownerId && !isExpired(lease, clock);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Release the lease (owner shutdown). Only the holder may release. */
|
|
357
|
+
export async function releaseLease(root: string, sessionId: string, ownerId: string): Promise<void> {
|
|
358
|
+
await withLeaseMutationLock(root, sessionId, async () => {
|
|
359
|
+
const lease = await readLease(root, sessionId);
|
|
360
|
+
if (!lease) return;
|
|
361
|
+
if (lease.ownerId !== ownerId) throw new LeaseError(`not_lease_holder:${sessionId}`, "not_lease_holder");
|
|
362
|
+
await fs.rm(sessionPaths(root, sessionId).lease, { force: true });
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function reapDeadOwnerArtifacts(
|
|
367
|
+
root: string,
|
|
368
|
+
sessionId: string,
|
|
369
|
+
expectedOwnerId: string,
|
|
370
|
+
expectedEpoch: number,
|
|
371
|
+
opts?: { clock?: () => number; probe?: (pid: number) => PidStatus },
|
|
372
|
+
): Promise<boolean> {
|
|
373
|
+
return await withLeaseMutationLock(root, sessionId, async () => {
|
|
374
|
+
const lease = await readLease(root, sessionId);
|
|
375
|
+
if (!lease || lease.ownerId !== expectedOwnerId || lease.leaseEpoch !== expectedEpoch) return false;
|
|
376
|
+
if (classifyLeaseStatus(lease, opts) !== "dead") return false;
|
|
377
|
+
const paths = sessionPaths(root, sessionId);
|
|
378
|
+
const endpointPath =
|
|
379
|
+
lease.endpoint?.kind === "unix-socket" &&
|
|
380
|
+
isOwnedUnixEndpointPath(root, sessionId, lease.endpoint.path, paths.controlSock)
|
|
381
|
+
? lease.endpoint.path
|
|
382
|
+
: null;
|
|
383
|
+
await fs.rm(paths.controlSock, { force: true });
|
|
384
|
+
if (endpointPath) await fs.rm(endpointPath, { force: true });
|
|
385
|
+
await fs.rm(paths.lease, { force: true });
|
|
386
|
+
return true;
|
|
387
|
+
});
|
|
388
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle state machine + the universal `{state, evidence, nextAllowedActions}` contract.
|
|
3
|
+
*
|
|
4
|
+
* `nextAllowedActions` is the forcing function: it tells the caller exactly which
|
|
5
|
+
* primitives are currently permitted and, when not, why. Owner-routed verbs
|
|
6
|
+
* (`submit`) report `owner-not-live` when no `RuntimeOwner` holds the session lease.
|
|
7
|
+
*/
|
|
8
|
+
import type { HarnessLifecycle, NextAllowedAction, PrimitiveResponse, SessionState, SessionStateView } from "./types";
|
|
9
|
+
|
|
10
|
+
const TERMINAL_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["completed", "retired"]);
|
|
11
|
+
|
|
12
|
+
const TRANSITIONS: Record<HarnessLifecycle, readonly HarnessLifecycle[]> = {
|
|
13
|
+
new: ["started", "blocked", "retired"],
|
|
14
|
+
started: ["submitted", "observing", "recovering", "blocked", "retired"],
|
|
15
|
+
submitted: ["observing", "recovering", "validating", "blocked", "retired"],
|
|
16
|
+
observing: ["submitted", "recovering", "validating", "finalizing", "blocked", "retired"],
|
|
17
|
+
recovering: ["started", "submitted", "observing", "blocked", "retired"],
|
|
18
|
+
validating: ["finalizing", "observing", "blocked", "retired"],
|
|
19
|
+
finalizing: ["completed", "blocked", "retired"],
|
|
20
|
+
completed: ["retired"],
|
|
21
|
+
blocked: ["started", "submitted", "observing", "recovering", "validating", "retired"],
|
|
22
|
+
retired: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function isTerminal(lifecycle: HarnessLifecycle): boolean {
|
|
26
|
+
return TERMINAL_LIFECYCLES.has(lifecycle);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function canTransition(from: HarnessLifecycle, to: HarnessLifecycle): boolean {
|
|
30
|
+
return TRANSITIONS[from]?.includes(to) ?? false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function assertTransition(from: HarnessLifecycle, to: HarnessLifecycle): void {
|
|
34
|
+
if (from === to) return;
|
|
35
|
+
if (!canTransition(from, to)) {
|
|
36
|
+
throw new Error(`invalid_transition:${from}->${to}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Derive the permitted next actions for a session given its lifecycle and whether
|
|
42
|
+
* a live owner currently holds the lease.
|
|
43
|
+
*/
|
|
44
|
+
export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boolean): NextAllowedAction[] {
|
|
45
|
+
const terminal = isTerminal(lifecycle);
|
|
46
|
+
const actions: NextAllowedAction[] = [];
|
|
47
|
+
const add = (verb: NextAllowedAction["verb"], available: boolean, reason?: string): void => {
|
|
48
|
+
actions.push(available ? { verb, available } : { verb, available, reason: reason ?? "unavailable" });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Pure / read-only verbs are always available.
|
|
52
|
+
add("observe", true);
|
|
53
|
+
add("classify", true);
|
|
54
|
+
add("events", true);
|
|
55
|
+
add("monitor", true);
|
|
56
|
+
|
|
57
|
+
// `start` creates a new session; never re-applicable to an existing record.
|
|
58
|
+
add("start", false, "session-already-exists");
|
|
59
|
+
|
|
60
|
+
// `submit` is owner-routed: it requires a live owner and a non-blocked, non-terminal lifecycle.
|
|
61
|
+
if (terminal) add("submit", false, `lifecycle-terminal:${lifecycle}`);
|
|
62
|
+
else if (lifecycle === "blocked") add("submit", false, "lifecycle-blocked");
|
|
63
|
+
else if (!ownerLive) add("submit", false, "owner-not-live");
|
|
64
|
+
else add("submit", true);
|
|
65
|
+
|
|
66
|
+
// `recover` handles a dead/failed owner, so it is available without a live owner.
|
|
67
|
+
add("recover", !terminal, terminal ? `lifecycle-terminal:${lifecycle}` : undefined);
|
|
68
|
+
add("validate", !terminal, terminal ? `lifecycle-terminal:${lifecycle}` : undefined);
|
|
69
|
+
add("finalize", !terminal, terminal ? `lifecycle-terminal:${lifecycle}` : undefined);
|
|
70
|
+
add("retire", lifecycle !== "retired", lifecycle === "retired" ? "already-retired" : undefined);
|
|
71
|
+
|
|
72
|
+
return actions;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildStateView(state: SessionState, ownerLive: boolean): SessionStateView {
|
|
76
|
+
return {
|
|
77
|
+
sessionId: state.sessionId,
|
|
78
|
+
lifecycle: state.lifecycle,
|
|
79
|
+
harness: state.harness,
|
|
80
|
+
ownerLive,
|
|
81
|
+
blockers: state.blockers,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build the universal contract response carried by every primitive. */
|
|
86
|
+
export function buildResponse<E extends Record<string, unknown>>(
|
|
87
|
+
state: SessionState,
|
|
88
|
+
ownerLive: boolean,
|
|
89
|
+
evidence: E,
|
|
90
|
+
ok = true,
|
|
91
|
+
): PrimitiveResponse<E> {
|
|
92
|
+
return {
|
|
93
|
+
ok,
|
|
94
|
+
state: buildStateView(state, ownerLive),
|
|
95
|
+
evidence,
|
|
96
|
+
nextAllowedActions: nextAllowedActions(state.lifecycle, ownerLive),
|
|
97
|
+
};
|
|
98
|
+
}
|