Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 242 additions & 0 deletions argoproj/codex-workspace/cron-configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: codex-workspace-cron
namespace: codex-workspace
data:
jobs.yaml: |
jobs:
- id: workspace-maintenance
name: Workspace maintenance
enabled: false
schedule: "0 22 * * *"
timeZone: Etc/UTC
session: isolated
promptFile: /opt/codex-cron/prompt-workspace-maintenance.md
workdir: /home/boxp
outputRoot: /home/boxp/.codex-cron/runs
bypassApprovals: true
- id: ticket-review
name: Ticket review
enabled: false
schedule: "30 22 * * *"
timeZone: Etc/UTC
session: isolated
promptFile: /opt/codex-cron/prompt-ticket-review.md
workdir: /home/boxp
outputRoot: /home/boxp/.codex-cron/runs
bypassApprovals: true
run-codex-cron.sh: |
#!/usr/bin/env bash
set -Eeuo pipefail

job_id="${CODEX_CRON_JOB_ID:?CODEX_CRON_JOB_ID is required}"
jobs_file="${CODEX_CRON_JOBS_FILE:-/opt/codex-cron/jobs.yaml}"

eval "$(
bb /opt/codex-cron/select-codex-cron-job.bb "${jobs_file}" "${job_id}"
)"

job_name="${CODEX_CRON_NAME:-${CODEX_CRON_JOB_ID}}"
prompt_file="${CODEX_CRON_PROMPT_FILE:?CODEX_CRON_PROMPT_FILE is required}"
workdir="${CODEX_CRON_WORKDIR:-/home/boxp}"
output_root="${CODEX_CRON_OUTPUT_ROOT:-/home/boxp/.codex-cron/runs}"
lock_root="${CODEX_CRON_LOCK_ROOT:-/home/boxp/.codex-cron/locks}"
run_id="${CODEX_CRON_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}"
run_dir="${output_root}/${job_id}/${run_id}"
lock_dir="${lock_root}/${job_id}.lock"

mkdir -p "${run_dir}" "${lock_root}"

if ! mkdir "${lock_dir}" 2>/dev/null; then
lock_stale_seconds="${CODEX_CRON_LOCK_STALE_SECONDS:-43200}"
lock_mtime="$(stat -c %Y "${lock_dir}" 2>/dev/null || echo 0)"
now_epoch="$(date -u +%s)"
if (( now_epoch - lock_mtime > lock_stale_seconds )); then
echo "removing stale codex cron lock: ${lock_dir}" >&2
rm -rf "${lock_dir}"
mkdir "${lock_dir}"
else
echo "codex cron job '${job_name}' is already running: ${lock_dir}" >&2
exit 75
fi
fi
cleanup() {
rmdir "${lock_dir}" 2>/dev/null || true
}
trap cleanup EXIT

started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
stdout_log="${run_dir}/events.jsonl"
stderr_log="${run_dir}/stderr.log"
last_message="${run_dir}/last-message.md"
summary_file="${run_dir}/summary.json"

codex_args=(exec --json --cd "${workdir}" --output-last-message "${last_message}")

if [[ "${CODEX_CRON_BYPASS_APPROVALS:-true}" == "true" ]]; then
codex_args+=(--dangerously-bypass-approvals-and-sandbox)
else
codex_args+=(--sandbox "${CODEX_CRON_SANDBOX:-workspace-write}")
fi

if [[ -n "${CODEX_CRON_MODEL:-}" ]]; then
codex_args+=(--model "${CODEX_CRON_MODEL}")
fi

if [[ -n "${CODEX_CRON_PROFILE:-}" ]]; then
codex_args+=(--profile "${CODEX_CRON_PROFILE}")
fi

if [[ -n "${CODEX_CRON_EXTRA_ARGS:-}" ]]; then
# Shell-style quoting is intentionally not supported. Keep values simple
# and use dedicated env vars above for options that need arguments.
read -r -a extra_args <<< "${CODEX_CRON_EXTRA_ARGS}"
codex_args+=("${extra_args[@]}")
fi

