hereya-cli 0.103.0 → 0.104.0
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/README.md +68 -68
- package/dist/backend/cloud/cloud-backend/executor-jobs.d.ts +6 -0
- package/dist/backend/cloud/cloud-backend/executor-jobs.js +67 -13
- package/dist/commands/executor/start/index.js +10 -1
- package/dist/iac/terraform.js +11 -7
- package/dist/lib/executor-start/hooks.d.ts +27 -0
- package/dist/lib/executor-start/hooks.js +42 -0
- package/dist/lib/executor-start/job-dispatch.d.ts +9 -1
- package/dist/lib/executor-start/job-dispatch.js +67 -2
- package/dist/lib/remote-job-utils.js +54 -18
- package/dist/lib/shell.js +4 -2
- package/oclif.manifest.json +101 -101
- package/package.json +1 -1
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { safeResponseJson } from './utils.js';
|
|
2
|
+
/**
|
|
3
|
+
* Failure reason reported when an executor HTTP call is aborted by its
|
|
4
|
+
* timeout signal. Callers (e.g. remote-job-utils) match on this exact string
|
|
5
|
+
* to treat the failure as transient/retryable.
|
|
6
|
+
*/
|
|
7
|
+
export const REQUEST_TIMED_OUT = 'Request timed out';
|
|
8
|
+
/**
|
|
9
|
+
* True when the error is an AbortSignal timeout/abort (possibly wrapped by
|
|
10
|
+
* undici as `TypeError: fetch failed` with the abort error as `cause`).
|
|
11
|
+
*/
|
|
12
|
+
function isAbortLikeError(error) {
|
|
13
|
+
if (!(error instanceof Error))
|
|
14
|
+
return false;
|
|
15
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError')
|
|
16
|
+
return true;
|
|
17
|
+
const { cause } = error;
|
|
18
|
+
return cause instanceof Error && (cause.name === 'AbortError' || cause.name === 'TimeoutError');
|
|
19
|
+
}
|
|
2
20
|
export async function cancelExecutorJob(config, input) {
|
|
3
21
|
const response = await fetch(`${config.url}/api/workspaces/${encodeURIComponent(input.workspace)}/jobs/${encodeURIComponent(input.jobId)}/cancel`, {
|
|
4
22
|
body: JSON.stringify({ force: input.force === true }),
|
|
@@ -38,10 +56,23 @@ export async function getExecutorJobStatus(config, input) {
|
|
|
38
56
|
if (input.lastStatus) {
|
|
39
57
|
url.searchParams.set('lastStatus', input.lastStatus);
|
|
40
58
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
let response;
|
|
60
|
+
try {
|
|
61
|
+
// Server-side long-poll holds the request for at most ~30s; 45s means a
|
|
62
|
+
// healthy long-poll never trips this, but a dead connection cannot hang
|
|
63
|
+
// the caller's wait loop forever.
|
|
64
|
+
response = await fetch(url.toString(), {
|
|
65
|
+
headers: { 'Authorization': `Bearer ${config.accessToken}` },
|
|
66
|
+
method: 'GET',
|
|
67
|
+
signal: AbortSignal.timeout(45_000),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (isAbortLikeError(error)) {
|
|
72
|
+
return { reason: REQUEST_TIMED_OUT, success: false };
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
45
76
|
if (!response.ok) {
|
|
46
77
|
if (response.status === 401) {
|
|
47
78
|
return { reason: 'Unauthorized', success: false };
|
|
@@ -77,10 +108,23 @@ export async function pollExecutorJobs(config, input) {
|
|
|
77
108
|
if (input.executorId) {
|
|
78
109
|
url.searchParams.set('executorId', input.executorId);
|
|
79
110
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
111
|
+
let response;
|
|
112
|
+
try {
|
|
113
|
+
// Server-side long-poll holds the request for at most ~30s; 45s means a
|
|
114
|
+
// healthy long-poll never trips this, but a dead connection cannot kill
|
|
115
|
+
// the executor's poll loop silently.
|
|
116
|
+
response = await fetch(url.toString(), {
|
|
117
|
+
headers: { 'Authorization': `Bearer ${config.accessToken}` },
|
|
118
|
+
method: 'GET',
|
|
119
|
+
signal: AbortSignal.timeout(45_000),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (isAbortLikeError(error)) {
|
|
124
|
+
return { reason: REQUEST_TIMED_OUT, success: false };
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
84
128
|
if (!response.ok) {
|
|
85
129
|
if (response.status === 401) {
|
|
86
130
|
return { reason: 'Unauthorized', success: false, unauthorized: true };
|
|
@@ -166,11 +210,21 @@ export async function updateExecutorJob(config, input) {
|
|
|
166
210
|
if (input.result) {
|
|
167
211
|
formData.append('result', JSON.stringify(input.result));
|
|
168
212
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
213
|
+
let response;
|
|
214
|
+
try {
|
|
215
|
+
response = await fetch(`${config.url}/api/executor/jobs/${encodeURIComponent(input.jobId)}`, {
|
|
216
|
+
body: formData,
|
|
217
|
+
headers: { 'Authorization': `Bearer ${config.accessToken}` },
|
|
218
|
+
method: 'PATCH',
|
|
219
|
+
signal: AbortSignal.timeout(30_000),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (isAbortLikeError(error)) {
|
|
224
|
+
return { reason: REQUEST_TIMED_OUT, success: false };
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
174
228
|
if (!response.ok) {
|
|
175
229
|
if (response.status === 401) {
|
|
176
230
|
return { reason: 'Unauthorized', success: false, unauthorized: true };
|
|
@@ -7,6 +7,7 @@ import { LocalExecutor } from '../../../executor/local.js';
|
|
|
7
7
|
import { createAuthenticatedBackend } from '../../../lib/executor-start/auth.js';
|
|
8
8
|
import { executeAppJob } from '../../../lib/executor-start/execute-app-job.js';
|
|
9
9
|
import { executeInitJob } from '../../../lib/executor-start/execute-init-job.js';
|
|
10
|
+
import { createExecutorHooks } from '../../../lib/executor-start/hooks.js';
|
|
10
11
|
import { dispatchJob } from '../../../lib/executor-start/job-dispatch.js';
|
|
11
12
|
export { redactCredentials } from '../../../lib/executor-start/format.js';
|
|
12
13
|
export default class ExecutorStart extends Command {
|
|
@@ -70,7 +71,15 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
|
|
|
70
71
|
process.on('SIGINT', shutdown);
|
|
71
72
|
process.on('SIGTERM', shutdown);
|
|
72
73
|
const activeJobs = new Set();
|
|
73
|
-
|
|
74
|
+
// Ephemeral mode = an idle-timeout is configured (the EC2/ASG executor).
|
|
75
|
+
// There, a job that hits the execution timeout triggers a delayed
|
|
76
|
+
// non-zero exit so systemd replaces the wedged instance; in non-ephemeral
|
|
77
|
+
// mode the hook only logs a warning. See createExecutorHooks.
|
|
78
|
+
const hooks = createExecutorHooks({
|
|
79
|
+
ephemeral: idleTimeout !== undefined,
|
|
80
|
+
log: (m) => this.log(m),
|
|
81
|
+
warn: (m) => this.warn(m),
|
|
82
|
+
});
|
|
74
83
|
// Idle clock advances on every successful poll OR while jobs are running.
|
|
75
84
|
// Generic transient errors (5xx, network) deliberately do NOT advance it
|
|
76
85
|
// — a flaky cloud must not keep the EC2 alive forever. Persistent 401s
|
package/dist/iac/terraform.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
6
|
import { Extract as decompress } from 'unzip-stream';
|
|
7
7
|
import { mapObject, paramToEnvString } from '../lib/object-utils.js';
|
|
8
|
-
import {
|
|
8
|
+
import { shellUtils } from '../lib/shell.js';
|
|
9
9
|
export class Terraform {
|
|
10
10
|
async apply(input) {
|
|
11
11
|
if (input.infraConfig?.localStatePath) {
|
|
@@ -41,7 +41,7 @@ export class Terraform {
|
|
|
41
41
|
}
|
|
42
42
|
try {
|
|
43
43
|
const terraform = await this.getTerraformBinary(input.logger);
|
|
44
|
-
await runShell(terraform, ['init'], {
|
|
44
|
+
await shellUtils.runShell(terraform, ['init', '-input=false'], {
|
|
45
45
|
directory: input.pkgPath,
|
|
46
46
|
env: {
|
|
47
47
|
...mapObject(input.env ?? {}, (key, value) => [
|
|
@@ -56,7 +56,10 @@ export class Terraform {
|
|
|
56
56
|
},
|
|
57
57
|
logger: input.logger,
|
|
58
58
|
});
|
|
59
|
-
|
|
59
|
+
// -input=false: never prompt (a prompt would hang the executor forever).
|
|
60
|
+
// -lock-timeout=5m: wait bounded time for a concurrently-held state lock
|
|
61
|
+
// instead of failing instantly.
|
|
62
|
+
await shellUtils.runShell(terraform, ['apply', '-auto-approve', '-input=false', '-lock-timeout=5m'], {
|
|
60
63
|
directory: input.pkgPath,
|
|
61
64
|
env: {
|
|
62
65
|
...mapObject(input.env ?? {}, (key, value) => [
|
|
@@ -119,7 +122,7 @@ export class Terraform {
|
|
|
119
122
|
await fs.promises.writeFile(backendFile, backendConfig);
|
|
120
123
|
}
|
|
121
124
|
try {
|
|
122
|
-
await runShell(terraform, ['init'], {
|
|
125
|
+
await shellUtils.runShell(terraform, ['init', '-input=false'], {
|
|
123
126
|
directory: input.pkgPath,
|
|
124
127
|
env: {
|
|
125
128
|
...mapObject(input.env ?? {}, (key, value) => [
|
|
@@ -135,7 +138,9 @@ export class Terraform {
|
|
|
135
138
|
logger: input.logger,
|
|
136
139
|
});
|
|
137
140
|
const env = await this.getEnv(input.pkgPath);
|
|
138
|
-
|
|
141
|
+
// See apply(): -input=false prevents interactive prompts from hanging
|
|
142
|
+
// the executor; -lock-timeout=5m bounds waiting on a held state lock.
|
|
143
|
+
await shellUtils.runShell(terraform, ['destroy', '-auto-approve', '-input=false', '-lock-timeout=5m'], {
|
|
139
144
|
directory: input.pkgPath,
|
|
140
145
|
env: {
|
|
141
146
|
...mapObject(input.env ?? {}, (key, value) => [
|
|
@@ -244,9 +249,8 @@ export class Terraform {
|
|
|
244
249
|
}
|
|
245
250
|
async getEnv(pkgPath, logger) {
|
|
246
251
|
const terraform = await this.getTerraformBinary(logger);
|
|
247
|
-
const resourceOut = await runShell(terraform, ['output', '--json'], {
|
|
252
|
+
const resourceOut = await shellUtils.runShell(terraform, ['output', '--json'], {
|
|
248
253
|
directory: pkgPath,
|
|
249
|
-
stdio: 'pipe',
|
|
250
254
|
});
|
|
251
255
|
let outStr = resourceOut.stdout.toString().trim();
|
|
252
256
|
const start = outStr.indexOf('{');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { JobDispatchHooks } from './job-dispatch.js';
|
|
2
|
+
/**
|
|
3
|
+
* Delay before the process exits after a job timeout on an ephemeral
|
|
4
|
+
* executor — long enough for the job-failed PATCH (and final warn logs)
|
|
5
|
+
* to flush to hereya cloud.
|
|
6
|
+
*/
|
|
7
|
+
export declare const JOB_TIMEOUT_EXIT_DELAY_MS = 5000;
|
|
8
|
+
/**
|
|
9
|
+
* Builds the dispatch hooks for the executor start loop, including the
|
|
10
|
+
* per-job-timeout policy:
|
|
11
|
+
*
|
|
12
|
+
* - Non-ephemeral executors (no idle-timeout configured) just log a warning
|
|
13
|
+
* and keep running — an operator owns the process lifecycle.
|
|
14
|
+
* - Ephemeral executors (idle-timeout configured) initiate a graceful exit
|
|
15
|
+
* with a non-zero code after a short delay. On the ephemeral executor,
|
|
16
|
+
* systemd's OnFailure/ExecStopPost drain then replaces the instance, which
|
|
17
|
+
* un-wedges everything: other in-flight jobs on the box lose their
|
|
18
|
+
* heartbeat, go stale, and the server-side reaper resets them for retry —
|
|
19
|
+
* that is the intended behavior.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createExecutorHooks(opts: {
|
|
22
|
+
ephemeral: boolean;
|
|
23
|
+
exit?: (code: number) => void;
|
|
24
|
+
exitDelayMs?: number;
|
|
25
|
+
log: (msg: string) => void;
|
|
26
|
+
warn: (msg: string) => void;
|
|
27
|
+
}): JobDispatchHooks;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delay before the process exits after a job timeout on an ephemeral
|
|
3
|
+
* executor — long enough for the job-failed PATCH (and final warn logs)
|
|
4
|
+
* to flush to hereya cloud.
|
|
5
|
+
*/
|
|
6
|
+
export const JOB_TIMEOUT_EXIT_DELAY_MS = 5000;
|
|
7
|
+
/**
|
|
8
|
+
* Builds the dispatch hooks for the executor start loop, including the
|
|
9
|
+
* per-job-timeout policy:
|
|
10
|
+
*
|
|
11
|
+
* - Non-ephemeral executors (no idle-timeout configured) just log a warning
|
|
12
|
+
* and keep running — an operator owns the process lifecycle.
|
|
13
|
+
* - Ephemeral executors (idle-timeout configured) initiate a graceful exit
|
|
14
|
+
* with a non-zero code after a short delay. On the ephemeral executor,
|
|
15
|
+
* systemd's OnFailure/ExecStopPost drain then replaces the instance, which
|
|
16
|
+
* un-wedges everything: other in-flight jobs on the box lose their
|
|
17
|
+
* heartbeat, go stale, and the server-side reaper resets them for retry —
|
|
18
|
+
* that is the intended behavior.
|
|
19
|
+
*/
|
|
20
|
+
export function createExecutorHooks(opts) {
|
|
21
|
+
const { ephemeral, log, warn } = opts;
|
|
22
|
+
const exitDelayMs = opts.exitDelayMs ?? JOB_TIMEOUT_EXIT_DELAY_MS;
|
|
23
|
+
const exit = opts.exit ??
|
|
24
|
+
((code) => {
|
|
25
|
+
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
|
|
26
|
+
process.exit(code);
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
log,
|
|
30
|
+
onJobTimeout(jobId) {
|
|
31
|
+
if (!ephemeral) {
|
|
32
|
+
warn(`Job ${jobId} hit the execution timeout. Executor keeps running (non-ephemeral mode); check the host for wedged child processes.`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
warn(`Job ${jobId} hit the execution timeout. Ephemeral executor exits with a non-zero code in ${Math.round(exitDelayMs / 1000)}s so the instance gets replaced.`);
|
|
36
|
+
const timer = setTimeout(() => exit(1), exitDelayMs);
|
|
37
|
+
// Don't let the exit timer keep an otherwise-finished process alive.
|
|
38
|
+
timer.unref?.();
|
|
39
|
+
},
|
|
40
|
+
warn,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -2,11 +2,19 @@ import { CloudBackend } from '../../backend/cloud/cloud-backend.js';
|
|
|
2
2
|
import { LocalExecutor } from '../../executor/local.js';
|
|
3
3
|
export interface JobDispatchHooks {
|
|
4
4
|
log(msg: string): void;
|
|
5
|
+
/**
|
|
6
|
+
* Fired after a job exceeds the execution timeout (job already PATCHed as
|
|
7
|
+
* failed). Lets the caller decide process-level policy, e.g. exit for
|
|
8
|
+
* instance replacement on an ephemeral executor.
|
|
9
|
+
*/
|
|
10
|
+
onJobTimeout?(jobId: string): void;
|
|
5
11
|
warn(msg: string): void;
|
|
6
12
|
}
|
|
7
13
|
/**
|
|
8
14
|
* Dispatches a single job to the right handler, with periodic log flushing
|
|
9
|
-
* and heartbeating to the cloud. Errors are caught and reported as failed
|
|
15
|
+
* and heartbeating to the cloud. Errors are caught and reported as failed
|
|
16
|
+
* jobs. Execution is raced against a per-job timeout so a hung child can
|
|
17
|
+
* never hold its slot indefinitely.
|
|
10
18
|
*/
|
|
11
19
|
export declare function dispatchJob(job: {
|
|
12
20
|
id: string;
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { executeAppJob } from './execute-app-job.js';
|
|
2
2
|
import { executeDeployJob } from './execute-deploy-job.js';
|
|
3
3
|
import { executeInitJob } from './execute-init-job.js';
|
|
4
|
+
const DEFAULT_EXECUTOR_JOB_TIMEOUT_MIN = 60;
|
|
5
|
+
/**
|
|
6
|
+
* Per-job execution timeout in minutes. Overridable via
|
|
7
|
+
* HEREYA_EXECUTOR_JOB_TIMEOUT_MIN (positive number, in minutes).
|
|
8
|
+
* A wedged child process (e.g. a tool blocking on a prompt) must not hold
|
|
9
|
+
* its executor slot forever while the heartbeat keeps the job "alive".
|
|
10
|
+
*/
|
|
11
|
+
function executorJobTimeoutMin() {
|
|
12
|
+
const fromEnv = process.env.HEREYA_EXECUTOR_JOB_TIMEOUT_MIN;
|
|
13
|
+
if (fromEnv !== undefined) {
|
|
14
|
+
const parsed = Number(fromEnv);
|
|
15
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return DEFAULT_EXECUTOR_JOB_TIMEOUT_MIN;
|
|
20
|
+
}
|
|
4
21
|
/**
|
|
5
22
|
* Builds a verbose error description for server-side job logs. If the thrown
|
|
6
23
|
* error is a `ShellCommandError` (or anything carrying `.stderr`/`.stdout`),
|
|
@@ -27,7 +44,9 @@ function formatJobError(error) {
|
|
|
27
44
|
}
|
|
28
45
|
/**
|
|
29
46
|
* Dispatches a single job to the right handler, with periodic log flushing
|
|
30
|
-
* and heartbeating to the cloud. Errors are caught and reported as failed
|
|
47
|
+
* and heartbeating to the cloud. Errors are caught and reported as failed
|
|
48
|
+
* jobs. Execution is raced against a per-job timeout so a hung child can
|
|
49
|
+
* never hold its slot indefinitely.
|
|
31
50
|
*/
|
|
32
51
|
export async function dispatchJob(job, executor, cloudBackend, hooks) {
|
|
33
52
|
let logBuffer = '';
|
|
@@ -62,7 +81,14 @@ export async function dispatchJob(job, executor, cloudBackend, hooks) {
|
|
|
62
81
|
logBuffer += msg + '\n';
|
|
63
82
|
},
|
|
64
83
|
};
|
|
84
|
+
const timeoutMin = executorJobTimeoutMin();
|
|
85
|
+
let timedOut = false;
|
|
86
|
+
let timeoutTimer;
|
|
65
87
|
const finalize = async (result, label) => {
|
|
88
|
+
// The timeout path already reported this job as failed; a late completion
|
|
89
|
+
// from the still-running handler must not overwrite that terminal status.
|
|
90
|
+
if (timedOut)
|
|
91
|
+
return;
|
|
66
92
|
clearInterval(logInterval);
|
|
67
93
|
clearInterval(heartbeatInterval);
|
|
68
94
|
await cloudBackend.updateExecutorJob({
|
|
@@ -73,7 +99,7 @@ export async function dispatchJob(job, executor, cloudBackend, hooks) {
|
|
|
73
99
|
});
|
|
74
100
|
hooks.log(`Job ${job.id} ${result.success ? 'completed' : 'failed'}${label ? ` (${label})` : ''}`);
|
|
75
101
|
};
|
|
76
|
-
|
|
102
|
+
const execute = async () => {
|
|
77
103
|
if (job.type === 'resolve-env') {
|
|
78
104
|
const resolved = await executor.resolveEnvValues(job.payload);
|
|
79
105
|
await finalize({ env: resolved, success: true }, 'resolve-env');
|
|
@@ -98,6 +124,41 @@ export async function dispatchJob(job, executor, cloudBackend, hooks) {
|
|
|
98
124
|
? await executor.provision({ ...job.payload, logger })
|
|
99
125
|
: await executor.destroy({ ...job.payload, logger });
|
|
100
126
|
await finalize(result, '');
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
130
|
+
timeoutTimer = setTimeout(() => resolve('timeout'), timeoutMin * 60 * 1000);
|
|
131
|
+
// Belt-and-braces: a pending job timer must never keep the process alive.
|
|
132
|
+
timeoutTimer.unref?.();
|
|
133
|
+
});
|
|
134
|
+
const executePromise = execute().then(() => 'done');
|
|
135
|
+
// Keep the chain explicitly handled even after the timeout wins the race:
|
|
136
|
+
// a late rejection from the wedged handler (hung tool killed by OOM,
|
|
137
|
+
// network drop, ...) must never surface as an unhandledRejection and
|
|
138
|
+
// crash a non-ephemeral executor. The job row is already failed at that
|
|
139
|
+
// point, so just log it. When execute() rejects BEFORE the timeout, the
|
|
140
|
+
// race still rejects into the catch block below and the timedOut guard
|
|
141
|
+
// keeps this extra warn silent.
|
|
142
|
+
executePromise.catch((error) => {
|
|
143
|
+
if (timedOut) {
|
|
144
|
+
hooks.warn(`Job ${job.id} late failure after timeout: ${error instanceof Error ? error.message : String(error)}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
const outcome = await Promise.race([executePromise, timeoutPromise]);
|
|
148
|
+
if (outcome === 'timeout') {
|
|
149
|
+
timedOut = true;
|
|
150
|
+
clearInterval(logInterval);
|
|
151
|
+
clearInterval(heartbeatInterval);
|
|
152
|
+
const reason = `Job execution timed out on the executor after ${timeoutMin} minutes (HEREYA_EXECUTOR_JOB_TIMEOUT_MIN to adjust)`;
|
|
153
|
+
await cloudBackend.updateExecutorJob({
|
|
154
|
+
jobId: job.id,
|
|
155
|
+
logs: logBuffer + `\nError: ${reason}\n`,
|
|
156
|
+
result: { reason, success: false },
|
|
157
|
+
status: 'failed',
|
|
158
|
+
});
|
|
159
|
+
hooks.warn(`Job ${job.id} failed: ${reason}`);
|
|
160
|
+
hooks.onJobTimeout?.(job.id);
|
|
161
|
+
}
|
|
101
162
|
}
|
|
102
163
|
catch (error) {
|
|
103
164
|
clearInterval(logInterval);
|
|
@@ -111,4 +172,8 @@ export async function dispatchJob(job, executor, cloudBackend, hooks) {
|
|
|
111
172
|
});
|
|
112
173
|
hooks.warn(`Job ${job.id} failed: ${reason}`);
|
|
113
174
|
}
|
|
175
|
+
finally {
|
|
176
|
+
if (timeoutTimer)
|
|
177
|
+
clearTimeout(timeoutTimer);
|
|
178
|
+
}
|
|
114
179
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { REQUEST_TIMED_OUT } from '../backend/cloud/cloud-backend/executor-jobs.js';
|
|
1
2
|
const DEFAULT_TIMEOUT_MIN = 90;
|
|
2
3
|
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
|
|
3
4
|
/**
|
|
@@ -11,6 +12,12 @@ const REWAKE_INTERVAL_MS = 60_000;
|
|
|
11
12
|
* a chance to land before we ask for another one.
|
|
12
13
|
*/
|
|
13
14
|
const REWAKE_INITIAL_DELAY_MS = 60_000;
|
|
15
|
+
/**
|
|
16
|
+
* A status fetch aborted by its timeout (see executor-jobs.ts) is transient —
|
|
17
|
+
* retry a bounded number of consecutive times with a short backoff.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_CONSECUTIVE_POLL_TIMEOUTS = 3;
|
|
20
|
+
const POLL_TIMEOUT_RETRY_DELAY_MS = 2000;
|
|
14
21
|
/**
|
|
15
22
|
* Default poll timeout, in ms. Overridable via HEREYA_JOB_TIMEOUT_MIN env var
|
|
16
23
|
* (positive integer, in minutes). Falls back to DEFAULT_TIMEOUT_MIN.
|
|
@@ -49,6 +56,33 @@ async function maybeRewake(args) {
|
|
|
49
56
|
}
|
|
50
57
|
return now;
|
|
51
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Forwards new job logs to the logger, or emits a periodic "still waiting"
|
|
61
|
+
* heartbeat when the job has been quiet. Returns the updated cursors.
|
|
62
|
+
*/
|
|
63
|
+
function forwardJobProgress(args) {
|
|
64
|
+
const { job, jobId, logger, startTime, timeout } = args;
|
|
65
|
+
let { lastLogLength, lastUserHeartbeat } = args;
|
|
66
|
+
const sawNewLog = Boolean(job.logs && job.logs.length > lastLogLength);
|
|
67
|
+
if (sawNewLog) {
|
|
68
|
+
const newLogs = job.logs.slice(lastLogLength);
|
|
69
|
+
if (newLogs.trim()) {
|
|
70
|
+
logger?.info?.(newLogs);
|
|
71
|
+
}
|
|
72
|
+
lastLogLength = job.logs.length;
|
|
73
|
+
lastUserHeartbeat = Date.now();
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
if (now - lastUserHeartbeat >= HEARTBEAT_INTERVAL_MS) {
|
|
78
|
+
const elapsedMin = Math.round((now - startTime) / 60_000);
|
|
79
|
+
const totalMin = Math.round(timeout / 60_000);
|
|
80
|
+
logger?.info?.(`still waiting on job ${jobId} (${elapsedMin}m elapsed, ${totalMin}m timeout). Set HEREYA_JOB_TIMEOUT_MIN to extend.`);
|
|
81
|
+
lastUserHeartbeat = now;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { lastLogLength, lastUserHeartbeat };
|
|
85
|
+
}
|
|
52
86
|
export async function pollExecutorJob(input) {
|
|
53
87
|
const { cloudBackend, jobId, logger, workspace } = input;
|
|
54
88
|
const timeout = input.timeoutMs ?? defaultTimeoutMs();
|
|
@@ -58,6 +92,7 @@ export async function pollExecutorJob(input) {
|
|
|
58
92
|
let lastStatus = 'pending';
|
|
59
93
|
let lastUserHeartbeat = startTime;
|
|
60
94
|
let lastRewakeAt = startTime;
|
|
95
|
+
let consecutiveTimeouts = 0;
|
|
61
96
|
while (Date.now() - startTime < timeout) {
|
|
62
97
|
// eslint-disable-next-line no-await-in-loop
|
|
63
98
|
const statusResult = await cloudBackend.getExecutorJobStatus({
|
|
@@ -67,28 +102,29 @@ export async function pollExecutorJob(input) {
|
|
|
67
102
|
workspace,
|
|
68
103
|
});
|
|
69
104
|
if (!statusResult.success) {
|
|
105
|
+
if (statusResult.reason === REQUEST_TIMED_OUT && consecutiveTimeouts < MAX_CONSECUTIVE_POLL_TIMEOUTS) {
|
|
106
|
+
consecutiveTimeouts += 1;
|
|
107
|
+
logger?.debug?.(`Job status poll timed out (attempt ${consecutiveTimeouts}/${MAX_CONSECUTIVE_POLL_TIMEOUTS}); retrying...`);
|
|
108
|
+
// eslint-disable-next-line no-await-in-loop
|
|
109
|
+
await new Promise((resolve) => {
|
|
110
|
+
setTimeout(resolve, POLL_TIMEOUT_RETRY_DELAY_MS);
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
70
114
|
return { reason: `Failed to get job status: ${statusResult.reason}`, success: false };
|
|
71
115
|
}
|
|
116
|
+
consecutiveTimeouts = 0;
|
|
72
117
|
const { job } = statusResult;
|
|
73
118
|
lastStatus = job.status;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
84
|
-
const now = Date.now();
|
|
85
|
-
if (now - lastUserHeartbeat >= HEARTBEAT_INTERVAL_MS) {
|
|
86
|
-
const elapsedMin = Math.round((now - startTime) / 60_000);
|
|
87
|
-
const totalMin = Math.round(timeout / 60_000);
|
|
88
|
-
logger?.info?.(`still waiting on job ${jobId} (${elapsedMin}m elapsed, ${totalMin}m timeout). Set HEREYA_JOB_TIMEOUT_MIN to extend.`);
|
|
89
|
-
lastUserHeartbeat = now;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
119
|
+
({ lastLogLength, lastUserHeartbeat } = forwardJobProgress({
|
|
120
|
+
job,
|
|
121
|
+
jobId,
|
|
122
|
+
lastLogLength,
|
|
123
|
+
lastUserHeartbeat,
|
|
124
|
+
logger,
|
|
125
|
+
startTime,
|
|
126
|
+
timeout,
|
|
127
|
+
}));
|
|
92
128
|
// eslint-disable-next-line no-await-in-loop
|
|
93
129
|
lastRewakeAt = await maybeRewake({
|
|
94
130
|
cloudBackend,
|
package/dist/lib/shell.js
CHANGED
|
@@ -34,8 +34,10 @@ export async function runShell(cmd, args, options = {}) {
|
|
|
34
34
|
cwd: options.directory ?? process.cwd(),
|
|
35
35
|
env: { ...process.env, ...options.env },
|
|
36
36
|
shell: true,
|
|
37
|
-
//
|
|
38
|
-
|
|
37
|
+
// Default: stdin ignored (closed), stdout/stderr piped. An open stdin
|
|
38
|
+
// pipe nothing writes to lets prompting tools (tofu, git, npm) block
|
|
39
|
+
// forever; with 'ignore' they read EOF and fail fast instead.
|
|
40
|
+
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
|
|
39
41
|
};
|
|
40
42
|
logger.debug(`Executing: ${cmd} ${args.join(' ')}\n`);
|
|
41
43
|
const child = spawn(cmd, args, spawnOptions);
|