Problem
Tycoon has a process manager — tycoon start / tycoon stop use ServiceManager (src/tycoon/services/manager.py) with a PID file at .tycoon/run/pids.json, signal handling, port-readiness probing, and process-tree termination on stop. It's a solid foundation.
But it's hardcoded to four built-in services: tycoon, rill, dagster, nao (_SERVER_NAMES in src/tycoon/commands/start.py:16). End users can't register their own long-running tasks to be managed alongside these. If you want to background, say, a Streamlit app you wrote on top of your warehouse, or a custom FastAPI route, or a watch-mode dbt run — you're on your own with &, nohup, tmux, or pm2.
There are two more rough edges:
tycoon start blocks the terminal (shutdown.wait() at start.py:78). PIDs are persisted so tycoon stop works from a different shell, but you have to either keep a terminal pinned or background it yourself with &. No --daemon flag.
- No introspection. No
tycoon ps, no tycoon logs <name>, no "is rill actually still running or did it crash and the PID is stale." stdout/stderr go to DEVNULL (manager.py:57-58), so when a service dies silently there's nothing to read.
What end users actually need
The user-facing story we want:
# Register an arbitrary long-running task in tycoon.yml
tycoon serve register my-streamlit \
--command \"streamlit run app.py --server.port 8501\" \
--port 8501
# Run it in the background
tycoon serve start my-streamlit
# See what's running (built-ins + user-registered together)
tycoon ps
# NAME STATUS PID PORT UPTIME COMMAND
# tycoon running 12345 8000 0:14:22 uvicorn tycoon.server.app:app
# rill running 12346 9009 0:14:22 rill start ./rill
# my-streamlit running 12380 8501 0:02:01 streamlit run app.py ...
# Tail logs
tycoon serve logs my-streamlit
# Kill it
tycoon serve stop my-streamlit
# or:
tycoon kill my-streamlit # alias, end-user friendly verb
Proposal
1. New services block in tycoon.yml
services:
my-streamlit:
command: \"streamlit run app.py --server.port 8501\"
port: 8501
cwd: ./streamlit_app # optional, defaults to project root
env: # optional
MY_VAR: foo
autostart: false # if true, included in plain `tycoon start`
Built-in services (tycoon, rill, dagster, nao) keep their current registration in services/definitions.py — the YAML block is purely additive. Names collide with built-ins → clear error.
2. New commands
tycoon serve register NAME --command \"...\" [--port N] [--cwd path] [--env K=V] — write the entry to tycoon.yml. Equivalent to hand-editing the YAML; just removes friction.
tycoon serve unregister NAME — remove the entry.
tycoon serve start NAME — start a single user-registered service, daemonized. (Built-ins still go through tycoon start --only NAME; or unify under tycoon serve start NAME and treat the four built-ins as just more entries.)
tycoon serve stop NAME (alias tycoon kill NAME) — terminate. Reuse the existing process-tree kill logic from commands/stop.py:_kill_pid.
tycoon serve logs NAME [--tail N] [--follow] — read log file. Logs land at .tycoon/run/logs/<name>.log instead of being piped to DEVNULL.
tycoon ps — top-level command (not under serve), shows everything. Status column reflects "running / stopped / crashed (PID file stale)". Unlike today's silent crash, we detect dead PIDs.
tycoon serve restart NAME — convenience wrapper.
3. Daemonization for tycoon start
Add --daemon / -d to the existing tycoon start. Without it, current blocking behavior is preserved (good for live demos and Ctrl-C). With it, fork to background, return shell prompt immediately. PID file already exists; just change the foreground/background story.
4. Crash detection in tycoon ps
When showing status: os.kill(pid, 0) to check if the process exists. If the PID file claims running but the PID is dead, status = crashed (stale). Optional: tycoon ps --prune to clean up stale entries.
5. Logs to disk, not DEVNULL
ServiceManager.start currently does stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL. Switch to .tycoon/run/logs/<name>.log opened in append mode. Rotate at some sane size (10 MB?) — basic, no need for logrotate-grade infrastructure. This is what makes tycoon serve logs possible.
Why this matters
The conference-talk pitch is "tycoon is the one tool for local-first analytics." When users build their own assets on top of tycoon's pipeline (a custom Streamlit app reading from the warehouse, a notebook server, a notification cron), they hit a UX cliff — tycoon manages its own four services flawlessly but their stuff is on its own. Closing that gap is the difference between "tycoon is a great demo runner" and "tycoon is the daily driver for my analytics stack."
The asks here are also concretely small: existing ServiceManager already does ~80% of the work. The missing pieces are (a) a YAML schema for user-registered services, (b) commands to read/write that schema, (c) logs-to-disk instead of DEVNULL, (d) a status view, and (e) --daemon mode.
Out of scope (this issue)
- Cross-host process management. Single machine only. If you need that, you need k8s or systemd; tycoon shouldn't compete.
- Auto-restart on crash. No supervisord-style respawn for v1. Crash detection in
ps is enough; user re-runs serve start.
- Dependency graphs between services. "Wait for rill before starting my-thing" is not in scope. If your service genuinely needs another to be up, write that into your
command (e.g. wait-for-it :9009 -- streamlit run ...).
- Per-service resource limits. Not in v1.
- Scheduled / cron-style jobs. Different problem; that's
tycoon data sources run on a schedule, which is a Dagster concern, not a process-manager one.
Acceptance criteria
Cross-references
src/tycoon/services/manager.py — existing ServiceManager, foundation for this work
src/tycoon/services/definitions.py — where built-in services are registered; the user-services flow should slot into this same machinery
src/tycoon/commands/start.py:16 — _SERVER_NAMES is the hardcode that needs to become dynamic
src/tycoon/commands/stop.py:82 — _kill_pid already implements process-tree termination; reuse for user services
.tycoon/run/pids.json — existing PID file location; extend to include user services
Problem
Tycoon has a process manager —
tycoon start/tycoon stopuseServiceManager(src/tycoon/services/manager.py) with a PID file at.tycoon/run/pids.json, signal handling, port-readiness probing, and process-tree termination on stop. It's a solid foundation.But it's hardcoded to four built-in services:
tycoon,rill,dagster,nao(_SERVER_NAMESinsrc/tycoon/commands/start.py:16). End users can't register their own long-running tasks to be managed alongside these. If you want to background, say, a Streamlit app you wrote on top of your warehouse, or a custom FastAPI route, or a watch-mode dbt run — you're on your own with&,nohup,tmux, orpm2.There are two more rough edges:
tycoon startblocks the terminal (shutdown.wait()atstart.py:78). PIDs are persisted sotycoon stopworks from a different shell, but you have to either keep a terminal pinned or background it yourself with&. No--daemonflag.tycoon ps, notycoon logs <name>, no "is rill actually still running or did it crash and the PID is stale."stdout/stderrgo toDEVNULL(manager.py:57-58), so when a service dies silently there's nothing to read.What end users actually need
The user-facing story we want:
Proposal
1. New
servicesblock intycoon.ymlBuilt-in services (
tycoon,rill,dagster,nao) keep their current registration inservices/definitions.py— the YAML block is purely additive. Names collide with built-ins → clear error.2. New commands
tycoon serve register NAME --command \"...\" [--port N] [--cwd path] [--env K=V]— write the entry totycoon.yml. Equivalent to hand-editing the YAML; just removes friction.tycoon serve unregister NAME— remove the entry.tycoon serve start NAME— start a single user-registered service, daemonized. (Built-ins still go throughtycoon start --only NAME; or unify undertycoon serve start NAMEand treat the four built-ins as just more entries.)tycoon serve stop NAME(aliastycoon kill NAME) — terminate. Reuse the existing process-tree kill logic fromcommands/stop.py:_kill_pid.tycoon serve logs NAME [--tail N] [--follow]— read log file. Logs land at.tycoon/run/logs/<name>.loginstead of being piped to DEVNULL.tycoon ps— top-level command (not underserve), shows everything. Status column reflects "running / stopped / crashed (PID file stale)". Unlike today's silent crash, we detect dead PIDs.tycoon serve restart NAME— convenience wrapper.3. Daemonization for
tycoon startAdd
--daemon/-dto the existingtycoon start. Without it, current blocking behavior is preserved (good for live demos and Ctrl-C). With it, fork to background, return shell prompt immediately. PID file already exists; just change the foreground/background story.4. Crash detection in
tycoon psWhen showing status:
os.kill(pid, 0)to check if the process exists. If the PID file claims running but the PID is dead, status =crashed (stale). Optional:tycoon ps --pruneto clean up stale entries.5. Logs to disk, not DEVNULL
ServiceManager.startcurrently doesstdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL. Switch to.tycoon/run/logs/<name>.logopened in append mode. Rotate at some sane size (10 MB?) — basic, no need for logrotate-grade infrastructure. This is what makestycoon serve logspossible.Why this matters
The conference-talk pitch is "tycoon is the one tool for local-first analytics." When users build their own assets on top of tycoon's pipeline (a custom Streamlit app reading from the warehouse, a notebook server, a notification cron), they hit a UX cliff — tycoon manages its own four services flawlessly but their stuff is on its own. Closing that gap is the difference between "tycoon is a great demo runner" and "tycoon is the daily driver for my analytics stack."
The asks here are also concretely small: existing
ServiceManageralready does ~80% of the work. The missing pieces are (a) a YAML schema for user-registered services, (b) commands to read/write that schema, (c) logs-to-disk instead of DEVNULL, (d) a status view, and (e)--daemonmode.Out of scope (this issue)
psis enough; user re-runsserve start.command(e.g.wait-for-it :9009 -- streamlit run ...).tycoon data sources runon a schedule, which is a Dagster concern, not a process-manager one.Acceptance criteria
tycoon.yml(programmatically viatycoon serve registeror by hand-editing)tycoon serve start NAMEruns the registered command, daemonized, with logs written to.tycoon/run/logs/<name>.logtycoon serve stop NAME/tycoon kill NAMEkills the service and its full process tree (existing_kill_pidlogic incommands/stop.py:82already handles this — reuse it)tycoon psshows built-ins and user services together; detects crashed-but-PID-file-stale state; column for uptimetycoon serve logs NAME [--tail N] [--follow]workstycoon start --daemonreturns immediately instead of blockingstreamlit/rillbinaries — use a fake long-running test command (e.g.sleep 60)docs/commands/serve.md;docs/commands/start.mdanddocs/commands/stop.mdupdated to cross-linkdocs/releases/v<X>.mdentry under whichever cycle this lands inCross-references
src/tycoon/services/manager.py— existingServiceManager, foundation for this worksrc/tycoon/services/definitions.py— where built-in services are registered; the user-services flow should slot into this same machinerysrc/tycoon/commands/start.py:16—_SERVER_NAMESis the hardcode that needs to become dynamicsrc/tycoon/commands/stop.py:82—_kill_pidalready implements process-tree termination; reuse for user services.tycoon/run/pids.json— existing PID file location; extend to include user services