feat(scheduler): durable jobs and Tencent safety#162
Conversation
Adds shadow/live runtime jobs, effect-safe delivery, private extension loading, scoped journal parity, and default-on QQ/QZone content-policy boundaries.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: af61e755d6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if rj.enabled: | ||
| _register_runtime_loop(state, rj) |
There was a problem hiding this comment.
Preserve catch-up when rehydrating runtime jobs
Rehydrated jobs are registered through SchedulerHandle.register, which starts _run_job_loop(..., catch_up=False). Consequently, when the service restarts shortly after an operator-created job's scheduled occurrence, that occurrence is silently skipped even if it is within CORLINMAN_SCHEDULER_CATCHUP_GRACE_SECS; only config-defined jobs receive startup catch-up. Boot rehydration needs a registration path that enables catch-up while ordinary create/edit registration remains catch-up-free.
Useful? React with 👍 / 👎.
| table[name] = rj | ||
| _sync_metadata(state, rj) | ||
| if rj.enabled: | ||
| _register_runtime_loop(state, rj) |
There was a problem hiding this comment.
Isolate registration failures per persisted row
If an enabled persisted row has an invalid cron/action or its registration raises, _register_runtime_loop propagates out of the entire rehydration loop. The gateway catches that only around the top-level boot hook, so all later valid rows remain unloaded and unregistered; moreover, _runtime_jobs has already set its loaded flag, preventing a retry. Catch registration errors for each row and remove or disable the rejected row before continuing.
Useful? React with 👍 / 👎.
| store = await SchedulerStore.open( | ||
| _resolve_data_dir() / "scheduler.sqlite" | ||
| ) |
There was a problem hiding this comment.
Open the effect ledger in the gateway's resolved data directory
In a co-hosted deployment started with --data-dir or [server].data_dir while CORLINMAN_DATA_DIR is unset, the gateway opens and reconciles <resolved_data_dir>/scheduler.sqlite, but this servicer opens ~/.corlinman/scheduler.sqlite instead because _resolve_data_dir() only reads the environment. Scheduled QZone effects are therefore absent from the gateway-owned ledger and its reconciliation surface; in containers where the home directory is ephemeral, replacing the agent can also erase the reservation and permit the same public effect to be sent again. Pass the resolved data directory or the already-open scheduler store into the servicer.
Useful? React with 👍 / 👎.
| _sched_store = await SchedulerStore.open( | ||
| resolved_data_dir / "scheduler.sqlite" | ||
| resolved_data_dir / "scheduler.sqlite", | ||
| reconcile_prepared=True, | ||
| ) |
There was a problem hiding this comment.
Avoid reconciling reservations owned by a live agent process
When the gateway and agent run as separate processes against the same SQLite file, restarting only the gateway executes this blanket reconciliation while the agent may still have an active prepared QZone send. The row is changed to unknown underneath that send, so a subsequent successful complete_effect(..., state="sent") conflicts and the job reports an unknown receipt even though the effect landed. Reconciliation needs ownership or lease information, or must run only when the process that owns reservations is known to have terminated.
Useful? React with 👍 / 👎.
| existing.execution_mode = ( | ||
| "shadow" if body.execution_mode == "shadow" else "live" | ||
| ) | ||
| existing.source_system = body.source_system | ||
| existing.source_job_id = body.source_job_id |
There was a problem hiding this comment.
Preserve imported jobs' immutable source identity
Posting an existing job name unconditionally replaces its source_system and source_job_id, including clearing them when a later upsert omits the fields. This contradicts the immutable source-identity contract and changes the prefix used for occurrence/effect keys; if an importer changes or omits the identity while recovering an occurrence whose effect landed but whose run record was lost, the ledger lookup uses a new key and can publish the effect again. Reject source-identity changes for an existing row rather than assigning these fields during an ordinary upsert.
Useful? React with 👍 / 👎.
| except Exception as exc: | ||
| # Telegram transport errors are ambiguous: the API may have accepted | ||
| # the request (or an earlier MSG_BREAK bubble) before the response was | ||
| # lost. Block replay until an operator reconciles the remote chat. | ||
| await _finish_effect( | ||
| store, | ||
| effect, | ||
| state="unknown", | ||
| error_code=type(exc).__name__, |
There was a problem hiding this comment.
Record local photo failures as definitive failures
When the photo path is missing, unreadable, or exceeds the local upload-size limit, TelegramSender.send_photo raises SendIoError before making any Telegram request, but this blanket handler records the reservation as unknown. That incorrectly places a definitely-unsent effect into the ambiguous-receipt state and requires remote reconciliation before the occurrence can be handled. Validate/read the local file before reservation, or catch SendIoError separately and complete the effect as failed.
Useful? React with 👍 / 👎.
Summary
Adds durable shadow/live scheduled-agent infrastructure with effect-safe Telegram/QZone delivery, and introduces default-on fail-closed Tencent freeze-risk protection across QQ, QQ Official, and QZone. Operator-specific migrated job implementations, schedules, targets, prompts, credentials, and continuity data remain outside the public repository.
Type
Scope / Packages Touched
corlinman-content-policycorlinman-serverscheduler, journal, gateway lifecycle/config, gRPC agent wiringcorlinman-agentOneBot/QZone dispatchcorlinman-channelsQQ, QQ Official, Telegram deliveryuiQQ channel configurationBehavior Proof
proof: suppliedCI Gate
py-ruff—uv run ruff check .py-mypy—uv run mypy python/packages/py-test— 7276 passed, 7 skippedui-typecheck— passedui-lint— passed with existing warningsui-test— 1259 passed, 2 skippedboundary-check— 2 contracts kept, 0 brokenSeparate checks:
proto-sync— generated successfully, no driftswift-mac— N/Apnpm -C ui build— production export passed, route assertion OKModule Boundaries / Proto
Risk & Rollback
Risk: scheduler/publication code and QQ safety boundaries affect automation and outbound transport. Runtime jobs imported for the migration remain
shadow; agenda remains disabled. Effect receipts block ambiguous resends.Rollback plan: pause affected Corlinman jobs, reconcile
prepared/unknown/sentreceipts, then revert this commit. Do not activate both Hermes and Corlinman for the same source occurrence. The private bundle and retained Hermes installation are unchanged by this PR.Ownership
Cross-package change; self-reviewed under the current Claude self-review convention.
Codex Review
Linked Issues
N/A
Checklist
--no-verifyor hook-skipping used.