Commit Graph
82 Commits
Author SHA1 Message Date
host c6754d0233 monitor.sh: worker check accepts autoscaler (static worker retired)
Since the autoscaler takeover (static codex-cloud-worker stopped at
2026-10-26 07:02:55, disabled by design), the monitor's worker check
(systemctl is-active codex-cloud-worker) has failed every 5 minutes
while the real workers were healthy (health worker_ok kept passing,
GET /workers shows live autoscale-1 + host-1). A monitor that always
fails masks real failures. Fix: pass if either codex-autoscaler or
codex-cloud-worker is active, covering both deployment modes; the
health check still covers worker heartbeat freshness.
2026-10-26 11:01:52 +08:00
openqj 7c0e9366f8 Merge pull request #29 from openqj/docker-runtime-prod
Production docker tasks: --docker-runtime runsc in autoscaler unit
2026-10-26 10:38:36 +08:00
host a55d2bcf4b Production docker tasks: --docker-runtime runsc in autoscaler unit
One-off worker verification passed before this flip (2026-10-26):
production-shaped docker task (apparmor=docker-codex, pids-limit 512,
tenant isolation, egress proxy 8888, --docker-runtime runsc) completed
in 60s with gpt-6.1-sol, worktree quadruple + proof verified, template
and tenant repo copies clean. README now states the production state.
2026-10-26 10:36:42 +08:00
openqj 54834c3744 Merge pull request #28 from openqj/docker-runtime-runsc
Docker tasks: --docker-runtime option (runsc support, default off)
2026-10-26 10:29:14 +08:00
host 9f05178f2b Docker tasks: --docker-runtime option (runsc support, default off)
DockerExecutor gains a runtime parameter (passed as docker --runtime
in build_run_command, after --pids-limit); worker CLI gains
--docker-runtime, wired through _build_cli_executor. Unset or empty
means no --runtime flag, so existing tasks keep docker default (runc);
an unregistered runtime fails container start (fail closed), it never
silently falls back. Tests: runsc appears in the command, default/empty
omit it, CLI wiring passes through. README documents the flag and
corrects a stale multi-tenant note (host kernel does not ban userns;
bwrap fails inside Docker containers on nested-pidns proc mount).
2026-10-26 10:26:21 +08:00
openqj b31e9365de Merge pull request #27 from openqj/host-profile-fixes
Fix codex-host profile (capability + netlink for bwrap)
2026-10-26 08:41:53 +08:00
host 4a78ca1e95 Fix codex-host profile: capability allow + netlink (bwrap needs both)
Two bwrap failures found by live host tasks, both in the
AppArmor profile (not in codex or the worker):
1. deny capability sys_admin (etc.) breaks bwrap at userns
   creation: codex-host with the deny list failed, the same
   profile with capability, allowed it. codexsvc holds no host
   capabilities, so the allow only covers fake-root caps inside
   bwrap's own user namespace (scoped, harmless to the host);
   network/ptrace/file denies still hold independently.
2. bwrap brings up loopback in its new net namespace via a
   NETLINK_ROUTE socket: without network netlink raw, codex
   workspace-write fails with 'Failed to bind NETLINK_ROUTE
   socket' and no tool runs. And a bare 'deny network raw'
   overrides the netlink allow (deny wins over allow), so the
   raw deny must name inet/inet6 explicitly (verified by an
   isolated netlink bind test under each profile variant).
README correction in the same change: codex workspace-write
does NOT isolate /tmp -- an agent /tmp write lands on host
/tmp (verified). The verified boundary is: own worktree
writable, everything else read-only, /tmp shared.
2026-10-26 08:39:57 +08:00
openqj bfe3521ef7 Merge pull request #26 from openqj/host-sandbox
Host-level sandbox tasks (LocalExecutor + bwrap)
2026-10-26 08:26:53 +08:00
host 5d09af855f Host-level sandbox tasks: LocalExecutor + bwrap workspace-write
Docker outer layer and bwrap inner layer are mutually exclusive
on this host (fresh proc mounts fail with kernel EPERM inside
Docker's nested pidns, but work on the host under a userns
profile). So tasks that need an inner sandbox run on the host:
- Worker --claim-executor-only: strict executor claim (only
  executor == own type, never NULL). The host worker MUST use
  it, or it would steal NULL-executor tasks meant for Docker
  workers (those tasks would lose the Docker outer layer).
  Default behavior unchanged (NULL claimable by any worker).
