dowafu 0.5.1 → 0.6.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 CHANGED
@@ -208,7 +208,7 @@ not work: a reviewer's fixed closing line has to match the template the audit ch
208
208
  against.
209
209
 
210
210
  ```bash
211
- npx degit eyesofkids/dowafu/publish/en#v0.5.0 .claude-pack # or publish/zh-tw
211
+ npx degit eyesofkids/dowafu/publish/en#v0.6.0 .claude-pack # or publish/zh-tw
212
212
 
213
213
  cd .claude-pack
214
214
  TARGET=<your project>
@@ -244,8 +244,9 @@ There are two transports, and the difference that matters is who starts what.
244
244
  ### Both transports need `dowafu serve`
245
245
 
246
246
  `dowafu mcp` only answers tool calls — **it never runs a reviewer**. The worker that
247
- actually spends money lives in `dowafu serve`. Without one running, an approved job sits
248
- in the queue forever and nothing tells the agent why.
247
+ actually spends money lives in `dowafu serve`. Without one running, an approved job never
248
+ runs — `dispatch_submit`, `dispatch_approve` and `dispatch_status` each print a `⚠` line
249
+ saying so.
249
250
 
250
251
  ```
251
252
  stdio: your client ──spawn──→ dowafu mcp ──┐
@@ -255,9 +256,11 @@ stdio: your client ──spawn──→ dowafu mcp ──┐
255
256
  HTTP: your client ──x-api-key──→ dowafu serve (endpoint and worker, one process)
256
257
  ```
257
258
 
258
- So on stdio you keep `dowafu serve` running in a terminal next to the client. The order
259
- is forgiving there — start it before or after approving, and the daemon picks the job up
260
- on its next tick.
259
+ So on stdio you keep `dowafu serve` running in a terminal next to the client. **Start it
260
+ before you approve.** A daemon only runs approvals made while it is up: when it starts,
261
+ and again when it stops, any job still waiting in `approved` goes back to pending approval,
262
+ so an approval given before the daemon was running has to be given again. `dowafu serve`
263
+ prints how many it sent back.
261
264
 
262
265
  ### stdio
263
266
 
@@ -353,6 +356,25 @@ With the daemon always up the stdio path needs nothing extra — `dowafu mcp` fi
353
356
  worker already running. Typing `dowafu serve` yourself will then refuse with
354
357
  `daemon already running (pid N)`: that is the guard working, not a fault.
355
358
 
359
+ ### Stopping and restarting
360
+
361
+ `dowafu serve --stop` stops the daemon; `dowafu serve --restart` stops it and starts a new
362
+ one (or just starts one if none is running). When something would be affected, both ask
363
+ first — separately — about:
364
+
365
+ - **jobs still running** — stopping the daemon does not stop a reviewer already in flight.
366
+ It runs to completion, and its results and API cost are still recorded.
367
+ - **jobs approved but not yet started** — they go back to pending approval, so the next
368
+ daemon cannot run them without a fresh approval.
369
+
370
+ With no terminal to answer (an agent calling it, a script), a question counts as "no" and
371
+ nothing is stopped; pass `--yes` to go ahead.
372
+
373
+ If the daemon ignores SIGTERM, `--stop` says so and prints the pid to inspect. Do not reach
374
+ for SIGKILL: a reviewer's worker process can outlive the daemon and still write its results.
375
+ And if the daemon comes straight back after stopping, a service manager (the `KeepAlive` /
376
+ `Restart=always` above) is restarting it — stop it with `launchctl` or `systemctl` instead.
377
+
356
378
  ### Approval is still a separate step
357
379
 
358
380
  `dispatch_submit` queues a job and stops; nothing is billed until it is approved. Two
package/README_zh-tw.md CHANGED
@@ -199,7 +199,7 @@ dowafu providers import <path> # 匯入自己的設定檔
199
199
  `publish/` 提供兩種語言:`publish/en/` 與 `publish/zh-tw/`。**擇一複製,不可混裝**——審查者的固定收尾句必須與稽核檢查用的範本是同一種語言。
200
200
 
201
201
  ```bash
202
- npx degit eyesofkids/dowafu/publish/zh-tw#v0.5.0 .claude-pack # 或 publish/en
202
+ npx degit eyesofkids/dowafu/publish/zh-tw#v0.6.0 .claude-pack # 或 publish/en
203
203
 
204
204
  cd .claude-pack
205
205
  TARGET=<your project>
