Skip to content

Commit a508750

Browse files
authored
Merge pull request #262 from AgentWorkforce/fix/channel-message-delivery
Fix: Channel message delivery for agents in channels
2 parents babed01 + 574f146 commit a508750

File tree

7 files changed

+210
-5
lines changed

7 files changed

+210
-5
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"id": "traj_jl5pp8rdu8up",
3+
"version": 1,
4+
"task": {
5+
"title": "Fix spawn timing race condition"
6+
},
7+
"status": "completed",
8+
"startedAt": "2026-01-21T22:10:45.793Z",
9+
"agents": [
10+
{
11+
"name": "default",
12+
"role": "lead",
13+
"joinedAt": "2026-01-21T22:29:43.316Z"
14+
}
15+
],
16+
"chapters": [
17+
{
18+
"id": "chap_joy3ffeznif4",
19+
"title": "Work",
20+
"agentName": "default",
21+
"startedAt": "2026-01-21T22:29:43.316Z",
22+
"events": [
23+
{
24+
"ts": 1769034583318,
25+
"type": "decision",
26+
"content": "Synced spawn registration by requiring both connected-agents.json and agents.json freshness before sending spawn tasks: Synced spawn registration by requiring both connected-agents.json and agents.json freshness before sending spawn tasks",
27+
"raw": {
28+
"question": "Synced spawn registration by requiring both connected-agents.json and agents.json freshness before sending spawn tasks",
29+
"chosen": "Synced spawn registration by requiring both connected-agents.json and agents.json freshness before sending spawn tasks",
30+
"alternatives": [],
31+
"reasoning": "Avoids mismatched online checks and prevents sending before registry is ready"
32+
},
33+
"significance": "high"
34+
},
35+
{
36+
"ts": 1769034594756,
37+
"type": "decision",
38+
"content": "Pass spawning callbacks to orchestrator and CLI spawners to keep router queueing aligned: Pass spawning callbacks to orchestrator and CLI spawners to keep router queueing aligned",
39+
"raw": {
40+
"question": "Pass spawning callbacks to orchestrator and CLI spawners to keep router queueing aligned",
41+
"chosen": "Pass spawning callbacks to orchestrator and CLI spawners to keep router queueing aligned",
42+
"alternatives": [],
43+
"reasoning": "Ensures messages to spawning agents are queued consistently across entrypoints"
44+
},
45+
"significance": "high"
46+
}
47+
],
48+
"endedAt": "2026-01-21T22:30:11.795Z"
49+
}
50+
],
51+
"commits": [],
52+
"filesChanged": [],
53+
"projectId": "/data/repos/relay",
54+
"tags": [],
55+
"completedAt": "2026-01-21T22:30:11.795Z",
56+
"retrospective": {
57+
"summary": "Hardened spawn registration checks against registry timing gaps and added freshness tests",
58+
"approach": "Standard approach",
59+
"confidence": 0.78
60+
}
61+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Trajectory: Fix spawn timing race condition
2+
3+
> **Status:** ✅ Completed
4+
> **Confidence:** 78%
5+
> **Started:** January 21, 2026 at 10:10 PM
6+
> **Completed:** January 21, 2026 at 10:30 PM
7+
8+
---
9+
10+
## Summary
11+
12+
Hardened spawn registration checks against registry timing gaps and added freshness tests
13+
14+
**Approach:** Standard approach
15+
16+
---
17+
18+
## Key Decisions
19+
20+
### Synced spawn registration by requiring both connected-agents.json and agents.json freshness before sending spawn tasks
21+
- **Chose:** Synced spawn registration by requiring both connected-agents.json and agents.json freshness before sending spawn tasks
22+
- **Reasoning:** Avoids mismatched online checks and prevents sending before registry is ready
23+
24+
### Pass spawning callbacks to orchestrator and CLI spawners to keep router queueing aligned
25+
- **Chose:** Pass spawning callbacks to orchestrator and CLI spawners to keep router queueing aligned
26+
- **Reasoning:** Ensures messages to spawning agents are queued consistently across entrypoints
27+
28+
---
29+
30+
## Chapters
31+
32+
### 1. Work
33+
*Agent: default*
34+
35+
- Synced spawn registration by requiring both connected-agents.json and agents.json freshness before sending spawn tasks: Synced spawn registration by requiring both connected-agents.json and agents.json freshness before sending spawn tasks
36+
- Pass spawning callbacks to orchestrator and CLI spawners to keep router queueing aligned: Pass spawning callbacks to orchestrator and CLI spawners to keep router queueing aligned

