@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsehub/runner",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Local, credential-safe Pulse Runner",
5
5
  "repository": {
6
6
  "type": "git",
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 remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id });
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
- const 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 }) });
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) }) });
@@ -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);