trinity-lite 0.2.3__tar.gz → 0.3.1__tar.gz
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.
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/PKG-INFO +1 -1
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/pyproject.toml +1 -1
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_mcp_server.py +3 -3
- trinity_lite-0.3.1/tests/test_pipeline.py +185 -0
- trinity_lite-0.3.1/tests/test_worker.py +161 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/__init__.py +1 -1
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/cli.py +35 -14
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/mcp_server.py +160 -2
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/orchestrator.py +11 -0
- trinity_lite-0.3.1/trinity_lite/pipeline.py +158 -0
- trinity_lite-0.3.1/trinity_lite/worker.py +206 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/PKG-INFO +1 -1
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/SOURCES.txt +2 -0
- trinity_lite-0.2.3/tests/test_worker.py +0 -67
- trinity_lite-0.2.3/trinity_lite/worker.py +0 -45
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/LICENSE +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/README.md +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/setup.cfg +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_bus.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_cli.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_doctor.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_guard.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_orchestrator.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_router.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/__main__.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/adapters.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/bus.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/config.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/doctor.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/guard.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/paths.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/py.typed +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/router.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/validation.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/dependency_links.txt +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/entry_points.txt +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/requires.txt +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/top_level.txt +0 -0
|
@@ -68,7 +68,7 @@ class McpServerTest(unittest.TestCase):
|
|
|
68
68
|
self.assertIn("resources", resp["result"]["capabilities"])
|
|
69
69
|
self.assertIn("serverInfo", resp["result"])
|
|
70
70
|
self.assertEqual(resp["result"]["serverInfo"]["name"], "trinity-lite-mcp")
|
|
71
|
-
self.assertEqual(resp["result"]["serverInfo"]["version"], "0.
|
|
71
|
+
self.assertEqual(resp["result"]["serverInfo"]["version"], "0.3.1")
|
|
72
72
|
|
|
73
73
|
def test_initialized_returns_none(self):
|
|
74
74
|
resp = self._call(self._msg("initialized"))
|
|
@@ -90,7 +90,7 @@ class McpServerTest(unittest.TestCase):
|
|
|
90
90
|
resp = self._call(self._msg("tools/list", 1))
|
|
91
91
|
tools = resp["result"]["tools"]
|
|
92
92
|
self.assertIsInstance(tools, list)
|
|
93
|
-
self.assertEqual(len(tools),
|
|
93
|
+
self.assertEqual(len(tools), 12)
|
|
94
94
|
names = [t["name"] for t in tools]
|
|
95
95
|
self.assertIn("trinity_dispatch", names)
|
|
96
96
|
self.assertIn("trinity_dispatch_auto", names)
|
|
@@ -133,7 +133,7 @@ class McpServerTest(unittest.TestCase):
|
|
|
133
133
|
data = json.loads(contents[0]["text"])
|
|
134
134
|
self.assertEqual(data["status"], "ok")
|
|
135
135
|
self.assertEqual(data["bus"], "connected")
|
|
136
|
-
self.assertEqual(data["tools"],
|
|
136
|
+
self.assertEqual(data["tools"], 12)
|
|
137
137
|
self.assertEqual(data["resources"], 3)
|
|
138
138
|
|
|
139
139
|
def test_resource_health_degraded(self):
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Tests for pipeline orchestration."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from trinity_lite.bus import TrinityBus
|
|
9
|
+
from trinity_lite.pipeline import (
|
|
10
|
+
load_pipeline,
|
|
11
|
+
resolve_step_prompt,
|
|
12
|
+
run_pipeline,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PipelineTest(unittest.TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.tmp = tempfile.TemporaryDirectory(dir=str(Path.home()))
|
|
19
|
+
self.root = Path(self.tmp.name)
|
|
20
|
+
|
|
21
|
+
def tearDown(self):
|
|
22
|
+
self.tmp.cleanup()
|
|
23
|
+
|
|
24
|
+
def _write_yaml(self, name, content):
|
|
25
|
+
path = self.root / name
|
|
26
|
+
path.write_text(content, encoding="utf-8")
|
|
27
|
+
return str(path)
|
|
28
|
+
|
|
29
|
+
def test_load_valid_pipeline(self):
|
|
30
|
+
yaml_text = "name: test-pipeline\nsteps:\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'Implement: {task}'\n"
|
|
31
|
+
yaml_path = self._write_yaml("test.yaml", yaml_text)
|
|
32
|
+
p = load_pipeline(yaml_path)
|
|
33
|
+
self.assertEqual(p["name"], "test-pipeline")
|
|
34
|
+
self.assertEqual(len(p["steps"]), 1)
|
|
35
|
+
self.assertEqual(p["steps"][0]["id"], "step1")
|
|
36
|
+
|
|
37
|
+
def test_load_invalid_yaml_raises(self):
|
|
38
|
+
json_path = self._write_yaml("bad.json", "not valid json{{{")
|
|
39
|
+
with self.assertRaises(ValueError):
|
|
40
|
+
load_pipeline(json_path)
|
|
41
|
+
|
|
42
|
+
def test_missing_required_field_raises(self):
|
|
43
|
+
yaml_text = "name: bad\nsteps:\n - id: step1\n task_type: implementation\n prompt_template: 'ok'\n"
|
|
44
|
+
yaml_path = self._write_yaml("bad.yaml", yaml_text)
|
|
45
|
+
with self.assertRaises(ValueError):
|
|
46
|
+
load_pipeline(yaml_path)
|
|
47
|
+
|
|
48
|
+
def test_empty_steps_raises(self):
|
|
49
|
+
yaml_path = self._write_yaml("empty.yaml", "name: empty\nsteps: []")
|
|
50
|
+
with self.assertRaises(ValueError):
|
|
51
|
+
load_pipeline(yaml_path)
|
|
52
|
+
|
|
53
|
+
def test_duplicate_step_ids_raises(self):
|
|
54
|
+
yaml_text = "name: dup\nsteps:\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'ok'\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'ok'\n"
|
|
55
|
+
yaml_path = self._write_yaml("dup.yaml", yaml_text)
|
|
56
|
+
with self.assertRaises(ValueError):
|
|
57
|
+
load_pipeline(yaml_path)
|
|
58
|
+
|
|
59
|
+
def test_unknown_step_reference_raises(self):
|
|
60
|
+
yaml_text = "name: badref\nsteps:\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'Result: {steps.nonexistent.result}'\n"
|
|
61
|
+
yaml_path = self._write_yaml("badref.yaml", yaml_text)
|
|
62
|
+
with self.assertRaises(ValueError):
|
|
63
|
+
load_pipeline(yaml_path)
|
|
64
|
+
|
|
65
|
+
def test_prompt_template_rendering_task_only(self):
|
|
66
|
+
step = {"prompt_template": "Do: {task}"}
|
|
67
|
+
result = resolve_step_prompt(step, "write tests", {})
|
|
68
|
+
self.assertEqual(result, "Do: write tests")
|
|
69
|
+
|
|
70
|
+
def test_prompt_template_rendering_with_step_result(self):
|
|
71
|
+
step = {"prompt_template": "Result was: {steps.step1.result}"}
|
|
72
|
+
step_results = {"step1": {"result": "all done", "status": "completed"}}
|
|
73
|
+
result = resolve_step_prompt(step, "task", step_results)
|
|
74
|
+
self.assertEqual(result, "Result was: all done")
|
|
75
|
+
|
|
76
|
+
def test_prompt_template_rendering_with_missing_step(self):
|
|
77
|
+
step = {"prompt_template": "Got: {steps.missing.result}"}
|
|
78
|
+
result = resolve_step_prompt(step, "task", {})
|
|
79
|
+
self.assertEqual(result, "Got: ")
|
|
80
|
+
|
|
81
|
+
def test_prompt_template_rendering_with_error(self):
|
|
82
|
+
step = {"prompt_template": "Error: {steps.step1.result}"}
|
|
83
|
+
step_results = {"step1": {"error": "failed hard", "status": "failed"}}
|
|
84
|
+
result = resolve_step_prompt(step, "task", step_results)
|
|
85
|
+
self.assertEqual(result, "Error: failed hard")
|
|
86
|
+
|
|
87
|
+
def test_run_single_step_pipeline(self):
|
|
88
|
+
bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
|
|
89
|
+
pipeline = {
|
|
90
|
+
"name": "test-single",
|
|
91
|
+
"steps": [
|
|
92
|
+
{"id": "implement", "agent": "codex", "task_type": "implementation",
|
|
93
|
+
"prompt_template": "Implement: {task}"}
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
result = run_pipeline(pipeline, "hello world", bus, cwd=str(self.root))
|
|
97
|
+
self.assertEqual(result["pipeline_name"], "test-single")
|
|
98
|
+
self.assertEqual(result["overall_status"], "completed")
|
|
99
|
+
self.assertEqual(len(result["steps"]), 1)
|
|
100
|
+
self.assertEqual(result["steps"][0]["status"], "completed")
|
|
101
|
+
|
|
102
|
+
def test_run_two_step_pipeline(self):
|
|
103
|
+
bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
|
|
104
|
+
pipeline = {
|
|
105
|
+
"name": "test-two",
|
|
106
|
+
"steps": [
|
|
107
|
+
{"id": "implement", "agent": "codex", "task_type": "implementation",
|
|
108
|
+
"prompt_template": "Implement: {task}"},
|
|
109
|
+
{"id": "review", "agent": "claude_code", "task_type": "code_review",
|
|
110
|
+
"prompt_template": "Review: {task}\nResult: {steps.implement.result}"},
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
result = run_pipeline(pipeline, "hello", bus, cwd=str(self.root))
|
|
114
|
+
self.assertEqual(result["overall_status"], "completed")
|
|
115
|
+
self.assertEqual(len(result["steps"]), 2)
|
|
116
|
+
self.assertEqual(result["steps"][0]["status"], "completed")
|
|
117
|
+
self.assertEqual(result["steps"][1]["status"], "completed")
|
|
118
|
+
|
|
119
|
+
def test_pipeline_step_results_accessible(self):
|
|
120
|
+
bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
|
|
121
|
+
pipeline = {
|
|
122
|
+
"name": "test-ref",
|
|
123
|
+
"steps": [
|
|
124
|
+
{"id": "step1", "agent": "codex", "task_type": "implementation",
|
|
125
|
+
"prompt_template": "Task: {task}"},
|
|
126
|
+
{"id": "step2", "agent": "claude_code", "task_type": "code_review",
|
|
127
|
+
"prompt_template": "Previous result was: {steps.step1.result}"},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
result = run_pipeline(pipeline, "check refs", bus, cwd=str(self.root))
|
|
131
|
+
self.assertEqual(result["overall_status"], "completed")
|
|
132
|
+
step2 = bus.get_task(result["steps"][1]["task_id"])
|
|
133
|
+
self.assertIn("Previous result was:", step2["prompt"])
|
|
134
|
+
|
|
135
|
+
def test_pipeline_no_run_workers(self):
|
|
136
|
+
bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
|
|
137
|
+
pipeline = {
|
|
138
|
+
"name": "test-no-run",
|
|
139
|
+
"steps": [
|
|
140
|
+
{"id": "step1", "agent": "codex", "task_type": "implementation",
|
|
141
|
+
"prompt_template": "{task}"}
|
|
142
|
+
],
|
|
143
|
+
}
|
|
144
|
+
result = run_pipeline(pipeline, "stuff", bus, cwd=str(self.root), run_workers=False)
|
|
145
|
+
self.assertEqual(result["overall_status"], "queued")
|
|
146
|
+
|
|
147
|
+
def test_pipeline_fail_fast(self):
|
|
148
|
+
bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
|
|
149
|
+
pipeline = {
|
|
150
|
+
"name": "test-fail",
|
|
151
|
+
"steps": [
|
|
152
|
+
{"id": "step1", "agent": "no_such_agent", "task_type": "implementation",
|
|
153
|
+
"prompt_template": "{task}"},
|
|
154
|
+
{"id": "step2", "agent": "codex", "task_type": "implementation",
|
|
155
|
+
"prompt_template": "{task}"},
|
|
156
|
+
],
|
|
157
|
+
}
|
|
158
|
+
result = run_pipeline(pipeline, "stuff", bus, cwd=str(self.root))
|
|
159
|
+
self.assertEqual(result["overall_status"], "failed")
|
|
160
|
+
self.assertEqual(len(result["steps"]), 1)
|
|
161
|
+
|
|
162
|
+
def test_cli_orchestrate_with_pipeline_flag(self):
|
|
163
|
+
import io as _io
|
|
164
|
+
import json as _json
|
|
165
|
+
from contextlib import redirect_stdout as _redirect_stdout
|
|
166
|
+
from trinity_lite.cli import main
|
|
167
|
+
yaml_text = "name: cli-pipe\nsteps:\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'ok'\n"
|
|
168
|
+
pipeline_path = self._write_yaml("cli_test.yaml", yaml_text)
|
|
169
|
+
output = _io.StringIO()
|
|
170
|
+
with _redirect_stdout(output):
|
|
171
|
+
code = main([
|
|
172
|
+
"orchestrate",
|
|
173
|
+
"test task",
|
|
174
|
+
"--pipeline", pipeline_path,
|
|
175
|
+
"--db", str(self.root / "cli_bus.db"),
|
|
176
|
+
"--cwd", str(self.root),
|
|
177
|
+
])
|
|
178
|
+
self.assertEqual(code, 0)
|
|
179
|
+
data = _json.loads(output.getvalue())
|
|
180
|
+
self.assertEqual(data["pipeline_name"], "cli-pipe")
|
|
181
|
+
self.assertEqual(data["overall_status"], "completed")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
unittest.main()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import os, signal, tempfile, time, unittest, json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from trinity_lite.bus import TrinityBus
|
|
6
|
+
from trinity_lite.worker import (
|
|
7
|
+
run_once, run_loop, _acquire_pid_file, _cleanup_pid_file,
|
|
8
|
+
_check_existing_pid, _read_pid_file, _stop_flag,
|
|
9
|
+
_install_signal_handlers, _default_pid_path,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorkerTest(unittest.TestCase):
|
|
14
|
+
def test_mock_worker_completes_task(self):
|
|
15
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
16
|
+
root = Path(tmp)
|
|
17
|
+
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
18
|
+
task = bus.submit_task("user", "codex", "implement hello", cwd=root)
|
|
19
|
+
done = run_once("codex", bus)
|
|
20
|
+
self.assertEqual(done["id"], task["id"])
|
|
21
|
+
self.assertEqual(done["status"], "completed")
|
|
22
|
+
self.assertIn("[mock:codex]", done["result"])
|
|
23
|
+
|
|
24
|
+
def test_missing_configured_agent_fails_task(self):
|
|
25
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
26
|
+
root = Path(tmp)
|
|
27
|
+
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
28
|
+
task = bus.submit_task("user", "unknown", "work", cwd=root)
|
|
29
|
+
done = run_once("unknown", bus)
|
|
30
|
+
self.assertEqual(done["id"], task["id"])
|
|
31
|
+
self.assertEqual(done["status"], "failed")
|
|
32
|
+
self.assertIn("agent not configured", done["error"])
|
|
33
|
+
|
|
34
|
+
def test_command_adapter_failure_is_recorded(self):
|
|
35
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
36
|
+
root = Path(tmp)
|
|
37
|
+
agents_path = root / "agents.json"
|
|
38
|
+
agents_path.write_text(json.dumps({
|
|
39
|
+
"agents": {
|
|
40
|
+
"codex": {
|
|
41
|
+
"mode": "command",
|
|
42
|
+
"command": ["definitely-not-a-real-trinity-lite-command"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}), encoding="utf-8")
|
|
46
|
+
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
47
|
+
task = bus.submit_task("user", "codex", "work", cwd=root)
|
|
48
|
+
done = run_once("codex", bus, str(agents_path))
|
|
49
|
+
self.assertEqual(done["id"], task["id"])
|
|
50
|
+
self.assertEqual(done["status"], "failed")
|
|
51
|
+
self.assertIn("executable not found", done["error"])
|
|
52
|
+
|
|
53
|
+
def test_memory_error_is_not_swallowed(self):
|
|
54
|
+
class OomAdapter:
|
|
55
|
+
def run(self, task):
|
|
56
|
+
raise MemoryError("oom")
|
|
57
|
+
|
|
58
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
59
|
+
root = Path(tmp)
|
|
60
|
+
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
61
|
+
task = bus.submit_task("user", "codex", "work", cwd=root)
|
|
62
|
+
with patch("trinity_lite.worker.build_adapter", return_value=OomAdapter()):
|
|
63
|
+
with self.assertRaises(MemoryError):
|
|
64
|
+
run_once("codex", bus)
|
|
65
|
+
self.assertEqual(bus.get_task(task["id"])["status"], "running")
|
|
66
|
+
|
|
67
|
+
# ── New daemon tests ──
|
|
68
|
+
|
|
69
|
+
def test_run_loop_stop_flag(self):
|
|
70
|
+
"""Start loop, set stop_flag, verify exits cleanly."""
|
|
71
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
72
|
+
root = Path(tmp)
|
|
73
|
+
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
74
|
+
_stop_flag.clear()
|
|
75
|
+
# Set stop flag after a short delay in a separate thread
|
|
76
|
+
import threading
|
|
77
|
+
def set_stop():
|
|
78
|
+
time.sleep(0.15)
|
|
79
|
+
_stop_flag.set()
|
|
80
|
+
t = threading.Thread(target=set_stop)
|
|
81
|
+
t.start()
|
|
82
|
+
exit_code = run_loop("codex", bus, poll_seconds=0.05)
|
|
83
|
+
t.join()
|
|
84
|
+
self.assertEqual(exit_code, 0)
|
|
85
|
+
|
|
86
|
+
def test_pid_file_creation(self):
|
|
87
|
+
"""Verify PID file created with correct contents."""
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
pid_path = Path(tmp) / "test.pid"
|
|
90
|
+
_acquire_pid_file(pid_path)
|
|
91
|
+
self.assertTrue(pid_path.exists())
|
|
92
|
+
pid = int(pid_path.read_text().strip())
|
|
93
|
+
self.assertEqual(pid, os.getpid())
|
|
94
|
+
_cleanup_pid_file(pid_path)
|
|
95
|
+
|
|
96
|
+
def test_pid_file_cleanup_on_exit(self):
|
|
97
|
+
"""Verify PID file created during daemon loop and cleanup works."""
|
|
98
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
99
|
+
root = Path(tmp)
|
|
100
|
+
pid_path = Path(tmp) / "cleanup_test.pid"
|
|
101
|
+
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
102
|
+
_stop_flag.clear()
|
|
103
|
+
import threading
|
|
104
|
+
def set_stop():
|
|
105
|
+
time.sleep(0.15)
|
|
106
|
+
_stop_flag.set()
|
|
107
|
+
t = threading.Thread(target=set_stop)
|
|
108
|
+
t.start()
|
|
109
|
+
exit_code = run_loop("codex", bus, poll_seconds=0.05, pid_file=pid_path)
|
|
110
|
+
t.join()
|
|
111
|
+
self.assertEqual(exit_code, 0)
|
|
112
|
+
# PID file was created
|
|
113
|
+
self.assertTrue(pid_path.exists())
|
|
114
|
+
# Manually verify cleanup works (atexit fires at process exit)
|
|
115
|
+
_cleanup_pid_file(pid_path)
|
|
116
|
+
self.assertFalse(pid_path.exists())
|
|
117
|
+
|
|
118
|
+
def test_duplicate_daemon_prevented(self):
|
|
119
|
+
"""Verify second attempt to acquire PID raises error."""
|
|
120
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
121
|
+
pid_path = Path(tmp) / "dup.pid"
|
|
122
|
+
# Write our own PID first
|
|
123
|
+
_acquire_pid_file(pid_path)
|
|
124
|
+
self.assertTrue(pid_path.exists())
|
|
125
|
+
# Second attempt should raise
|
|
126
|
+
with self.assertRaises(RuntimeError):
|
|
127
|
+
_check_existing_pid(pid_path)
|
|
128
|
+
_cleanup_pid_file(pid_path)
|
|
129
|
+
|
|
130
|
+
def test_stale_pid_cleanup(self):
|
|
131
|
+
"""Write fake PID, verify it's treated as stale and can be re-acquired."""
|
|
132
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
133
|
+
pid_path = Path(tmp) / "stale.pid"
|
|
134
|
+
# Write a PID that doesn't exist
|
|
135
|
+
pid_path.write_text("99999")
|
|
136
|
+
# Should be treated as stale (None returned)
|
|
137
|
+
result = _check_existing_pid(pid_path)
|
|
138
|
+
self.assertIsNone(result)
|
|
139
|
+
|
|
140
|
+
def test_read_pid_file(self):
|
|
141
|
+
"""Test _read_pid_file returns correct PID."""
|
|
142
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
143
|
+
pid_path = Path(tmp) / "read.pid"
|
|
144
|
+
pid_path.write_text("42")
|
|
145
|
+
self.assertEqual(_read_pid_file(pid_path), 42)
|
|
146
|
+
# Corrupt file
|
|
147
|
+
pid_path.write_text("not_a_pid")
|
|
148
|
+
self.assertIsNone(_read_pid_file(pid_path))
|
|
149
|
+
# Missing file
|
|
150
|
+
self.assertIsNone(_read_pid_file(Path(tmp) / "nonexistent.pid"))
|
|
151
|
+
|
|
152
|
+
def test_default_pid_path(self):
|
|
153
|
+
"""Test default PID path resolution."""
|
|
154
|
+
path = _default_pid_path("codex")
|
|
155
|
+
self.assertIn(".trinity", str(path))
|
|
156
|
+
self.assertIn("workers", str(path))
|
|
157
|
+
self.assertTrue(str(path).endswith("codex.pid"))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
unittest.main()
|
|
@@ -12,8 +12,9 @@ from typing import Any
|
|
|
12
12
|
from .bus import TrinityBus
|
|
13
13
|
from .doctor import run_doctor
|
|
14
14
|
from .orchestrator import run_review_flow
|
|
15
|
+
from .pipeline import load_pipeline, run_pipeline
|
|
15
16
|
from .router import resolve_route
|
|
16
|
-
from .worker import run_loop, run_once
|
|
17
|
+
from .worker import _default_pid_path, run_loop, run_once
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def print_json(data: Any) -> None:
|
|
@@ -77,6 +78,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
77
78
|
|
|
78
79
|
orchestrate = sub.add_parser("orchestrate", parents=[common], help="dispatch and run a primary task with optional review")
|
|
79
80
|
orchestrate.add_argument("task")
|
|
81
|
+
orchestrate.add_argument("--pipeline", default=None, help="path to pipeline YAML (uses pipeline orchestration instead of review flow)")
|
|
80
82
|
orchestrate.add_argument("--source", default="user")
|
|
81
83
|
orchestrate.add_argument("--type", dest="task_type")
|
|
82
84
|
orchestrate.add_argument("--previous-agent")
|
|
@@ -94,8 +96,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
94
96
|
|
|
95
97
|
worker = sub.add_parser("worker", parents=[common], help="run a worker for one agent")
|
|
96
98
|
worker.add_argument("agent")
|
|
97
|
-
worker.add_argument("--once", action="store_true")
|
|
98
|
-
worker.add_argument("--poll", type=float, default=2.0)
|
|
99
|
+
worker.add_argument("--once", action="store_true", help="run a single worker cycle and exit")
|
|
100
|
+
worker.add_argument("--poll", type=float, default=2.0, help="poll interval in seconds (default: 2.0)")
|
|
101
|
+
worker.add_argument("--daemon", action="store_true", help="run in continuous daemon mode with signal handling and PID locking")
|
|
102
|
+
worker.add_argument("--pid-file", default=None, help="custom PID file path (default: ~/.trinity/workers/<agent>.pid)")
|
|
99
103
|
|
|
100
104
|
send = sub.add_parser("send", parents=[common], help="send a durable message")
|
|
101
105
|
send.add_argument("target_agent")
|
|
@@ -198,17 +202,30 @@ def run_command(args: argparse.Namespace) -> int:
|
|
|
198
202
|
print_json(task)
|
|
199
203
|
return 0
|
|
200
204
|
if args.command == "orchestrate":
|
|
201
|
-
|
|
202
|
-
args.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
205
|
+
if args.pipeline:
|
|
206
|
+
pipeline = load_pipeline(args.pipeline)
|
|
207
|
+
result = run_pipeline(
|
|
208
|
+
pipeline,
|
|
209
|
+
args.task,
|
|
210
|
+
bus,
|
|
211
|
+
args.routes,
|
|
212
|
+
args.agents,
|
|
213
|
+
args.source,
|
|
214
|
+
cwd=args.cwd,
|
|
215
|
+
run_workers=not args.no_run and not getattr(args, "wait", False),
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
result = run_review_flow(
|
|
219
|
+
args.task,
|
|
220
|
+
bus,
|
|
221
|
+
args.routes,
|
|
222
|
+
args.agents,
|
|
223
|
+
args.source,
|
|
224
|
+
args.task_type,
|
|
225
|
+
args.previous_agent,
|
|
226
|
+
args.cwd,
|
|
227
|
+
run_workers=not args.no_run and not getattr(args, "wait", False),
|
|
228
|
+
)
|
|
212
229
|
if getattr(args, "wait", False):
|
|
213
230
|
result = _wait_for_flow(bus, result, args.agents, args.routes, args.wait_timeout)
|
|
214
231
|
print_json(result)
|
|
@@ -223,6 +240,10 @@ def run_command(args: argparse.Namespace) -> int:
|
|
|
223
240
|
if args.once:
|
|
224
241
|
print_json(run_once(args.agent, bus, args.agents))
|
|
225
242
|
return 0
|
|
243
|
+
if args.daemon:
|
|
244
|
+
pid_file = args.pid_file or str(_default_pid_path(args.agent))
|
|
245
|
+
return run_loop(args.agent, bus, args.agents, args.poll, pid_file=pid_file)
|
|
246
|
+
# Default: loop without daemon features (backwards compatible)
|
|
226
247
|
run_loop(args.agent, bus, args.agents, args.poll)
|
|
227
248
|
return 0
|
|
228
249
|
if args.command == "send":
|
|
@@ -17,8 +17,9 @@ from typing import Any
|
|
|
17
17
|
from .bus import TERMINAL_STATUSES, TrinityBus
|
|
18
18
|
from .doctor import run_doctor
|
|
19
19
|
from .guard import GuardError
|
|
20
|
+
from .pipeline import load_pipeline, run_pipeline
|
|
20
21
|
from .router import resolve_route
|
|
21
|
-
from .worker import run_once as worker_run_once
|
|
22
|
+
from .worker import _default_pid_path, _read_pid_file, run_once as worker_run_once
|
|
22
23
|
|
|
23
24
|
# ---------------------------------------------------------------------------
|
|
24
25
|
# agent-skill-system integration (optional, graceful fallback)
|
|
@@ -67,7 +68,7 @@ def _init_skill_engine() -> bool:
|
|
|
67
68
|
JSONRPC_VERSION = "2.0"
|
|
68
69
|
PROTOCOL_VERSION = "2024-11-05"
|
|
69
70
|
SERVER_NAME = "trinity-lite-mcp"
|
|
70
|
-
SERVER_VERSION = "0.
|
|
71
|
+
SERVER_VERSION = "0.3.1"
|
|
71
72
|
|
|
72
73
|
# ---------------------------------------------------------------------------
|
|
73
74
|
# Tool definitions (MCP schema)
|
|
@@ -115,6 +116,21 @@ TOOL_DEFINITIONS = [
|
|
|
115
116
|
},
|
|
116
117
|
["task"],
|
|
117
118
|
),
|
|
119
|
+
_tool_def(
|
|
120
|
+
"trinity_orchestrate",
|
|
121
|
+
"Orchestrate a multi-step pipeline from a YAML file or run the default review flow.",
|
|
122
|
+
{
|
|
123
|
+
"task": {"type": "string", "description": "Task prompt"},
|
|
124
|
+
"source_agent": {"type": "string", "description": "Originating agent id (default: mcp)"},
|
|
125
|
+
"cwd": {"type": "string", "description": "Working directory (default: $HOME)"},
|
|
126
|
+
"task_type": {"type": "string", "description": "Task type hint"},
|
|
127
|
+
"previous_agent": {"type": "string", "description": "Previous agent for avoidance"},
|
|
128
|
+
"pipeline": {"type": "string", "description": "Path to pipeline YAML file"},
|
|
129
|
+
"wait": {"type": "boolean", "description": "Block until task completes"},
|
|
130
|
+
"wait_timeout": {"type": "number", "description": "Timeout in seconds for wait"},
|
|
131
|
+
},
|
|
132
|
+
["task"],
|
|
133
|
+
),
|
|
118
134
|
_tool_def(
|
|
119
135
|
"trinity_status",
|
|
120
136
|
"Get the current state and result of a task.",
|
|
@@ -141,6 +157,15 @@ TOOL_DEFINITIONS = [
|
|
|
141
157
|
},
|
|
142
158
|
["agent"],
|
|
143
159
|
),
|
|
160
|
+
_tool_def(
|
|
161
|
+
"trinity_worker_daemon",
|
|
162
|
+
"Start, stop, or check status of a daemon worker.",
|
|
163
|
+
{
|
|
164
|
+
"agent": {"type": "string", "description": "Agent name"},
|
|
165
|
+
"action": {"type": "string", "enum": ["start", "stop", "status"], "description": "Action to perform"},
|
|
166
|
+
},
|
|
167
|
+
["agent", "action"],
|
|
168
|
+
),
|
|
144
169
|
_tool_def(
|
|
145
170
|
"trinity_doctor",
|
|
146
171
|
"Run health and diagnostic checks.",
|
|
@@ -621,17 +646,150 @@ def _handle_trinity_skill_load(params, bus, agents_path, routes_path):
|
|
|
621
646
|
})
|
|
622
647
|
|
|
623
648
|
|
|
649
|
+
|
|
650
|
+
def _handle_trinity_orchestrate(params, bus, agents_path, routes_path):
|
|
651
|
+
"""Handle trinity_orchestrate: orchestrate pipeline or review flow."""
|
|
652
|
+
prompt = _validate_text(params["task"])
|
|
653
|
+
source = params.get("source_agent", "mcp")
|
|
654
|
+
source = _validate_text(source, 128)
|
|
655
|
+
cwd = params.get("cwd", os.environ.get("HOME", str(Path.home())))
|
|
656
|
+
task_type = params.get("task_type") or None
|
|
657
|
+
previous_agent = params.get("previous_agent") or None
|
|
658
|
+
pipeline_path = params.get("pipeline") or None
|
|
659
|
+
wait = params.get("wait", False)
|
|
660
|
+
wait_timeout = _validate_timeout(params.get("wait_timeout"))
|
|
661
|
+
|
|
662
|
+
known = _get_known_agents(bus, agents_path)
|
|
663
|
+
_validate_agent_id(source, known)
|
|
664
|
+
|
|
665
|
+
if pipeline_path:
|
|
666
|
+
pipeline = load_pipeline(pipeline_path)
|
|
667
|
+
result = run_pipeline(
|
|
668
|
+
pipeline,
|
|
669
|
+
prompt,
|
|
670
|
+
bus,
|
|
671
|
+
routes_path=routes_path,
|
|
672
|
+
agents_path=agents_path,
|
|
673
|
+
source_agent=source,
|
|
674
|
+
cwd=cwd,
|
|
675
|
+
run_workers=True,
|
|
676
|
+
)
|
|
677
|
+
return jsonrpc_response(1, result)
|
|
678
|
+
else:
|
|
679
|
+
from .orchestrator import run_review_flow
|
|
680
|
+
result = run_review_flow(
|
|
681
|
+
prompt,
|
|
682
|
+
bus,
|
|
683
|
+
routes_path=routes_path,
|
|
684
|
+
agents_path=agents_path,
|
|
685
|
+
source_agent=source,
|
|
686
|
+
task_type=task_type,
|
|
687
|
+
previous_agent=previous_agent,
|
|
688
|
+
cwd=cwd,
|
|
689
|
+
run_workers=True,
|
|
690
|
+
)
|
|
691
|
+
return jsonrpc_response(1, result)
|
|
692
|
+
def _handle_trinity_worker_daemon(params, bus, agents_path, routes_path):
|
|
693
|
+
"""Handle trinity_worker_daemon: start/stop/status a daemon worker."""
|
|
694
|
+
import subprocess as _sp
|
|
695
|
+
import time as _time
|
|
696
|
+
|
|
697
|
+
agent = _validate_text(params["agent"], 128)
|
|
698
|
+
action = params.get("action", "status")
|
|
699
|
+
|
|
700
|
+
if action not in ("start", "stop", "status"):
|
|
701
|
+
return jsonrpc_error(1, -32602, "action must be one of: start, stop, status")
|
|
702
|
+
|
|
703
|
+
pid_path = _default_pid_path(agent)
|
|
704
|
+
|
|
705
|
+
if action == "status":
|
|
706
|
+
pid = _read_pid_file(pid_path)
|
|
707
|
+
if pid is not None:
|
|
708
|
+
try:
|
|
709
|
+
os.kill(pid, 0)
|
|
710
|
+
return jsonrpc_response(1, {"running": True, "pid": pid, "pid_file": str(pid_path)})
|
|
711
|
+
except OSError:
|
|
712
|
+
pass
|
|
713
|
+
return jsonrpc_response(1, {"running": False, "pid_file": str(pid_path)})
|
|
714
|
+
|
|
715
|
+
if action == "stop":
|
|
716
|
+
pid = _read_pid_file(pid_path)
|
|
717
|
+
if pid is None:
|
|
718
|
+
# Clean up stale pid file if exists
|
|
719
|
+
try:
|
|
720
|
+
pid_path.unlink(missing_ok=True)
|
|
721
|
+
except OSError:
|
|
722
|
+
pass
|
|
723
|
+
return jsonrpc_response(1, {"stopped": True, "message": "no running daemon found"})
|
|
724
|
+
try:
|
|
725
|
+
os.kill(pid, signal.SIGTERM)
|
|
726
|
+
except OSError:
|
|
727
|
+
pid_path.unlink(missing_ok=True)
|
|
728
|
+
return jsonrpc_response(1, {"stopped": True, "message": "stale PID cleaned up"})
|
|
729
|
+
|
|
730
|
+
# Wait up to 5s for graceful shutdown
|
|
731
|
+
deadline = _time.monotonic() + 5
|
|
732
|
+
while _time.monotonic() < deadline:
|
|
733
|
+
try:
|
|
734
|
+
os.kill(pid, 0)
|
|
735
|
+
except OSError:
|
|
736
|
+
break
|
|
737
|
+
_time.sleep(0.1)
|
|
738
|
+
else:
|
|
739
|
+
# Force kill
|
|
740
|
+
try:
|
|
741
|
+
os.kill(pid, signal.SIGKILL)
|
|
742
|
+
except OSError:
|
|
743
|
+
pass
|
|
744
|
+
|
|
745
|
+
# Clean PID file
|
|
746
|
+
try:
|
|
747
|
+
pid_path.unlink(missing_ok=True)
|
|
748
|
+
except OSError:
|
|
749
|
+
pass
|
|
750
|
+
|
|
751
|
+
return jsonrpc_response(1, {"stopped": True, "pid": pid})
|
|
752
|
+
|
|
753
|
+
if action == "start":
|
|
754
|
+
# Check if already running
|
|
755
|
+
pid = _read_pid_file(pid_path)
|
|
756
|
+
if pid is not None:
|
|
757
|
+
try:
|
|
758
|
+
os.kill(pid, 0)
|
|
759
|
+
return jsonrpc_error(1, -32000,
|
|
760
|
+
"worker daemon for '{}' is already running (PID {})".format(agent, pid))
|
|
761
|
+
except OSError:
|
|
762
|
+
pid_path.unlink(missing_ok=True)
|
|
763
|
+
|
|
764
|
+
# Spawn daemon via subprocess (non-blocking)
|
|
765
|
+
try:
|
|
766
|
+
cmd = [sys.executable, "-m", "trinity_lite", "worker", agent, "--daemon"]
|
|
767
|
+
proc = _sp.Popen(cmd, stdin=_sp.DEVNULL, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
|
|
768
|
+
# Give it a moment to acquire PID lock
|
|
769
|
+
_time.sleep(0.3)
|
|
770
|
+
pid = _read_pid_file(pid_path)
|
|
771
|
+
return jsonrpc_response(1, {
|
|
772
|
+
"started": True,
|
|
773
|
+
"pid": pid or proc.pid,
|
|
774
|
+
"pid_file": str(pid_path),
|
|
775
|
+
})
|
|
776
|
+
except Exception as exc:
|
|
777
|
+
return jsonrpc_error(1, -32603, "failed to start daemon: {}".format(exc))
|
|
778
|
+
|
|
779
|
+
|
|
624
780
|
TOOL_HANDLERS = {
|
|
625
781
|
"trinity_dispatch": _handle_trinity_dispatch,
|
|
626
782
|
"trinity_dispatch_auto": _handle_trinity_dispatch_auto,
|
|
627
783
|
"trinity_status": _handle_trinity_status,
|
|
628
784
|
"trinity_tasks": _handle_trinity_tasks,
|
|
629
785
|
"trinity_worker": _handle_trinity_worker,
|
|
786
|
+
"trinity_worker_daemon": _handle_trinity_worker_daemon,
|
|
630
787
|
"trinity_doctor": _handle_trinity_doctor,
|
|
631
788
|
"trinity_inbox": _handle_trinity_inbox,
|
|
632
789
|
"trinity_send": _handle_trinity_send,
|
|
633
790
|
"trinity_skill_search": _handle_trinity_skill_search,
|
|
634
791
|
"trinity_skill_load": _handle_trinity_skill_load,
|
|
792
|
+
"trinity_orchestrate": _handle_trinity_orchestrate,
|
|
635
793
|
}
|
|
636
794
|
|
|
637
795
|
|
|
@@ -10,6 +10,17 @@ from .router import resolve_route
|
|
|
10
10
|
from .worker import run_once
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
BUILTIN_REVIEW_PIPELINE = {
|
|
14
|
+
"name": "review",
|
|
15
|
+
"description": "Built-in implement-then-review pipeline",
|
|
16
|
+
"steps": [
|
|
17
|
+
{"id": "implement", "agent": "", "task_type": "", "prompt_template": "{task}"},
|
|
18
|
+
{"id": "review_step", "agent": "", "task_type": "code_review",
|
|
19
|
+
"prompt_template": "Review: {task}\n\nResult:\n{steps.implement.result}"},
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
13
24
|
def run_review_flow(
|
|
14
25
|
task: str,
|
|
15
26
|
bus: TrinityBus,
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""YAML pipeline orchestration for Trinity Lite.
|
|
2
|
+
|
|
3
|
+
Defines load/validate/execute for N-step sequential pipelines.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .bus import TrinityBus
|
|
13
|
+
from .router import resolve_route
|
|
14
|
+
from .worker import run_once
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_yaml(path: str) -> dict[str, Any]:
|
|
18
|
+
"""Load a YAML file, falling back gracefully."""
|
|
19
|
+
try:
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
23
|
+
return yaml.safe_load(fh)
|
|
24
|
+
except ImportError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
31
|
+
return json.load(fh)
|
|
32
|
+
except (json.JSONDecodeError, ValueError):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Cannot parse pipeline file {path}: yaml module not available and file is not valid JSON"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_RE_STEP_REF = re.compile(
|
|
39
|
+
r"\{steps\.([a-zA-Z_][a-zA-Z0-9_]*)\.result\}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
REQUIRED_STEP_FIELDS = ("id", "agent", "task_type", "prompt_template")
|
|
43
|
+
|
|
44
|
+
def _validate_pipeline(pipeline: dict[str, Any]) -> None:
|
|
45
|
+
"""Validate a loaded pipeline dictionary."""
|
|
46
|
+
if not isinstance(pipeline, dict):
|
|
47
|
+
raise ValueError("pipeline must be a dictionary")
|
|
48
|
+
|
|
49
|
+
name = pipeline.get("name")
|
|
50
|
+
if not name or not isinstance(name, str):
|
|
51
|
+
raise ValueError("pipeline must have non-empty name")
|
|
52
|
+
|
|
53
|
+
steps = pipeline.get("steps")
|
|
54
|
+
if not steps or not isinstance(steps, list):
|
|
55
|
+
raise ValueError("pipeline must have non-empty steps list")
|
|
56
|
+
|
|
57
|
+
step_ids: set[str] = set()
|
|
58
|
+
|
|
59
|
+
for i, step in enumerate(steps):
|
|
60
|
+
if not isinstance(step, dict):
|
|
61
|
+
raise ValueError(f"step[{i}] must be dict, got {type(step).__name__}")
|
|
62
|
+
|
|
63
|
+
for field in REQUIRED_STEP_FIELDS:
|
|
64
|
+
val = step.get(field)
|
|
65
|
+
if not val or not isinstance(val, str):
|
|
66
|
+
raise ValueError(f"step[{i}] missing field {field}: {val!r}")
|
|
67
|
+
|
|
68
|
+
sid = step["id"]
|
|
69
|
+
if sid in step_ids:
|
|
70
|
+
raise ValueError(f"duplicate step id: {sid!r}")
|
|
71
|
+
step_ids.add(sid)
|
|
72
|
+
|
|
73
|
+
for step in steps:
|
|
74
|
+
refs = _RE_STEP_REF.findall(step["prompt_template"])
|
|
75
|
+
for ref in refs:
|
|
76
|
+
if ref not in step_ids:
|
|
77
|
+
raise ValueError(f"step references unknown step: {ref}")
|
|
78
|
+
|
|
79
|
+
def load_pipeline(path: str) -> dict[str, Any]:
|
|
80
|
+
"""Load and validate a pipeline YAML (or JSON) file."""
|
|
81
|
+
pipeline = _load_yaml(path)
|
|
82
|
+
_validate_pipeline(pipeline)
|
|
83
|
+
return pipeline
|
|
84
|
+
|
|
85
|
+
def resolve_step_prompt(step: dict[str, Any], task: str, step_results: dict[str, Any]) -> str:
|
|
86
|
+
"""Render prompt_template with {task} and step references."""
|
|
87
|
+
template = step["prompt_template"]
|
|
88
|
+
|
|
89
|
+
def _replace_step_ref(m):
|
|
90
|
+
sid = m.group(1)
|
|
91
|
+
info = step_results.get(sid, {})
|
|
92
|
+
return str(info.get("result") or info.get("error") or "")
|
|
93
|
+
|
|
94
|
+
rendered = _RE_STEP_REF.sub(_replace_step_ref, template)
|
|
95
|
+
rendered = rendered.replace("{task}", task)
|
|
96
|
+
return rendered
|
|
97
|
+
|
|
98
|
+
def run_pipeline(
|
|
99
|
+
pipeline: dict[str, Any],
|
|
100
|
+
task: str,
|
|
101
|
+
bus: TrinityBus,
|
|
102
|
+
routes_path: str | None = None,
|
|
103
|
+
agents_path: str | None = None,
|
|
104
|
+
source_agent: str = "user",
|
|
105
|
+
cwd: str | None = None,
|
|
106
|
+
run_workers: bool = True,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""Execute pipeline steps sequentially."""
|
|
109
|
+
workdir = cwd or os.getcwd()
|
|
110
|
+
step_results: dict[str, Any] = {}
|
|
111
|
+
step_summaries: list[dict[str, Any]] = []
|
|
112
|
+
|
|
113
|
+
for step in pipeline["steps"]:
|
|
114
|
+
sid = step["id"]
|
|
115
|
+
rendered_prompt = resolve_step_prompt(step, task, step_results)
|
|
116
|
+
target_agent = step["agent"]
|
|
117
|
+
|
|
118
|
+
submitted = bus.submit_task(
|
|
119
|
+
source_agent=source_agent,
|
|
120
|
+
target_agent=target_agent,
|
|
121
|
+
prompt=rendered_prompt,
|
|
122
|
+
task_type=step.get("task_type"),
|
|
123
|
+
cwd=workdir,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if run_workers:
|
|
127
|
+
result = run_once(target_agent, bus, agents_path, task_id=submitted["id"])
|
|
128
|
+
if result is None:
|
|
129
|
+
result = bus.get_task(submitted["id"])
|
|
130
|
+
else:
|
|
131
|
+
result = submitted
|
|
132
|
+
|
|
133
|
+
step_results[sid] = result
|
|
134
|
+
summary = {
|
|
135
|
+
"id": sid,
|
|
136
|
+
"task_id": result["id"] if result else submitted["id"],
|
|
137
|
+
"status": result.get("status", "unknown") if result else "unknown",
|
|
138
|
+
"result": result.get("result") if result else None,
|
|
139
|
+
}
|
|
140
|
+
step_summaries.append(summary)
|
|
141
|
+
|
|
142
|
+
if result and result.get("status") == "failed":
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
all_done = all(s["status"] == "completed" for s in step_summaries)
|
|
146
|
+
any_fail = any(s["status"] == "failed" for s in step_summaries)
|
|
147
|
+
if any_fail:
|
|
148
|
+
overall = "failed"
|
|
149
|
+
elif all_done:
|
|
150
|
+
overall = "completed"
|
|
151
|
+
else:
|
|
152
|
+
overall = "queued"
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"pipeline_name": pipeline["name"],
|
|
156
|
+
"steps": step_summaries,
|
|
157
|
+
"overall_status": overall,
|
|
158
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Worker loop for Trinity Lite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import traceback
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .adapters import AdapterError, build_adapter, load_specs
|
|
17
|
+
from .bus import TrinityBus
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Stop-flag pattern (Huey standard) — signal handlers ONLY set flags
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
_stop_flag: threading.Event = threading.Event()
|
|
24
|
+
"""Set by SIGTERM (immediate) or SIGINT (graceful — finish current task)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _signal_handler(signum: int, frame: Any) -> None: # pragma: no cover — signal integration
|
|
28
|
+
reason = signal.Signals(signum).name
|
|
29
|
+
if signum == signal.SIGTERM:
|
|
30
|
+
print(f"[worker] received {reason} — shutting down", file=sys.stderr, flush=True)
|
|
31
|
+
_stop_flag.set()
|
|
32
|
+
elif signum == signal.SIGINT:
|
|
33
|
+
print(f"[worker] received {reason} — finishing current task then exiting", file=sys.stderr, flush=True)
|
|
34
|
+
_stop_flag.set()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _install_signal_handlers() -> None:
|
|
38
|
+
"""Install POSIX signal handlers. Idempotent — safe to call multiple times."""
|
|
39
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
40
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# PID file management
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
_PID_DIR = Path.home() / ".trinity" / "workers"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _default_pid_path(agent: str) -> Path:
|
|
51
|
+
"""Return the default PID file path for a given agent."""
|
|
52
|
+
return _PID_DIR / f"{agent}.pid"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _acquire_pid_file(pid_path: Path) -> None:
|
|
56
|
+
"""Atomically create + lock a PID file.
|
|
57
|
+
|
|
58
|
+
Raises RuntimeError if another daemon is already running for this agent.
|
|
59
|
+
Cleans up stale PID files (process no longer exists).
|
|
60
|
+
"""
|
|
61
|
+
# Ensure parent directory exists
|
|
62
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
fd = os.open(str(pid_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
if exc.errno != errno.EEXIST:
|
|
68
|
+
raise
|
|
69
|
+
# PID file exists — check if process is still alive
|
|
70
|
+
fd = _check_existing_pid(pid_path)
|
|
71
|
+
if fd is None:
|
|
72
|
+
# Stale — recreate after removing
|
|
73
|
+
pid_path.unlink(missing_ok=True)
|
|
74
|
+
fd = os.open(str(pid_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
75
|
+
|
|
76
|
+
with os.fdopen(fd, "w") as f:
|
|
77
|
+
f.write(str(os.getpid()))
|
|
78
|
+
|
|
79
|
+
# Register cleanup
|
|
80
|
+
atexit.register(_cleanup_pid_file, pid_path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_existing_pid(pid_path: Path) -> int | None:
|
|
84
|
+
"""Check whether the PID stored in *pid_path* corresponds to a live process.
|
|
85
|
+
|
|
86
|
+
Returns None if the PID file is stale (process gone), otherwise raises
|
|
87
|
+
RuntimeError for a live duplicate daemon.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
raw = pid_path.read_text().strip()
|
|
91
|
+
except OSError:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
if not raw:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
pid = int(raw)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
os.kill(pid, 0)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
if exc.errno == errno.ESRCH:
|
|
106
|
+
return None
|
|
107
|
+
if exc.errno == errno.EPERM:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"Worker daemon for agent is already running (PID {pid} exists). "
|
|
110
|
+
f"PID file: {pid_path}"
|
|
111
|
+
) from None
|
|
112
|
+
raise
|
|
113
|
+
else:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"Worker daemon for agent is already running (PID {pid}). "
|
|
116
|
+
f"Remove {pid_path} if this is stale."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cleanup_pid_file(pid_path: Path) -> None:
|
|
121
|
+
"""Remove the PID file on exit (registered via atexit)."""
|
|
122
|
+
try:
|
|
123
|
+
pid_path.unlink(missing_ok=True)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _read_pid_file(pid_path: Path) -> int | None:
|
|
129
|
+
"""Read the PID from a PID file, returning None if unreadable/corrupt."""
|
|
130
|
+
try:
|
|
131
|
+
raw = pid_path.read_text().strip()
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
if not raw:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
return int(raw)
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Core worker functions
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_once(
|
|
148
|
+
agent: str,
|
|
149
|
+
bus: TrinityBus,
|
|
150
|
+
agents_path: str | None = None,
|
|
151
|
+
task_id: str | None = None,
|
|
152
|
+
) -> dict[str, Any] | None:
|
|
153
|
+
task = bus.task_for_worker(agent, task_id=task_id)
|
|
154
|
+
if task is None:
|
|
155
|
+
return None
|
|
156
|
+
specs = load_specs(agents_path)
|
|
157
|
+
spec = specs.get(agent)
|
|
158
|
+
if spec is None:
|
|
159
|
+
return bus.finish_worker(task["id"], error=f"agent not configured: {agent}")
|
|
160
|
+
try:
|
|
161
|
+
result = build_adapter(spec).run(task)
|
|
162
|
+
return bus.finish_worker(task["id"], result=result)
|
|
163
|
+
except (KeyboardInterrupt, SystemExit, MemoryError):
|
|
164
|
+
raise
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
tb = traceback.format_exc()
|
|
167
|
+
return bus.finish_worker(task["id"], error=f"{exc}\n{tb}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_loop(
|
|
171
|
+
agent: str,
|
|
172
|
+
bus: TrinityBus,
|
|
173
|
+
agents_path: str | None = None,
|
|
174
|
+
poll_seconds: float = 2.0,
|
|
175
|
+
pid_file: str | Path | None = None,
|
|
176
|
+
) -> int:
|
|
177
|
+
"""Run the worker loop continuously until a stop signal is received.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
agent: The agent id to process tasks for.
|
|
181
|
+
bus: The TrinityBus instance.
|
|
182
|
+
agents_path: Path to agents configuration JSON.
|
|
183
|
+
poll_seconds: Sleep duration between polls when no task is available.
|
|
184
|
+
pid_file: Optional path to the PID lock file. When provided, the PID
|
|
185
|
+
file is acquired before entering the loop and cleaned up on exit.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Exit code: 0 on clean shutdown, 1 if pid lock acquisition fails.
|
|
189
|
+
"""
|
|
190
|
+
if pid_file is not None:
|
|
191
|
+
pid_path = Path(pid_file)
|
|
192
|
+
try:
|
|
193
|
+
_acquire_pid_file(pid_path)
|
|
194
|
+
except RuntimeError as exc:
|
|
195
|
+
print(f"[worker] {exc}", file=sys.stderr, flush=True)
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
_install_signal_handlers()
|
|
199
|
+
|
|
200
|
+
while not _stop_flag.is_set():
|
|
201
|
+
handled = run_once(agent, bus, agents_path)
|
|
202
|
+
if handled is None and not _stop_flag.is_set():
|
|
203
|
+
time.sleep(poll_seconds)
|
|
204
|
+
|
|
205
|
+
print("[worker] daemon stopped", file=sys.stderr, flush=True)
|
|
206
|
+
return 0
|
|
@@ -7,6 +7,7 @@ tests/test_doctor.py
|
|
|
7
7
|
tests/test_guard.py
|
|
8
8
|
tests/test_mcp_server.py
|
|
9
9
|
tests/test_orchestrator.py
|
|
10
|
+
tests/test_pipeline.py
|
|
10
11
|
tests/test_router.py
|
|
11
12
|
tests/test_worker.py
|
|
12
13
|
trinity_lite/__init__.py
|
|
@@ -20,6 +21,7 @@ trinity_lite/guard.py
|
|
|
20
21
|
trinity_lite/mcp_server.py
|
|
21
22
|
trinity_lite/orchestrator.py
|
|
22
23
|
trinity_lite/paths.py
|
|
24
|
+
trinity_lite/pipeline.py
|
|
23
25
|
trinity_lite/py.typed
|
|
24
26
|
trinity_lite/router.py
|
|
25
27
|
trinity_lite/validation.py
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import tempfile
|
|
2
|
-
import unittest
|
|
3
|
-
import json
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from unittest.mock import patch
|
|
6
|
-
|
|
7
|
-
from trinity_lite.bus import TrinityBus
|
|
8
|
-
from trinity_lite.worker import run_once
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class WorkerTest(unittest.TestCase):
|
|
12
|
-
def test_mock_worker_completes_task(self):
|
|
13
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
14
|
-
root = Path(tmp)
|
|
15
|
-
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
16
|
-
task = bus.submit_task("user", "codex", "implement hello", cwd=root)
|
|
17
|
-
done = run_once("codex", bus)
|
|
18
|
-
self.assertEqual(done["id"], task["id"])
|
|
19
|
-
self.assertEqual(done["status"], "completed")
|
|
20
|
-
self.assertIn("[mock:codex]", done["result"])
|
|
21
|
-
|
|
22
|
-
def test_missing_configured_agent_fails_task(self):
|
|
23
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
24
|
-
root = Path(tmp)
|
|
25
|
-
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
26
|
-
task = bus.submit_task("user", "unknown", "work", cwd=root)
|
|
27
|
-
done = run_once("unknown", bus)
|
|
28
|
-
self.assertEqual(done["id"], task["id"])
|
|
29
|
-
self.assertEqual(done["status"], "failed")
|
|
30
|
-
self.assertIn("agent not configured", done["error"])
|
|
31
|
-
|
|
32
|
-
def test_command_adapter_failure_is_recorded(self):
|
|
33
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
34
|
-
root = Path(tmp)
|
|
35
|
-
agents_path = root / "agents.json"
|
|
36
|
-
agents_path.write_text(json.dumps({
|
|
37
|
-
"agents": {
|
|
38
|
-
"codex": {
|
|
39
|
-
"mode": "command",
|
|
40
|
-
"command": ["definitely-not-a-real-trinity-lite-command"]
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}), encoding="utf-8")
|
|
44
|
-
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
45
|
-
task = bus.submit_task("user", "codex", "work", cwd=root)
|
|
46
|
-
done = run_once("codex", bus, str(agents_path))
|
|
47
|
-
self.assertEqual(done["id"], task["id"])
|
|
48
|
-
self.assertEqual(done["status"], "failed")
|
|
49
|
-
self.assertIn("executable not found", done["error"])
|
|
50
|
-
|
|
51
|
-
def test_memory_error_is_not_swallowed(self):
|
|
52
|
-
class OomAdapter:
|
|
53
|
-
def run(self, task):
|
|
54
|
-
raise MemoryError("oom")
|
|
55
|
-
|
|
56
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
57
|
-
root = Path(tmp)
|
|
58
|
-
bus = TrinityBus(root / "bus.db", allowed_roots=[root])
|
|
59
|
-
task = bus.submit_task("user", "codex", "work", cwd=root)
|
|
60
|
-
with patch("trinity_lite.worker.build_adapter", return_value=OomAdapter()):
|
|
61
|
-
with self.assertRaises(MemoryError):
|
|
62
|
-
run_once("codex", bus)
|
|
63
|
-
self.assertEqual(bus.get_task(task["id"])["status"], "running")
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if __name__ == "__main__":
|
|
67
|
-
unittest.main()
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"""Worker loop for Trinity Lite."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import time
|
|
6
|
-
import traceback
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from .adapters import AdapterError, build_adapter, load_specs
|
|
10
|
-
from .bus import TrinityBus
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def run_once(
|
|
14
|
-
agent: str,
|
|
15
|
-
bus: TrinityBus,
|
|
16
|
-
agents_path: str | None = None,
|
|
17
|
-
task_id: str | None = None,
|
|
18
|
-
) -> dict[str, Any] | None:
|
|
19
|
-
task = bus.task_for_worker(agent, task_id=task_id)
|
|
20
|
-
if task is None:
|
|
21
|
-
return None
|
|
22
|
-
specs = load_specs(agents_path)
|
|
23
|
-
spec = specs.get(agent)
|
|
24
|
-
if spec is None:
|
|
25
|
-
return bus.finish_worker(task["id"], error=f"agent not configured: {agent}")
|
|
26
|
-
try:
|
|
27
|
-
result = build_adapter(spec).run(task)
|
|
28
|
-
return bus.finish_worker(task["id"], result=result)
|
|
29
|
-
except (KeyboardInterrupt, SystemExit, MemoryError):
|
|
30
|
-
raise
|
|
31
|
-
except Exception as exc:
|
|
32
|
-
tb = traceback.format_exc()
|
|
33
|
-
return bus.finish_worker(task["id"], error=f"{exc}\n{tb}")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def run_loop(
|
|
37
|
-
agent: str,
|
|
38
|
-
bus: TrinityBus,
|
|
39
|
-
agents_path: str | None = None,
|
|
40
|
-
poll_seconds: float = 2.0,
|
|
41
|
-
) -> None:
|
|
42
|
-
while True:
|
|
43
|
-
handled = run_once(agent, bus, agents_path)
|
|
44
|
-
if handled is None:
|
|
45
|
-
time.sleep(poll_seconds)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|