parallel-codex-tui 0.1.3 → 0.1.5

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.
Files changed (78) hide show
  1. package/.parallel-codex/config.example.toml +136 -3
  2. package/README.md +299 -21
  3. package/dist/bootstrap.js +37 -17
  4. package/dist/cli-args.js +34 -3
  5. package/dist/cli-help.js +20 -0
  6. package/dist/cli-startup-preflight.js +18 -0
  7. package/dist/cli-startup-recovery.js +82 -0
  8. package/dist/cli-workspace-picker.js +330 -0
  9. package/dist/cli-workspace-transition.js +33 -0
  10. package/dist/cli-workspace.js +7 -71
  11. package/dist/cli.js +234 -24
  12. package/dist/core/collaboration-timeline.js +268 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +297 -109
  15. package/dist/core/file-store.js +119 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +7 -0
  18. package/dist/core/process-identity.js +48 -0
  19. package/dist/core/process-mutation-turn.js +128 -0
  20. package/dist/core/process-ownership.js +276 -0
  21. package/dist/core/process-tree.js +90 -0
  22. package/dist/core/router-audit.js +155 -0
  23. package/dist/core/router-redaction.js +31 -0
  24. package/dist/core/router.js +462 -35
  25. package/dist/core/session-index.js +412 -88
  26. package/dist/core/session-manager.js +1110 -40
  27. package/dist/core/task-session-details.js +175 -0
  28. package/dist/core/task-state-machine.js +18 -0
  29. package/dist/core/workspace-commit-recovery.js +118 -0
  30. package/dist/core/workspace.js +19 -11
  31. package/dist/doctor.js +373 -34
  32. package/dist/domain/schemas.js +142 -7
  33. package/dist/orchestrator/collaboration-channel.js +289 -6
  34. package/dist/orchestrator/feature-plan.js +70 -0
  35. package/dist/orchestrator/final-acceptance.js +86 -0
  36. package/dist/orchestrator/judge-artifacts.js +236 -0
  37. package/dist/orchestrator/orchestrator.js +2086 -203
  38. package/dist/orchestrator/prompts.js +168 -5
  39. package/dist/orchestrator/supervisor-summary.js +56 -2
  40. package/dist/orchestrator/workspace-sandbox.js +927 -0
  41. package/dist/tui/App.js +3187 -161
  42. package/dist/tui/AppShell.js +196 -25
  43. package/dist/tui/CollaborationTimelineView.js +327 -0
  44. package/dist/tui/FeatureBoardView.js +232 -0
  45. package/dist/tui/InputBar.js +581 -57
  46. package/dist/tui/RouterDiagnosticsView.js +469 -0
  47. package/dist/tui/StatusBar.js +610 -57
  48. package/dist/tui/StatusDetailView.js +164 -0
  49. package/dist/tui/TaskSessionDetailView.js +222 -0
  50. package/dist/tui/TaskSessionsView.js +210 -0
  51. package/dist/tui/TerminalOutput.js +53 -9
  52. package/dist/tui/WorkerOutputView.js +1404 -161
  53. package/dist/tui/WorkerOverviewView.js +269 -0
  54. package/dist/tui/chat-history.js +25 -0
  55. package/dist/tui/chat-input.js +67 -19
  56. package/dist/tui/chat-paste.js +76 -0
  57. package/dist/tui/display-width.js +41 -3
  58. package/dist/tui/incremental-text-file.js +101 -0
  59. package/dist/tui/keyboard.js +49 -0
  60. package/dist/tui/markdown-text.js +14 -0
  61. package/dist/tui/raw-input-decoder.js +3 -0
  62. package/dist/tui/scrolling.js +2 -1
  63. package/dist/tui/status-line.js +360 -11
  64. package/dist/tui/task-memory.js +13 -1
  65. package/dist/tui/task-result.js +105 -0
  66. package/dist/tui/terminal-screen.js +13 -1
  67. package/dist/tui/theme-contrast.js +144 -0
  68. package/dist/tui/theme-preview.js +109 -0
  69. package/dist/tui/theme.js +158 -0
  70. package/dist/version.js +1 -1
  71. package/dist/workers/capabilities.js +213 -0
  72. package/dist/workers/live-probe.js +177 -0
  73. package/dist/workers/mock-adapter.js +73 -8
  74. package/dist/workers/native-attach.js +106 -16
  75. package/dist/workers/process-adapter.js +572 -77
  76. package/dist/workers/provider.js +26 -0
  77. package/dist/workers/registry.js +12 -20
  78. package/package.json +11 -2
