From c4c5e1d8b747263492747ac8f2c7e81ffd172eff Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 16:28:49 +0800 Subject: [PATCH 1/2] emrg: a task record names its agent with config.agent, migrated in place The task table's key was spelled project in six places that had to agree - the seeded record, the resolution, the idle-status report, task_create/task_update, the daemon's wire field and the GUI form payload - and each was a hand-written literal, so a stale one killed scheduling without a failing test. Move the key to config.agent and let a record carry one spelling. A table still carrying config.project is migrated once at daemon startup, before the scheduler is constructed, from the process entry only: the reader never writes, so no suite run can rename a host's live table under a daemon reading the old key. Six built-in templates read the key as {{ task.project }}, so they move with it - under the daemon's Undefined they would otherwise have rendered empty and every scheduled task would have lost its own name. --- emrg/gui/main.js | 6 +- .../src/components/TaskFormDialog.test.tsx | 6 +- .../src/components/TaskFormDialog.tsx | 8 +- .../src/components/WorkspaceView.test.tsx | 2 +- .../renderer/src/lib/workspaceView.test.ts | 8 +- emrg/gui/renderer/src/lib/workspaceView.ts | 8 +- emrg/server/__main__.py | 8 + emrg/server/competition_prompt.md | 4 +- emrg/server/daemon.py | 6 +- emrg/server/evolution_prompt.md | 2 +- emrg/server/feishu_prompt.md | 4 +- emrg/server/ios_app_prompt.md | 4 +- emrg/server/journal_prompt.md | 8 +- emrg/server/open_source_prompt.md | 4 +- emrg/server/scheduler.py | 95 ++++++++--- tests/test_agents_registry_migration.py | 7 +- tests/test_competition_prompt.py | 8 +- tests/test_daemon.py | 4 +- tests/test_daemon_tool_path.py | 3 + tests/test_evolution_prompt_index_rule.py | 2 +- .../test_evolution_prompt_merge_condition.py | 2 +- tests/test_evolution_prompt_vote_body.py | 2 +- tests/test_prompt_templates.py | 6 +- tests/test_schedule_window.py | 10 +- tests/test_scheduler.py | 110 ++++++------- ..._and_llm_logs_apply_the_credential_rule.py | 2 +- tests/test_task_config_key_migration.py | 153 ++++++++++++++++++ tests/test_ws_e2e.py | 18 +-- 28 files changed, 364 insertions(+), 136 deletions(-) create mode 100644 tests/test_task_config_key_migration.py diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 46e173013..fa85733db 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -968,15 +968,15 @@ vision = false // ── Task/template CRUD IPC (rant 2026-08-12T18:23:15 P3) ────────── ipcMain.handle("emrg:taskCreate", async (_e, payload) => { - const { name, type, project, interval, enabled, repo, description, sandbox, schedule } = payload || {}; + const { name, type, agent, interval, enabled, repo, description, sandbox, schedule } = payload || {}; if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type"); - if (typeof project !== "string" || !project.trim()) throw new Error("invalid project"); + if (typeof agent !== "string" || !agent.trim()) throw new Error("invalid agent"); // `schedule` 必须原样转发:这张固定字段表此前把它丢掉,于是 GUI 永远只能建 // 单一频次的任务,daemon 收了也白收(issue #2490)。只有 payload 真的带了这个 // 键才发,缺席=不声明,与 task_update 的「缺省不动该字段」一致。 const fields = { - name: name.trim(), task_type: type.trim(), project: project.trim(), + name: name.trim(), task_type: type.trim(), agent: agent.trim(), interval, enabled, repo, description, sandbox, }; if ("schedule" in (payload || {})) fields.schedule = schedule; diff --git a/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx b/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx index 720a156b2..ad38fa063 100644 --- a/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx +++ b/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx @@ -37,7 +37,7 @@ function taskWith(overrides: Partial = {}): TaskRec { type: "evolution", interval: 10800, enabled: true, - config: { project: "emrg" }, + config: { agent: "emrg" }, ...overrides, }; } @@ -91,7 +91,7 @@ describe("TaskFormDialog", () => { }); it("编辑:预填任务字段,name 只读不可改名", () => { - const task = taskWith({ type: "journal", interval: 3600, enabled: false, config: { project: "sci" } }); + const task = taskWith({ type: "journal", interval: 3600, enabled: false, config: { agent: "sci" } }); (task as TaskRec & { sandbox?: string }).sandbox = "read-only"; render( wrapper( @@ -161,7 +161,7 @@ describe("TaskFormDialog", () => { expect(payload).toEqual({ name: "t1", type: "journal", - project: "sci", + agent: "sci", interval: 300, enabled: false, repo: undefined, diff --git a/emrg/gui/renderer/src/components/TaskFormDialog.tsx b/emrg/gui/renderer/src/components/TaskFormDialog.tsx index 0bed89ea6..941d5b0d9 100644 --- a/emrg/gui/renderer/src/components/TaskFormDialog.tsx +++ b/emrg/gui/renderer/src/components/TaskFormDialog.tsx @@ -19,7 +19,7 @@ import { Dialog } from "./Dialog"; export interface TaskFormPayload { name: string; type: string; - project: string; + agent: string; interval: number; enabled: boolean; repo?: string; @@ -114,8 +114,8 @@ export function TaskFormDialog({ const curType = (task?.type && types.includes(task.type) ? task.type : types[0]) || "evolution"; if (typeRef.current) typeRef.current.value = curType; - const curProj = projects.some((p) => (p.name || p.path || "") === task?.config?.project) - ? (task?.config?.project as string) + const curProj = projects.some((p) => (p.name || p.path || "") === task?.config?.agent) + ? (task?.config?.agent as string) : (projects[0]?.name || projects[0]?.path || ""); if (projectRef.current) projectRef.current.value = curProj; if (intervalRef.current) intervalRef.current.value = String(task?.interval ?? 1800); @@ -176,7 +176,7 @@ export function TaskFormDialog({ const payload: TaskFormPayload = { name, type: typeRef.current.value, - project: projectRef.current.value, + agent: projectRef.current.value, interval, enabled: enabledRef.current?.checked ?? true, repo: repoRef.current?.value.trim() || undefined, diff --git a/emrg/gui/renderer/src/components/WorkspaceView.test.tsx b/emrg/gui/renderer/src/components/WorkspaceView.test.tsx index c727706f3..06de7ad37 100644 --- a/emrg/gui/renderer/src/components/WorkspaceView.test.tsx +++ b/emrg/gui/renderer/src/components/WorkspaceView.test.tsx @@ -82,7 +82,7 @@ describe("WorkspaceView", () => { expect(screen.getByTestId("projects-empty")).toBeInTheDocument(); const projects = [project("emrg"), project("aitokenpool", "/scm/aitokenpool")]; - const tasks = [{ name: "emrg-task", config: { project: "emrg" } }] as TaskRec[]; + const tasks = [{ name: "emrg-task", config: { agent: "emrg" } }] as TaskRec[]; const { rerender } = setup({ projects, tasks }); expect(screen.getAllByTestId("project-row")).toHaveLength(2); // emrg 有演化任务 → ⚡ 徽标;aitokenpool 无 diff --git a/emrg/gui/renderer/src/lib/workspaceView.test.ts b/emrg/gui/renderer/src/lib/workspaceView.test.ts index d38a6f621..3a0db64d8 100644 --- a/emrg/gui/renderer/src/lib/workspaceView.test.ts +++ b/emrg/gui/renderer/src/lib/workspaceView.test.ts @@ -117,10 +117,10 @@ describe("preprocessRantMarkdown", () => { }); describe("buildEvolveProjects", () => { - it("收集 config.project 集合(含 evolution 任务)", () => { + it("收集 config.agent 集合(含 evolution 任务)", () => { const tasks: TaskRec[] = [ - { name: "a", config: { project: "emrg" } }, - { name: "b", config: { project: "aitokenpool" } }, + { name: "a", config: { agent: "emrg" } }, + { name: "b", config: { agent: "aitokenpool" } }, { name: "c", config: {} }, ]; const set = buildEvolveProjects(tasks); @@ -176,7 +176,7 @@ describe("taskStatusBadge", () => { describe("taskHint", () => { it("项目 / 间隔 / 停用 join ·", () => { - const h = taskHint({ config: { project: "emrg" }, interval: 1800 }, t); + const h = taskHint({ config: { agent: "emrg" }, interval: 1800 }, t); expect(h).toBe('emrg · app.taskInterval={"n":1800}'); expect(taskHint({ enabled: false }, t)).toBe("app.taskDisabled"); }); diff --git a/emrg/gui/renderer/src/lib/workspaceView.ts b/emrg/gui/renderer/src/lib/workspaceView.ts index a9ed05b28..e54ed4a08 100644 --- a/emrg/gui/renderer/src/lib/workspaceView.ts +++ b/emrg/gui/renderer/src/lib/workspaceView.ts @@ -44,7 +44,7 @@ export interface TaskRec { interval?: number | null; last_run_at?: string | null; saturation?: { heartbeat_active?: boolean; heartbeat_interval?: number } | null; - config?: { project?: string; repo?: string; sandbox?: string } | null; + config?: { agent?: string; repo?: string; sandbox?: string } | null; /** 分时段频次窗口;缺省=全天单一 `interval`(`list_tasks()` 逐字回报) */ schedule?: ScheduleWindow[] | null; /** 任务面板「打开会话」所需字段(daemon TaskHandler.status() 返回,vanilla #924 迁移时丢失) */ @@ -142,11 +142,11 @@ export function preprocessRantMarkdown(text?: string | null): string { /* ── 项目面板工具 ────────────────────────────────────────── */ -/** auto_evolve 徽标集合:tasks.yml 中 config.project 与项目名相同(含 evolution 类型) */ +/** auto_evolve 徽标集合:tasks.yml 中 config.agent 与项目名相同(含 evolution 类型) */ export function buildEvolveProjects(tasks: TaskRec[] | null | undefined): Set { const set = new Set(); for (const t of tasks || []) { - const proj = t?.config?.project; + const proj = t?.config?.agent; if (proj) set.add(proj); } return set; @@ -202,7 +202,7 @@ export function taskStatusBadge(t: TaskRec, tFn?: TFunc, now: number = Date.now( export function taskHint(t: TaskRec, tFn?: TFunc): string { const tr = tFn || ((k: string) => k); const parts: string[] = []; - if (t.config?.project) parts.push(t.config.project); + if (t.config?.agent) parts.push(t.config.agent); if (t.interval != null) parts.push(tr("app.taskInterval", { n: t.interval })); if (t.enabled === false) parts.push(tr("app.taskDisabled")); return parts.join(" · "); diff --git a/emrg/server/__main__.py b/emrg/server/__main__.py index 58c05567b..db764fc7d 100644 --- a/emrg/server/__main__.py +++ b/emrg/server/__main__.py @@ -9,6 +9,7 @@ from emrg.logfiles import migrate_legacy_logs from emrg.server.daemon import DaemonExit, _non_regular_kind, run_server from emrg.server.logcontext import session_label +from emrg.server.scheduler import migrate_task_config_keys from emrg.tool_path import ensure_tool_dirs @@ -202,6 +203,13 @@ def main() -> None: # fails without touching the host's register at all, and it sits in no path # derivation — asking where the register lives must never move it. migrate_legacy_registry() + # The task table's agent key moved from `project` to `agent` (issue #2531), + # and it moves here for the same reason: this is a process that owns the + # file, it runs before the scheduler is constructed below, and no test path + # reaches it — so a suite run cannot rewrite a host's live table under a + # daemon still reading the old key. Asking a record for its agent never + # writes; only this call does. + migrate_task_config_keys() result = asyncio.run(run_server(config.llm)) except KeyboardInterrupt: # SIGINT delivered at the event-loop poll point escapes the main diff --git a/emrg/server/competition_prompt.md b/emrg/server/competition_prompt.md index cba45679b..e88f652a5 100644 --- a/emrg/server/competition_prompt.md +++ b/emrg/server/competition_prompt.md @@ -17,7 +17,7 @@ You are EMRG's competition participation module. **Every cycle you MUST fully ex 5. **Think before acting.** Before an irreversible or quota-consuming action — a submission, a registration, starting a long run — state in the reply **what will be done, on what evidence, the expected result, and the cost of failure**. If the evidence is not there yet, go and get it rather than doing it to find out. ### Current State -- Task project: **{{ task.project }}** (from tasks.yml) +- Task agent: **{{ task.agent }}** (from tasks.yml) - Local source: `{{ source_dir }}` - **Current time: `{{ timestamp }}`({{ current_time_human }})** — the time anchor for judging deadlines @@ -58,7 +58,7 @@ Cross-round continuity comes from what the daemon already gives you: it loads th Rants are how the host sends instructions directly to a task. -- A rant matches **only if its `project` field is exactly `{{ task.project }}`**. Rants that do not match are ignored completely. +- A rant matches **only if its `project` field is exactly `{{ task.agent }}`**. Rants that do not match are ignored completely. - Unmatched but seemingly relevant rants must be **counted and reported to the host** — never silently dropped. - A matching rant is an instruction for this round: it takes priority over the round's planned work. diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 84c1ae449..64cc813f3 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2937,7 +2937,7 @@ async def _process_message( if self._scheduler: ok, res = self._scheduler.task_create( name=name, task_type="evolution", - project=name, interval=600, + agent=name, interval=600, ) if ok: self._scheduler.reconcile() @@ -3004,7 +3004,7 @@ async def _process_message( ok, res = self._scheduler.task_create( name=msg.get("name", "").strip(), task_type=msg.get("task_type", "").strip(), - project=msg.get("project", "").strip(), + agent=msg.get("agent", "").strip(), interval=msg.get("interval"), enabled=msg.get("enabled", True), repo=msg.get("repo"), @@ -3026,7 +3026,7 @@ async def _process_message( if not self._scheduler: await self._send(ws, {"type": "task_result", "error": "scheduler not running"}) return - fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox", "schedule") if k in msg} + fields = {k: msg[k] for k in ("task_type", "agent", "interval", "enabled", "repo", "description", "sandbox", "schedule") if k in msg} if "task_type" in fields: fields["type"] = fields.pop("task_type") ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields) diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index d4f666d05..3fd3e34b2 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -221,7 +221,7 @@ Two ways this source goes quiet, and both are legitimate: an installation whose - **Issue**: something needing a reply or triage, or a resolved one to close — the close itself is §3.1. What finishes an issue, and how the link reading is read, is R5. - **Rant**: take each one through three questions. - **Has it been handled?** `git log --oneline -20` for a commit naming its timestamp or keywords. Naming proves only that it was **touched**, never that it is finished — check for unmet acceptance items, unmerged branches, and a stage that landed without finishing the work. - - **Does it match this task?** A rant's `project` matches if it is `{{ task.project }}` or `{{ owner }}/{{ repo }}`; a rant that names neither is not this instance's to act on — **ignore rants without a `project` field entirely**. + - **Does it match this task?** A rant's `project` matches if it is `{{ task.agent }}` or `{{ owner }}/{{ repo }}`; a rant that names neither is not this instance's to act on — **ignore rants without a `project` field entirely**. - **Is it work?** A rant this cycle decides against is closed with the reason in its progress; a rant taken on becomes an issue before any code is written (R5). The lifecycle is R6. **Before any reason is written down as the host's ruling, R7.** #### 2.3 Decide this cycle's work diff --git a/emrg/server/feishu_prompt.md b/emrg/server/feishu_prompt.md index 38346511f..0ff43a841 100644 --- a/emrg/server/feishu_prompt.md +++ b/emrg/server/feishu_prompt.md @@ -4,7 +4,7 @@ You are EMRG handling the host's Feishu messages: read what arrived, judge each what needs no permission, ask where it does. ### Current State -- Task project: **{{ task.project }}** (from tasks.yml) +- Task agent: **{{ task.agent }}** (from tasks.yml) - Local source: `{{ source_dir }}` - **Current time: `{{ timestamp }}`({{ current_time_human }})** — the anchor for "since the last round" and for every dated record @@ -47,7 +47,7 @@ in the usual install locations before calling it blocked. task instructions. - Take the messages that arrived after the last one you handled. Remember that boundary yourself; there is no state file to keep and none to trust. -- Rants whose `project` is `{{ task.project }}` are the host's instructions for this round +- Rants whose `project` is `{{ task.agent }}` are the host's instructions for this round and outrank the plan; those that do not match are counted, not acted on. - A CLI that cannot run is a `blocked` item reported to the host, and so is an account that really cannot authenticate — but `missing identity` is a symptom, not a cause. The CLI diff --git a/emrg/server/ios_app_prompt.md b/emrg/server/ios_app_prompt.md index cbab252fa..5d3c1077e 100644 --- a/emrg/server/ios_app_prompt.md +++ b/emrg/server/ios_app_prompt.md @@ -12,7 +12,7 @@ You are EMRG as the **product owner** of iOS applications: you choose what to bu **Every round runs §0 Preparation first (§0.1–§0.7), whichever phase it then enters.** ### Current State -- Task project: **{{ task.project }}** (from tasks.yml) +- Task agent: **{{ task.agent }}** (from tasks.yml) - Local source: `{{ source_dir }}` - **Current time: `{{ timestamp }}`({{ current_time_human }})** — the time anchor for judging review turnaround and every dated record @@ -60,7 +60,7 @@ Everything needed to build, sign and upload an iOS app is installed. **Adding a Rants are how the host sends instructions directly to a task. -- A rant matches **only if its `project` field is exactly `{{ task.project }}`**. Rants that do not match are ignored completely. +- A rant matches **only if its `project` field is exactly `{{ task.agent }}`**. Rants that do not match are ignored completely. - Unmatched but seemingly relevant rants must be **counted and reported to the host**, never silently dropped. - A matching rant is an instruction for this round and takes priority over the round's planned work. diff --git a/emrg/server/journal_prompt.md b/emrg/server/journal_prompt.md index db3af3c07..7129b9a07 100644 --- a/emrg/server/journal_prompt.md +++ b/emrg/server/journal_prompt.md @@ -127,9 +127,9 @@ cat ~/.emrg/rants.jsonl 2>/dev/null || echo "[no rants.jsonl — skip rant scan] ``` Filter rules (same as open-source tasks): -- Match rant's `project` against exactly `{{ task.project }}` — equal counts, anything else does not +- Match rant's `project` against exactly `{{ task.agent }}` — equal counts, anything else does not - Ignore rants without a `project` field; only `pending` / `in_progress` count -- **Irrelevant-rant exclusion (HARD)**: any rant that does NOT match `project` exactly, or whose content is not about THIS journal, must be **ignored entirely** — do NOT read it into your thinking, do NOT adopt it as a research direction, do NOT cite it. **An irrelevant rant must never become a topic anchor.** Only a rant with `project` = `{{ task.project }}` AND content actually directed at this journal enters the candidate pool / Phase Ops. +- **Irrelevant-rant exclusion (HARD)**: any rant that does NOT match `project` exactly, or whose content is not about THIS journal, must be **ignored entirely** — do NOT read it into your thinking, do NOT adopt it as a research direction, do NOT cite it. **An irrelevant rant must never become a topic anchor.** Only a rant with `project` = `{{ task.agent }}` AND content actually directed at this journal enters the candidate pool / Phase Ops. - **Dedup check** before treating any rant as actionable: search the journal repo commit log for the rant's timestamp/keywords — a commit referencing the rant only counts as handled when ALL acceptance items are satisfied AND related PRs merged - **Rant status machine**: `pending → in_progress → completed` (never jump pending → completed). When starting work on a rant: set `in_progress` + progress note. When all its PRs merged + self-verification passes: set `completed` + ISO timestamp. Host feedback that a fix is insufficient → revert to `in_progress` with reason. - Rant-driven journal ops (e.g. "adjust CfP", "revise review policy") are processed in Phase Ops with highest priority. @@ -370,7 +370,7 @@ Nothing pending? → Phase Research (new di - Read the 2–3 most relevant recent abstracts (last 6 months), record their limitations → this forms your gap. If browser-harness is available, prefer it for reading full texts. - **The editor's CfP / journal themes**: README, CfP issue, editorial comments — topics the journal explicitly wants. - **Your own papers' future work**: Discussion / Future-Work paragraphs of your published or in-review manuscripts. **Own-work relevance is NOT by itself a topic reason** — it is only one candidate among many. - - **Host rants with `project` = {{ task.project }}**: directional feedback, not just bug reports. (Per §0.5, irrelevant rants are excluded entirely.) + - **Host rants with `project` = {{ task.agent }}**: directional feedback, not just bug reports. (Per §0.5, irrelevant rants are excluded entirely.) - **Uncovered gaps in the journal**: open issues / published papers that raise questions nobody has answered yet. - **Data-source debias**: having data for a particular project on hand is NOT a topic reason. Prefer directions backed by external data sources (public datasets, open-source repositories, simulations) or where data can be obtained/created during the research. 2. **Research-hotspot identification** (using the current-time anchor; run in parallel with the candidate pool): @@ -544,7 +544,7 @@ cd {{ source_dir }} && gh issue list -R {{ owner }}/{{ repo }} --label in-review 1. **Never touch host dirt**: never stash/reset/clean host work — the daemon converges the tree itself before the prompt, so a tree still dirty here is a convergence that failed (and the round is then read-only); `papers/*/research/` being present is normal 2. **git add exact path**: submission/revision commits use `git add papers/issue-/` — never `-A` / `.` -3. **Rant handling**: follow 0.5 — project must equal `{{ task.project }}`; pending → in_progress → completed +3. **Rant handling**: follow 0.5 — project must equal `{{ task.agent }}`; pending → in_progress → completed 4. **Language policy**: external journal-facing text (issues/PRs/reviews/decisions) in English; internal records (session history, memory entries, the closing summary) in the author's language 5. **One thing at a time**: advance exactly one phase per cycle; don't aim for completeness, just for progress 6. **Never claim/review your own submission** (author) diff --git a/emrg/server/open_source_prompt.md b/emrg/server/open_source_prompt.md index 5594697ff..405d106d6 100644 --- a/emrg/server/open_source_prompt.md +++ b/emrg/server/open_source_prompt.md @@ -111,9 +111,9 @@ For each one: **merged** → read what landed and whether anything follows from The queue is read **through the tool** — `submit_rant(action="list")` — never by opening the file by hand; the file is the store, the tool is the reader. -A rant is a **work order for THIS repository** when its `project` field equals this task's `config.project` — **`{{ task.project }}`** — and nothing else counts: a rant filed as `{{ owner }}/{{ repo }}` does **NOT** match, and a rant with no `project` field at all is ignored entirely. Only `pending` and `in_progress` rants are candidates. +A rant is a **work order for THIS repository** when its `project` field equals this task's `config.project` — **`{{ task.agent }}`** — and nothing else counts: a rant filed as `{{ owner }}/{{ repo }}` does **NOT** match, and a rant with no `project` field at all is ignored entirely. Only `pending` and `in_progress` rants are candidates. -**Unmatched-rant hint**: if rants are still pending whose `project` merely *starts with* `{{ task.project }}` or `{{ owner }}/{{ repo }}`, record the count in the closing summary so the host can correct the field — never pass over them in silence. +**Unmatched-rant hint**: if rants are still pending whose `project` merely *starts with* `{{ task.agent }}` or `{{ owner }}/{{ repo }}`, record the count in the closing summary so the host can correct the field — never pass over them in silence. **Dedup check — before treating any candidate as actionable:** diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 045902476..b6a2eaa98 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -15,7 +15,7 @@ clock and whatever its zone does with DST. Weekdays only: no holidays, no make-up workdays. config — type-specific config, and the mapping the templates see as ``task``. For - evolution: config.project links to agents.yml name. + evolution: config.agent links to agents.yml name. sandbox — optional; also accepted under config, where the record-level value wins. extra_prompt — optional; also accepted at the record's top level, where config wins (the templates read it as ``task.extra_prompt``; see ``_template_task_context``). @@ -543,7 +543,7 @@ def _silence_deadline(now: float, last_frame_at: float, tool_deadline: float | N DEFAULT_TASK_RECORD: dict = { "name": "emrg-task", "type": "evolution", - "config": {"project": "emrg"}, + "config": {"agent": "emrg"}, "interval": 600, "enabled": True, } @@ -769,11 +769,70 @@ def write_table(records: list[dict], path: Path | None = None) -> None: atomic_write_yaml(records, path, prefix=".tasks_", dumper=_TaskTableDumper) +def migrate_task_config_keys(path: Path | None = None) -> int: + """Move a task record's agent key from ``project`` to ``agent``, in place. + + Returns how many records this call rewrote, so a caller can say whether it did + anything rather than guess. + + **One spelling, no compat read** (issue #2531). The resolution reads + ``config.agent`` and only ``config.agent``: a tree that answered both spellings + would keep alive exactly the ambiguity the rename exists to remove — every + reader would again have to decide which of the two a given record meant. So the + old key is not a fallback; it is moved, once, and then it is gone. + + **Called from the daemon's process entry and nowhere else.** The same separation + `emrg.config.migrate_legacy_registry` uses, for the same reason: the test suite + constructs schedulers and drives `load_and_start` against tables of its own, and + a migration reachable from those paths would rewrite a host's live ``tasks.yml`` + under a daemon that is still reading the old key. The reader never writes — + :func:`read_table` returns a record exactly as written, old key and all — which is + the property ``test_scheduler.py`` pins. + + A record is rewritten at the key's own position, so every other key, its order and + its value travel across unchanged. A record already carrying ``agent`` keeps it + and loses the stale ``project`` rather than gaining a second value. A record with + neither is untouched, a table that cannot be read is left exactly where it lies + (the caller is starting up, and refusing to start over a table it could not parse + would be a worse failure than the one it reports), and a file that does not exist + is not created — this moves an existing table, it never seeds one. + """ + path = path or _tasks_yml_path() + if not path.exists(): + return 0 + try: + records = read_table(path) + except TableUnreadable as exc: + logger.warning("TaskScheduler: %s not migrated — %s", path, exc) + return 0 + changed = 0 + for record in records: + cfg = record.get("config") + if not isinstance(cfg, dict) or "project" not in cfg: + continue + moved: dict = {} + for key, value in cfg.items(): + if key == "project": + if "agent" not in cfg: + moved["agent"] = value + continue + moved[key] = value + record["config"] = moved + changed += 1 + if changed: + write_table(records, path) + logger.info( + "TaskScheduler: %s migrated — %d task record(s) moved to config.agent", + path, changed, + ) + return changed + + def _template_task_context(config: dict, record: dict) -> dict: """The ``task`` mapping the prompt templates render against. ``task`` is the record's ``config``: every name a template reads there — - ``extra_prompt``, ``project``, ``role``, ``author_id``, ``keywords``, ``platform`` — + ``extra_prompt``, ``agent``, ``role``, ``author_id``, ``keywords``, ``platform`` — lives under ``config:`` in tasks.yml. ``extra_prompt`` is **also honoured at the record's top level**, which is the position @@ -1133,7 +1192,7 @@ def _apply_record(self, record: dict) -> None: cfg = record.get("config") if isinstance(record.get("config"), dict) else {} # Resolve project path from config (new schema) or fall back to # config.path for backward-compat with old tasks.yml entries. - project_name = cfg.get("project", "") + project_name = cfg.get("agent", "") path = _resolve_project_path(project_name) if project_name else cfg.get("path", "") # Derive owner/repo/git from config override → path git remote → defaults. # (rant 2026-08-19T14:20:52: workspace self-heal deleted — these fields @@ -3583,7 +3642,7 @@ def _idle_status(self, record: dict) -> dict: started before it can render the row. """ cfg = record.get("config") if isinstance(record.get("config"), dict) else {} - project = cfg.get("project", "") + project = cfg.get("agent", "") # Issue #2486 ⑦: the same three cadence keys the live handler reports — a # client must not have to know whether a task has been started before it can # render the frequency that is in force, or when it next changes. @@ -3678,7 +3737,7 @@ def _ensure_emrg_project_entry(self) -> None: @staticmethod def _validate_task_fields( - name: str, task_type: str, project: str, interval: int, + name: str, task_type: str, agent: str, interval: int, schedule: list | None = None, ) -> str | None: """Return an error string, or None when the fields are valid.""" @@ -3686,22 +3745,22 @@ def _validate_task_fields( return f"invalid task name {name!r} ({TASK_NAME_PATTERN}, <= {TASK_NAME_MAX} chars)" if task_type not in TASK_TEMPLATES and task_type not in _custom_templates(): return f"unknown task type {task_type!r} (not builtin, no custom template)" - if not project or _resolve_project_path(project) is None: - return f"project {project!r} is not registered in agents.yml" + if not agent or _resolve_project_path(agent) is None: + return f"agent {agent!r} is not registered in agents.yml" if not isinstance(interval, int) or isinstance(interval, bool) or interval < MIN_INTERVAL: return f"interval must be an integer >= {MIN_INTERVAL}" # The same validator the loader runs: one shape, one set of messages. return validate_schedule(schedule) def task_create( - self, name: str, task_type: str, project: str, + self, name: str, task_type: str, agent: str, interval: int | None = None, enabled: bool = True, repo: str | None = None, description: str | None = None, sandbox: str | None = None, schedule: list | None = None, ) -> tuple[bool, str | dict]: """Create a task. Returns (ok, error) or (ok, task-dict).""" interval = DEFAULT_INTERVAL if interval is None else interval - err = self._validate_task_fields(name, task_type, project, interval, schedule) + err = self._validate_task_fields(name, task_type, agent, interval, schedule) if err: return False, err if sandbox is not None and sandbox not in SANDBOX_MODES: @@ -3716,7 +3775,7 @@ def task_create( "the name is a file name and a session id, and those are not case-sensitive " "on every platform)" ) - cfg: dict = {"project": project} + cfg: dict = {"agent": agent} if repo: cfg["repo"] = repo task: dict = { @@ -3744,23 +3803,23 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]: if task is None: return False, f"task {name!r} not found" new_type = fields.get("type", task.get("type", "evolution")) - new_project = fields.get("project") - if new_project is None: + new_agent = fields.get("agent") + if new_agent is None: cfg = task.get("config") if isinstance(task.get("config"), dict) else {} - new_project = cfg.get("project", "") + new_agent = cfg.get("agent", "") new_interval = fields.get("interval", task.get("interval", DEFAULT_INTERVAL)) new_schedule = fields.get("schedule", task.get("schedule")) err = self._validate_task_fields( - name, new_type, new_project, new_interval, new_schedule, + name, new_type, new_agent, new_interval, new_schedule, ) if err: return False, err if "type" in fields: task["type"] = fields["type"] - if "project" in fields or "repo" in fields: + if "agent" in fields or "repo" in fields: cfg = task.get("config") if isinstance(task.get("config"), dict) else {} - if "project" in fields: - cfg["project"] = fields["project"] + if "agent" in fields: + cfg["agent"] = fields["agent"] if "repo" in fields: if fields["repo"]: cfg["repo"] = fields["repo"] diff --git a/tests/test_agents_registry_migration.py b/tests/test_agents_registry_migration.py index 344ec248d..8954d30d9 100644 --- a/tests/test_agents_registry_migration.py +++ b/tests/test_agents_registry_migration.py @@ -126,6 +126,11 @@ def test_the_daemon_entrypoint_migrates_before_it_serves(monkeypatch): "migrate_legacy_registry", lambda *a, **k: calls.append("migrate") or True, ) + monkeypatch.setattr( + server_main, + "migrate_task_config_keys", + lambda *a, **k: calls.append("migrate-tasks") or 0, + ) monkeypatch.setattr( "emrg.config.load_config", lambda: types.SimpleNamespace(llm=None), @@ -146,7 +151,7 @@ async def _run_server(llm): with pytest.raises(SystemExit): server_main.main() - assert calls == ["migrate", "serve"], calls + assert calls == ["migrate", "migrate-tasks", "serve"], calls def test_the_old_name_is_spelled_only_where_the_move_is(): diff --git a/tests/test_competition_prompt.py b/tests/test_competition_prompt.py index b42fa20be..63b6813cc 100644 --- a/tests/test_competition_prompt.py +++ b/tests/test_competition_prompt.py @@ -75,7 +75,7 @@ def rendered(tmp_path_factory) -> str: handler = make_handler( name="competition-task", type="competition", - config={"project": PROMPT_PROJECT}, + config={"agent": PROMPT_PROJECT}, identity=InstanceIdentity(), template_path=PROMPT, ) @@ -353,7 +353,7 @@ def test_preparation_and_closing_summary_are_mandatory(): assert "a round that ends without a summary strands the next one" in text assert "Seven questions the closing summary must answer" in text # Rant scan must match the project field exactly, like the other templates. - assert "exactly `{{ task.project }}`" in text + assert "exactly `{{ task.agent }}`" in text def test_error_handling_and_forbidden_sections_exist(): @@ -998,7 +998,7 @@ def test_the_render_substitutes_values_and_carries_no_template_syntax(rendered): Two ways a render assertion goes vacuous, both closed here. If the block were read with its syntax intact — a `{% raw %}` wrapper, or the text copied out of a literal — - the round would be sent `{{ task.project }}` and would be told nothing; and if what + the round would be sent `{{ task.agent }}` and would be told nothing; and if what this test examined were the file, the same assertion catches it. Then the substituted value has to arrive, so "it rendered" is measured by what the round can read rather than by the absence of braces alone. @@ -1010,7 +1010,7 @@ def test_the_render_substitutes_values_and_carries_no_template_syntax(rendered): assert PROMPT_PROJECT in rendered, ( "the render does not carry the project name " f"{PROMPT_PROJECT!r}: " - "`{{ task.project }}` resolved to nothing (`jinja2.Undefined` renders empty), so " + "`{{ task.agent }}` resolved to nothing (`jinja2.Undefined` renders empty), so " "the round is told its project is blank" ) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index a0d445db3..1500d3ee4 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -5784,9 +5784,9 @@ def test_the_tasks_list_frame_carries_the_minimum_interval(tmp_path, monkeypatch sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "windowed", "type": "evolution", "config": {"project": "emrg"}, + {"name": "windowed", "type": "evolution", "config": {"agent": "emrg"}, "interval": 10800, "enabled": True, "schedule": windows}, - {"name": "plain", "type": "evolution", "config": {"project": "emrg"}, + {"name": "plain", "type": "evolution", "config": {"agent": "emrg"}, "interval": 1800, "enabled": True}, ])) diff --git a/tests/test_daemon_tool_path.py b/tests/test_daemon_tool_path.py index 84e97d6cf..305ae3780 100644 --- a/tests/test_daemon_tool_path.py +++ b/tests/test_daemon_tool_path.py @@ -344,6 +344,9 @@ def test_the_launcher_entry_normalizes_the_path(monkeypatch): # Stubbed so the test opens no host file: these are the real entry's side effects. monkeypatch.setattr(server_main, "_configure_logging", lambda: None) monkeypatch.setattr(server_main, "_redirect_std_streams", lambda: None) + # Stubbed for the same reason as the logging hooks above: the real call moves a + # key in the host's tasks.yml, and a test opens no host file. + monkeypatch.setattr(server_main, "migrate_task_config_keys", lambda *a, **k: 0) monkeypatch.setattr("emrg.config.load_config", lambda: types.SimpleNamespace(llm=object())) class _Exit: diff --git a/tests/test_evolution_prompt_index_rule.py b/tests/test_evolution_prompt_index_rule.py index ce4d2e63a..417ed7338 100644 --- a/tests/test_evolution_prompt_index_rule.py +++ b/tests/test_evolution_prompt_index_rule.py @@ -116,7 +116,7 @@ def rendered(tmp_path_factory) -> str: try: handler = make_handler( name="demo-task", - config={"project": "demoproj"}, + config={"agent": "demoproj"}, interval=300, identity=InstanceIdentity(), template_path=PROMPTS_DIR / "evolution_prompt.md", diff --git a/tests/test_evolution_prompt_merge_condition.py b/tests/test_evolution_prompt_merge_condition.py index 992e96777..bcc6b4e44 100644 --- a/tests/test_evolution_prompt_merge_condition.py +++ b/tests/test_evolution_prompt_merge_condition.py @@ -119,7 +119,7 @@ def rendered(tmp_path_factory) -> str: try: handler = make_handler( name="demo-task", - config={"project": "demoproj"}, + config={"agent": "demoproj"}, interval=300, identity=InstanceIdentity(), template_path=PROMPTS_DIR / "evolution_prompt.md", diff --git a/tests/test_evolution_prompt_vote_body.py b/tests/test_evolution_prompt_vote_body.py index cd57e2257..c7f6d9b39 100644 --- a/tests/test_evolution_prompt_vote_body.py +++ b/tests/test_evolution_prompt_vote_body.py @@ -93,7 +93,7 @@ def rendered(tmp_path, monkeypatch) -> str: ) handler = make_handler( name="demo-task", - config={"project": "demoproj"}, + config={"agent": "demoproj"}, interval=300, identity=InstanceIdentity(), template_path=PROMPTS_DIR / "evolution_prompt.md", diff --git a/tests/test_prompt_templates.py b/tests/test_prompt_templates.py index c8fb10814..6bb29ff24 100644 --- a/tests/test_prompt_templates.py +++ b/tests/test_prompt_templates.py @@ -62,7 +62,7 @@ # Optional task/project fields the prompts guard on. Populated only by the strict # render, so that a "missing" name there is a typo rather than a design choice. FULL_TASK_CONFIG: dict = { - "project": "demoproj", + "agent": "demoproj", "role": "committer", "author_id": "argszero", "keywords": "demo, wiring", @@ -95,7 +95,7 @@ def _make_handler( ) -> TaskHandler: """A real handler, from a hermetic config dir, pointed at a real template.""" monkeypatch.setattr(mod, "config_dir", lambda: tmp_path) - name = config.get("project", "demoproj") + name = config.get("agent", "demoproj") _write_projects_yml(tmp_path, name, project_entry or {"name": name}) return make_handler( name="demo-task", @@ -112,7 +112,7 @@ def _builtin_templates() -> list[tuple[str, str]]: def test_every_prompt_template_renders_through_the_real_builder(tmp_path, monkeypatch) -> None: """Each built-in task type renders, with the optional sections unset.""" - minimal = {"project": "demoproj"} + minimal = {"agent": "demoproj"} for task_type, filename in _builtin_templates(): handler = _make_handler(tmp_path, monkeypatch, filename, minimal) rendered = handler._build_evolution_prompt() diff --git a/tests/test_schedule_window.py b/tests/test_schedule_window.py index 8852db6f9..dc7e8f3df 100644 --- a/tests/test_schedule_window.py +++ b/tests/test_schedule_window.py @@ -49,7 +49,7 @@ def _record(**overrides) -> dict: record = { "name": "windowed", "type": "evolution", - "config": {"project": "emrg"}, + "config": {"agent": "emrg"}, "interval": 10800, "enabled": True, "schedule": [ @@ -271,7 +271,7 @@ def _row(tasks_yml, name: str) -> dict: def test_create_and_update_carry_the_windows_through_the_file(sched): ok, created = sched.task_create( - name="windowed", task_type="evolution", project="emrg", + name="windowed", task_type="evolution", agent="emrg", interval=10800, schedule=WINDOW, ) assert ok, created @@ -292,12 +292,12 @@ def test_create_and_update_carry_the_windows_through_the_file(sched): def test_a_bad_window_is_refused_on_both_write_paths(sched): bad = [{"days": "mon", "from": "18:00", "to": "09:00", "interval": 900}] ok, error = sched.task_create( - name="bad", task_type="evolution", project="emrg", interval=10800, schedule=bad, + name="bad", task_type="evolution", agent="emrg", interval=10800, schedule=bad, ) assert not ok and "does not move forward" in error, error assert sched.task_create( - name="good", task_type="evolution", project="emrg", interval=10800, + name="good", task_type="evolution", agent="emrg", interval=10800, )[0] ok, error = sched.task_update("good", schedule=bad) assert not ok and "does not move forward" in error, error @@ -310,7 +310,7 @@ def test_list_tasks_reports_the_effective_interval_and_the_next_edge(sched): """A record with no handler reports the same cadence keys a live one does.""" all_day = [{"days": None, "from": "00:00", "to": "24:00", "interval": 900}] sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "always", "type": "evolution", "config": {"project": "emrg"}, + {"name": "always", "type": "evolution", "config": {"agent": "emrg"}, "interval": 10800, "enabled": True, "schedule": all_day}, ])) row = sched.list_tasks()[0] diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index e33bf5683..df0d0f748 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -37,7 +37,7 @@ def test_task_handler_logger_adapter_carries_task_column(tmp_path): try: handler = make_handler( name="emrg-task", - config={"project": "emrg"}, + config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) @@ -124,7 +124,7 @@ def test_write_table_replaces_atomically(tmp_path): tasks_yml.write_text(yaml.safe_dump([{"name": "old"}])) write_table([ - {"name": "test1", "type": "evolution", "config": {"project": "emrg"}}, + {"name": "test1", "type": "evolution", "config": {"agent": "emrg"}}, ], tasks_yml) data = yaml.safe_load(tasks_yml.read_text()) @@ -224,7 +224,7 @@ def _task_create_fixture(tmp_path, monkeypatch, sched): ) sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "emrg-task", "type": "evolution", "config": {"project": "emrg"}}, + {"name": "emrg-task", "type": "evolution", "config": {"agent": "emrg"}}, ])) @@ -260,9 +260,9 @@ def test_task_delete_and_update_do_not_touch_other_records(tmp_path, monkeypatch sched = TaskScheduler(InstanceIdentity()) _task_create_fixture(tmp_path, monkeypatch, sched) sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "emrg-task", "type": "evolution", "config": {"project": "emrg"}}, - {"name": "keep", "type": "evolution", "config": {"project": "proj"}, "interval": 300}, - {"name": "drop", "type": "evolution", "config": {"project": "proj"}, "interval": 400}, + {"name": "emrg-task", "type": "evolution", "config": {"agent": "emrg"}}, + {"name": "keep", "type": "evolution", "config": {"agent": "proj"}, "interval": 300}, + {"name": "drop", "type": "evolution", "config": {"agent": "proj"}, "interval": 400}, ])) before = yaml.safe_load(sched._tasks_file.read_text()) @@ -336,7 +336,7 @@ def test_load_and_start_no_file_seeds_the_default_table(tmp_path): def test_load_and_start_enabled_task(tmp_path): """Starts a coroutine for each enabled task, and the interval is the record's.""" snapshot = asyncio.run(_boot(tmp_path, [ - {"name": "emrg", "type": "evolution", "config": {"project": "emrg"}, + {"name": "emrg", "type": "evolution", "config": {"agent": "emrg"}, "interval": 99, "enabled": True}, ])) @@ -350,7 +350,7 @@ def test_load_and_start_enabled_task(tmp_path): def test_load_and_start_skips_disabled(tmp_path): """Disabled tasks are not started.""" snapshot = asyncio.run(_boot(tmp_path, [ - {"name": "enabled", "type": "evolution", "config": {"project": "emrg"}, "enabled": True}, + {"name": "enabled", "type": "evolution", "config": {"agent": "emrg"}, "enabled": True}, {"name": "disabled", "type": "evolution", "config": {"path": "/tmp"}, "enabled": False}, ])) @@ -405,7 +405,7 @@ def test_load_and_start_competition_task(tmp_path): """ snapshot = asyncio.run(_boot(tmp_path, [ {"name": "competition-task", "type": "competition", - "config": {"project": "competitions"}, + "config": {"agent": "competitions"}, "interval": 3600, "enabled": True}, ])) @@ -428,7 +428,7 @@ def test_load_and_start_ios_app_task(tmp_path): """ snapshot = asyncio.run(_boot(tmp_path, [ {"name": "ios-app-task", "type": "ios-app", - "config": {"project": "iosapps"}, + "config": {"agent": "iosapps"}, "interval": 3600, "enabled": True}, ])) @@ -504,7 +504,7 @@ def test_load_and_start_feishu_task(tmp_path): """ snapshot = asyncio.run(_boot(tmp_path, [ {"name": "feishu-task", "type": "feishu", - "config": {"project": "emrg"}, + "config": {"agent": "emrg"}, "interval": 900, "enabled": True}, ])) @@ -595,7 +595,7 @@ def test_load_and_start_promote_task(tmp_path): """Promote tasks start a TaskHandler with the promote template.""" snapshot = asyncio.run(_boot(tmp_path, [ {"name": "olr-promote", "type": "promote", - "config": {"project": "openlocalrouter"}, + "config": {"agent": "openlocalrouter"}, "interval": 3600, "enabled": True}, ])) @@ -672,7 +672,7 @@ def test_load_and_start_journal_task(tmp_path): """Journal tasks start a TaskHandler with the journal template.""" snapshot = asyncio.run(_boot(tmp_path, [ {"name": "journal-task", "type": "journal", - "config": {"project": "mem", "role": "author", "author_id": "author-a"}, + "config": {"agent": "mem", "role": "author", "author_id": "author-a"}, "interval": 3600, "enabled": True}, ])) @@ -720,7 +720,7 @@ def test_journal_template_renders_with_context(): ctx = dict( instance_id="test", host_name="host", owner="x", repo="y", source_dir="/tmp/j", timestamp="20260824-182903", current_time_human="2026-08-24 18:29", - task={"role": "author", "project": "j", "author_id": "author-a"}, + task={"role": "author", "agent": "j", "author_id": "author-a"}, ) out = template.render(**ctx) # Current-time injection (rant 18:19:08) @@ -1037,7 +1037,7 @@ def test_task_handler_emrg_configured_by_default(): """The emrg evolution task is always repo-configured (defaults to argszero/emrg).""" handler = make_handler( name="emrg-task", - config={"project": "emrg"}, + config={"agent": "emrg"}, interval=1800, identity=InstanceIdentity(), ) @@ -1049,7 +1049,7 @@ def test_task_handler_repo_configured_from_config(): """config owner/repo overrides the default and enables self-heal (rant 18:14:46 P1).""" handler = make_handler( name="paper-x", - config={"project": "some-proj", "owner": "acme", "repo": "paper-x"}, + config={"agent": "some-proj", "owner": "acme", "repo": "paper-x"}, interval=1800, identity=InstanceIdentity(), ) @@ -1123,7 +1123,7 @@ def test_journal_template_renders_the_continuity_contract(): template = env.from_string(template_path.read_text(encoding="utf-8")) out = template.render( instance_id="test", host_name="host", source_dir="/tmp/journal", session_id="s1", timestamp="20260919-100129", - current_time_human="2026-09-19 10:01", task={"role": "editor", "project": "silicon-science-cs"}, + current_time_human="2026-09-19 10:01", task={"role": "editor", "agent": "silicon-science-cs"}, project={}, owner="argszero", repo="silicon-science-cs", ) # The state and reflection files were retired (rant 2026-09-14T14:35:47): the @@ -1152,7 +1152,7 @@ def test_open_source_template_renders_with_context(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260813", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) assert "The rant queue" in out, "rant-scan 0.5 节应渲染" @@ -1160,7 +1160,7 @@ def test_open_source_template_renders_with_context(): assert "config.project" in out, "project 匹配过滤应渲染" # Rant 2026-08-17T14:17:03: rant project matching is the SINGLE value # config.project — owner/repo dual-compat removed (host decision). - assert "aitokenpool" in out, "task.project 值应渲染进 0.5 节" + assert "aitokenpool" in out, "task.agent 值应渲染进 0.5 节" assert "does **NOT** match" in out, "owner/repo 形式明确不匹配" # the unmatched-rant hint may still mention the owner/repo form for # detecting rants the host should fix — that is a hint, not a match rule @@ -1200,13 +1200,13 @@ def test_open_source_template_allow_self_merge_conditional(): ) # default (allow_self_merge absent) → rule stands, no override - out_default = template.render(task={"role": "committer", "project": "aitokenpool"}, **base) + out_default = template.render(task={"role": "committer", "agent": "aitokenpool"}, **base) assert "Do not merge your own PRs (wait for other Committers to review)" in out_default assert "allow_self_merge: true" not in out_default # explicit true → override text appears + opt-in note in role section out_true = template.render( - task={"role": "committer", "project": "aitokenpool", "allow_self_merge": True}, **base + task={"role": "committer", "agent": "aitokenpool", "allow_self_merge": True}, **base ) assert "allow_self_merge" in out_true, "allow_self_merge 说明应渲染" assert "may review and merge their own PRs" in out_true, "self-merge 允许说明应渲染" @@ -1214,7 +1214,7 @@ def test_open_source_template_allow_self_merge_conditional(): # explicit false → same as default out_false = template.render( - task={"role": "committer", "project": "aitokenpool", "allow_self_merge": False}, **base + task={"role": "committer", "agent": "aitokenpool", "allow_self_merge": False}, **base ) assert "Do not merge your own PRs (wait for other Committers to review)" in out_false assert "**overridden**" not in out_false @@ -1269,7 +1269,7 @@ def test_open_source_template_never_stashes_host_work(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260820", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # the old data-losing instruction is gone @@ -1301,7 +1301,7 @@ def test_open_source_template_full_code_study_b2b(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260814", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the new section exists (positive discrimination: absent section → red) @@ -1335,7 +1335,7 @@ def test_open_source_template_measures_the_shipped_artifact(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260814", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) a defect is measured on the shipped artifact, not only on the checkout @@ -1379,7 +1379,7 @@ def test_open_source_template_distinguishes_an_unrunnable_toolchain(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260814", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the resolver runs before the suite and says what an absent tool is not @@ -1418,7 +1418,7 @@ def test_open_source_template_parallel_recon_c15(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260824", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the new section exists @@ -1472,7 +1472,7 @@ def test_open_source_entry_check_reads_the_claim_thread(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260824", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the entry check asks for the thread, which is where a claim is written @@ -1515,7 +1515,7 @@ def test_open_source_template_empty_recon_names_the_extension_surface(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260824", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) assert "no gap inside the project's extension surface" in out, "空 Recon 结论须含扩展面阅读" @@ -1546,7 +1546,7 @@ def test_open_source_template_reads_the_extension_surface(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260824", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the section exists and says what a contribution through that layer even is @@ -1579,7 +1579,7 @@ def test_promote_template_learn_latest_state_04(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/pm", source_dir="/tmp/pm", session_id="s1", timestamp="20260814", - task={"project": "aitokenpool"}, + task={"agent": "aitokenpool"}, project={"path": "/tmp/proj", "name": "aitokenpool", "description": "d"}, git_path="git", gh_path="gh", ) @@ -1615,7 +1615,7 @@ def test_promote_template_homework_first_dehardening(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/pm", source_dir="/tmp/pm", session_id="s1", timestamp="20260815", - task={"project": "aitokenpool"}, + task={"agent": "aitokenpool"}, project={"path": "/tmp/proj", "name": "aitokenpool", "description": "d"}, git_path="git", gh_path="gh", ) @@ -1655,7 +1655,7 @@ def test_promote_template_direct_cdp_rule(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/pm", source_dir="/tmp/pm", session_id="s1", timestamp="20260825", - task={"project": "aitokenpool"}, + task={"agent": "aitokenpool"}, project={"path": "/tmp/proj", "name": "aitokenpool", "description": "d"}, git_path="git", gh_path="gh", ) @@ -1683,7 +1683,7 @@ def test_promote_template_registration_blog_sections(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/pm", source_dir="/tmp/pm", session_id="s1", timestamp="20260815", - task={"project": "aitokenpool"}, + task={"agent": "aitokenpool"}, project={"path": "/tmp/proj", "name": "aitokenpool", "description": "d"}, git_path="git", gh_path="gh", ) @@ -1720,7 +1720,7 @@ def _make_handler(tmp_path, name="emrg-task", project="emrg", path=None): try: handler = make_handler( name=name, - config={"project": project} if project else {}, + config={"agent": project} if project else {}, interval=60, ) finally: @@ -1745,7 +1745,7 @@ def test_a_record_deleted_from_an_existing_file_stays_deleted(tmp_path): tasks_yml = tmp_path / "tasks.yml" tasks_yml.write_text(yaml.safe_dump([ - {"name": "other", "type": "evolution", "config": {"project": "other"}, "enabled": True}, + {"name": "other", "type": "evolution", "config": {"agent": "other"}, "enabled": True}, ])) sched = TaskScheduler(InstanceIdentity()) @@ -2703,7 +2703,7 @@ def test_connect_backoff_zero_failures_returns_interval(): from emrg.server.scheduler import TaskHandler handler = make_handler( - name="emrg-task", config={"project": "emrg"}, interval=60, + name="emrg-task", config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) handler._connect_failures = 0 @@ -2715,7 +2715,7 @@ def test_connect_backoff_grows_exponentially_capped(tmp_path): from emrg.server.scheduler import TaskHandler handler = make_handler( - name="emrg-task", config={"project": "emrg"}, interval=60, + name="emrg-task", config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) # interval=60s: 2^1=2 → 120s; 2^2=4 → 240s; 2^3=8 → 480s; 2^4=16 → 960s → capped 600s @@ -2732,7 +2732,7 @@ def test_connect_backoff_floor_30s_for_small_interval(tmp_path): from emrg.server.scheduler import TaskHandler handler = make_handler( - name="emrg-task", config={"project": "emrg"}, interval=10, + name="emrg-task", config={"agent": "emrg"}, interval=10, identity=InstanceIdentity(), ) handler._connect_failures = 2 @@ -2888,7 +2888,7 @@ def slow_status(): sched = mod.TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "slow", "type": "evolution", "config": {"project": "emrg"}, "enabled": True}, + {"name": "slow", "type": "evolution", "config": {"agent": "emrg"}, "enabled": True}, ])) sched._live = {"slow": handler} with caplog.at_level(logging.WARNING, logger="emrg.server.scheduler"): @@ -2915,7 +2915,7 @@ def test_list_tasks_reads_the_record_for_the_declared_fields(tmp_path): sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ {"name": "journal", "type": "journal", - "config": {"project": "sci", "repo": "argszero/sci"}, + "config": {"agent": "sci", "repo": "argszero/sci"}, "interval": 3600, "enabled": False, "sandbox": "read-only"}, ])) @@ -2926,7 +2926,7 @@ def test_list_tasks_reads_the_record_for_the_declared_fields(tmp_path): assert row["name"] == "journal" assert row["type"] == "journal" assert row["enabled"] is False - assert row["config"] == {"project": "sci", "repo": "argszero/sci"} + assert row["config"] == {"agent": "sci", "repo": "argszero/sci"} assert row["sandbox"] == "read-only" assert row["running"] is False, "no handler is live for it yet" assert row["interval"] == 3600 @@ -2943,8 +2943,8 @@ def test_list_tasks_shows_a_deleted_task_gone_at_once(tmp_path): sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "keep", "type": "evolution", "config": {"project": "emrg"}}, - {"name": "drop", "type": "evolution", "config": {"project": "emrg"}}, + {"name": "keep", "type": "evolution", "config": {"agent": "emrg"}}, + {"name": "drop", "type": "evolution", "config": {"agent": "emrg"}}, ])) assert [t["name"] for t in sched.list_tasks()] == ["keep", "drop"] @@ -3000,7 +3000,7 @@ def test_task_create_and_duplicate(tmp_path): sched._tasks_file = tasks_file ok, task = sched.task_create("daily", "evolution", "mem", 300, repo="acme/x") assert ok and task["name"] == "daily" and task["interval"] == 300 - assert task["config"] == {"project": "mem", "repo": "acme/x"} + assert task["config"] == {"agent": "mem", "repo": "acme/x"} saved = yaml.safe_load(tasks_file.read_text(encoding="utf-8")) assert any(t["name"] == "daily" for t in saved) ok, err = sched.task_create("daily", "evolution", "emrg", 60) @@ -3033,7 +3033,7 @@ def test_a_record_the_file_carries_runs_even_with_uppercase_in_its_name(tmp_path "name": "OfficeCLI-opensource-task", "type": "open-source", "sandbox": "workspace-write", - "config": {"project": "OfficeCLI", "role": "contributor"}, + "config": {"agent": "OfficeCLI", "role": "contributor"}, "interval": 3600, "enabled": True, } @@ -3143,8 +3143,8 @@ def test_reconcile_starts_what_is_missing_and_cancels_nothing(tmp_path): sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._write_table([ - {"name": "a", "type": "evolution", "config": {"project": "emrg"}, "interval": 300, "enabled": True}, - {"name": "b", "type": "evolution", "config": {"project": "mem"}, "interval": 300, "enabled": True}, + {"name": "a", "type": "evolution", "config": {"agent": "emrg"}, "interval": 300, "enabled": True}, + {"name": "b", "type": "evolution", "config": {"agent": "mem"}, "interval": 300, "enabled": True}, ]) async def _run(): @@ -3152,8 +3152,8 @@ async def _run(): running_a = sched._live["a"] # b is deleted and c added; a's interval changes. sched._write_table([ - {"name": "a", "type": "evolution", "config": {"project": "emrg"}, "interval": 900, "enabled": True}, - {"name": "c", "type": "evolution", "config": {"project": "mem"}, "interval": 900, "enabled": True}, + {"name": "a", "type": "evolution", "config": {"agent": "emrg"}, "interval": 900, "enabled": True}, + {"name": "c", "type": "evolution", "config": {"agent": "mem"}, "interval": 900, "enabled": True}, ]) second = sched.reconcile() # Nothing has yielded yet, so no handler has woken: this is the live set @@ -3190,7 +3190,7 @@ def test_reconcile_is_idempotent(tmp_path): sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._write_table([ - {"name": "a", "type": "evolution", "config": {"project": "emrg"}, "interval": 300, "enabled": True}, + {"name": "a", "type": "evolution", "config": {"agent": "emrg"}, "interval": 300, "enabled": True}, ]) async def _run(): @@ -3220,7 +3220,7 @@ def test_reconcile_changes_nothing_when_the_table_is_unreadable(tmp_path): sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._write_table([ - {"name": "a", "type": "evolution", "config": {"project": "emrg"}, "interval": 300, "enabled": True}, + {"name": "a", "type": "evolution", "config": {"agent": "emrg"}, "interval": 300, "enabled": True}, ]) async def _run(): @@ -3361,10 +3361,10 @@ def test_evolution_template_renders_dual_project_match(): instance_id="test", host_name="host", repo_url="https://github.com/argszero/emrg.git", owner="argszero", repo="emrg", local_source="/tmp/evo", source_dir="/tmp/evo", session_id="s1", timestamp="20260817", - task={"role": "committer", "project": "emrg"}, + task={"role": "committer", "agent": "emrg"}, project={}, git_path="git", gh_path="gh", ) - assert "emrg" in out, "task.project 值应渲染" + assert "emrg" in out, "task.agent 值应渲染" assert "argszero/emrg" in out, "owner/repo 形式应渲染" assert "ignore rants without a `project` field entirely" in out @@ -3388,7 +3388,7 @@ def test_sandbox_resolution_unified_default_rule(): # no name-based defaults: emrg-task no longer implies workspace-write # via the old special case — it is the unified default now handler = make_handler( - name="emrg-task", config={"project": "emrg"}, interval=60, + name="emrg-task", config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) assert handler._sandbox == "workspace-write" diff --git a/tests/test_scheduler_and_llm_logs_apply_the_credential_rule.py b/tests/test_scheduler_and_llm_logs_apply_the_credential_rule.py index 4883d1bad..6d65449a7 100644 --- a/tests/test_scheduler_and_llm_logs_apply_the_credential_rule.py +++ b/tests/test_scheduler_and_llm_logs_apply_the_credential_rule.py @@ -207,7 +207,7 @@ def _handler(tmp_path, monkeypatch, name="emrg-task"): monkeypatch.setattr(sched_mod, "config_dir", lambda: tmp_path) return make_handler( name=name, - config={"project": "emrg"}, + config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) diff --git a/tests/test_task_config_key_migration.py b/tests/test_task_config_key_migration.py new file mode 100644 index 000000000..870e74c6c --- /dev/null +++ b/tests/test_task_config_key_migration.py @@ -0,0 +1,153 @@ +"""A task record's agent key: ``config.project`` → ``config.agent`` (issue #2531). + +The migration moves a key in a file a host's daemon reads, so these tests hold two +separate things: that the move is faithful (nothing else travels, order included), +and that **only the daemon's process entry** performs it. The second matters more +than the first — a migration reachable from any path the suite drives would rewrite a +host's live ``tasks.yml`` under a daemon that is still reading the old key. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from emrg.server import scheduler as mod +from emrg.server.scheduler import migrate_task_config_keys, read_table + +#: One table in the pre-rename shape, as a host's file carries it: the agent name +#: sits under `config.project` and nowhere else, and the surrounding fields are the +#: ones a real record has. +PRE_RENAME_SHAPE = [ + {"name": "emrg-task", "type": "evolution", "config": {"project": "emrg"}, + "interval": 600, "enabled": True}, + {"name": "journal-editor", "type": "journal", "config": {"project": "silicon-science-cs"}, + "interval": 1800, "enabled": True}, + {"name": "feishu-inbox", "type": "feishu", "config": {"project": "mem"}, + "interval": 300, "enabled": True}, +] + + +def _write(path: Path, records: list[dict]) -> None: + path.write_text( + yaml.safe_dump(records, sort_keys=False, allow_unicode=True), encoding="utf-8" + ) + + +def test_the_key_moves_and_nothing_else_travels(tmp_path): + """The value arrives unchanged, at the key's own position, with the record intact.""" + path = tmp_path / "tasks.yml" + _write(path, [{ + "name": "a", "type": "evolution", + "config": {"project": "emrg", "repo": "acme/x", "role": "author"}, + "interval": 600, "enabled": True, "schedule": None, + }]) + + assert migrate_task_config_keys(path) == 1 + + record = yaml.safe_load(path.read_text(encoding="utf-8"))[0] + assert record["config"] == {"agent": "emrg", "repo": "acme/x", "role": "author"} + assert list(record["config"]) == ["agent", "repo", "role"], "the key keeps its place" + assert record["interval"] == 600 + assert record["enabled"] is True + assert "project" not in record["config"] + + +def test_every_record_keeps_its_own_agent(tmp_path): + """One record per task: no record may end up wearing another's name.""" + path = tmp_path / "tasks.yml" + _write(path, PRE_RENAME_SHAPE) + expected = [r["config"]["project"] for r in PRE_RENAME_SHAPE] + + assert migrate_task_config_keys(path) == len(PRE_RENAME_SHAPE) + + migrated = read_table(path) + assert [r["config"]["agent"] for r in migrated] == expected + assert [r["name"] for r in migrated] == [r["name"] for r in PRE_RENAME_SHAPE] + assert [r["interval"] for r in migrated] == [r["interval"] for r in PRE_RENAME_SHAPE] + assert all("project" not in r["config"] for r in migrated) + + +def test_a_second_call_is_a_no_op(tmp_path): + """The migration is idempotent, so a restart does not rewrite the file again.""" + path = tmp_path / "tasks.yml" + _write(path, PRE_RENAME_SHAPE) + assert migrate_task_config_keys(path) == len(PRE_RENAME_SHAPE) + before = path.read_bytes() + + assert migrate_task_config_keys(path) == 0 + assert path.read_bytes() == before + + +def test_a_record_already_carrying_agent_is_left_alone(tmp_path): + path = tmp_path / "tasks.yml" + _write(path, [{"name": "a", "type": "evolution", "config": {"agent": "emrg"}, + "interval": 600, "enabled": True}]) + before = path.read_bytes() + + assert migrate_task_config_keys(path) == 0 + assert path.read_bytes() == before + + +def test_a_stale_project_key_is_dropped_rather_than_duplicated(tmp_path): + """Both keys present: `agent` governs, and the old word does not survive as residue.""" + path = tmp_path / "tasks.yml" + _write(path, [{"name": "a", "type": "evolution", + "config": {"agent": "correct", "project": "stale"}, + "interval": 600, "enabled": True}]) + + assert migrate_task_config_keys(path) == 1 + record = yaml.safe_load(path.read_text(encoding="utf-8"))[0] + assert record["config"] == {"agent": "correct"} + + +def test_a_missing_file_is_not_created(tmp_path): + """This moves an existing table; it never seeds one.""" + path = tmp_path / "tasks.yml" + assert migrate_task_config_keys(path) == 0 + assert not path.exists() + + +def test_an_unreadable_table_is_left_exactly_where_it_lies(tmp_path): + """A file this cannot parse is not a file to rewrite from a half-understood read.""" + path = tmp_path / "tasks.yml" + path.write_text("- name: a\n config: [unbalanced\n", encoding="utf-8") + before = path.read_bytes() + + assert migrate_task_config_keys(path) == 0 + assert path.read_bytes() == before + + +def test_the_reader_never_writes(tmp_path): + """`read_table` answers the record as written — old key and all — and touches nothing. + + This is the property that keeps a suite run off a host's live table: reading a + record must never be the act that renames it. + """ + path = tmp_path / "tasks.yml" + _write(path, PRE_RENAME_SHAPE) + before = path.read_bytes() + before_mtime = path.stat().st_mtime_ns + + records = read_table(path) + + assert records[0]["config"] == {"project": "emrg"}, "the reader reports what is written" + assert path.read_bytes() == before + assert path.stat().st_mtime_ns == before_mtime + + +def test_only_the_daemon_process_entry_calls_the_migration(): + """The migration has one reachable call site, and it is the one no test drives. + + A second call site — a scheduler method, a loader, a `read_table` — would make the + rename happen wherever that path ran, which is the shape that rewrites a host's + live file from inside a test run. + """ + root = Path(mod.__file__).resolve().parents[2] + hits = [] + for path in sorted((root / "emrg").rglob("*.py")): + if "migrate_task_config_keys()" in path.read_text(encoding="utf-8"): + hits.append(str(path.relative_to(root))) + + assert hits == ["emrg/server/__main__.py"] diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index ba6d16a55..3e2eb58b0 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -3170,8 +3170,8 @@ async def _test(): try: calls = {} - def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): - calls.update(name=name, task_type=task_type, project=project) + def fake_create(name, task_type, agent, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): + calls.update(name=name, task_type=task_type, agent=agent) return True, {"name": name, "type": task_type} server._scheduler = self._mock_scheduler(create_side_effect=fake_create) @@ -3179,12 +3179,12 @@ def fake_create(name, task_type, project, interval=None, enabled=True, repo=None try: resp = await self._cmd(ws, { "type": "task_create", "name": "daily", - "task_type": "evolution", "project": "emrg", + "task_type": "evolution", "agent": "emrg", }) assert resp["type"] == "task_result" assert resp.get("ok") is True assert calls["task_type"] == "evolution", "daemon must read task_type" - assert calls["project"] == "emrg" + assert calls["agent"] == "emrg" finally: await ws.close() finally: @@ -3200,15 +3200,15 @@ async def _test(): try: calls = {} - def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): - calls.update(name=name, task_type=task_type, project=project) + def fake_create(name, task_type, agent, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): + calls.update(name=name, task_type=task_type, agent=agent) return True, {"name": name, "type": task_type} server._scheduler = self._mock_scheduler(create_side_effect=fake_create) ws = await connect_to_server() try: resp = await self._cmd(ws, { - "type": "task_create", "name": "daily", "project": "emrg", + "type": "task_create", "name": "daily", "agent": "emrg", }) assert resp["type"] == "task_result" assert calls["task_type"] == "", "must not fall back to wire type 'task_create'" @@ -3231,7 +3231,7 @@ async def _test(): try: calls = {} - def fake_create(name, task_type, project, interval=None, enabled=True, + def fake_create(name, task_type, agent, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): calls.update(interval=interval, schedule=schedule) return True, {"name": name, "type": task_type} @@ -3244,7 +3244,7 @@ def fake_create(name, task_type, project, interval=None, enabled=True, try: resp = await self._cmd(ws, { "type": "task_create", "name": "daily", - "task_type": "evolution", "project": "emrg", + "task_type": "evolution", "agent": "emrg", "interval": 10800, "schedule": window, }) assert resp["type"] == "task_result" From eb0ff6cb79952bbe9b01636b4b6ae2babf0f5d9e Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 17:01:54 +0800 Subject: [PATCH 2/2] emrg: the call-site guard compares posix paths The scan walks a real tree, so on Windows its hits carry backslashes and the assertion named a separator rather than the property. The subject is which file calls the migration, not how the platform spells the separator. --- tests/test_task_config_key_migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_task_config_key_migration.py b/tests/test_task_config_key_migration.py index 870e74c6c..562165a11 100644 --- a/tests/test_task_config_key_migration.py +++ b/tests/test_task_config_key_migration.py @@ -148,6 +148,6 @@ def test_only_the_daemon_process_entry_calls_the_migration(): hits = [] for path in sorted((root / "emrg").rglob("*.py")): if "migrate_task_config_keys()" in path.read_text(encoding="utf-8"): - hits.append(str(path.relative_to(root))) + hits.append(path.relative_to(root).as_posix()) assert hits == ["emrg/server/__main__.py"]