codex-team 0.1.0__py3-none-any.whl
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.
- codex_team/__init__.py +4 -0
- codex_team/__main__.py +3 -0
- codex_team/cli.py +579 -0
- codex_team-0.1.0.dist-info/METADATA +216 -0
- codex_team-0.1.0.dist-info/RECORD +8 -0
- codex_team-0.1.0.dist-info/WHEEL +5 -0
- codex_team-0.1.0.dist-info/entry_points.txt +3 -0
- codex_team-0.1.0.dist-info/top_level.txt +1 -0
codex_team/__init__.py
ADDED
codex_team/__main__.py
ADDED
codex_team/cli.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local Codex team: tmux display, Git submodules and durable task queues."""
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import select
|
|
12
|
+
import shlex
|
|
13
|
+
import shutil
|
|
14
|
+
import sqlite3
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
VERSION = '0.1.0'
|
|
21
|
+
|
|
22
|
+
SLUG = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$')
|
|
23
|
+
TERMINAL = {'done', 'failed', 'interrupted'}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(args, **kwargs):
|
|
27
|
+
return subprocess.run([str(a) for a in args], check=True, text=True, **kwargs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def capture(args):
|
|
31
|
+
return run(args, stdout=subprocess.PIPE).stdout.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def emit(value):
|
|
35
|
+
print(json.dumps(value, ensure_ascii=False, indent=2), flush=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def now():
|
|
39
|
+
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Team:
|
|
43
|
+
def __init__(self, root):
|
|
44
|
+
self.root = Path(root).expanduser().resolve()
|
|
45
|
+
self.state = self.root / '.codex-team'
|
|
46
|
+
self.config_path = self.state / 'settings.json'
|
|
47
|
+
|
|
48
|
+
def settings(self):
|
|
49
|
+
if not self.config_path.exists():
|
|
50
|
+
raise ValueError('Time não inicializado. Execute start primeiro.')
|
|
51
|
+
return json.loads(self.config_path.read_text())
|
|
52
|
+
|
|
53
|
+
@contextlib.contextmanager
|
|
54
|
+
def lock(self, name='topology', nonblocking=False):
|
|
55
|
+
with (self.state / (name + '.lock')).open('a') as handle:
|
|
56
|
+
fcntl.flock(handle, fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0))
|
|
57
|
+
yield
|
|
58
|
+
|
|
59
|
+
@contextlib.contextmanager
|
|
60
|
+
def db(self):
|
|
61
|
+
conn = sqlite3.connect(self.state / 'team.sqlite3', timeout=30)
|
|
62
|
+
conn.row_factory = sqlite3.Row
|
|
63
|
+
try:
|
|
64
|
+
with conn:
|
|
65
|
+
yield conn
|
|
66
|
+
finally:
|
|
67
|
+
conn.close()
|
|
68
|
+
|
|
69
|
+
def init(self, session=None, layout='panes'):
|
|
70
|
+
if self.config_path.exists():
|
|
71
|
+
return
|
|
72
|
+
session = session or 'codex-team-' + hashlib.sha256(str(self.root).encode()).hexdigest()[:10]
|
|
73
|
+
if not SLUG.fullmatch(session):
|
|
74
|
+
raise ValueError('Nome da sessão inválido. Use letras, números, hífen e sublinhado (até 48 caracteres).')
|
|
75
|
+
if self.root.exists() and any(self.root.iterdir()):
|
|
76
|
+
raise ValueError('Use uma pasta nova ou vazia para a raiz do time; nenhum repositório existente será convertido.')
|
|
77
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
self.state.mkdir(mode=0o700)
|
|
79
|
+
for directory in ['bin', 'logs', 'results', 'wake']:
|
|
80
|
+
(self.state / directory).mkdir()
|
|
81
|
+
run(['git', 'init', self.root], stdout=subprocess.DEVNULL)
|
|
82
|
+
self.config_path.write_text(json.dumps({'session': session, 'layout': layout, 'stopping': False}))
|
|
83
|
+
(self.root / '.gitignore').write_text('.codex-team/\n')
|
|
84
|
+
(self.root / 'repos').mkdir()
|
|
85
|
+
destination = self.state / 'bin/team.py'
|
|
86
|
+
shutil.copy2(Path(__file__).resolve(), destination)
|
|
87
|
+
wrapper = '#!/usr/bin/env bash\nset -euo pipefail\nROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)\nexec python3 "$ROOT/.codex-team/bin/team.py" --root "$ROOT" "$@"\n'
|
|
88
|
+
(self.root / 'team').write_text(wrapper)
|
|
89
|
+
(self.root / 'team').chmod(0o755)
|
|
90
|
+
(self.root / 'AGENTS.md').write_text(CONTROLLER_RULES)
|
|
91
|
+
(self.root / 'README.md').write_text('# Equipe Codex\n\nLeia AGENTS.md. Use `./team --help` para gerenciar o time.\n'
|
|
92
|
+
'Os projetos em repos/ são submódulos; não há commits ou pushes automáticos.\n'
|
|
93
|
+
'O runtime local fica em .codex-team/ (ignorado pelo Git).\n')
|
|
94
|
+
with self.db() as db:
|
|
95
|
+
db.executescript('''
|
|
96
|
+
CREATE TABLE agents(name TEXT PRIMARY KEY, repo TEXT NOT NULL, branch TEXT,
|
|
97
|
+
thread TEXT, pane TEXT, status TEXT NOT NULL DEFAULT 'stopped');
|
|
98
|
+
CREATE TABLE tasks(id TEXT PRIMARY KEY, agent TEXT NOT NULL, message TEXT NOT NULL,
|
|
99
|
+
status TEXT NOT NULL, created TEXT NOT NULL, started TEXT, finished TEXT, error TEXT);
|
|
100
|
+
''')
|
|
101
|
+
|
|
102
|
+
def save_settings(self, data):
|
|
103
|
+
temp = self.state / 'settings.tmp'
|
|
104
|
+
temp.write_text(json.dumps(data, indent=2))
|
|
105
|
+
temp.replace(self.config_path)
|
|
106
|
+
|
|
107
|
+
def agent(self, name):
|
|
108
|
+
with self.db() as db:
|
|
109
|
+
row = db.execute('SELECT * FROM agents WHERE name=?', (name,)).fetchone()
|
|
110
|
+
if row is None:
|
|
111
|
+
raise ValueError(f'Agente desconhecido: {name}')
|
|
112
|
+
return dict(row)
|
|
113
|
+
|
|
114
|
+
def session_alive(self):
|
|
115
|
+
return subprocess.run(['tmux', 'has-session', '-t', self.settings()['session']],
|
|
116
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
|
117
|
+
|
|
118
|
+
def pane_alive(self, pane):
|
|
119
|
+
if not pane or not re.fullmatch(r'%\d+', pane):
|
|
120
|
+
return False
|
|
121
|
+
result = subprocess.run(['tmux', 'display-message', '-p', '-t', pane,
|
|
122
|
+
'#{pane_dead}'], capture_output=True, text=True)
|
|
123
|
+
return result.returncode == 0 and result.stdout.strip() == '0'
|
|
124
|
+
|
|
125
|
+
def create_controller(self):
|
|
126
|
+
config = self.settings()
|
|
127
|
+
alive = self.session_alive()
|
|
128
|
+
if alive:
|
|
129
|
+
marker = subprocess.run(['tmux', 'show-options', '-v', '-t', config['session'],
|
|
130
|
+
'@codex-team-root'], capture_output=True, text=True)
|
|
131
|
+
if marker.stdout.strip() != str(self.root):
|
|
132
|
+
raise ValueError('O nome da sessão tmux já está em uso por outro time ou processo.')
|
|
133
|
+
if alive and self.pane_alive(config.get('controller_pane')):
|
|
134
|
+
return
|
|
135
|
+
command = shlex.join([sys.executable, str(self.state / 'bin/team.py'),
|
|
136
|
+
'--root', str(self.root), '_controller'])
|
|
137
|
+
if alive:
|
|
138
|
+
pane = capture(['tmux', 'new-window', '-d', '-P', '-F', '#{pane_id}',
|
|
139
|
+
'-t', config['session'] + ':', '-n', 'controlador', '-c', self.root, command])
|
|
140
|
+
else:
|
|
141
|
+
pane = capture(['tmux', 'new-session', '-d', '-P', '-F', '#{pane_id}',
|
|
142
|
+
'-s', config['session'], '-n', 'equipe', '-c', self.root, command])
|
|
143
|
+
config['controller_pane'] = pane
|
|
144
|
+
self.save_settings(config)
|
|
145
|
+
run(['tmux', 'set-option', '-t', config['session'], '@codex-team-root', str(self.root)])
|
|
146
|
+
run(['tmux', 'set-option', '-t', config['session'], 'mouse', 'on'])
|
|
147
|
+
run(['tmux', 'set-option', '-t', config['session'], 'pane-border-status', 'top'])
|
|
148
|
+
run(['tmux', 'set-option', '-t', config['session'], 'pane-border-format', ' #{pane_title} '])
|
|
149
|
+
run(['tmux', 'select-pane', '-t', pane, '-T', 'Controlador'])
|
|
150
|
+
|
|
151
|
+
def add(self, name, repo, branch=None):
|
|
152
|
+
validate_agent(name, repo, branch)
|
|
153
|
+
with self.lock():
|
|
154
|
+
if self.settings()['stopping']:
|
|
155
|
+
raise ValueError('O time está parando. Execute start para reabrir a entrada de tarefas.')
|
|
156
|
+
if not self.session_alive():
|
|
157
|
+
raise ValueError('Sessão tmux indisponível. Execute start antes de adicionar agentes.')
|
|
158
|
+
with self.db() as db:
|
|
159
|
+
old = db.execute('SELECT * FROM agents WHERE name=?', (name,)).fetchone()
|
|
160
|
+
if old:
|
|
161
|
+
if old['repo'] != repo or old['branch'] != branch:
|
|
162
|
+
raise ValueError('Este nome já está associado a outro repositório ou branch.')
|
|
163
|
+
else:
|
|
164
|
+
target = self.root / 'repos' / name
|
|
165
|
+
if target.exists():
|
|
166
|
+
raise ValueError(f'O destino já existe; revise antes de cadastrar: {target}')
|
|
167
|
+
args = ['git', '-C', str(self.root), 'submodule', 'add']
|
|
168
|
+
if branch:
|
|
169
|
+
args += ['--branch', branch]
|
|
170
|
+
args += ['--', repo, 'repos/' + name]
|
|
171
|
+
run(args)
|
|
172
|
+
with self.db() as db:
|
|
173
|
+
db.execute('INSERT INTO agents(name,repo,branch) VALUES(?,?,?)', (name, repo, branch))
|
|
174
|
+
self.launch_worker(name)
|
|
175
|
+
return self.agent(name)
|
|
176
|
+
|
|
177
|
+
def launch_worker(self, name):
|
|
178
|
+
row = self.agent(name)
|
|
179
|
+
if self.pane_alive(row['pane']):
|
|
180
|
+
# A pane id can be reused by a fresh server; verify its identity too.
|
|
181
|
+
result = subprocess.run(['tmux', 'show-options', '-p', '-v', '-t', row['pane'],
|
|
182
|
+
'@codex-team-agent'], capture_output=True, text=True)
|
|
183
|
+
marker = result.stdout.strip()
|
|
184
|
+
if marker == str(self.root) + ':' + name:
|
|
185
|
+
return
|
|
186
|
+
fifo = self.state / 'wake' / name
|
|
187
|
+
if not fifo.exists():
|
|
188
|
+
os.mkfifo(fifo, mode=0o600)
|
|
189
|
+
config = self.settings()
|
|
190
|
+
command = shlex.join([sys.executable, str(self.state / 'bin/team.py'), '--root', str(self.root), '_worker', name])
|
|
191
|
+
if config['layout'] == 'panes':
|
|
192
|
+
args = ['tmux', 'split-window', '-d', '-P', '-F', '#{pane_id}',
|
|
193
|
+
'-t', config['controller_pane'], '-c', self.root / 'repos' / name, command]
|
|
194
|
+
result = subprocess.run([str(a) for a in args], capture_output=True, text=True)
|
|
195
|
+
else:
|
|
196
|
+
result = None
|
|
197
|
+
if result is None or result.returncode:
|
|
198
|
+
pane = capture(['tmux', 'new-window', '-d', '-P', '-F', '#{pane_id}',
|
|
199
|
+
'-t', config['session'] + ':', '-n', name, '-c', self.root / 'repos' / name, command])
|
|
200
|
+
else:
|
|
201
|
+
pane = result.stdout.strip()
|
|
202
|
+
run(['tmux', 'select-layout', '-t', config['controller_pane'], 'tiled'], stdout=subprocess.DEVNULL)
|
|
203
|
+
run(['tmux', 'select-pane', '-t', pane, '-T', name])
|
|
204
|
+
run(['tmux', 'set-option', '-p', '-t', pane, '@codex-team-agent', str(self.root) + ':' + name])
|
|
205
|
+
with self.db() as db:
|
|
206
|
+
db.execute('UPDATE agents SET pane=? WHERE name=?', (pane, name))
|
|
207
|
+
|
|
208
|
+
def wake(self, name):
|
|
209
|
+
try:
|
|
210
|
+
fd = os.open(self.state / 'wake' / name, os.O_WRONLY | os.O_NONBLOCK)
|
|
211
|
+
except OSError:
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
os.write(fd, b'!')
|
|
215
|
+
finally:
|
|
216
|
+
os.close(fd)
|
|
217
|
+
|
|
218
|
+
def enqueue(self, name, message):
|
|
219
|
+
if not message.strip():
|
|
220
|
+
raise ValueError('A tarefa não pode ser vazia.')
|
|
221
|
+
with self.lock():
|
|
222
|
+
if self.settings()['stopping']:
|
|
223
|
+
raise ValueError('Time em encerramento; novas tarefas não são aceitas.')
|
|
224
|
+
row = self.agent(name)
|
|
225
|
+
if not self.session_alive() or not self.pane_alive(row['pane']):
|
|
226
|
+
raise ValueError('O agente não está aberto. Execute start para restaurar os painéis.')
|
|
227
|
+
task_id = uuid.uuid4().hex
|
|
228
|
+
with self.db() as db:
|
|
229
|
+
db.execute('INSERT INTO tasks(id,agent,message,status,created) VALUES(?,?,?,?,?)',
|
|
230
|
+
(task_id, name, message, 'queued', now()))
|
|
231
|
+
self.wake(name)
|
|
232
|
+
return task_id
|
|
233
|
+
|
|
234
|
+
def task(self, task_id):
|
|
235
|
+
with self.db() as db:
|
|
236
|
+
row = db.execute('SELECT * FROM tasks WHERE id=?', (task_id,)).fetchone()
|
|
237
|
+
if row is None:
|
|
238
|
+
raise ValueError('Tarefa desconhecida: ' + task_id)
|
|
239
|
+
result = dict(row)
|
|
240
|
+
output = self.state / 'results' / (task_id + '.md')
|
|
241
|
+
result['result_file'] = str(output)
|
|
242
|
+
if output.exists():
|
|
243
|
+
result['result'] = output.read_text()
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
def snapshot(self):
|
|
247
|
+
with self.db() as db:
|
|
248
|
+
agents = [dict(row) for row in db.execute('SELECT * FROM agents ORDER BY name')]
|
|
249
|
+
tasks = [dict(row) for row in db.execute('SELECT id,agent,status,created,error FROM tasks ORDER BY created,rowid')]
|
|
250
|
+
for agent in agents:
|
|
251
|
+
agent['pane_alive'] = self.pane_alive(agent['pane'])
|
|
252
|
+
return {'root': str(self.root), 'session': self.settings()['session'],
|
|
253
|
+
'stopping': self.settings()['stopping'], 'agents': agents, 'tasks': tasks}
|
|
254
|
+
|
|
255
|
+
def worker(self, name):
|
|
256
|
+
# Lock spans the complete worker lifetime: one executor per repository.
|
|
257
|
+
with self.lock('worker-' + name, nonblocking=True):
|
|
258
|
+
fd = os.open(self.state / 'wake' / name, os.O_RDWR | os.O_NONBLOCK)
|
|
259
|
+
try:
|
|
260
|
+
with self.db() as db:
|
|
261
|
+
db.execute("UPDATE tasks SET status='interrupted',finished=?,error=? WHERE agent=? AND status='running'",
|
|
262
|
+
(now(), 'Executor anterior terminou sem confirmar o resultado. Revise os arquivos antes de reenviar.', name))
|
|
263
|
+
db.execute("UPDATE agents SET status='idle' WHERE name=?", (name,))
|
|
264
|
+
print(f'Agente {name}: responsável por {self.root / "repos" / name}. Aguardando tarefas.', flush=True)
|
|
265
|
+
while True:
|
|
266
|
+
with self.lock():
|
|
267
|
+
if self.settings()['stopping']:
|
|
268
|
+
break
|
|
269
|
+
with self.db() as db:
|
|
270
|
+
db.execute('BEGIN IMMEDIATE')
|
|
271
|
+
task = db.execute("SELECT * FROM tasks WHERE agent=? AND status='queued' ORDER BY rowid LIMIT 1", (name,)).fetchone()
|
|
272
|
+
if task:
|
|
273
|
+
db.execute("UPDATE tasks SET status='running',started=? WHERE id=?", (now(), task['id']))
|
|
274
|
+
db.execute("UPDATE agents SET status='running' WHERE name=?", (name,))
|
|
275
|
+
if task:
|
|
276
|
+
self.execute(name, dict(task))
|
|
277
|
+
print(f'Agente {name}: aguardando tarefas.', flush=True)
|
|
278
|
+
else:
|
|
279
|
+
# FIFO blocks while idle; timeout only checks unexpected session shutdown.
|
|
280
|
+
ready, _, _ = select.select([fd], [], [], 30)
|
|
281
|
+
if ready:
|
|
282
|
+
os.read(fd, 4096)
|
|
283
|
+
finally:
|
|
284
|
+
os.close(fd)
|
|
285
|
+
with self.db() as db:
|
|
286
|
+
db.execute("UPDATE agents SET status='stopped' WHERE name=?", (name,))
|
|
287
|
+
|
|
288
|
+
def execute(self, name, task):
|
|
289
|
+
row = self.agent(name)
|
|
290
|
+
result_path = self.state / 'results' / (task['id'] + '.md')
|
|
291
|
+
log_path = self.state / 'logs' / (task['id'] + '.jsonl')
|
|
292
|
+
error = None
|
|
293
|
+
thread = row['thread']
|
|
294
|
+
command = ['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'never',
|
|
295
|
+
'-C', str(self.root / 'repos' / name), 'exec', '--json', '-o', str(result_path)]
|
|
296
|
+
if thread:
|
|
297
|
+
command += ['resume', thread]
|
|
298
|
+
command += ['-']
|
|
299
|
+
prompt = (f'Você é o responsável pelo repositório {name}, no diretório atual. '
|
|
300
|
+
'Siga o AGENTS.md aplicável e as regras específicas do repositório. '
|
|
301
|
+
'Trabalhe apenas neste submódulo. Não edite o Git raiz, outros repositórios, '
|
|
302
|
+
'nem execute deploy, push ou operações destrutivas sem autorização explícita na tarefa. '
|
|
303
|
+
'Execute a tarefa e termine relatando alterações, validações e impedimentos. '
|
|
304
|
+
'Se uma ação depender de permissão indisponível, registre o bloqueio sem contornar o sandbox.\n\n'
|
|
305
|
+
f'Tarefa {task["id"]}:\n{task["message"]}')
|
|
306
|
+
print(f'\n▶ Tarefa {task["id"]}\n{task["message"]}\n', flush=True)
|
|
307
|
+
completed = False
|
|
308
|
+
try:
|
|
309
|
+
with log_path.open('w') as log:
|
|
310
|
+
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
311
|
+
stderr=log, text=True, cwd=self.root / 'repos' / name)
|
|
312
|
+
process.stdin.write(prompt)
|
|
313
|
+
process.stdin.close()
|
|
314
|
+
for line in process.stdout:
|
|
315
|
+
log.write(line)
|
|
316
|
+
log.flush()
|
|
317
|
+
try:
|
|
318
|
+
event = json.loads(line)
|
|
319
|
+
except ValueError:
|
|
320
|
+
continue
|
|
321
|
+
if event.get('type') == 'thread.started':
|
|
322
|
+
with self.db() as db:
|
|
323
|
+
db.execute('UPDATE agents SET thread=? WHERE name=?', (event['thread_id'], name))
|
|
324
|
+
elif event.get('type') == 'turn.completed':
|
|
325
|
+
completed = True
|
|
326
|
+
elif event.get('type') in ('turn.failed', 'error'):
|
|
327
|
+
error = json.dumps(event, ensure_ascii=False)
|
|
328
|
+
elif event.get('type') in ('item.started', 'item.completed'):
|
|
329
|
+
item = event.get('item', {})
|
|
330
|
+
if item.get('type') == 'agent_message':
|
|
331
|
+
print(item.get('text', ''), flush=True)
|
|
332
|
+
elif item.get('type') == 'command_execution':
|
|
333
|
+
print('$ ' + item.get('command', ''), flush=True)
|
|
334
|
+
if item.get('aggregated_output'):
|
|
335
|
+
print(item['aggregated_output'], flush=True)
|
|
336
|
+
else:
|
|
337
|
+
print('• ' + item.get('type', 'evento'), flush=True)
|
|
338
|
+
code = process.wait()
|
|
339
|
+
if code or not completed or not result_path.exists():
|
|
340
|
+
error = error or f'Codex terminou com código {code}; conclusão ou resultado ausente. Consulte {log_path}.'
|
|
341
|
+
except (OSError, KeyError) as exc:
|
|
342
|
+
error = str(exc)
|
|
343
|
+
status = 'failed' if error else 'done'
|
|
344
|
+
with self.db() as db:
|
|
345
|
+
db.execute('UPDATE tasks SET status=?,finished=?,error=? WHERE id=?', (status, now(), error, task['id']))
|
|
346
|
+
db.execute("UPDATE agents SET status='idle' WHERE name=?", (name,))
|
|
347
|
+
print(f'\n■ {task["id"]}: {status}' + (f' — {error}' if error else ''), flush=True)
|
|
348
|
+
|
|
349
|
+
def stop(self, timeout):
|
|
350
|
+
with self.lock():
|
|
351
|
+
config = self.settings()
|
|
352
|
+
config['stopping'] = True
|
|
353
|
+
self.save_settings(config)
|
|
354
|
+
with self.db() as db:
|
|
355
|
+
names = [row['name'] for row in db.execute('SELECT name FROM agents')]
|
|
356
|
+
for name in names:
|
|
357
|
+
self.wake(name)
|
|
358
|
+
deadline = time.monotonic() + timeout
|
|
359
|
+
while True:
|
|
360
|
+
busy = []
|
|
361
|
+
for name in names:
|
|
362
|
+
try:
|
|
363
|
+
with self.lock('worker-' + name, nonblocking=True):
|
|
364
|
+
pass
|
|
365
|
+
except BlockingIOError:
|
|
366
|
+
busy.append(name)
|
|
367
|
+
if not busy:
|
|
368
|
+
break
|
|
369
|
+
if time.monotonic() >= deadline:
|
|
370
|
+
emit({'stopping': True, 'waiting_for': busy,
|
|
371
|
+
'message': 'Nenhum processo foi forçado a parar. Aguarde e execute stop novamente.'})
|
|
372
|
+
return 2
|
|
373
|
+
time.sleep(.25)
|
|
374
|
+
# Workers that were killed externally may have left a running marker.
|
|
375
|
+
with self.db() as db:
|
|
376
|
+
db.execute("UPDATE tasks SET status='interrupted',finished=?,error=? WHERE status='running'",
|
|
377
|
+
(now(), 'Executor encerrado sem confirmação; revise antes de reenviar.'))
|
|
378
|
+
db.execute("UPDATE agents SET status='stopped'")
|
|
379
|
+
emit({'stopped': True, 'message': 'Responsáveis encerrados. Tarefas pendentes preservadas. '
|
|
380
|
+
'O controlador e a sessão tmux continuam abertos; encerre-os quando o controlador estiver ocioso.'})
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
CONTROLLER_RULES = '''# Equipe Codex — instruções de coordenação
|
|
385
|
+
|
|
386
|
+
Na raiz você é o controlador. Os projetos em repos/ são submódulos Git e têm
|
|
387
|
+
responsáveis próprios. Dentro de cada submódulo, atue como responsável daquele
|
|
388
|
+
projeto, respeitando suas instruções locais e o escopo da tarefa recebida.
|
|
389
|
+
|
|
390
|
+
## Comandos do controlador (execute a partir da raiz)
|
|
391
|
+
|
|
392
|
+
- `./team add --name NOME --repo URL [--branch BRANCH]`: cadastra um submódulo
|
|
393
|
+
e abre um responsável. Use nomes únicos com letras, números, hífen ou sublinhado.
|
|
394
|
+
- `./team task NOME --message 'TAREFA'`: devolve um identificador de tarefa.
|
|
395
|
+
Para texto longo use `--file caminho`; o conteúdo é lido literalmente.
|
|
396
|
+
- `./team status`: consulta agentes e tarefas em JSON.
|
|
397
|
+
- `./team wait ID [ID ...] --timeout 50`: aguarda resultados (retorna 2 se ainda
|
|
398
|
+
estiverem pendentes). Continue aguardando enquanto houver trabalho necessário.
|
|
399
|
+
- `./team result ID`: lê resultado, erro e estado. Não trate failed/interrupted
|
|
400
|
+
como sucesso, nem done como prova de testes aprovados: leia o relatório.
|
|
401
|
+
- `./team stop --timeout 50`: impede novas tarefas e deixa a atual terminar;
|
|
402
|
+
preserva pendentes e não interrompe processos à força.
|
|
403
|
+
|
|
404
|
+
## Fluxo
|
|
405
|
+
|
|
406
|
+
Ao receber um pedido, defina contratos e dependências, distribua tarefas com
|
|
407
|
+
critérios de aceitação e acompanhe até concluir ou registrar um bloqueio real.
|
|
408
|
+
Os responsáveis ficam ociosos sem chamadas ao modelo e só executam tarefas da
|
|
409
|
+
fila. Recebem as instruções de papel na primeira tarefa e reutilizam sua sessão.
|
|
410
|
+
O controlador deve usar wait/result para receber conclusões: não há mensagem
|
|
411
|
+
espontânea para reativar um controlador que já encerrou sua resposta.
|
|
412
|
+
Só o controlador altera arquivos da raiz e coordena atualizações dos gitlinks.
|
|
413
|
+
Não faça commits, pushes, deploys ou ações destrutivas sem autorização do usuário.
|
|
414
|
+
Não altere simultaneamente um submódulo que já possui tarefa em execução.
|
|
415
|
+
Não acrescente outros worktrees ou clones em repos/: todos os projetos devem
|
|
416
|
+
ser submódulos. Consulte git status na raiz e nos submódulos antes da integração.
|
|
417
|
+
O histórico e resultados locais ficam em .codex-team/ e não são versionados.
|
|
418
|
+
Quando o usuário pedir um novo responsável, use add com o nome e URL informados,
|
|
419
|
+
sem pedir que ele abra um terminal manualmente. Permissões do sandbox continuam
|
|
420
|
+
válidas; registre ou solicite a permissão necessária quando um comando for negado.
|
|
421
|
+
'''
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def validate_agent(name, repo, branch=None):
|
|
425
|
+
if not SLUG.fullmatch(name) or name.lower() in {'controlador', 'controller', 'chefe'}:
|
|
426
|
+
raise ValueError('Nome de agente inválido ou reservado (até 48 caracteres).')
|
|
427
|
+
if not isinstance(repo, str) or not repo or repo.startswith('-') or any(c in repo for c in '\n\r\x00'):
|
|
428
|
+
raise ValueError('URL de repositório inválida.')
|
|
429
|
+
if re.match(r'https?://[^/]*@', repo):
|
|
430
|
+
raise ValueError('Não inclua credenciais na URL; use o login Git/SSH da máquina.')
|
|
431
|
+
if branch is not None and (not isinstance(branch, str) or not branch or branch.startswith('-')
|
|
432
|
+
or subprocess.run(['git', 'check-ref-format', '--branch', branch],
|
|
433
|
+
capture_output=True).returncode):
|
|
434
|
+
raise ValueError('Branch inválida.')
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def load_agents(filename):
|
|
438
|
+
value = json.loads(Path(filename).read_text())
|
|
439
|
+
agents = value.get('agents') if isinstance(value, dict) else value
|
|
440
|
+
if not isinstance(agents, list):
|
|
441
|
+
raise ValueError('O config precisa conter {"agents": [{"name": ..., "repo": ...}]}.')
|
|
442
|
+
names = set()
|
|
443
|
+
for item in agents:
|
|
444
|
+
if not isinstance(item, dict) or not isinstance(item.get('name'), str):
|
|
445
|
+
raise ValueError('Cada agente precisa de name e repo.')
|
|
446
|
+
validate_agent(item['name'], item.get('repo'), item.get('branch'))
|
|
447
|
+
if item['name'].lower() in names:
|
|
448
|
+
raise ValueError('Nome duplicado no config: ' + item['name'])
|
|
449
|
+
names.add(item['name'].lower())
|
|
450
|
+
return agents
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def parser():
|
|
454
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
455
|
+
p.add_argument('--version', action='version', version='codex-team ' + VERSION)
|
|
456
|
+
p.add_argument('--root', default='codex-team', help='Raiz própria do time (padrão: ./codex-team)')
|
|
457
|
+
subs = p.add_subparsers(dest='command', required=True)
|
|
458
|
+
start = subs.add_parser('start', help='Inicializa ou retoma o time')
|
|
459
|
+
start.add_argument('--root', default=argparse.SUPPRESS, help='Raiz própria do time (padrão: ./codex-team)')
|
|
460
|
+
start.add_argument('--version', action='version', version='codex-team ' + VERSION)
|
|
461
|
+
start.add_argument('--agents', help='JSON de agentes; usa ./config.json se existir')
|
|
462
|
+
start.add_argument('--session')
|
|
463
|
+
start.add_argument('--layout', choices=['panes', 'windows'], default='panes')
|
|
464
|
+
start.add_argument('--no-attach', action='store_true')
|
|
465
|
+
add = subs.add_parser('add', help='Adiciona um responsável/submódulo')
|
|
466
|
+
add.add_argument('--name', required=True)
|
|
467
|
+
add.add_argument('--repo', required=True)
|
|
468
|
+
add.add_argument('--branch')
|
|
469
|
+
task = subs.add_parser('task', help='Enfileira uma tarefa para um responsável')
|
|
470
|
+
task.add_argument('name')
|
|
471
|
+
messages = task.add_mutually_exclusive_group(required=True)
|
|
472
|
+
messages.add_argument('--message')
|
|
473
|
+
messages.add_argument('--file', type=Path)
|
|
474
|
+
subs.add_parser('example-config', help='Imprime um JSON de exemplo (substitua as URLs)')
|
|
475
|
+
subs.add_parser('status')
|
|
476
|
+
result = subs.add_parser('result')
|
|
477
|
+
result.add_argument('task_id')
|
|
478
|
+
wait = subs.add_parser('wait')
|
|
479
|
+
wait.add_argument('task_ids', nargs='+')
|
|
480
|
+
wait.add_argument('--timeout', type=float, default=50)
|
|
481
|
+
stop = subs.add_parser('stop')
|
|
482
|
+
stop.add_argument('--timeout', type=float, default=50)
|
|
483
|
+
subs.add_parser('attach')
|
|
484
|
+
subs.add_parser('_controller', help=argparse.SUPPRESS)
|
|
485
|
+
worker = subs.add_parser('_worker', help=argparse.SUPPRESS)
|
|
486
|
+
worker.add_argument('name')
|
|
487
|
+
return p
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def attach(team):
|
|
491
|
+
if not team.session_alive():
|
|
492
|
+
raise ValueError('Sessão tmux indisponível. Execute start.')
|
|
493
|
+
if os.environ.get('TMUX'):
|
|
494
|
+
run(['tmux', 'switch-client', '-t', team.settings()['session']])
|
|
495
|
+
else:
|
|
496
|
+
os.execvp('tmux', ['tmux', 'attach-session', '-t', team.settings()['session']])
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def main(argv=None):
|
|
500
|
+
args = parser().parse_args(argv)
|
|
501
|
+
if args.command == 'example-config':
|
|
502
|
+
emit({'agents': [
|
|
503
|
+
{'name': 'publicapi', 'repo': 'git@github.com:SUA_ORGANIZACAO/publicapi.git'},
|
|
504
|
+
{'name': 'anunciantes', 'repo': 'git@github.com:SUA_ORGANIZACAO/flutter_anunciantes.git'},
|
|
505
|
+
{'name': 'parceiros', 'repo': 'git@github.com:SUA_ORGANIZACAO/flutter_parceiros.git'},
|
|
506
|
+
]})
|
|
507
|
+
return 0
|
|
508
|
+
team = Team(args.root)
|
|
509
|
+
if args.command == 'start':
|
|
510
|
+
for cmd in ('git', 'tmux', 'codex'):
|
|
511
|
+
if not shutil.which(cmd):
|
|
512
|
+
raise ValueError('Comando necessário não encontrado: ' + cmd)
|
|
513
|
+
filename = args.agents or ('config.json' if Path('config.json').is_file() else None)
|
|
514
|
+
agents = load_agents(filename) if filename else []
|
|
515
|
+
team.init(args.session, args.layout)
|
|
516
|
+
with team.lock():
|
|
517
|
+
config = team.settings()
|
|
518
|
+
config['stopping'] = False
|
|
519
|
+
team.save_settings(config)
|
|
520
|
+
team.create_controller()
|
|
521
|
+
for item in agents:
|
|
522
|
+
team.add(item['name'], item['repo'], item.get('branch'))
|
|
523
|
+
with team.lock():
|
|
524
|
+
with team.db() as db:
|
|
525
|
+
names = [row['name'] for row in db.execute('SELECT name FROM agents')]
|
|
526
|
+
for name in names:
|
|
527
|
+
team.launch_worker(name)
|
|
528
|
+
emit({'root': str(team.root), 'session': team.settings()['session'], 'agents': names})
|
|
529
|
+
if not args.no_attach:
|
|
530
|
+
attach(team)
|
|
531
|
+
elif args.command == '_controller':
|
|
532
|
+
# Interactive TUI remains available for the user's instructions.
|
|
533
|
+
command = ['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'on-request',
|
|
534
|
+
'-C', str(team.root), 'Você é o controlador deste time. Leia AGENTS.md, '
|
|
535
|
+
'consulte ./team status e aguarde o pedido do usuário. Gerencie os responsáveis usando ./team.']
|
|
536
|
+
os.execvp(command[0], command)
|
|
537
|
+
elif args.command == '_worker':
|
|
538
|
+
team.worker(args.name)
|
|
539
|
+
elif args.command == 'add':
|
|
540
|
+
emit(team.add(args.name, args.repo, args.branch))
|
|
541
|
+
elif args.command == 'task':
|
|
542
|
+
message = args.file.read_text() if args.file else args.message
|
|
543
|
+
emit({'task_id': team.enqueue(args.name, message)})
|
|
544
|
+
elif args.command == 'status':
|
|
545
|
+
emit(team.snapshot())
|
|
546
|
+
elif args.command == 'result':
|
|
547
|
+
emit(team.task(args.task_id))
|
|
548
|
+
elif args.command == 'wait':
|
|
549
|
+
deadline = time.monotonic() + max(0, min(args.timeout, 3600))
|
|
550
|
+
while True:
|
|
551
|
+
tasks = [team.task(task_id) for task_id in args.task_ids]
|
|
552
|
+
if all(task['status'] in TERMINAL for task in tasks):
|
|
553
|
+
emit(tasks)
|
|
554
|
+
return 1 if any(task['status'] != 'done' for task in tasks) else 0
|
|
555
|
+
if time.monotonic() >= deadline:
|
|
556
|
+
emit(tasks)
|
|
557
|
+
return 2
|
|
558
|
+
time.sleep(.25)
|
|
559
|
+
elif args.command == 'stop':
|
|
560
|
+
return team.stop(max(0, args.timeout))
|
|
561
|
+
elif args.command == 'attach':
|
|
562
|
+
attach(team)
|
|
563
|
+
return 0
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def entrypoint(argv=None):
|
|
567
|
+
try:
|
|
568
|
+
return main(argv)
|
|
569
|
+
except (ValueError, OSError, subprocess.CalledProcessError, sqlite3.Error) as exc:
|
|
570
|
+
print('Erro: ' + str(exc), file=sys.stderr)
|
|
571
|
+
return 1
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def start_entrypoint():
|
|
575
|
+
return entrypoint(['start', *sys.argv[1:]])
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
if __name__ == '__main__':
|
|
579
|
+
sys.exit(entrypoint())
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-team
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Orchestrate local Codex agents with tmux, Git submodules, and persistent task queues.
|
|
5
|
+
Author: PixPool
|
|
6
|
+
Keywords: codex,tmux,agents,orchestration,git,submodules
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Codex Team
|
|
21
|
+
|
|
22
|
+
Linux, Python 3.10+, Git, tmux e Codex CLI autenticado. Compatibilidade conferida
|
|
23
|
+
com Codex CLI 0.163.0 e tmux 3.3a. Usa a autenticação e o modelo configurados no
|
|
24
|
+
Codex local; não instala SDK nem exige uma chave de API separada.
|
|
25
|
+
|
|
26
|
+
## Instalar pelo pip
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python3 -m pip install codex-team
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Em um Python gerenciado pelo sistema operacional, use um ambiente virtual ou
|
|
33
|
+
`pipx install codex-team`. Git, tmux e Codex CLI são dependências externas
|
|
34
|
+
que precisam estar no PATH. Nenhuma biblioteca Python adicional é necessária.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
codex-team --version
|
|
38
|
+
codex-team example-config > config.json
|
|
39
|
+
# Edite config.json para informar os repositórios reais.
|
|
40
|
+
start-git-codex-team --root /caminho/novo-time --agents config.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
O comando completo equivalente é:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
codex-team start --root /caminho/novo-time --agents config.json
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Também funciona como módulo: `python3 -m codex_team --help`.
|
|
50
|
+
Depois da criação, use `./team` dentro da pasta do time para adicionar agentes,
|
|
51
|
+
enviar tarefas e consultar resultados. Fora dela, use por exemplo
|
|
52
|
+
`codex-team --root /caminho/novo-time status`.
|
|
53
|
+
|
|
54
|
+
## Iniciar a partir deste checkout
|
|
55
|
+
|
|
56
|
+
Copie `config.example.json` para um `config.json` e substitua as URLs pelos
|
|
57
|
+
repositórios reais. Repositórios privados precisam de acesso Git/SSH na máquina.
|
|
58
|
+
Não inclua tokens ou senhas nas URLs.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
./ops/start-git-codex-team.sh --root /caminho/novo-time --agents /caminho/config.json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
O diretório passado em `--root` precisa estar vazio ou já
|
|
65
|
+
pertencer a este gerenciador. Por padrão a raiz é `./codex-team`; sem `--agents`,
|
|
66
|
+
carrega `./config.json` se existir. Sem JSON, inicia apenas o controlador.
|
|
67
|
+
Também aceita uma lista JSON diretamente, além do objeto com a chave `agents`.
|
|
68
|
+
Cada agente exige `name` e `repo`; `branch` é opcional (branch existente no remoto).
|
|
69
|
+
|
|
70
|
+
Use `--no-attach` para iniciar sem anexar o terminal. O layout padrão usa painéis
|
|
71
|
+
lado a lado; `--layout windows` cria uma janela por agente. Quando não há espaço
|
|
72
|
+
para mais divisões, um novo agente ganha uma janela própria. Escolha o layout na
|
|
73
|
+
primeira inicialização. A sessão tem nome derivado da pasta; `--session equipe-x`
|
|
74
|
+
permite escolher o nome na primeira inicialização. Não reutilize o mesmo nome
|
|
75
|
+
para times diferentes.
|
|
76
|
+
|
|
77
|
+
A raiz passa a ter:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
novo-time/
|
|
81
|
+
├── .git/ # Repositório Git raiz, sem commit automático
|
|
82
|
+
├── .gitmodules # Projetos registrados como submódulos
|
|
83
|
+
├── AGENTS.md # Instruções do controlador
|
|
84
|
+
├── README.md
|
|
85
|
+
├── team # Gerenciador local, executável
|
|
86
|
+
├── repos/
|
|
87
|
+
│ ├── publicapi/ # Submódulo Git
|
|
88
|
+
│ └── parceiros/ # Submódulo Git
|
|
89
|
+
└── .codex-team/ # Banco, filas, sessões e resultados locais
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`git submodule add` deixa `.gitmodules` e os gitlinks no índice. O gerenciador não
|
|
93
|
+
faz commit nem push na raiz ou nos projetos. A raiz ainda não possui remoto;
|
|
94
|
+
configure um se quiser compartilhar a composição do time. O runtime é local e
|
|
95
|
+
ignorado pelo Git; os arquivos versionados sozinhos não reinstalam o gerenciador
|
|
96
|
+
em outra máquina. Preserve essa pasta para retomar este time.
|
|
97
|
+
|
|
98
|
+
## Conversar com o controlador
|
|
99
|
+
|
|
100
|
+
O painel Controlador é uma sessão interativa do Codex. Você pode pedir:
|
|
101
|
+
|
|
102
|
+
> Adicione um responsável chamado pagamentos para o repositório
|
|
103
|
+
> git@github.com:empresa/pagamentos.git e aguarde as tarefas.
|
|
104
|
+
|
|
105
|
+
O AGENTS.md instrui o chefe a executar:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
./team add --name pagamentos --repo git@github.com:empresa/pagamentos.git
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
O novo projeto é cadastrado como submódulo e seu painel é aberto. Um responsável
|
|
112
|
+
inicialmente fica ocioso; não há chamada ao modelo para apenas aguardar. Ao
|
|
113
|
+
receber uma tarefa, o executor chama `codex exec --json`, registra seu thread ID e
|
|
114
|
+
reutiliza a conversa usando `codex exec resume` nas tarefas seguintes.
|
|
115
|
+
|
|
116
|
+
Os painéis dos responsáveis mostram mensagens, comandos e resultados da
|
|
117
|
+
execução. São consoles do executor, não TUIs interativas do Codex. Envie tarefas
|
|
118
|
+
pelo controlador ou pelo comando abaixo, sem digitar no painel do responsável.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
cd /caminho/novo-time
|
|
122
|
+
./team task publicapi --message 'Implemente o endpoint X e valide os testes.'
|
|
123
|
+
./team task parceiros --file /caminho/tarefa.txt
|
|
124
|
+
./team status
|
|
125
|
+
./team wait ID_DA_TAREFA --timeout 50
|
|
126
|
+
./team result ID_DA_TAREFA
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`wait` retorna 0 se todas terminaram com sucesso de execução, 1 se alguma falhou
|
|
130
|
+
ou foi interrompida, e 2 se ainda há pendentes. Leia o relatório para conferir
|
|
131
|
+
se os critérios de aceitação e os testes passaram; `done` indica que a chamada
|
|
132
|
+
Codex terminou normalmente, não substitui revisão. Não há dependências implícitas
|
|
133
|
+
na fila: o chefe deve esperar a API antes de enviar uma integração dependente.
|
|
134
|
+
Cada responsável executa uma tarefa por vez; repositórios diferentes trabalham
|
|
135
|
+
em paralelo. Uma tarefa interrompida é marcada e não é reenviada automaticamente.
|
|
136
|
+
|
|
137
|
+
A fila usa SQLite e a espera ociosa dos responsáveis usa FIFO. Resultados ficam
|
|
138
|
+
em `.codex-team/results/ID.md`, logs em `.codex-team/logs/ID.jsonl`. Esses arquivos
|
|
139
|
+
podem conter conteúdo do projeto; não os publique inadvertidamente.
|
|
140
|
+
|
|
141
|
+
O chefe deve chamar `wait`/`result` enquanto acompanha um pedido. O gerenciador
|
|
142
|
+
não reativa espontaneamente um controlador que já encerrou sua resposta.
|
|
143
|
+
Ele pode distribuir várias tarefas e aguardar resultados na mesma conversa.
|
|
144
|
+
|
|
145
|
+
## Permissões e escopo
|
|
146
|
+
|
|
147
|
+
O controlador usa `workspace-write` com aprovação `on-request`. Git remoto, tmux
|
|
148
|
+
ou ações fora do sandbox podem exigir aprovação conforme sua instalação. Os
|
|
149
|
+
responsáveis usam `workspace-write` e aprovação `never`: se uma ação não for
|
|
150
|
+
permitida, devem relatar o impedimento ao chefe. O gerenciador não usa bypass de
|
|
151
|
+
sandbox nem executa comandos recebidos por interpolação de shell.
|
|
152
|
+
|
|
153
|
+
A tarefa instrui cada responsável a alterar apenas seu submódulo. Isso é uma
|
|
154
|
+
regra de coordenação, não um isolamento de leitura ou segurança entre processos
|
|
155
|
+
que compartilham o mesmo usuário Linux. Instruções locais do repositório e
|
|
156
|
+
configurações do Codex continuam aplicáveis.
|
|
157
|
+
|
|
158
|
+
## Sair, parar e retomar
|
|
159
|
+
|
|
160
|
+
- Desconectar mantendo o trabalho: `Ctrl+B`, depois `D`.
|
|
161
|
+
- Voltar: `./team attach` na raiz do time.
|
|
162
|
+
- Parar os responsáveis com segurança, em outro terminal:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
cd /caminho/novo-time
|
|
166
|
+
./team stop --timeout 50
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`stop` fecha a entrada de tarefas. A tarefa atual de cada responsável pode
|
|
170
|
+
terminar, mas as pendentes ficam preservadas. Se o prazo expirar, retorna 2 e
|
|
171
|
+
não força a interrupção. Execute `stop` novamente depois. O controlador e a
|
|
172
|
+
sessão tmux permanecem abertos. Quando ele estiver ocioso e os responsáveis
|
|
173
|
+
parados, encerre a sessão usando o nome mostrado em `./team status`:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
tmux kill-session -t NOME_DA_SESSAO
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Para reabrir o time, reaproveite a mesma raiz:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
./ops/start-git-codex-team.sh --root /caminho/novo-time
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Isso restaura os painéis ausentes, as filas e os IDs das conversas dos
|
|
186
|
+
responsáveis. Tarefas pendentes voltam a ser executadas. Se um controlador foi
|
|
187
|
+
fechado, abre uma conversa nova que consulta o estado persistido; a conversa
|
|
188
|
+
interativa anterior pode ser retomada manualmente pelo Codex. Nenhum arquivo
|
|
189
|
+
modificado ou commit dos submódulos é descartado.
|
|
190
|
+
|
|
191
|
+
## Validação
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
python3 -m unittest discover -s ops/codex-team -p 'test_*.py' -v
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Os testes usam repositórios Git locais, servidor tmux isolado e um executável
|
|
198
|
+
Codex simulado. Não fazem chamadas ao modelo, não clonam projetos reais e não
|
|
199
|
+
alteram o servidor tmux do usuário.
|
|
200
|
+
|
|
201
|
+
Referência: [execução não interativa do Codex](https://developers.openai.com/codex/noninteractive).
|
|
202
|
+
|
|
203
|
+
## Desenvolvimento e build
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
cd ops/codex-team
|
|
207
|
+
python3 -m pip install -e .
|
|
208
|
+
python3 -m pip install build twine
|
|
209
|
+
python3 -m build
|
|
210
|
+
python3 -m twine check dist/*
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A distribuição inclui somente o gerenciador, a documentação e os exemplos;
|
|
214
|
+
nenhum repositório de trabalho, fila, credencial ou configuração de usuário é
|
|
215
|
+
incluído nos artefatos. A versão inicial é 0.1.0 (alpha). A implementação usa
|
|
216
|
+
tmux; GNU screen não é um backend suportado por este pacote.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
codex_team/__init__.py,sha256=jWP8Lp1qNj01zb-UoB535MAOOVv_fGi3xs7RatklI90,141
|
|
2
|
+
codex_team/__main__.py,sha256=PIJIOolexzGnrOJz3yXWQZ6eCKD9h2LLAao-qtQ8HYE,60
|
|
3
|
+
codex_team/cli.py,sha256=AiZDuDyByyb5hx5r-S5b_x71nZSXMlLj8DAPhqrv094,28889
|
|
4
|
+
codex_team-0.1.0.dist-info/METADATA,sha256=vkma9v3iamzYbApZbut1p2fFacgPxapl0qLxSGh96to,8928
|
|
5
|
+
codex_team-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
codex_team-0.1.0.dist-info/entry_points.txt,sha256=l97m-HSFmjMzd5l4Djb4h-zEYLBkMM08hzvIgBgSlfQ,112
|
|
7
|
+
codex_team-0.1.0.dist-info/top_level.txt,sha256=9-CdtNoAAQJ590wambLcCn_5kyLdc9wkvs5x7pFeyQA,11
|
|
8
|
+
codex_team-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
codex_team
|