- host-config-host.json: sandbox workspace-write, state
  work/host-state-host.json (distinct from Docker tasks'
  host-state.json), same relay home and test repo as Docker.
- deploy/apparmor/codex-host: docker-codex base + userns,
  mount, pivot_root for bwrap; still denies raw network,
  ptrace, dangerous capabilities, /proc/sys writes.
- deploy/host-worker/codex-host-worker.service: aa-exec -p
  codex-host, --executor local --claim-executor-only,
  capacity 2, proxy env 127.0.0.1:8888 so host tasks' relay/git
  egress also goes through the Tinyproxy allowlist (host direct
  egress would bypass it).
POST /tasks already accepts executor (local/docker/cloud), so
no API change was needed for routing.
Tests: 4 host-sandbox-routing tests (strict claim incl. CAS,
default still takes NULL, config sandbox/state, profile
allows bwrap without deny mount). Full suites pass.
2026-10-26 08:23:37 +08:00
openqj 67acddaf7c Merge pull request #25 from openqj/egress-fixes
Fix egress/AppArmor configs found during host install
2026-10-26 08:03:13 +08:00
host eae60cf3d7 Fix egress/AppArmor configs found during host install
- AppArmor network rules must use socket types (stream/dgram),
  not protocol names (tcp/udp/icmp): the parser rejected the
  profile (Invalid network entry) until fixed.
- Tinyproxy has no 'Upstream none' syntax; the line failed the
  service at startup. Direct connection is the default, so the
  line is removed.
- Tinyproxy Filter is POSIX BASIC regex, not extended: the
  extended-style anchored patterns denied even allowed domains
  (verified: xcmapi.org got 403). Rewritten in basic syntax
  (\(...\)* groups); the anchored form still blocks
  evilgithub.com and xcmapi.org.evil.com (verified on host).
- Filter file must be world-readable (was 640 root:root, so
  tinyproxy could not read it and exited on reload); install
  steps now chmod 644 (documented in the filter header via
  the install note in tinyproxy.conf).
Host already runs these exact files (proxy allow/block
verified: xcmapi/github/api.github 200, example/evilgithub/
xcmapi.org.evil.com 403).
2026-10-26 08:01:20 +08:00
openqj 7859d5dd8c Merge pull request #24 from openqj/outer-hardening
Outer hardening: AppArmor, pids limit, egress allowlist
2026-10-26 07:43:57 +08:00
host 1e4623c61c Outer hardening: AppArmor profile, pids limit, egress allowlist proxy
Inner codex sandbox stays danger-full-access by choice (bwrap
path is a separate decision); this hardens everything around it:
- DockerExecutor: --security-opt plumbing (repeatable, dedups
  no-new-privileges), --pids-limit (CLI default 512, fork-bomb
  guard), per-call extra_env (-e NAME=VALUE, non-secrets only,
  e.g. proxy URLs; the model key still goes by -e NAME only).
- Worker: --egress-proxy-port (0 = off). When on, Docker task
  containers get HTTP(S)_PROXY pointing at the host filtering
  proxy via their network gateway IP (tenant network gateway
  or default bridge gateway). Gateway unresolvable => the
  attempt fails instead of running unproxied (fail closed,
  same rule as tenant network creation failure).
- AppArmor profile deploy/apparmor/docker-codex: docker-default
  base + deny network raw/packet, deny ptrace, deny mount/
  umount/pivot_root, deny dangerous capabilities, deny writes
  to /proc/sys, /sys, /proc/keys. Deliberately NO userns rule
  (that would enable the bwrap path, not this one). File rules
  mirror docker-default (broad + sensitive denies); a strict
  file allowlist would need profiling codex's file access set
  and is not claimed here.
- Egress proxy deploy/egress/: Tinyproxy with
  FilterDefaultDeny + anchored domain allowlist (relay,
  github, pypi, npm). Anchoring matters: an unanchored
  'github.com' regex would also match 'evilgithub.com'.
- Seccomp honesty (README): Docker default seccomp already
  enforces on all task containers; no new stricter allowlist
  is claimed in this change (building one from memory risks
  silently breaking the toolchain; it needs syscall profiling
  first).
