@davidvornholt/standards 0.17.1 → 0.17.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/package.json +2 -1
- package/src/poller-codex-stderr.ts +68 -0
- package/src/poller-codex.ts +117 -51
- package/src/poller-fix-run.ts +1 -1
- package/src/poller-review-execution.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"src/poller-acknowledgements.ts",
|
|
82
82
|
"src/poller-claim.ts",
|
|
83
83
|
"src/poller-codex.ts",
|
|
84
|
+
"src/poller-codex-stderr.ts",
|
|
84
85
|
"src/poller-commands.ts",
|
|
85
86
|
"src/poller-comment-metadata.ts",
|
|
86
87
|
"src/poller-config.ts",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const STDERR_SNIPPET_LIMIT = 2000;
|
|
2
|
+
const STDERR_RETAINED_LIMIT = STDERR_SNIPPET_LIMIT * 2;
|
|
3
|
+
const CAPTURE_FAILURE_DETAIL_LIMIT = 800;
|
|
4
|
+
const CAPTURE_FAILURE_PREFIX = 'stderr capture failed: ';
|
|
5
|
+
const STDERR_WITH_CAPTURE_FAILURE_LIMIT =
|
|
6
|
+
STDERR_SNIPPET_LIMIT -
|
|
7
|
+
CAPTURE_FAILURE_PREFIX.length -
|
|
8
|
+
CAPTURE_FAILURE_DETAIL_LIMIT -
|
|
9
|
+
'\n'.length;
|
|
10
|
+
const LOW_SURROGATE_MIN = 0xdc_00;
|
|
11
|
+
const LOW_SURROGATE_MAX = 0xdf_ff;
|
|
12
|
+
|
|
13
|
+
const unicodeSafeTail = (value: string, limit: number): string => {
|
|
14
|
+
let start = Math.max(0, value.length - limit);
|
|
15
|
+
const first = value.charCodeAt(start);
|
|
16
|
+
if (first >= LOW_SURROGATE_MIN && first <= LOW_SURROGATE_MAX) {
|
|
17
|
+
start += 1;
|
|
18
|
+
}
|
|
19
|
+
return value.slice(start);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const createStderrCapture = () => {
|
|
23
|
+
const decoder = new TextDecoder();
|
|
24
|
+
let retained = '';
|
|
25
|
+
let pendingWhitespace = '';
|
|
26
|
+
const appendText = (text: string): void => {
|
|
27
|
+
const content = text.trimEnd();
|
|
28
|
+
if (content === '') {
|
|
29
|
+
pendingWhitespace = unicodeSafeTail(
|
|
30
|
+
pendingWhitespace + text,
|
|
31
|
+
STDERR_RETAINED_LIMIT,
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
retained = unicodeSafeTail(
|
|
36
|
+
retained + pendingWhitespace + content,
|
|
37
|
+
STDERR_RETAINED_LIMIT,
|
|
38
|
+
);
|
|
39
|
+
pendingWhitespace = unicodeSafeTail(
|
|
40
|
+
text.slice(content.length),
|
|
41
|
+
STDERR_RETAINED_LIMIT,
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
append: (chunk: Buffer): void => {
|
|
46
|
+
appendText(decoder.decode(chunk, { stream: true }));
|
|
47
|
+
},
|
|
48
|
+
finish: (): string => {
|
|
49
|
+
decoder.decode();
|
|
50
|
+
return unicodeSafeTail(retained.trim(), STDERR_SNIPPET_LIMIT);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const withCaptureFailure = (
|
|
56
|
+
stderr: string,
|
|
57
|
+
captureFailure: string | null,
|
|
58
|
+
): string => {
|
|
59
|
+
if (captureFailure === null) {
|
|
60
|
+
return stderr;
|
|
61
|
+
}
|
|
62
|
+
const retained = unicodeSafeTail(stderr, STDERR_WITH_CAPTURE_FAILURE_LIMIT);
|
|
63
|
+
const failure = `${CAPTURE_FAILURE_PREFIX}${unicodeSafeTail(
|
|
64
|
+
captureFailure,
|
|
65
|
+
CAPTURE_FAILURE_DETAIL_LIMIT,
|
|
66
|
+
)}`;
|
|
67
|
+
return retained === '' ? failure : `${retained}\n${failure}`;
|
|
68
|
+
};
|
package/src/poller-codex.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
// worktree and hands results back through the outcome file — never stdout,
|
|
3
|
-
// which is unreliable once agent tools are active. The poller then verifies
|
|
4
|
-
// effects (commits, diffs, gates) instead of trusting the narration.
|
|
5
|
-
|
|
6
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
7
2
|
import { rmSync } from 'node:fs';
|
|
8
3
|
import { join } from 'node:path';
|
|
9
4
|
import process from 'node:process';
|
|
10
|
-
import {
|
|
5
|
+
import { createStderrCapture, withCaptureFailure } from './poller-codex-stderr';
|
|
11
6
|
import type { PollerConfig } from './poller-config';
|
|
12
7
|
import { OUTCOME_DIR } from './poller-protocol';
|
|
13
8
|
|
|
14
9
|
const MS_PER_SECOND = 1000;
|
|
15
10
|
const SECONDS_PER_MINUTE = 60;
|
|
16
11
|
const MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
17
|
-
const STDERR_SNIPPET_LIMIT = 2000;
|
|
18
12
|
|
|
19
13
|
export type CodexRunResult = {
|
|
20
14
|
readonly succeeded: boolean;
|
|
@@ -25,14 +19,14 @@ type CodexExecutor = (
|
|
|
25
19
|
file: string,
|
|
26
20
|
args: ReadonlyArray<string>,
|
|
27
21
|
options: {
|
|
28
|
-
readonly
|
|
29
|
-
readonly
|
|
30
|
-
readonly stdio: readonly ['ignore', 'ignore', 'pipe'];
|
|
22
|
+
readonly detached: true;
|
|
23
|
+
readonly stdio: ['ignore', 'ignore', 'pipe'];
|
|
31
24
|
readonly env: Record<string, string | undefined>;
|
|
32
25
|
},
|
|
33
|
-
) =>
|
|
26
|
+
) => ChildProcess;
|
|
34
27
|
|
|
35
|
-
const defaultExecutor: CodexExecutor =
|
|
28
|
+
const defaultExecutor: CodexExecutor = (file, args, options) =>
|
|
29
|
+
spawn(file, [...args], options);
|
|
36
30
|
|
|
37
31
|
type CodexRunOptions = {
|
|
38
32
|
readonly workDir: string;
|
|
@@ -41,30 +35,113 @@ type CodexRunOptions = {
|
|
|
41
35
|
readonly config: PollerConfig;
|
|
42
36
|
};
|
|
43
37
|
|
|
44
|
-
const exitCause = (
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
const exitCause = (
|
|
39
|
+
status: number | null,
|
|
40
|
+
signal: NodeJS.Signals | null,
|
|
41
|
+
): string => {
|
|
42
|
+
if (status !== null) {
|
|
43
|
+
return `exit status ${status}`;
|
|
47
44
|
}
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
return signal === null ? 'unknown exit cause' : `signal ${signal}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const failureResult = (cause: string, stderr = ''): CodexRunResult => ({
|
|
49
|
+
succeeded: false,
|
|
50
|
+
failure:
|
|
51
|
+
stderr === ''
|
|
52
|
+
? `codex exec failed: ${cause}`
|
|
53
|
+
: `codex exec failed (${cause}):\n${stderr}`,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const errorMessage = (error: unknown): string =>
|
|
57
|
+
error instanceof Error ? error.message : String(error);
|
|
58
|
+
|
|
59
|
+
const timeoutCause = (timeout: number): string => {
|
|
60
|
+
if (timeout % MS_PER_MINUTE !== 0) {
|
|
61
|
+
return `timed out after ${timeout} ms`;
|
|
50
62
|
}
|
|
51
|
-
|
|
63
|
+
const minutes = timeout / MS_PER_MINUTE;
|
|
64
|
+
return `timed out after ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
|
|
52
65
|
};
|
|
53
66
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
const terminateProcessTree = (child: ChildProcess): void => {
|
|
68
|
+
try {
|
|
69
|
+
if (process.platform === 'win32' || child.pid === undefined) {
|
|
70
|
+
child.kill('SIGKILL');
|
|
71
|
+
} else {
|
|
72
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// The process may have exited between the timeout and the kill.
|
|
76
|
+
}
|
|
77
|
+
};
|
|
59
78
|
|
|
60
|
-
|
|
79
|
+
const awaitChild = (
|
|
80
|
+
child: ChildProcess,
|
|
81
|
+
capture: ReturnType<typeof createStderrCapture>,
|
|
82
|
+
timeout: number,
|
|
83
|
+
): Promise<CodexRunResult> =>
|
|
84
|
+
new Promise((resolve) => {
|
|
85
|
+
let settled = false;
|
|
86
|
+
let captureFailure: string | null = null;
|
|
87
|
+
const settle = (result: CodexRunResult): void => {
|
|
88
|
+
if (!settled) {
|
|
89
|
+
settled = true;
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
resolve(result);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const failed = (cause: string): void => {
|
|
95
|
+
let stderr = '';
|
|
96
|
+
try {
|
|
97
|
+
stderr = capture.finish();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
captureFailure = errorMessage(error);
|
|
100
|
+
}
|
|
101
|
+
settle(failureResult(cause, withCaptureFailure(stderr, captureFailure)));
|
|
102
|
+
};
|
|
103
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
104
|
+
try {
|
|
105
|
+
capture.append(chunk);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
captureFailure = errorMessage(error);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
child.stderr?.on('error', (error) => {
|
|
111
|
+
captureFailure = errorMessage(error);
|
|
112
|
+
});
|
|
113
|
+
child.once('error', (error) => {
|
|
114
|
+
failed(errorMessage(error));
|
|
115
|
+
});
|
|
116
|
+
child.once('close', (status, signal) => {
|
|
117
|
+
if (status === 0) {
|
|
118
|
+
settle({ succeeded: true, failure: null });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
failed(exitCause(status, signal));
|
|
122
|
+
});
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
terminateProcessTree(child);
|
|
125
|
+
child.stderr?.destroy();
|
|
126
|
+
failed(timeoutCause(timeout));
|
|
127
|
+
}, timeout);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const runCodex = async (
|
|
61
131
|
options: CodexRunOptions,
|
|
62
132
|
execute: CodexExecutor = defaultExecutor,
|
|
63
|
-
): CodexRunResult => {
|
|
133
|
+
): Promise<CodexRunResult> => {
|
|
64
134
|
const { workDir, gitCommonDir, prompt, config } = options;
|
|
65
|
-
rmSync(join(workDir, OUTCOME_DIR), { recursive: true, force: true });
|
|
66
135
|
try {
|
|
67
|
-
|
|
136
|
+
rmSync(join(workDir, OUTCOME_DIR), { recursive: true, force: true });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return failureResult(
|
|
139
|
+
`could not prepare the outcome directory: ${errorMessage(error)}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
let child: ChildProcess;
|
|
143
|
+
try {
|
|
144
|
+
child = execute(
|
|
68
145
|
'codex',
|
|
69
146
|
[
|
|
70
147
|
'exec',
|
|
@@ -84,32 +161,21 @@ export const runCodex = (
|
|
|
84
161
|
prompt,
|
|
85
162
|
],
|
|
86
163
|
{
|
|
87
|
-
|
|
88
|
-
timeout: config.runTimeoutMinutes * MS_PER_MINUTE,
|
|
164
|
+
detached: true,
|
|
89
165
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
90
|
-
env:
|
|
166
|
+
env: { ...process.env },
|
|
91
167
|
},
|
|
92
168
|
);
|
|
93
|
-
return { succeeded: true, failure: null };
|
|
94
169
|
} catch (error) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
-
// Node's "Command failed" message echoes the full command line, and the
|
|
102
|
-
// prompt embedded in it would push stderr past the failure-comment
|
|
103
|
-
// snippet limit — report the exit cause instead of the echoed command.
|
|
104
|
-
const cause = message.startsWith('Command failed')
|
|
105
|
-
? exitCause(details)
|
|
106
|
-
: message;
|
|
107
|
-
return {
|
|
108
|
-
succeeded: false,
|
|
109
|
-
failure:
|
|
110
|
-
stderr === ''
|
|
111
|
-
? `codex exec failed: ${cause}`
|
|
112
|
-
: `codex exec failed (${cause}):\n${stderr}`,
|
|
113
|
-
};
|
|
170
|
+
return failureResult(errorMessage(error));
|
|
171
|
+
}
|
|
172
|
+
if (child.stderr === null) {
|
|
173
|
+
child.kill();
|
|
174
|
+
return failureResult('stderr capture was not available');
|
|
114
175
|
}
|
|
176
|
+
return await awaitChild(
|
|
177
|
+
child,
|
|
178
|
+
createStderrCapture(),
|
|
179
|
+
config.runTimeoutMinutes * MS_PER_MINUTE,
|
|
180
|
+
);
|
|
115
181
|
};
|
package/src/poller-fix-run.ts
CHANGED