.trajectories/index.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"version": 1,
3-
"lastUpdated": "2026-01-21T19:08:36.280Z",
3+
"lastUpdated": "2026-01-21T22:30:12.356Z",
44
"trajectories": {
55
"traj_ozd98si6a7ns": {
66
"title": "Fix thinking indicator showing on all messages",
@@ -876,6 +876,13 @@
876876
"startedAt": "2026-01-21T17:36:44.996Z",
877877
"completedAt": "2026-01-21T19:08:35.954Z",
878878
"path": "/data/repos/relay/.trajectories/completed/2026-01/traj_mvqthi1oitfo.json"
879+
},
880+
"traj_jl5pp8rdu8up": {
881+
"title": "Fix spawn timing race condition",
882+
"status": "completed",
883+
"startedAt": "2026-01-21T22:10:45.793Z",
884+
"completedAt": "2026-01-21T22:30:11.795Z",
885+
"path": "/data/repos/relay/.trajectories/completed/2026-01/traj_jl5pp8rdu8up.json"
879886
}
880887
}
881888
}

src/bridge/spawner.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,62 @@ describe('AgentSpawner', () => {
349349
const output = spawner.getWorkerOutput('Unknown');
350350
expect(output).toBeNull();
351351
});
352+
353+
describe('isAgentRegistered', () => {
354+
it('returns true when connected and registry are fresh', () => {
355+
const now = new Date().toISOString();
356+
readFileSyncMock.mockImplementation((filePath: string) => {
357+
if (filePath.includes('connected-agents.json')) {
358+
return JSON.stringify({ agents: ['Dev1'], users: [], updatedAt: Date.now() });
359+
}
360+
if (filePath.includes('agents.json')) {
361+
return JSON.stringify({ agents: [{ name: 'Dev1', lastSeen: now }] });
362+
}
363+
return '';
364+
});
365+
366+
const spawner = new AgentSpawner(projectRoot);
367+
const registered = (spawner as any).isAgentRegistered('Dev1');
368+
369+
expect(registered).toBe(true);
370+
});
371+
372+
it('returns false when connected list is stale', () => {
373+
const now = new Date().toISOString();
374+
readFileSyncMock.mockImplementation((filePath: string) => {
375+
if (filePath.includes('connected-agents.json')) {
376+
return JSON.stringify({ agents: ['Dev1'], users: [], updatedAt: Date.now() - 60_000 });
377+
}
378+
if (filePath.includes('agents.json')) {
379+
return JSON.stringify({ agents: [{ name: 'Dev1', lastSeen: now }] });
380+
}
381+
return '';
382+
});
383+
384+
const spawner = new AgentSpawner(projectRoot);
385+
const registered = (spawner as any).isAgentRegistered('Dev1');
386+
387+
expect(registered).toBe(false);
388+
});
389+
390+
it('returns false when registry is stale', () => {
391+
const old = new Date(Date.now() - 60_000).toISOString();
392+
readFileSyncMock.mockImplementation((filePath: string) => {
393+
if (filePath.includes('connected-agents.json')) {
394+
return JSON.stringify({ agents: ['Dev1'], users: [], updatedAt: Date.now() });
395+
}
396+
if (filePath.includes('agents.json')) {
397+
return JSON.stringify({ agents: [{ name: 'Dev1', lastSeen: old }] });
398+
}
399+
return '';
400+
});
401+
402+
const spawner = new AgentSpawner(projectRoot);
403+
const registered = (spawner as any).isAgentRegistered('Dev1');
404+
405+
expect(registered).toBe(false);
406+
});
407+
});
352408
});
353409