@@ -1,5 +1,8 @@
1
- import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
- import { basename, dirname, join } from "node:path";
1
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ const appendQueues = new Map();
4
+ const defaultRecentJsonLineChunkBytes = 64 * 1024;
5
+ const defaultRecentJsonLineMaxBytes = 2 * 1024 * 1024;
3
6
  export async function ensureDir(path) {
4
7
  await mkdir(path, { recursive: true });
5
8
  }
@@ -38,16 +41,120 @@ export async function readJson(path, schema) {
38
41
  return schema.parse(JSON.parse(text));
39
42
  }
40
43
  export async function appendJsonLine(path, value) {
41
- await ensureDir(dirname(path));
42
- await writeFile(path, `${JSON.stringify(value)}\n`, { encoding: "utf8", flag: "a" });
44
+ await appendFile(path, `${JSON.stringify(value)}\n`);
43
45
  }
44
46
  export async function writeText(path, value) {
45
47
  await ensureDir(dirname(path));
46
48
  await writeFile(path, value, "utf8");
47
49
  }
48
50
  export async function appendText(path, value) {
49
- await ensureDir(dirname(path));
50
- await writeFile(path, value, { encoding: "utf8", flag: "a" });
51
+ await appendFile(path, value);
52
+ }
53
+ export async function readRecentJsonLines(path, schema, limit, options = {}) {
54
+ const targetLimit = Number.isFinite(limit)
55
+ ? Math.min(10000, Math.max(0, Math.trunc(limit)))
56
+ : 10000;
57
+ if (targetLimit === 0) {
58
+ return [];
59
+ }
60
+ const chunkBytes = boundedPositiveInteger(options.chunkBytes, defaultRecentJsonLineChunkBytes, 4 * 1024 * 1024);
61
+ const maxLineBytes = boundedPositiveInteger(options.maxLineBytes, defaultRecentJsonLineMaxBytes, 16 * 1024 * 1024);
62
+ let handle;
63
+ try {
64
+ handle = await open(path, "r");
65
+ }
66
+ catch (error) {
67
+ if (error.code === "ENOENT") {
68
+ return [];
69
+ }
70
+ throw error;
71
+ }
72
+ try {
73
+ let position = (await handle.stat()).size;
74
+ let lineSegments = [];
75
+ let lineBytes = 0;
76
+ let lineTooLong = false;
77
+ const newestFirst = [];
78
+ const addLineSegment = (segment) => {
79
+ if (segment.length === 0 || lineTooLong) {
80
+ return;
81
+ }
82
+ lineBytes += segment.length;
83
+ if (lineBytes > maxLineBytes) {
84
+ lineSegments = [];
85
+ lineTooLong = true;
86
+ return;
87
+ }
88
+ lineSegments.push(Buffer.from(segment));
89
+ };
90
+ const finishLine = () => {
91
+ if (!lineTooLong && lineBytes > 0) {
92
+ const line = Buffer.concat([...lineSegments].reverse(), lineBytes).toString("utf8").trim();
93
+ if (line) {
94
+ try {
95
+ const parsed = schema.safeParse(JSON.parse(line));
96
+ if (parsed.success) {
97
+ newestFirst.push(parsed.data);
98
+ }
99
+ }
100
+ catch {
101
+ // Invalid or partial rows do not hide earlier valid records.
102
+ }
103
+ }
104
+ }
105
+ lineSegments = [];
106
+ lineBytes = 0;
107
+ lineTooLong = false;
108
+ };
109
+ while (position > 0 && newestFirst.length < targetLimit) {
110
+ const requested = Math.min(chunkBytes, position);
111
+ position -= requested;
112
+ const buffer = Buffer.allocUnsafe(requested);
113
+ const { bytesRead } = await handle.read(buffer, 0, requested, position);
114
+ if (bytesRead === 0) {
115
+ continue;
116
+ }
117
+ let segmentEnd = bytesRead;
118
+ for (let index = bytesRead - 1; index >= 0; index -= 1) {
119
+ if (buffer[index] !== 0x0a) {
120
+ continue;
121
+ }
122
+ addLineSegment(buffer.subarray(index + 1, segmentEnd));
123
+ finishLine();
124
+ segmentEnd = index;
125
+ if (newestFirst.length >= targetLimit) {
126
+ break;
127
+ }
128
+ }
129
+ if (newestFirst.length < targetLimit) {
130
+ addLineSegment(buffer.subarray(0, segmentEnd));
131
+ }
132
+ }
133
+ if (position === 0 && newestFirst.length < targetLimit && (lineBytes > 0 || lineTooLong)) {
134
+ finishLine();
135
+ }
136
+ return newestFirst.reverse();
137
+ }
138
+ finally {
139
+ await handle.close();
140
+ }
141
+ }
142
+ async function appendFile(path, value) {
143
+ const key = resolve(path);
144
+ const previous = appendQueues.get(key) ?? Promise.resolve();
145
+ const operation = previous.catch(() => undefined).then(async () => {
146
+ await ensureDir(dirname(path));
147
+ await writeFile(path, value, { encoding: "utf8", flag: "a" });
148
+ });
149
+ appendQueues.set(key, operation);
150
+ try {
151
+ await operation;
152
+ }
153
+ finally {
154
+ if (appendQueues.get(key) === operation) {
155
+ appendQueues.delete(key);
156
+ }
157
+ }
51
158
  }
