@delegoapp/runner 0.1.0 → 0.5.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 CHANGED
@@ -23,7 +23,7 @@ All flags also accept the matching `DELEGO_*` environment variable.
23
23
  | `--relay-url` | `DELEGO_RELAY_URL` | (required) | Relay base URL |
24
24
  | `--pairing-token` | `DELEGO_PAIRING_TOKEN` | — | Single-use bearer, first run only (`drs_pair_*`) |
25
25
  | `--profile` | `DELEGO_PROFILE` | `default` | Credentials profile name |
26
- | `--workspace-root` | `DELEGO_WORKSPACE_ROOT` | `process.cwd()` | Where to clone/worktree repos |
26
+ | `--workspace-root` | `DELEGO_WORKSPACE_ROOT` | `~/delego-runner/workspace` | Where to clone/worktree repos |
27
27
  | `--git-clone-base-url` | `DELEGO_GIT_CLONE_BASE_URL` | `git@github.com:` | Base for `git clone` |
28
28
  | `--codex-command` | `DELEGO_CODEX_COMMAND` | `codex` | Codex CLI executable |
29
29
  | `--codex-args` | `DELEGO_CODEX_ARGS` | `exec --sandbox workspace-write` | Codex args before prompt |
package/dist/config.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { ADAPTERS, ADAPTERS_BY_ID, SUPPORTED_EXECUTOR_IDS, } from './executor/adapters/index.js';
2
2
  import { createRequire } from 'node:module';
3
- import { resolve } from 'node:path';
4
- const defaultHeartbeatIntervalMs = 10_000;
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ const defaultHeartbeatIntervalMs = 300_000;
5
6
  const defaultPollIntervalMs = 10_000;
6
7
  const defaultCancellationPollIntervalMs = 5_000;
8
+ const defaultSafetySyncIntervalMs = 300_000;
9
+ const defaultFallbackPollIntervalMs = 30_000;
7
10
  const defaultExecutorTimeoutMs = 30 * 60_000;
8
11
  const PROFILE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
9
12
  const DEFAULT_PROFILE = 'default';
