@pulsehub/runner 0.1.14 → 0.1.16
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/package.json +1 -1
- package/src/runtime.js +20 -2
- package/test/integration.test.js +46 -1
package/package.json
CHANGED
package/src/runtime.js
CHANGED
|
@@ -139,11 +139,21 @@ export async function runOnce(config, options = {}) {
|
|
|
139
139
|
let job;
|
|
140
140
|
let jobPhase = 'poll';
|
|
141
141
|
let completionReported = false;
|
|
142
|
+
let agentActive = false;
|
|
143
|
+
let heartbeatQueue = Promise.resolve();
|
|
144
|
+
// Preserve host phase order, including the explicit final stop. A delayed
|
|
145
|
+
// preparation request must finish before execution can start its lease.
|
|
146
|
+
const sendJobHeartbeat = () => {
|
|
147
|
+
const active = agentActive;
|
|
148
|
+
const sent = heartbeatQueue.then(() => remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id, agentActive: active }));
|
|
149
|
+
heartbeatQueue = sent.catch(() => {});
|
|
150
|
+
return sent;
|
|
151
|
+
};
|
|
142
152
|
const journal = join(paths().root, 'active-job.json');
|
|
143
153
|
const checkActive = async () => {
|
|
144
154
|
controller.signal.throwIfAborted();
|
|
145
155
|
if (Date.parse(job.expiresAt) <= Date.now()) controller.abort();
|
|
146
|
-
const response = await
|
|
156
|
+
const response = await sendJobHeartbeat();
|
|
147
157
|
if (response.cancelRequested) controller.abort();
|
|
148
158
|
controller.signal.throwIfAborted();
|
|
149
159
|
};
|
|
@@ -204,7 +214,15 @@ export async function runOnce(config, options = {}) {
|
|
|
204
214
|
controller.signal.throwIfAborted();
|
|
205
215
|
jobPhase = 'run-agent';
|
|
206
216
|
await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
|
|
207
|
-
|
|
217
|
+
agentActive = true;
|
|
218
|
+
let result;
|
|
219
|
+
try {
|
|
220
|
+
await checkActive();
|
|
221
|
+
result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal, profile, onDiagnostic: ({ event, ...details }) => logEvent(event, { jobId: job.id, ...details }) });
|
|
222
|
+
} finally {
|
|
223
|
+
agentActive = false;
|
|
224
|
+
await sendJobHeartbeat().catch(() => {});
|
|
225
|
+
}
|
|
208
226
|
controller.signal.throwIfAborted();
|
|
209
227
|
await checkActive();
|
|
210
228
|
await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
|
package/test/integration.test.js
CHANGED
|
@@ -11,7 +11,7 @@ import { paths, ensureDataDir, writeCredential } from '../src/config.js';
|
|
|
11
11
|
import { jobPayload } from '../src/job.js';
|
|
12
12
|
import { command } from '../src/process.js';
|
|
13
13
|
import { inspectReadiness } from '../src/readiness.js';
|
|
14
|
-
import { runOnce, recoverInterruptedWork, withRunnerLock } from '../src/runtime.js';
|
|
14
|
+
import { runOnce, request, recoverInterruptedWork, withRunnerLock } from '../src/runtime.js';
|
|
15
15
|
|
|
16
16
|
const exists = (file) => access(file).then(() => true).catch(() => false);
|
|
17
17
|
async function git(args) { const result = await command('git', args); assert.equal(result.code, 0, result.output); return result.output.trim(); }
|
|
@@ -138,6 +138,10 @@ for (const kind of ['claude','codex']) {
|
|
|
138
138
|
assert.equal(await exists(join(paths().root,'tmp',f.job.id)),false);
|
|
139
139
|
const completion = f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
|
|
140
140
|
assert.equal(completion.outcome,'completed');
|
|
141
|
+
const heartbeats = f.calls.filter(c => c.path === '/pulseRunnerHeartbeat' && c.body.jobId);
|
|
142
|
+
assert.equal(heartbeats[0].body.agentActive, false);
|
|
143
|
+
assert(heartbeats.some(c => c.body.agentActive === true));
|
|
144
|
+
assert.equal(heartbeats.at(-1).body.agentActive, false);
|
|
141
145
|
assert.ok(!JSON.stringify(f.calls).includes('pulse_sk_fixture'));
|
|
142
146
|
}));
|
|
143
147
|
test(`integration: ${kind} missing native session rejects before creating worktrees`, () => fixture(kind, async f => {
|
|
@@ -150,6 +154,46 @@ for (const kind of ['claude','codex']) {
|
|
|
150
154
|
}));
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
test('integration: delayed preparation heartbeat cannot stop a running CLI lease', () => fixture('codex', async f => {
|
|
158
|
+
f.config.agentRole = 'qa'; f.job.mode = 'review'; f.signJob();
|
|
159
|
+
let preparationStarted;
|
|
160
|
+
const preparation = new Promise(resolve => { preparationStarted = resolve; });
|
|
161
|
+
let busyCount = 0, inFlight = 0, peak = 0;
|
|
162
|
+
const committed = [];
|
|
163
|
+
await f.run({
|
|
164
|
+
checkout: async () => {
|
|
165
|
+
await preparation;
|
|
166
|
+
await mkdir(join(paths().worktrees, f.job.id), { recursive: true });
|
|
167
|
+
const primary = { repo: f.job.repoFullName, worktree: f.source };
|
|
168
|
+
return { primary, checkouts: [primary] };
|
|
169
|
+
},
|
|
170
|
+
cleanup: async () => {},
|
|
171
|
+
request: async (config, path, body) => {
|
|
172
|
+
if (path !== 'pulseRunnerHeartbeat' || !body.jobId) return request(config, path, body);
|
|
173
|
+
inFlight++; peak = Math.max(peak, inFlight);
|
|
174
|
+
const order = ++busyCount;
|
|
175
|
+
try {
|
|
176
|
+
if (order === 2) {
|
|
177
|
+
assert.equal(body.agentActive, false);
|
|
178
|
+
preparationStarted();
|
|
179
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
180
|
+
}
|
|
181
|
+
const response = await request(config, path, body);
|
|
182
|
+
committed.push({ order, active: body.agentActive });
|
|
183
|
+
return response;
|
|
184
|
+
} finally { inFlight--; }
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
assert.equal(peak, 1, 'watcher and phase transitions must never overlap requests');
|
|
188
|
+
assert.equal(committed[1].active, false);
|
|
189
|
+
const firstActive = committed.findIndex(entry => entry.active);
|
|
190
|
+
assert(firstActive > 1, 'execution starts only after preparation heartbeat commits');
|
|
191
|
+
const stopped = committed.findIndex((entry, i) => i > firstActive && !entry.active);
|
|
192
|
+
assert(stopped > firstActive);
|
|
193
|
+
assert(committed.slice(stopped).every(entry => !entry.active), 'final stop stays final');
|
|
194
|
+
assert.equal(f.calls.find(c => c.path === '/pulseRunnerComplete').body.outcome, 'completed');
|
|
195
|
+
}));
|
|
196
|
+
|
|
153
197
|
test('integration: invalid signed jobs never reach checkout', () => fixture('codex', async f => {
|
|
154
198
|
const original = {...f.job};
|
|
155
199
|
const cases = [
|
|
@@ -388,6 +432,7 @@ test('integration: publication failure retains commits and retry does not run th
|
|
|
388
432
|
f.job.publicationTargets=[...f.job.publicationTargets.map(t=>({...t,sha:entry.sha}))];f.signJob();
|
|
389
433
|
await f.run();
|
|
390
434
|
assert.equal(await readFile(f.trace,'utf8'),previousTrace);
|
|
435
|
+
assert(f.calls.filter(c => c.path === '/pulseRunnerHeartbeat' && c.body.jobId).every(c => c.body.agentActive === false));
|
|
391
436
|
const retry=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
|
|
392
437
|
assert.equal(retry.outcome,'completed');assert.equal(retry.publication.repositories[0].sha,entry.sha);
|
|
393
438
|
assert.equal(retry.publication.repositories[0].stage,'linked');assert.equal(publishCalls,1);
|