@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 +1 -1
- package/dist/config.js +12 -3
- package/dist/executor/adapters/claude.js +137 -4
- package/dist/executor/process.js +95 -12
- package/dist/executor/prompt.js +16 -0
- package/dist/git/commit.js +38 -0
- package/dist/git/workspace.js +20 -2
- package/dist/job-pipeline.js +138 -35
- package/dist/realtime-subscriber.js +91 -0
- package/dist/relay-client.js +21 -0
- package/dist/run.js +104 -31
- package/package.json +15 -11
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` | `
|
|
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 {
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|
package/dist/executor/process.js
CHANGED
|
@@ -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 =
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/executor/prompt.js
CHANGED
|
@@ -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
|
package/dist/git/commit.js
CHANGED
|
@@ -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) {
|
package/dist/git/workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/job-pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
116
|
+
const executorSucceeded = result.exitCode === 0 &&
|
|
49
117
|
!result.cancelled &&
|
|
50
118
|
!result.timedOut &&
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 (
|
|
186
|
+
if (executorSucceeded) {
|
|
98
187
|
const executorLabel = ADAPTERS_BY_ID[job.executionPreferences.executor].displayName;
|
|
99
|
-
if (changes.length === 0) {
|
|
100
|
-
|
|
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
|
|
115
|
-
: `${executorLabel} executor completed successfully
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/relay-client.js
CHANGED
|
@@ -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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
180
|
+
clearInterval(heartbeatTimer);
|
|
181
|
+
clearInterval(safetySync);
|
|
182
|
+
clearInterval(fallbackPoll);
|
|
183
|
+
await subscriber.stop();
|
|
101
184
|
}
|
|
102
|
-
catch (
|
|
103
|
-
|
|
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
|
-
|
|
113
|
-
|
|
188
|
+
finally {
|
|
189
|
+
resolve();
|
|
114
190
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
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
|
+
}
|