duaer-spec 0.15.0 → 0.16.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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "featureDir": ".duaer/specs/055-release-015",
3
- "branch": "feat/release-015",
4
- "worktree": ".worktree/feat-release-015",
5
- "status": "active"
2
+ "featureDir": ".duaer/specs/060-release-016",
3
+ "branch": "feat/release-016",
4
+ "worktree": ".worktree/feat-release-016",
5
+ "status": "accepted"
6
6
  }
@@ -41,8 +41,13 @@ Covers without a paid LLM:
41
41
  3. `/api/validate` pass and `/api/validate/fix` via an in-process mock
42
42
  OpenAI-compatible server
43
43
 
44
- Does **not** cover: real model quality, Terminal CLI launch, or full revise
45
- dispatch. Those stay manual E2E catalog rows or future suites.
44
+ Also run `npm test` (includes revise-reliability contract tests: Brief
45
+ snapshot/rollback helpers + source markers for `PREEMPT_FAILED` / longer
46
+ client timeout).
47
+
48
+ Does **not** cover: real model quality, live Terminal CLI launch against a
49
+ real Cursor/Claude session. Those stay manual E2E catalog rows (E2E-055) or
50
+ future suites.
46
51
 
47
52
  ## Opt-in (still need an explicit ask or Spec note)
48
53
 
