@pulsehub/runner 0.1.15 → 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 +11 -2
- package/test/integration.test.js +41 -1
package/package.json
CHANGED
package/src/runtime.js
CHANGED
|
@@ -140,11 +140,20 @@ export async function runOnce(config, options = {}) {
|
|
|
140
140
|
let jobPhase = 'poll';
|
|
141
141
|
let completionReported = false;
|
|
142
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
|
+
};
|
|
143
152
|
const journal = join(paths().root, 'active-job.json');
|
|
144
153
|
const checkActive = async () => {
|
|
145
154
|
controller.signal.throwIfAborted();
|
|
146
155
|
if (Date.parse(job.expiresAt) <= Date.now()) controller.abort();
|
|
147
|
-
const response = await
|
|
156
|
+
const response = await sendJobHeartbeat();
|
|
148
157
|
if (response.cancelRequested) controller.abort();
|
|
149
158
|
controller.signal.throwIfAborted();
|
|
150
159
|
};
|
|
@@ -212,7 +221,7 @@ export async function runOnce(config, options = {}) {
|
|
|
212
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 }) });
|
|
213
222
|
} finally {
|
|
214
223
|
agentActive = false;
|
|
215
|
-
await
|
|
224
|
+
await sendJobHeartbeat().catch(() => {});
|
|
216
225
|
}
|
|
217
226
|
controller.signal.throwIfAborted();
|
|
218
227
|
await checkActive();
|
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(); }
|
|
@@ -154,6 +154,46 @@ for (const kind of ['claude','codex']) {
|
|
|
154
154
|
}));
|
|
155
155
|
}
|
|
156
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
|
+
|
|
157
197
|
test('integration: invalid signed jobs never reach checkout', () => fixture('codex', async f => {
|
|
158
198
|
const original = {...f.job};
|
|
159
199
|
const cases = [
|