@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.17.1",
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
+ };
@@ -1,20 +1,14 @@
1
- // Codex invocation for poller jobs. The agent runs headless inside the job
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 { isRecord } from './github-settings-parse';
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 encoding: 'utf8';
29
- readonly timeout: number;
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
- ) => unknown;
26
+ ) => ChildProcess;
34
27
 
35
- const defaultExecutor: CodexExecutor = execFileSync;
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 = (details: Record<string, unknown>): string => {
45
- if (typeof details.status === 'number') {
46
- return `exit status ${details.status}`;
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
- if (typeof details.signal === 'string') {
49
- return `signal ${details.signal}`;
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
- return 'unknown exit cause';
63
+ const minutes = timeout / MS_PER_MINUTE;
64
+ return `timed out after ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
52
65
  };
53
66
 
54
- // Codex deliberately inherits the poller's authenticated GitHub environment.
55
- // The agent uses `gh` for review-fix ledger operations and PR metadata, while
56
- // the poller still owns verified commit publication, labels, and ready/merge
57
- // transitions.
58
- const agentEnv = (): Record<string, string | undefined> => ({ ...process.env });
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
- export const runCodex = (
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
- execute(
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
- encoding: 'utf8',
88
- timeout: config.runTimeoutMinutes * MS_PER_MINUTE,
164
+ detached: true,
89
165
  stdio: ['ignore', 'ignore', 'pipe'],
90
- env: agentEnv(),
166
+ env: { ...process.env },
91
167
  },
92
168
  );
93
- return { succeeded: true, failure: null };
94
169
  } catch (error) {
95
- const details: Record<string, unknown> = isRecord(error) ? error : {};
96
- const stderr =
97
- typeof details.stderr === 'string'
98
- ? details.stderr.trim().slice(-STDERR_SNIPPET_LIMIT)
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
  };
@@ -158,7 +158,7 @@ export const runFixJob = async (
158
158
  workspace,
159
159
  };
160
160
  try {
161
- const run = runCodex({
161
+ const run = await runCodex({
162
162
  workDir: workspace.dir,
163
163
  gitCommonDir: cacheClone,
164
164
  prompt: fixPrompt({
@@ -37,7 +37,7 @@ export const executeReviewJob = async (options: {
37
37
  ),
38
38
  );
39
39
  try {
40
- const run = runAgent({
40
+ const run = await runAgent({
41
41
  workDir: workspace.dir,
42
42
  gitCommonDir: cacheClone,
43
43
  prompt: reviewPrompt({