354410
describe('readWorkersMetadata', () => {

src/bridge/spawner.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,10 @@ export interface AgentSpawnerOptions {
228228
}
229229

230230
export class AgentSpawner {
231+
private static readonly ONLINE_THRESHOLD_MS = 30_000;
231232
private activeWorkers: Map<string, ActiveWorker> = new Map();
232233
private agentsPath: string;
234+
private registryPath: string;
233235
private projectRoot: string;
234236
private socketPath?: string;
235237
private logsDir: string;
@@ -255,6 +257,7 @@ export class AgentSpawner {
255257
// Use connected-agents.json (live socket connections) instead of agents.json (historical registry)
256258
// This ensures spawned agents have actual daemon connections for channel message delivery
257259
this.agentsPath = path.join(paths.teamDir, 'connected-agents.json');
260+
this.registryPath = path.join(paths.teamDir, 'agents.json');
258261
this.socketPath = paths.socketPath;
259262
this.logsDir = path.join(paths.teamDir, 'worker-logs');
260263
this.workersPath = path.join(paths.teamDir, 'workers.json');
@@ -1055,7 +1058,7 @@ export class AgentSpawner {
10551058
}
10561059

10571060
/**
1058-
* Wait for an agent to appear in the registry (agents.json)
1061+
* Wait for an agent to appear in the connected list and registry (connected-agents.json + agents.json).
10591062
*/
10601063
private async waitForAgentRegistration(
10611064
name: string,
@@ -1076,6 +1079,10 @@ export class AgentSpawner {
10761079
}
10771080

10781081
private isAgentRegistered(name: string): boolean {
1082+
return this.isAgentConnected(name) && this.isAgentRecentlySeen(name);
1083+
}
1084+
1085+
private isAgentConnected(name: string): boolean {
10791086
if (!this.agentsPath) return false;
10801087
if (!fs.existsSync(this.agentsPath)) return false;
10811088

@@ -1084,6 +1091,10 @@ export class AgentSpawner {
10841091
// connected-agents.json format: { agents: string[], users: string[], updatedAt: number }
10851092
// agents is a string array of connected agent names (not objects)
10861093
const agents: string[] = Array.isArray(raw?.agents) ? raw.agents : [];
1094+
const updatedAt = typeof raw?.updatedAt === 'number' ? raw.updatedAt : 0;
1095+
const isFresh = Date.now() - updatedAt <= AgentSpawner.ONLINE_THRESHOLD_MS;
1096+
1097+
if (!isFresh) return false;
10871098

10881099
// Case-insensitive check to match router behavior
10891100
const lowerName = name.toLowerCase();
@@ -1094,6 +1105,27 @@ export class AgentSpawner {
10941105
}
10951106
}
10961107

1108+
private isAgentRecentlySeen(name: string): boolean {
1109+
if (!this.registryPath) return false;
1110+
if (!fs.existsSync(this.registryPath)) return false;
1111+
1112+
try {
1113+
const raw = JSON.parse(fs.readFileSync(this.registryPath, 'utf-8'));
1114+
const agents = Array.isArray(raw?.agents)
1115+
? raw.agents
1116+
: typeof raw?.agents === 'object' && raw?.agents !== null
1117+
? Object.values(raw.agents)
1118+
: [];
1119+
const lowerName = name.toLowerCase();
1120+
const agent = agents.find((entry: { name?: string; lastSeen?: string }) => typeof entry?.name === 'string' && entry.name.toLowerCase() === lowerName);
1121+
if (!agent?.lastSeen) return false;
1122+
return Date.now() - new Date(agent.lastSeen).getTime() <= AgentSpawner.ONLINE_THRESHOLD_MS;
1123+
} catch (err: any) {
1124+
console.error('[spawner] Failed to read agents.json:', err.message);
1125+
return false;
1126+
}
1127+
}
1128+
10971129
/**
10981130
* Save workers metadata to disk for CLI access
10991131
*/

src/cli/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,12 @@ program
457457
console.log('');
458458
console.log('Auto-spawning agents from teams.json...');
459459

460-
spawner = new AgentSpawner(paths.projectRoot, undefined, dashboardPort);
460+
spawner = new AgentSpawner({
461+
projectRoot: paths.projectRoot,
462+
dashboardPort,
463+
onMarkSpawning: (name) => daemon.markSpawning(name),
464+
onClearSpawning: (name) => daemon.clearSpawning(name),
465+
});
461466

462467
for (const agent of teamsConfig.agents) {
463468
console.log(` Spawning ${agent.name} (${agent.cli})...`);

src/daemon/orchestrator.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,11 @@ export class Orchestrator extends EventEmitter {
343343

344344
// Ensure spawner exists
345345
if (!workspace.spawner) {
346-
workspace.spawner = new AgentSpawner(workspace.path);
346+
workspace.spawner = new AgentSpawner({
347+
projectRoot: workspace.path,
348+
onMarkSpawning: (name) => workspace.daemon?.markSpawning(name),
349+
onClearSpawning: (name) => workspace.daemon?.clearSpawning(name),
350+
});
347351
}
348352

349353
const result = await workspace.spawner.spawn({
@@ -445,7 +449,11 @@ export class Orchestrator extends EventEmitter {
445449
workspace.status = 'active';
446450

447451
// Create spawner
448-
workspace.spawner = new AgentSpawner(workspace.path);
452+
workspace.spawner = new AgentSpawner({
453+
projectRoot: workspace.path,
454+
onMarkSpawning: (name) => workspace.daemon?.markSpawning(name),
455+
onClearSpawning: (name) => workspace.daemon?.clearSpawning(name),
456+
});
449457

450458
// Set up agent death notifications
451459
workspace.spawner.setOnAgentDeath((info) => {

0 commit comments

Comments
 (0)