@@ -238,8 +238,8 @@ Agent 可以不走 shell,改用 MCP 驅動 `dowafu`。露出五個 tool:`dis
238
238
  ### 兩條路都需要 `dowafu serve`
239
239
 
240
240
  `dowafu mcp` 只回應 tool 呼叫,**它不執行任何 reviewer**。真正花錢的 worker 在
241
- `dowafu serve` 裡。沒有它,核准過的 job 會永遠留在佇列裡,而且沒有任何東西會告訴 agent
242
- 為什麼。
241
+ `dowafu serve` 裡。沒有它,核准過的 job 永遠不會執行——`dispatch_submit`、`dispatch_approve`、
242
+ `dispatch_status` 都會印一行 `⚠` 說明這件事。
243
243
 
244
244
  ```
245
245
  stdio: 你的 client ──spawn──→ dowafu mcp ──┐
@@ -249,8 +249,9 @@ stdio: 你的 client ──spawn──→ dowafu mcp ──┐
249
249
  HTTP: 你的 client ──x-api-key──→ dowafu serve (端點與 worker 同一個行程)
250
250
  ```
251
251
 
252
- 所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve` 跑著。這條路的順序很寬鬆
253
- ——核准之前或之後才起都可以,daemon 下一個 tick 就會把它撿走。
252
+ 所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve` 跑著。**要先起 daemon,再核准。**
253
+ daemon 只執行它在線期間核准的 job:它啟動時、以及停止時,還停在「已核准」的 job 會被退回待核准,
254
+ 所以在 daemon 起來之前給的核准要再給一次。`dowafu serve` 會印出退回了幾張。
254
255
 
255
256
  ### stdio
256
257
 
@@ -339,6 +340,20 @@ daemon 常駐之後,stdio 那條路不需要任何額外動作——`dowafu mc
339
340
  這時自己再打 `dowafu serve` 會被擋下並回報 `daemon already running (pid N)`:
340
341
  **那是守衛在運作,不是故障。**
341
342
 
343
+ ### 停止與重啟
344
+
345
+ `dowafu serve --stop` 停掉 daemon;`dowafu serve --restart` 停掉之後再起一支新的
346
+ (沒在跑的話就直接起)。有東西會受影響時,兩者都會先分開問兩件事:
347
+
348
+ - **還在執行的 job**——停掉 daemon 不會停掉已經在跑的審查者,它會跑完,結果與 API 成本照樣記帳。
349
+ - **已核准但還沒開始的 job**——會退回待核准,下一支 daemon 沒有新的核准就不會跑它們。
350
+
351
+ 沒有終端機可以回答時(agent 代為呼叫、腳本),問題一律視為「不要」,什麼都不會停;要繼續就帶 `--yes`。
352
+
353
+ daemon 收到 SIGTERM 卻沒停下來時,`--stop` 會明說並印出要檢查的 pid。**不要改用 SIGKILL**:
354
+ 審查者的 worker 行程可能比 daemon 活得久,照樣會寫入結果。停掉之後 daemon 馬上又回來,
355
+ 代表有服務管理器(上面的 `KeepAlive`/`Restart=always`)在重拉它——那要用 `launchctl` 或 `systemctl` 停。
356
+
342
357
  ### 核准仍然是獨立的一步
343
358
 
344
359
  `dispatch_submit` 只把 job 排進佇列就停下,核准之前不會計費。兩種核准方式:在自己的終端機下
package/dist/cli-args.js CHANGED
@@ -104,6 +104,7 @@ export function parseArgs(argv, lang) {
104
104
  let doctorMode = false;
105
105
  let serveMode = false;
106
106
  let serveStop = false;
107
+ let serveRestart = false;
107
108
  let mcpMode = false;
108
109
  const numFlag = (name, apply) => {
109
110
  flagHandlers[name] = (v) => {
@@ -159,6 +160,9 @@ export function parseArgs(argv, lang) {
159
160
  else if (arg === "--stop") {
160
161
  serveStop = true;
161
162
  }
163
+ else if (arg === "--restart") {
164
+ serveRestart = true;
165
+ }
162
166
  else if (arg === "mcp") {
163
167
  mcpMode = true;
164
168
  }
@@ -190,16 +194,18 @@ export function parseArgs(argv, lang) {
190
194
  if (ticketDir !== undefined) {
191
195
  throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "--doctor", helpText), 2);
192
196
  }
193
- if (serveStop)
197
+ if (serveStop || serveRestart)
194
198
  throw new DispatchError(m(lang, "stopRequiresServe"), 2);
195
199
  return { mode: "doctor", options };
196
200
  }
197
201
  if (serveMode) {
198
202
  if (ticketDir !== undefined)
199
203
  throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "serve", helpText), 2);
200
- return { mode: "serve", options, stop: serveStop };
204
+ if (serveStop && serveRestart)
205
+ throw new DispatchError(m(lang, "stopRestartConflict"), 2);
206
+ return { mode: "serve", options, stop: serveStop, restart: serveRestart };
201
207
  }
