hereya-cli 0.103.0 → 0.105.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,7 +7,9 @@ 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';
12
+ import { KeyedMutex } from '../../../lib/executor-start/job-serialization.js';
11
13
  export { redactCredentials } from '../../../lib/executor-start/format.js';
12
14
  export default class ExecutorStart extends Command {
13
15
  static description = `Start the remote executor process (polls for jobs from hereya cloud).
@@ -70,7 +72,18 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
70
72
  process.on('SIGINT', shutdown);
71
73
  process.on('SIGTERM', shutdown);
72
74
  const activeJobs = new Set();
73
- const hooks = { log: (m) => this.log(m), warn: (m) => this.warn(m) };
75
+ // One shared mutex per executor process: jobs targeting the same
76
+ // (target, workspace) run serially, unrelated jobs stay concurrent.
77
+ const serialization = new KeyedMutex();
78
+ // Ephemeral mode = an idle-timeout is configured (the EC2/ASG executor).
79
+ // There, a job that hits the execution timeout triggers a delayed
80
+ // non-zero exit so systemd replaces the wedged instance; in non-ephemeral
81
+ // mode the hook only logs a warning. See createExecutorHooks.
82
+ const hooks = createExecutorHooks({
83
+ ephemeral: idleTimeout !== undefined,
84
+ log: (m) => this.log(m),
85
+ warn: (m) => this.warn(m),
86
+ });
74
87
  // Idle clock advances on every successful poll OR while jobs are running.
75
88
  // Generic transient errors (5xx, network) deliberately do NOT advance it
76
89
  // — a flaky cloud must not keep the EC2 alive forever. Persistent 401s
@@ -98,6 +111,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
98
111
  lastJobAt,
99
112
  lastSuccessfulPollAt,
100
113
  maxConsecutive401s: MAX_CONSECUTIVE_401S,
114
+ serialization,
101
115
  workspace: flags.workspace,
102
116
  });
103
117
  consecutive401s = step.consecutive401s;
@@ -156,7 +170,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
156
170
  return { exit: false };
157
171
  }
158
172
  async runPollIteration(args) {
159
- const { activeJobs, cloudBackend, cloudUrl, concurrency, executor, executorId, hereyaToken, hooks, idleTimeout, maxConsecutive401s, workspace, } = args;
173
+ const { activeJobs, cloudBackend, cloudUrl, concurrency, executor, executorId, hereyaToken, hooks, idleTimeout, maxConsecutive401s, serialization, workspace, } = args;
160
174
  let { consecutive401s, lastJobAt, lastSuccessfulPollAt } = args;
161
175
  if (activeJobs.size > 0) {
162
176
  lastJobAt = Date.now();
@@ -192,7 +206,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
192
206
  const { job } = pollResult;
193
207
  this.log(`Received job ${job.id} (${job.type}) [${activeJobs.size + 1}/${concurrency} slots used]`);
194
208
  lastJobAt = Date.now();
195
- const jobPromise = dispatchJob(job, executor, cloudBackend, hooks).finally(() => {
209
+ const jobPromise = dispatchJob(job, executor, cloudBackend, hooks, serialization).finally(() => {
196
210
  activeJobs.delete(jobPromise);
197
211
  });
198
212
  activeJobs.add(jobPromise);
@@ -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('{');
@@ -4,6 +4,15 @@
4
4
  * the same sanitization.
5
5
  */
6
6
  export declare function sanitizeWorkspaceForFilename(workspace: string): string;
7
+ /**
8
+ * Builds a verbose error description for server-side job logs. If the thrown
9
+ * error is a `ShellCommandError` (or anything carrying `.stderr`/`.stdout`),
10
+ * include the full buffers so operators can see the complete failure context.
11
+ */
12
+ export declare function formatJobError(error: unknown): {
13
+ fullErr: string;
14
+ reason: string;
15
+ };
7
16
  /**
8
17
  * Strip credentials from log strings before flushing them to the cloud.
9
18
  * Removes:
@@ -6,6 +6,30 @@
6
6
  export function sanitizeWorkspaceForFilename(workspace) {
7
7
  return workspace.replaceAll('/', '-');
8
8
  }
9
+ /**
10
+ * Builds a verbose error description for server-side job logs. If the thrown
11
+ * error is a `ShellCommandError` (or anything carrying `.stderr`/`.stdout`),
12
+ * include the full buffers so operators can see the complete failure context.
13
+ */
14
+ export function formatJobError(error) {
15
+ const reason = error && typeof error === 'object' && 'message' in error && typeof error.message === 'string'
16
+ ? error.message
17
+ : String(error);
18
+ const stderrBuffer = error && typeof error === 'object' && typeof error.stderr === 'string'
19
+ ? error.stderr
20
+ : '';
21
+ const stdoutBuffer = error && typeof error === 'object' && typeof error.stdout === 'string'
22
+ ? error.stdout
23
+ : '';
24
+ let extra = '';
25
+ if (stderrBuffer.trim()) {
26
+ extra = `\n--- stderr ---\n${stderrBuffer}`;
27
+ }
28
+ else if (stdoutBuffer.trim()) {
29
+ extra = `\n--- stdout ---\n${stdoutBuffer}`;
30
+ }
31
+ return { fullErr: reason + extra, reason };
32
+ }
9
33
  /**
10
34
  * Strip credentials from log strings before flushing them to the cloud.
11
35
  * Removes:
@@ -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
+ }
@@ -1,15 +1,28 @@
1
1
  import { CloudBackend } from '../../backend/cloud/cloud-backend.js';
2
2
  import { LocalExecutor } from '../../executor/local.js';
3
+ import { KeyedMutex } from './job-serialization.js';
3
4
  export interface JobDispatchHooks {
4
5
  log(msg: string): void;
6
+ /**
7
+ * Fired after a job exceeds the execution timeout (job already PATCHed as
8
+ * failed). Lets the caller decide process-level policy, e.g. exit for
9
+ * instance replacement on an ephemeral executor.
10
+ */
11
+ onJobTimeout?(jobId: string): void;
5
12
  warn(msg: string): void;
6
13
  }
7
14
  /**
8
15
  * 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.
16
+ * and heartbeating to the cloud. Errors are caught and reported as failed
17
+ * jobs. Execution is raced against a per-job timeout so a hung child can
18
+ * never hold its slot indefinitely.
19
+ *
20
+ * `serialization` must be the executor-process-wide KeyedMutex: jobs that
21
+ * mutate the same logical (target, workspace) run serially on it, everything
22
+ * else stays concurrent. See job-serialization.ts.
10
23
  */
11
24
  export declare function dispatchJob(job: {
12
25
  id: string;
13
26
  payload: any;
14
27
  type: string;
15
- }, executor: LocalExecutor, cloudBackend: CloudBackend, hooks: JobDispatchHooks): Promise<void>;
28
+ }, executor: LocalExecutor, cloudBackend: CloudBackend, hooks: JobDispatchHooks, serialization: KeyedMutex): Promise<void>;
@@ -1,35 +1,21 @@
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
- /**
5
- * Builds a verbose error description for server-side job logs. If the thrown
6
- * error is a `ShellCommandError` (or anything carrying `.stderr`/`.stdout`),
7
- * include the full buffers so operators can see the complete failure context.
8
- */
9
- function formatJobError(error) {
10
- const reason = error && typeof error === 'object' && 'message' in error && typeof error.message === 'string'
11
- ? error.message
12
- : String(error);
13
- const stderrBuffer = error && typeof error === 'object' && typeof error.stderr === 'string'
14
- ? error.stderr
15
- : '';
16
- const stdoutBuffer = error && typeof error === 'object' && typeof error.stdout === 'string'
17
- ? error.stdout
18
- : '';
19
- let extra = '';
20
- if (stderrBuffer.trim()) {
21
- extra = `\n--- stderr ---\n${stderrBuffer}`;
22
- }
23
- else if (stdoutBuffer.trim()) {
24
- extra = `\n--- stdout ---\n${stdoutBuffer}`;
25
- }
26
- return { fullErr: reason + extra, reason };
27
- }
4
+ import { formatJobError } from './format.js';
5
+ import { deriveJobSerializationKey } from './job-serialization.js';
6
+ import { executorJobTimeoutMin } from './job-timeout.js';
28
7
  /**
29
8
  * 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.
9
+ * and heartbeating to the cloud. Errors are caught and reported as failed
10
+ * jobs. Execution is raced against a per-job timeout so a hung child can
11
+ * never hold its slot indefinitely.
12
+ *
13
+ * `serialization` must be the executor-process-wide KeyedMutex: jobs that
14
+ * mutate the same logical (target, workspace) run serially on it, everything
15
+ * else stays concurrent. See job-serialization.ts.
31
16
  */
32
- export async function dispatchJob(job, executor, cloudBackend, hooks) {
17
+ // eslint-disable-next-line max-params -- explicit DI of the process-wide mutex beats hidden module state
18
+ export async function dispatchJob(job, executor, cloudBackend, hooks, serialization) {
33
19
  let logBuffer = '';
34
20
  const logInterval = setInterval(async () => {
35
21
  if (logBuffer) {
@@ -62,7 +48,14 @@ export async function dispatchJob(job, executor, cloudBackend, hooks) {
62
48
  logBuffer += msg + '\n';
63
49
  },
64
50
  };
51
+ const timeoutMin = executorJobTimeoutMin();
52
+ let timedOut = false;
53
+ let timeoutTimer;
65
54
  const finalize = async (result, label) => {
55
+ // The timeout path already reported this job as failed; a late completion
56
+ // from the still-running handler must not overwrite that terminal status.
57
+ if (timedOut)
58
+ return;
66
59
  clearInterval(logInterval);
67
60
  clearInterval(heartbeatInterval);
68
61
  await cloudBackend.updateExecutorJob({
@@ -73,7 +66,7 @@ export async function dispatchJob(job, executor, cloudBackend, hooks) {
73
66
  });
74
67
  hooks.log(`Job ${job.id} ${result.success ? 'completed' : 'failed'}${label ? ` (${label})` : ''}`);
75
68
  };
76
- try {
69
+ const execute = async () => {
77
70
  if (job.type === 'resolve-env') {
78
71
  const resolved = await executor.resolveEnvValues(job.payload);
79
72
  await finalize({ env: resolved, success: true }, 'resolve-env');
@@ -98,6 +91,63 @@ export async function dispatchJob(job, executor, cloudBackend, hooks) {
98
91
  ? await executor.provision({ ...job.payload, logger })
99
92
  : await executor.destroy({ ...job.payload, logger });
100
93
  await finalize(result, '');
94
+ };
95
+ // Jobs mutating the same logical (target, workspace) — e.g. a resubmitted
96
+ // duplicate of a deploy that looked stuck — must run serially: concurrent
97
+ // runs race over the same cloned repo dirs, local hereya state and tofu/CDK
98
+ // state. The wait happens INSIDE the job lifecycle, AFTER the log/heartbeat
99
+ // intervals started above, so a queued job keeps heartbeating — otherwise
100
+ // the server-side stale reaper would reset it and this same executor would
101
+ // claim it again. The per-job execution timeout below INTENTIONALLY covers
102
+ // queue-wait + execution: a job stuck behind a wedged predecessor is failed
103
+ // and retried rather than waiting forever.
104
+ const serializationKey = deriveJobSerializationKey(job);
105
+ const executeSerialized = async () => {
106
+ if (serialization.isBusy(serializationKey)) {
107
+ logger.info(`Waiting for an in-flight job on the same target (${serializationKey}) to finish before starting...`);
108
+ }
109
+ await serialization.run(serializationKey, async () => {
110
+ // A job that timed out while queued must not ghost-execute after its
111
+ // predecessor finally drains — its row is already failed.
112
+ if (timedOut)
113
+ return;
114
+ await execute();
115
+ });
116
+ };
117
+ try {
118
+ const timeoutPromise = new Promise((resolve) => {
119
+ timeoutTimer = setTimeout(() => resolve('timeout'), timeoutMin * 60 * 1000);
120
+ // Belt-and-braces: a pending job timer must never keep the process alive.
121
+ timeoutTimer.unref?.();
122
+ });
123
+ const executePromise = executeSerialized().then(() => 'done');
124
+ // Keep the chain explicitly handled even after the timeout wins the race:
125
+ // a late rejection from the wedged handler (hung tool killed by OOM,
126
+ // network drop, ...) must never surface as an unhandledRejection and
127
+ // crash a non-ephemeral executor. The job row is already failed at that
128
+ // point, so just log it. When execute() rejects BEFORE the timeout, the
129
+ // race still rejects into the catch block below and the timedOut guard
130
+ // keeps this extra warn silent.
131
+ executePromise.catch((error) => {
132
+ if (timedOut) {
133
+ hooks.warn(`Job ${job.id} late failure after timeout: ${error instanceof Error ? error.message : String(error)}`);
134
+ }
135
+ });
136
+ const outcome = await Promise.race([executePromise, timeoutPromise]);
137
+ if (outcome === 'timeout') {
138
+ timedOut = true;
139
+ clearInterval(logInterval);
140
+ clearInterval(heartbeatInterval);
141
+ const reason = `Job execution timed out on the executor after ${timeoutMin} minutes (HEREYA_EXECUTOR_JOB_TIMEOUT_MIN to adjust)`;
142
+ await cloudBackend.updateExecutorJob({
143
+ jobId: job.id,
144
+ logs: logBuffer + `\nError: ${reason}\n`,
145
+ result: { reason, success: false },
146
+ status: 'failed',
147
+ });
148
+ hooks.warn(`Job ${job.id} failed: ${reason}`);
149
+ hooks.onJobTimeout?.(job.id);
150
+ }
101
151
  }
102
152
  catch (error) {
103
153
  clearInterval(logInterval);
@@ -111,4 +161,8 @@ export async function dispatchJob(job, executor, cloudBackend, hooks) {
111
161
  });
112
162
  hooks.warn(`Job ${job.id} failed: ${reason}`);
113
163
  }
164
+ finally {
165
+ if (timeoutTimer)
166
+ clearTimeout(timeoutTimer);
167
+ }
114
168
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Per-(target, workspace) job serialization for the remote executor.
3
+ *
4
+ * Production incident: a user resubmitted a deploy that looked stuck, and the
5
+ * executor ran BOTH identical deploys of the same project+workspace
6
+ * concurrently. They raced each other over the same cloned repo dirs, local
7
+ * hereya state and tofu/CDK state, and both wedged. Jobs that mutate the same
8
+ * logical target in the same workspace must run serially on one executor;
9
+ * unrelated jobs (e.g. the normal boot burst of provisions for different
10
+ * packages) must keep running concurrently.
11
+ */
12
+ /**
13
+ * Derives a stable key identifying "same logical target in the same
14
+ * workspace", or null when the job must NOT be serialized (read-only jobs,
15
+ * unknown job types, malformed payloads — those run immediately, no lock).
16
+ * Never throws.
17
+ *
18
+ * Key derivation per job type (the kind prefix prevents cross-kind
19
+ * collisions, e.g. a project named like an app):
20
+ * - deploy / undeploy: `project:<payload.project>:<payload.workspace>`
21
+ * - init: `project:<payload.projectName>:<payload.workspace>`
22
+ * - app-deploy / app-destroy: `app:<payload.appName>:<payload.workspace>`
23
+ * - provision / destroy: `pkg:<payload.package>:<payload.workspace>`
24
+ * - resolve-env: null (read-only, never serialized)
25
+ */
26
+ export declare function deriveJobSerializationKey(job: {
27
+ id: string;
28
+ payload: any;
29
+ type: string;
30
+ }): null | string;
31
+ /**
32
+ * Minimal keyed mutex: chains async functions per key so runs with the same
33
+ * key execute strictly serially (FIFO), while runs with different keys — or a
34
+ * null key — start immediately and proceed concurrently.
35
+ *
36
+ * - A rejecting fn never blocks its successors: each fn is chained via
37
+ * `.then(fn, fn)` onto a rejection-swallowed tail.
38
+ * - The map entry for a key is removed once its chain drains, so the map
39
+ * cannot grow unboundedly over executor uptime.
40
+ */
41
+ export declare class KeyedMutex {
42
+ private readonly chains;
43
+ /** Number of keys with in-flight or queued runs (introspection/test aid). */
44
+ get size(): number;
45
+ /**
46
+ * True when at least one run for this key is in flight or queued — i.e. a
47
+ * new run() with this key would have to wait. A null key is never busy.
48
+ */
49
+ isBusy(key: null | string): boolean;
50
+ run<T>(key: null | string, fn: () => Promise<T>): Promise<T>;
51
+ }