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.
Files changed (38) hide show
  1. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/PKG-INFO +1 -1
  2. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/pyproject.toml +1 -1
  3. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_mcp_server.py +3 -3
  4. trinity_lite-0.3.1/tests/test_pipeline.py +185 -0
  5. trinity_lite-0.3.1/tests/test_worker.py +161 -0
  6. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/__init__.py +1 -1
  7. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/cli.py +35 -14
  8. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/mcp_server.py +160 -2
  9. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/orchestrator.py +11 -0
  10. trinity_lite-0.3.1/trinity_lite/pipeline.py +158 -0
  11. trinity_lite-0.3.1/trinity_lite/worker.py +206 -0
  12. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/PKG-INFO +1 -1
  13. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/SOURCES.txt +2 -0
  14. trinity_lite-0.2.3/tests/test_worker.py +0 -67
  15. trinity_lite-0.2.3/trinity_lite/worker.py +0 -45
  16. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/LICENSE +0 -0
  17. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/README.md +0 -0
  18. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/setup.cfg +0 -0
  19. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_bus.py +0 -0
  20. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_cli.py +0 -0
  21. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_doctor.py +0 -0
  22. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_guard.py +0 -0
  23. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_orchestrator.py +0 -0
  24. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/tests/test_router.py +0 -0
  25. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/__main__.py +0 -0
  26. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/adapters.py +0 -0
  27. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/bus.py +0 -0
  28. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/config.py +0 -0
  29. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/doctor.py +0 -0
  30. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/guard.py +0 -0
  31. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/paths.py +0 -0
  32. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/py.typed +0 -0
  33. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/router.py +0 -0
  34. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite/validation.py +0 -0
  35. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/dependency_links.txt +0 -0
  36. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/entry_points.txt +0 -0
  37. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/requires.txt +0 -0
  38. {trinity_lite-0.2.3 → trinity_lite-0.3.1}/trinity_lite.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trinity-lite
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: The bus that lets your AI agents talk to each other.
5
5
  Author: Yomiracle
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trinity-lite"
7
- version = "0.2.3"
7
+ version = "0.3.1"
8
8
  description = "The bus that lets your AI agents talk to each other."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.2.2")
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), 10)
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"], 10)
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()
@@ -1,3 +1,3 @@
1
1
  """Trinity Lite: a minimal public multi-agent task bus."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.1"
@@ -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
- result = run_review_flow(
202
- args.task,
203
- bus,
204
- args.routes,
205
- args.agents,
206
- args.source,
207
- args.task_type,
208
- args.previous_agent,
209
- args.cwd,
210
- run_workers=not args.no_run and not getattr(args, "wait", False),
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.2.2"
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trinity-lite
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: The bus that lets your AI agents talk to each other.
5
5
  Author: Yomiracle
6
6
  License-Expression: MIT
@@ -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