@gajae-code/coding-agent 0.17.4 → 0.17.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/dist/types/config/autorouting-tier-map.d.ts +1031 -3
- package/dist/types/config/file-lock.d.ts +4 -2
- package/dist/types/config/model-registry.d.ts +3 -1
- package/dist/types/config/model-resolver.d.ts +1 -1
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +11 -2
- package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
- package/dist/types/harness-control-plane/control-endpoint.d.ts +4 -1
- package/dist/types/harness-control-plane/finalize.d.ts +14 -3
- package/dist/types/main.d.ts +1 -1
- package/dist/types/runtime-mcp/loader.d.ts +2 -0
- package/dist/types/runtime-mcp/manager.d.ts +13 -6
- package/dist/types/sdk/broker/broker.d.ts +4 -2
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/identity.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +11 -3
- package/dist/types/sdk/broker/managed-task-dag.d.ts +1335 -0
- package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +8 -0
- package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -1
- package/dist/types/sdk/prompt-deadline-flush.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +3 -3
- package/dist/types/session/agent-session.d.ts +7 -2
- package/dist/types/task/types.d.ts +2 -0
- package/dist/types/tools/bash-interactive.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +10 -1
- package/dist/types/tools/subagent.d.ts +8 -1
- package/package.json +7 -7
- package/src/cli/public-command-registry.ts +8 -2
- package/src/cli/setup-cli.ts +1 -0
- package/src/cli/stats-cli.ts +11 -1
- package/src/config/autorouting-tier-map.ts +529 -3
- package/src/config/file-lock.ts +59 -14
- package/src/config/model-discovery-manager.ts +4 -4
- package/src/config/model-profile-activation.ts +83 -23
- package/src/config/model-registry.ts +72 -12
- package/src/config/model-resolver.ts +6 -1
- package/src/coordinator-mcp/question-state.ts +4 -1
- package/src/coordinator-mcp/server.ts +14 -6
- package/src/exec/bash-executor.ts +3 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
- package/src/gjc-runtime/state-writer.ts +317 -6
- package/src/harness-control-plane/control-endpoint.ts +24 -6
- package/src/harness-control-plane/finalize.ts +312 -25
- package/src/harness-control-plane/owner.ts +323 -89
- package/src/internal-urls/docs-index.generated.ts +6 -4
- package/src/main.ts +69 -19
- package/src/modes/acp/acp-agent.ts +581 -37
- package/src/modes/components/gjc-bundle-settings.ts +21 -1
- package/src/modes/components/tool-status-header.ts +72 -6
- package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
- package/src/runtime-mcp/loader.ts +6 -3
- package/src/runtime-mcp/manager.ts +177 -75
- package/src/sdk/broker/broker.ts +1058 -150
- package/src/sdk/broker/ensure.ts +65 -8
- package/src/sdk/broker/identity.ts +13 -0
- package/src/sdk/broker/lifecycle-ledger.ts +16 -4
- package/src/sdk/broker/managed-task-dag.ts +1927 -0
- package/src/sdk/broker/managed-task-verification.ts +642 -0
- package/src/sdk/broker/spawn-authority.ts +140 -7
- package/src/sdk/broker/startup-failure.ts +80 -1
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +22 -6
- package/src/sdk/bus/telegram-daemon-contract.ts +5 -1
- package/src/sdk/cli/session-cli.ts +18 -0
- package/src/sdk/host/session-runtime.test.ts +46 -0
- package/src/sdk/host/session-runtime.ts +13 -6
- package/src/sdk/lifecycle/service.ts +2 -0
- package/src/sdk/prompt-deadline-flush.ts +26 -4
- package/src/sdk/session.ts +587 -115
- package/src/session/agent-session.ts +60 -14
- package/src/session/internal/managed-session-scope.ts +10 -0
- package/src/session/internal/managed-session-storage.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/task/executor.ts +46 -17
- package/src/task/types.ts +2 -0
- package/src/tools/bash-interactive.ts +2 -0
- package/src/tools/bash.ts +20 -0
- package/src/tools/fetch.ts +21 -5
- package/src/tools/subagent-render.ts +24 -1
- package/src/tools/subagent.ts +27 -1
- package/src/utils/mupdf.ts +11 -4
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
13
|
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
14
15
|
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { type LinuxProcPidProbeResult, probeLinuxProcPidSync } from "../gjc-runtime/linux-proc";
|
|
15
17
|
import {
|
|
16
18
|
buildReceipt,
|
|
17
19
|
type CompletionEvidence,
|
|
@@ -23,7 +25,7 @@ import {
|
|
|
23
25
|
type ValidationEvidence,
|
|
24
26
|
validateReceipt,
|
|
25
27
|
} from "./receipts";
|
|
26
|
-
import { readReceiptIndex, writeReceiptImmutable } from "./storage";
|
|
28
|
+
import { type ReceiptIndexEntry, readReceiptIndex, writeReceiptImmutable } from "./storage";
|
|
27
29
|
import { extractReviewVerdict, isReviewVerdict, type ReviewVerdict } from "./types";
|
|
28
30
|
|
|
29
31
|
export interface ValidationCommandSpec {
|
|
@@ -37,12 +39,28 @@ export interface ValidationRun {
|
|
|
37
39
|
exitStatus: number;
|
|
38
40
|
pass: boolean;
|
|
39
41
|
}
|
|
42
|
+
export class ValidationObservationUncertainError extends Error {
|
|
43
|
+
readonly exactCommand: string;
|
|
44
|
+
readonly cwd: string;
|
|
45
|
+
constructor(exactCommand: string, cwd: string, cause?: unknown) {
|
|
46
|
+
super("validation observation is uncertain");
|
|
47
|
+
this.name = "ValidationObservationUncertainError";
|
|
48
|
+
this.exactCommand = exactCommand;
|
|
49
|
+
this.cwd = cwd;
|
|
50
|
+
if (cause !== undefined) this.cause = cause;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
40
53
|
|
|
41
54
|
export interface FinalizeChecks {
|
|
42
|
-
runValidation(spec: ValidationCommandSpec): Promise<ValidationRun>;
|
|
55
|
+
runValidation(spec: ValidationCommandSpec, signal?: AbortSignal): Promise<ValidationRun>;
|
|
43
56
|
resolveCommit(): Promise<string | null>;
|
|
44
57
|
commitOnBranch(commit: string, branch: string): Promise<boolean>;
|
|
45
58
|
prOrIssue(): Promise<{ prUrl: string | null; issueArtifact: string | null }>;
|
|
59
|
+
/** Owner-scoped receipt commit hook; null means lifecycle authority was withdrawn. */
|
|
60
|
+
writeReceipt?(
|
|
61
|
+
family: "validation" | "completion" | "review-failure" | "review-verdict",
|
|
62
|
+
receipt: ReceiptEnvelope<unknown>,
|
|
63
|
+
): Promise<ReceiptIndexEntry | null>;
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
export interface FinalizeOptions {
|
|
@@ -66,6 +84,8 @@ export interface FinalizeOptions {
|
|
|
66
84
|
prTarget?: string | null;
|
|
67
85
|
validationCommands?: ValidationCommandSpec[];
|
|
68
86
|
checks: FinalizeChecks;
|
|
87
|
+
/** Cancels an in-flight validation subprocess when its owning request is withdrawn. */
|
|
88
|
+
signal?: AbortSignal;
|
|
69
89
|
clock?: () => number;
|
|
70
90
|
}
|
|
71
91
|
|
|
@@ -84,6 +104,15 @@ function receiptId(prefix: string): string {
|
|
|
84
104
|
return `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
function persistFinalizeReceipt(
|
|
108
|
+
opts: FinalizeOptions,
|
|
109
|
+
family: "validation" | "completion" | "review-failure" | "review-verdict",
|
|
110
|
+
receipt: ReceiptEnvelope<unknown>,
|
|
111
|
+
): Promise<ReceiptIndexEntry | null> {
|
|
112
|
+
if (opts.checks.writeReceipt) return opts.checks.writeReceipt(family, receipt);
|
|
113
|
+
return writeReceiptImmutable(opts.root, opts.sessionId, family, receipt.receiptId, receipt);
|
|
114
|
+
}
|
|
115
|
+
|
|
87
116
|
/** Bound + whitespace-collapse assistant text into a redaction-safe digest summary (never a raw dump). */
|
|
88
117
|
function boundedAssistantSummary(text: string | null): string | null {
|
|
89
118
|
if (!text) return null;
|
|
@@ -93,19 +122,53 @@ function boundedAssistantSummary(text: string | null): string | null {
|
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
export async function runFinalize(opts: FinalizeOptions): Promise<FinalizeResult> {
|
|
125
|
+
if (opts.signal?.aborted) {
|
|
126
|
+
return {
|
|
127
|
+
completed: false,
|
|
128
|
+
receiptPath: null,
|
|
129
|
+
validation: [],
|
|
130
|
+
commitHash: null,
|
|
131
|
+
prUrl: null,
|
|
132
|
+
issueArtifact: null,
|
|
133
|
+
blockers: ["validation-unknown:cancelled"],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
96
136
|
if (opts.reviewOnly) return runReviewFinalize(opts);
|
|
97
137
|
|
|
98
138
|
const now = () => new Date(opts.clock ? opts.clock() : Date.now()).toISOString();
|
|
99
139
|
const blockers: string[] = [];
|
|
100
140
|
const validation: FinalizeResult["validation"] = [];
|
|
101
141
|
const validationReceiptIds: string[] = [];
|
|
142
|
+
const canceled = (
|
|
143
|
+
commit: string | null,
|
|
144
|
+
artifact: { prUrl: string | null; issueArtifact: string | null },
|
|
145
|
+
): FinalizeResult => ({
|
|
146
|
+
completed: false,
|
|
147
|
+
receiptPath: null,
|
|
148
|
+
validation,
|
|
149
|
+
commitHash: commit,
|
|
150
|
+
prUrl: artifact.prUrl,
|
|
151
|
+
issueArtifact: artifact.issueArtifact,
|
|
152
|
+
blockers: ["validation-unknown:cancelled"],
|
|
153
|
+
});
|
|
102
154
|
|
|
103
155
|
const commit = await opts.checks.resolveCommit();
|
|
156
|
+
if (opts.signal?.aborted) return canceled(commit, { prUrl: null, issueArtifact: null });
|
|
104
157
|
const subject: ReceiptSubject = { workspace: opts.workspace, branch: opts.branch, head: commit, commit };
|
|
105
158
|
|
|
106
159
|
// 1. Validation receipts.
|
|
107
160
|
for (const spec of opts.validationCommands ?? []) {
|
|
108
|
-
|
|
161
|
+
let run: ValidationRun;
|
|
162
|
+
try {
|
|
163
|
+
run = await opts.checks.runValidation(spec, opts.signal);
|
|
164
|
+
} catch (caught) {
|
|
165
|
+
if (caught instanceof ValidationObservationUncertainError) {
|
|
166
|
+
blockers.push(`validation-unknown:${spec.name}`);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
throw caught;
|
|
170
|
+
}
|
|
171
|
+
if (opts.signal?.aborted) return canceled(commit, { prUrl: null, issueArtifact: null });
|
|
109
172
|
const evidence: ValidationEvidence = {
|
|
110
173
|
command: spec.name,
|
|
111
174
|
exactCommand: run.exactCommand,
|
|
@@ -124,25 +187,36 @@ export async function runFinalize(opts: FinalizeOptions): Promise<FinalizeResult
|
|
|
124
187
|
createdAt: now(),
|
|
125
188
|
valid: run.pass,
|
|
126
189
|
});
|
|
127
|
-
await
|
|
190
|
+
const entry = await persistFinalizeReceipt(opts, "validation", receipt);
|
|
191
|
+
if (!entry) return canceled(commit, { prUrl: null, issueArtifact: null });
|
|
128
192
|
const outcome = validateReceipt(receipt);
|
|
129
193
|
validation.push({ name: spec.name, valid: outcome.valid, exitStatus: run.exitStatus });
|
|
130
194
|
validationReceiptIds.push(receipt.receiptId);
|
|
195
|
+
if (opts.signal?.aborted) return canceled(commit, { prUrl: null, issueArtifact: null });
|
|
131
196
|
if (opts.requireTests && !outcome.valid) blockers.push(`validation-failed:${spec.name}`);
|
|
132
197
|
}
|
|
133
198
|
if (opts.requireTests && (opts.validationCommands?.length ?? 0) === 0) {
|
|
134
199
|
blockers.push("validation-required-but-none-run");
|
|
135
200
|
}
|
|
201
|
+
const validationUnknown = blockers.some(blocker => blocker.startsWith("validation-unknown:"));
|
|
136
202
|
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
203
|
+
let artifact: { prUrl: string | null; issueArtifact: string | null } = { prUrl: null, issueArtifact: null };
|
|
204
|
+
if (!validationUnknown) {
|
|
205
|
+
// 2. Commit on branch.
|
|
206
|
+
if (opts.requireCommit) {
|
|
207
|
+
if (!commit) blockers.push("missing-commit");
|
|
208
|
+
else {
|
|
209
|
+
const onBranch = await opts.checks.commitOnBranch(commit, opts.branch);
|
|
210
|
+
if (opts.signal?.aborted) return canceled(commit, artifact);
|
|
211
|
+
if (!onBranch) blockers.push("commit-not-on-branch");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
142
214
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
215
|
+
// 3. PR / issue artifact.
|
|
216
|
+
artifact = await opts.checks.prOrIssue();
|
|
217
|
+
if (opts.signal?.aborted) return canceled(commit, artifact);
|
|
218
|
+
if (opts.requirePr && !artifact.prUrl && !artifact.issueArtifact) blockers.push("missing-pr-or-issue");
|
|
219
|
+
}
|
|
146
220
|
|
|
147
221
|
// B4: cross-validate the persisted validation receipts (validity + commit freshness) before completion.
|
|
148
222
|
if (blockers.length === 0) {
|
|
@@ -170,6 +244,7 @@ export async function runFinalize(opts: FinalizeOptions): Promise<FinalizeResult
|
|
|
170
244
|
blockers,
|
|
171
245
|
};
|
|
172
246
|
}
|
|
247
|
+
if (opts.signal?.aborted) return canceled(commit, artifact);
|
|
173
248
|
|
|
174
249
|
// 4. Completion receipt + predicate.
|
|
175
250
|
const completion: CompletionEvidence = {
|
|
@@ -192,7 +267,8 @@ export async function runFinalize(opts: FinalizeOptions): Promise<FinalizeResult
|
|
|
192
267
|
createdAt: now(),
|
|
193
268
|
});
|
|
194
269
|
const outcome = validateReceipt(receipt);
|
|
195
|
-
const entry = await
|
|
270
|
+
const entry = await persistFinalizeReceipt(opts, "completion", receipt);
|
|
271
|
+
if (!entry) return canceled(commit, artifact);
|
|
196
272
|
return {
|
|
197
273
|
completed: outcome.valid,
|
|
198
274
|
receiptPath: entry.path,
|
|
@@ -227,6 +303,15 @@ async function runReviewFinalize(opts: FinalizeOptions): Promise<FinalizeResult>
|
|
|
227
303
|
prUrl: null,
|
|
228
304
|
issueArtifact: null,
|
|
229
305
|
};
|
|
306
|
+
if (opts.signal?.aborted) {
|
|
307
|
+
return {
|
|
308
|
+
...baseResult,
|
|
309
|
+
completed: false,
|
|
310
|
+
receiptPath: null,
|
|
311
|
+
verdict: null,
|
|
312
|
+
blockers: ["validation-unknown:cancelled"],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
230
315
|
|
|
231
316
|
// Explicit operator/loop verdict always wins. Only when none is supplied do we fall back to
|
|
232
317
|
// extracting a closed-vocabulary verdict from the live RPC owner's final assistant text.
|
|
@@ -259,13 +344,16 @@ async function runReviewFinalize(opts: FinalizeOptions): Promise<FinalizeResult>
|
|
|
259
344
|
createdAt: now(),
|
|
260
345
|
});
|
|
261
346
|
const outcome = validateReceipt(receipt);
|
|
262
|
-
const entry = await
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
347
|
+
const entry = await persistFinalizeReceipt(opts, "review-failure", receipt);
|
|
348
|
+
if (!entry) {
|
|
349
|
+
return {
|
|
350
|
+
...baseResult,
|
|
351
|
+
completed: false,
|
|
352
|
+
receiptPath: null,
|
|
353
|
+
verdict: null,
|
|
354
|
+
blockers: ["validation-unknown:cancelled"],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
269
357
|
const blockers = outcome.valid ? [reason] : [reason, ...outcome.reasons];
|
|
270
358
|
return { ...baseResult, completed: false, receiptPath: entry.path, verdict: null, blockers };
|
|
271
359
|
}
|
|
@@ -289,7 +377,16 @@ async function runReviewFinalize(opts: FinalizeOptions): Promise<FinalizeResult>
|
|
|
289
377
|
createdAt: now(),
|
|
290
378
|
});
|
|
291
379
|
const outcome = validateReceipt(receipt);
|
|
292
|
-
const entry = await
|
|
380
|
+
const entry = await persistFinalizeReceipt(opts, "review-verdict", receipt);
|
|
381
|
+
if (!entry) {
|
|
382
|
+
return {
|
|
383
|
+
...baseResult,
|
|
384
|
+
completed: false,
|
|
385
|
+
receiptPath: null,
|
|
386
|
+
verdict: null,
|
|
387
|
+
blockers: ["validation-unknown:cancelled"],
|
|
388
|
+
};
|
|
389
|
+
}
|
|
293
390
|
// A confirmation-required verdict is recorded but never an autonomous success.
|
|
294
391
|
const humanActionRequired = verdict === "OWNER_CONFIRMATION_REQUIRED";
|
|
295
392
|
const completed = outcome.valid && !humanActionRequired;
|
|
@@ -308,11 +405,201 @@ function git(workspace: string, args: string[]): string | null {
|
|
|
308
405
|
}
|
|
309
406
|
}
|
|
310
407
|
|
|
311
|
-
/** Real checks: git for commit/branch, gh for PR, Bun.spawn for validation commands. */
|
|
312
|
-
|
|
408
|
+
/** Real checks: git for commit/branch, gh for PR, async Bun.spawn for validation commands. */
|
|
409
|
+
async function discardSpawnStream(stream: ReadableStream<Uint8Array> | null | undefined): Promise<void> {
|
|
410
|
+
if (!stream) return;
|
|
411
|
+
const reader = stream.getReader();
|
|
412
|
+
try {
|
|
413
|
+
for (;;) {
|
|
414
|
+
const { done } = await reader.read();
|
|
415
|
+
if (done) return;
|
|
416
|
+
}
|
|
417
|
+
} finally {
|
|
418
|
+
reader.releaseLock();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
type ValidationProcessGroupIdentity = "verified" | "leader-absent" | "unverified";
|
|
423
|
+
|
|
424
|
+
function validationProcessGroupIdentity(
|
|
425
|
+
proc: Bun.Subprocess<"ignore", "pipe", "pipe">,
|
|
426
|
+
startTime: string | undefined,
|
|
427
|
+
probeProcess: (pid: number) => LinuxProcPidProbeResult,
|
|
428
|
+
): ValidationProcessGroupIdentity {
|
|
429
|
+
if (process.platform !== "linux") return proc.exitCode === null ? "verified" : "unverified";
|
|
430
|
+
const leader = probeProcess(proc.pid);
|
|
431
|
+
if (leader.kind === "absent") return "leader-absent";
|
|
432
|
+
if (leader.kind !== "live" || startTime === undefined || leader.startTime !== startTime) return "unverified";
|
|
433
|
+
return "verified";
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function validationProcessGroupHasRunningMembers(
|
|
437
|
+
proc: Bun.Subprocess<"ignore", "pipe", "pipe">,
|
|
438
|
+
startTime: string | undefined,
|
|
439
|
+
probeProcess: (pid: number) => LinuxProcPidProbeResult,
|
|
440
|
+
): boolean {
|
|
441
|
+
const identity = validationProcessGroupIdentity(proc, startTime, probeProcess);
|
|
442
|
+
if (identity === "unverified") {
|
|
443
|
+
// We may prove that the group has disappeared, but a failed /proc identity
|
|
444
|
+
// probe cannot authorize either signaling the group or declaring it clean.
|
|
445
|
+
try {
|
|
446
|
+
process.kill(-proc.pid, 0);
|
|
447
|
+
return true;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
450
|
+
if (code === "ESRCH") return false;
|
|
451
|
+
if (code === "EPERM") return true;
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
process.kill(-proc.pid, 0);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
459
|
+
if (code === "ESRCH") return false;
|
|
460
|
+
if (code !== "EPERM") throw error;
|
|
461
|
+
}
|
|
462
|
+
if (process.platform !== "linux") return true;
|
|
463
|
+
try {
|
|
464
|
+
for (const entry of readdirSync("/proc")) {
|
|
465
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
466
|
+
let stat: string;
|
|
467
|
+
try {
|
|
468
|
+
stat = readFileSync(`/proc/${entry}/stat`, "utf8");
|
|
469
|
+
} catch (error) {
|
|
470
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
471
|
+
if (code === "ENOENT" || code === "ESRCH") continue;
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
475
|
+
const state = fields[0] ?? "";
|
|
476
|
+
if (Number(fields[2]) === proc.pid && state !== "Z" && state !== "X") return true;
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
} catch {
|
|
480
|
+
// Procfs is not available/readable; conservatively keep waiting for group exit.
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function waitForValidationProcessGroupExit(
|
|
486
|
+
proc: Bun.Subprocess<"ignore", "pipe", "pipe">,
|
|
487
|
+
startTime: string | undefined,
|
|
488
|
+
probeProcess: (pid: number) => LinuxProcPidProbeResult,
|
|
489
|
+
): Promise<void> {
|
|
490
|
+
while (validationProcessGroupHasRunningMembers(proc, startTime, probeProcess)) await Bun.sleep(20);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function signalValidationProcess(
|
|
494
|
+
proc: Bun.Subprocess<"ignore", "pipe", "pipe">,
|
|
495
|
+
signal: NodeJS.Signals,
|
|
496
|
+
groupStartTime: string | undefined,
|
|
497
|
+
probeProcess: (pid: number) => LinuxProcPidProbeResult,
|
|
498
|
+
): void {
|
|
499
|
+
if (process.platform !== "win32" && Number.isSafeInteger(proc.pid) && proc.pid > 0) {
|
|
500
|
+
if (validationProcessGroupIdentity(proc, groupStartTime, probeProcess) === "unverified") {
|
|
501
|
+
// The Subprocess handle is exact authority for its direct child. Never
|
|
502
|
+
// signal a numeric process group whose leader identity could not be proven.
|
|
503
|
+
if (proc.exitCode === null) {
|
|
504
|
+
try {
|
|
505
|
+
proc.kill(signal);
|
|
506
|
+
} catch {}
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
// `detached: true` makes the validator the leader of its own POSIX session
|
|
512
|
+
// and process group, so this also reaches shells and background children.
|
|
513
|
+
process.kill(-proc.pid, signal);
|
|
514
|
+
return;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") {
|
|
517
|
+
// Still signal the exact child as a best effort; group liveness below
|
|
518
|
+
// remains authoritative and shutdown will not proceed while it exists.
|
|
519
|
+
try {
|
|
520
|
+
proc.kill(signal);
|
|
521
|
+
} catch {}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
proc.kill(signal);
|
|
528
|
+
} catch {
|
|
529
|
+
// The exact child may already have exited; the exit promise is authoritative.
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function terminateValidationProcess(
|
|
534
|
+
proc: Bun.Subprocess<"ignore", "pipe", "pipe">,
|
|
535
|
+
groupStartTime: string | undefined,
|
|
536
|
+
probeProcess: (pid: number) => LinuxProcPidProbeResult,
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
const hasProcessGroup = process.platform !== "win32" && Number.isSafeInteger(proc.pid) && proc.pid > 0;
|
|
539
|
+
signalValidationProcess(proc, "SIGTERM", groupStartTime, probeProcess);
|
|
540
|
+
if (hasProcessGroup) {
|
|
541
|
+
const groupExit = waitForValidationProcessGroupExit(proc, groupStartTime, probeProcess);
|
|
542
|
+
const exitedGracefully = await Promise.race([groupExit.then(() => true), Bun.sleep(250).then(() => false)]);
|
|
543
|
+
if (!exitedGracefully) {
|
|
544
|
+
signalValidationProcess(proc, "SIGKILL", groupStartTime, probeProcess);
|
|
545
|
+
await groupExit;
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
const exitedGracefully = await Promise.race([proc.exited.then(() => true), Bun.sleep(250).then(() => false)]);
|
|
549
|
+
if (!exitedGracefully) signalValidationProcess(proc, "SIGKILL", groupStartTime, probeProcess);
|
|
550
|
+
}
|
|
551
|
+
await proc.exited;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function defaultFinalizeChecks(
|
|
555
|
+
workspace: string,
|
|
556
|
+
probeProcess: (pid: number) => LinuxProcPidProbeResult = probeLinuxProcPidSync,
|
|
557
|
+
): FinalizeChecks {
|
|
313
558
|
return {
|
|
314
|
-
async runValidation(spec) {
|
|
315
|
-
|
|
559
|
+
async runValidation(spec, signal) {
|
|
560
|
+
if (signal?.aborted) throw new ValidationObservationUncertainError(spec.command, workspace, signal.reason);
|
|
561
|
+
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
562
|
+
try {
|
|
563
|
+
proc = Bun.spawn(["bash", "-lc", spec.command], {
|
|
564
|
+
cwd: workspace,
|
|
565
|
+
stdout: "pipe",
|
|
566
|
+
stderr: "pipe",
|
|
567
|
+
stdin: "ignore",
|
|
568
|
+
detached: true,
|
|
569
|
+
});
|
|
570
|
+
} catch {
|
|
571
|
+
return { exactCommand: spec.command, cwd: workspace, exitStatus: 1, pass: false };
|
|
572
|
+
}
|
|
573
|
+
const spawnLeader =
|
|
574
|
+
process.platform === "linux" && Number.isSafeInteger(proc.pid) && proc.pid > 0
|
|
575
|
+
? probeProcess(proc.pid)
|
|
576
|
+
: undefined;
|
|
577
|
+
const groupStartTime = spawnLeader?.kind === "live" ? spawnLeader.startTime : undefined;
|
|
578
|
+
const stdout = discardSpawnStream(proc.stdout);
|
|
579
|
+
const stderr = discardSpawnStream(proc.stderr);
|
|
580
|
+
const exited = proc.exited;
|
|
581
|
+
let abortListener: (() => void) | undefined;
|
|
582
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
583
|
+
if (!signal) return;
|
|
584
|
+
abortListener = () => reject(signal.reason ?? new Error("validation_aborted"));
|
|
585
|
+
if (signal.aborted) abortListener();
|
|
586
|
+
else signal.addEventListener("abort", abortListener, { once: true });
|
|
587
|
+
});
|
|
588
|
+
try {
|
|
589
|
+
await Promise.race([Promise.all([stdout, stderr, exited]), aborted]);
|
|
590
|
+
} catch (cause) {
|
|
591
|
+
try {
|
|
592
|
+
await terminateValidationProcess(proc, groupStartTime, probeProcess);
|
|
593
|
+
} catch (cleanupError) {
|
|
594
|
+
throw new Error("validation_process_cleanup_failed", {
|
|
595
|
+
cause: new AggregateError([cause, cleanupError]),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
await Promise.allSettled([stdout, stderr, exited]);
|
|
599
|
+
throw new ValidationObservationUncertainError(spec.command, workspace, cause);
|
|
600
|
+
} finally {
|
|
601
|
+
if (abortListener) signal?.removeEventListener("abort", abortListener);
|
|
602
|
+
}
|
|
316
603
|
const exitStatus = proc.exitCode ?? 1;
|
|
317
604
|
return { exactCommand: spec.command, cwd: workspace, exitStatus, pass: exitStatus === 0 };
|
|
318
605
|
},
|