Tests: 3 outer-hardening tests (security-opt dedup, pids
limit, proxy env without leaking the key value, gateway
parse/fail, proxy fail-closed). Full suites pass.
2026-10-26 07:42:14 +08:00
openqj 4f7c7eb13e Merge pull request #23 from openqj/tenant-isolation
Multi-tenant isolation for Docker tasks
2026-10-26 07:26:33 +08:00
host 03a98124db Multi-tenant isolation for Docker tasks (per-project mounts + networks + drop caps)
Container-side isolation was project-blind: every task mounted
the shared /app/work (all worktrees + host-state), the shared
codex-home (all thread history), the shared repos dir and the
shared test-repo, and all containers shared the default bridge
network. With --tenant-isolation (Docker only; Local runs on
the host under one user and has no container boundary to
enforce), each project gets its own host dirs under
tenants_root/<project>/{work,codex-home,test-repo,<project>}:
- mounts: container paths stay identical (/app/work,
  codex-home, repos/<pid>, test-repo) so host.py config needs
  no change; only the host mount sources are per-project.
  Static --docker-extra-mounts are replaced per task (not
  appended), so a shared mount cannot leak back in.
- codex-home seeds ONLY the template's config.toml (relay
  endpoint/provider names, no secret values); thread_history
  is never copied (it holds other tasks' prompts/results).
  test-repo is a per-project copy of the fixture (worktree
  creation needs a writable repo; a shared writable fixture
  would let one tenant poison the next task's base code).
- repos: repo_url checkouts go to the tenant dir and task
  repo is set to the container path; no-repo_url tasks get
  the tenant test-repo copy instead of the shared fixture.
- network: one Docker network per project (codex-<pid>,
  created idempotently, already-exists is OK); network
  creation failure fails the task instead of silently
  falling back to the default network (a silent downgrade
  would make isolation fail quietly).
- hardening for all Docker runs: --cap-drop=ALL and
  --security-opt=no-new-privileges (non-root user was already
  the case); verified live by a good-model task, not assumed.
Honest limit, kept in README: the inner codex sandbox is
still danger-full-access (host kernel blocks user namespaces,
bwrap unusable), so the boundary is container + mounts +
network, not the inner sandbox.
Tests: 7 tenant tests (seeding incl. no-history, per-project
mounts, network sanitization, per-call override, ensure
network ok/fail). Full suites pass.
2026-10-26 07:24:54 +08:00
host b74deffb26 hotfix: autoscaler queue_depth NULL depends_on blinded scaler
queue_depth turned a NULL depends_on into [None], and
ready_tasks then required None to be in completed_ids, so
such tasks never counted as ready. Worker pick_next was
unaffected (_task_from_row maps NULL to []), which is why
tasks still ran while the scaler always saw depth 0 and
never scaled up (proven live: depth probe with 1 queued
task returned 0). NULL now means no dependencies, with a
regression test.
2026-10-26 07:13:45 +08:00
host 951730e224 autoscaler: fix double-prefixed worker log filename
Log file was autoscale-autoscale-1.log because worker_id already
carries the autoscale- prefix; now it is autoscale-1.log.
2026-10-26 07:05:04 +08:00
host 26daddf909 hotfix: autoscaler main() setup_logging arg
setup_logging() takes a log LEVEL, not a logger name; passing
'cloud.autoscaler' raised ValueError at startup, so the scaler
crash-looped under systemd and started zero workers (the host
had no worker at all until this fix). The '/opt/.../-m'
FileNotFoundError in the same journal lines is only Ubuntu's
apport hook misfiring on 'python3 -m', not a second bug.
Regression test: main() with mocked run_loop/signal must reach
the loop (this would have caught the crash before deploy).
2026-10-26 07:02:31 +08:00
host 1f4463209b deploy: fix host backup tar exclude patterns
The host backup tar runs with -C /opt codex-app-server, so its
members are named codex-app-server/..., and the old patterns
./work and ./worker-heartbeat.json never matched anything.
The live work/ dir (host-state, worktrees rewritten by running
workers) was therefore included, tar hit 'file changed as we
read it', exited 1, and set -e aborted the deploy. Patterns
are now codex-app-server/work and
codex-app-server/worker-heartbeat.json, which actually match.
2026-10-26 07:00:24 +08:00
host d1095853c1 deploy: exclude worker heartbeat from host backup tar
The worker rewrites ./worker-heartbeat.json every poll interval
(10s); the host backup tar reads the same directory, so it
could hit 'file changed as we read it', exit 1, and abort the
deploy under set -e (this killed the PR #22 deploy). The
heartbeat file is regenerable runtime state, not code, so
excluding it from the backup is correct, not a workaround.
2026-10-26 06:59:38 +08:00
openqj c5d245a7fb Merge pull request #22 from openqj/autoscaler
Autoscale workers by queue depth
2026-10-26 06:58:42 +08:00
host 7e147be9ed Autoscale workers by queue depth (adaptive threads + process scaler)
Layer 1, in-worker: run_all pool thread count is now adaptive
per round (min(capacity, ready_count)) instead of always
capacity threads; ready_count counts only dependency-satisfied
queued tasks via a shared _ready_tasks() helper (pick_next
refactored onto it), so blocked tasks never inflate the pool.

Layer 2, process scaler (cloud/autoscaler.py): a systemd-managed
daemon that starts/stops cloud.worker subprocesses by ready
queue depth. desired = ceil(depth / per_worker_capacity)
clamped to [min_workers, max_workers]; scale-up is immediate,
scale-down waits scale_down_after consecutive low checks
(default 3) to avoid flapping on bursty completions. Scale-down
is LIFO (youngest worker first), SIGTERM for graceful drain
(worker run_loop drains up to drain_timeout), SIGKILL of the
process group only after drain timeout. Depth read failure
keeps the current worker count (never scale on unknown state).
Worker command forces --loop/--worker-id/--worker-capacity
(appended last so argparse last-wins); --db comes from scaler
or worker args; workers inherit scaler env (Postgres etc.).
State file (depth/desired/actual/workers) written atomically
for observability. deploy.sh now restarts codex-autoscaler
instead of the static worker unit when the scaler is active,
so the two never poll the same queue at once.
Tests: 8 autoscaler tests (desired math, ready-only depth,
cycle=0, scale up/down, cooldown streak, forced worker flags).
2026-10-26 06:56:59 +08:00
openqj 67e1d3eb24 Merge pull request #21 from openqj/worker-concurrency-and-retry
Worker concurrency, survival (heartbeat/sweep/drain), retry endpoint
2026-10-26 06:48:48 +08:00
host d51c06a900 Worker concurrency (capacity enforced), survival (heartbeat/sweep/drain), retry endpoint
1. Concurrency: capacity is now a real limit, not display-only.
   run_all with capacity>1 runs a manual thread pool (threading,
   not concurrent.futures: a test in this repo puts cloud/ on
   sys.path, shadowing stdlib queue and breaking
   concurrent.futures' SimpleQueue import). Claim stays CAS, so
   two threads racing one task still yield one owner; max_tasks
   counts dispatched+running to avoid overshoot; stop signals
   stop new claims only, running tasks are drained by run_loop.
   Running state moved from single fields to a _running dict
   (proc/container per task) so cancel kills the right task.
   Docker timeout is now a per-call argument, never a mutated
   shared executor field. CostStore opens a fresh connection per
   record/report (shared sqlite connection is thread-bound and
   raised ProgrammingError under the pool; :memory: keeps the
   shared connection because a fresh one is an empty DB).
   Final status write is CAS on status='running' so a background
   thread cannot overwrite an interrupted task with completed.
2. Survival: run_loop starts a daemon heartbeat thread (every
   30s, refreshes running tasks' last_heartbeat) and sweeps stale
   tasks every round (threshold 300s, best-effort); live tasks
   never go stale now, dead workers' tasks are reclaimed without
   manual /admin/sweep. On exit, run_loop waits up to
   drain_timeout (CLI --drain-timeout, default 60s, inside
   systemd's 90s stop window) for running tasks before killing
   leftovers and marking interrupted.
3. Retry: POST /tasks/{id}/retry (maintainer+, 404/403/409):
   failed/interrupted/merge-failed go back to queued with
   result/error/model cleared, prompt/model/deps kept, CAS
   update, audit task_retried.
Tests: 5 concurrency tests (barrier overlap, serial capacity=1,
max_tasks, wait_for_running, CAS final write), 3 retry tests;
full suites pass.
2026-10-26 06:46:20 +08:00
openqj 341f4a83e1 Merge pull request #20 from openqj/worker-poll-perf
Perf: light worker poll query + tasks(status) index
2026-10-26 06:21:30 +08:00
host 26d9edac33 Perf: light worker poll query + tasks(status) index
Worker polls the DB every interval (production 30s) and every
poll ran SELECT * FROM tasks: full rows including result_text
and error_text (up to 4000 chars each) for every task ever,
growing with history. load_tasks now selects only the columns
pick_next actually needs (id/project/prompt/status/depends/
priority/timestamps/executor) and only queued/completed/merged
rows; running/failed rows are neither ready nor completed, so
excluding them cannot change pick_next. get_task still does
SELECT * for the single claimed task. Plus CREATE INDEX IF NOT
EXISTS idx_tasks_status on tasks(status) in both SQLite
init_db and the Postgres schema, so the poll filter stops being
a full table scan on old databases too. Test: load_tasks returns
only queued+completed out of queued/completed/running/failed.
2026-10-26 06:19:01 +08:00
openqj a1e492657a Merge pull request #19 from openqj/host-error-surfacing
Surface failed-turn reason from thread_history in dispatch output
2026-10-26 06:15:03 +08:00
host c114178463 Surface failed-turn reason: host.py recovers it from thread_history, dispatch prints it
Real bad-model turn: relay returns 404, codex retries sampling
5 times, turn fails, but app-server's turn/completed event
carries status=failed with NO error and NO text, so worker
never saw any fallbackable signal. The 404 actually lives in
codex's thread_history_1.sqlite (thread_turns.error_json).
host.py Server.run_turn now best-effort recovers that message
(read-only query by thread_id+turn_id, any failure -> None,
never breaks the turn itself), and dispatch CLI prints the
error field (run CLI already printed the full result). Worker
_attempt_error prefers the error field when text is empty, so
a double failure keeps the real reasons, not just status codes.
Tests: host TurnErrorFromHistoryTest (recovery + missing/corrupt
DB -> None), full host + cloud suites pass.
2026-10-26 06:13:27 +08:00
openqj 6a857e06bd Merge pull request #18 from openqj/fallback-real-turn-token-and-codex-home
Fallback: extract HTTP code from failed-turn JSON; Docker codex_home points at real relay home
2026-10-26 06:09:37 +08:00
host 5827716a3a Fallback: extract HTTP code from failed-turn JSON; Docker codex_home points at real relay home
1. Worker _fallback_token correction: real host.py never returns
status='404'; a failed turn is status='failed' with the real
error (e.g. '404 model not found') in text/error. The old code
returned host_status verbatim, so 'failed' blocked every real
fallback and the old event-only branch did too. Now: if
host_status itself is a fallbackable code it is used; otherwise
the code is extracted from turn JSON text/error, stdout, stderr
or runner error, and 'failed' alone never counts as a signal.
Tests: failed-turn JSON with 404 in text falls back and
succeeds; with 401 it never falls back.
2. Docker config codex_home: /var/lib/codex-app-server/codex-home,
mounted at the same path into the container, is the only home
that holds the real relay config (xcmapi provider, base_url,
env_key). Pointing codex_home at a fresh empty dir made codex
POST to api.openai.com with no auth header and loop on connect
timeouts until host.py's 600s turn timeout. The /app/work
codex-home from PR #17 was necessary to get past initialize
but insufficient for a real turn; this replaces it.
2026-10-26 06:07:07 +08:00
openqj edc187bb70 Merge pull request #17 from openqj/docker-config-codex-home
Docker config: writable codex_home under /app/work
2026-10-26 05:59:27 +08:00
host c1fba878d2 Docker config: writable codex_home under /app/work
Inside the container the default HOME is /root, which uid
1003 cannot write, so codex cannot create ~/.codex and the
app-server never answers initialize (host.py times out after
60s, task fails with queue.Empty). Setting codex_home to
/app/work/codex-home (mounted writable, owned by codexsvc uid
1003, same as the container user) lets the app-server start
and run turns. No secrets in this file.
2026-10-26 05:56:47 +08:00
openqj 0f2e427d93 Merge pull request #16 from openqj/docker-config-repo-and-stderr
Docker default repo + keep stderr in worker error_text
2026-10-26 05:48:17 +08:00
host 87a2ad0066 Docker default repo + keep stderr in worker error_text
Two live-test findings in one fix:
1. DockerExecutor has no repo fallback: when a project has no
repo_url, worker leaves task['repo'] unset and the container
host.py dispatch fails instantly with 'missing --repo' because
host-config-docker.json had no repo field either. Add the host
test-repo as the config default; projects with repo_url still
override it via worker's explicit --repo.
2. Worker discarded stderr from the final error_text, so every
container/host crash surfaced only as the generic 'host
execution failed' and was undebuggable from the task record.
When stdout is empty, error_text now keeps the stderr tail
(last 1000 chars, where the real error line lives).
2026-10-26 05:45:51 +08:00
openqj 3acf0d7b8c Merge pull request #15 from openqj/docker-config-state-path
Fix Docker config state path to work/host-state.json
2026-10-26 05:41:33 +08:00
host 619366aac8 Fix Docker config state path: work/host-state.json, not /work/host-state.json
/work is a bind mount whose host dir docker auto-creates as
root:root, so the container user (1003:1004) cannot write a
state lock there (Permission denied, task fails in ~1s).
work/host-state.json resolves relative to the config dir /app
to /app/work/host-state.json, which maps via the extra mount to
the host work dir owned by codexsvc (uid 1003, same as the
container user), so it is writable. /app/host-state.json itself
is still unusable (/app is mounted read-only).
2026-10-26 05:39:04 +08:00
openqj f49ccfd9bf Merge pull request #14 from openqj/docker-config-in-repo
Commit host-config-docker.json to the repo
2026-10-26 05:37:22 +08:00
host 17c9debc32 Commit host-config-docker.json to the repo (Docker executor was silently broken without it)
The production worker runs host.py inside a container where /app
is mounted read-only, so the default state path /app/host-state.json
fails instantly with Read-only file system. The Docker config must
set state to /work/host-state.json (the task worktree mount, writable)
and sandbox to danger-full-access (host kernel forbids untrusted user
namespaces, so Docker itself is the isolation layer). This file was
previously host-local only, so PR #9's deploy-clean step deleted it
and every Docker task since then has failed in ~1s with the generic
'host execution failed' (stderr was discarded from the task record,
so the breakage was invisible to health checks, which only look at
the worker heartbeat). Committing it makes deploys restore it.
No secrets in this file; the model key stays in the environment.
2026-10-26 05:34:57 +08:00
openqj 1e33b6b3e0 Merge pull request #13 from openqj/fallback-thread-start-crash
Fallback also triggers on thread/start crash with explicit HTTP code
2026-10-26 05:31:34 +08:00
host 6cee452750 Fallback also triggers on thread/start crash with explicit HTTP code
Live test found the gap: a nonexistent model fails in
thread/start, host.py exits non-zero without JSON, so the
executor result has host_status=None and the worker never
fell back (task failed, no worker_fallback audit). When
host_status is absent, worker now extracts an explicit signal
from stdout/stderr/runner error: 404/429/5xx codes, or
'model not found' style text (counted as 404). Generic
failures with no explicit code still do not fall back, and
401 in stderr still never falls back. Tests: fallback 9 pass,
cloud 333 pass, host 27 pass.
2026-10-26 05:30:04 +08:00
openqj 52048d7633 Merge pull request #12 from openqj/model-fallback-retry
Real model fallback retry (opt-in, at most once)
2026-10-26 05:25:29 +08:00
host 7ce603b610 Real model fallback retry (opt-in, at most once)
Worker previously only recorded a model_fallback event on a
fallbackable failure and failed the task; ModelRouter.next_after
was never used to actually run the task on another model.
With --fallback-models (default empty = off, old behaviour
unchanged), a fallbackable failure (404/429/5xx/timeout; 401/400
and cancellations never fall back) now re-runs the same task,
same worktree/branch, on the first fallback model via host.py's
top-level --model flag (no host.py signature change). The second
attempt's result is final: actual_model is the fallback model,
fallback_count=1, and a double failure keeps both reasons in
error_text. LocalExecutor/DockerExecutor pass task model through
to host.py; CloudExecutor includes model only when set.
2026-10-26 05:23:52 +08:00
openqj f1d42d04f3 Merge pull request #11 from openqj/workers-precision-fix
Fix workers time columns to DOUBLE PRECISION on Postgres
2026-10-26 05:07:16 +08:00
host e2d6a454bf Fix workers time columns to DOUBLE PRECISION on Postgres
SQLite REAL is 8-byte double, but Postgres REAL is 4-byte
float4 (~7 significant digits). Epoch seconds (10 digits) were
rounded to 128-second steps, so worker liveness ages were off
by up to +-64s and could even go negative (observed live on
the host: last_seen ahead of NOW() by 20s). New tables use
DOUBLE PRECISION; existing Postgres tables are upgraded only
when information_schema actually reports REAL, so the ALTER
does not run on every heartbeat (it would take an ACCESS
EXCLUSIVE lock each time). SQLite path is unchanged (its
REAL is already double precision).
Regression test asserts new tables use DOUBLE PRECISION.
2026-10-26 05:04:59 +08:00
openqj 3bc9d49d0c Merge pull request #10 from openqj/cloud-dispatch-pr
Cloud dispatch pool + auto PR (opt-in, skip clearly)
2026-10-26 04:59:51 +08:00
host 306cdf0d0c Cloud dispatch pool + auto PR (opt-in, skip clearly)
Worker pool: workers table + register/heartbeat (heartbeat is
registration, no 404), GET /workers with alive (120s default),
POST /workers/heartbeat (X-API-Key, missing worker_id 400).
Tasks gain executor (local/docker/cloud, None = any worker);
worker claim filters by executor in pick_next and in the CAS
WHERE clause, CAS semantics unchanged; CloudExecutor _name
now correctly returns cloud, not local.
CloudExecutor: generic HTTP runner interface (endpoint from
explicit arg or CLOUD_RUNNER_ENDPOINT, auth header name only,
token value only from env into the request header, never into
logs/body/errors); no endpoint configured = explicit failure,
never silent downgrade. Vendor adapters (ECS/Cloud Run) are
out of scope, documented as such.
Auto PR: projects.auto_pr (0/1, create-time only, no PATCH
route exists); POST /tasks/{id}/pr (maintainer+ via
approve_merge) only for merged tasks (else 409); skip reasons
in order: auto_pr_disabled, no_worktree, no_remote, no_gh,
gh_not_authenticated — all return 200 with pr_skipped, task
stays merged, no push attempted; push/pr-create failure
returns 502 with step, task stays merged; success writes
pr_url/pr_number and audits pr_created. No default auto-push
from the worker. Remote URLs in errors are scrubbed of
userinfo; gh credentials are managed by gh itself, never
stored by the control plane.
Tests: host 27 pass, cloud 323 pass (8 skipped are
environment-dependent real-Postgres tests).
2026-10-26 04:57:37 +08:00
openqj d3e588e4b3 Merge pull request #9 from openqj/deploy-clean-fix
Fix deploy/rollback to clean code dir before extract
2026-10-26 04:43:18 +08:00
host 4cee5a0c53 Fix deploy/rollback to clean code dir before extract
Overlay-only extract left files added by the newer version behind
after a rollback (2026-10-26: after rolling back ops-batch,
deploy/ new scripts were still present). Before every extract,
delete app-dir contents except work/ (task worktrees), then
extract; backup tarballs also exclude work/, so worktrees are
preserved on both deploy and rollback.
2026-10-26 04:41:01 +08:00
openqj 138094a3ae Merge pull request #8 from openqj/ops-batch
Ops batch: deploy automation, backup restore drill, monitoring, multi-user verification
2026-10-26 04:37:53 +08:00
host 89a918b543 Ops batch: deploy automation, backup restore drill, monitoring, multi-user verification
Deploy: deploy.sh builds tarball, backs up current code (keep 3),
deploys, restarts, health-gates 60s (ok/backend=postgres/db_ok),
auto-rolls back on failure; rollback.sh restores latest backup.
Rollback is code-only, DB migrations are additive-only so old
code can still read new columns.
Backup: restore_postgres refuses to restore into the production
DB name unless explicitly allowed (drill never allows it);
restore-drill.sh backs up fresh, restores to a test DB, compares
tasks/projects/users/audit row counts, drops the test DB.
Monitoring: worker writes heartbeat file every loop (atomic
replace, failure only logged); GET /health adds
worker_last_seen_seconds_ago + worker_ok (180s default, env
tunable, still HTTP 200 when worker is stale); monitor.sh
checks server/worker/health/pg_isready/docker-postgres/disk/
app dir as one JSON line, exit non-zero on any fail, plus
systemd timer every 5 minutes. Push alerting to chat still
needs a separate channel decision (SSH key or webhook URL).
Multi-user: verify-multiuser.py creates test users/projects
in memory-key only, checks RBAC via real HTTP API (owner/
maintainer/contributor/viewer, cross-project isolation,
merge 200/403/409, no-key 401), test tasks carry a never-
satisfiable dependency so the production worker never claims
them, cleanup deletes only multiuser-test- rows (audit log
is append-only and keeps test entries by design).
Tests: host 27 pass, cloud 298 pass (8 skipped are
environment-dependent real-Postgres tests).
2026-10-26 04:37:40 +08:00