@@ -0,0 +1,13 @@
1
+ {
2
+ "status": "accepted",
3
+ "summary": "Revise dispatch: honest preempt, Brief rollback on launch fail, structured retryable errors, terminal status line, longer client wait.",
4
+ "verification": {
5
+ "levels": ["L0", "L3"],
6
+ "commands": [
7
+ "node --check bin/duaer-live.mjs",
8
+ "npm run test:live",
9
+ "node --test test/revise-reliability.test.mjs"
10
+ ],
11
+ "notes": "E2E-055 manual for live Terminal preempt path."
12
+ }
13
+ }
@@ -0,0 +1,44 @@
1
+ # Feature Specification: Revise round reliability
2
+
3
+ **Feature Branch**: `feat/revise-reliability`
4
+
5
+ **Created**: 2026-09-17
6
+
7
+ **Status**: Active
8
+
9
+ ## Goal
10
+
11
+ Revise dispatch is **observable**, **retryable**, and failures are **readable**:
12
+ confirm → Terminal enqueue must not report success when preempt failed; Brief
13
+ mutations must not stick after a failed launch; client timeout matches server
14
+ budgets; status exposes Terminal busy/queue; UI keeps dispatch unlocked when
15
+ retryable.
16
+
17
+ ## In scope
18
+
19
+ - Honest preempt outcome (no false preempted / false HTTP 200)
20
+ - Rollback Brief writes if launch/enqueue fails
21
+ - Structured revise errors (`code`, `retryable`) + UI copy
22
+ - Align client `/api/revise` abort with recreate/init budgets (~3 min)
23
+ - `/api/status` includes `terminal: { busy, queueDepth, runnerHealthy }`
24
+ - Minimal status/hint line; extend L3 smoke for error shape / rollback unit
25
+
26
+ ## Out of scope
27
+
28
+ - Desk layout / column redesign
29
+ - Full Playwright Terminal automation
30
+ - Paid live LLM quality
31
+
32
+ ## Acceptance
33
+
34
+ 1. When preempt cannot clear leftover agents, `/api/revise` fails with
35
+ `PREEMPT_FAILED` and does **not** lock the revise card
36
+ 2. Failed launch does not leave a new Revision block in Brief (rollback)
37
+ 3. Client wait ≥ worst-case recreate path; timeout message mentions retry
38
+ 4. Status poll shows terminal busy/queue when revising
39
+ 5. `npm run test:live` still passes; new smoke/unit covers rollback or error codes
40
+
41
+ ## Assumptions
42
+
43
+ - Worktree: `.worktree/feat-revise-reliability`
44
+ - Brief: `.duaer/specs/056-revise-reliability/`
@@ -0,0 +1,10 @@
1
+ # Tasks: Revise round reliability
2
+
3
+ **Brief**: `.duaer/specs/056-revise-reliability/`
4
+
5
+ ## Checklist
6
+
7
+ - [x] Honest preempt + PREEMPT_FAILED; Brief rollback on launch fail
8
+ - [x] Structured errors on `/api/revise`; status.terminal
9
+ - [x] UI: longer timeout, retryable unlock, terminal line, i18n
10
+ - [x] L3 / unit smoke; docs; accept; merge develop
@@ -0,0 +1,9 @@
1
+ {
2
+ "status": "accepted",
3
+ "summary": "On delivery accepted, progress shows N/N and reconciles unchecked tasks.md boxes.",
4
+ "verification": {
5
+ "levels": ["L0", "L3"],
6
+ "commands": ["npm test"],
7
+ "notes": "E2E-056."
8
+ }
9
+ }
@@ -0,0 +1,42 @@
1
+ # Feature Specification: Progress sync when delivery accepted
2
+
3
+ **Feature Branch**: `fix/progress-accepted`
4
+
5
+ **Created**: 2026-09-17
6
+
7
+ **Status**: Active
8
+
9
+ ## Goal
10
+
11
+ Task progress must not show `0/N · delivery accepted · 工单完成`. When
12
+ `delivery.json` is `accepted`, the desk treats tasks as complete (done ===
13
+ total) and reconciles unchecked boxes in `tasks.md` when safe.
14
+
15
+ ## Problem
16
+
17
+ `dispatchStatus` only rewrites `progress.current` on accept; `done` stays at
18
+ the unchecked count. Agents often stamp `delivery.json` without flipping every
19
+ `- [ ]` to `- [x]`, so 任务跟进 shows a contradictory `0/3 · …工单完成`.
20
+
21
+ ## In scope
22
+
23
+ - Normalize progress when accepted: `done = total`, all task rows `done: true`
24
+ - Reconcile `tasks.md` unchecked boxes to `[x]` once when accepted (idempotent)
25
+ - Unit/smoke coverage; short README/CHANGELOG/E2E note
26
+
27
+ ## Out of scope
28
+
29
+ - Changing how agents are prompted (except if a one-line reminder helps)
30
+ - Progress column layout redesign
31
+
32
+ ## Acceptance
33
+
34
+ 1. Accepted delivery never surfaces `done < total` with an “工单完成 /
35
+ delivery accepted” current line
36
+ 2. Unchecked tasks in Brief are marked `[x]` after status observes accept
37
+ 3. `npm test` / `npm run test:live` pass
38
+
39
+ ## Assumptions
40
+
41
+ - Worktree: `.worktree/fix-progress-accepted`
42
+ - Brief: `.duaer/specs/057-progress-accepted/`
@@ -0,0 +1,8 @@
1
+ # Tasks: Progress sync when delivery accepted
2
+
3
+ **Brief**: `.duaer/specs/057-progress-accepted/`
4
+
5
+ ## Checklist
6
+
7
+ - [x] Normalize progress on accept; reconcile tasks.md
8
+ - [x] Tests + docs; accept; merge develop
@@ -0,0 +1,13 @@
1
+ {
2
+ "status": "accepted",
3
+ "acceptedAt": "2026-09-17T09:50:00.000Z",
4
+ "summary": "Revise-round progress scopes to Revision N / R{n}-* tasks; lagging accepted + busy Terminal no longer freezes on 工单完成.",
5
+ "verification": {
6
+ "levels": ["L0", "L1", "L3"],
7
+ "commands": [
8
+ "node --check bin/duaer-live.mjs",
9
+ "npm test"
10
+ ],
11
+ "result": "pass (14 tests)"
12
+ }
13
+ }
@@ -0,0 +1,48 @@
1
+ # Feature Specification: Track revise-round task progress
2
+
3
+ **Feature Branch**: `fix/revise-progress-track`
4
+
5
+ **Created**: 2026-09-17
6
+
7
+ **Status**: Accepted
8
+
9
+ ## Goal
10
+
11
+ After **Confirm revise and dispatch**, 任务跟进 shows **this revision’s**
12
+ checklist and updates as work starts — not the prior dispatch’s completed
13
+ `T001…` pile, and not a frozen “delivery accepted · 工单完成” while Terminal
14
+ is already running the revise.
15
+
16
+ ## Problem
17
+
18
+ Progress parses the whole `tasks.md`. After revise, old tasks stay `[x]` so
19
+ the bar looks finished (or accepted-normalized to N/N) while the new `R{n}-*`
20
+ round has started. Live `revisionCount` can lag; status may still say
21
+ `accepted`, so the wrong run-block is updated and revise work looks untracked.
22
+
23
+ ## In scope
24
+
25
+ - Scope progress to dispatch (pre-Revision section) vs active `Revision N`
26
+ - Infer active revision from job / delivery / tasks.md headers and `R{n}` ids
27
+ - While revising (or Terminal busy with open `R{n}` tasks), do not force
28
+ accept-complete progress
29
+ - Persist inferred `revisionCount` onto the live job when missing
30
+ - Tests + CHANGELOG / E2E note
31
+
32
+ ## Out of scope
33
+
34
+ - Layout / column redesign
35
+ - Changing revise launch / preempt behavior
36
+
37
+ ## Acceptance
38
+
39
+ 1. After Confirm revise, progress shows only this revision’s tasks (e.g. `0/3`
40
+ then rising), under the Revision N run block
41
+ 2. While revise is active, status is not stuck on `delivery accepted · 工单完成`
42
+ 3. When revise delivery accepts, that revision shows `N/N · …完成`
43
+ 4. `npm test` passes
44
+
45
+ ## Assumptions
46
+
47
+ - Worktree: `.worktree/fix-revise-progress-track`
48
+ - Brief: `.duaer/specs/058-revise-progress-track/`
@@ -0,0 +1,9 @@
1
+ # Tasks: Track revise-round task progress
2
+
3
+ **Brief**: `.duaer/specs/058-revise-progress-track/`
4
+
5
+ ## Checklist
6
+
7
+ - [x] Scope parseTasksProgress by revision; infer active revision
8
+ - [x] Revising / busy Terminal skips accept-complete normalize
9
+ - [x] Tests + docs; merge develop
@@ -0,0 +1,13 @@
1
+ {
2
+ "status": "accepted",
3
+ "acceptedAt": "2026-09-17T10:05:00.000Z",
4
+ "summary": "priorAccepted revise preempts leftovers before enqueue so Confirm revise no longer returns PREEMPT_FAILED while the revise Agent is already running.",
5
+ "verification": {
6
+ "levels": ["L0", "L1", "L3"],
7
+ "commands": [
8
+ "node --check bin/duaer-live.mjs",
9
+ "npm test"
10
+ ],
11
+ "result": "pass (14 tests)"
12
+ }
13
+ }
@@ -0,0 +1,47 @@
1
+ # Feature Specification: Honest revise preempt (enqueue after clear)
2
+
3
+ **Feature Branch**: `fix/revise-preempt-order`
4
+
5
+ **Created**: 2026-09-17
6
+
7
+ **Status**: Accepted
8
+
9
+ ## Goal
10
+
11
+ After Confirm revise, either the revise Agent is launched and the desk
12
+ reports success, or preempt truly failed and nothing was started — never
13
+ “Could not preempt…” while Terminal is already running the revise job.
14
+
15
+ ## Problem
16
+
17
+ `launchInTerminal` enqueues the revise job **before** preempt. Within the
18
+ post-preempt wait, the runner starts the new Agent; status then sees
19
+ `busy && agentsLeft > 0` and throws `PREEMPT_FAILED`. The API rolls back
20
+ Brief while the queued revise Agent keeps working — UI error, work already
21
+ underway; retries kill that Agent.
22
+
23
+ ## In scope
24
+
25
+ - Preempt leftover agents **before** enqueueing the revise Terminal job
26
+ - Fail `PREEMPT_FAILED` only when prior agents remain (nothing queued yet)
27
+ - Do not treat the newly started revise Agent as a preempt failure
28
+ - Tests + CHANGELOG / E2E note
29
+
30
+ ## Out of scope
31
+
32
+ - Progress scoping (058)
33
+ - Changing when priorAccepted revise preempts vs waits
34
+
35
+ ## Acceptance
36
+
37
+ 1. priorAccepted revise: preempt clears leftovers, then enqueue; HTTP 200
38
+ 2. If leftover agents cannot be cleared, `PREEMPT_FAILED` and **no** new
39
+ Terminal job for that attempt
40
+ 3. Desk does not show preempt failure while revise Agent is already running
41
+ from that same Confirm tap
42
+ 4. `npm test` passes
43
+
44
+ ## Assumptions
45
+
46
+ - Worktree: `.worktree/fix-revise-preempt-order`
47
+ - Brief: `.duaer/specs/059-revise-preempt-order/`
@@ -0,0 +1,8 @@
1
+ # Tasks: Honest revise preempt (enqueue after clear)
2
+
3
+ **Brief**: `.duaer/specs/059-revise-preempt-order/`
4
+
5
+ ## Checklist
6
+
7
+ - [x] Preempt before enqueue; fail only on leftover prior agents
8
+ - [x] Tests + docs; merge develop
@@ -0,0 +1,10 @@
1
+ {
2
+ "status": "accepted",
3
+ "acceptedAt": "2026-09-18T00:00:00.000Z",
4
+ "summary": "Release 0.16.0: honest revise preempt, revise progress scope, accept progress sync.",
5
+ "verification": {
6
+ "levels": ["L0", "L1", "L3"],
7
+ "commands": ["npm test"],
8
+ "result": "pass (14 tests)"
9
+ }
10
+ }
@@ -0,0 +1,24 @@
1
+ # Feature Specification: Release 0.16.0
2
+
3
+ **Feature Branch**: `feat/release-016`
4
+
5
+ **Created**: 2026-09-18
6
+
7
+ **Status**: Accepted
8
+
9
+ ## Goal
10
+
11
+ Ship **0.16.0** to `main` + npm: revise reliability (honest preempt order),
12
+ revise-round progress tracking, and accept progress sync.
13
+
14
+ ## Acceptance
15
+
16
+ 1. `package.json` version `0.16.0`; CHANGELOG dated section; Unreleased cleared
17
+ 2. `main` tagged `v0.16.0`; GitHub Release; npm `latest` is `0.16.0`
18
+ 3. `npm test` passes before promote
19
+
20
+ ## Assumptions
21
+
22
+ - Worktree: `.worktree/feat-release-016`
23
+ - Brief: `.duaer/specs/060-release-016/`
24
+ - User ask「发版」authorizes push + promote `develop` → `main`
@@ -0,0 +1,4 @@
1
+ # Tasks: Release 0.16.0
2
+
3
+ - [x] Bump 0.16.0 + CHANGELOG + light README notes
4
+ - [x] Verify `npm test`; merge develop; promote main; tag + npm
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.16.0 — 2026-09-18
6
+
7
+ ### Honest revise preempt
8
+
9
+ - priorAccepted revise preempts leftover Agents **before** enqueueing the new
10
+ Terminal job (no false `PREEMPT_FAILED` while the revise Agent already runs)
11
+ - `PREEMPT_FAILED` only when leftovers remain — nothing queued for that attempt
12
+
13
+ ### Revise progress tracking
14
+
15
+ - After Confirm revise, task progress scopes to the active `Revision N` checklist
16
+ (not the prior dispatch’s completed `T*` pile)
17
+ - While revise is active (or Terminal is busy on open `R{n}` work), status does
18
+ not freeze on the accepted “job complete” label
19
+ - Live `revisionCount` is inferred from Brief/`tasks.md` when the job stamp lags
20
+
21
+ ### Progress sync when delivery accepted
22
+
23
+ - When `delivery.json` is `accepted`, task progress shows `N/N` (not `0/N` with
24
+ an accepted label)
25
+ - Unchecked `tasks.md` boxes are reconciled to `[x]` on status poll after accept
26
+
27
+ ### Revise round reliability
28
+
29
+ - Honest Terminal preempt: no false `preempted` / HTTP 200 when agents still run (`PREEMPT_FAILED`, retryable)
30
+ - Rollback Brief `spec.md` / `tasks.md` / `delivery.json` if revise launch fails
31
+ - `/api/status` includes `terminal: { busy, queueDepth, runnerHealthy }`; desk shows the line
32
+ - Client revise wait raised to ~3 minutes; structured error codes + keep Confirm revise unlocked on failure
33
+ - Stuck revising (busy Terminal, no task progress) resurfaces **Revise again**
34
+
3
35
  ## 0.15.0 — 2026-09-17
