agent-lyceum 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/README.zh-TW.md +4 -4
- package/dist/cli.js +7 -4
- package/dist/mailbox.js +60 -0
- package/dist/prompt.js +1 -1
- package/dist/run-outcome.d.ts +10 -0
- package/dist/run-outcome.js +22 -2
- package/dist/run-session.d.ts +9 -0
- package/dist/run-session.js +22 -23
- package/dist/run-store.d.ts +10 -1
- package/dist/run-store.js +7 -3
- package/dist/scaffold.js +2 -2
- package/dist/schema.js +3 -3
- package/dist/status.d.ts +7 -0
- package/dist/status.js +9 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
|
65
65
|
|
|
66
66
|
**Use it.**
|
|
67
67
|
- **Toasts:** a message pops up when a run finishes, is interrupted, or an agent's wake fails (prefixed `[project]` when several are watched). Nothing to do.
|
|
68
|
-
- **`/status-monitor`:** type it to open (or, if already open, close) the pane. It is off at session start unless `autoOpen` is set. In the **fullscreen layout** (`"tui": "fullscreen"` in `~/.claude/settings.json`, restart the session to apply; `/tui fullscreen` does the same) from 110 terminal columns it docks on the right at half the terminal width, and tabs and **關閉** (Close) can be clicked. Otherwise it shows above the prompt; the layout is fixed when the session starts, so a session started on the main screen stays that way. Keyboard (works in both): `x` closes, `v` switches graph/text, `1`-`9` or Tab/Enter switch task, Esc returns to the prompt, running the command again also closes. It draws one block per agent, with the links between the lead and each member taken from `can_message` (`▼` the lead may write to the member, `▲` the member to the lead, `↕` both; yellow marks the latest hand-off). Each block shows what that agent is doing now, the mail it is handling and the mail queued for it as
|
|
68
|
+
- **`/status-monitor`:** type it to open (or, if already open, close) the pane. It is off at session start unless `autoOpen` is set. In the **fullscreen layout** (`"tui": "fullscreen"` in `~/.claude/settings.json`, restart the session to apply; `/tui fullscreen` does the same) from 110 terminal columns it docks on the right at half the terminal width, and tabs and **關閉** (Close) can be clicked. Otherwise it shows above the prompt; the layout is fixed when the session starts, so a session started on the main screen stays that way. Keyboard (works in both): `x` closes, `v` switches graph/text, `e` expands or collapses the details, `1`-`9` or Tab/Enter switch task, Esc returns to the prompt, running the command again also closes. It draws one block per agent, with the links between the lead and each member taken from `can_message` (`▼` the lead may write to the member, `▲` the member to the lead, `↕` both; yellow marks the latest hand-off). Each block shows what that agent is doing now, the mail it is handling and the mail queued for it as one key line each: the point of the mail by type (task: the goal; reply: the changes; done: the result), plus any open item, risk or unfinished part it reports (none when it says None) — not the start of the text cut short. Press `e` to expand: every section of every mail (task: goal and acceptance criteria; reply: changes, verification, open items, risks; done: result and what is not done) and the full checklist; collapsed, the checklist is only the progress bar and the current step. Each block also shows its wake count and output tokens. An agent that is working right now is drawn larger: its block gets a row of its own at the full width, and its text wraps instead of being cut; idle and queued blocks stay compact. Above the graph: the run id and state, task, progress bar and checklist; below it: blocked integrations, notes and the result summary. `v` switches to the plain text list (per-runtime tokens, per-agent statistics, the last 8 wakes). Every block lists the agents it may message in a "→" line, with the latest hand-off marked "●" (this also covers member-to-member targets, which get no glyph). When the graph area is narrower than 70 columns (compared with the pane's own width, as the host reports it), the blocks stack in one column and the link glyphs are left out. Each running task is a tab, newest first; finished and interrupted runs are not listed. It refreshes with the polling interval while open.
|
|
69
69
|
|
|
70
70
|
**Configure it.** In `~/.claude/settings.json` under `pluginConfigs."status-monitor@inline"` (`options`):
|
|
71
71
|
|
|
@@ -100,7 +100,7 @@ Changes take effect on the next session start. If the pane shows `無法取得
|
|
|
100
100
|
# project.yaml
|
|
101
101
|
dir: ~/code/web-app
|
|
102
102
|
team: { lead: lead }
|
|
103
|
-
dispatcher: { max_rounds:
|
|
103
|
+
dispatcher: { max_rounds: 0, max_parallel: 1, max_concurrent_runs: 1, wake_timeout_sec: 600, retry: 2, strict: false, workspace_mode: auto, log_max_bytes: 8388608 }
|
|
104
104
|
agents:
|
|
105
105
|
lead: { resume: true, can_message: all, can_edit_agent_md: true }
|
|
106
106
|
fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
|
|
@@ -119,7 +119,7 @@ Rules checked by `doctor`: ≥ 2 agents (the lead and one member; the template h
|
|
|
119
119
|
3. Every wake-up prompt contains the agent's `AGENT.md`, the team protocol, `COMMON.md` (read-only, ≤ 8 KB), the `MEMORY.md` index of each memory dir, the **oldest** unread message in full (one message per wake-up; the lead instead takes a run of up to 5 `reply`/`failure` messages at once and decides on them together), and titles of the queued ones, which stay unread until their own wake-up.
|
|
120
120
|
4. Agents send mail by writing a Markdown file with frontmatter (`to`, `type`, `subject`) into **their own** `outbox/`. The dispatcher checks `can_message`, stamps the real sender, and moves it to the recipient's `inbox/`. Handled mail goes to `inbox/<agent>/read/`. Mailboxes belong to one run (`runs/<run-id>/mail/`), so mail never crosses runs. Runs started by older versions keep using the shared `shared/{inbox,outbox}/` mailboxes (their mail is never moved); resuming such a run shows that legacy layout.
|
|
121
121
|
The body of every `task` and `reply` should carry fixed `##` headings (injected into each agent's prompt): `task` → `Goal`, `Acceptance criteria`, `Scope`, `Upstream`; `reply` → `Changes`, `Verification`, `Open items`, `Risks` (write `None` when empty; `done` is exempt). Mail missing a heading is still delivered, with a warning note prepended and a `format-warning` entry in the run log.
|
|
122
|
-
5. The run ends when the lead sends `type: done`, when all mailboxes are empty, or after `max_rounds` wake-ups. A `done` must declare `outcome: completed|partial|blocked|failed` in its frontmatter and carry `## Result`, `## Files`, `## Verification`, `## Not done`; `completed` also needs every `## Steps` item ticked. A `done` that breaks this is sent back to the lead (twice at most, then it is kept as `partial`). Agent-lyceum does not verify what the lead reports. A failed wake-up is retried
|
|
122
|
+
5. The run ends when the lead sends `type: done`, when all mailboxes are empty, or after `max_rounds` wake-ups when you set a limit (the default `0` means no limit). A `done` must declare `outcome: completed|partial|blocked|failed` in its frontmatter and carry `## Result`, `## Files`, `## Verification`, `## Not done`; `completed` also needs every `## Steps` item ticked. A `done` that breaks this is sent back to the lead (twice at most, then it is kept as `partial`). Agent-lyceum does not verify what the lead reports. A failed wake-up is retried up to `retry` times (default 2, so 3 calls in all); if the agent still fails, the run stops (`agent_failed`, or `lead_failed` for the lead) instead of carrying on without it: the message it was handling stays unread, and the end message says how to look into the failure and how to continue (`agent-lyceum resume <run>` after fixing the cause, or `clear` to give up). Mail that repeats an earlier mail from the same sender to the same recipient (same type, reply target and text, whitespace aside) is not delivered again: the sender is told, which keeps two agents from looping on the same message.
|
|
123
123
|
|
|
124
124
|
Parallelism (`max_parallel > 1`) only runs agents with disjoint `owns`, and never alongside the lead. Each non-lead agent then works in its **own git worktree** (`projects/<name>/worktrees/<run-id>/<agent>`, branch `agent-lyceum/<run-id>/<agent>`) cut from a snapshot of the repo as it is when the agent is woken (`refs/agent-lyceum/<run-id>/base-<n>`; it includes the lead's uncommitted and new, non-ignored files; your HEAD, branches and working tree are not touched). `dispatcher.workspace_mode` is `auto` (worktrees when `max_parallel > 1`), `worktree` or `shared`; `shared` cannot be combined with parallel agents. A parallel run refuses to start while the repo has uncommitted changes of yours, and when the repo is not a git repository (run sequentially instead). When a member finishes, the dispatcher commits its worktree and brings the changes into your working tree (not your index or HEAD) before the lead reads the member's mail. Every changed path is checked first: it must be inside the member's `owns` (renames and deletions included), must not be `CLAUDE.md`/`AGENTS.md`, and symlinks may not point out of the project or the member's `owns`. If a file also changed in your repo since the member's snapshot, if a check fails, or if `git apply` fails, nothing is applied: the work stays on its branch and worktree, a report and patch are written to `runs/<run-id>/integration/`, the lead is told, and the run cannot end as `completed` (it ends `blocked`). A worktree isolates files only: it cannot isolate side effects on external services, databases or the network.
|
|
125
125
|
|
|
@@ -147,7 +147,7 @@ A finished (`done`) run can be reopened with a follow-up request: `agent-lyceum
|
|
|
147
147
|
|
|
148
148
|
- **Which runs:** only a finished run can be reopened. A run that is still running is refused; a run that is not finished is refused when given text (resume it without text first). Without text nothing changes: a finished run just prints its result. Without a run, the candidates are the finished runs, newest first: exactly one is used, none is an error, and several are listed (in a terminal you pick one by number or name; without a terminal the command fails and lists them). A lone argument counts as follow-up text only when it cannot be a run reference (it contains whitespace or a character a run name cannot contain); a one-word follow-up needs `resume <run> "<text>"` or `--task-file`.
|
|
149
149
|
- **What is kept:** the follow-up is saved as `runs/<run-id>/followups/<n>.md`. Each `done` report is kept as `done-<n>.md`, and `result.md` is always the latest. `state.json` gains `name`, `reopened`, `round_floor` and `step_base` (all optional, so older runs still load). The progress (`## Steps`) carries on: the checklist at the reopen stays, and the steps the lead lists for the follow-up are added after it (a step it lists again only takes the new tick); for the follow-up's `completed` only its own steps must be ticked. Unresolved blocked integrations stay; the done-contract reminders start afresh for each reopened segment.
|
|
150
|
-
- **Round limit:** `max_rounds` counts from the reopen: `rounds` keeps accumulating, and the limit is the rounds at reopen plus `max_rounds`.
|
|
150
|
+
- **Round limit:** with a `max_rounds` set, it counts from the reopen: `rounds` keeps accumulating, and the limit is the rounds at reopen plus `max_rounds`.
|
|
151
151
|
- **The lead's branch** (runs with a lead worktree): if the branch was already merged into your `HEAD` and the lead worktree is clean and untouched since the run ended, the worktree and the branch (same name, `agent-lyceum/<run-id>/<lead>`) are re-cut from your current `HEAD`, and a line says so. Otherwise the same worktree and branch continue. If a pull request was already recorded (`pr_url`) and the branch was not re-cut, a new `open_pr: true` only pushes ("pull request updated") while that pull request is still open; if it was merged (a squash merge included) or closed, a new pull request is opened instead. If the branch was re-cut, the recorded `pr_url` is cleared (the old URL stays in the run's notes) and a new pull request is opened when asked. agent-lyceum still never merges.
|
|
152
152
|
- **Handoff notes:** before it sends `done`, the lead writes what a follow-up will need into `task-memory/<run-id>/_shared/` in the agent-lyceum home (`MEMORY.md` is the index): decisions and why, interfaces agreed, current state, what is left and the next steps, pitfalls met. Members can read this folder but not write it (a run started with `run --agent` has one agent, which is its own lead and writes it; `call` has none). Once a run is reopened, every wake-up of every agent gets `MEMORY.md` in its prompt (the first 8 KB; a longer index is cut and flagged), and the reopened `task` mail's `Upstream` names it; the first segment's prompts do not contain it. If the lead sends `done` without having changed `MEMORY.md` in that segment, the run is not blocked: a note `shared-not-updated` is added to the run's notes. `clear` deletes the folder with the task memory; `tidy` does not touch it. It holds notes about this task only; lessons that outlive it belong in project or global memory.
|
|
153
153
|
|
package/README.zh-TW.md
CHANGED
|
@@ -65,7 +65,7 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
|
65
65
|
|
|
66
66
|
**操作。**
|
|
67
67
|
- **通知:** 執行完成、被中斷或某個 agent 喚醒失敗時會跳出訊息(監看多個專案時前面加 `[專案名]`),不需要任何操作。
|
|
68
|
-
- **`/status-monitor`:** 輸入即開啟面板(已開著則關閉)。session 啟動時預設不開,除非設定 `autoOpen`。在 **fullscreen 版面**(`~/.claude/settings.json` 設 `"tui": "fullscreen"` 後重開 session;`/tui fullscreen` 效果相同)且終端寬度 110 欄以上時,面板會靠右 dock 在終端寬度的一半,分頁與 **關閉** 都可以用滑鼠點。否則顯示在輸入框上方;版面在 session 啟動時就固定,從主畫面啟動的 session 不會變。鍵盤操作兩種版面都能用:`x` 關閉,`v` 切換圖形/文字,`1`-`9` 或 Tab/Enter 切換任務,Esc 回到輸入框,再執行一次指令也會關閉。面板為每個 agent 畫一個區塊,並依 `can_message` 在 lead 與各成員之間畫出連結(`▼` lead 可寄給成員、`▲` 成員可寄給 lead、`↕` 雙向;黃色表示最近一次寄信的那條邊)。每個區塊顯示該 agent
|
|
68
|
+
- **`/status-monitor`:** 輸入即開啟面板(已開著則關閉)。session 啟動時預設不開,除非設定 `autoOpen`。在 **fullscreen 版面**(`~/.claude/settings.json` 設 `"tui": "fullscreen"` 後重開 session;`/tui fullscreen` 效果相同)且終端寬度 110 欄以上時,面板會靠右 dock 在終端寬度的一半,分頁與 **關閉** 都可以用滑鼠點。否則顯示在輸入框上方;版面在 session 啟動時就固定,從主畫面啟動的 session 不會變。鍵盤操作兩種版面都能用:`x` 關閉,`v` 切換圖形/文字,`e` 展開/收合細節,`1`-`9` 或 Tab/Enter 切換任務,Esc 回到輸入框,再執行一次指令也會關閉。面板為每個 agent 畫一個區塊,並依 `can_message` 在 lead 與各成員之間畫出連結(`▼` lead 可寄給成員、`▲` 成員可寄給 lead、`↕` 雙向;黃色表示最近一次寄信的那條邊)。每個區塊顯示該 agent 現在在做什麼、正在處理與排隊中的信,每封信預設只顯示一行重點:依信種挑出它在講什麼(task 是目標,reply 是變更,done 是結果),再加上信裡回報的待辦、風險或未完成(寫 None 就不顯示)——不是把開頭截短。按 `e` 展開:每封信的每個段落(task 看目標與驗收,reply 看變更、驗證、待辦與風險,done 看結果與未完成)和完整的步驟清單;收合時清單只有進度條與目前步驟。每個區塊也顯示喚醒次數與輸出 token。正在運作的 agent 會被放大:它的區塊獨佔一整列、使用全寬,文字自動換行而不截斷;閒置與排隊中的區塊維持精簡。圖形上方是執行 id 與狀態、任務、進度條與清單;下方是受阻的整合、備註與結果摘要。按 `v` 切換成純文字清單(各 runtime 的 token、各 agent 統計、最近 8 次喚醒)。每個區塊以「→」一行列出它可以寄信的對象,最近一次寄信的對象標 「●」(成員之間的對象也在此,不畫符號)。當圖形區域寬度少於 70 欄(以面板本身的寬度比較,由 host 回報),區塊改成單欄堆疊,不畫連結符號。每個執行中的任務是一個分頁,最新的排最前面;已結束或已中斷的不列出。面板開著時會依輪詢間隔自動更新。
|
|
69
69
|
|
|
70
70
|
**設定。** 在 `~/.claude/settings.json` 的 `pluginConfigs."status-monitor@inline"`(`options`):
|
|
71
71
|
|
|
@@ -100,7 +100,7 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
|
100
100
|
# project.yaml
|
|
101
101
|
dir: ~/code/web-app
|
|
102
102
|
team: { lead: lead }
|
|
103
|
-
dispatcher: { max_rounds:
|
|
103
|
+
dispatcher: { max_rounds: 0, max_parallel: 1, max_concurrent_runs: 1, wake_timeout_sec: 600, retry: 2, strict: false, workspace_mode: auto, log_max_bytes: 8388608 }
|
|
104
104
|
agents:
|
|
105
105
|
lead: { resume: true, can_message: all, can_edit_agent_md: true }
|
|
106
106
|
fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
|
|
@@ -119,7 +119,7 @@ Agent 欄位:`runtime`(`claude-code`|`codex`;若 `model` 可辨識則可
|
|
|
119
119
|
3. 每次喚醒的 prompt 都包含:該 agent 的 `AGENT.md`、團隊協定、`COMMON.md`(唯讀,≤ 8 KB)、每個記憶資料夾的 `MEMORY.md` 索引、**最舊**一封未讀信件全文(每次喚醒只處理一封;lead 例外,會一次拿到連續最多 5 封 `reply`/`failure` 並一併決策),以及排隊中信件的標題;排隊的信維持未讀,等各自的喚醒再處理。
|
|
120
120
|
4. agent 寄信的方式,是在**自己的** `outbox/` 寫一個含 frontmatter(`to`、`type`、`subject`)的 Markdown 檔。dispatcher 會檢查 `can_message`、蓋上真正的寄件者,並移到收件者的 `inbox/`。處理完的信件移至 `inbox/<agent>/read/`。信箱屬於單一 run(`runs/<run-id>/mail/`),信件不會跨 run。舊版本啟動的 run 繼續使用共用的 `shared/{inbox,outbox}/` 信箱(信件不會被搬移),接續這類 run 時會沿用舊版配置。
|
|
121
121
|
每封 `task` 和 `reply` 的內文應包含固定的 `##` 標題(會注入每個 agent 的 prompt):`task` → `Goal`、`Acceptance criteria`、`Scope`、`Upstream`;`reply` → `Changes`、`Verification`、`Open items`、`Risks`(沒內容就寫 `None`;`done` 免檢)。缺標題的信仍會送達,但開頭會加上警告說明,run log 也會記一筆 `format-warning`。
|
|
122
|
-
5. 結束條件:lead 寄出 `type: done
|
|
122
|
+
5. 結束條件:lead 寄出 `type: done`、所有信箱都空了,或(有設上限時)喚醒次數達到 `max_rounds`;預設 `0` 代表不限輪數。`done` 的 frontmatter 必須有 `outcome: completed|partial|blocked|failed`,內文要有 `## Result`、`## Files`、`## Verification`、`## Not done`;`completed` 還要求 `## Steps` 全部勾選。不符合的 `done` 會退回給 lead(最多兩次,之後以 `partial` 收下)。agent-lyceum 不會驗證 lead 回報的內容是否屬實。喚醒失敗最多重試 `retry` 次(預設 2,即最多呼叫 3 次);仍然失敗就停止整個 run(成員失敗為 `agent_failed`,lead 失敗為 `lead_failed`),不會少了它繼續跑:它正在處理的那封信維持未讀,結束訊息會告訴你怎麼查原因與怎麼繼續(修正後 `agent-lyceum resume <run>`,或用 `clear` 放棄)。同一個寄件人寄給同一個收件人、type、reply_to 與內文(忽略空白差異)都相同的信不會再被投遞,並通知寄件人,避免兩個 agent 對著同一封信繞圈。
|
|
123
123
|
|
|
124
124
|
平行執行(`max_parallel > 1`)只會同時跑 `owns` 互不相交的 agent,且絕不與 lead 同時執行。此時每個非 lead 的 agent 都在**自己的 git worktree**(`projects/<name>/worktrees/<run-id>/<agent>`,branch `agent-lyceum/<run-id>/<agent>`)工作;worktree 是從喚醒當下 repo 的快照(`refs/agent-lyceum/<run-id>/base-<n>`,包含 lead 尚未提交的修改與新增、未被 ignore 的檔案)切出來的,你的 HEAD、branch 與工作目錄都不會被動到。`dispatcher.workspace_mode` 可設為 `auto`(`max_parallel > 1` 時用 worktree)、`worktree` 或 `shared`;`shared` 不能與平行 agent 並用。平行 run 在 repo 有你自己未提交的修改時,或 repo 不是 git repository 時,會拒絕啟動(請改為序列執行)。成員完成後,dispatcher 會提交它 worktree 中的修改,並在 lead 讀到該成員的信之前,把變更套用到你的工作目錄(不動你的 index 與 HEAD)。每個被改動的路徑都會先檢查:必須在該成員的 `owns` 內(rename 與刪除也算)、不得是 `CLAUDE.md`/`AGENTS.md`,symlink 不得指向專案或該成員 `owns` 之外。若同一檔案在成員的快照之後又在你的 repo 被改過、檢查未通過,或 `git apply` 失敗,就一個檔案都不套用:成果保留在它的 branch 與 worktree,報告與 patch 寫到 `runs/<run-id>/integration/`,並通知 lead,且這個 run 不能以 `completed` 結束(會以 `blocked` 結束)。worktree 只隔離檔案,無法隔離對外部服務、資料庫或網路的副作用。
|
|
125
125
|
|
|
@@ -147,7 +147,7 @@ Agent 欄位:`runtime`(`claude-code`|`codex`;若 `model` 可辨識則可
|
|
|
147
147
|
|
|
148
148
|
- **哪些 run:** 只有已完成的 run 能重開。仍在執行的 run 會拒絕;尚未完成的 run 若帶文字也會拒絕(先不帶文字 resume 它)。不帶文字時行為不變:已完成的 run 只印出結果。沒指定 run 時,候選是已完成的 run,由新到舊:剛好一個就用它,沒有就報錯,有多個就列出(在終端機可用編號或名稱選;非互動環境則報錯並列出候選)。只有一個參數時,只有在它不可能是 run 參照(含空白,或含 run 名稱不能用的字元)才會被當成追加要求;單一個詞的追加要求要寫成 `resume <run> "<text>"` 或用 `--task-file`。
|
|
149
149
|
- **保留什麼:** 追加要求存成 `runs/<run-id>/followups/<n>.md`。每份 `done` 報告保留為 `done-<n>.md`,`result.md` 永遠是最新一份。`state.json` 新增 `name`、`reopened`、`round_floor`、`step_base`(都是選填,舊的 run 照樣能讀)。進度(`## Steps`)會接續:重開時的清單保留,lead 為追加要求列的步驟接在後面(再次列出的舊步驟只更新勾選狀態);追加要求的 `completed` 只要求它自己的步驟全部勾選。未解決的 blocked integration 會保留;每段重開的 done 規範提醒重新計算。
|
|
150
|
-
- **輪數上限:** `max_rounds`
|
|
150
|
+
- **輪數上限:** 有設 `max_rounds` 時,它從重開時起算:`rounds` 持續累加,上限是重開當下的輪數加 `max_rounds`。
|
|
151
151
|
- **lead 的 branch**(lead 有 worktree 的 run):若該 branch 已合併進你的 `HEAD`,且 lead 的 worktree 乾淨、自 run 結束後沒被動過,worktree 與 branch(同名,`agent-lyceum/<run-id>/<lead>`)會從你目前的 `HEAD` 重新切出,並印一行說明。否則沿用原本的 worktree 與 branch。若已記錄 PR(`pr_url`)且 branch 沒有重切,新的 `open_pr: true` 只會 push(「pull request updated」),而且只在該 PR 仍開啟時;若它已被合併(含 squash merge)或關閉,就改開一個新的 PR;若 branch 重切了,已記錄的 `pr_url` 會被清掉(舊網址留在 run 的 notes),要求時再開一個新的 PR。agent-lyceum 仍然從不合併。
|
|
152
152
|
- **交接筆記:** lead 在送 `done` 之前,把追加要求會用到的內容寫進 agent-lyceum home 裡的 `task-memory/<run-id>/_shared/`(`MEMORY.md` 為索引):做過的決定與理由、介面約定、目前狀態、剩下的事與下一步、踩過的坑。成員可讀不可寫(用 `run --agent` 啟動的 run 只有一個 agent,它就是自己的 lead,所以可寫;`call` 沒有這個資料夾)。run 重開後,每個 agent 的每次喚醒都在 prompt 裡拿到 `MEMORY.md`(前 8 KB,較長的索引會被截斷並標示),重開的 `task` 信的 `Upstream` 也會指向它;第一段的 prompt 不含它。若 lead 在該段沒有更動 `MEMORY.md` 就送出 `done`,run 不會被擋,只會在 run 的 notes 加一筆 `shared-not-updated`。`clear` 會連同任務記憶一併刪除;`tidy` 不會動它。只放這個 task 的筆記,跨 task 的教訓仍放 project 或 global memory。
|
|
153
153
|
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,8 @@ import { RESULT_FILE, runTeam } from "./dispatcher.js";
|
|
|
10
10
|
import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
|
|
11
11
|
import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
|
|
12
12
|
import { acquireProjectLock, forceUnlock, inspectRunLock, listRunLocks, listUnreadableRunLocks, LOCK_FILE, lockHolderAlive } from "./project-lock.js";
|
|
13
|
-
import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, runLabel, slugify, uniqueName } from "./run-store.js";
|
|
13
|
+
import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, roundsLabel, runLabel, slugify, uniqueName } from "./run-store.js";
|
|
14
|
+
import { failureGuide } from "./run-outcome.js";
|
|
14
15
|
import { exitCodeForOutcome } from "./schema.js";
|
|
15
16
|
import { absPath, assertName, NAME_RE, projectPaths, resolveHome } from "./paths.js";
|
|
16
17
|
import { addProject, initHome, removeProject } from "./scaffold.js";
|
|
@@ -167,6 +168,8 @@ function printRun(summary, runDir) {
|
|
|
167
168
|
console.log(`\nRun ${runLabel({ name: summary.name, run_id: summary.runId })} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
|
|
168
169
|
if (summary.outcomeNote)
|
|
169
170
|
console.log(`Note: ${summary.outcomeNote}`);
|
|
171
|
+
if (summary.failure)
|
|
172
|
+
console.log(failureGuide(summary.project, summary.name ?? summary.runId, summary.failure, runDir));
|
|
170
173
|
if (summary.verification)
|
|
171
174
|
console.log(`Verification (as reported by the lead, not checked by agent-lyceum): ${summary.verification}`);
|
|
172
175
|
if (summary.resultBranch?.files) {
|
|
@@ -200,7 +203,7 @@ async function continueRun(pr, dir, runId, followup) {
|
|
|
200
203
|
if (followup)
|
|
201
204
|
console.log(`Reopening run ${runLabel(state)} (follow-up ${(state.reopened?.length ?? 0) + 1}): ${firstLine(followup.text)}`);
|
|
202
205
|
else
|
|
203
|
-
console.log(`Resuming run ${runLabel(state)} (${state.end_reason ?? "interrupted"}) at round ${state.rounds
|
|
206
|
+
console.log(`Resuming run ${runLabel(state)} (${state.end_reason ?? "interrupted"}) at round ${roundsLabel(state.rounds, roundLimit(state, pr.dispatcher.max_rounds))}: ${state.task_summary}`);
|
|
204
207
|
// Read the state again under the lock: another process may have changed it while we were checking.
|
|
205
208
|
return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal, followup });
|
|
206
209
|
}
|
|
@@ -393,8 +396,8 @@ program
|
|
|
393
396
|
process.exit(exitCodeForOutcome(o.outcome));
|
|
394
397
|
}
|
|
395
398
|
// A reopen recalculates the round limit from where the run stands.
|
|
396
|
-
if (!reopen && state.rounds >= roundLimit(state, pr.dispatcher.max_rounds))
|
|
397
|
-
fail(`Run ${runLabel(state)} used ${state.rounds}/${roundLimit(state, pr.dispatcher.max_rounds)} rounds; raise dispatcher.max_rounds in project.yaml first.`);
|
|
399
|
+
if (!reopen && roundLimit(state, pr.dispatcher.max_rounds) > 0 && state.rounds >= roundLimit(state, pr.dispatcher.max_rounds))
|
|
400
|
+
fail(`Run ${runLabel(state)} used ${state.rounds}/${roundLimit(state, pr.dispatcher.max_rounds)} rounds; raise dispatcher.max_rounds in project.yaml (or set it to 0 for no limit) first.`);
|
|
398
401
|
if (opts.answer && state.end_reason !== "waiting")
|
|
399
402
|
fail(`Run ${runLabel(state)} is not waiting for answers (${state.end_reason ?? "not finished"}).`);
|
|
400
403
|
if (state.end_reason === "waiting") {
|
package/dist/mailbox.js
CHANGED
|
@@ -166,6 +166,37 @@ export function rejectDone(file) {
|
|
|
166
166
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
167
167
|
fs.renameSync(file, dest);
|
|
168
168
|
}
|
|
169
|
+
/** A body with whitespace collapsed and the dispatcher's own format warning removed, so the same text compares equal. */
|
|
170
|
+
function sameText(body) {
|
|
171
|
+
return body.replace(/^\[agent-lyceum\][^\n]*(\n|$)\s*/, "").replace(/\s+/g, " ").trim();
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* An earlier mail from `from` to `to` with the same type, the same reply target and the same text (unread or already read):
|
|
175
|
+
* sending it again changes nothing and is how two agents end up in a loop. Texts too short to mean anything are never duplicates.
|
|
176
|
+
*/
|
|
177
|
+
function earlierDuplicate(project, from, to, type, replyTo, body) {
|
|
178
|
+
const text = sameText(body);
|
|
179
|
+
if (text.length < 20)
|
|
180
|
+
return undefined;
|
|
181
|
+
const dir = inboxDir(project, to);
|
|
182
|
+
for (const d of [dir, path.join(dir, "read")]) {
|
|
183
|
+
if (!fs.existsSync(d))
|
|
184
|
+
continue;
|
|
185
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
186
|
+
if (!e.isFile() || !e.name.endsWith(".md") || e.name.startsWith("."))
|
|
187
|
+
continue;
|
|
188
|
+
try {
|
|
189
|
+
const m = readMessage(path.join(d, e.name));
|
|
190
|
+
if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text)
|
|
191
|
+
return m;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* an unreadable file is not a duplicate of anything */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
169
200
|
/** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
|
|
170
201
|
export function routeOutboxes(project) {
|
|
171
202
|
const res = { delivered: [], rejected: [], warnings: [], asks: [] };
|
|
@@ -250,6 +281,33 @@ export function routeOutboxes(project) {
|
|
|
250
281
|
}
|
|
251
282
|
const replyTo = d.reply_to ? String(d.reply_to) : undefined;
|
|
252
283
|
const thread = d.thread ? String(d.thread) : undefined;
|
|
284
|
+
// Mail whose delivery was already planned (a replay after a crash) is not a repeat of itself.
|
|
285
|
+
const repeats = new Map();
|
|
286
|
+
for (const to of targets) {
|
|
287
|
+
if (journal?.get(source, to))
|
|
288
|
+
continue;
|
|
289
|
+
const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body);
|
|
290
|
+
if (earlier)
|
|
291
|
+
repeats.set(to, earlier);
|
|
292
|
+
}
|
|
293
|
+
const repeatNote = () => [...repeats]
|
|
294
|
+
.map(([to, m]) => `to "${to}" it repeats mail ${m.meta.id} ("${m.meta.subject}", ${m.meta.created}) with the same content`)
|
|
295
|
+
.join("; ");
|
|
296
|
+
const REPEAT_HELP = "Do not send it again: if that mail has not been answered yet, wait for the reply; if you need something different, change what you ask for; if the work is finished, the lead sends `done`.";
|
|
297
|
+
if (repeats.size === targets.length) {
|
|
298
|
+
reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (repeats.size) {
|
|
302
|
+
deliverOnce(project, journal, `dup:${source}`, {
|
|
303
|
+
from: "dispatcher",
|
|
304
|
+
to: sender.name,
|
|
305
|
+
type: "failure",
|
|
306
|
+
subject: `Part of ${path.basename(file)} was not sent`,
|
|
307
|
+
body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
|
|
308
|
+
});
|
|
309
|
+
res.rejected.push({ from: sender.name, file, reason: `duplicate: ${repeatNote()}` });
|
|
310
|
+
}
|
|
253
311
|
const missing = missingSections(type, parsed.body);
|
|
254
312
|
const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
|
|
255
313
|
if (sender.name === project.lead) {
|
|
@@ -258,6 +316,8 @@ export function routeOutboxes(project) {
|
|
|
258
316
|
res.steps = st;
|
|
259
317
|
}
|
|
260
318
|
for (const to of targets) {
|
|
319
|
+
if (repeats.has(to))
|
|
320
|
+
continue;
|
|
261
321
|
const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo });
|
|
262
322
|
if (!fresh)
|
|
263
323
|
continue;
|
package/dist/prompt.js
CHANGED
|
@@ -88,7 +88,7 @@ export function buildSystemPrompt(project, agent) {
|
|
|
88
88
|
? `When the whole job is finished, send a message with \`type: done\` (no \`to\` needed). That ends the run. A summary in your reply text is never delivered and does not end the run: after the last teammate reply is verified you MUST send \`done\` (or a \`task\` for the remaining work), or the run stops as "idle" with no final report.`
|
|
89
89
|
: `Report results to the lead by mail; the lead decides what happens next.`, isLead
|
|
90
90
|
? "Each wake-up handles one message, except that several `reply`/`failure` messages waiting together are handed to you in one wake-up: weigh them together, then decide the next step once."
|
|
91
|
-
: "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
|
|
91
|
+
: "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "Mail that repeats an earlier mail of yours to the same agent (same type, same text) is not sent again; you are told, and should wait for the answer or ask for something different instead of resending.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
|
|
92
92
|
? "- `done`: your final report to the user (shown when the run ends, saved as `result.md`). Its frontmatter must contain `outcome: completed|partial|blocked|failed`, and the body the headings `## Result` (the actual deliverable or conclusion, not just \"done\"), `## Files` (paths created or changed), `## Verification` (what you ran or checked, and what it showed) and `## Not done` (anything skipped or unverified, or `None`). Only `completed` means success, and it needs all four headings and every checklist step ticked; if work remains or is blocked, say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A `done` that breaks this is sent back to you and does not end the run. " + OPEN_PR_RULE
|
|
93
93
|
: "- `done` has no required headings.", ...(agent.canAskUser
|
|
94
94
|
? [
|
package/dist/run-outcome.d.ts
CHANGED
|
@@ -16,7 +16,17 @@ export declare function resolveDoneOutcome(contract: DoneContract, rejections: n
|
|
|
16
16
|
export declare function endOutcome(endReason: EndReason, done?: {
|
|
17
17
|
outcome: RunOutcome;
|
|
18
18
|
note?: string;
|
|
19
|
+
}, failure?: {
|
|
20
|
+
agent: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
attempts: number;
|
|
19
23
|
}): {
|
|
20
24
|
outcome: RunOutcome;
|
|
21
25
|
note?: string;
|
|
22
26
|
};
|
|
27
|
+
/** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
|
|
28
|
+
export declare function failureGuide(project: string, run: string, f: {
|
|
29
|
+
agent: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
attempts: number;
|
|
32
|
+
}, runDir: string): string;
|
package/dist/run-outcome.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
/** What a lead's `done` amounts to: its declared outcome, unless the contract is broken or member work never reached the repo. */
|
|
2
3
|
export function resolveDoneOutcome(contract, rejections, unintegrated) {
|
|
3
4
|
const ok = contract.missing.length === 0;
|
|
@@ -10,12 +11,18 @@ export function resolveDoneOutcome(contract, rejections, unintegrated) {
|
|
|
10
11
|
return { outcome, note, verification: contract.verification };
|
|
11
12
|
}
|
|
12
13
|
/** How a run turned out, from why it stopped (`done` carries the lead's own verdict). */
|
|
13
|
-
export function endOutcome(endReason, done) {
|
|
14
|
+
export function endOutcome(endReason, done, failure) {
|
|
14
15
|
switch (endReason) {
|
|
15
16
|
case "done":
|
|
16
17
|
return { outcome: done?.outcome ?? "partial", note: done?.note };
|
|
17
18
|
case "lead_failed":
|
|
18
|
-
|
|
19
|
+
case "agent_failed":
|
|
20
|
+
return {
|
|
21
|
+
outcome: "failed",
|
|
22
|
+
note: failure
|
|
23
|
+
? `${failure.agent} failed ${failure.attempts} time(s) in a row (${failure.error ?? "unknown error"}), so the run stopped. The message it was handling is still unread.`
|
|
24
|
+
: "The lead's wake-up failed.",
|
|
25
|
+
};
|
|
19
26
|
case "waiting":
|
|
20
27
|
return { outcome: "waiting", note: "Waiting for your answers. Fill in ask-reply.md or run `agent-lyceum resume <run-id> --answer`, then continue with `agent-lyceum resume`." };
|
|
21
28
|
case "cancelled":
|
|
@@ -26,3 +33,16 @@ export function endOutcome(endReason, done) {
|
|
|
26
33
|
return { outcome: "partial", note: "Stopped at the round limit before the lead sent done." };
|
|
27
34
|
}
|
|
28
35
|
}
|
|
36
|
+
/** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
|
|
37
|
+
export function failureGuide(project, run, f, runDir) {
|
|
38
|
+
return [
|
|
39
|
+
"",
|
|
40
|
+
`${f.agent} was called ${f.attempts} time(s) and failed every time (${f.error ?? "unknown error"}). The run stopped; nothing was lost: the message ${f.agent} was handling is still unread and the work done so far is kept.`,
|
|
41
|
+
"To fix it:",
|
|
42
|
+
` 1. Read why it failed: ${path.join(runDir, "log.jsonl")} (event "wake"), and the full output of each attempt in ${path.join(runDir, "mail", "attempts")}`,
|
|
43
|
+
` 2. Fix the cause: the ${f.agent} CLI (installed, logged in, model name), a timeout (dispatcher.wake_timeout_sec), or its AGENT.md / the message it keeps failing on. Check with: agent-lyceum doctor -p ${project}`,
|
|
44
|
+
"To go on:",
|
|
45
|
+
` agent-lyceum resume ${run} -p ${project} (the same message is handled again; the task is not re-sent)`,
|
|
46
|
+
` Or give up on it: agent-lyceum clear ${run} -p ${project}`,
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
package/dist/run-session.d.ts
CHANGED
|
@@ -33,6 +33,14 @@ export interface RunSummary {
|
|
|
33
33
|
resultBranch?: ResultBranch;
|
|
34
34
|
/** The lead asked for a pull request: what became of it. */
|
|
35
35
|
pr?: PrOutcome;
|
|
36
|
+
/** The project's name, for the commands printed after the run. */
|
|
37
|
+
project: string;
|
|
38
|
+
/** The run stopped because this agent kept failing. */
|
|
39
|
+
failure?: {
|
|
40
|
+
agent: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
attempts: number;
|
|
43
|
+
};
|
|
36
44
|
}
|
|
37
45
|
export interface RunOptions {
|
|
38
46
|
project: ResolvedProject;
|
|
@@ -78,6 +86,7 @@ export declare class RunSession {
|
|
|
78
86
|
private pendingDoneFile;
|
|
79
87
|
private doneOutcome;
|
|
80
88
|
private doneNote;
|
|
89
|
+
private failure;
|
|
81
90
|
private doneVerification;
|
|
82
91
|
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
83
92
|
private openPrRequested;
|
package/dist/run-session.js
CHANGED
|
@@ -13,7 +13,7 @@ import { outboxDir, ownsDirs, sharedIndex } from "./policy.js";
|
|
|
13
13
|
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
14
14
|
import { atomicWrite, readFileCapped } from "./fs-util.js";
|
|
15
15
|
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, resolveWorkspaceMode, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
|
-
import { bindRunProject, newRunId, newRunState, roundLimit, saveRunState } from "./run-store.js";
|
|
16
|
+
import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
|
|
17
17
|
import { soloProject } from "./solo.js";
|
|
18
18
|
import { taskMessageBody } from "./task.js";
|
|
19
19
|
import { must } from "./assert.js";
|
|
@@ -58,6 +58,7 @@ export class RunSession {
|
|
|
58
58
|
pendingDoneFile;
|
|
59
59
|
doneOutcome;
|
|
60
60
|
doneNote;
|
|
61
|
+
failure;
|
|
61
62
|
doneVerification;
|
|
62
63
|
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
63
64
|
openPrRequested = false;
|
|
@@ -124,7 +125,7 @@ export class RunSession {
|
|
|
124
125
|
}
|
|
125
126
|
let state;
|
|
126
127
|
if (resume) {
|
|
127
|
-
state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
128
|
+
state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, failure: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
128
129
|
// A wake that was running when the process died never finished; keep it in the history as interrupted.
|
|
129
130
|
for (const [agent, a] of Object.entries(resume.active ?? {})) {
|
|
130
131
|
state.wakes.push({
|
|
@@ -304,14 +305,16 @@ export class RunSession {
|
|
|
304
305
|
};
|
|
305
306
|
let result;
|
|
306
307
|
for (let attempt = 0; attempt <= cfg.retry; attempt++) {
|
|
307
|
-
if (state.rounds >= state.max_rounds) {
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
if (state.max_rounds > 0 && state.rounds >= state.max_rounds) {
|
|
309
|
+
// The round limit (when one is set) ends the run; this message stays unread for the resume.
|
|
310
|
+
abandonClaim(runDir, claim.id);
|
|
311
|
+
claims.delete(agent.name);
|
|
312
|
+
return { ok: false, cancelled: true };
|
|
310
313
|
}
|
|
311
314
|
const round = ++state.rounds;
|
|
312
315
|
const started = Date.now();
|
|
313
316
|
const att = beginAttempt(runDir, claim.id);
|
|
314
|
-
say(`[round ${state.rounds
|
|
317
|
+
say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
|
|
315
318
|
const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
|
|
316
319
|
const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
|
|
317
320
|
// What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
|
|
@@ -380,16 +383,10 @@ export class RunSession {
|
|
|
380
383
|
if (result?.ok)
|
|
381
384
|
markOutputReady(runDir, claim.id);
|
|
382
385
|
if (!result?.ok) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
to: project.lead,
|
|
388
|
-
type: "failure",
|
|
389
|
-
subject: `${agent.name} failed`,
|
|
390
|
-
body: `Agent "${agent.name}" could not handle message(s) "${unread.map((m) => m.meta.subject).join('", "')}".\n\nError: ${result?.error ?? "unknown"}\n\nDecide whether to retry, reassign, or adjust the plan.`,
|
|
391
|
-
});
|
|
392
|
-
return { ok: false };
|
|
386
|
+
// The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
|
|
387
|
+
abandonClaim(runDir, claim.id);
|
|
388
|
+
claims.delete(agent.name);
|
|
389
|
+
return { ok: false, error: result?.error, attempts: cfg.retry + 1 };
|
|
393
390
|
}
|
|
394
391
|
return { ok: true };
|
|
395
392
|
}
|
|
@@ -553,10 +550,12 @@ export class RunSession {
|
|
|
553
550
|
this.endReason = "waiting";
|
|
554
551
|
return;
|
|
555
552
|
}
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
this.
|
|
553
|
+
const failedIdx = results.findIndex((r) => !r.ok && !r.cancelled);
|
|
554
|
+
if (failedIdx >= 0) {
|
|
555
|
+
const failed = must(batch[failedIdx], "failed agent");
|
|
556
|
+
this.failure = { agent: failed.name, error: results[failedIdx]?.error, attempts: results[failedIdx]?.attempts ?? this.cfg.retry + 1 };
|
|
557
|
+
state.failure = this.failure;
|
|
558
|
+
this.endReason = failed.name === project.lead ? "lead_failed" : "agent_failed";
|
|
560
559
|
return;
|
|
561
560
|
}
|
|
562
561
|
}
|
|
@@ -757,7 +756,7 @@ export class RunSession {
|
|
|
757
756
|
this.endReason = "idle";
|
|
758
757
|
break;
|
|
759
758
|
}
|
|
760
|
-
if (state.rounds >= state.max_rounds) {
|
|
759
|
+
if (state.max_rounds > 0 && state.rounds >= state.max_rounds) {
|
|
761
760
|
this.endReason = "max_rounds";
|
|
762
761
|
break;
|
|
763
762
|
}
|
|
@@ -799,7 +798,7 @@ export class RunSession {
|
|
|
799
798
|
this.endReason = "cancelled";
|
|
800
799
|
}
|
|
801
800
|
const endReason = must(this.endReason, "end reason");
|
|
802
|
-
const { outcome, note: outcomeNote } = endOutcome(endReason, endReason === "done" ? { outcome: this.doneOutcome ?? "partial", note: this.doneNote } : undefined);
|
|
801
|
+
const { outcome, note: outcomeNote } = endOutcome(endReason, endReason === "done" ? { outcome: this.doneOutcome ?? "partial", note: this.doneNote } : undefined, this.failure);
|
|
803
802
|
state.end_reason = endReason;
|
|
804
803
|
state.outcome = outcome;
|
|
805
804
|
state.outcome_note = outcomeNote;
|
|
@@ -819,6 +818,6 @@ export class RunSession {
|
|
|
819
818
|
this.saveState();
|
|
820
819
|
if (this.pendingDoneFile)
|
|
821
820
|
finishDone(this.pendingDoneFile);
|
|
822
|
-
return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr };
|
|
821
|
+
return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr, project: project.name, failure: this.failure };
|
|
823
822
|
}
|
|
824
823
|
}
|
package/dist/run-store.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { RunOutcome } from "./schema.js";
|
|
|
4
4
|
import type { ResultBranch } from "./worktree.js";
|
|
5
5
|
export declare const STATE_FILE = "state.json";
|
|
6
6
|
export declare const STATE_SCHEMA_VERSION = 2;
|
|
7
|
-
export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled" | "waiting";
|
|
7
|
+
export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "agent_failed" | "cancelled" | "waiting";
|
|
8
8
|
export type MailLayout = "run" | "legacy";
|
|
9
9
|
export interface WakeTopic {
|
|
10
10
|
from: string;
|
|
@@ -97,6 +97,12 @@ export interface RunState {
|
|
|
97
97
|
active: Record<string, ActiveWake>;
|
|
98
98
|
/** Finished wakes, oldest first. */
|
|
99
99
|
wakes: WakeRecord[];
|
|
100
|
+
/** Set when the run stopped because an agent kept failing: who, the last error, how many times it was called. */
|
|
101
|
+
failure?: {
|
|
102
|
+
agent: string;
|
|
103
|
+
error?: string;
|
|
104
|
+
attempts: number;
|
|
105
|
+
};
|
|
100
106
|
/** Checklist progress: seeded from the task, then replaced by the lead's latest `## Steps` (after a reopen, added to `step_base`). */
|
|
101
107
|
steps?: Step[];
|
|
102
108
|
/** The checklist as it stood at the last reopen: the follow-up's steps are shown after it, and its open steps do not hold up the follow-up's done. */
|
|
@@ -154,4 +160,7 @@ export type Resolved = {
|
|
|
154
160
|
};
|
|
155
161
|
/** Find a run by full id, exact name, or a prefix of either that matches exactly one run. An exact id or name always wins. */
|
|
156
162
|
export declare function resolveRun(runs: RunRef[], query: string): Resolved;
|
|
163
|
+
/** The round the run stops at; 0 (`dispatcher.max_rounds: 0`) means there is no limit. */
|
|
157
164
|
export declare const roundLimit: (state: Pick<RunState, "round_floor">, maxRounds: number) => number;
|
|
165
|
+
/** `rounds`, or `rounds/limit` when the run has a limit. */
|
|
166
|
+
export declare const roundsLabel: (rounds: number, limit: number) => string;
|
package/dist/run-store.js
CHANGED
|
@@ -23,7 +23,7 @@ const WireState = z
|
|
|
23
23
|
ended_at: z.string().optional(),
|
|
24
24
|
rounds: nonNeg.optional(),
|
|
25
25
|
max_rounds: nonNeg.optional(),
|
|
26
|
-
end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled", "waiting"]).optional(),
|
|
26
|
+
end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "agent_failed", "cancelled", "waiting"]).optional(),
|
|
27
27
|
outcome: z.enum(["completed", "partial", "blocked", "failed", "waiting", "cancelled"]).optional(),
|
|
28
28
|
outcome_note: z.string().optional(),
|
|
29
29
|
verification: z.string().optional(),
|
|
@@ -39,6 +39,7 @@ const WireState = z
|
|
|
39
39
|
wakes: z.array(z.any()).optional(),
|
|
40
40
|
solo_agent: z.string().optional(),
|
|
41
41
|
steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
42
|
+
failure: z.object({ agent: z.string(), error: z.string().optional(), attempts: z.number() }).optional(),
|
|
42
43
|
step_base: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
43
44
|
name: z.string().optional(),
|
|
44
45
|
reopened: z.array(z.object({ at: z.string() })).optional(),
|
|
@@ -121,7 +122,7 @@ export function outcomeOf(s) {
|
|
|
121
122
|
return { outcome: s.outcome, verified: true };
|
|
122
123
|
if (!s.end_reason)
|
|
123
124
|
return undefined;
|
|
124
|
-
return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
|
|
125
|
+
return { outcome: s.end_reason === "lead_failed" || s.end_reason === "agent_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
|
|
125
126
|
}
|
|
126
127
|
const NAME_MAX = 30;
|
|
127
128
|
export function slugify(text) {
|
|
@@ -157,4 +158,7 @@ export function resolveRun(runs, query) {
|
|
|
157
158
|
return { ok: true, run: pool[0] };
|
|
158
159
|
return { ok: false, reason: pool.length ? "ambiguous" : "none", candidates: pool };
|
|
159
160
|
}
|
|
160
|
-
|
|
161
|
+
/** The round the run stops at; 0 (`dispatcher.max_rounds: 0`) means there is no limit. */
|
|
162
|
+
export const roundLimit = (state, maxRounds) => (maxRounds > 0 ? (state.round_floor ?? 0) + maxRounds : 0);
|
|
163
|
+
/** `rounds`, or `rounds/limit` when the run has a limit. */
|
|
164
|
+
export const roundsLabel = (rounds, limit) => (limit > 0 ? `${rounds}/${limit}` : String(rounds));
|
package/dist/scaffold.js
CHANGED
|
@@ -85,12 +85,12 @@ dir: ${repo} # the repo the agents work in (the tool never writes con
|
|
|
85
85
|
team:
|
|
86
86
|
lead: lead
|
|
87
87
|
dispatcher:
|
|
88
|
-
max_rounds:
|
|
88
|
+
max_rounds: 0 # 0 = no limit on wake-ups; set a number to stop a run after that many
|
|
89
89
|
max_parallel: 1 # >1 needs non-overlapping "owns" for every non-lead agent, and a git repo
|
|
90
90
|
max_concurrent_runs: 1 # >1 lets several runs of this project be active at once, each in its own git worktree (needs a git repo); every run's work stays on its own branch
|
|
91
91
|
workspace_mode: auto # auto = a git worktree per non-lead agent when max_parallel > 1; shared | worktree to force
|
|
92
92
|
wake_timeout_sec: 600
|
|
93
|
-
retry:
|
|
93
|
+
retry: 2 # an agent that fails is called again up to this many times; if it still fails the run stops (resume it after fixing the cause)
|
|
94
94
|
log_max_bytes: 8388608 # log.jsonl is renamed to log.1.jsonl past this size; 0 = never
|
|
95
95
|
strict: false # true = refuse to start unless context protection is OS-enforced
|
|
96
96
|
agents:
|
package/dist/schema.js
CHANGED
|
@@ -43,7 +43,7 @@ export const AgentPartial = z
|
|
|
43
43
|
.strict();
|
|
44
44
|
export const DispatcherPartial = z
|
|
45
45
|
.object({
|
|
46
|
-
max_rounds: z.number().int().
|
|
46
|
+
max_rounds: z.number().int().min(0).optional(),
|
|
47
47
|
max_parallel: z.number().int().positive().optional(),
|
|
48
48
|
max_concurrent_runs: z.number().int().positive().optional(),
|
|
49
49
|
wake_timeout_sec: z.number().int().positive().optional(),
|
|
@@ -67,11 +67,11 @@ export const ProjectConfig = z
|
|
|
67
67
|
})
|
|
68
68
|
.strict();
|
|
69
69
|
export const DISPATCHER_DEFAULTS = {
|
|
70
|
-
max_rounds:
|
|
70
|
+
max_rounds: 0,
|
|
71
71
|
max_parallel: 1,
|
|
72
72
|
max_concurrent_runs: 1,
|
|
73
73
|
wake_timeout_sec: 600,
|
|
74
|
-
retry:
|
|
74
|
+
retry: 2,
|
|
75
75
|
strict: false,
|
|
76
76
|
workspace_mode: "auto",
|
|
77
77
|
log_max_bytes: 8 * 1024 * 1024,
|
package/dist/status.d.ts
CHANGED
|
@@ -69,7 +69,14 @@ export interface RunReport {
|
|
|
69
69
|
started_at: string;
|
|
70
70
|
ended_at?: string;
|
|
71
71
|
rounds: number;
|
|
72
|
+
/** 0 = no limit. */
|
|
72
73
|
max_rounds: number;
|
|
74
|
+
/** Set when an agent kept failing and the run stopped: `resume` after fixing the cause. */
|
|
75
|
+
failure?: {
|
|
76
|
+
agent: string;
|
|
77
|
+
error?: string;
|
|
78
|
+
attempts: number;
|
|
79
|
+
};
|
|
73
80
|
output_tokens: number;
|
|
74
81
|
task_source: "text" | "file";
|
|
75
82
|
task_path?: string;
|
package/dist/status.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { askReplyPath, readAskReply } from "./ask-reply.js";
|
|
4
4
|
import { RESULT_FILE } from "./dispatcher.js";
|
|
5
|
-
import { bindRunProject, loadRunState, outcomeOf, runLabel } from "./run-store.js";
|
|
5
|
+
import { bindRunProject, loadRunState, outcomeOf, roundsLabel, runLabel } from "./run-store.js";
|
|
6
6
|
import { briefOf, digestOf } from "./format.js";
|
|
7
7
|
import { listUnread } from "./mailbox.js";
|
|
8
8
|
import { tidyAdvice } from "./memory-tidy.js";
|
|
@@ -204,6 +204,7 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
204
204
|
ended_at: s.ended_at,
|
|
205
205
|
rounds: s.rounds,
|
|
206
206
|
max_rounds: s.max_rounds,
|
|
207
|
+
...(s.failure ? { failure: s.failure } : {}),
|
|
207
208
|
output_tokens: s.output_tokens,
|
|
208
209
|
task_source: s.task_source,
|
|
209
210
|
task_path: s.task_path,
|
|
@@ -289,11 +290,11 @@ function summaryBlock(run, now, c) {
|
|
|
289
290
|
const s = run;
|
|
290
291
|
const live = s.live;
|
|
291
292
|
const lines = [
|
|
292
|
-
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 第 ${s.rounds
|
|
293
|
+
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 第 ${roundsLabel(s.rounds, s.max_rounds)} 輪`,
|
|
293
294
|
`${L("任務:")}${taskText(s)}`,
|
|
294
295
|
];
|
|
295
296
|
const steps = stepsLine(s.steps, c, s.end_reason === "done");
|
|
296
|
-
lines.push(`${L("進度:")}${steps ?? `無清單;已用 ${s.rounds
|
|
297
|
+
lines.push(`${L("進度:")}${steps ?? `無清單;已用 ${roundsLabel(s.rounds, s.max_rounds)} 輪(lead 可在信中加入 "## Steps" 清單)`}`);
|
|
297
298
|
const active = Object.entries(s.active ?? {});
|
|
298
299
|
if (active.length) {
|
|
299
300
|
for (const [i, [agent, a]] of active.entries()) {
|
|
@@ -319,6 +320,9 @@ function summaryBlock(run, now, c) {
|
|
|
319
320
|
const lastWake = (s.wakes ?? []).at(-1);
|
|
320
321
|
if (lastWake)
|
|
321
322
|
lines.push(`${L("上一次:")}#${lastWake.round} ${c.cyan(lastWake.agent)} ${okText(c, lastWake.ok)} → ${sentText(lastWake.sent)}`);
|
|
323
|
+
if (s.failure) {
|
|
324
|
+
lines.push(`${L("需要處理:")}${c.red(`${s.failure.agent} 呼叫 ${s.failure.attempts} 次都失敗`)}(${clip(s.failure.error ?? "未知錯誤", 80)})`, c.dim(` 查 log 找原因並修正後,執行 agent-lyceum resume ${s.run_id} 從同一封信繼續(或 agent-lyceum clear ${s.run_id} 放棄)`));
|
|
325
|
+
}
|
|
322
326
|
if (s.ask)
|
|
323
327
|
lines.push(`${L("等你回答:")}${s.ask.unanswered}/${s.ask.total} 題未答 → ${s.ask.path}`, c.dim(` 填完後執行 agent-lyceum resume ${s.run_id} --answer`));
|
|
324
328
|
lines.push(`${L("流程:")}${flowLine(s, c)}`);
|
|
@@ -327,7 +331,7 @@ function summaryBlock(run, now, c) {
|
|
|
327
331
|
/** What a run is doing / did, one line per wake. */
|
|
328
332
|
function describeRun(s, now, c, limit = Infinity) {
|
|
329
333
|
const lines = [
|
|
330
|
-
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 輪次 ${s.rounds
|
|
334
|
+
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 輪次 ${roundsLabel(s.rounds, s.max_rounds)} 輸出 tokens ${tokens(s.output_tokens)}`,
|
|
331
335
|
` 任務:${taskText(s)}`,
|
|
332
336
|
];
|
|
333
337
|
const steps = stepsLine(s.steps, c);
|
|
@@ -401,7 +405,7 @@ export function formatTaskListReport(report, color = false) {
|
|
|
401
405
|
...(named ? [s.name ?? ""] : []),
|
|
402
406
|
s.run_id,
|
|
403
407
|
reportLabel(s),
|
|
404
|
-
|
|
408
|
+
roundsLabel(s.rounds, s.max_rounds),
|
|
405
409
|
s.steps?.length ? (s.current_step ? clip(s.current_step, 30) : "全部完成") : "-",
|
|
406
410
|
clip(taskText(s), 60),
|
|
407
411
|
]);
|