52
159
  export async function readTextIfExists(path) {
53
160
  if (!(await pathExists(path))) {
@@ -65,3 +172,9 @@ export async function removeIfExists(path) {
65
172
  }
66
173
  }
67
174
  }
175
+ function boundedPositiveInteger(value, fallback, maximum) {
176
+ if (typeof value !== "number" || !Number.isFinite(value)) {
177
+ return fallback;
178
+ }
179
+ return Math.min(maximum, Math.max(1, Math.trunc(value)));
180
+ }
@@ -0,0 +1,22 @@
1
+ export async function runWithLeaseFinalization(subject, lease, run) {
2
+ const outcome = await run().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
3
+ try {
4
+ await lease.release();
5
+ }
6
+ catch (releaseError) {
7
+ const releaseSummary = `${subject} lease release failed: ${errorMessage(releaseError)}`;
8
+ if (!outcome.ok) {
9
+ throw new Error(`${errorMessage(outcome.error)}; ${releaseSummary}`, {
10
+ cause: new AggregateError([outcome.error, releaseError])
11
+ });
12
+ }
13
+ throw new Error(releaseSummary, { cause: releaseError });
14
+ }
15
+ if (!outcome.ok) {
16
+ throw outcome.error;
17
+ }
18
+ return outcome.value;
19
+ }
20
+ function errorMessage(error) {
21
+ return error instanceof Error ? error.message : String(error);
22
+ }
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { TaskSessionIdSchema } from "../domain/schemas.js";
2
3
  function pad(value) {
3
4
  return String(value).padStart(2, "0");
4
5
  }
@@ -16,5 +17,11 @@ export function routerRuntimeDir(appRoot, dataDir) {
16
17
  return join(appRoot, dataDir, "router");
17
18
  }
18
19
  export function taskDir(projectRoot, dataDir, taskId) {
20
+ if (!taskSessionIdIsValid(taskId)) {
21
+ throw new Error(`Invalid task session id: ${JSON.stringify(taskId)}`);
22
+ }
19
23
  return join(sessionsRoot(projectRoot, dataDir), taskId);
20
24
  }