4
36
 
5
37
  ### Live desk L3 smoke
package/README.md CHANGED
@@ -77,7 +77,7 @@ mock LLM — no paid API).
77
77
  3. Choose a **CLI** digital employee: **Cursor Agent** or **Claude Code** (install Cursor CLI if needed: `curl https://cursor.com/install -fsS | bash`)
78
78
  4. Edit the start command (must begin with `Duaer`) → dispatch creates `.worktree/feat-*` and opens **Terminal** to run the CLI
79
79
  5. Watch progress in the right column (`tasks.md`); when `delivery.json` is `accepted`, open **View product** (`preview.url` or auto-detected `index.html`)
80
- 6. If the result is not right: click **Continue improving (left chat)**, clarify why and what to change, confirm the revise card (same validate gate), then the desk relaunches with `agent --continue` / `claude --continue` on the same worktree
80
+ 6. If the result is not right: click **Continue improving (left chat)**, clarify why and what to change, confirm the revise card (same validate gate), then the desk relaunches on the same worktree (preempt leftover Agents before enqueueing revise). Progress tracks this revision’s `R{n}-*` tasks. Failed launch rolls back the Brief Revision and keeps **Confirm revise** available; status shows Terminal busy/queue.
81
81
  7. If the job needs hosting / a public URL, deploy with **GitHub CLI (`gh`) + Actions** (Pages template under `.duaer/templates/deploy-github-pages.yml`; see [`docs/agent/deploy-github.md`](docs/agent/deploy-github.md))
82
82
 
83
83
  Worktree id ≠ Brief folder name — use `.worktree/feat-<name>/`, Brief under `.duaer/specs/<nnn-slug>/` inside that worktree.
package/README.zh-CN.md CHANGED
@@ -75,7 +75,7 @@ duaer live
75
75
  3. 只选 **CLI 数字员工**:**Cursor Agent** 或 **Claude Code**(未装 Cursor 时页面会提示:`curl https://cursor.com/install -fsS | bash`)
76
76
  4. 编辑「启动命令」(须以 `Duaer` 开头)→ 派工建 `.worktree/feat-*`,并打开 **Terminal** 跑对应 CLI
77
77
  5. 右侧进度列轮询 `tasks.md`;`delivery.json` 为 `accepted` 后出现 **查看成品**(`preview.url`,或自动发现 `index.html`)
78
- 6. 成品不满意:点 **继续改进(左侧对话)**,说清原因与期望,确认改进卡(同样须校验通过)后,用 Terminal `agent --continue` / `claude --continue` 续派(`POST /api/revise`)
78
+ 6. 成品不满意:点 **继续改进(左侧对话)**,说清原因与期望,确认改进卡(同样须校验通过)后续派(先清残留 Agent 再入队)。进度跟本轮 `R{n}-*` 任务。启动失败会回滚本轮 Brief Revision,并可再点「再派一版」;状态行显示 Terminal 忙闲/队列。
79
79
  7. 若需求需要上线 / 公网访问:默认用 **GitHub CLI(`gh`)+ Actions** 自动化部署(静态站可参考 `.duaer/templates/deploy-github-pages.yml`;说明见英文 [`docs/agent/deploy-github.md`](docs/agent/deploy-github.md))
80
80
 
81
81
  ### 文档语言
@@ -1908,12 +1908,51 @@ function preemptBusyTerminalJob(queueDir, { worktreePath = null, logPath = null
1908
1908
  }
1909
1909
  return {
1910
1910
  preempted: killed.length > 0,
1911
- reason: killed.join(",") || "signal-attempted",
1911
+ reason: killed.length > 0 ? killed.join(",") : "signal-attempted",
1912
1912
  killed,
1913
1913
  agentPids: agentTargets.map((t) => t.pid),
1914
1914
  };
1915
1915
  }
1916
1916
 
1917
+ class LaunchGateError extends Error {
1918
+ constructor(message, { code = "LAUNCH_FAILED", retryable = true, detail = null } = {}) {
1919
+ super(message);
1920
+ this.name = "LaunchGateError";
1921
+ this.code = code;
1922
+ this.retryable = Boolean(retryable);
1923
+ this.detail = detail;
1924
+ }
1925
+ }
1926
+
1927
+ function snapshotTextFile(filePath) {
1928
+ if (!fs.existsSync(filePath)) return { exists: false, content: "" };
1929
+ return { exists: true, content: fs.readFileSync(filePath, "utf8") };
1930
+ }
1931
+
1932
+ function restoreTextFile(filePath, snap) {
1933
+ if (!snap?.exists) {
1934
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
1935
+ return;
1936
+ }
1937
+ fs.writeFileSync(filePath, snap.content, "utf8");
1938
+ }
1939
+
1940
+ function terminalQueueSnapshot(worktreePath) {
1941
+ if (!worktreePath || !fs.existsSync(worktreePath)) {
1942
+ return {
1943
+ busy: false,
1944
+ queueDepth: 0,
1945
+ runnerHealthy: false,
1946
+ };
1947
+ }
1948
+ const qdir = terminalQueueDir(worktreePath);
1949
+ return {
1950
+ busy: isTerminalRunnerBusy(qdir),
1951
+ queueDepth: countQueuedJobs(qdir),
1952
+ runnerHealthy: isTerminalRunnerHealthy(qdir),
1953
+ };
1954
+ }
1955
+
1917
1956
  function terminalJobsDir(queueDir) {
1918
1957
  return path.join(queueDir, "jobs");
1919
1958
  }
