claude-comms 1.0.35 → 1.0.37
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/.agents/AGENTS.md +6 -6
- package/.agents/repo.md +10 -2
- package/.agents/tools/agent-job/README.md +208 -0
- package/.agents/tools/agent-job/agent_job.py +857 -0
- package/.agents/tools/agent-job/agent_monitor.py +575 -0
- package/{.claude/agents/core/consultant.md → .agents/tools/agent-job/consultant_template.txt} +14 -72
- package/.claude/agents/core/consultants/codex.md +39 -0
- package/.claude/agents/core/consultants/gemini.md +39 -0
- package/.claude/agents/core/designer.md +1 -1
- package/.claude/agents/core/engineer.md +1 -1
- package/.claude/agents/core/uat.md +1 -1
- package/.claude/commands/analyse-document.md +1 -1
- package/.claude/commands/cook.md +8 -10
- package/.claude/commands/install-feedback-widget.md +74 -42
- package/.claude/commands/learn.md +1 -1
- package/CLAUDE.md +9 -11
- package/package.json +1 -1
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""
|
|
7
|
+
Agent Job Adapter - Turns verbose CLI agent runs into file-backed jobs.
|
|
8
|
+
|
|
9
|
+
Supports Codex and Gemini CLI harnesses with:
|
|
10
|
+
- Stable job IDs (harness-native)
|
|
11
|
+
- File-backed logs and status
|
|
12
|
+
- Idle timeout enforcement
|
|
13
|
+
- Simple CLI API: spawn, status, list
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
uv run agent_job.py spawn --harness <gemini|codex> -- "<assignment>"
|
|
17
|
+
uv run agent_job.py status <job_id>
|
|
18
|
+
uv run agent_job.py list
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field, asdict
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# =============================================================================
|
|
36
|
+
# Configuration
|
|
37
|
+
# =============================================================================
|
|
38
|
+
|
|
39
|
+
IDLE_TIMEOUT_SEC = int(os.environ.get("IDLE_TIMEOUT_SEC", "300"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_jobs_root() -> Path:
|
|
43
|
+
"""Get the jobs root directory from environment or default."""
|
|
44
|
+
if root := os.environ.get("AGENT_JOBS_ROOT"):
|
|
45
|
+
return Path(root)
|
|
46
|
+
|
|
47
|
+
tmpdir = os.environ.get("TMPDIR", "/tmp")
|
|
48
|
+
user = os.environ.get("USER", "unknown")
|
|
49
|
+
return Path(tmpdir) / "agent_jobs" / user
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_consultant_template() -> str:
|
|
53
|
+
"""Load the consultant template from the template file."""
|
|
54
|
+
template_path = Path(__file__).parent / "consultant_template.txt"
|
|
55
|
+
if template_path.exists():
|
|
56
|
+
return template_path.read_text()
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# =============================================================================
|
|
61
|
+
# Data Models
|
|
62
|
+
# =============================================================================
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class TokenStats:
|
|
66
|
+
input: Optional[int] = None
|
|
67
|
+
output: Optional[int] = None
|
|
68
|
+
total: Optional[int] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Completion:
|
|
73
|
+
messages: list[str] = field(default_factory=list)
|
|
74
|
+
final_message: Optional[str] = None
|
|
75
|
+
tokens: TokenStats = field(default_factory=TokenStats)
|
|
76
|
+
duration_ms: Optional[int] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class JobStatus:
|
|
81
|
+
job_id: str
|
|
82
|
+
harness: str
|
|
83
|
+
agent_id: Optional[str] = None
|
|
84
|
+
pid: Optional[int] = None
|
|
85
|
+
logs: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
status: str = "running" # running | complete | error | timeout
|
|
88
|
+
status_reason: Optional[str] = None # current activity or error reason
|
|
89
|
+
|
|
90
|
+
start_time: Optional[str] = None
|
|
91
|
+
last_event_time: Optional[str] = None
|
|
92
|
+
end_time: Optional[str] = None
|
|
93
|
+
|
|
94
|
+
operations: int = 0
|
|
95
|
+
|
|
96
|
+
completion: Completion = field(default_factory=Completion)
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict:
|
|
99
|
+
"""Convert to dictionary for JSON serialization."""
|
|
100
|
+
return {
|
|
101
|
+
"job_id": self.job_id,
|
|
102
|
+
"harness": self.harness,
|
|
103
|
+
"agent_id": self.agent_id,
|
|
104
|
+
"pid": self.pid,
|
|
105
|
+
"logs": self.logs,
|
|
106
|
+
"status": self.status,
|
|
107
|
+
"status_reason": self.status_reason,
|
|
108
|
+
"start_time": self.start_time,
|
|
109
|
+
"last_event_time": self.last_event_time,
|
|
110
|
+
"end_time": self.end_time,
|
|
111
|
+
"operations": self.operations,
|
|
112
|
+
"completion": {
|
|
113
|
+
"messages": self.completion.messages,
|
|
114
|
+
"final_message": self.completion.final_message,
|
|
115
|
+
"tokens": {
|
|
116
|
+
"input": self.completion.tokens.input,
|
|
117
|
+
"output": self.completion.tokens.output,
|
|
118
|
+
"total": self.completion.tokens.total,
|
|
119
|
+
},
|
|
120
|
+
"duration_ms": self.completion.duration_ms,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_dict(cls, data: dict) -> "JobStatus":
|
|
126
|
+
"""Create from dictionary."""
|
|
127
|
+
completion_data = data.get("completion", {})
|
|
128
|
+
tokens_data = completion_data.get("tokens", {})
|
|
129
|
+
|
|
130
|
+
return cls(
|
|
131
|
+
job_id=data["job_id"],
|
|
132
|
+
harness=data["harness"],
|
|
133
|
+
agent_id=data.get("agent_id"),
|
|
134
|
+
pid=data.get("pid"),
|
|
135
|
+
logs=data.get("logs"),
|
|
136
|
+
status=data.get("status", "running"),
|
|
137
|
+
status_reason=data.get("status_reason"),
|
|
138
|
+
start_time=data.get("start_time"),
|
|
139
|
+
last_event_time=data.get("last_event_time"),
|
|
140
|
+
end_time=data.get("end_time"),
|
|
141
|
+
operations=data.get("operations", 0),
|
|
142
|
+
completion=Completion(
|
|
143
|
+
messages=completion_data.get("messages", []),
|
|
144
|
+
final_message=completion_data.get("final_message"),
|
|
145
|
+
tokens=TokenStats(
|
|
146
|
+
input=tokens_data.get("input"),
|
|
147
|
+
output=tokens_data.get("output"),
|
|
148
|
+
total=tokens_data.get("total"),
|
|
149
|
+
),
|
|
150
|
+
duration_ms=completion_data.get("duration_ms"),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# =============================================================================
|
|
156
|
+
# Utilities
|
|
157
|
+
# =============================================================================
|
|
158
|
+
|
|
159
|
+
def utc_now_iso() -> str:
|
|
160
|
+
"""Get current UTC time in ISO format."""
|
|
161
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_iso_time(iso_str: str) -> datetime:
|
|
165
|
+
"""Parse ISO time string to datetime."""
|
|
166
|
+
# Handle 'Z' suffix
|
|
167
|
+
if iso_str.endswith("Z"):
|
|
168
|
+
iso_str = iso_str[:-1] + "+00:00"
|
|
169
|
+
return datetime.fromisoformat(iso_str)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def atomic_write_json(path: Path, data: dict) -> None:
|
|
173
|
+
"""Write JSON atomically using temp file and rename."""
|
|
174
|
+
tmp_path = path.with_suffix(".tmp")
|
|
175
|
+
with open(tmp_path, "w") as f:
|
|
176
|
+
json.dump(data, f, indent=2)
|
|
177
|
+
f.flush()
|
|
178
|
+
os.fsync(f.fileno())
|
|
179
|
+
tmp_path.rename(path)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def try_parse_json(line: str) -> Optional[dict]:
|
|
183
|
+
"""Try to parse a line as JSON, return None if not valid JSON."""
|
|
184
|
+
line = line.strip()
|
|
185
|
+
if not line:
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
return json.loads(line)
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# =============================================================================
|
|
194
|
+
# Harness Event Handlers
|
|
195
|
+
# =============================================================================
|
|
196
|
+
|
|
197
|
+
class CodexEventHandler:
|
|
198
|
+
"""Handle Codex JSON stream events."""
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def extract_job_id(event: dict) -> Optional[str]:
|
|
202
|
+
"""Extract job ID from initial event."""
|
|
203
|
+
if event.get("type") == "thread.started":
|
|
204
|
+
return event.get("thread_id")
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def process_event(event: dict, status: JobStatus) -> None:
|
|
209
|
+
"""Process a Codex event and update status."""
|
|
210
|
+
event_type = event.get("type", "")
|
|
211
|
+
|
|
212
|
+
# Update status_reason based on current activity
|
|
213
|
+
if event_type == "turn.started":
|
|
214
|
+
status.status_reason = "thinking"
|
|
215
|
+
|
|
216
|
+
elif event_type == "item.started":
|
|
217
|
+
item = event.get("item", {})
|
|
218
|
+
item_type = item.get("type")
|
|
219
|
+
if item_type:
|
|
220
|
+
status.status_reason = item_type # reasoning, command_execution, todo_list
|
|
221
|
+
|
|
222
|
+
elif event_type == "item.completed":
|
|
223
|
+
item = event.get("item", {})
|
|
224
|
+
item_type = item.get("type")
|
|
225
|
+
if item_type:
|
|
226
|
+
status.status_reason = item_type
|
|
227
|
+
|
|
228
|
+
# Capture agent_message text
|
|
229
|
+
if item_type == "agent_message":
|
|
230
|
+
text = item.get("text", "")
|
|
231
|
+
if text:
|
|
232
|
+
status.completion.messages.append(text)
|
|
233
|
+
status.completion.final_message = text
|
|
234
|
+
|
|
235
|
+
# Handle turn completion with usage stats
|
|
236
|
+
elif event_type == "turn.completed":
|
|
237
|
+
status.status_reason = "completed"
|
|
238
|
+
usage = event.get("usage", {})
|
|
239
|
+
if usage:
|
|
240
|
+
status.completion.tokens.input = usage.get("input_tokens")
|
|
241
|
+
status.completion.tokens.output = usage.get("output_tokens")
|
|
242
|
+
if status.completion.tokens.input and status.completion.tokens.output:
|
|
243
|
+
status.completion.tokens.total = (
|
|
244
|
+
status.completion.tokens.input + status.completion.tokens.output
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def is_completion_event(event: dict) -> bool:
|
|
249
|
+
"""Check if this event indicates completion."""
|
|
250
|
+
return event.get("type") == "turn.completed"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class GeminiEventHandler:
|
|
254
|
+
"""Handle Gemini CLI JSON stream events."""
|
|
255
|
+
|
|
256
|
+
def __init__(self):
|
|
257
|
+
self._assistant_buffer = ""
|
|
258
|
+
|
|
259
|
+
def extract_job_id(self, event: dict) -> Optional[str]:
|
|
260
|
+
"""Extract job ID from initial event."""
|
|
261
|
+
if event.get("type") == "init":
|
|
262
|
+
return event.get("session_id")
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def process_event(self, event: dict, status: JobStatus) -> None:
|
|
266
|
+
"""Process a Gemini event and update status."""
|
|
267
|
+
event_type = event.get("type", "")
|
|
268
|
+
|
|
269
|
+
# Update status_reason based on current activity
|
|
270
|
+
if event_type == "tool_use":
|
|
271
|
+
tool_name = event.get("tool_name")
|
|
272
|
+
if tool_name:
|
|
273
|
+
status.status_reason = tool_name # run_shell_command, write_file, read_file, write_todos
|
|
274
|
+
|
|
275
|
+
# Handle assistant messages (accumulate deltas)
|
|
276
|
+
elif event_type == "message" and event.get("role") == "assistant":
|
|
277
|
+
status.status_reason = "responding"
|
|
278
|
+
content = event.get("content", "")
|
|
279
|
+
if content:
|
|
280
|
+
self._assistant_buffer += content
|
|
281
|
+
|
|
282
|
+
# Handle result with stats
|
|
283
|
+
elif event_type == "result":
|
|
284
|
+
status.status_reason = "completed"
|
|
285
|
+
stats = event.get("stats", {})
|
|
286
|
+
if stats:
|
|
287
|
+
status.completion.tokens.input = stats.get("input_tokens")
|
|
288
|
+
status.completion.tokens.output = stats.get("output_tokens")
|
|
289
|
+
status.completion.tokens.total = stats.get("total_tokens")
|
|
290
|
+
status.completion.duration_ms = stats.get("duration_ms")
|
|
291
|
+
|
|
292
|
+
# Finalize assistant message buffer
|
|
293
|
+
if self._assistant_buffer:
|
|
294
|
+
status.completion.final_message = self._assistant_buffer
|
|
295
|
+
status.completion.messages = [self._assistant_buffer]
|
|
296
|
+
|
|
297
|
+
def is_completion_event(self, event: dict) -> bool:
|
|
298
|
+
"""Check if this event indicates completion."""
|
|
299
|
+
return event.get("type") == "result" and event.get("status") == "success"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# =============================================================================
|
|
303
|
+
# Job Manager
|
|
304
|
+
# =============================================================================
|
|
305
|
+
|
|
306
|
+
class JobManager:
|
|
307
|
+
"""Manage agent job lifecycle."""
|
|
308
|
+
|
|
309
|
+
def __init__(self, jobs_root: Optional[Path] = None):
|
|
310
|
+
self.jobs_root = jobs_root or get_jobs_root()
|
|
311
|
+
self.jobs_root.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
|
|
313
|
+
def get_job_dir(self, job_id: str) -> Path:
|
|
314
|
+
"""Get the directory for a job."""
|
|
315
|
+
return self.jobs_root / job_id
|
|
316
|
+
|
|
317
|
+
def get_status_path(self, job_id: str) -> Path:
|
|
318
|
+
"""Get the status.json path for a job."""
|
|
319
|
+
return self.get_job_dir(job_id) / "status.json"
|
|
320
|
+
|
|
321
|
+
def get_log_path(self, job_id: str) -> Path:
|
|
322
|
+
"""Get the agent.log path for a job."""
|
|
323
|
+
return self.get_job_dir(job_id) / "agent.log"
|
|
324
|
+
|
|
325
|
+
def create_job_dir(self, job_id: str) -> Path:
|
|
326
|
+
"""Create job directory structure."""
|
|
327
|
+
job_dir = self.get_job_dir(job_id)
|
|
328
|
+
job_dir.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
return job_dir
|
|
330
|
+
|
|
331
|
+
def write_status(self, status: JobStatus) -> None:
|
|
332
|
+
"""Write status.json atomically."""
|
|
333
|
+
status_path = self.get_status_path(status.job_id)
|
|
334
|
+
atomic_write_json(status_path, status.to_dict())
|
|
335
|
+
|
|
336
|
+
def read_status(self, job_id: str) -> Optional[JobStatus]:
|
|
337
|
+
"""Read status.json for a job."""
|
|
338
|
+
status_path = self.get_status_path(job_id)
|
|
339
|
+
if not status_path.exists():
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
with open(status_path) as f:
|
|
343
|
+
data = json.load(f)
|
|
344
|
+
return JobStatus.from_dict(data)
|
|
345
|
+
|
|
346
|
+
def list_jobs(self) -> list[dict]:
|
|
347
|
+
"""List all jobs with summary info."""
|
|
348
|
+
jobs = []
|
|
349
|
+
|
|
350
|
+
if not self.jobs_root.exists():
|
|
351
|
+
return jobs
|
|
352
|
+
|
|
353
|
+
for job_dir in self.jobs_root.iterdir():
|
|
354
|
+
if not job_dir.is_dir():
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
status = self.read_status(job_dir.name)
|
|
358
|
+
if not status:
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
# Compute runtime and idle time
|
|
362
|
+
now = datetime.now(timezone.utc)
|
|
363
|
+
runtime_sec = 0.0
|
|
364
|
+
idle_sec = 0.0
|
|
365
|
+
if status.start_time:
|
|
366
|
+
start = parse_iso_time(status.start_time)
|
|
367
|
+
if status.end_time:
|
|
368
|
+
end = parse_iso_time(status.end_time)
|
|
369
|
+
else:
|
|
370
|
+
end = now
|
|
371
|
+
runtime_sec = (end - start).total_seconds()
|
|
372
|
+
if status.last_event_time:
|
|
373
|
+
last = parse_iso_time(status.last_event_time)
|
|
374
|
+
idle_sec = (now - last).total_seconds()
|
|
375
|
+
|
|
376
|
+
job_data = {
|
|
377
|
+
"job_id": status.job_id,
|
|
378
|
+
"harness": status.harness,
|
|
379
|
+
"pid": status.pid,
|
|
380
|
+
"logs": status.logs,
|
|
381
|
+
"status": status.status,
|
|
382
|
+
"status_reason": status.status_reason,
|
|
383
|
+
"start_time": status.start_time,
|
|
384
|
+
"last_event_time": status.last_event_time,
|
|
385
|
+
"runtime_sec": round(runtime_sec, 1),
|
|
386
|
+
"operations": status.operations,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# Only include idle_sec for running jobs
|
|
390
|
+
if status.status == "running":
|
|
391
|
+
job_data["idle_sec"] = round(idle_sec, 1)
|
|
392
|
+
|
|
393
|
+
# Include end_time for finished jobs
|
|
394
|
+
if status.end_time:
|
|
395
|
+
job_data["end_time"] = status.end_time
|
|
396
|
+
|
|
397
|
+
jobs.append(job_data)
|
|
398
|
+
|
|
399
|
+
# Sort by start_time (newest first)
|
|
400
|
+
jobs.sort(key=lambda x: x.get("start_time") or "", reverse=True)
|
|
401
|
+
return jobs
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# =============================================================================
|
|
405
|
+
# Spawn Command
|
|
406
|
+
# =============================================================================
|
|
407
|
+
|
|
408
|
+
def build_harness_command(harness: str, assignment: str) -> list[str]:
|
|
409
|
+
"""Build the command to run the harness."""
|
|
410
|
+
if harness == "gemini":
|
|
411
|
+
return [
|
|
412
|
+
"gemini",
|
|
413
|
+
"--yolo",
|
|
414
|
+
"-m", "gemini-3-pro-preview",
|
|
415
|
+
"--output-format", "stream-json",
|
|
416
|
+
assignment,
|
|
417
|
+
]
|
|
418
|
+
elif harness == "codex":
|
|
419
|
+
return [
|
|
420
|
+
"codex",
|
|
421
|
+
"--yolo",
|
|
422
|
+
"e",
|
|
423
|
+
assignment,
|
|
424
|
+
"--json",
|
|
425
|
+
]
|
|
426
|
+
else:
|
|
427
|
+
raise ValueError(f"Unknown harness: {harness}")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _run_monitor_loop(
|
|
431
|
+
process: subprocess.Popen,
|
|
432
|
+
handler,
|
|
433
|
+
manager: JobManager,
|
|
434
|
+
harness: str,
|
|
435
|
+
job_id: str,
|
|
436
|
+
status: JobStatus,
|
|
437
|
+
log_file,
|
|
438
|
+
initial_lines: list[str],
|
|
439
|
+
) -> None:
|
|
440
|
+
"""
|
|
441
|
+
Background monitor loop - runs in forked child process.
|
|
442
|
+
|
|
443
|
+
Continues reading harness output, updating status.json, and enforcing idle timeout.
|
|
444
|
+
"""
|
|
445
|
+
seen_completion = False
|
|
446
|
+
last_event_time = time.time()
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
# First, process any lines we already buffered before forking
|
|
450
|
+
for line in initial_lines:
|
|
451
|
+
event = try_parse_json(line)
|
|
452
|
+
if event:
|
|
453
|
+
last_event_time = time.time()
|
|
454
|
+
status.operations += 1
|
|
455
|
+
status.last_event_time = utc_now_iso()
|
|
456
|
+
handler.process_event(event, status)
|
|
457
|
+
if handler.is_completion_event(event):
|
|
458
|
+
seen_completion = True
|
|
459
|
+
manager.write_status(status)
|
|
460
|
+
|
|
461
|
+
if log_file:
|
|
462
|
+
log_file.write(line)
|
|
463
|
+
log_file.flush()
|
|
464
|
+
|
|
465
|
+
# Continue reading from harness
|
|
466
|
+
for line in iter(process.stdout.readline, ""):
|
|
467
|
+
if not line:
|
|
468
|
+
if process.poll() is not None:
|
|
469
|
+
break
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
current_time = time.time()
|
|
473
|
+
|
|
474
|
+
# Check idle timeout
|
|
475
|
+
if (current_time - last_event_time) > IDLE_TIMEOUT_SEC:
|
|
476
|
+
process.terminate()
|
|
477
|
+
try:
|
|
478
|
+
process.wait(timeout=5)
|
|
479
|
+
except subprocess.TimeoutExpired:
|
|
480
|
+
process.kill()
|
|
481
|
+
|
|
482
|
+
status.status = "timeout"
|
|
483
|
+
status.status_reason = "idle_timeout"
|
|
484
|
+
status.end_time = utc_now_iso()
|
|
485
|
+
manager.write_status(status)
|
|
486
|
+
|
|
487
|
+
if log_file:
|
|
488
|
+
log_file.close()
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
# Try to parse as JSON
|
|
492
|
+
event = try_parse_json(line)
|
|
493
|
+
|
|
494
|
+
if event:
|
|
495
|
+
last_event_time = current_time
|
|
496
|
+
status.operations += 1
|
|
497
|
+
status.last_event_time = utc_now_iso()
|
|
498
|
+
|
|
499
|
+
handler.process_event(event, status)
|
|
500
|
+
|
|
501
|
+
if handler.is_completion_event(event):
|
|
502
|
+
seen_completion = True
|
|
503
|
+
|
|
504
|
+
manager.write_status(status)
|
|
505
|
+
|
|
506
|
+
# Write to log file
|
|
507
|
+
if log_file:
|
|
508
|
+
log_file.write(line)
|
|
509
|
+
log_file.flush()
|
|
510
|
+
|
|
511
|
+
# Wait for process to complete
|
|
512
|
+
exit_code = process.wait()
|
|
513
|
+
|
|
514
|
+
# Finalize status
|
|
515
|
+
status.end_time = utc_now_iso()
|
|
516
|
+
|
|
517
|
+
if exit_code == 0 and seen_completion:
|
|
518
|
+
status.status = "complete"
|
|
519
|
+
else:
|
|
520
|
+
status.status = "error"
|
|
521
|
+
status.status_reason = f"process_exit_{exit_code}"
|
|
522
|
+
|
|
523
|
+
manager.write_status(status)
|
|
524
|
+
|
|
525
|
+
except Exception as e:
|
|
526
|
+
if process.poll() is None:
|
|
527
|
+
process.terminate()
|
|
528
|
+
|
|
529
|
+
status.status = "error"
|
|
530
|
+
status.status_reason = str(e)
|
|
531
|
+
status.end_time = utc_now_iso()
|
|
532
|
+
manager.write_status(status)
|
|
533
|
+
|
|
534
|
+
finally:
|
|
535
|
+
if log_file:
|
|
536
|
+
log_file.close()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def spawn_job(harness: str, assignment: str) -> dict:
|
|
540
|
+
"""
|
|
541
|
+
Spawn a new agent job.
|
|
542
|
+
|
|
543
|
+
Starts the harness, waits for job ID, then forks to background.
|
|
544
|
+
Parent returns immediately with job info; child continues monitoring.
|
|
545
|
+
|
|
546
|
+
Returns dict with job_id, status_path, log_path on success.
|
|
547
|
+
"""
|
|
548
|
+
manager = JobManager()
|
|
549
|
+
|
|
550
|
+
# Build and start harness command
|
|
551
|
+
cmd = build_harness_command(harness, assignment)
|
|
552
|
+
|
|
553
|
+
process = subprocess.Popen(
|
|
554
|
+
cmd,
|
|
555
|
+
stdin=subprocess.DEVNULL, # Detach from TTY to survive fork
|
|
556
|
+
stdout=subprocess.PIPE,
|
|
557
|
+
stderr=subprocess.STDOUT,
|
|
558
|
+
text=True,
|
|
559
|
+
bufsize=1, # Line buffered
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
# Create appropriate event handler
|
|
563
|
+
if harness == "gemini":
|
|
564
|
+
handler = GeminiEventHandler()
|
|
565
|
+
else:
|
|
566
|
+
handler = CodexEventHandler()
|
|
567
|
+
|
|
568
|
+
job_id: Optional[str] = None
|
|
569
|
+
status: Optional[JobStatus] = None
|
|
570
|
+
log_file = None
|
|
571
|
+
buffered_lines: list[str] = [] # Lines read after job_id but before fork
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
# Read stdout until we get the job ID
|
|
575
|
+
for line in iter(process.stdout.readline, ""):
|
|
576
|
+
if not line:
|
|
577
|
+
if process.poll() is not None:
|
|
578
|
+
break
|
|
579
|
+
continue
|
|
580
|
+
|
|
581
|
+
# Try to parse as JSON
|
|
582
|
+
event = try_parse_json(line)
|
|
583
|
+
|
|
584
|
+
if event and not job_id:
|
|
585
|
+
job_id = handler.extract_job_id(event)
|
|
586
|
+
|
|
587
|
+
if job_id:
|
|
588
|
+
# Initialize job
|
|
589
|
+
manager.create_job_dir(job_id)
|
|
590
|
+
log_file = open(manager.get_log_path(job_id), "w")
|
|
591
|
+
|
|
592
|
+
# Write the init line to log
|
|
593
|
+
log_file.write(line)
|
|
594
|
+
log_file.flush()
|
|
595
|
+
|
|
596
|
+
status = JobStatus(
|
|
597
|
+
job_id=job_id,
|
|
598
|
+
harness=harness,
|
|
599
|
+
agent_id=job_id,
|
|
600
|
+
pid=process.pid,
|
|
601
|
+
logs=str(manager.get_log_path(job_id)),
|
|
602
|
+
status="running",
|
|
603
|
+
status_reason="initializing",
|
|
604
|
+
start_time=utc_now_iso(),
|
|
605
|
+
last_event_time=utc_now_iso(),
|
|
606
|
+
operations=1, # Count the init event
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# Write initial status
|
|
610
|
+
manager.write_status(status)
|
|
611
|
+
|
|
612
|
+
# Build result for parent to return
|
|
613
|
+
result = {
|
|
614
|
+
"job_id": job_id,
|
|
615
|
+
"status_path": str(manager.get_status_path(job_id)),
|
|
616
|
+
"log_path": str(manager.get_log_path(job_id)),
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
# Fork: parent returns, child continues monitoring
|
|
620
|
+
pid = os.fork()
|
|
621
|
+
|
|
622
|
+
if pid > 0:
|
|
623
|
+
# Parent process - return immediately
|
|
624
|
+
# Don't close log_file or process handles here;
|
|
625
|
+
# the child owns them now
|
|
626
|
+
return result
|
|
627
|
+
else:
|
|
628
|
+
# Child process - detach and continue monitoring
|
|
629
|
+
# Create new session to detach from terminal
|
|
630
|
+
os.setsid()
|
|
631
|
+
|
|
632
|
+
# Redirect stdin/stdout/stderr to /dev/null
|
|
633
|
+
# (we communicate via status.json now)
|
|
634
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
635
|
+
os.dup2(devnull, sys.stdin.fileno())
|
|
636
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
637
|
+
os.dup2(devnull, sys.stderr.fileno())
|
|
638
|
+
os.close(devnull)
|
|
639
|
+
|
|
640
|
+
# Run the monitor loop
|
|
641
|
+
_run_monitor_loop(
|
|
642
|
+
process=process,
|
|
643
|
+
handler=handler,
|
|
644
|
+
manager=manager,
|
|
645
|
+
harness=harness,
|
|
646
|
+
job_id=job_id,
|
|
647
|
+
status=status,
|
|
648
|
+
log_file=log_file,
|
|
649
|
+
initial_lines=buffered_lines,
|
|
650
|
+
)
|
|
651
|
+
# Exit child process
|
|
652
|
+
os._exit(0)
|
|
653
|
+
|
|
654
|
+
elif job_id:
|
|
655
|
+
# We have a job_id but haven't forked yet - buffer the line
|
|
656
|
+
# (This shouldn't normally happen as we fork immediately after job_id)
|
|
657
|
+
buffered_lines.append(line)
|
|
658
|
+
|
|
659
|
+
# If we get here without a job_id, the harness exited early
|
|
660
|
+
if log_file:
|
|
661
|
+
log_file.close()
|
|
662
|
+
|
|
663
|
+
return {"error": "Failed to extract job ID from harness output"}
|
|
664
|
+
|
|
665
|
+
except Exception as e:
|
|
666
|
+
# Clean up on error
|
|
667
|
+
if process.poll() is None:
|
|
668
|
+
process.terminate()
|
|
669
|
+
if log_file:
|
|
670
|
+
log_file.close()
|
|
671
|
+
|
|
672
|
+
if status:
|
|
673
|
+
status.status = "error"
|
|
674
|
+
status.status_reason = str(e)
|
|
675
|
+
status.end_time = utc_now_iso()
|
|
676
|
+
manager.write_status(status)
|
|
677
|
+
|
|
678
|
+
raise
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# =============================================================================
|
|
682
|
+
# Status Command
|
|
683
|
+
# =============================================================================
|
|
684
|
+
|
|
685
|
+
def get_status(job_id: str) -> dict:
|
|
686
|
+
"""Get status for a job with computed fields."""
|
|
687
|
+
manager = JobManager()
|
|
688
|
+
status = manager.read_status(job_id)
|
|
689
|
+
|
|
690
|
+
if not status:
|
|
691
|
+
return {"error": f"Job not found: {job_id}"}
|
|
692
|
+
|
|
693
|
+
now = datetime.now(timezone.utc)
|
|
694
|
+
|
|
695
|
+
# Compute runtime_sec
|
|
696
|
+
runtime_sec = 0.0
|
|
697
|
+
if status.start_time:
|
|
698
|
+
start = parse_iso_time(status.start_time)
|
|
699
|
+
if status.end_time:
|
|
700
|
+
end = parse_iso_time(status.end_time)
|
|
701
|
+
else:
|
|
702
|
+
end = now
|
|
703
|
+
runtime_sec = (end - start).total_seconds()
|
|
704
|
+
|
|
705
|
+
# Compute idle_sec
|
|
706
|
+
idle_sec = 0.0
|
|
707
|
+
if status.last_event_time:
|
|
708
|
+
last = parse_iso_time(status.last_event_time)
|
|
709
|
+
idle_sec = (now - last).total_seconds()
|
|
710
|
+
|
|
711
|
+
# Build result
|
|
712
|
+
result = {
|
|
713
|
+
"job_id": status.job_id,
|
|
714
|
+
"harness": status.harness,
|
|
715
|
+
"pid": status.pid,
|
|
716
|
+
"logs": status.logs,
|
|
717
|
+
"status": status.status,
|
|
718
|
+
"status_reason": status.status_reason,
|
|
719
|
+
"start_time": status.start_time,
|
|
720
|
+
"last_event_time": status.last_event_time,
|
|
721
|
+
"runtime_sec": round(runtime_sec, 1),
|
|
722
|
+
"operations": status.operations,
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
# Only include idle_sec when job is still running
|
|
726
|
+
if status.status == "running":
|
|
727
|
+
result["idle_sec"] = round(idle_sec, 1)
|
|
728
|
+
|
|
729
|
+
# Only include end_time and completion when job has finished
|
|
730
|
+
if status.end_time:
|
|
731
|
+
result["end_time"] = status.end_time
|
|
732
|
+
result["completion"] = {
|
|
733
|
+
"messages": status.completion.messages,
|
|
734
|
+
"tokens": {
|
|
735
|
+
"input": status.completion.tokens.input,
|
|
736
|
+
"output": status.completion.tokens.output,
|
|
737
|
+
"total": status.completion.tokens.total,
|
|
738
|
+
},
|
|
739
|
+
"duration_ms": status.completion.duration_ms,
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return result
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# =============================================================================
|
|
746
|
+
# List Command
|
|
747
|
+
# =============================================================================
|
|
748
|
+
|
|
749
|
+
def list_jobs() -> list[dict]:
|
|
750
|
+
"""List all jobs."""
|
|
751
|
+
manager = JobManager()
|
|
752
|
+
return manager.list_jobs()
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# =============================================================================
|
|
756
|
+
# CLI
|
|
757
|
+
# =============================================================================
|
|
758
|
+
|
|
759
|
+
def main():
|
|
760
|
+
parser = argparse.ArgumentParser(
|
|
761
|
+
description="Agent Job Adapter - Manage file-backed agent jobs"
|
|
762
|
+
)
|
|
763
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
764
|
+
|
|
765
|
+
# spawn command
|
|
766
|
+
spawn_parser = subparsers.add_parser(
|
|
767
|
+
"spawn",
|
|
768
|
+
help="Spawn a new agent job"
|
|
769
|
+
)
|
|
770
|
+
spawn_parser.add_argument(
|
|
771
|
+
"--harness",
|
|
772
|
+
required=True,
|
|
773
|
+
choices=["gemini", "codex"],
|
|
774
|
+
help="Agent harness to use"
|
|
775
|
+
)
|
|
776
|
+
spawn_parser.add_argument(
|
|
777
|
+
"--consultant",
|
|
778
|
+
action="store_true",
|
|
779
|
+
help="Append consultant template (WORKFLOW + TEAMWORK) to assignment"
|
|
780
|
+
)
|
|
781
|
+
spawn_parser.add_argument(
|
|
782
|
+
"assignment",
|
|
783
|
+
nargs=argparse.REMAINDER,
|
|
784
|
+
help="Assignment text (after --)"
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
# status command
|
|
788
|
+
status_parser = subparsers.add_parser(
|
|
789
|
+
"status",
|
|
790
|
+
help="Get status of a job"
|
|
791
|
+
)
|
|
792
|
+
status_parser.add_argument(
|
|
793
|
+
"job_id",
|
|
794
|
+
help="Job ID to query"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# list command
|
|
798
|
+
subparsers.add_parser(
|
|
799
|
+
"list",
|
|
800
|
+
help="List all jobs"
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
args = parser.parse_args()
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
if args.command == "spawn":
|
|
807
|
+
# Handle assignment parsing (skip leading --)
|
|
808
|
+
assignment_parts = args.assignment
|
|
809
|
+
if assignment_parts and assignment_parts[0] == "--":
|
|
810
|
+
assignment_parts = assignment_parts[1:]
|
|
811
|
+
|
|
812
|
+
if not assignment_parts:
|
|
813
|
+
print(json.dumps({"error": "No assignment provided"}))
|
|
814
|
+
sys.exit(1)
|
|
815
|
+
|
|
816
|
+
assignment = " ".join(assignment_parts)
|
|
817
|
+
|
|
818
|
+
# Append consultant template if --consultant flag is set
|
|
819
|
+
if args.consultant:
|
|
820
|
+
template = get_consultant_template()
|
|
821
|
+
if template:
|
|
822
|
+
assignment = assignment + "\n\n" + template
|
|
823
|
+
|
|
824
|
+
result = spawn_job(args.harness, assignment)
|
|
825
|
+
|
|
826
|
+
if "error" in result:
|
|
827
|
+
print(json.dumps(result))
|
|
828
|
+
sys.exit(1)
|
|
829
|
+
|
|
830
|
+
# Print user-friendly message with status command
|
|
831
|
+
job_id = result.get("job_id", "")
|
|
832
|
+
script_path = sys.argv[0]
|
|
833
|
+
print(f'Agent running. Check status with: `uv run {script_path} status {job_id}`')
|
|
834
|
+
sys.stdout.flush()
|
|
835
|
+
|
|
836
|
+
elif args.command == "status":
|
|
837
|
+
result = get_status(args.job_id)
|
|
838
|
+
print(json.dumps(result, indent=2))
|
|
839
|
+
|
|
840
|
+
if "error" in result:
|
|
841
|
+
sys.exit(1)
|
|
842
|
+
|
|
843
|
+
elif args.command == "list":
|
|
844
|
+
result = list_jobs()
|
|
845
|
+
print(json.dumps(result, indent=2))
|
|
846
|
+
|
|
847
|
+
except KeyboardInterrupt:
|
|
848
|
+
print(json.dumps({"error": "Interrupted"}))
|
|
849
|
+
sys.exit(130)
|
|
850
|
+
|
|
851
|
+
except Exception as e:
|
|
852
|
+
print(json.dumps({"error": str(e)}))
|
|
853
|
+
sys.exit(1)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
if __name__ == "__main__":
|
|
857
|
+
main()
|