{
echo "job=${job_name}"
echo "job_id=${job_id}"
echo "run_id=${run_id}"
echo "started_at=${started_at}"
echo "workdir=${workdir}"
echo "prompt_file=${prompt_file}"
echo "codex_args=${codex_args[*]}"
} > "${run_dir}/metadata.env"

set +e
codex "${codex_args[@]}" - < "${prompt_file}" > "${stdout_log}" 2> "${stderr_log}"
exit_code=$?
set -e

finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
status="ok"
if [[ "${exit_code}" -ne 0 ]]; then
status="error"
fi

cat > "${summary_file}" <<EOF
{
"job": "${job_id}",
"name": "${job_name}",
"runId": "${run_id}",
"status": "${status}",
"exitCode": ${exit_code},
"startedAt": "${started_at}",
"finishedAt": "${finished_at}",
"workdir": "${workdir}",
"promptFile": "${prompt_file}"
}
EOF

echo "Codex cron ${job_id}/${run_id}: ${status} (exit ${exit_code})"
exit "${exit_code}"
select-codex-cron-job.bb: |
#!/usr/bin/env bb

(require '[clojure.string :as str])

(defn fail [message]
(binding [*out* *err*]
(println message))
(System/exit 1))

(defn shell-quote [value]
(let [s (str value)]
(str "'" (str/replace s #"'" "'\"'\"'") "'")))

(defn parse-scalar [raw]
(let [value (str/trim (or raw ""))]
(cond
(and (>= (count value) 2)
(= (first value) (last value))
(#{\" \'} (first value)))
(subs value 1 (dec (count value)))

(= "true" (str/lower-case value)) true
(= "false" (str/lower-case value)) false
:else value)))

(defn parse-jobs [text]
(loop [lines (str/split-lines text)
jobs []
current nil]
(if-not (seq lines)
(cond-> jobs current (conj current))
(let [raw (first lines)
stripped (str/trim raw)]
(cond
(or (empty? stripped) (= stripped "jobs:") (str/starts-with? stripped "#"))
(recur (rest lines) jobs current)

(str/starts-with? stripped "- ")
(let [jobs (cond-> jobs current (conj current))
stripped (str/trim (subs stripped 2))
current {}]
(if (empty? stripped)
(recur (rest lines) jobs current)
(recur (cons stripped (rest lines)) jobs current)))

(nil? current)
(fail (str "invalid jobs file: property outside a job: " raw))

(not (str/includes? stripped ":"))
(fail (str "unsupported jobs.yaml line: " raw))

:else
(let [[k v] (str/split stripped #":" 2)
k (str/trim k)]
(when-not (re-matches #"[A-Za-z][A-Za-z0-9]*" k)
(fail (str "unsupported key: " k)))
(recur (rest lines) jobs (assoc current k (parse-scalar v)))))))))

(defn emit [name value]
(when-not (or (nil? value) (= "" value))
(println (str name "=" (shell-quote value)))))

(let [[jobs-file job-id] *command-line-args*]
(when-not (and jobs-file job-id)
(fail "usage: select-codex-cron-job.bb <jobs.yaml> <job-id>"))
(let [job (first (filter #(= job-id (get % "id")) (parse-jobs (slurp jobs-file))))]
(when-not job
(fail (str "codex cron job not found: " job-id)))
(when-not (true? (get job "enabled"))
(fail (str "codex cron job is disabled in jobs.yaml: " job-id)))
(emit "CODEX_CRON_NAME" (or (get job "name") (get job "id")))
(emit "CODEX_CRON_PROMPT_FILE" (get job "promptFile"))
(emit "CODEX_CRON_WORKDIR" (get job "workdir"))
(emit "CODEX_CRON_OUTPUT_ROOT" (get job "outputRoot"))
(emit "CODEX_CRON_MODEL" (get job "model"))
(emit "CODEX_CRON_PROFILE" (get job "profile"))
(emit "CODEX_CRON_BYPASS_APPROVALS" (str (get job "bypassApprovals" true)))
(emit "CODEX_CRON_EXTRA_ARGS" (get job "extraArgs"))))
prompt-workspace-maintenance.md: |
あなたは Codex workspace の定期実行ジョブです。

目的:
- Codex workspace の状態を軽く確認する。
- 変更は加えず、必要な次アクションだけを短く報告する。

確認対象:
- `/home/boxp/Documents/obsidian-headless/BOXP/Tickets/BOXP-16.md`
- `/home/boxp/ghq/github.com/boxp/lolice`
- `/home/boxp/ghq/github.com/boxp/arch`

制約:
- ファイル編集、commit、push、PR 作成はしない。
- 問題があれば、最後の応答に箇条書きで記録する。
prompt-ticket-review.md: |
あなたは Codex workspace の定期実行ジョブです。

目的:
- Obsidian vault のタスクボードを確認する。
- stale なチケットや次アクションが曖昧なチケットを短く報告する。

確認対象:
- `/home/boxp/Documents/obsidian-headless/BOXP/Boards/Task Board.md`
- `/home/boxp/Documents/obsidian-headless/BOXP/Tickets`

制約:
- ファイル編集、commit、push、PR 作成はしない。
- 日本語で簡潔に報告する。
59 changes: 59 additions & 0 deletions argoproj/codex-workspace/cron.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Codex Workspace Cron

Codex workspace の prompt 定期実行は `jobs.yaml` と Kubernetes CronJob で管理する。
OpenClaw の `jobs.json` と同じく、複数のジョブがそれぞれ schedule と prompt を持つ。

## 仕組み

- `cron-configmap.yaml`
- `jobs.yaml`: 登録済み cron job の一覧。
- `run-codex-cron.sh`: `codex exec` を非対話で起動する runner。
- `prompt-*.md`: 定期実行したい prompt。
- `cronjob.yaml`
- `spec.schedule`: Kubernetes が実行する schedule。`jobs.yaml` の同名 entry と合わせる。
- `spec.suspend`: 誤実行防止。初期値は `true`。
- `CODEX_CRON_JOB_ID`: `jobs.yaml` の job id。
- 実行ログ
- `/home/boxp/.codex-cron/runs/<job>/<run-id>/events.jsonl`
- `/home/boxp/.codex-cron/runs/<job>/<run-id>/stderr.log`
- `/home/boxp/.codex-cron/runs/<job>/<run-id>/last-message.md`
- `/home/boxp/.codex-cron/runs/<job>/<run-id>/summary.json`

## 有効化

prompt と schedule を確認してから、`jobs.yaml` の対象 job を `enabled: true` にし、対応する CronJob の `suspend` を `false` にする。

```yaml
jobs:
- id: workspace-maintenance
enabled: true
schedule: "0 22 * * *"
```

```yaml
spec:
suspend: false
schedule: "0 22 * * *"
```

## 手動実行

```bash
kubectl create job \
--from=cronjob/codex-cron-workspace-maintenance \
codex-cron-workspace-maintenance-manual \
-n codex-workspace
```

## ジョブ追加

1. `cron-configmap.yaml` の `jobs.yaml` に job entry を追加する。
2. `cron-configmap.yaml` に対応する `prompt-<name>.md` を追加する。
3. `cronjob.yaml` をコピーして CronJob 名、`schedule`、`CODEX_CRON_JOB_ID` を変更する。
4. `jobs.yaml` の `id/schedule/timeZone` と CronJob の `CODEX_CRON_JOB_ID/schedule/timeZone` を揃える。
5. 多重実行を避けるため `concurrencyPolicy: Forbid` は維持する。
6. workspace home PVC を mount するため、workspace Pod と同じ node へ寄せる `podAffinity` は維持する。

## OpenClaw との差分

OpenClaw は Gateway 内蔵 scheduler が `jobs.json` と `jobs-state.json` を管理する。Codex workspace では Gateway 相当の常駐 scheduler を持たないため、scheduler は Kubernetes CronJob に委譲し、ジョブ登録情報は ConfigMap の `jobs.yaml` に集約する。Runner 補助スクリプトは workspace image に入っている Babashka (`bb`) で動かす。
Loading
Loading