@bill10/agent-007 0.9.1001 → 0.9.2000

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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.1.1
1
+ 0.9.2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bill10/agent-007",
3
- "version": "0.9.1001",
3
+ "version": "0.9.2000",
4
4
  "description": "From web terminals for your coding agents to a self-running agent company: Claude Code and Codex in parallel git worktrees, a job board they pick work from, and one agent that runs the board from a goal.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/server/jobs.js CHANGED
@@ -313,6 +313,7 @@ export function addJob({ title, detail, repoPath, type, schedule, permissionMode
313
313
  if (written) result.job.attachments = written.attachments;
314
314
  allJobs().push(result.job);
315
315
  persist(broadcast);
316
+ requestDispatch();
316
317
  return { job: result.job };
317
318
  }
318
319
 
@@ -699,6 +700,7 @@ export async function finishJobForAgent({ session, summary, prUrl }, broadcast,
699
700
  job.lastErrorAt = null;
700
701
  clearPrCheckError(job);
701
702
  persist(broadcast);
703
+ requestDispatch();
702
704
  if (broadcast) {
703
705
  broadcast({
704
706
  type: 'notification', level: 'info',
@@ -876,6 +878,7 @@ export function setJobPaused(jobId, paused, broadcast) {
876
878
  job.paused = next;
877
879
  if (!next && job.schedule) job.nextRunAt = nextCronIso(job.schedule);
878
880
  persist(broadcast);
881
+ if (!next) requestDispatch();
879
882
  return { job };
880
883
  }
881
884
 
@@ -889,6 +892,7 @@ export async function deleteJob(jobId, broadcast, { killSession } = {}) {
889
892
  if (idx === -1) return { error: 'Job not found' };
890
893
  const [removed] = jobs.splice(idx, 1);
891
894
  persist(broadcast);
895
+ requestDispatch();
892
896
  // After persist, so a file the OS will not let go of (open in a browser tab
893
897
  // on Windows) cannot leave a card that is gone from memory but back on the
894
898
  // next restart. The id is checked the same way a path is: it too is
@@ -1031,6 +1035,8 @@ export async function moveJob(jobId, state, broadcast, { killSession, findPr = f
1031
1035
  // Persist and repaint before the kill so the card moves immediately; the kill
1032
1036
  // then emits its own session-ended and orphan notifications.
1033
1037
  persist(broadcast);
1038
+ // Back in To do, or a slot freed by leaving In progress.
1039
+ if (state === 'todo' || fromState === 'in-progress') requestDispatch();
1034
1040
  if (retiringSessionId && killSession) {
1035
1041
  const session = sessions.get(retiringSessionId);
1036
1042
  if (session && !session.exited) {
@@ -1103,6 +1109,8 @@ export async function releasePushedOrphans(broadcast) {
1103
1109
 
1104
1110
  export function updateSettings(fields, broadcast) {
1105
1111
  const settings = boardSettings();
1112
+ // Starting the board, or raising its cap, makes room right away.
1113
+ const before = { running: settings.running, maxPerRepo: settings.maxPerRepo };
1106
1114
  if (typeof fields.running === 'boolean') settings.running = fields.running;
1107
1115
  if (Number.isFinite(fields.maxPerRepo)) settings.maxPerRepo = Math.max(1, Math.min(10, Math.floor(fields.maxPerRepo)));
1108
1116
  if (Number.isFinite(fields.intervalMs)) settings.intervalMs = Math.max(30_000, Math.min(60 * 60_000, Math.floor(fields.intervalMs)));
@@ -1114,6 +1122,7 @@ export function updateSettings(fields, broadcast) {
1114
1122
  settings.permissionModeChosen = true;
1115
1123
  }
1116
1124
  persist(broadcast);
1125
+ if (settings.running && (!before.running || settings.maxPerRepo > before.maxPerRepo)) requestDispatch();
1117
1126
  return { settings };
1118
1127
  }
1119
1128
 
@@ -2130,6 +2139,31 @@ export async function runScan(createSession, broadcast, { onSessionCreated, kill
2130
2139
  }
2131
2140
  }
2132
2141
 
2142
+ // --- Dispatch on events ---
2143
+
2144
+ // A card posted or requeued, or a slot freed, asks for a dispatch pass here
2145
+ // instead of waiting out the scan interval (5 minutes by default). Events
2146
+ // within DISPATCH_DEBOUNCE_MS coalesce into one pass. The pass is dispatchOnce
2147
+ // alone, behind the scan's flag: the PR and merge sweeps ask GitHub about
2148
+ // every card and have nothing to do with a card just posted, so they stay on
2149
+ // the interval. A pass that finds a scan running asks again, since the scan
2150
+ // may have picked its candidates before the event.
2151
+ export const DISPATCH_DEBOUNCE_MS = 2000;
2152
+ let dispatchPass = null;
2153
+ let kickTimer = null;
2154
+
2155
+ export function requestDispatch() {
2156
+ if (!dispatchPass || kickTimer) return;
2157
+ kickTimer = setTimeout(async () => {
2158
+ kickTimer = null;
2159
+ try { await dispatchPass(); } catch (err) {
2160
+ console.error('Job dispatch pass failed:', err.message);
2161
+ }
2162
+ }, DISPATCH_DEBOUNCE_MS);
2163
+ // Never the thing keeping a process alive: the interval loop is the board.
2164
+ kickTimer.unref?.();
2165
+ }
2166
+
2133
2167
  // --- Loop ---
2134
2168
 
2135
2169
  let dispatchTimer = null;
@@ -2165,12 +2199,25 @@ export function startDispatcher(createSession, broadcast, { onSessionCreated, ki
2165
2199
  if (generation === loopGeneration) ciTimer = setTimeout(ciTick, CI_POLL_MS);
2166
2200
  };
2167
2201
  ciTimer = setTimeout(ciTick, CI_POLL_MS);
2202
+ dispatchPass = async () => {
2203
+ if (!boardSettings().running) return;
2204
+ if (scanInFlight) { requestDispatch(); return; }
2205
+ scanInFlight = true;
2206
+ try {
2207
+ await dispatchOnce(createSession, broadcast, { onSessionCreated, killSession });
2208
+ } finally {
2209
+ scanInFlight = false;
2210
+ }
2211
+ };
2168
2212
  }
2169
2213
 
2170
2214
  export function stopDispatcher() {
2171
2215
  loopGeneration++;
2172
2216
  clearTimeout(dispatchTimer);
2173
2217
  clearTimeout(ciTimer);
2218
+ clearTimeout(kickTimer);
2174
2219
  dispatchTimer = null;
2175
2220
  ciTimer = null;
2221
+ kickTimer = null;
2222
+ dispatchPass = null;
2176
2223
  }
package/server/pty.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { spawn as spawnPty } from 'node-pty';
4
4
  import { homedir } from 'os';
5
5
  import { basename } from 'path';
6
+ import { writeSync } from 'fs';
6
7
  import { stripAnsiComplete, detectState, createRingBuffer, parseCommand, isRealOutput, trackSyncFrames, ptyEnv } from '../lib/helpers.js';
7
8
  // Re-exported so the handler's tests reach the parser through the module they drive.
8
9
  export { trackSyncFrames } from '../lib/helpers.js';
@@ -10,7 +11,7 @@ import { resolveExecutable, isUsableCwd, commandExists, missingCommandMessage }
10
11
  import { RING_BUFFER_MAX } from './state.js';
11
12
  import { mintAgentToken, authEnabled } from './auth.js';
12
13
  import { writeMcpConfig, removeMcpConfig, withMcpConfig, takesMcpConfig, withApprovalHook } from './agent-mcp.js';
13
- import { broadcastJobs } from './jobs.js';
14
+ import { broadcastJobs, requestDispatch } from './jobs.js';
14
15
  import { flushMessages, dropMessages } from './messages.js';
15
16
  import { sessionAgentFromCommand, permissionFlagsFromCommand } from '../lib/jobs.js';
16
17
  import { trustDialogKey } from './billion.js';
@@ -36,8 +37,10 @@ function installAsyncSpawnGuard() {
36
37
  const match = ASYNC_SPAWN_FAILURE_RE.exec(err?.message || '');
37
38
  if (!match) {
38
39
  // Not ours. Reproduce Node's default uncaughtException behaviour rather
39
- // than silently swallowing an unrelated bug.
40
- console.error(err);
40
+ // than silently swallowing an unrelated bug. Written synchronously:
41
+ // console.error to a pipe is async on Windows, and process.exit dropped
42
+ // it, leaving a crash with no error to read.
43
+ try { writeSync(2, `${err?.stack || err}\n`); } catch { /* exiting anyway */ }
41
44
  process.exit(1);
42
45
  }
43
46
 
@@ -158,6 +161,8 @@ export function setupPtyHandlers(session, sessionId, broadcast) {
158
161
  dropMessages(sessionId);
159
162
  if (session.isBillion) dropApprovals();
160
163
  updateState(session, broadcast);
164
+ // A board worker gone frees its repo's slot.
165
+ if (session.jobId) requestDispatch();
161
166
  // What it is as it ends, which a relink or a board retirement may have
162
167
  // changed since session-created: the client's finished-worker path reads it.
163
168
  broadcast({ type: 'session-ended', sessionId, reason: `Process exited with code ${exitCode}`,