202
- if (serveStop)
208
+ if (serveStop || serveRestart)
203
209
  throw new DispatchError(m(lang, "stopRequiresServe"), 2);
204
210
  if (mcpMode) {
205
211
  if (ticketDir !== undefined)
package/dist/cli.js CHANGED
@@ -34,7 +34,7 @@ import { m } from "./messages.js";
34
34
  import { defaultDbPath, openDb } from "./db.js";
35
35
  import { issueToken, listTokens, revokeToken } from "./api-token.js";
36
36
  import { addAllow, addSpoke, createTicket, formatTicket, hasResults, importTicketDirectory } from "./ticket-store.js";
37
- import { approveJob, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
37
+ import { approveJob, countQueueJobs, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
38
38
  import { serve, stopDaemon } from "./daemon.js";
39
39
  import { daemonAlive, daemonStatePath } from "./liveness.js";
40
40
  import { startStdioServer } from "./mcp/stdio.js";
@@ -611,6 +611,9 @@ async function main() {
611
611
  // 工單 key-db §B-3 #6:逐 provider 印出實際生效的是哪一層——doctorDb 開不起來時就沒有
612
612
  // DB 可查,keyStatusRows 留空陣列(buildApiKeyValue 對空陣列的降級輸出)。
613
613
  let keyStatusRows = [];
614
+ // A database failure must leave --doctor usable. Its JSON contract still has
615
+ // numeric queue fields, so the unavailable DB degrades to zero counts.
616
+ let queue = { pendingApproval: 0, approved: 0, running: 0 };
614
617
  // A(gaps #1):整份報表——DB、金鑰、型號白名單、daemon——都要來自**這次真的會用到的**
615
618
  // 那個 DB。先前這裡寫死 undefined/defaultDbPath(),於是 `--doctor --db <別的>` 印的
616
619
  // 全是預設 DB 的狀況,而使用者指定的那個檔連建都不會建。
@@ -620,49 +623,60 @@ async function main() {
620
623
  const providers = loadProviders(doctorDb, messageLang);
621
624
  providersResult = { ok: true, providers, models: listModels(doctorDb) };
622
625
  keyStatusRows = buildKeyStatusRows(doctorDb);
626
+ queue = countQueueJobs(doctorDb);
623
627
  }
624
628
  catch (err) {
625
629
  const reason = err instanceof DispatchError ? err.message : maskString(String(err));
626
630
  providersResult = { ok: false, reason };
627
631
  }
628
- const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath);
632
+ const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath, queue);
629
633
  console.log(parsed.options.json ? JSON.stringify(doctorData) : renderDoctorReport(messageLang, getCommandName(), doctorData));
630
634
  return;
631
635
  }
632
636
  if (parsed.mode === "serve") {
633
- if (parsed.stop) {
637
+ if (parsed.stop || parsed.restart) {
634
638
  const outcome = await stopDaemon(parsed.options.dbPath, {
635
639
  force: parsed.options.yes,
636
640
  confirmRunningJobs: async (count) => {
637
641
  console.log(m(messageLang, "daemonStopRunningWarning", count));
638
642
  return confirm(m(messageLang, "daemonStopConfirmPrompt"), process.stdout);
639
643
  },
644
+ confirmApprovedJobs: async (count) => {
645
+ console.log(m(messageLang, "daemonStopApprovedWarning", count));
646
+ return confirm(m(messageLang, "daemonStopApprovedConfirmPrompt"), process.stdout);
647
+ },
640
648
  });
641
649
  switch (outcome.status) {
642
650
  case "not_running":
643
651
  console.log(m(messageLang, "daemonStopNotRunning", outcome.reason));
652
+ if (parsed.restart)
653
+ break;
644
654
  break;
645
655
  case "cancelled":
646
656
  console.log(m(messageLang, "daemonStopCancelled"));
647
- break;
657
+ return;
648
658
  case "pid_not_dowafu":
649
659
  console.log(m(messageLang, "daemonStopPidNotDowafu", outcome.pid));
650
- break;
660
+ return;
651
661
  case "signal_failed":
652
662
  console.log(m(messageLang, "daemonStopSignalFailed", outcome.pid));
653
- break;
663
+ console.log(m(messageLang, "daemonStopApprovedCleaned", outcome.requeuedApprovedJobs));
664
+ return;
654
665
  case "stopped":
655
666
  console.log(m(messageLang, "daemonStopSucceeded", outcome.pid));
667
+ console.log(m(messageLang, "daemonStopApprovedCleaned", outcome.requeuedApprovedJobs));
656
668
  break;
657
669
  case "restarted":
658
670
  console.log(m(messageLang, "daemonStopRestarted", outcome.pid));
659
- break;
671
+ return;
660
672
  }
661
- return;
673
+ if (parsed.stop)
674
+ return;
662
675
  }
663
676
  try {
664
677
  const daemon = await serve(parsed.options.dbPath, undefined, parsed.options.httpPort, messageLang);
665
- console.log(m(messageLang, "daemonStarted"));
678
+ console.log(m(messageLang, parsed.restart ? "daemonRestartStarted" : "daemonStarted"));
679
+ console.log(m(messageLang, "daemonStartApprovedCleaned", daemon.requeuedApprovedJobs));
666
680
  console.log(m(messageLang, "httpServerStarted", daemon.httpPort));
667
681
  // B-1 #6:端點活著但沒發過 token,任何請求都進不來——失敗長得像成功,必須警告。
668
682
  if (!daemon.hasActiveToken)
package/dist/daemon.js CHANGED
@@ -2,7 +2,7 @@ import { execFileSync, spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { hasAnyActiveToken } from "./api-token.js";
4
4
  import { defaultDbPath, openDb } from "./db.js";
5
- import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs } from "./job.js";
5
+ import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs, requeueApprovedJobs } from "./job.js";
6
6
  import { DAEMON_STALE_MS, daemonAlive, daemonStatePath, writeHeartbeat } from "./liveness.js";
7
7
  import { createHttpServer, DEFAULT_HTTP_PORT } from "./mcp/http.js";
8
8
  import { maskString } from "./mask.js";
@@ -112,6 +112,11 @@ const DEFAULT_STOP_DAEMON_DEPS = {
112
112
  const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'running'").get();
113
113
  return row.count;
114
114
  },
115
+ countApprovedJobs: (dbPath) => {
116
+ const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'approved'").get();
117
+ return row.count;
118
+ },
119
+ requeueApprovedJobs: (dbPath) => requeueApprovedJobs(openDb(dbPath)),
115
120
  processCommand: (pid) => {
116
121
  try {
117
122
  return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -120,6 +125,15 @@ const DEFAULT_STOP_DAEMON_DEPS = {
120
125
  return null;
121
126
  }
122
127
  },
128
+ processExists: (pid) => {
129
+ try {
130
+ process.kill(pid, 0);
131
+ return true;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ },
123
137
  kill: (pid, signal) => process.kill(pid, signal),
124
138
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
125
139
  };
@@ -132,28 +146,33 @@ export async function stopDaemon(dbPath = defaultDbPath(), options = {}) {
132
146
  const initial = deps.liveness(statePath);
133
147
  if (!initial.alive)
134
148
  return { status: "not_running", reason: initial.reason };
135
- const runningJobs = deps.countRunningJobs(dbPath);
136
- if (runningJobs > 0 && !options.force) {
137
- const confirmed = await (options.confirmRunningJobs?.(runningJobs) ?? Promise.resolve(false));
138
- if (!confirmed)
139
- return { status: "cancelled", runningJobs };
140
- }
141
149
  if (!isDowafuProcess(deps.processCommand(initial.pid)))
142
150
  return { status: "pid_not_dowafu", pid: initial.pid };
151
+ const runningJobs = deps.countRunningJobs(dbPath);
152
+ const approvedJobs = deps.countApprovedJobs(dbPath);
153
+ const confirmedRunning = runningJobs === 0 || options.force || await (options.confirmRunningJobs?.(runningJobs) ?? Promise.resolve(false));
154
+ // Ask independently even when the stop confirmation was declined. A running
155
+ // worker warning and an old-approval cleanup warning describe different risks.
156
+ const confirmedApproved = approvedJobs === 0 || options.force || await (options.confirmApprovedJobs?.(approvedJobs) ?? Promise.resolve(false));
157
+ if (!confirmedRunning || !confirmedApproved)
158
+ return { status: "cancelled", runningJobs, approvedJobs };
159
+ const requeuedApprovedJobs = deps.requeueApprovedJobs(dbPath);
143
160
  try {
144
161
  deps.kill(initial.pid, "SIGTERM");
145
162
  }
146
163
  catch {
147
- return { status: "signal_failed", pid: initial.pid };
164
+ return { status: "signal_failed", pid: initial.pid, requeuedApprovedJobs };
148
165
  }
149
166
  for (let attempt = 0; attempt < STOP_CHECK_ATTEMPTS; attempt++) {
150
167
  await deps.wait(STOP_CHECK_INTERVAL_MS);
151
- if (!deps.liveness(statePath).alive) {
168
+ if (!deps.liveness(statePath).alive && !deps.processExists(initial.pid)) {
152
169
  await deps.wait(STOP_RESTART_WAIT_MS);
153
- return deps.liveness(statePath).alive ? { status: "restarted", pid: initial.pid } : { status: "stopped", pid: initial.pid };
170
+ return deps.liveness(statePath).alive
171
+ ? { status: "restarted", pid: initial.pid }
172
+ : { status: "stopped", pid: initial.pid, requeuedApprovedJobs };
154
173
  }
155
174
  }
156
- return { status: "signal_failed", pid: initial.pid };
175
+ return { status: "signal_failed", pid: initial.pid, requeuedApprovedJobs };
157
176
  }
158
177
  function bindHttp(db, dbPath, httpPort) {
159
178
  return new Promise((resolve, reject) => {
@@ -186,7 +205,9 @@ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.e
186
205
  const db = openDb(dbPath);
187
206
  return bindHttp(db, dbPath, httpPort).then((httpServer) => {
188
207
  let daemon;
208
+ let requeuedApprovedJobs;
189
209
  try {
210
+ requeuedApprovedJobs = requeueApprovedJobs(db);
190
211
  daemon = createDaemon(db, { dbPath, maxConcurrent, execute: (job) => runCliJob(job, dbPath, lang) });
191
212
  }
192
213
  catch (err) {
@@ -208,6 +229,7 @@ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.e
208
229
  },
209
230
  httpPort: boundPort,
210
231
  hasActiveToken: hasAnyActiveToken(db),
232
+ requeuedApprovedJobs,
211
233
  };
212
234
  });
213
235
  }
package/dist/doctor.js CHANGED
@@ -64,6 +64,9 @@ function buildDaemonValue(lang, daemon) {
64
64
  return m(lang, "doctorDaemonAlive", daemon.pid);
65
65
  return m(lang, "doctorDaemonMissing", daemon?.reason ?? "heartbeat_missing");
66
66
  }
67
+ function buildQueueValue(lang, queue) {
68
+ return m(lang, "doctorQueueValue", queue.pendingApproval, queue.approved, queue.running);
69
+ }
67
70
  function buildConfigDirValue(lang, configDir) {
68
71
  if (configDir.source === "unresolved")
69
72
  return m(lang, "doctorConfigDirUnresolved");
@@ -117,7 +120,7 @@ function buildLensValue(lang, lenses) {
117
120
  const closingNames = lenses.withClosing.length > 0 ? lenses.withClosing.join(joiner) : m(lang, "noneLabel");
118
121
  return m(lang, "doctorLensFoundValue", lenses.dirPath, lenses.withClosing.length + lenses.withoutClosing.length, lenses.withClosing.length, closingNames, noClosingSuffix);
119
122
  }
120
- export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
123
+ export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }) {
121
124
  const dispatchHome = resolveDispatchHome(env, homedir);
122
125
  const configDir = dispatchHome === null
123
126
  ? { source: "unresolved" }
@@ -149,6 +152,7 @@ export function buildDoctorReportData(cmd, providers, env = process.env, homedir
149
152
  apiKeys: keyStatusRows.map((row) => ({ ...row })),
150
153
  models,
151
154
  daemon: daemon ?? { alive: false, reason: "heartbeat_missing" },
155
+ queue: { ...queue },
152
156
  lenses: buildLensData(probe, cwd),
153
157
  };
154
158
  }
@@ -163,12 +167,13 @@ export function renderDoctorReport(lang, cmd, data) {
163
167
  m(lang, "doctorApiKeyLine", buildApiKeyValue(lang, data.apiKeys)),
164
168
  m(lang, "doctorModelListLine", buildModelListValue(lang, data.models)),
165
169
  m(lang, "doctorDaemonLine", buildDaemonValue(lang, data.daemon)),
170
+ m(lang, "doctorQueueLine", buildQueueValue(lang, data.queue)),
166
171
  m(lang, "doctorLensLine", buildLensValue(lang, data.lenses)),
167
172
  "",
168
173
  m(lang, "doctorFooter"),
169
174
  ];
170
175
  return lines.join("\n");
171
176
  }
172
- export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
173
- return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath));
177
+ export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }) {
178
+ return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath, queue));
174
179
  }
