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.
@@ -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
- const response = await fetch(url.toString(), {
42
- headers: { 'Authorization': `Bearer ${config.accessToken}` },
43
- method: 'GET',
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
- const response = await fetch(url.toString(), {
81
- headers: { 'Authorization': `Bearer ${config.accessToken}` },
82
- method: 'GET',
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
- const response = await fetch(`${config.url}/api/executor/jobs/${encodeURIComponent(input.jobId)}`, {
170
- body: formData,
171
- headers: { 'Authorization': `Bearer ${config.accessToken}` },
172
- method: 'PATCH',
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
- const hooks = { log: (m) => this.log(m), warn: (m) => this.warn(m) };
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
@@ -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 { runShell } from '../lib/shell.js';
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
- await runShell(terraform, ['apply', '-auto-approve'], {
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
- await runShell(terraform, ['destroy', '-auto-approve'], {
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 jobs.
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 jobs.
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
- try {
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
- const sawNewLog = Boolean(job.logs && job.logs.length > lastLogLength);
75
- if (sawNewLog) {
76
- const newLogs = job.logs.slice(lastLogLength);
77
- if (newLogs.trim()) {
78
- logger?.info?.(newLogs);
79
- }
80
- lastLogLength = job.logs.length;
81
- lastUserHeartbeat = Date.now();
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
- // If not explicitly set, use 'inherit' when debugging, or 'pipe' otherwise
38
- stdio: options.stdio ?? 'pipe',
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);