@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsehub/runner",
3
- "version": "0.1.15",
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
@@ -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 remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id, agentActive });
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 remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id, agentActive: false }).catch(() => {});
224
+ await sendJobHeartbeat().catch(() => {});
216
225
  }
217
226
  controller.signal.throwIfAborted();
218
227
  await checkActive();
@@ -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 = [