package/dist/job.js CHANGED
@@ -21,6 +21,26 @@ export function findJobsByIdPrefix(db, prefix) {
21
21
  export function listPendingApprovalJobs(db) {
22
22
  return db.prepare("SELECT * FROM jobs WHERE status = 'pending_approval' ORDER BY created_at, id").all();
23
23
  }
24
+ // Keep this to one indexed aggregate query rather than loading jobs and counting
25
+ // in JavaScript. `idx_jobs_status` covers both the WHERE predicate and status.
26
+ export function countQueueJobs(db) {
27
+ const rows = db
28
+ .prepare(`SELECT status, COUNT(*) AS count
29
+ FROM jobs
30
+ WHERE status IN ('pending_approval', 'approved', 'running')
31
+ GROUP BY status`)
32
+ .all();
33
+ const counts = { pendingApproval: 0, approved: 0, running: 0 };
34
+ for (const row of rows) {
35
+ if (row.status === "pending_approval")
36
+ counts.pendingApproval = Number(row.count);
37
+ else if (row.status === "approved")
38
+ counts.approved = Number(row.count);
39
+ else
40
+ counts.running = Number(row.count);
41
+ }
42
+ return counts;
43
+ }
24
44
  export function submitJob(db, ticket, id = randomUUID(), lang = null) {
25
45
  if (!db.prepare("SELECT 1 FROM tickets WHERE name = ?").get(ticket))
26
46
  throw new Error(`ticket does not exist: ${ticket}`);
@@ -51,6 +71,15 @@ export function approveJob(db, id) {
51
71
  WHERE id = ? AND status = 'pending_approval'
52
72
  RETURNING *`, now(), id);
53
73
  }
74
+ // A daemon owns only approvals made after it starts. Before a new daemon begins
75
+ // claiming work, return any older approvals to the explicit human-approval gate.
76
+ // Keep this as a single guarded UPDATE: concurrent approval is allowed to remain
77
+ // approved for the new daemon, and no other lifecycle state is touched.
78
+ export function requeueApprovedJobs(db) {
79
+ return Number(db.prepare(`UPDATE jobs
80
+ SET status = 'pending_approval', approved_at = NULL
81
+ WHERE status = 'approved'`).run().changes);
82
+ }
54
83
  export function rejectJob(db, id) {
55
84
  return one(db, `UPDATE jobs
56
85
  SET status = 'rejected', finished_at = ?
package/dist/messages.js CHANGED
@@ -35,7 +35,8 @@ const MESSAGES = {
35
35
  ${cmd} token ... 入站 HTTP 憑證的發/列/撤銷
36
36
  ${cmd} approve [jobId] 核准佇列中等待的派工
37
37
  ${cmd} serve 啟動常駐 daemon(含 MCP 的 HTTP binding)
38
- ${cmd} serve --stop [--yes] 停止常駐 daemon;有執行中的 job 時會先確認
38
+ ${cmd} serve --stop [--yes] 停止常駐 daemon;有執行中的 job 或舊核准 job 時會先確認
39
+ ${cmd} serve --restart [--yes] 停止後重新啟動 daemon,會走兩端清理
39
40
  ${cmd} mcp 以 stdio 提供 MCP server
40
41
 
41
42
  --lang <en|zh-tw> CLI 輸出與 spoke prompt 的語言,預設 en
@@ -107,7 +108,8 @@ const MESSAGES = {
107
108
  mcpJobPrefixAmbiguous: (prefix, count) => `前綴 ${prefix} 命中 ${count} 個 job,請改用完整 id:`,
108
109
  jobNotApprovable: (jobId, status) => `job ${jobId} 目前是 ${status},不能核准`,
109
110
  jobApproved: (jobId) => `已核准 job:${jobId}`,
110
- daemonStarted: () => "daemon 已啟動。收到 SIGINT 時會乾淨退出;進行中的 job 下次啟動會被回收。",
111
+ daemonStarted: () => "daemon 已啟動。收到 SIGINT 時會乾淨退出。",
112
+ daemonStartApprovedCleaned: (count) => `啟動前已將 ${count} 張舊的已核准 job 退回待核准。`,
111
113
  daemonStartFailed: (detail) => `daemon 無法啟動:${detail}`,
112
114
  daemonWorkerSpawnFailed: (detail) => `worker 無法啟動:${detail}`,
113
115
  daemonWorkerExited: (exitCode) => `worker 以 exit code ${exitCode} 結束`,
@@ -115,18 +117,23 @@ const MESSAGES = {
115
117
  daemonWorkerTerminated: (signal) => `worker 被訊號 ${signal} 終止`,
116
118
  daemonWorkerTerminatedDetail: (signal, detail) => `worker 被訊號 ${signal} 終止:${detail}`,
117
119
  stopRequiresServe: () => "--stop 只能與 serve 一起使用。",
120
+ stopRestartConflict: () => "--stop 與 --restart 不能同時使用。",
118
121
  daemonStopRunningWarning: (count) => `目前有 ${count} 張 job 正在執行;停止 daemon 不會停止正在跑的 spoke,它們會繼續跑完,之後結果與 API 成本仍會被認列。`,
119
122
  daemonStopConfirmPrompt: () => "確定要停止 daemon 嗎?[y/N] ",
123
+ daemonStopApprovedWarning: (count) => `目前有 ${count} 張已核准 job 尚未執行;停止前會退回待核准,避免下一支 daemon 未經本輪核准就執行。`,
124
+ daemonStopApprovedConfirmPrompt: () => "確定要清理這些已核准 job 嗎?[y/N] ",
120
125
  daemonStopCancelled: () => "已取消,daemon 仍在執行。",
121
126
  daemonStopNotRunning: (reason) => `daemon 未在執行(${reason})。`,
122
127
  daemonStopPidNotDowafu: (pid) => `pid ${pid} 的行程不是 dowafu,為避免誤殺未送出訊號。`,
123
- daemonStopSignalFailed: (pid) => `已向 pid ${pid} 送出停止訊號,但它沒有停止。`,
128
+ daemonStopSignalFailed: (pid) => `已向 pid ${pid} 送出 SIGTERM,但它沒有停止。請手動檢查:ps -p ${pid} -o pid,ppid,command=;確認後可再送 SIGTERM。不要用 SIGKILL,worker 子行程可能仍會繼續寫入。`,
124
129
  daemonStopSucceeded: (pid) => `daemon(pid ${pid})已停止。`,
125
130
  daemonStopRestarted: (pid) => `daemon(原 pid ${pid})停止後又恢復運行;看起來有 OS 層服務管理在重拉它。要真的停下來請用 launchctl 或 systemctl。`,
131
+ daemonStopApprovedCleaned: (count) => `已將 ${count} 張已核准 job 退回待核准。`,
132
+ daemonRestartStarted: () => "daemon 已重新啟動。",
126
133
  doctorDaemonLine: (value) => ` daemon ${value}`,
127
134
  doctorDaemonAlive: (pid) => `運行中(pid ${pid})`,
128
135
  doctorDaemonMissing: (reason) => `未運行(${reason})`,
129
- daemonOfflineWarning: (reason) => `⚠ worker daemon 未運行(${reason});job 已排入但不會執行。請先執行 dowafu serve。`,
136
+ daemonOfflineWarning: (reason) => `⚠ worker daemon 未運行(${reason});job 已排入但不會執行。請先起 dowafu serve 再核准——daemon 沒在跑時給的核准,會在它啟動時退回待核准。`,
130
137
  mcpTicketsEmpty: () => "沒有可派的 DB 工單。",
131
138
  mcpTicketsTitle: () => "可派工單:",
132
139
  mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}:${spokeCount} 個 spoke`,
@@ -346,6 +353,8 @@ const MESSAGES = {
346
353
  doctorModelListValue: (enabled, total, mostExpensive) => `啟用 ${enabled}/${total};最貴啟用型號 ${mostExpensive};來源 SQLite DB`,
347
354
  doctorModelListLoadFailedValue: (reason) => `無法載入:${reason}`,
348
355
  doctorLensLine: (value) => ` lens 定義 ${value}`,
356
+ doctorQueueLine: (value) => ` 佇列 ${value}`,
357
+ doctorQueueValue: (pendingApproval, approved, running) => `待核准 ${pendingApproval};已核准 ${approved};執行中 ${running}`,
349
358
  doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} 找到 ${total} 個定義檔,其中 ${closingCount} 個具備固定收尾句:\n${" ".repeat(14)}${closingNames}${noClosingSuffix}`,
350
359
  doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(14)}(另有 ${count} 個不是 lens:${names})`,
351
360
  doctorLensDirMissingValue: (dirPath) => `目錄不存在:${dirPath}`,
@@ -365,7 +374,8 @@ Subcommands (each has its own usage message):
365
374
  ${cmd} token ... Issue, list and revoke inbound HTTP tokens
366
375
  ${cmd} approve [jobId] Approve a dispatch waiting in the queue
367
376
  ${cmd} serve Run the daemon (includes the MCP HTTP binding)
368
- ${cmd} serve --stop [--yes] Stop the daemon; asks first if jobs are running
377
+ ${cmd} serve --stop [--yes] Stop the daemon; asks first about running or old approved jobs
378
+ ${cmd} serve --restart [--yes] Stop then start the daemon, running both cleanup phases
369
379
  ${cmd} mcp Serve MCP over stdio
370
380
 
371
381
  --lang <en|zh-tw> Language for CLI output and spoke prompts, default en
@@ -435,7 +445,8 @@ Subcommands (each has its own usage message):
435
445
  mcpJobPrefixAmbiguous: (prefix, count) => `Prefix ${prefix} matches ${count} jobs; use the full id:`,
436
446
  jobNotApprovable: (jobId, status) => `Job ${jobId} is ${status} and cannot be approved`,
437
447
  jobApproved: (jobId) => `Approved job: ${jobId}`,
438
- daemonStarted: () => "Daemon started. SIGINT exits cleanly; running jobs are reaped on the next start.",
448
+ daemonStarted: () => "Daemon started. SIGINT exits cleanly.",
449
+ daemonStartApprovedCleaned: (count) => `${count} older approved job(s) returned to pending approval before startup.`,
439
450
  daemonStartFailed: (detail) => `Could not start daemon: ${detail}`,
440
451
  daemonWorkerSpawnFailed: (detail) => `Could not start worker: ${detail}`,
441
452
  daemonWorkerExited: (exitCode) => `worker exited ${exitCode}`,
@@ -443,18 +454,23 @@ Subcommands (each has its own usage message):
443
454
  daemonWorkerTerminated: (signal) => `worker terminated by ${signal}`,
444
455
  daemonWorkerTerminatedDetail: (signal, detail) => `worker terminated by ${signal}: ${detail}`,
445
456
  stopRequiresServe: () => "--stop can only be used with serve.",
457
+ stopRestartConflict: () => "--stop and --restart cannot be used together.",
446
458
  daemonStopRunningWarning: (count) => `${count} job(s) are running; stopping the daemon does not stop their active spokes, which continue to completion and will still have their results and API costs recorded.`,
447
459
  daemonStopConfirmPrompt: () => "Stop the daemon? [y/N] ",
460
+ daemonStopApprovedWarning: (count) => `${count} approved job(s) have not started; stopping returns them to pending approval so the next daemon cannot run them without a new approval.`,
461
+ daemonStopApprovedConfirmPrompt: () => "Clear these approved jobs? [y/N] ",
448
462
  daemonStopCancelled: () => "Cancelled; the daemon is still running.",
449
463
  daemonStopNotRunning: (reason) => `Daemon is not running (${reason}).`,
450
464
  daemonStopPidNotDowafu: (pid) => `pid ${pid} is not a dowafu process; no signal was sent to avoid killing the wrong process.`,
451
- daemonStopSignalFailed: (pid) => `A stop signal was sent to pid ${pid}, but it did not stop.`,
465
+ daemonStopSignalFailed: (pid) => `SIGTERM was sent to pid ${pid}, but it did not stop. Inspect it manually: ps -p ${pid} -o pid,ppid,command=; after confirming, you can send SIGTERM again. Do not use SIGKILL: worker children may still write results.`,
452
466
  daemonStopSucceeded: (pid) => `Daemon (pid ${pid}) stopped.`,
453
467
  daemonStopRestarted: (pid) => `Daemon (previous pid ${pid}) came back after stopping; an OS-level service manager appears to be restarting it. Use launchctl or systemctl to stop it permanently.`,
468
+ daemonStopApprovedCleaned: (count) => `${count} approved job(s) returned to pending approval.`,
469
+ daemonRestartStarted: () => "Daemon restarted.",
454
470
  doctorDaemonLine: (value) => ` Daemon ${value}`,
455
471
  doctorDaemonAlive: (pid) => `running (pid ${pid})`,
456
472
  doctorDaemonMissing: (reason) => `not running (${reason})`,
457
- daemonOfflineWarning: (reason) => `⚠ The worker daemon is not running (${reason}); the job is queued but will not execute. Start it with dowafu serve.`,
473
+ daemonOfflineWarning: (reason) => `⚠ The worker daemon is not running (${reason}); the job is queued but will not execute. Start dowafu serve before approving — approvals given while no daemon is running are returned to pending approval when it starts.`,
458
474
  mcpTicketsEmpty: () => "No dispatchable DB tickets.",
459
475
  mcpTicketsTitle: () => "Dispatchable tickets:",
460
476
  mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}: ${spokeCount} spoke(s)`,
@@ -677,6 +693,8 @@ Subcommands (each has its own usage message):
677
693
  doctorModelListValue: (enabled, total, mostExpensive) => `enabled ${enabled}/${total}; most expensive enabled ${mostExpensive}; source SQLite DB`,
678
694
  doctorModelListLoadFailedValue: (reason) => `failed to load: ${reason}`,
679
695
  doctorLensLine: (value) => ` Lens defs ${value}`,
696
+ doctorQueueLine: (value) => ` Queue ${value}`,
697
+ doctorQueueValue: (pendingApproval, approved, running) => `pending approval ${pendingApproval}; approved ${approved}; running ${running}`,
680
698
  doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} holds ${total} definition file(s), ${closingCount} with a fixed closing line:\n${" ".repeat(15)}${closingNames}${noClosingSuffix}`,
681
699
  doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(15)}(${count} other agent file(s), not lenses: ${names})`,
682
700
  doctorLensDirMissingValue: (dirPath) => `directory not found: ${dirPath}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowafu",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Send a section of your design doc to external LLMs for review. They read only the files you whitelist, and nothing is billed until you confirm.",
5
5
  "keywords": [
6
6
  "llm",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "type": "module",
27
27
  "engines": {
28
- "node": ">=20"
28
+ "node": ">=22.13.0"
29
29
  },
30
30
  "bin": {
31
31
  "dowafu": "dist/cli.js"