@@ -1978,30 +2017,65 @@ function launchInTerminal({
1978
2017
  if (key && fs.existsSync(key)) {
1979
2018
  const qdir = terminalQueueDir(key);
1980
2019
  fs.mkdirSync(qdir, { recursive: true });
1981
- const jobPath = enqueueTerminalJob(qdir, commandLine);
1982
- let queuedCount = countQueuedJobs(qdir);
1983
2020
 
1984
2021
  if (isTerminalRunnerHealthy(qdir)) {
1985
2022
  let busy = isTerminalRunnerBusy(qdir);
1986
2023
  let preempted = false;
2024
+ let pre = null;
1987
2025
  // After accept, leftover first-agent must not block revise forever.
2026
+ // Preempt *before* enqueue so the new revise Agent is never mistaken
2027
+ // for a leftover (false PREEMPT_FAILED while work already started).
1988
2028
  if (busy && preemptBusy) {
1989
- const pre = preemptBusyTerminalJob(qdir, {
2029
+ pre = preemptBusyTerminalJob(qdir, {
1990
2030
  worktreePath: cwd,
1991
2031
  logPath,
1992
2032
  });
1993
- preempted = Boolean(pre.preempted || pre.reason === "signal-attempted");
1994
- busy = isTerminalRunnerBusy(qdir);
1995
- queuedCount = countQueuedJobs(qdir);
1996
- try {
1997
- fs.writeFileSync(
1998
- path.join(qdir, "wake"),
1999
- `${path.basename(jobPath)}\n`,
2033
+ preempted = Array.isArray(pre.killed) && pre.killed.length > 0;
2034
+ let agentsLeft = cwd ? listWorktreeAgentPids(cwd).length : 0;
2035
+ for (let i = 0; i < 10 && agentsLeft > 0; i += 1) {
2036
+ sleepBriefMs(200);
2037
+ if (i === 4 || i === 8) {
2038
+ preemptBusyTerminalJob(qdir, {
2039
+ worktreePath: cwd,
2040
+ logPath,
2041
+ });
2042
+ }
2043
+ agentsLeft = cwd ? listWorktreeAgentPids(cwd).length : 0;
2044
+ }
2045
+ if (agentsLeft > 0) {
2046
+ throw new LaunchGateError(
2047
+ "无法抢占仍在运行的 Agent。请结束该 worktree 的 Terminal 任务后重试续派。",
2048
+ {
2049
+ code: "PREEMPT_FAILED",
2050
+ retryable: true,
2051
+ detail: {
2052
+ busy: isTerminalRunnerBusy(qdir),
2053
+ agentsLeft,
2054
+ killed: pre?.killed || [],
2055
+ reason: pre?.reason || null,
2056
+ enqueued: false,
2057
+ },
2058
+ },
2000
2059
  );
2001
- } catch {
2002
- // ignore
2060
+ }
2061
+ // Best-effort: let runner drop running.cmd before we queue revise.
2062
+ for (let i = 0; i < 10; i += 1) {
2063
+ if (!isTerminalRunnerBusy(qdir)) break;
2064
+ sleepBriefMs(150);
2003
2065
  }
2004
2066
  }
2067
+
2068
+ const jobPath = enqueueTerminalJob(qdir, commandLine);
2069
+ const queuedCount = countQueuedJobs(qdir);
2070
+ busy = isTerminalRunnerBusy(qdir);
2071
+ try {
2072
+ fs.writeFileSync(
2073
+ path.join(qdir, "wake"),
2074
+ `${path.basename(jobPath)}\n`,
2075
+ );
2076
+ } catch {
2077
+ // ignore
2078
+ }
2005
2079
  const pid = readRunnerPid(qdir);
2006
2080
  appendLaunchLog(
2007
2081
  logPath,
@@ -2021,6 +2095,7 @@ function launchInTerminal({
2021
2095
  }
2022
2096
 
2023
2097
  // Stale/dead PID or pre-heartbeat runner: do not claim reuse
2098
+ const jobPath = enqueueTerminalJob(qdir, commandLine);
2024
2099
  const stalePid = readRunnerPid(qdir);
2025
2100
  if (stalePid != null) {
2026
2101
  appendLaunchLog(
@@ -3022,10 +3097,11 @@ async function reviseDispatchedJob({
3022
3097
  const specPath = path.join(dispatch.featureDir, "spec.md");
3023
3098
  const tasksPath = path.join(dispatch.featureDir, "tasks.md");
3024
3099
  const deliveryPath = path.join(dispatch.featureDir, "delivery.json");
3100
+ const snapSpec = snapshotTextFile(specPath);
3101
+ const snapTasks = snapshotTextFile(tasksPath);
3102
+ const snapDelivery = snapshotTextFile(deliveryPath);
3025
3103
 
3026
- let specMd = fs.existsSync(specPath)
3027
- ? fs.readFileSync(specPath, "utf8")
3028
- : "";
3104
+ let specMd = snapSpec.exists ? snapSpec.content : "";
3029
3105
  specMd = specMd.replace(/\*\*Status\*\*:\s*.+$/m, `**Status**: Revising (r${revN})`);
3030
3106
  if (!/\*\*Status\*\*:/.test(specMd)) {
3031
3107
  specMd = `${specMd.trim()}\n\n**Status**: Revising (r${revN})\n`;
@@ -3047,9 +3123,7 @@ ${reasonLine || text}
3047
3123
  `;
3048
3124
  fs.writeFileSync(specPath, `${specMd.trim()}\n${revisionBlock}\n`, "utf8");
3049
3125
 
3050
- let tasksMd = fs.existsSync(tasksPath)
3051
- ? fs.readFileSync(tasksPath, "utf8")
3052
- : "# Tasks\n\n";
3126
+ let tasksMd = snapTasks.exists ? snapTasks.content : "# Tasks\n\n";
3053
3127
  const taskBlock = `
3054
3128
  - [ ] R${revN}-1 Apply revision: ${restated.change.replace(/\n/g, " ").slice(0, 160)}
3055
3129
  - [ ] R${revN}-2 Verify against revision acceptance
@@ -3062,9 +3136,9 @@ ${reasonLine || text}
3062
3136
  );
3063
3137
 
3064
3138
  let prevDelivery = null;
3065
- if (fs.existsSync(deliveryPath)) {
3139
+ if (snapDelivery.exists) {
3066
3140
  try {
3067
- prevDelivery = JSON.parse(fs.readFileSync(deliveryPath, "utf8"));
3141
+ prevDelivery = JSON.parse(snapDelivery.content);
3068
3142
  } catch {
3069
3143
  prevDelivery = null;
3070
3144
  }
@@ -3093,11 +3167,23 @@ ${reasonLine || text}
3093
3167
  chosen = detected.agents[0]?.id || "";
3094
3168
  }
3095
3169
  if (!chosen) {
3096
- throw new Error("未检测到可用 CLI(Cursor Agent / Claude Code)");
3170
+ restoreTextFile(specPath, snapSpec);
3171
+ restoreTextFile(tasksPath, snapTasks);
3172
+ restoreTextFile(deliveryPath, snapDelivery);
3173
+ throw new LaunchGateError("未检测到可用 CLI(Cursor Agent / Claude Code)", {
3174
+ code: "NO_AGENT",
3175
+ retryable: true,
3176
+ });
3097
3177
  }
3098
3178
  const ok = detected.agents.some((a) => a.id === chosen && a.available);
3099
3179
  if (!ok) {
3100
- throw new Error(`未安装启动器:${chosen}`);
3180
+ restoreTextFile(specPath, snapSpec);
3181
+ restoreTextFile(tasksPath, snapTasks);
3182
+ restoreTextFile(deliveryPath, snapDelivery);
3183
+ throw new LaunchGateError(`未安装启动器:${chosen}`, {
3184
+ code: "AGENT_MISSING",
3185
+ retryable: true,
3186
+ });
3101
3187
  }
3102
3188
 
3103
3189
  // After accepted delivery (or recreated worktree), do not use --continue:
@@ -3153,17 +3239,37 @@ ${restated.keep}
3153
3239
  logPath,
3154
3240
  `\n—— revise r${revN} ${now} (continue=${continueSession} recreated=${recreated} priorAccepted=${priorAccepted}) ——\n${restated.summary}\n`,
3155
3241
  );
3156
- const launch = launchAgent({
3157
- agentId: chosen,
3158
- worktreePath: dispatch.worktreePath,
3159
- agentPrompt,
3160
- logPath,
3161
- featureDir: dispatch.featureDir,
3162
- continueSession,
3163
- // Revise after accept/recreate must not wait forever behind leftover agent.
3164
- reviseLaunch: true,
3165
- // preempt only when !continueSession (priorAccepted || recreated) — see launchAgent
3166
- });
3242
+
3243
+ let launch;
3244
+ try {
3245
+ launch = launchAgent({
3246
+ agentId: chosen,
3247
+ worktreePath: dispatch.worktreePath,
3248
+ agentPrompt,
3249
+ logPath,
3250
+ featureDir: dispatch.featureDir,
3251
+ continueSession,
3252
+ // Revise after accept/recreate must not wait forever behind leftover agent.
3253
+ reviseLaunch: true,
3254
+ // preempt only when !continueSession (priorAccepted || recreated) — see launchAgent
3255
+ });
3256
+ } catch (err) {
3257
+ restoreTextFile(specPath, snapSpec);
3258
+ restoreTextFile(tasksPath, snapTasks);
3259
+ restoreTextFile(deliveryPath, snapDelivery);
3260
+ if (err?.code === "PREEMPT_FAILED" || err?.name === "LaunchGateError") {
3261
+ throw err;
3262
+ }
3263
+ const wrapped = new LaunchGateError(
3264
+ err instanceof Error ? err.message : "续派启动失败",
3265
+ {
3266
+ code: err?.code === "CHILD_TIMEOUT" ? "CHILD_TIMEOUT" : "LAUNCH_FAILED",
3267
+ retryable: true,
3268
+ detail: { cause: err?.code || err?.name || null },
3269
+ },
3270
+ );
3271
+ throw wrapped;
3272
+ }
3167
3273
  rememberPreferredAgent(chosen);
3168
3274
 
3169
3275
  const nextDispatch = {
@@ -3214,15 +3320,53 @@ function extractSection(md, title) {
3214
3320
  return m ? m[1].trim() : "";
3215
3321
  }
3216
3322
 
3217
- function parseTasksProgress(tasksMd) {
3323
+ function inferRevisionFromTasksMd(tasksMd) {
3324
+ let max = 0;
3325
+ for (const line of String(tasksMd || "").split(/\r?\n/)) {
3326
+ const header = line.match(/^##\s+Revision\s+(\d+)\b/i);
3327
+ if (header) max = Math.max(max, Number(header[1]) || 0);
3328
+ const item = line.match(/^\s*[-*]\s+\[[ xX]\]\s+R(\d+)(?:[-.\s]|$)/i);
3329
+ if (item) max = Math.max(max, Number(item[1]) || 0);
3330
+ }
3331
+ return max;
3332
+ }
3333
+
3334
+ function tasksMdScope(tasksMd, revision = 0) {
3335
+ const raw = String(tasksMd || "");
3336
+ const rev = Number(revision) || 0;
3337
+ if (rev > 0) {
3338
+ const section = raw.match(
3339
+ new RegExp(
3340
+ `##\\s+Revision\\s+${rev}\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s+Revision\\s+\\d+|$)`,
3341
+ "i",
3342
+ ),
3343
+ );
3344
+ if (section) return section[1];
3345
+ // Fallback: only checkbox lines for this revision id (R1-1 / R1-01 / R1 …)
3346
+ return raw
3347
+ .split(/\r?\n/)
3348
+ .filter((line) =>
3349
+ new RegExp(`^\\s*[-*]\\s+\\[[ xX]\\]\\s+R${rev}(?:[-.\\s]|$)`, "i").test(
3350
+ line,
3351
+ ),
3352
+ )
3353
+ .join("\n");
3354
+ }
3355
+ // Dispatch scope: everything before the first Revision section
3356
+ const cut = raw.search(/\n##\s+Revision\s+\d+\b/i);
3357
+ return cut >= 0 ? raw.slice(0, cut) : raw;
3358
+ }
3359
+
3360
+ function parseTasksProgress(tasksMd, { revision = 0 } = {}) {
3218
3361
  const tasks = [];
3219
- const lines = String(tasksMd || "").split(/\r?\n/);
3362
+ const scope = tasksMdScope(tasksMd, revision);
3363
+ const lines = String(scope || "").split(/\r?\n/);
3220
3364
  for (const line of lines) {
3221
3365
  const m = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/);
3222
3366
  if (!m) continue;
3223
3367
  const done = m[1].toLowerCase() === "x";
3224
3368
  const text = m[2].trim();
3225
- const idMatch = text.match(/^(T\d+)\b/i);
3369
+ const idMatch = text.match(/^(T\d+|R\d+[-\w]*)\b/i);
3226
3370
  tasks.push({
3227
3371
  id: idMatch ? idMatch[1].toUpperCase() : `S${tasks.length + 1}`,
3228
3372
  text,
@@ -3233,17 +3377,69 @@ function parseTasksProgress(tasksMd) {
3233
3377
  const doneCount = tasks.filter((t) => t.done).length;
3234
3378
  const next = tasks.find((t) => !t.done) || null;
3235
3379
  let current = "暂无任务清单";
3236
- if (total === 0) current = "暂无任务清单";
3237
- else if (!next) current = "任务已全部勾选 · 等待 delivery accepted";
3238
- else current = `进行中:${next.text}`;
3380
+ if (total === 0) {
3381
+ current =
3382
+ Number(revision) > 0
3383
+ ? `Revision ${revision} · 等待任务清单`
3384
+ : "暂无任务清单";
3385
+ } else if (!next) {
3386
+ current =
3387
+ Number(revision) > 0
3388
+ ? `Revision ${revision} · 任务已全部勾选 · 等待 delivery accepted`
3389
+ : "任务已全部勾选 · 等待 delivery accepted";
3390
+ } else {
3391
+ current = `进行中:${next.text}`;
3392
+ }
3239
3393
  return {
3240
3394
  total,
3241
3395
  done: doneCount,
3242
3396
  current,
3243
3397
  tasks,
3398
+ revision: Number(revision) || 0,
3244
3399
  };
3245
3400
  }
3246
3401
 
3402
+ /** When delivery is accepted, never show 0/N · 工单完成 — treat checklist as complete. */
3403
+ function progressForAcceptedDelivery(progress, { worktreeExists = true } = {}) {
3404
+ const base =
3405
+ progress && typeof progress === "object"
3406
+ ? progress
3407
+ : { total: 0, done: 0, current: "", tasks: [] };
3408
+ const tasks = Array.isArray(base.tasks)
3409
+ ? base.tasks.map((t) => ({ ...t, done: true }))
3410
+ : [];
3411
+ const total = Number(base.total) || tasks.length;
3412
+ return {
3413
+ ...base,
3414
+ total,
3415
+ done: total,
3416
+ tasks,
3417
+ current: worktreeExists
3418
+ ? "delivery accepted · 工单完成"
3419
+ : "delivery accepted · 已合入 develop(worktree 已清理)",
3420
+ };
3421
+ }
3422
+
3423
+ /** Idempotent: flip remaining `- [ ]` to `- [x]` once delivery is accepted. */
3424
+ function reconcileTasksMdOnAccept(tasksPath) {
3425
+ if (!tasksPath || !fs.existsSync(tasksPath)) return false;
3426
+ let raw;
3427
+ try {
3428
+ raw = fs.readFileSync(tasksPath, "utf8");
3429
+ } catch {
3430
+ return false;
3431
+ }
3432
+ if (!/^\s*[-*]\s+\[\s\]\s+/m.test(raw)) return false;
3433
+ const next = raw.replace(/^(\s*[-*]\s+)\[\s\](\s+)/gm, "$1[x]$2");
3434
+ if (next === raw) return false;
3435
+ try {
3436
+ fs.writeFileSync(tasksPath, next, "utf8");
3437
+ return true;
3438
+ } catch {
3439
+ return false;
3440
+ }
3441
+ }
3442
+
3247
3443
  function readLogTail(logPath, maxLines = 12) {
3248
3444
  if (!logPath || !fs.existsSync(logPath)) return [];
3249
3445
  try {
@@ -3406,12 +3602,12 @@ function dispatchStatus(jobId) {
3406
3602
  }
3407
3603
  }
3408
3604
  const tasksPath = featureDir ? path.join(featureDir, "tasks.md") : null;
3409
- let progress = parseTasksProgress("");
3605
+ let tasksRaw = "";
3410
3606
  if (tasksPath && fs.existsSync(tasksPath)) {
3411
3607
  try {
3412
- progress = parseTasksProgress(fs.readFileSync(tasksPath, "utf8"));
3608
+ tasksRaw = fs.readFileSync(tasksPath, "utf8");
3413
3609
  } catch {
3414
- progress = parseTasksProgress("");
3610
+ tasksRaw = "";
3415
3611
  }
3416
3612
  }
3417
3613
  const logPath =
@@ -3419,13 +3615,61 @@ function dispatchStatus(jobId) {
3419
3615
  (featureDir ? path.join(featureDir, "agent-launch.log") : null);
3420
3616
  const logTail = logPath ? readLogTail(logPath) : [];
3421
3617
  const worktreeExists = roots.worktreeExists;
3422
- const accepted = delivery?.status === "accepted";
3423
- if (accepted && progress.total > 0) {
3618
+ const terminal = terminalQueueSnapshot(dispatch.worktreePath);
3619
+ const inferredFromTasks = inferRevisionFromTasksMd(tasksRaw);
3620
+ const revisionHint = Math.max(
3621
+ Number(live.job.revisionCount || 0),
3622
+ Number(delivery?.revision || 0),
3623
+ inferredFromTasks,
3624
+ );
3625
+ const revProgressHint =
3626
+ revisionHint > 0
3627
+ ? parseTasksProgress(tasksRaw, { revision: revisionHint })
3628
+ : null;
3629
+ const hasOpenRevWork =
3630
+ Boolean(revProgressHint) &&
3631
+ revProgressHint.total > 0 &&
3632
+ revProgressHint.done < revProgressHint.total;
3633
+ // First-dispatch delivery is also "open" — only treat as revise when a
3634
+ // Revision N already exists (job / delivery / tasks.md), or Terminal is
3635
+ // busy on open R{n} work while status still lags on accepted.
3636
+ const deliveryAccepted = delivery?.status === "accepted";
3637
+ const deliveryOpen = delivery?.status === "open";
3638
+ const activelyRevising =
3639
+ (!deliveryAccepted && live.job.status === "revising") ||
3640
+ (revisionHint > 0 && deliveryOpen) ||
3641
+ (hasOpenRevWork &&
3642
+ (terminal.busy || Number(terminal.queueDepth || 0) > 0));
3643
+ const activeRevision =
3644
+ revisionHint > 0 &&
3645
+ (activelyRevising ||
3646
+ Number(live.job.revisionCount || 0) > 0 ||
3647
+ Number(delivery?.revision || 0) > 0 ||
3648
+ inferredFromTasks > 0)
3649
+ ? revisionHint
3650
+ : 0;
3651
+
3652
+ let progress = parseTasksProgress(tasksRaw, {
3653
+ revision: activeRevision > 0 ? activeRevision : 0,
3654
+ });
3655
+ const accepted = deliveryAccepted && !activelyRevising;
3656
+ if (accepted) {
3657
+ if (tasksPath) reconcileTasksMdOnAccept(tasksPath);
3658
+ if (tasksPath && fs.existsSync(tasksPath)) {
3659
+ try {
3660
+ tasksRaw = fs.readFileSync(tasksPath, "utf8");
3661
+ } catch {
3662
+ // keep
3663
+ }
3664
+ }
3665
+ progress = parseTasksProgress(tasksRaw, {
3666
+ revision: activeRevision > 0 ? activeRevision : 0,
3667
+ });
3668
+ progress = progressForAcceptedDelivery(progress, { worktreeExists });
3669
+ } else if (activelyRevising && progress.total === 0 && activeRevision > 0) {
3424
3670
  progress = {
3425
3671
  ...progress,
3426
- current: worktreeExists
3427
- ? "delivery accepted · 工单完成"
3428
- : "delivery accepted · 已合入 develop(worktree 已清理)",
3672
+ current: `Revision ${activeRevision} · 已续派,等待任务勾选…`,
3429
3673
  };
3430
3674
  } else if (!worktreeExists && roots.source === "primary") {
3431
3675
  progress = {
@@ -3435,6 +3679,36 @@ function dispatchStatus(jobId) {
3435
3679
  "worktree 已清理;成品与 Brief 在产品仓 develop",
3436
3680
  };
3437
3681
  }
3682
+
3683
+ // Keep live job revisionCount in sync when Brief/tasks already show Revision N
3684
+ if (
3685
+ activeRevision > Number(live.job.revisionCount || 0) ||
3686
+ (activelyRevising && live.job.status !== "revising")
3687
+ ) {
3688
+ try {
3689
+ const nextJob = {
3690
+ ...live.job,
3691
+ revisionCount: Math.max(
3692
+ Number(live.job.revisionCount || 0),
3693
+ activeRevision,
3694
+ ),
3695
+ status: activelyRevising
3696
+ ? "revising"
3697
+ : accepted
3698
+ ? "accepted"
3699
+ : live.job.status,
3700
+ };
3701
+ fs.writeFileSync(
3702
+ live.jobPath,
3703
+ `${JSON.stringify(nextJob, null, 2)}\n`,
3704
+ "utf8",
3705
+ );
3706
+ live.job = nextJob;
3707
+ } catch {
3708
+ // ignore
3709
+ }
3710
+ }
3711
+
3438
3712
  const preview = resolvePreview({
3439
3713
  delivery: delivery || { status: "open" },
3440
3714
  worktreePath: roots.root,
@@ -3443,8 +3717,10 @@ function dispatchStatus(jobId) {
3443
3717
  // Only after accept (or during a revise round). Not while first dispatch is still in progress.
3444
3718
  const showPreview =
3445
3719
  accepted ||
3720
+ activelyRevising ||
3446
3721
  live.job.status === "revising" ||
3447
- Number(live.job.revisionCount || 0) > 0;
3722
+ Number(live.job.revisionCount || 0) > 0 ||
3723
+ activeRevision > 0;
3448
3724
 
3449
3725
  // Persist accepted status when handoff moved Brief to primary
3450
3726
  if (accepted && live.job.status !== "accepted" && roots.source === "primary") {
@@ -3455,6 +3731,7 @@ function dispatchStatus(jobId) {
3455
3731
  `${JSON.stringify(nextJob, null, 2)}\n`,
3456
3732
  "utf8",
3457
3733
  );
3734
+ live.job = nextJob;
3458
3735
  } catch {
3459
3736
  // ignore
3460
3737
  }
@@ -3463,12 +3740,20 @@ function dispatchStatus(jobId) {
3463
3740
  const canRevise =
3464
3741
  Boolean(dispatch.repoPath && fs.existsSync(dispatch.repoPath)) &&
3465
3742
  (accepted ||
3743
+ activelyRevising ||
3466
3744
  live.job.status === "revising" ||
3467
- Number(live.job.revisionCount || 0) > 0);
3745
+ Number(live.job.revisionCount || 0) > 0 ||
3746
+ activeRevision > 0);
3747
+
3748
+ const statusOut = activelyRevising
3749
+ ? "revising"
3750
+ : accepted
3751
+ ? "accepted"
3752
+ : live.job.status;
3468
3753
 
3469
3754
  return {
3470
3755
  jobId: live.id,
3471
- status: accepted ? "accepted" : live.job.status,
3756
+ status: statusOut,
3472
3757
  dispatch: {
3473
3758
  ...dispatch,
3474
3759
  worktreeExists,
@@ -3480,8 +3765,13 @@ function dispatchStatus(jobId) {
3480
3765
  logTail,
3481
3766
  preview: showPreview ? preview : null,
3482
3767
  canRevise,
3483
- revision: live.job.revisionCount || 0,
3768
+ revision: Math.max(
3769
+ Number(live.job.revisionCount || 0),
3770
+ activeRevision,
3771
+ Number(delivery?.revision || 0),
3772
+ ),
3484
3773
  handoffCleaned: !worktreeExists && roots.source === "primary",
3774
+ terminal,
3485
3775
  };
3486
3776
  }
3487
3777
 
@@ -3932,6 +4222,8 @@ async function handleApi(req, res) {
3932
4222
  send(res, 422, {
3933
4223
  ok: false,
3934
4224
  passed: false,
4225
+ code: "VALIDATE_GATE",
4226
+ retryable: true,
3935
4227
  error: err.message,
3936
4228
  summary: review.summary || err.message,
3937
4229
  issues: review.issues || [],
@@ -3944,8 +4236,28 @@ async function handleApi(req, res) {
3944
4236
  });
3945
4237
  return;
3946
4238
  }
3947
- send(res, err?.code === "CHILD_TIMEOUT" ? 504 : 400, {
4239
+ const code =
4240
+ err?.code === "PREEMPT_FAILED"
4241
+ ? "PREEMPT_FAILED"
4242
+ : err?.code === "CHILD_TIMEOUT"
4243
+ ? "CHILD_TIMEOUT"
4244
+ : err?.code === "NO_AGENT" || err?.code === "AGENT_MISSING"
4245
+ ? err.code
4246
+ : err?.name === "LaunchGateError"
4247
+ ? err.code || "LAUNCH_FAILED"
4248
+ : err?.code || "REVISE_FAILED";
4249
+ const status =
4250
+ code === "CHILD_TIMEOUT"
4251
+ ? 504
4252
+ : code === "PREEMPT_FAILED"
4253
+ ? 409
4254
+ : 400;
4255
+ send(res, status, {
4256
+ ok: false,
4257
+ code,
4258
+ retryable: err?.retryable !== false,
3948
4259
  error: err instanceof Error ? err.message : "revise failed",
4260
+ detail: err?.detail || undefined,
3949
4261
  });
3950
4262
  }
3951
4263
  return;
@@ -67,6 +67,10 @@ the table for their app.
67
67
  | E2E-052 | Confirm column wider than chat; requirements (and revise) fields fully expanded without inner scroll; structured paragraphs/lists display with click-to-edit | manual |
68
68
  | E2E-053 | Full-screen desk (no 1440 shell cap); chat column ~50px wider than prior confirm-wide min; confirm still wider than chat | manual |
69
69
  | E2E-054 | Live L3 smoke: `npm run test:live` covers desk shell markers + validate fail/pass + validate/fix via mock LLM (no paid API) | `npm run test:live` |
70
+ | E2E-055 | Revise reliability: preempt failure returns `PREEMPT_FAILED` without locking card; failed launch rolls back Brief Revision; status shows Terminal busy/queue; retry stays available | `npm test` + manual |
71
+ | E2E-056 | Progress on accept: status never shows `0/N · delivery accepted`; unchecked tasks reconciled to `[x]` | `npm test` |
72
+ | E2E-057 | After Confirm revise, progress tracks this revision’s `R{n}-*` tasks (not prior T* / frozen accepted); busy Terminal + lagging revisionCount still scopes correctly | `npm test` + manual |
73
+ | E2E-058 | priorAccepted revise: preempt clears leftovers then enqueue; false `PREEMPT_FAILED` must not appear while that revise Agent is already running; hard fail leaves nothing queued | `npm test` + manual |
70
74
  | E2E-041 | Busy Terminal runner: revise/re-dispatch queues (`pending.cmd`), waits (no interrupt); API `queued`/`busy`/`reused`; UI does not claim agent already started | manual |
71
75
  | E2E-007 | Request worktrees use `.worktree/<id>`; `.worktree/` gitignored | manual |
72
76
  | E2E-008 | Docs require main+develop+feat+fix and typed go-live flows | manual |
@@ -128,3 +132,6 @@ the table for their app.
128
132
  | E2E-039 | `038-revise-enqueue-style` | honest enqueue + dark revise card |
129
133
  | E2E-040 | `039-desk-scroll-sync` | left/right viewport sync |
130
134
  | E2E-041 | `040-terminal-queue-wait` | busy queue wait + honest API/UI |
135
+ | E2E-056 | `057-progress-accepted` | accept never shows 0/N |
136
+ | E2E-057 | `058-revise-progress-track` | revise scopes R{n} progress |
137
+ | E2E-058 | `059-revise-preempt-order` | preempt before enqueue |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duaer-spec",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Digital employees for Cursor, Claude, Codex, Copilot, Windsurf, Cline, Continue, Gemini, Aider",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,8 @@ const state = {
36
36
  mode: "specify", // specify | revise
37
37
  /** After a successful revise: right card stays locked 改进卡. */
38
38
  reviseLocked: false,
39
+ /** Revising but Terminal busy with no task progress — offer retry CTA. */
40
+ reviseStuckHint: false,
39
41
  lastRevision: null, // { revision, change, keep, acceptance, reason }
40
42
  originalCard: null, // snapshot after first confirm
41
43
  lastStatus: null,
@@ -612,7 +614,9 @@ function syncReviseDispatchButton(ready) {
612
614
  const accepted = state.lastDeliveryAccepted || status === "accepted";
613
615
  const revising = status === "revising";
614
616
  if (el.reviseHint) {
615
- if (revising && !dialoguing) {
617
+ if (revising && state.reviseStuckHint && !dialoguing) {
618
+ el.reviseHint.textContent = t("revise.hintStuck");
619
+ } else if (revising && !dialoguing) {
616
620
  el.reviseHint.textContent = t("revise.hintRevising");
617
621
  } else if (state.reviseLocked && !dialoguing) {
618
622
  el.reviseHint.textContent = t("revise.hintLocked");
@@ -629,7 +633,8 @@ function syncReviseDispatchButton(ready) {
629
633
  }
630
634
  }
631
635
  if (el.startReviseChat || el.startReviseChatAlt) {
632
- const showCta = !dialoguing && accepted && !revising;
636
+ const showCta =
637
+ !dialoguing && accepted && (!revising || state.reviseStuckHint);
633
638
  const previewVisible = el.previewPanel && !el.previewPanel.hidden;
634
639
  if (el.startReviseChat) {
635
640
  el.startReviseChat.hidden = !(showCta && previewVisible);
@@ -2204,7 +2209,8 @@ async function confirmReviseAndDispatch() {
2204
2209
  agentId: state.agentId,
2205
2210
  };
2206
2211
  const ac = new AbortController();
2207
- const timer = setTimeout(() => ac.abort(), 90000);
2212
+ // Cover recreate (git ≤60s) + optional duaer init (≤120s) + validate/launch headroom.
2213
+ const timer = setTimeout(() => ac.abort(), 180000);
2208
2214
  const res = await fetch("/api/revise", {
2209
2215
  method: "POST",
2210
2216
  headers: { "content-type": "application/json" },
@@ -2218,10 +2224,13 @@ async function confirmReviseAndDispatch() {
2218
2224
  data = rawText ? JSON.parse(rawText) : {};
2219
2225
  } catch {
2220
2226
  const snippet = String(rawText || "").replace(/\s+/g, " ").slice(0, 180);
2221
- throw new Error(
2222
- snippet
2223
- ? `HTTP ${res.status}: ${snippet}`
2224
- : `HTTP ${res.status} ${t("err.revise")}`,
2227
+ throw Object.assign(
2228
+ new Error(
2229
+ snippet
2230
+ ? `HTTP ${res.status}: ${snippet}`
2231
+ : `HTTP ${res.status} ${t("err.revise")}`,
2232
+ ),
2233
+ { retryable: true },
2225
2234
  );
2226
2235
  }
2227
2236
  if (res.status === 422 || data.passed === false) {
@@ -2238,7 +2247,12 @@ async function confirmReviseAndDispatch() {
2238
2247
  syncConfirmEnabled();
2239
2248
  return;
2240
2249
  }
2241
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status} ${t("err.revise")}`);
2250
+ if (!res.ok) {
2251
+ const err = new Error(reviseErrorMessage(data, res.status));
2252
+ err.retryable = data.retryable !== false;
2253
+ err.code = data.code || "";
2254
+ throw err;
2255
+ }
2242
2256
  resetValidateGate();
2243
2257
  const restated = data.restated
2244
2258
  ? t("bot.reviseRestate", {
@@ -2273,23 +2287,49 @@ async function confirmReviseAndDispatch() {
2273
2287
  }
2274
2288
  startStatusPoll();
2275
2289
  } catch (err) {
2276
- const msg =
2277
- err?.name === "AbortError"
2278
- ? t("err.timeout")
2279
- : err instanceof Error
2280
- ? err.message
2281
- : t("err.revise");
2290
+ const timedOut = err?.name === "AbortError";
2291
+ const msg = timedOut
2292
+ ? t("err.reviseTimeout")
2293
+ : err instanceof Error
2294
+ ? err.message
2295
+ : t("err.revise");
2296
+ const retryable = timedOut || err?.retryable !== false;
2282
2297
  if (el.reviseErr) {
2283
2298
  el.reviseErr.hidden = false;
2284
- el.reviseErr.textContent = msg;
2299
+ el.reviseErr.textContent = retryable
2300
+ ? `${msg} ${t("err.retryableHint")}`
2301
+ : msg;
2285
2302
  }
2286
2303
  addBubble("bot", msg);
2304
+ // Keep card unlocked so Confirm revise stays available.
2305
+ state.reviseLocked = false;
2306
+ setReviseFieldsReadonly(false);
2287
2307
  } finally {
2288
2308
  state.reviseDispatching = false;
2289
2309
  syncConfirmEnabled();
2290
2310
  }
2291
2311
  }
2292
2312
 
2313
+ function reviseErrorMessage(data, status) {
2314
+ const code = String(data?.code || "").trim();
2315
+ if (code) {
2316
+ const keyed = t(`err.code.${code}`);
2317
+ if (keyed && keyed !== `err.code.${code}`) return keyed;
2318
+ }
2319
+ return data?.error || `HTTP ${status} ${t("err.revise")}`;
2320
+ }
2321
+
2322
+ function formatTerminalStatus(term) {
2323
+ if (!term || typeof term !== "object") return "";
2324
+ if (!term.runnerHealthy) return t("status.terminalDown");
2325
+ const parts = [
2326
+ term.busy ? t("status.terminalBusy") : t("status.terminalIdle"),
2327
+ ];
2328
+ const depth = Number(term.queueDepth || 0);
2329
+ if (depth > 0) parts.push(t("status.terminalQueue", { n: depth }));
2330
+ return parts.join(" · ");
2331
+ }
2332
+
2293
2333
  function startStatusPoll() {
2294
2334
  if (state.statusTimer) clearInterval(state.statusTimer);
2295
2335
  let acceptedNotified = false;
@@ -2317,6 +2357,25 @@ function startStatusPoll() {
2317
2357
  ? t("status.worktreeGone")
2318
2358
  : "",
2319
2359
  });
2360
+ const termLine = formatTerminalStatus(data.terminal);
2361
+ if (termLine) {
2362
+ el.dispatchStatus.textContent = `${el.dispatchStatus.textContent} · ${t(
2363
+ "status.terminalLine",
2364
+ { state: termLine },
2365
+ )}`;
2366
+ }
2367
+ // If revising but Terminal is stuck busy with no progress, keep「再改一版」visible.
2368
+ if (
2369
+ data.status === "revising" &&
2370
+ data.terminal?.busy &&
2371
+ data.terminal?.runnerHealthy &&
2372
+ !(data.progress?.done > 0)
2373
+ ) {
2374
+ state.reviseStuckHint = true;
2375
+ } else {
2376
+ state.reviseStuckHint = false;
2377
+ }
2378
+ renderRevisePanel(data);
2320
2379
  if (data.status === "accepted" && data.delivery?.status === "accepted") {
2321
2380
  el.dispatchStatus.textContent = t("status.acceptedRevise", { rev });
2322
2381
  if (!acceptedNotified || data.revision !== lastRevision) {
@@ -112,6 +112,8 @@ const zhCN = {
112
112
  "revise.hintEnqueue": "正在送入同一 Terminal(不新开窗口)…",
113
113
  "revise.hintLocked": "本轮改进卡(下方)已确认。改完验收后若仍不满意,再点「再改一版」。",
114
114
  "revise.hintRevising": "数字员工改写中。可继续「查看成品」;验收后再点「再改一版」。",
115
+ "revise.hintStuck":
116
+ "Terminal 仍忙且尚无任务进度。可结束旧任务后点「再改一版」重试续派。",
115
117
  "bot.ready":
116
118
  "模型已就绪。随便说你想做什么;我会多轮问清,右侧是确认卡。确认前不会改你的业务仓库。",
117
119
  "bot.cardReady": "右侧确认卡可再改。满意后点「需求无误,开始干活」。",
@@ -222,6 +224,20 @@ const zhCN = {
222
224
  "err.reviseKickoff": "改进对话启动失败",
223
225
  "err.confirm": "确认失败",
224
226
  "err.timeout": "超时。请刷新重试;若 Agent 已打开可在 Terminal 里继续。",
227
+ "err.reviseTimeout":
228
+ "续派超时(约 3 分钟)。Brief 未写入本轮 Revision 时可直接再点「再派一版」;若 Terminal 已有任务请在窗口里继续。",
229
+ "err.code.PREEMPT_FAILED":
230
+ "无法抢占仍在运行的 Agent。请结束该 worktree 的 Terminal 任务后,再点「再派一版」。",
231
+ "err.code.CHILD_TIMEOUT": "子进程超时被中止。请重试续派;若 git/init 卡住请检查仓库与钩子。",
232
+ "err.code.LAUNCH_FAILED": "续派启动失败。可直接再点「再派一版」重试。",
233
+ "err.code.NO_AGENT": "未检测到可用 CLI。请安装 Cursor Agent 或 Claude Code 后重试。",
234
+ "err.code.AGENT_MISSING": "所选 CLI 未安装。请安装后重试。",
235
+ "err.retryableHint": "可以再试一次。",
236
+ "status.terminalLine": "Terminal:{state}",
237
+ "status.terminalBusy": "忙碌中",
238
+ "status.terminalIdle": "空闲",
239
+ "status.terminalQueue": "队列 {n}",
240
+ "status.terminalDown": "无 runner",
225
241
  "err.installAgent": "请先安装:{cmd}",
226
242
  "err.needAgent": "请先安装所选 CLI",
227
243
  "err.needAgentAlt": "请先安装对应 CLI",
@@ -342,6 +358,8 @@ const en = {
342
358
  "This revise card is locked. After accept, tap Revise again if needed.",
343
359
  "revise.hintRevising":
344
360
  "Employee is revising. You can View product; Revise again after accept.",
361
+ "revise.hintStuck":
362
+ "Terminal is still busy with no task progress. Stop the old job, then tap Revise again to retry.",
345
363
  "bot.ready":
346
364
  "Model ready. Tell me what you want; I will clarify in chat. The confirm card is on the right. Nothing touches your product repo until you confirm.",
347
365
  "bot.cardReady":
@@ -453,6 +471,21 @@ const en = {
453
471
  "err.reviseKickoff": "Could not start revise chat",
454
472
  "err.confirm": "Confirm failed",
455
473
  "err.timeout": "Timed out. Refresh; if the Agent is open, continue in Terminal.",
474
+ "err.reviseTimeout":
475
+ "Revise timed out (~3 min). If this Revision was not written, tap Confirm revise again; if Terminal already has a job, continue there.",
476
+ "err.code.PREEMPT_FAILED":
477
+ "Could not preempt a running Agent. Stop that worktree Terminal job, then tap Confirm revise again.",
478
+ "err.code.CHILD_TIMEOUT":
479
+ "A child process timed out. Retry revise; check git hooks if checkout/init stalls.",
480
+ "err.code.LAUNCH_FAILED": "Revise launch failed. Tap Confirm revise to retry.",
481
+ "err.code.NO_AGENT": "No CLI found. Install Cursor Agent or Claude Code, then retry.",
482
+ "err.code.AGENT_MISSING": "Selected CLI is not installed. Install it, then retry.",
483
+ "err.retryableHint": "You can retry.",
484
+ "status.terminalLine": "Terminal: {state}",
485
+ "status.terminalBusy": "busy",
486
+ "status.terminalIdle": "idle",
487
+ "status.terminalQueue": "queue {n}",
488
+ "status.terminalDown": "no runner",
456
489
  "err.installAgent": "Install first: {cmd}",
457
490
  "err.needAgent": "Install the selected CLI first",
458
491
  "err.needAgentAlt": "Install the matching CLI first",