25
+ export function taskSessionIdIsValid(taskId) {
26
+ return TaskSessionIdSchema.safeParse(taskId).success;
27
+ }
@@ -0,0 +1,48 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { promisify } from "node:util";
4
+ const execFileAsync = promisify(execFile);
5
+ export function processIsAlive(pid) {
6
+ if (!Number.isInteger(pid) || pid <= 0) {
7
+ return false;
8
+ }
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ }
13
+ catch (error) {
14
+ return error.code === "EPERM";
15
+ }
16
+ }
17
+ export async function readProcessStartToken(pid) {
18
+ if (!processIsAlive(pid)) {
19
+ return null;
20
+ }
21
+ if (process.platform === "linux") {
22
+ try {
23
+ const stat = await readFile(`/proc/${pid}/stat`, "utf8");
24
+ const fields = stat.slice(stat.lastIndexOf(") ") + 2).trim().split(/\s+/);
25
+ const startTick = fields[19];
26
+ if (startTick) {
27
+ return `linux:${startTick}`;
28
+ }
29
+ }
30
+ catch {
31
+ // Fall through to ps when procfs is unavailable.
32
+ }
33
+ }
34
+ if (process.platform !== "win32") {
35
+ try {
36
+ const result = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)], {
37
+ encoding: "utf8",
38
+ timeout: 1000
39
+ });
40
+ const value = String(result.stdout).trim().replace(/\s+/g, " ");
41
+ return value ? `ps:${value}` : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ return null;
48
+ }
@@ -0,0 +1,128 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { ensureDir, readJson, removeIfExists, writeJson } from "./file-store.js";
6
+ import { processIsAlive, readProcessStartToken } from "./process-identity.js";
7
+ const ProcessMutationIntentSchema = z.object({
8
+ version: z.literal(1),
9
+ intent_id: z.string().min(1),
10
+ pid: z.number().int().positive(),
11
+ created_at: z.string().datetime(),
12
+ choosing: z.boolean(),
13
+ ticket: z.number().int().nonnegative(),
14
+ process_start_token: z.string().min(1).optional()
15
+ });
16
+ let currentProcessStartToken = null;
17
+ export async function acquireProcessMutationTurn(directory, options) {
18
+ validateIntentPrefix(options.intentPrefix);
19
+ await ensureDir(directory);
20
+ const identity = await currentMutationIdentity();
21
+ const intentId = randomUUID();
22
+ const path = join(directory, `${options.intentPrefix}${intentId}.json`);
23
+ let intent = ProcessMutationIntentSchema.parse({
24
+ version: 1,
25
+ intent_id: intentId,
26
+ pid: identity.pid,
27
+ created_at: new Date().toISOString(),
28
+ choosing: true,
29
+ ticket: 0,
30
+ ...(identity.process_start_token ? { process_start_token: identity.process_start_token } : {})
31
+ });
32
+ await writeJson(path, intent);
33
+ try {
34
+ const existing = await readActiveIntents(directory, options.intentPrefix);
35
+ intent = {
36
+ ...intent,
37
+ choosing: false,
38
+ ticket: Math.max(0, ...existing.map((candidate) => candidate.ticket)) + 1
39
+ };
40
+ await writeJson(path, intent);
41
+ const deadline = Date.now() + (options.timeoutMs ?? 5000);
42
+ while (true) {
43
+ const candidates = await readActiveIntents(directory, options.intentPrefix);
44
+ const blocked = candidates.some((candidate) => (candidate.intent_id !== intent.intent_id
45
+ && (candidate.choosing || intentPrecedes(candidate, intent))));
46
+ if (!blocked) {
47
+ let released = false;
48
+ return {
49
+ release: async () => {
50
+ if (released) {
51
+ return;
52
+ }
53
+ released = true;
54
+ await removeIfExists(path);
55
+ }
56
+ };
57
+ }
58
+ if (Date.now() >= deadline) {
59
+ throw new Error(options.timeoutMessage);
60
+ }
61
+ await delay(options.pollMs ?? 5);
62
+ }
63
+ }
64
+ catch (error) {
65
+ await removeIfExists(path);
66
+ throw error;
67
+ }
68
+ }
69
+ async function currentMutationIdentity() {
70
+ currentProcessStartToken ??= readProcessStartToken(process.pid);
71
+ const processStartToken = await currentProcessStartToken;
72
+ return {
73
+ pid: process.pid,
74
+ ...(processStartToken ? { process_start_token: processStartToken } : {})
75
+ };
76
+ }
77
+ async function readActiveIntents(directory, prefix) {
78
+ const names = await readdir(directory);
79
+ const tokenReads = new Map();
80
+ const active = [];
81
+ for (const name of names) {
82
+ if (!name.startsWith(prefix) || !name.endsWith(".json")) {
83
+ continue;
84
+ }
85
+ const path = join(directory, name);
86
+ const intent = await readValidIntent(path);
87
+ if (!intent || !processIsAlive(intent.pid)) {
88
+ await removeIfExists(path);
89
+ continue;
90
+ }
91
+ if (intent.process_start_token) {
92
+ let tokenRead = tokenReads.get(intent.pid);
93
+ if (!tokenRead) {
94
+ tokenRead = readProcessStartToken(intent.pid);
95
+ tokenReads.set(intent.pid, tokenRead);
96
+ }
97
+ const currentToken = await tokenRead;
98
+ if (!currentToken || currentToken !== intent.process_start_token) {
99
+ await removeIfExists(path);
100
+ continue;
101
+ }
102
+ }
103
+ active.push(intent);
104
+ }
105
+ return active;
106
+ }
107
+ async function readValidIntent(path) {
108
+ try {
109
+ return await readJson(path, ProcessMutationIntentSchema);
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ function intentPrecedes(candidate, current) {
116
+ if (candidate.ticket !== current.ticket) {
117
+ return candidate.ticket < current.ticket;
118
+ }
119
+ return candidate.intent_id < current.intent_id;
120
+ }
121
+ function validateIntentPrefix(prefix) {
122
+ if (!prefix.startsWith(".") || prefix.includes("/") || prefix.includes("\\")) {
123
+ throw new Error(`Invalid process mutation intent prefix: ${prefix}`);
124
+ }
125
+ }
126
+ async function delay(milliseconds) {
127
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
128
+ }
@@ -0,0 +1,276 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { ensureDir, pathExists, readJson, removeIfExists, writeJson } from "./file-store.js";
6
+ import { processIsAlive, readProcessStartToken } from "./process-identity.js";
7
+ import { acquireProcessMutationTurn } from "./process-mutation-turn.js";
8
+ export { processIsAlive, readProcessStartToken } from "./process-identity.js";
9
+ const TaskRunOwnerSchema = z.object({
10
+ version: z.literal(1),
11
+ owner_id: z.string().min(1),
12
+ pid: z.number().int().positive(),
13
+ acquired_at: z.string().datetime(),
14
+ process_start_token: z.string().min(1).optional()
15
+ });
16
+ const CLAIM_INTENT_PREFIX = ".run-owner-claim-";
17
+ const CLAIM_INTENT_TIMEOUT_MS = 5000;
18
+ const CLAIM_INTENT_POLL_MS = 5;
19
+ const WorkerProcessRecordSchema = z.object({
20
+ version: z.literal(1),
21
+ worker_id: z.string().min(1),
22
+ pid: z.number().int().positive(),
23
+ process_group_id: z.number().int().positive().optional(),
24
+ process_start_token: z.string().min(1).optional(),
25
+ owner_pid: z.number().int().positive(),
26
+ command: z.string().min(1),
27
+ started_at: z.string().datetime()
28
+ });
29
+ export class TaskRunLeaseConflictError extends Error {
30
+ owner;
31
+ constructor(owner) {
32
+ super(`Task is already running in another parallel-codex-tui process (pid ${owner?.pid ?? "unknown"}).`);
33
+ this.owner = owner;
34
+ this.name = "TaskRunLeaseConflictError";
35
+ }
36
+ }
37
+ export function taskRunOwnerPath(taskDir) {
38
+ return join(taskDir, "run-owner.json");
39
+ }
40
+ export function workerProcessRecordPath(workerDir) {
41
+ return join(workerDir, "process.json");
42
+ }
43
+ export async function claimTaskRunLease(taskDir, options = {}) {
44
+ const pid = options.pid ?? process.pid;
45
+ const owner = TaskRunOwnerSchema.parse({
46
+ version: 1,
47
+ owner_id: options.ownerId ?? randomUUID(),
48
+ pid,
49
+ acquired_at: (options.now ?? (() => new Date()))().toISOString(),
50
+ ...await optionalProcessStartToken(pid)
51
+ });
52
+ const path = taskRunOwnerPath(taskDir);
53
+ await ensureDir(taskDir);
54
+ const mutationTurn = await acquireTaskRunMutationTurn(taskDir);
55
+ try {
56
+ if (await writeJsonExclusive(path, owner)) {
57
+ return {
58
+ owner,
59
+ release: () => releaseTaskRunLease(taskDir, path, owner.owner_id)
60
+ };
61
+ }
62
+ const inspection = await inspectTaskRunLease(taskDir);
63
+ if (inspection.state === "active") {
64
+ throw new TaskRunLeaseConflictError(inspection.owner);
65
+ }
66
+ await removeOwnedLease(path, inspection.owner?.owner_id);
67
+ if (await writeJsonExclusive(path, owner)) {
68
+ return {
69
+ owner,
70
+ release: () => releaseTaskRunLease(taskDir, path, owner.owner_id)
71
+ };
72
+ }
73
+ const current = await inspectTaskRunLease(taskDir);
74
+ throw new TaskRunLeaseConflictError(current.owner);
75
+ }
76
+ finally {
77
+ await mutationTurn.release();
78
+ }
79
+ }
80
+ export async function inspectTaskRunLease(taskDir) {
81
+ const path = taskRunOwnerPath(taskDir);
82
+ if (!(await pathExists(path))) {
83
+ return { state: "missing", owner: null };
84
+ }
85
+ const owner = await readValidJson(path, TaskRunOwnerSchema);
86
+ if (!owner) {
87
+ return { state: "stale", owner: null };
88
+ }
89
+ if (!processIsAlive(owner.pid)) {
90
+ return { state: "stale", owner };
91
+ }
92
+ if (owner.process_start_token) {
93
+ const currentToken = await readProcessStartToken(owner.pid);
94
+ if (!currentToken || currentToken !== owner.process_start_token) {
95
+ return { state: "stale", owner };
96
+ }
97
+ }
98
+ return { state: "active", owner };
99
+ }
100
+ export async function clearStaleTaskRunLease(taskDir, owner) {
101
+ const mutationTurn = await acquireTaskRunMutationTurn(taskDir);
102
+ try {
103
+ const inspection = await inspectTaskRunLease(taskDir);
104
+ if (inspection.state !== "stale") {
105
+ return;
106
+ }
107
+ await removeOwnedLease(taskRunOwnerPath(taskDir), owner?.owner_id ?? inspection.owner?.owner_id);
108
+ }
109
+ finally {
110
+ await mutationTurn.release();
111
+ }
112
+ }
113
+ export async function writeWorkerProcessRecord(workerDir, input) {
114
+ const record = WorkerProcessRecordSchema.parse({
115
+ version: 1,
116
+ worker_id: input.workerId,
117
+ pid: input.pid,
118
+ ...(input.processGroupId ? { process_group_id: input.processGroupId } : {}),
119
+ owner_pid: process.pid,
120
+ command: input.command,
121
+ started_at: (input.now ?? (() => new Date()))().toISOString(),
122
+ ...await optionalProcessStartToken(input.pid)
123
+ });
124
+ await writeJson(workerProcessRecordPath(workerDir), record);
125
+ return record;
126
+ }
127
+ export async function clearWorkerProcessRecord(workerDir) {
128
+ await removeIfExists(workerProcessRecordPath(workerDir));
129
+ }
130
+ export async function terminateOwnedWorkerProcess(workerDir) {
131
+ const path = workerProcessRecordPath(workerDir);
132
+ if (!(await pathExists(path))) {
133
+ return "missing";
134
+ }
135
+ const record = await readValidJson(path, WorkerProcessRecordSchema);
136
+ if (!record) {
137
+ return "unverifiable";
138
+ }
139
+ const leaderIsAlive = processIsAlive(record.pid);
140
+ if (!ownedProcessIsAlive(record)) {
141
+ await removeIfExists(path);
142
+ return "not-running";
143
+ }
144
+ if (!record.process_start_token) {
145
+ return "unverifiable";
146
+ }
147
+ if (leaderIsAlive) {
148
+ const currentToken = await readProcessStartToken(record.pid);
149
+ if (!currentToken) {
150
+ return "unverifiable";
151
+ }
152
+ if (currentToken !== record.process_start_token) {
153
+ return "identity-mismatch";
154
+ }
155
+ }
156
+ signalOwnedProcess(record, "SIGTERM");
157
+ if (!(await waitForOwnedProcessExit(record, 1500))) {
158
+ if (!targetsProcessGroup(record)) {
159
+ const tokenBeforeKill = await readProcessStartToken(record.pid);
160
+ if (tokenBeforeKill !== record.process_start_token) {
161
+ return "identity-mismatch";
162
+ }
163
+ }
164
+ signalOwnedProcess(record, "SIGKILL");
165
+ await waitForOwnedProcessExit(record, 500);
166
+ }
167
+ if (!ownedProcessIsAlive(record)) {
168
+ await removeIfExists(path);
169
+ return "terminated";
170
+ }
171
+ return "still-running";
172
+ }
173
+ async function optionalProcessStartToken(pid) {
174
+ const processStartToken = await readProcessStartToken(pid);
175
+ return processStartToken ? { process_start_token: processStartToken } : {};
176
+ }
177
+ async function writeJsonExclusive(path, value) {
178
+ try {
179
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
180
+ return true;
181
+ }
182
+ catch (error) {
183
+ if (error.code === "EEXIST") {
184
+ return false;
185
+ }
186
+ throw error;
187
+ }
188
+ }
189
+ async function acquireTaskRunMutationTurn(taskDir) {
190
+ return acquireProcessMutationTurn(taskDir, {
191
+ intentPrefix: CLAIM_INTENT_PREFIX,
192
+ timeoutMs: CLAIM_INTENT_TIMEOUT_MS,
193
+ pollMs: CLAIM_INTENT_POLL_MS,
194
+ timeoutMessage: "Timed out waiting to update task run ownership."
195
+ });
196
+ }
197
+ async function releaseTaskRunLease(taskDir, path, ownerId) {
198
+ if (!(await pathExists(taskDir))) {
199
+ return;
200
+ }
201
+ const mutationTurn = await acquireTaskRunMutationTurn(taskDir);
202
+ try {
203
+ await removeOwnedLease(path, ownerId);
204
+ }
205
+ finally {
206
+ await mutationTurn.release();
207
+ }
208
+ }
209
+ async function removeOwnedLease(path, ownerId) {
210
+ if (ownerId) {
211
+ const current = await readValidJson(path, TaskRunOwnerSchema);
212
+ if (!current || current.owner_id !== ownerId) {
213
+ return;
214
+ }
215
+ }
216
+ await removeIfExists(path);
217
+ }
218
+ async function readValidJson(path, schema) {
219
+ try {
220
+ return await readJson(path, schema);
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ function signalProcess(pid, signal) {
227
+ try {
228
+ process.kill(pid, signal);
229
+ }
230
+ catch (error) {
231
+ if (error.code !== "ESRCH") {
232
+ throw error;
233
+ }
234
+ }
235
+ }
236
+ function signalOwnedProcess(record, signal) {
237
+ if (targetsProcessGroup(record)) {
238
+ try {
239
+ process.kill(-record.process_group_id, signal);
240
+ return;
241
+ }
242
+ catch (error) {
243
+ if (error.code !== "ESRCH") {
244
+ throw error;
245
+ }
246
+ }
247
+ }
248
+ signalProcess(record.pid, signal);
249
+ }
250
+ function targetsProcessGroup(record) {
251
+ return Boolean(record.process_group_id && process.platform !== "win32");
252
+ }
253
+ function ownedProcessIsAlive(record) {
254
+ return targetsProcessGroup(record)
255
+ ? processGroupIsAlive(record.process_group_id)
256
+ : processIsAlive(record.pid);
257
+ }
258
+ function processGroupIsAlive(processGroupId) {
259
+ try {
260
+ process.kill(-processGroupId, 0);
261
+ return true;
262
+ }
263
+ catch (error) {
264
+ return error.code === "EPERM";
265
+ }
266
+ }
267
+ async function waitForOwnedProcessExit(record, timeoutMs) {
268
+ const deadline = Date.now() + timeoutMs;
269
+ while (Date.now() < deadline) {
270
+ if (!ownedProcessIsAlive(record)) {
271
+ return true;
272
+ }
273
+ await new Promise((resolve) => setTimeout(resolve, 40));
274
+ }
275
+ return !ownedProcessIsAlive(record);
276
+ }