@@ -114,10 +117,12 @@ export function readConfig(args, env = process.env) {
114
117
  const heartbeatIntervalMs = readNumberOption(args, env, '--heartbeat-interval-ms', 'DELEGO_HEARTBEAT_INTERVAL_MS', defaultHeartbeatIntervalMs);
115
118
  const pollIntervalMs = readNumberOption(args, env, '--poll-interval-ms', 'DELEGO_POLL_INTERVAL_MS', defaultPollIntervalMs);
116
119
  const cancellationPollIntervalMs = readNumberOption(args, env, '--cancellation-poll-interval-ms', 'DELEGO_CANCELLATION_POLL_INTERVAL_MS', defaultCancellationPollIntervalMs);
120
+ const safetySyncIntervalMs = readNumberOption(args, env, '--safety-sync-interval-ms', 'DELEGO_SAFETY_SYNC_INTERVAL_MS', defaultSafetySyncIntervalMs);
121
+ const fallbackPollIntervalMs = readNumberOption(args, env, '--fallback-poll-interval-ms', 'DELEGO_FALLBACK_POLL_INTERVAL_MS', defaultFallbackPollIntervalMs);
117
122
  const executorTimeoutMs = readNumberOption(args, env, '--executor-timeout-ms', 'DELEGO_EXECUTOR_TIMEOUT_MS', defaultExecutorTimeoutMs);
118
123
  const workspaceRoot = readArg(args, '--workspace-root') ??
119
124
  env.DELEGO_WORKSPACE_ROOT ??
120
- process.cwd();
125
+ join(homedir(), 'delego-runner', 'workspace');
121
126
  const gitCloneBaseUrl = (readArg(args, '--git-clone-base-url') ??
122
127
  env.DELEGO_GIT_CLONE_BASE_URL ??
123
128
  'git@github.com:').replace(/\/+$/, '');
@@ -129,6 +134,8 @@ export function readConfig(args, env = process.env) {
129
134
  ['--heartbeat-interval-ms', heartbeatIntervalMs, 1_000],
130
135
  ['--poll-interval-ms', pollIntervalMs, 1_000],
131
136
  ['--cancellation-poll-interval-ms', cancellationPollIntervalMs, 1_000],
137
+ ['--safety-sync-interval-ms', safetySyncIntervalMs, 30_000],
138
+ ['--fallback-poll-interval-ms', fallbackPollIntervalMs, 5_000],
132
139
  ['--executor-timeout-ms', executorTimeoutMs, 1_000],
133
140
  ]) {
134
141
  if (!Number.isFinite(value) || value < minimum) {
@@ -145,6 +152,8 @@ export function readConfig(args, env = process.env) {
145
152
  heartbeatIntervalMs,
146
153
  pollIntervalMs,
147
154
  cancellationPollIntervalMs,
155
+ safetySyncIntervalMs,
156
+ fallbackPollIntervalMs,
148
157
  workspaceRoot: resolve(workspaceRoot),
149
158
  gitCloneBaseUrl,
150
159
  executors,
@@ -6,6 +6,13 @@ const defaultArgs = [
6
6
  'stream-json',
7
7
  '--dangerously-skip-permissions',
8
8
  ];
9
+ // Claude Code surfaces fatal startup conditions (invalid/inaccessible model,
10
+ // etc.) either as a bare stdout line or as the text of an assistant event,
11
+ // then exits with a non-zero code while the JSON stream's final `result`
12
+ // event still reads `subtype: success`. Both code paths must mark the run
13
+ // as failed so the pipeline can surface the real reason instead of "exit
14
+ // code 1".
15
+ const FATAL_ERROR_PATTERN = /There's an issue with the selected model[^\n]*/;
9
16
  function summarizeToolInput(name, input) {
10
17
  if (!input || typeof input !== 'object')
11
18
  return '';
@@ -30,10 +37,48 @@ function summarizeToolInput(name, input) {
30
37
  }
31
38
  return '';
32
39
  }
40
+ function parseAskUserQuestion(input) {
41
+ if (!input || typeof input !== 'object')
42
+ return null;
43
+ const record = input;
44
+ const questions = record.questions;
45
+ if (!Array.isArray(questions) || questions.length === 0)
46
+ return null;
47
+ const first = questions[0];
48
+ if (!first || typeof first !== 'object')
49
+ return null;
50
+ const q = first;
51
+ if (typeof q.question !== 'string' || !q.question.trim())
52
+ return null;
53
+ const rawOptions = Array.isArray(q.options) ? q.options : [];
54
+ const options = rawOptions
55
+ .map((opt) => {
56
+ if (!opt || typeof opt !== 'object')
57
+ return null;
58
+ const o = opt;
59
+ if (typeof o.label !== 'string' || !o.label.trim())
60
+ return null;
61
+ const description = typeof o.description === 'string' ? o.description : undefined;
62
+ return description ? { label: o.label, description } : { label: o.label };
63
+ })
64
+ .filter((o) => o !== null);
65
+ const pending = {
66
+ question: q.question,
67
+ options,
68
+ };
69
+ if (typeof q.header === 'string')
70
+ pending.header = q.header;
71
+ if (typeof q.multiSelect === 'boolean')
72
+ pending.multiSelect = q.multiSelect;
73
+ return pending;
74
+ }
33
75
  function formatAssistantContent(content) {
34
76
  const lines = [];
35
77
  const replyParts = [];
36
78
  let progress = null;
79
+ let askUserQuestion = null;
80
+ let toolUseId = null;
81
+ let fatalError = null;
37
82
  for (const block of content) {
38
83
  if (!block || typeof block !== 'object')
39
84
  continue;
@@ -45,6 +90,11 @@ function formatAssistantContent(content) {
45
90
  replyParts.push(entry.text);
46
91
  if (!progress)
47
92
  progress = entry.text.split(/\r?\n/)[0]?.trim() ?? null;
93
+ if (!fatalError) {
94
+ const match = entry.text.match(FATAL_ERROR_PATTERN);
95
+ if (match)
96
+ fatalError = match[0];
97
+ }
48
98
  }
49
99
  else if (entry.type === 'tool_use' && typeof entry.name === 'string') {
50
100
  const detail = summarizeToolInput(entry.name, entry.input);
@@ -52,33 +102,77 @@ function formatAssistantContent(content) {
52
102
  lines.push(formatted);
53
103
  if (!progress)
54
104
  progress = `${entry.name}${detail}`;
105
+ if (entry.name === 'AskUserQuestion') {
106
+ const parsed = parseAskUserQuestion(entry.input);
107
+ if (parsed) {
108
+ askUserQuestion = parsed;
109
+ toolUseId = typeof entry.id === 'string' ? entry.id : null;
110
+ }
111
+ }
55
112
  }
56
113
  }
57
114
  return {
58
115
  display: lines.join('\n'),
59
116
  progress,
60
117
  replyText: replyParts.length ? replyParts.join('\n\n') : null,
118
+ askUserQuestion,
119
+ toolUseId,
120
+ fatalError,
61
121
  };
62
122
  }
123
+ function extractToolResult(content) {
124
+ for (const block of content) {
125
+ if (!block || typeof block !== 'object')
126
+ continue;
127
+ const entry = block;
128
+ if (entry.type === 'tool_result' &&
129
+ typeof entry.tool_use_id === 'string' &&
130
+ entry.tool_use_id.length > 0) {
131
+ return {
132
+ id: entry.tool_use_id,
133
+ isError: entry.is_error === true,
134
+ };
135
+ }
136
+ }
137
+ return null;
138
+ }
63
139
  function formatStreamEvent(event) {
64
140
  const type = typeof event.type === 'string' ? event.type : '';
65
141
  if (type === 'system' && event.subtype === 'init') {
66
- return { display: null, progress: null };
142
+ const sessionId = typeof event.session_id === 'string' ? event.session_id : null;
143
+ return { display: null, progress: null, sessionId };
67
144
  }
68
145
  if (type === 'assistant' &&
69
146
  event.message &&
70
147
  typeof event.message === 'object') {
71
148
  const content = event.message.content;
72
149
  if (Array.isArray(content)) {
73
- const { display, progress, replyText } = formatAssistantContent(content);
150
+ const { display, progress, replyText, askUserQuestion, toolUseId, fatalError, } = formatAssistantContent(content);
74
151
  return {
75
152
  display: display.length ? display : null,
76
153
  progress,
77
154
  finalReply: replyText,
155
+ askUserQuestion,
156
+ toolUseId,
157
+ fatalError,
78
158
  };
79
159
  }
80
160
  }
81
161
  if (type === 'user') {
162
+ if (event.message && typeof event.message === 'object') {
163
+ const content = event.message.content;
164
+ if (Array.isArray(content)) {
165
+ const toolResult = extractToolResult(content);
166
+ if (toolResult) {
167
+ return {
168
+ display: null,
169
+ progress: null,
170
+ toolResultId: toolResult.id,
171
+ toolResultIsError: toolResult.isError,
172
+ };
173
+ }
174
+ }
175
+ }
82
176
  return { display: null, progress: null };
83
177
  }
84
178
  if (type === 'result') {
@@ -90,7 +184,37 @@ function formatStreamEvent(event) {
90
184
  parts.push(`${(ms / 1000).toFixed(1)}s`);
91
185
  if (cost !== null)
92
186
  parts.push(`$${cost.toFixed(4)}`);
93
- return { display: parts.join(' · '), progress: null };
187
+ // Claude Code emits a `permission_denials` array on the final result
188
+ // event listing every tool call that was denied. In non-interactive
189
+ // mode (--print --dangerously-skip-permissions), AskUserQuestion is
190
+ // always denied because there's no human at the terminal — and that
191
+ // denial is exactly the signal we want: the agent needs the user to
192
+ // answer. This is the authoritative end-of-run source for pending
193
+ // questions; tracking tool_use/tool_result pairs in the stream is
194
+ // best-effort and can miss the signal when Claude pivots to a text
195
+ // response after the denial.
196
+ let askUserQuestion = null;
197
+ let toolUseId = null;
198
+ if (Array.isArray(event.permission_denials)) {
199
+ for (const denial of event.permission_denials) {
200
+ if (!denial || typeof denial !== 'object')
201
+ continue;
202
+ const d = denial;
203
+ if (d.tool_name !== 'AskUserQuestion')
204
+ continue;
205
+ const parsed = parseAskUserQuestion(d.tool_input);
206
+ if (parsed) {
207
+ askUserQuestion = parsed;
208
+ toolUseId = typeof d.tool_use_id === 'string' ? d.tool_use_id : null;
209
+ }
210
+ }
211
+ }
212
+ return {
213
+ display: parts.join(' · '),
214
+ progress: null,
215
+ askUserQuestion,
216
+ toolUseId,
217
+ };
94
218
  }
95
219
  return { display: null, progress: null };
96
220
  }
@@ -106,7 +230,13 @@ export const claudeAdapter = {
106
230
  if (job.executionPreferences.model) {
107
231
  out.push('--model', job.executionPreferences.model);
108
232
  }
109
- out.push(promptForJob(job));
233
+ if (job.resumeContext?.claudeSessionId) {
234
+ out.push('--resume', job.resumeContext.claudeSessionId);
235
+ out.push(job.resumeContext.userReply);
236
+ }
237
+ else {
238
+ out.push(promptForJob(job));
239
+ }
110
240
  return { command, args: out, summaryLabel: 'Claude' };
111
241
  },
112
242
  formatStdoutLine(line) {
@@ -118,6 +248,9 @@ export const claudeAdapter = {
118
248
  return formatStreamEvent(event);
119
249
  }
120
250
  catch {
251
+ if (FATAL_ERROR_PATTERN.test(trimmed)) {
252
+ return { display: line, progress: null, fatalError: trimmed };
253
+ }
121
254
  return { display: line, progress: null };
122
255
  }
123
256
  },
@@ -24,7 +24,7 @@ function forceKillProcessGroup(child) {
24
24
  child.kill('SIGKILL');
25
25
  }
26
26
  }
27
- export async function runExecutor(config, job, repositoryPath) {
27
+ export async function runExecutor(config, job, repositoryPath, isCancelled) {
28
28
  const executorId = job.executionPreferences.executor;
29
29
  const adapter = ADAPTERS_BY_ID[executorId];
30
30
  const options = config.executors[executorId];
@@ -60,23 +60,43 @@ export async function runExecutor(config, job, repositoryPath) {
60
60
  terminateProcessGroup(child);
61
61
  setTimeout(() => forceKillProcessGroup(child), 5_000).unref();
62
62
  }, config.executorTimeoutMs);
63
- const cancellationPoll = setInterval(() => {
64
- pollCancellation(config, job)
65
- .then((requested) => {
66
- if (!requested || settled || cancelled) {
63
+ const cancellationPoll = isCancelled
64
+ ? setInterval(() => {
65
+ if (!isCancelled() || settled || cancelled) {
67
66
  return;
68
67
  }
69
68
  cancelled = true;
70
69
  terminateProcessGroup(child);
71
70
  setTimeout(() => forceKillProcessGroup(child), 5_000).unref();
72
- })
73
- .catch((error) => {
74
- const message = error instanceof Error ? error.message : String(error);
75
- console.error(`cancellation poll failed for ${job.id}: ${message}`);
76
- });
77
- }, config.cancellationPollIntervalMs);
71
+ }, 200)
72
+ : setInterval(() => {
73
+ pollCancellation(config, job)
74
+ .then((requested) => {
75
+ if (!requested || settled || cancelled) {
76
+ return;
77
+ }
78
+ cancelled = true;
79
+ terminateProcessGroup(child);
80
+ setTimeout(() => forceKillProcessGroup(child), 5_000).unref();
81
+ })
82
+ .catch((error) => {
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ console.error(`cancellation poll failed for ${job.id}: ${message}`);
85
+ });
86
+ }, config.cancellationPollIntervalMs);
78
87
  let stdoutLineBuffer = '';
79
88
  let finalReply = null;
89
+ let sessionId = null;
90
+ let fatalError = null;
91
+ // Track unanswered AskUserQuestion tool_use ids. Cleared by paired
92
+ // tool_result events. Whatever remains at executor exit is the live
93
+ // pending question.
94
+ const pendingQuestions = new Map();
95
+ // Once we've captured the first AskUserQuestion + sessionId we have
96
+ // everything needed to resume later; killing the executor now skips
97
+ // Claude's 4-retry "tool dismissed" dance and the subsequent text
98
+ // fallback. Tracks whether we've already issued the kill.
99
+ let killedForQuestion = false;
80
100
  const sendProgress = (summary) => {
81
101
  void reportProgress(config, job, summary.slice(0, 240), {
82
102
  stream: 'stdout',
@@ -100,6 +120,37 @@ export async function runExecutor(config, job, repositoryPath) {
100
120
  formatted.finalReply.trim()) {
101
121
  finalReply = formatted.finalReply;
102
122
  }
123
+ if (typeof formatted.sessionId === 'string' && formatted.sessionId) {
124
+ sessionId = formatted.sessionId;
125
+ }
126
+ if (typeof formatted.fatalError === 'string' && formatted.fatalError) {
127
+ fatalError = formatted.fatalError;
128
+ }
129
+ if (formatted.askUserQuestion && formatted.toolUseId) {
130
+ pendingQuestions.set(formatted.toolUseId, formatted.askUserQuestion);
131
+ // Short-circuit the run as soon as we have a usable pause point:
132
+ // a pending question + a session id we can `--resume` from later.
133
+ // Without this, Claude sees the synthetic "dismissed" tool_result
134
+ // (no human at the terminal), retries AskUserQuestion 3-4 times,
135
+ // then writes the question as text — burning ~1 minute and
136
+ // spamming the timeline. The close handler still fires and
137
+ // pipeline routes to /jobs/await as the happy path.
138
+ if (!killedForQuestion && sessionId !== null) {
139
+ killedForQuestion = true;
140
+ terminateProcessGroup(child);
141
+ setTimeout(() => forceKillProcessGroup(child), 5_000).unref();
142
+ }
143
+ }
144
+ if (typeof formatted.toolResultId === 'string' &&
145
+ formatted.toolResultId) {
146
+ // Only clear the pending question when the tool_result was a real
147
+ // answer. `is_error: true` is what Claude Code injects when
148
+ // AskUserQuestion is called in non-interactive mode (no human at
149
+ // the terminal) — the question is effectively still unanswered.
150
+ if (!formatted.toolResultIsError) {
151
+ pendingQuestions.delete(formatted.toolResultId);
152
+ }
153
+ }
103
154
  };
104
155
  child.stdout.on('data', (chunk) => {
105
156
  stdout = appendBoundedOutput(stdout, chunk);
@@ -138,6 +189,7 @@ export async function runExecutor(config, job, repositoryPath) {
138
189
  stdoutLineBuffer = '';
139
190
  }
140
191
  console.log(`executor: exited for ${job.id} with code ${exitCode ?? 'null'} signal ${signal ?? 'none'}`);
192
+ const pending = Array.from(pendingQuestions.values());
141
193
  finish({
142
194
  exitCode,
143
195
  signal,
@@ -146,10 +198,36 @@ export async function runExecutor(config, job, repositoryPath) {
146
198
  timedOut,
147
199
  cancelled,
148
200
  finalReply,
201
+ sessionId,
202
+ pendingQuestion: pending.length > 0 ? pending[pending.length - 1] : null,
203
+ fatalError,
149
204
  });
150
205
  });
151
206
  });
152
207
  }
208
+ const FAILURE_TAIL_MAX_LINES = 5;
209
+ const FAILURE_TAIL_MAX_BYTES = 1_024;
210
+ function lastMeaningfulTail(text) {
211
+ if (!text)
212
+ return null;
213
+ const lines = text
214
+ .split(/\r?\n/)
215
+ .map((line) => line.trimEnd())
216
+ .filter((line) => line.trim().length > 0);
217
+ if (lines.length === 0)
218
+ return null;
219
+ const tail = lines.slice(-FAILURE_TAIL_MAX_LINES).join('\n');
220
+ if (Buffer.byteLength(tail, 'utf8') <= FAILURE_TAIL_MAX_BYTES) {
221
+ return tail;
222
+ }
223
+ return tail.slice(-FAILURE_TAIL_MAX_BYTES);
224
+ }
225
+ export function pickFailureDetail(result) {
226
+ const finalReply = result.finalReply?.trim();
227
+ if (finalReply)
228
+ return finalReply;
229
+ return lastMeaningfulTail(result.stderr) ?? lastMeaningfulTail(result.stdout);
230
+ }
153
231
  export function summarizeExecutorFailure(result) {
154
232
  if (result.cancelled) {
155
233
  return 'Executor was cancelled by relay request.';
@@ -157,5 +235,10 @@ export function summarizeExecutorFailure(result) {
157
235
  if (result.timedOut) {
158
236
  return 'Executor timed out and was terminated.';
159
237
  }
160
- return `Executor failed with exit code ${result.exitCode ?? 'unknown'}.`;
238
+ if (result.fatalError) {
239
+ return result.fatalError;
240
+ }
241
+ const header = `Executor failed with exit code ${result.exitCode ?? 'unknown'}.`;
242
+ const detail = pickFailureDetail(result);
243
+ return detail ? `${header}\n\n${detail}` : header;
161
244
  }
@@ -2,8 +2,24 @@ import { promptThinkingInstruction } from '../thinking.js';
2
2
  export function promptForJob(job) {
3
3
  const thinkingInstruction = promptThinkingInstruction(job.executionPreferences.thinking);
4
4
  return [
5
+ // This directive must come first — before task content — so Claude enters
6
+ // execution with the correct mental model. When placed at the end, Claude
7
+ // has already oriented itself toward completing the task and ignores it,
8
+ // phrasing questions as plain text (which ends the job immediately).
9
+ [
10
+ 'OPERATING MODE — THIS OVERRIDES ALL OTHER INSTRUCTIONS INCLUDING CLAUDE.md, AGENTS.md, AND ANY SKILL INSTRUCTIONS:',
11
+ 'This task runs through the Delego runner. There IS a real user waiting in Linear, but plain text replies end the job immediately — the user sees your text but CANNOT reply to it.',
12
+ 'DO NOT invoke any skills (brainstorming, feature-dev, TDD, etc.). Skills communicate via plain text, which does not work here. Ignore all skill invocation rules from CLAUDE.md.',
13
+ 'To pause and ask the user anything, you MUST call the AskUserQuestion tool:',
14
+ ' • With options: for structured A/B/C choices.',
15
+ ' • Without options: for open-ended questions or design proposals — put your full markdown in the question field.',
16
+ 'NEVER write a question as plain text in your response. If you do, the job will be marked complete and the user will have no way to answer.',
17
+ 'The AskUserQuestion call WILL return an `is_error: true` "dismissed" tool_result — this is expected. STOP immediately when you see it. Do not retry, do not restate the question as text, do not continue with any other work. The runner delivers your question to the user and resumes your session with their answer.',
18
+ ].join('\n'),
19
+ '',
5
20
  `Linear issue: ${job.linearIssue.identifier}${job.linearIssue.title ? ` - ${job.linearIssue.title}` : ''}`,
6
21
  job.linearIssue.url ? `URL: ${job.linearIssue.url}` : null,
22
+ `If you create a pull request, start the PR title with: ${job.linearIssue.identifier}:`,
7
23
  '',
8
24
  `Requested executor: ${job.executionPreferences.executor}`,
9
25
  job.executionPreferences.model
@@ -1,5 +1,6 @@
1
1
  import { runCommand } from './command.js';
2
2
  import { changedFilePaths } from './workspace.js';
3
+ const HEAD_FILE_LIST_LIMIT = 200;
3
4
  async function readGitValue(path, args) {
4
5
  const result = await runCommand('git', args, path);
5
6
  if (result.code !== 0) {
@@ -47,6 +48,43 @@ function commitBodyForJob(job, changedFiles) {
47
48
  .filter((part) => part !== null)
48
49
  .join('\n');
49
50
  }
51
+ /**
52
+ * Read CommitMetadata from the worktree's current HEAD. Used when the
53
+ * executor itself created commits inside the workspace (Codex defaults to
54
+ * git-committing as part of its workflow) — the runner did not author the
55
+ * commit, but still needs commit info to populate the PR body / activity log
56
+ * when it pushes the branch.
57
+ */
58
+ export async function readHeadCommitMetadata(repositoryPath, branchName, startingSha) {
59
+ const [sha, subject, authorName, authorEmail, committedAt, diffNames] = await Promise.all([
60
+ readGitValue(repositoryPath, ['rev-parse', 'HEAD']),
61
+ readGitValue(repositoryPath, ['show', '-s', '--format=%s', 'HEAD']),
62
+ readGitValue(repositoryPath, ['show', '-s', '--format=%an', 'HEAD']),
63
+ readGitValue(repositoryPath, ['show', '-s', '--format=%ae', 'HEAD']),
64
+ readGitValue(repositoryPath, ['show', '-s', '--format=%cI', 'HEAD']),
65
+ readGitValue(repositoryPath, [
66
+ 'diff',
67
+ '--name-only',
68
+ `${startingSha}..HEAD`,
69
+ ]),
70
+ ]);
71
+ const changedFiles = diffNames
72
+ .split(/\r?\n/)
73
+ .map((line) => line.trim())
74
+ .filter(Boolean)
75
+ .slice(0, HEAD_FILE_LIST_LIMIT);
76
+ return {
77
+ sha,
78
+ shortSha: sha.slice(0, 12),
79
+ subject,
80
+ branchName,
81
+ authorName,
82
+ authorEmail,
83
+ committedAt,
84
+ changedFileCount: changedFiles.length,
85
+ changedFiles,
86
+ };
87
+ }
50
88
  export async function createLocalCommit(repositoryPath, job, branchName, statusLines) {
51
89
  const changedFiles = changedFilePaths(statusLines);
52
90
  if (changedFiles.length === 0) {
@@ -142,6 +142,17 @@ async function currentBranch(path) {
142
142
  const branch = result.stdout.trim();
143
143
  return branch ? branch : null;
144
144
  }
145
+ export async function readHeadSha(path) {
146
+ const result = await runCommand('git', ['rev-parse', 'HEAD'], path);
147
+ if (result.code !== 0) {
148
+ throw new Error(`Unable to read HEAD sha at ${path}: ${result.stderr || result.stdout || 'unknown git error'}`);
149
+ }
150
+ const sha = result.stdout.trim();
151
+ if (!sha) {
152
+ throw new Error(`Empty HEAD sha at ${path}`);
153
+ }
154
+ return sha;
155
+ }
145
156
  async function branchExistsInBareRepo(sourceRepositoryPath, cwd, branchName) {
146
157
  const result = await runCommand('git', [
147
158
  '--git-dir',
@@ -166,7 +177,13 @@ export async function prepareWorkspace(config, job) {
166
177
  const head = await currentBranch(worktreePath);
167
178
  if (head === branchName) {
168
179
  console.log(`workspace: reusing ${worktreePath} on ${branchName}`);
169
- return { sourceRepositoryPath, worktreePath, branchName };
180
+ const startingSha = await readHeadSha(worktreePath);
181
+ return {
182
+ sourceRepositoryPath,
183
+ worktreePath,
184
+ branchName,
185
+ startingSha,
186
+ };
170
187
  }
171
188
  }
172
189
  catch {
@@ -208,6 +225,7 @@ export async function prepareWorkspace(config, job) {
208
225
  if (await isDirty(worktreePath)) {
209
226
  throw new Error(`Prepared worktree has uncommitted changes; refusing to run in ${worktreePath}`);
210
227
  }
228
+ const startingSha = await readHeadSha(worktreePath);
211
229
  console.log(`workspace: created ${worktreePath} from ${sourceRepositoryPath} on ${branchName}`);
212
- return { sourceRepositoryPath, worktreePath, branchName };
230
+ return { sourceRepositoryPath, worktreePath, branchName, startingSha };
213
231
  }
@@ -2,10 +2,29 @@ import { validatePublishingPolicy } from './config.js';
2
2
  import { normalizeExecutionPreferences } from './execution-prefs.js';
3
3
  import { ADAPTERS_BY_ID } from './executor/adapters/index.js';
4
4
  import { runExecutor, summarizeExecutorFailure } from './executor/process.js';
5
- import { createLocalCommit } from './git/commit.js';
5
+ import { createLocalCommit, readHeadCommitMetadata, } from './git/commit.js';
6
6
  import { publishBranchAndMaybePr, } from './git/publish.js';
7
- import { changedFilePaths, changedFiles, prepareWorkspace, } from './git/workspace.js';
8
- import { postJson, postRunnerEvent, reportCompletion, reportFailure, reportProgress, } from './relay-client.js';
7
+ import { changedFilePaths, changedFiles, prepareWorkspace, readHeadSha, } from './git/workspace.js';
8
+ import { postJson, postRunnerEvent, reportAwaitingUserInput, reportCompletion, reportFailure, reportProgress, } from './relay-client.js';
9
+ export function createCancellationSignal(jobId) {
10
+ return { jobId, cancelled: false };
11
+ }
12
+ /**
13
+ * Wrap a relay status-report call so a transient relay failure (network blip,
14
+ * 5xx, rate-limit) never aborts the surrounding work or cascades to the
15
+ * outer-queue catch in run.ts. Status reporting is best-effort by design — the
16
+ * local commit and the git push are the load-bearing operations, not the
17
+ * Linear activity stream.
18
+ */
19
+ async function safeReport(label, jobId, fn) {
20
+ try {
21
+ await fn();
22
+ }
23
+ catch (error) {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ console.warn(`${label} failed for ${jobId}: ${message}`);
26
+ }
27
+ }
9
28
  export async function pollOnce(config) {
10
29
  const poll = await postJson(config, '/api/runner/jobs/poll', {
11
30
  version: config.version,
@@ -20,18 +39,38 @@ export async function pollOnce(config) {
20
39
  executionPreferences: normalizeExecutionPreferences(poll.job),
21
40
  };
22
41
  }
23
- export async function runRealExecutionOnce(config) {
42
+ export async function runRealExecutionOnce(config, externalSignal) {
24
43
  const job = await pollOnce(config);
25
44
  if (!job) {
26
45
  return false;
27
46
  }
28
- console.log(`claimed ${job.id} for ${job.repositorySlug}`);
47
+ // Propagate the real job id into the external signal so that
48
+ // onCancellation(jobId) checks in run.ts can match correctly.
49
+ if (externalSignal) {
50
+ externalSignal.jobId = job.id;
51
+ }
52
+ console.log(`claimed ${job.id} for ${job.repositorySlug ?? '<no repository>'}`);
29
53
  await postRunnerEvent(config, '/api/runner/heartbeat', job.id);
30
54
  let workspace = null;
31
55
  let commitMetadata = null;
56
+ if (!job.repositorySlug) {
57
+ const message = "This job has no repository configured. Add a Repository Mapping (Linear → Repository) in delego for the issue's project, then re-trigger the agent session.";
58
+ await reportFailure(config, job, message, {
59
+ attemptId: job.attemptId,
60
+ repositorySlug: job.repositorySlug,
61
+ });
62
+ console.error(`failed ${job.id}: ${message}`);
63
+ await postRunnerEvent(config, '/api/runner/heartbeat', null);
64
+ return true;
65
+ }
66
+ // After the guard above the slug is non-null for the remainder of the function.
67
+ const executableJob = {
68
+ ...job,
69
+ repositorySlug: job.repositorySlug,
70
+ };
32
71
  try {
33
72
  validatePublishingPolicy(job.publishingPolicy);
34
- workspace = await prepareWorkspace(config, job);
73
+ workspace = await prepareWorkspace(config, executableJob);
35
74
  await reportProgress(config, job, `Prepared local workspace for ${job.executionPreferences.executor} execution.`, {
36
75
  executor: job.executionPreferences.executor,
37
76
  model: job.executionPreferences.model,
@@ -41,31 +80,78 @@ export async function runRealExecutionOnce(config) {
41
80
  worktreePath: workspace.worktreePath,
42
81
  branchName: workspace.branchName,
43
82
  });
44
- const result = await runExecutor(config, job, workspace.worktreePath);
83
+ const result = await runExecutor(config, job, workspace.worktreePath, externalSignal ? () => externalSignal.cancelled : undefined);
84
+ // Conversational mode: if Claude paused on an unanswered AskUserQuestion,
85
+ // route to /jobs/await (which posts the Linear elicitation and pauses
86
+ // the job in `awaiting_user_input`) instead of treating the silent exit
87
+ // as completion. The worktree is preserved for the resume claim.
88
+ //
89
+ // We do not require `exitCode === 0` here: the executor deliberately
90
+ // SIGTERMs the Claude subprocess as soon as it captures the first
91
+ // AskUserQuestion + session id (see process.ts), so a SIGTERM exit with
92
+ // a pending question is the normal happy path, not a failure.
93
+ if (!result.cancelled && !result.timedOut && result.pendingQuestion) {
94
+ if (!result.sessionId) {
95
+ await reportFailure(config, job, 'Claude returned a pending question without a session id; cannot resume.', {
96
+ attemptId: job.attemptId,
97
+ repositorySlug: job.repositorySlug,
98
+ branchName: workspace.branchName,
99
+ worktreePath: workspace.worktreePath,
100
+ });
101
+ return true;
102
+ }
103
+ await reportAwaitingUserInput(config, job, {
104
+ sessionId: result.sessionId,
105
+ question: result.pendingQuestion,
106
+ worktreePath: workspace.worktreePath,
107
+ branchName: workspace.branchName,
108
+ attemptId: job.attemptId,
109
+ });
110
+ console.log(`await: reported pending question for ${job.id}`);
111
+ return true;
112
+ }
45
113
  const changes = await changedFiles(workspace.worktreePath);
46
114
  let prMetadata = null;
47
115
  let pushed = false;
48
- if (result.exitCode === 0 &&
116
+ const executorSucceeded = result.exitCode === 0 &&
49
117
  !result.cancelled &&
50
118
  !result.timedOut &&
51
- changes.length > 0 &&
52
- config.createCommit) {
119
+ !result.fatalError;
120
+ if (executorSucceeded && changes.length > 0 && config.createCommit) {
53
121
  commitMetadata = await createLocalCommit(workspace.worktreePath, job, workspace.branchName, changes);
54
122
  console.log(`commit: created ${commitMetadata.shortSha} on ${workspace.branchName}`);
55
- if (job.publishingPolicy.autoPush) {
56
- await reportProgress(config, job, 'Publishing branch with local git credentials.', {
57
- branchName: workspace.branchName,
58
- repositorySlug: job.repositorySlug,
59
- publishingPolicy: job.publishingPolicy,
60
- });
61
- prMetadata = await publishBranchAndMaybePr(workspace.worktreePath, job, workspace.branchName, commitMetadata);
62
- pushed = true;
63
- if (prMetadata) {
64
- console.log(`publish: created ${prMetadata.draft ? 'draft ' : ''}PR ${prMetadata.url}`);
65
- }
66
- else {
67
- console.log(`publish: pushed ${workspace.branchName} without creating a PR`);
68
- }
123
+ }
124
+ // Codex (and other executors that script git) often commit changes inside
125
+ // the worktree on their own, so `changedFiles` returns empty but the
126
+ // branch still has new commits to publish. Detect that by comparing HEAD
127
+ // against the SHA captured before the executor ran, and publish whichever
128
+ // commits exist regardless of who authored them.
129
+ const headSha = await readHeadSha(workspace.worktreePath);
130
+ const branchHasNewCommits = headSha !== workspace.startingSha;
131
+ if (executorSucceeded && branchHasNewCommits && !commitMetadata) {
132
+ commitMetadata = await readHeadCommitMetadata(workspace.worktreePath, workspace.branchName, workspace.startingSha);
133
+ console.log(`commit: detected executor-created HEAD ${commitMetadata.shortSha} on ${workspace.branchName}`);
134
+ }
135
+ if (executorSucceeded &&
136
+ branchHasNewCommits &&
137
+ commitMetadata &&
138
+ job.publishingPolicy.autoPush) {
139
+ // Pre-publish progress is informational. Never let it block the actual
140
+ // git push — otherwise the executor's commits sit unpushed on the local
141
+ // branch with no path to recovery if the relay is unhappy.
142
+ const { branchName } = workspace;
143
+ await safeReport('progress report', job.id, () => reportProgress(config, job, 'Publishing branch with local git credentials.', {
144
+ branchName,
145
+ repositorySlug: job.repositorySlug,
146
+ publishingPolicy: job.publishingPolicy,
147
+ }));
148
+ prMetadata = await publishBranchAndMaybePr(workspace.worktreePath, executableJob, workspace.branchName, commitMetadata);
149
+ pushed = true;
150
+ if (prMetadata) {
151
+ console.log(`publish: created ${prMetadata.draft ? 'draft ' : ''}PR ${prMetadata.url}`);
152
+ }
153
+ else {
154
+ console.log(`publish: pushed ${workspace.branchName} without creating a PR`);
69
155
  }
70
156
  }
71
157
  const remainingChanges = await changedFiles(workspace.worktreePath);
@@ -87,6 +173,9 @@ export async function runRealExecutionOnce(config) {
87
173
  remainingChangeCount: remainingChanges.length,
88
174
  remainingChanges: changedFilePaths(remainingChanges).slice(0, 50),
89
175
  commitMetadata,
176
+ branchHasNewCommits,
177
+ startingSha: workspace.startingSha,
178
+ headSha,
90
179
  prMetadata,
91
180
  publishingPolicy: job.publishingPolicy,
92
181
  pushed,
@@ -94,10 +183,14 @@ export async function runRealExecutionOnce(config) {
94
183
  stdoutTail: result.stdout,
95
184
  stderrTail: result.stderr,
96
185
  };
97
- if (result.exitCode === 0 && !result.cancelled && !result.timedOut) {
186
+ if (executorSucceeded) {
98
187
  const executorLabel = ADAPTERS_BY_ID[job.executionPreferences.executor].displayName;
99
- if (changes.length === 0) {
100
- await reportCompletion(config, job, `${executorLabel} executor completed successfully with no repository changes.`, metadata);
188
+ if (changes.length === 0 && !branchHasNewCommits) {
189
+ // Terminal status reports are best-effort: the underlying work is
190
+ // already finished (or in this branch, there was none). A relay
191
+ // failure here must not cascade into the catch block below and
192
+ // misreport a successful job as failed.
193
+ await safeReport('completion report', job.id, () => reportCompletion(config, job, `${executorLabel} executor completed successfully with no repository changes.`, metadata));
101
194
  console.log(`complete: reported no-change success for ${job.id}`);
102
195
  if (result.stdout.trim()) {
103
196
  console.log(`executor stdout tail:\n${result.stdout.trim()}`);
@@ -111,23 +204,24 @@ export async function runRealExecutionOnce(config) {
111
204
  ? `${executorLabel} executor completed successfully and created pull request ${prMetadata.url}.`
112
205
  : commitMetadata
113
206
  ? job.publishingPolicy.autoPush
114
- ? `${executorLabel} executor completed successfully, created local commit ${commitMetadata.shortSha}, and pushed ${workspace.branchName}.`
115
- : `${executorLabel} executor completed successfully and created local commit ${commitMetadata.shortSha}; publishing is manual.`
207
+ ? `${executorLabel} executor completed successfully and pushed ${workspace.branchName} (HEAD ${commitMetadata.shortSha}).`
208
+ : `${executorLabel} executor completed successfully with HEAD ${commitMetadata.shortSha} on ${workspace.branchName}; publishing is manual.`
116
209
  : `${executorLabel} executor completed successfully with ${changes.length} changed file(s); local commit creation is disabled.`;
117
- await reportCompletion(config, job, summary, metadata);
118
- console.log(`changes: ${changes.join(', ')}`);
210
+ await safeReport('completion report', job.id, () => reportCompletion(config, job, summary, metadata));
211
+ const changedSummary = changes.length > 0 ? changes.join(', ') : '<committed by executor>';
212
+ console.log(`changes: ${changedSummary}`);
119
213
  console.log(`complete: reported success for ${job.id}`);
120
214
  }
121
215
  }
122
216
  else {
123
217
  const summary = summarizeExecutorFailure(result);
124
- await reportFailure(config, job, summary, metadata, result.cancelled);
218
+ await safeReport('failure report', job.id, () => reportFailure(config, job, summary, metadata, result.cancelled));
125
219
  console.log(`terminal: reported ${result.cancelled ? 'cancellation' : 'failure'} for ${job.id}`);
126
220
  }
127
221
  }
128
222
  catch (error) {
129
223
  const message = error instanceof Error ? error.message : String(error);
130
- await reportFailure(config, job, message, {
224
+ await safeReport('failure report', job.id, () => reportFailure(config, job, message, {
131
225
  attemptId: job.attemptId,
132
226
  repositorySlug: job.repositorySlug,
133
227
  branchName: workspace?.branchName,
@@ -136,11 +230,20 @@ export async function runRealExecutionOnce(config) {
136
230
  worktreePath: workspace?.worktreePath,
137
231
  commitMetadata,
138
232
  publishingPolicy: job.publishingPolicy,
139
- });
233
+ }));
140
234
  console.error(`failed ${job.id}: ${message}`);
141
235
  }
142
236
  finally {
143
- await postRunnerEvent(config, '/api/runner/heartbeat', null);
237
+ // The trailing heartbeat is purely informational ("I'm idle again"); it
238
+ // must not throw out of the finally block and replace any in-flight
239
+ // exception with an unrelated network/auth error.
240
+ try {
241
+ await postRunnerEvent(config, '/api/runner/heartbeat', null);
242
+ }
243
+ catch (error) {
244
+ const message = error instanceof Error ? error.message : String(error);
245
+ console.warn(`post-job heartbeat failed: ${message}`);
246
+ }
144
247
  }
145
248
  return true;
146
249
  }
@@ -0,0 +1,91 @@
1
+ import { createClient as defaultCreateClient, } from '@supabase/supabase-js';
2
+ import { fetchRealtimeToken } from './relay-client.js';
3
+ const REFRESH_LEAD_MS = 60_000;
4
+ export function createRealtimeSubscriber(opts) {
5
+ const fetchToken = opts.fetchToken ?? fetchRealtimeToken;
6
+ const factory = opts.createClient ??
7
+ defaultCreateClient;
8
+ let client = null;
9
+ let channel = null;
10
+ let refreshTimer = null;
11
+ let connected = false;
12
+ let stopped = false;
13
+ async function refresh() {
14
+ const next = await fetchToken(opts.config);
15
+ if (client) {
16
+ client.realtime.setAuth(next.token);
17
+ }
18
+ scheduleRefresh(next.expiresAt);
19
+ }
20
+ function scheduleRefresh(expiresAtIso) {
21
+ if (refreshTimer)
22
+ clearTimeout(refreshTimer);
23
+ const delay = Math.max(1_000, new Date(expiresAtIso).getTime() - Date.now() - REFRESH_LEAD_MS);
24
+ refreshTimer = setTimeout(() => {
25
+ if (stopped)
26
+ return;
27
+ refresh().catch((err) => {
28
+ console.warn(`realtime: token refresh failed: ${err instanceof Error ? err.message : String(err)}`);
29
+ // Retry in 30s; the existing JWT may still be valid for a while,
30
+ // and reconnect logic will take over once it expires.
31
+ scheduleRefresh(new Date(Date.now() + 30_000).toISOString());
32
+ });
33
+ }, delay);
34
+ }
35
+ return {
36
+ isConnected: () => connected,
37
+ async start() {
38
+ stopped = false;
39
+ const initial = await fetchToken(opts.config);
40
+ client = factory(initial.supabaseUrl, initial.token);
41
+ channel = client.channel(`runner-account-${opts.accountId}`);
42
+ channel.on('postgres_changes', {
43
+ event: '*',
44
+ schema: 'public',
45
+ table: 'jobs',
46
+ filter: `account_id=eq.${opts.accountId}`,
47
+ }, (payload) => {
48
+ if (payload.eventType === 'INSERT' ||
49
+ payload.eventType === 'UPDATE') {
50
+ opts.onJobReady();
51
+ }
52
+ if (payload.eventType === 'UPDATE') {
53
+ const oldCancel = payload.old?.cancellation_requested;
54
+ const newCancel = payload.new?.cancellation_requested;
55
+ if (newCancel === true && oldCancel !== true) {
56
+ const jobId = payload.new?.id;
57
+ if (typeof jobId === 'string') {
58
+ opts.onCancellation(jobId);
59
+ }
60
+ }
61
+ }
62
+ });
63
+ channel.subscribe((status) => {
64
+ if (status === 'SUBSCRIBED') {
65
+ connected = true;
66
+ opts.onJobReady();
67
+ }
68
+ else if (status === 'CHANNEL_ERROR' ||
69
+ status === 'TIMED_OUT' ||
70
+ status === 'CLOSED') {
71
+ connected = false;
72
+ }
73
+ });
74
+ scheduleRefresh(initial.expiresAt);
75
+ },
76
+ async stop() {
77
+ stopped = true;
78
+ if (refreshTimer) {
79
+ clearTimeout(refreshTimer);
80
+ refreshTimer = null;
81
+ }
82
+ if (channel && client) {
83
+ await channel.unsubscribe();
84
+ client.removeChannel(channel);
85
+ }
86
+ channel = null;
87
+ client = null;
88
+ connected = false;
89
+ },
90
+ };
91
+ }
@@ -76,9 +76,30 @@ export async function reportFailure(config, job, summary, metadata, cancelled =
76
76
  metadata,
77
77
  });
78
78
  }
79
+ /**
80
+ * Conversational mode: report an unanswered AskUserQuestion. The relay
81
+ * transitions the job to `awaiting_user_input`, persists the question +
82
+ * Claude session id + worktree path, and posts a Linear elicitation
83
+ * activity so the user is prompted to answer.
84
+ */
85
+ export async function reportAwaitingUserInput(config, job, body) {
86
+ await postJson(config, '/api/runner/jobs/await', {
87
+ jobId: job.id,
88
+ organizationId: job.organizationId,
89
+ agentSessionId: job.agentSessionId,
90
+ sessionId: body.sessionId,
91
+ question: body.question,
92
+ worktreePath: body.worktreePath,
93
+ branchName: body.branchName,
94
+ attemptId: body.attemptId,
95
+ });
96
+ }
79
97
  export async function pollCancellation(config, job) {
80
98
  const status = await postJson(config, '/api/runner/jobs/status', {
81
99
  jobId: job.id,
82
100
  });
83
101
  return Boolean(status.job?.cancellationRequested);
84
102
  }
103
+ export async function fetchRealtimeToken(config) {
104
+ return postJson(config, '/api/runner/realtime-token', {});
105
+ }
package/dist/run.js CHANGED
@@ -1,12 +1,51 @@
1
1
  import { readConfig, usageMessage, } from './config.js';
2
2
  import { getProfile, readStore, resolveCredentialsPath, } from './credentials-store.js';
3
- import { runMockExecutionOnce, runRealExecutionOnce } from './job-pipeline.js';
3
+ import { createCancellationSignal, runMockExecutionOnce, runRealExecutionOnce, } from './job-pipeline.js';
4
4
  import { pairAndStore } from './pairing.js';
5
- import { describeRunnerResponse, postRunnerEvent, RelayUnauthorizedError, } from './relay-client.js';
6
- function sleep(ms) {
7
- return new Promise((resolve) => {
8
- setTimeout(resolve, ms);
9
- });
5
+ import { describeRunnerResponse, fetchRealtimeToken, postRunnerEvent, RelayUnauthorizedError, } from './relay-client.js';
6
+ function createKickableQueue(action) {
7
+ let running = false;
8
+ let kickedWhileRunning = false;
9
+ const runOnce = async () => {
10
+ if (running) {
11
+ kickedWhileRunning = true;
12
+ return;
13
+ }
14
+ running = true;
15
+ try {
16
+ do {
17
+ kickedWhileRunning = false;
18
+ try {
19
+ await action();
20
+ }
21
+ catch (err) {
22
+ console.warn(`job action failed: ${err instanceof Error ? err.message : String(err)}`);
23
+ }
24
+ } while (kickedWhileRunning);
25
+ }
26
+ finally {
27
+ running = false;
28
+ }
29
+ };
30
+ return {
31
+ kick: (source) => {
32
+ console.log(`kick: ${source} at ${new Date().toISOString()}`);
33
+ runOnce().catch(() => {
34
+ /* swallowed in inner catch */
35
+ });
36
+ },
37
+ };
38
+ }
39
+ function decodeJwtSub(token) {
40
+ const parts = token.split('.');
41
+ if (parts.length !== 3 || !parts[1]) {
42
+ throw new Error('runtime: malformed realtime token');
43
+ }
44
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
45
+ if (typeof payload.sub !== 'string') {
46
+ throw new Error('runtime: realtime token missing sub claim');
47
+ }
48
+ return payload.sub;
10
49
  }
11
50
  export async function resolveRunnerConfig(input, env = process.env) {
12
51
  const credentialsPath = resolveCredentialsPath(env);
@@ -52,6 +91,8 @@ function buildRunnerConfig(input, relayUrl, bearer, runnerId) {
52
91
  heartbeatIntervalMs: input.heartbeatIntervalMs,
53
92
  pollIntervalMs: input.pollIntervalMs,
54
93
  cancellationPollIntervalMs: input.cancellationPollIntervalMs,
94
+ safetySyncIntervalMs: input.safetySyncIntervalMs,
95
+ fallbackPollIntervalMs: input.fallbackPollIntervalMs,
55
96
  workspaceRoot: input.workspaceRoot,
56
97
  gitCloneBaseUrl: input.gitCloneBaseUrl,
57
98
  executors: input.executors,
@@ -75,9 +116,6 @@ export async function run(args = process.argv.slice(2)) {
75
116
  }
76
117
  const input = readConfig(args);
77
118
  const config = await resolveRunnerConfig(input);
78
- // In v0.3, /api/runner/register is the *pairing* endpoint and only accepts a
79
- // pairing-token body. Pairing happens once inside resolveRunnerConfig (when
80
- // --pairing-token is supplied); after that the runner just heartbeats.
81
119
  try {
82
120
  const heartbeat = await postRunnerEvent(config, '/api/runner/heartbeat');
83
121
  console.log(describeRunnerResponse('heartbeat', heartbeat));
@@ -92,33 +130,68 @@ export async function run(args = process.argv.slice(2)) {
92
130
  await runMockExecutionOnce(config);
93
131
  return;
94
132
  }
133
+ let activeSignal = null;
134
+ const queue = createKickableQueue(async () => {
135
+ const signal = createCancellationSignal('');
136
+ activeSignal = signal;
137
+ try {
138
+ return await runRealExecutionOnce(config, signal);
139
+ }
140
+ catch (error) {
141
+ if (error instanceof RelayUnauthorizedError) {
142
+ handleUnauthorized(error, config, input.profileName);
143
+ }
144
+ throw error;
145
+ }
146
+ finally {
147
+ activeSignal = null;
148
+ }
149
+ });
150
+ if (config.once) {
151
+ queue.kick('once');
152
+ return;
153
+ }
154
+ const initial = await fetchRealtimeToken(config);
155
+ const accountId = decodeJwtSub(initial.token);
156
+ const { createRealtimeSubscriber } = await import('./realtime-subscriber.js');
157
+ const subscriber = createRealtimeSubscriber({
158
+ config,
159
+ accountId,
160
+ onJobReady: () => queue.kick('realtime-push'),
161
+ onCancellation: (jobId) => {
162
+ if (activeSignal && activeSignal.jobId === jobId) {
163
+ activeSignal.cancelled = true;
164
+ }
165
+ },
166
+ });
167
+ await subscriber.start();
95
168
  const heartbeatTimer = startHeartbeatLoop(config, input.profileName);
96
- try {
97
- do {
98
- let handled = false;
169
+ const safetySync = setInterval(() => queue.kick('safety-sync'), config.safetySyncIntervalMs);
170
+ const fallbackPoll = setInterval(() => {
171
+ if (!subscriber.isConnected()) {
172
+ queue.kick('fallback-poll');
173
+ }
174
+ }, config.fallbackPollIntervalMs);
175
+ // Keep the process alive until SIGTERM / SIGINT. The actual work is
176
+ // event-driven from this point on.
177
+ await new Promise((resolve) => {
178
+ const shutdown = async () => {
99
179
  try {
100
- handled = await runRealExecutionOnce(config);
180
+ clearInterval(heartbeatTimer);
181
+ clearInterval(safetySync);
182
+ clearInterval(fallbackPoll);
183
+ await subscriber.stop();
101
184
  }
102
- catch (error) {
103
- if (error instanceof RelayUnauthorizedError) {
104
- handleUnauthorized(error, config, input.profileName);
105
- }
106
- const message = error instanceof Error ? error.message : String(error);
107
- console.warn(`poll cycle failed: ${message}`);
108
- if (config.once) {
109
- throw error;
110
- }
185
+ catch (err) {
186
+ console.warn(`runner shutdown error: ${err instanceof Error ? err.message : String(err)}`);
111
187
  }
112
- if (config.once) {
113
- return;
188
+ finally {
189
+ resolve();
114
190
  }
115
- await sleep(handled ? 1_000 : config.pollIntervalMs);
116
- // oxlint-disable-next-line no-constant-condition
117
- } while (true);
118
- }
119
- finally {
120
- clearInterval(heartbeatTimer);
121
- }
191
+ };
192
+ process.once('SIGINT', shutdown);
193
+ process.once('SIGTERM', shutdown);
194
+ });
122
195
  }
123
196
  function startHeartbeatLoop(config, profileName) {
124
197
  let inFlight = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delegoapp/runner",
3
- "version": "0.1.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Delego runner — polls the Delego relay and executes Linear-delegated coding jobs via Codex CLI or Claude Code.",
6
6
  "keywords": [
@@ -30,20 +30,24 @@
30
30
  "publishConfig": {
31
31
  "access": "public"
32
32
  },
33
- "devDependencies": {
34
- "typescript": "^6.0.3",
35
- "vitest": "^4.1.5",
36
- "@kit/tsconfig": "0.1.0"
37
- },
38
- "engines": {
39
- "node": ">=20"
40
- },
41
33
  "scripts": {
42
34
  "build": "tsc -p tsconfig.json && node scripts/add-shebang.mjs",
43
35
  "prestart": "pnpm build",
44
36
  "start": "node dist/index.js",
45
37
  "typecheck": "tsc --noEmit -p tsconfig.json",
46
38
  "test": "vitest run",
47
- "clean": "rm -rf .turbo dist node_modules"
39
+ "clean": "rm -rf .turbo dist node_modules",
40
+ "prepublishOnly": "pnpm build"
41
+ },
42
+ "dependencies": {
43
+ "@supabase/supabase-js": "catalog:"
44
+ },
45
+ "devDependencies": {
46
+ "@kit/tsconfig": "workspace:*",
47
+ "typescript": "catalog:",
48
+ "vitest": "catalog:"
49
+ },
50
+ "engines": {
51
+ "node": ">=20"
48
52
  }
49
- }
53
+ }