input-kanban 0.0.7 → 0.0.8
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/PROJECT_GUIDE.md +54 -2
- package/README.en.md +41 -1
- package/README.md +41 -1
- package/RELEASE_NOTES.md +17 -0
- package/bin/input-kanban.js +532 -22
- package/package.json +1 -2
- package/public/index.html +64 -17
- package/src/orchestrator.js +150 -16
package/PROJECT_GUIDE.md
CHANGED
|
@@ -51,9 +51,9 @@ The npm CLI entry is:
|
|
|
51
51
|
bin/input-kanban.js
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
It parses CLI options
|
|
54
|
+
It parses CLI options and sets environment variables before importing backend modules. Without a subcommand, or with `serve`, it starts the HTTP server. With `submit`, it creates a run directly in the shared runs directory and can optionally run an auto loop.
|
|
55
55
|
|
|
56
|
-
Supported options:
|
|
56
|
+
Supported serve options:
|
|
57
57
|
|
|
58
58
|
```text
|
|
59
59
|
--host <host>
|
|
@@ -61,10 +61,62 @@ Supported options:
|
|
|
61
61
|
--repo <path>
|
|
62
62
|
--runs-dir <path>
|
|
63
63
|
--codex-bin <path>
|
|
64
|
+
--runner <headless|tmux>
|
|
64
65
|
--open
|
|
65
66
|
--no-open
|
|
66
67
|
```
|
|
67
68
|
|
|
69
|
+
Supported status options:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
[runId]
|
|
73
|
+
--runs-dir <path>
|
|
74
|
+
--watch
|
|
75
|
+
--poll-ms <ms>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`input-kanban status` refreshes and prints a run summary. If no `runId` is provided, it uses the latest run from the shared runs directory. `--watch` keeps polling until the run reaches a terminal state.
|
|
79
|
+
|
|
80
|
+
Supported result options:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
[runId]
|
|
84
|
+
--runs-dir <path>
|
|
85
|
+
--copy
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`input-kanban result [runId]` prints the final judge result. It prefers `judge/verdict.json` and falls back to `judge/last_message.md`. If no `runId` is provided, it uses the latest run. `--copy` sends the result to the system clipboard.
|
|
89
|
+
|
|
90
|
+
Supported stop options:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
<runId>
|
|
94
|
+
--runs-dir <path>
|
|
95
|
+
--reason <text>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`input-kanban stop <runId>` calls the same orchestrator stop path as the Web dashboard. Stop requires an explicit run id. The backend first asks the active runner to stop known processes and then falls back to killing live stored PIDs, so Web and CLI processes can stop each other's headless workers.
|
|
99
|
+
|
|
100
|
+
Supported submit options:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
--repo <path>
|
|
104
|
+
--label <label>
|
|
105
|
+
--task <text>
|
|
106
|
+
--task-file <path|->
|
|
107
|
+
--max-parallel <n>
|
|
108
|
+
--worker-sandbox <read-only|workspace-write|danger-full-access>
|
|
109
|
+
--runner <headless|tmux>
|
|
110
|
+
--runs-dir <path>
|
|
111
|
+
--auto
|
|
112
|
+
--no-auto
|
|
113
|
+
--detach / -d
|
|
114
|
+
--watch
|
|
115
|
+
--poll-ms <ms>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`input-kanban submit` creates a run and starts the planner. Task content can come from `--task <text>` or `--task-file <path|->`; omitting `--repo` uses the current working directory as the target Git work tree. Omitting `--label` derives the run label from the first non-empty task line. Auto mode is the default for submit: it keeps polling the run, dispatches batches when the plan is ready, and starts the final judge once all batches complete. `--no-auto` keeps submit to create + plan only. `-d` / `--detach` starts a background supervisor process for the auto loop and lets the submitting terminal return immediately. The submit output includes `input-kanban status <runId> --watch` for terminal-side observation. Because it writes to the same runs directory as the Web server, CLI-created runs are visible in the 8787 dashboard when both processes use the same `--runs-dir`.
|
|
119
|
+
|
|
68
120
|
Default behavior:
|
|
69
121
|
|
|
70
122
|
- default repo: current working directory when `input-kanban` is launched; run creation validates that the selected repo is inside a Git work tree;
|
package/README.en.md
CHANGED
|
@@ -43,6 +43,46 @@ If you do not want to `cd` into the target repository first, pass it explicitly:
|
|
|
43
43
|
input-kanban --repo /path/to/your/repo
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
## CLI Auto Execution
|
|
47
|
+
|
|
48
|
+
To submit a task from the terminal and let it advance automatically, use `submit`. Task content supports two input modes:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
input-kanban submit --task-file task.md --label "Fix login issue"
|
|
52
|
+
input-kanban submit --task "Fix the login issue and add regression tests" --label "Fix login issue"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`submit` creates a run, starts planning, dispatches all batches, and starts the final judge after all workers finish by default. The default repo is the current directory. If `--label` is omitted, the run label is generated from the task text. It uses the same runs directory, so CLI-created runs are visible in the Web dashboard on port 8787 as long as the dashboard uses the same `--runs-dir`.
|
|
56
|
+
|
|
57
|
+
To return immediately and let a background supervisor continue the auto loop, pass `-d` / `--detach`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
input-kanban submit --task-file task.md -d
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
To create the run and start planning without dispatching or judging, pass `--no-auto`.
|
|
64
|
+
|
|
65
|
+
Common examples:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
input-kanban submit --task "Fix login issue"
|
|
69
|
+
input-kanban submit --task-file task.md --max-parallel 2 --worker-sandbox workspace-write
|
|
70
|
+
input-kanban submit --runs-dir ~/.input-kanban/runs --runner tmux -d
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Check and stop:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
input-kanban status
|
|
77
|
+
input-kanban status --watch
|
|
78
|
+
input-kanban status <runId> --watch
|
|
79
|
+
input-kanban result
|
|
80
|
+
input-kanban result <runId> --copy
|
|
81
|
+
input-kanban stop <runId>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Without a `runId`, `status` and `result` use the latest run by default. `result --copy` copies the final judge result. Stopping requires an explicit `runId` to avoid stopping the wrong run.
|
|
85
|
+
|
|
46
86
|
## Common Startup Options
|
|
47
87
|
|
|
48
88
|
```bash
|
|
@@ -76,7 +116,7 @@ After run-level tmux metadata is available, the dashboard shows `Copy tmux attac
|
|
|
76
116
|
1. Click `New Run`.
|
|
77
117
|
2. Enter a label, target repository, worker sandbox, and task description.
|
|
78
118
|
3. Click `Create Run`.
|
|
79
|
-
4.
|
|
119
|
+
4. The dashboard automatically starts `Plan` so the Codex planner can generate batches and workers.
|
|
80
120
|
5. Click `Dispatch` to run workers by batch barrier and concurrency limits.
|
|
81
121
|
6. Inspect execution logs, final messages, error logs, and artifacts.
|
|
82
122
|
7. After all batches complete, click `Final Judge`.
|
package/README.md
CHANGED
|
@@ -43,6 +43,46 @@ http://127.0.0.1:8787
|
|
|
43
43
|
input-kanban --repo /path/to/your/repo
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
## CLI 自动执行
|
|
47
|
+
|
|
48
|
+
如果希望从终端直接提交任务并自动推进,可以使用 `submit`。任务内容支持两种输入方式:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
input-kanban submit --task-file task.md --label "修复登录问题"
|
|
52
|
+
input-kanban submit --task "修复登录问题,并补充回归测试" --label "修复登录问题"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`submit` 默认会创建任务批次、发起拆分、自动派发所有批次,并在全部完成后自动发起最终验收。默认 repo 是当前目录;如果不传 `--label`,任务批次名称会从任务内容自动生成。它使用同一个 runs 目录,所以只要 8787 Web 看板也使用相同的 `--runs-dir`,CLI 创建的任务会在 Web 界面里可见。
|
|
56
|
+
|
|
57
|
+
如果希望提交后立即返回,让任务在后台自动执行,可以加 `-d` / `--detach`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
input-kanban submit --task-file task.md -d
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
如果只想创建并拆分,不自动派发和验收,可以加 `--no-auto`。
|
|
64
|
+
|
|
65
|
+
常用参数:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
input-kanban submit --task "修复登录问题"
|
|
69
|
+
input-kanban submit --task-file task.md --max-parallel 2 --worker-sandbox workspace-write
|
|
70
|
+
input-kanban submit --runs-dir ~/.input-kanban/runs --runner tmux -d
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
查看和停止:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
input-kanban status
|
|
77
|
+
input-kanban status --watch
|
|
78
|
+
input-kanban status <runId> --watch
|
|
79
|
+
input-kanban result
|
|
80
|
+
input-kanban result <runId> --copy
|
|
81
|
+
input-kanban stop <runId>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
不传 `runId` 时,`status` 和 `result` 默认查看最近一次任务批次。`result --copy` 会复制最终验收结果;停止任务请显式传入 `runId`,避免误停。
|
|
85
|
+
|
|
46
86
|
## 常用启动参数
|
|
47
87
|
|
|
48
88
|
```bash
|
|
@@ -76,7 +116,7 @@ tmux 模式是可选能力,主要用于在终端里实时查看每个 Codex
|
|
|
76
116
|
1. 点击 `新建任务批次`。
|
|
77
117
|
2. 输入批次名称、目标仓库、Worker 沙箱和任务说明。
|
|
78
118
|
3. 点击 `创建批次`。
|
|
79
|
-
4.
|
|
119
|
+
4. 看板会自动发起 `拆分任务`,让 Codex planner 生成 batches 和 workers。
|
|
80
120
|
5. 点击 `派发执行`,按 batch barrier 和并发限制运行 workers。
|
|
81
121
|
6. 查看执行日志、最终回复、错误日志和产物。
|
|
82
122
|
7. 所有 batch 完成后,点击 `汇总验收`。
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## v0.0.8
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
|
|
7
|
+
- Add CLI `submit` workflow with two task input modes: `--task-file <markdown>` for Markdown files and `--task <text>` for inline task text.
|
|
8
|
+
- Add CLI auto loop as the default `submit` behavior to create a run, start planning, dispatch batches, and run the final judge while keeping the run visible in the shared Web dashboard.
|
|
9
|
+
- Add CLI `-d` / `--detach` to run the auto loop in a background supervisor, plus `--no-auto` for create-and-plan-only mode.
|
|
10
|
+
- Add CLI `status [runId] [--watch]`, defaulting to the latest run when `runId` is omitted.
|
|
11
|
+
- Add CLI `result [runId] [--copy]` to print or copy the final judge result.
|
|
12
|
+
- Add CLI `stop <runId>` and make backend stop robust across CLI/Web processes by falling back to stored live PIDs.
|
|
13
|
+
- Derive the run label from task text when `--label` / form label is omitted.
|
|
14
|
+
- Add dashboard run-card archive confirmation without modal popups and replace the detail refresh text chips with a one-shot circle animation.
|
|
15
|
+
|
|
16
|
+
### Verification
|
|
17
|
+
|
|
18
|
+
- `npm run check` passed with 51 tests.
|
|
19
|
+
|
|
3
20
|
## v0.0.7
|
|
4
21
|
|
|
5
22
|
### Highlights
|
package/bin/input-kanban.js
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
|
|
5
7
|
const VALID_RUNNERS = ['headless', 'tmux'];
|
|
8
|
+
const VALID_SANDBOXES = ['read-only', 'workspace-write', 'danger-full-access'];
|
|
9
|
+
const COMMANDS = new Set(['serve', 'submit', 'status', 'result', 'stop', 'auto']);
|
|
10
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
11
|
+
const STATUS_TEXT = {
|
|
12
|
+
created: '已创建', planning: '拆分中', plan_failed: '拆分失败', plan_empty: '拆分为空', planned: '已拆分',
|
|
13
|
+
running: '执行中', batch_blocked: '批次阻塞', batches_completed: '批次完成', judging: '验收中', judged: '已验收',
|
|
14
|
+
judge_failed: '验收失败', stopped: '已停止'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function validateChoice(value, source, choices) {
|
|
18
|
+
if (choices.includes(value)) return value;
|
|
19
|
+
throw new Error(`invalid ${source}: ${value}; expected one of: ${choices.join(', ')}`);
|
|
20
|
+
}
|
|
6
21
|
|
|
7
22
|
function validateRunner(value, source) {
|
|
8
|
-
|
|
9
|
-
|
|
23
|
+
return validateChoice(value, source, VALID_RUNNERS);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validateSandbox(value, source) {
|
|
27
|
+
return validateChoice(value, source, VALID_SANDBOXES);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function splitCommand(argv) {
|
|
31
|
+
if (argv[0] && COMMANDS.has(argv[0])) return { command: argv[0], rest: argv.slice(1) };
|
|
32
|
+
return { command: 'serve', rest: argv };
|
|
10
33
|
}
|
|
11
34
|
|
|
12
|
-
function
|
|
35
|
+
function parseServeArgs(argv) {
|
|
13
36
|
const args = { host: '127.0.0.1', port: undefined, repo: undefined, runsDir: undefined, codexBin: undefined, runner: undefined, open: false, help: false };
|
|
14
37
|
for (let i = 0; i < argv.length; i++) {
|
|
15
38
|
const arg = argv[i];
|
|
@@ -28,22 +51,240 @@ function parseArgs(argv) {
|
|
|
28
51
|
return args;
|
|
29
52
|
}
|
|
30
53
|
|
|
54
|
+
function parseStatusArgs(argv) {
|
|
55
|
+
const args = { host: '127.0.0.1', port: 8787, runsDir: undefined, runId: undefined, watch: false, pollMs: 3000, help: false };
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const arg = argv[i];
|
|
58
|
+
const next = () => argv[++i];
|
|
59
|
+
if (arg === '--help' || arg === '-h') args.help = true;
|
|
60
|
+
else if (arg === '--host') args.host = next();
|
|
61
|
+
else if (arg === '--port' || arg === '-p') args.port = Number(next());
|
|
62
|
+
else if (arg === '--runs-dir') args.runsDir = next();
|
|
63
|
+
else if (arg === '--watch') args.watch = true;
|
|
64
|
+
else if (arg === '--poll-ms') args.pollMs = Number(next());
|
|
65
|
+
else if (!arg.startsWith('-') && !args.runId) args.runId = arg;
|
|
66
|
+
else throw new Error(`unknown status argument: ${arg}`);
|
|
67
|
+
}
|
|
68
|
+
return args;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseResultArgs(argv) {
|
|
72
|
+
const args = { runsDir: undefined, runId: undefined, copy: false, help: false };
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const arg = argv[i];
|
|
75
|
+
const next = () => argv[++i];
|
|
76
|
+
if (arg === '--help' || arg === '-h') args.help = true;
|
|
77
|
+
else if (arg === '--runs-dir') args.runsDir = next();
|
|
78
|
+
else if (arg === '--copy') args.copy = true;
|
|
79
|
+
else if (!arg.startsWith('-') && !args.runId) args.runId = arg;
|
|
80
|
+
else throw new Error(`unknown result argument: ${arg}`);
|
|
81
|
+
}
|
|
82
|
+
return args;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseStopArgs(argv) {
|
|
86
|
+
const args = { runsDir: undefined, runId: undefined, reason: 'stopped from CLI', help: false };
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const arg = argv[i];
|
|
89
|
+
const next = () => argv[++i];
|
|
90
|
+
if (arg === '--help' || arg === '-h') args.help = true;
|
|
91
|
+
else if (arg === '--runs-dir') args.runsDir = next();
|
|
92
|
+
else if (arg === '--reason') args.reason = next();
|
|
93
|
+
else if (!arg.startsWith('-') && !args.runId) args.runId = arg;
|
|
94
|
+
else throw new Error(`unknown stop argument: ${arg}`);
|
|
95
|
+
}
|
|
96
|
+
return args;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseAutoArgs(argv) {
|
|
100
|
+
const args = { host: '127.0.0.1', port: 8787, runsDir: undefined, codexBin: undefined, runner: undefined, runId: undefined, pollMs: 3000, help: false };
|
|
101
|
+
for (let i = 0; i < argv.length; i++) {
|
|
102
|
+
const arg = argv[i];
|
|
103
|
+
const next = () => argv[++i];
|
|
104
|
+
if (arg === '--help' || arg === '-h') args.help = true;
|
|
105
|
+
else if (arg === '--host') args.host = next();
|
|
106
|
+
else if (arg === '--port' || arg === '-p') args.port = Number(next());
|
|
107
|
+
else if (arg === '--runs-dir') args.runsDir = next();
|
|
108
|
+
else if (arg === '--codex-bin') args.codexBin = next();
|
|
109
|
+
else if (arg === '--runner') args.runner = validateRunner(next(), '--runner');
|
|
110
|
+
else if (arg === '--poll-ms') args.pollMs = Number(next());
|
|
111
|
+
else if (!arg.startsWith('-') && !args.runId) args.runId = arg;
|
|
112
|
+
else throw new Error(`unknown auto argument: ${arg}`);
|
|
113
|
+
}
|
|
114
|
+
return args;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseSubmitArgs(argv) {
|
|
118
|
+
const args = {
|
|
119
|
+
host: '127.0.0.1', port: 8787, repo: undefined, runsDir: undefined, codexBin: undefined,
|
|
120
|
+
runner: undefined, label: undefined, taskText: undefined, taskFile: undefined, maxParallel: 3,
|
|
121
|
+
workerSandbox: 'workspace-write', auto: true, detach: false, watch: true, pollMs: 3000, help: false
|
|
122
|
+
};
|
|
123
|
+
for (let i = 0; i < argv.length; i++) {
|
|
124
|
+
const arg = argv[i];
|
|
125
|
+
const next = () => argv[++i];
|
|
126
|
+
if (arg === '--help' || arg === '-h') args.help = true;
|
|
127
|
+
else if (arg === '--host') args.host = next();
|
|
128
|
+
else if (arg === '--port' || arg === '-p') args.port = Number(next());
|
|
129
|
+
else if (arg === '--repo' || arg === '-r') args.repo = next();
|
|
130
|
+
else if (arg === '--runs-dir') args.runsDir = next();
|
|
131
|
+
else if (arg === '--codex-bin') args.codexBin = next();
|
|
132
|
+
else if (arg === '--runner') args.runner = validateRunner(next(), '--runner');
|
|
133
|
+
else if (arg === '--label' || arg === '-l') args.label = next();
|
|
134
|
+
else if (arg === '--task') args.taskText = next();
|
|
135
|
+
else if (arg === '--task-file') args.taskFile = next();
|
|
136
|
+
else if (arg === '--max-parallel') args.maxParallel = Number(next());
|
|
137
|
+
else if (arg === '--worker-sandbox') args.workerSandbox = validateSandbox(next(), '--worker-sandbox');
|
|
138
|
+
else if (arg === '--auto') { args.auto = true; args.watch = true; }
|
|
139
|
+
else if (arg === '--no-auto') { args.auto = false; args.watch = false; }
|
|
140
|
+
else if (arg === '--detach' || arg === '-d') args.detach = true;
|
|
141
|
+
else if (arg === '--watch') args.watch = true;
|
|
142
|
+
else if (arg === '--poll-ms') args.pollMs = Number(next());
|
|
143
|
+
else throw new Error(`unknown submit argument: ${arg}`);
|
|
144
|
+
}
|
|
145
|
+
return args;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function applyRuntimeEnv(args) {
|
|
149
|
+
if (args.port) process.env.PORT = String(args.port);
|
|
150
|
+
if (args.host) process.env.HOST = args.host;
|
|
151
|
+
if (args.repo) process.env.KANBAN_DEFAULT_REPO = path.resolve(args.repo);
|
|
152
|
+
else if (!process.env.KANBAN_DEFAULT_REPO) process.env.KANBAN_DEFAULT_REPO = process.cwd();
|
|
153
|
+
if (args.runsDir) process.env.KANBAN_RUNS_DIR = path.resolve(args.runsDir);
|
|
154
|
+
if (args.codexBin) process.env.KANBAN_CODEX_BIN = args.codexBin;
|
|
155
|
+
if (args.runner) process.env.KANBAN_RUNNER = args.runner;
|
|
156
|
+
}
|
|
157
|
+
|
|
31
158
|
function printHelp() {
|
|
32
159
|
console.log(`input-kanban
|
|
33
160
|
|
|
34
161
|
Usage:
|
|
35
162
|
input-kanban [options]
|
|
163
|
+
input-kanban serve [options]
|
|
164
|
+
input-kanban submit [options]
|
|
165
|
+
input-kanban status [runId] [options]
|
|
166
|
+
input-kanban result [runId] [options]
|
|
167
|
+
input-kanban stop <runId> [options]
|
|
168
|
+
|
|
169
|
+
Serve options:
|
|
170
|
+
--host <host> Host to bind, default 127.0.0.1
|
|
171
|
+
-p, --port <port> Port to bind, default 8787
|
|
172
|
+
-r, --repo <path> Default target repository, default current directory
|
|
173
|
+
--runs-dir <path> Runtime runs directory, default ~/.input-kanban/runs
|
|
174
|
+
--codex-bin <path> Codex CLI executable, default codex
|
|
175
|
+
--runner <mode> Runner mode: headless or tmux, default headless
|
|
176
|
+
--open Open browser after starting
|
|
177
|
+
--no-open Do not open browser, default
|
|
178
|
+
|
|
179
|
+
Status options:
|
|
180
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
181
|
+
--watch Keep printing status until the run reaches a terminal state
|
|
182
|
+
--poll-ms <ms> Watch poll interval, default 3000
|
|
183
|
+
|
|
184
|
+
Result options:
|
|
185
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
186
|
+
--copy Copy final result to clipboard
|
|
187
|
+
|
|
188
|
+
Stop options:
|
|
189
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
190
|
+
--reason <text> Stop reason stored in run state
|
|
191
|
+
|
|
192
|
+
Submit options:
|
|
193
|
+
-r, --repo <path> Target Git work tree, default current directory
|
|
194
|
+
-l, --label <label> Task batch name, default generated from task text
|
|
195
|
+
--task <text> Task description text
|
|
196
|
+
--task-file <path> Read task description from file, use - for stdin
|
|
197
|
+
--max-parallel <n> Default max parallel workers, default 3
|
|
198
|
+
--worker-sandbox <mode> read-only, workspace-write, or danger-full-access
|
|
199
|
+
--runner <mode> Runner mode: headless or tmux
|
|
200
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
201
|
+
--auto Plan, dispatch all batches, judge, and watch, default for submit
|
|
202
|
+
--no-auto Only create the run and start planning
|
|
203
|
+
-d, --detach Run the default auto loop in a background supervisor
|
|
204
|
+
--watch Watch status after starting the planner
|
|
205
|
+
--poll-ms <ms> Watch poll interval, default 3000
|
|
206
|
+
-h, --help Show help
|
|
207
|
+
`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function printSubmitHelp() {
|
|
211
|
+
console.log(`input-kanban submit
|
|
212
|
+
|
|
213
|
+
Usage:
|
|
214
|
+
input-kanban submit --repo <path> --task-file task.md
|
|
215
|
+
input-kanban submit --repo <path> --task "fix the bug" --label "bugfix"
|
|
216
|
+
input-kanban submit --task-file task.md -d
|
|
36
217
|
|
|
37
218
|
Options:
|
|
38
|
-
--
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
--
|
|
42
|
-
--
|
|
43
|
-
--
|
|
44
|
-
--
|
|
45
|
-
--
|
|
46
|
-
|
|
219
|
+
-r, --repo <path> Target Git work tree, default current directory
|
|
220
|
+
-l, --label <label> Task batch name, default generated from task text
|
|
221
|
+
--task <text> Task description text
|
|
222
|
+
--task-file <path> Read task description from file, use - for stdin
|
|
223
|
+
--max-parallel <n> Default max parallel workers, default 3
|
|
224
|
+
--worker-sandbox <mode> read-only, workspace-write, or danger-full-access
|
|
225
|
+
--runner <mode> Runner mode: headless or tmux
|
|
226
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
227
|
+
--auto Plan, dispatch all batches, judge, and watch, default for submit
|
|
228
|
+
--no-auto Only create the run and start planning
|
|
229
|
+
-d, --detach Run the default auto loop in a background supervisor
|
|
230
|
+
--watch Watch status after starting the planner
|
|
231
|
+
--poll-ms <ms> Watch poll interval, default 3000
|
|
232
|
+
`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function printStatusHelp() {
|
|
236
|
+
console.log(`input-kanban status
|
|
237
|
+
|
|
238
|
+
Usage:
|
|
239
|
+
input-kanban status
|
|
240
|
+
input-kanban status <runId>
|
|
241
|
+
input-kanban status --watch
|
|
242
|
+
input-kanban status <runId> --watch
|
|
243
|
+
|
|
244
|
+
Options:
|
|
245
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
246
|
+
--watch Keep printing status until the run reaches a terminal state
|
|
247
|
+
--poll-ms <ms> Watch poll interval, default 3000
|
|
248
|
+
`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function printResultHelp() {
|
|
252
|
+
console.log(`input-kanban result
|
|
253
|
+
|
|
254
|
+
Usage:
|
|
255
|
+
input-kanban result
|
|
256
|
+
input-kanban result <runId>
|
|
257
|
+
input-kanban result <runId> --copy
|
|
258
|
+
|
|
259
|
+
Options:
|
|
260
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
261
|
+
--copy Copy final result to clipboard
|
|
262
|
+
`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function printStopHelp() {
|
|
266
|
+
console.log(`input-kanban stop
|
|
267
|
+
|
|
268
|
+
Usage:
|
|
269
|
+
input-kanban stop <runId>
|
|
270
|
+
|
|
271
|
+
Options:
|
|
272
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
273
|
+
--reason <text> Stop reason stored in run state
|
|
274
|
+
`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function printAutoHelp() {
|
|
278
|
+
console.log(`input-kanban auto
|
|
279
|
+
|
|
280
|
+
Usage:
|
|
281
|
+
input-kanban auto <runId>
|
|
282
|
+
|
|
283
|
+
Options:
|
|
284
|
+
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
285
|
+
--codex-bin <path> Codex CLI executable, default codex
|
|
286
|
+
--runner <mode> Runner mode: headless or tmux
|
|
287
|
+
--poll-ms <ms> Watch poll interval, default 3000
|
|
47
288
|
`);
|
|
48
289
|
}
|
|
49
290
|
|
|
@@ -54,17 +295,124 @@ function openBrowser(url) {
|
|
|
54
295
|
child.unref();
|
|
55
296
|
}
|
|
56
297
|
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (args.
|
|
65
|
-
if (args.
|
|
66
|
-
if (args.
|
|
298
|
+
async function readStdin() {
|
|
299
|
+
const chunks = [];
|
|
300
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
301
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function readTaskText(args) {
|
|
305
|
+
if (args.taskText !== undefined) return args.taskText;
|
|
306
|
+
if (args.taskFile === '-') return await readStdin();
|
|
307
|
+
if (args.taskFile) return await fsp.readFile(path.resolve(args.taskFile), 'utf8');
|
|
308
|
+
throw new Error('submit requires --task or --task-file');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function webUrl(args, runId = '') {
|
|
312
|
+
return `http://${args.host || '127.0.0.1'}:${Number(args.port || 8787)}${runId ? ` (runId: ${runId})` : ''}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function displayStatus(status) {
|
|
316
|
+
const text = STATUS_TEXT[status] || status || '-';
|
|
317
|
+
return status && text !== status ? `${text}(${status})` : text;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function countByStatus(state) {
|
|
321
|
+
const tasks = state.tasks || [];
|
|
322
|
+
return {
|
|
323
|
+
total: tasks.length,
|
|
324
|
+
completed: tasks.filter(task => task.status === 'completed').length,
|
|
325
|
+
running: tasks.filter(task => task.status === 'running').length,
|
|
326
|
+
failed: tasks.filter(task => ['failed', 'unknown'].includes(task.status)).length
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function currentBatchText(state) {
|
|
331
|
+
const batch = (state.batches || []).find(item => item.status !== 'completed');
|
|
332
|
+
if (!batch) return '-';
|
|
333
|
+
const tasks = batch.tasks || [];
|
|
334
|
+
const completed = tasks.filter(task => task.status === 'completed').length;
|
|
335
|
+
return `${batch.name || batch.id}(${batch.id}) ${displayStatus(batch.status)} ${completed}/${tasks.length}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function statusLine(state) {
|
|
339
|
+
const counts = countByStatus(state);
|
|
340
|
+
return `${state.label || state.runId}|${state.runId}|状态 ${displayStatus(state.status)}|进度 ${counts.completed}/${counts.total}|执行中 ${counts.running}|失败 ${counts.failed}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function printRunStatus(state) {
|
|
344
|
+
const counts = countByStatus(state);
|
|
345
|
+
console.log(`任务批次: ${state.label || '-'}`);
|
|
346
|
+
console.log(`Run ID: ${state.runId}`);
|
|
347
|
+
console.log(`状态: ${displayStatus(state.status)}`);
|
|
348
|
+
console.log(`仓库: ${state.repo || '-'}`);
|
|
349
|
+
console.log(`当前批次: ${currentBatchText(state)}`);
|
|
350
|
+
console.log(`进度: ${counts.completed}/${counts.total} |执行中 ${counts.running} |失败 ${counts.failed}`);
|
|
351
|
+
if (state.judge?.status && state.judge.status !== 'pending') console.log(`验收: ${displayStatus(state.judge.status)}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isTerminal(state) {
|
|
355
|
+
return ['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(state.status);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isFailureTerminal(state) {
|
|
359
|
+
return ['judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(state.status);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function hasRecoverableUnknownTask(state) {
|
|
363
|
+
return (state.tasks || []).some(task => task.status === 'unknown' && (task.exitCode === undefined || task.exitCode === 0));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function confirmFailureTerminal(runId, state, refreshRun, pollMs) {
|
|
367
|
+
let confirmed = state;
|
|
368
|
+
const deadline = Date.now() + 30000;
|
|
369
|
+
while (confirmed?.status === 'batch_blocked' && hasRecoverableUnknownTask(confirmed) && Date.now() < deadline) {
|
|
370
|
+
await delay(Math.max(500, Number(pollMs) || 3000));
|
|
371
|
+
confirmed = await refreshRun(runId);
|
|
372
|
+
if (!confirmed || !isTerminal(confirmed) || confirmed.status !== state.status) return { confirmed: false, state: confirmed };
|
|
373
|
+
}
|
|
374
|
+
return { confirmed: true, state: confirmed };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function watchRun(runId, { auto = false, pollMs = 3000 } = {}) {
|
|
378
|
+
const { dispatchRun, refreshRun, startJudge } = await import('../src/orchestrator.js');
|
|
379
|
+
let lastStatus = '';
|
|
380
|
+
let judgeStarted = false;
|
|
381
|
+
while (true) {
|
|
382
|
+
const state = await refreshRun(runId);
|
|
383
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
384
|
+
const line = statusLine(state);
|
|
385
|
+
if (line !== lastStatus) {
|
|
386
|
+
console.log(`[${new Date().toLocaleTimeString()}] ${line}`);
|
|
387
|
+
lastStatus = line;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (auto && state.status === 'planned') {
|
|
391
|
+
console.log('自动派发任务...');
|
|
392
|
+
await dispatchRun(runId);
|
|
393
|
+
} else if (auto && state.status === 'batches_completed' && state.judge?.status !== 'running' && !judgeStarted) {
|
|
394
|
+
console.log('自动发起最终验收...');
|
|
395
|
+
judgeStarted = true;
|
|
396
|
+
await startJudge(runId);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (isTerminal(state)) {
|
|
400
|
+
if (isFailureTerminal(state)) {
|
|
401
|
+
const result = await confirmFailureTerminal(runId, state, refreshRun, pollMs);
|
|
402
|
+
if (!result.confirmed) {
|
|
403
|
+
lastStatus = '';
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
return result.state || state;
|
|
407
|
+
}
|
|
408
|
+
return state;
|
|
409
|
+
}
|
|
410
|
+
await delay(Math.max(500, Number(pollMs) || 3000));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
67
413
|
|
|
414
|
+
async function serve(args) {
|
|
415
|
+
applyRuntimeEnv(args);
|
|
68
416
|
const { startServer } = await import('../src/server.js');
|
|
69
417
|
const instance = await startServer({ host: process.env.HOST, port: Number(process.env.PORT || 8787), log: false });
|
|
70
418
|
console.log('Input Kanban started');
|
|
@@ -76,6 +424,168 @@ try {
|
|
|
76
424
|
const shutdown = () => { instance.stop().finally(() => process.exit(0)); };
|
|
77
425
|
process.on('SIGINT', shutdown);
|
|
78
426
|
process.on('SIGTERM', shutdown);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function detachedAutoArgs(runId, args) {
|
|
430
|
+
const cliPath = fileURLToPath(import.meta.url);
|
|
431
|
+
const values = [cliPath, 'auto', runId, '--host', args.host || '127.0.0.1', '--port', String(args.port || 8787), '--poll-ms', String(args.pollMs || 3000)];
|
|
432
|
+
if (args.runsDir) values.push('--runs-dir', path.resolve(args.runsDir));
|
|
433
|
+
if (args.codexBin) values.push('--codex-bin', args.codexBin);
|
|
434
|
+
if (args.runner) values.push('--runner', args.runner);
|
|
435
|
+
return values;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function startDetachedAuto(runId, args) {
|
|
439
|
+
const child = spawn(process.execPath, detachedAutoArgs(runId, args), {
|
|
440
|
+
detached: true,
|
|
441
|
+
stdio: 'ignore',
|
|
442
|
+
env: process.env
|
|
443
|
+
});
|
|
444
|
+
child.unref();
|
|
445
|
+
return child.pid;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function latestRunId() {
|
|
449
|
+
const { listRuns } = await import('../src/orchestrator.js');
|
|
450
|
+
const runs = await listRuns();
|
|
451
|
+
if (!runs.length) throw new Error('没有找到任务批次');
|
|
452
|
+
return runs[0].runId;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function status(args) {
|
|
456
|
+
applyRuntimeEnv(args);
|
|
457
|
+
const runId = args.runId || await latestRunId();
|
|
458
|
+
if (args.watch) {
|
|
459
|
+
await watchRun(runId, { auto: false, pollMs: args.pollMs });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const { refreshRun } = await import('../src/orchestrator.js');
|
|
463
|
+
const state = await refreshRun(runId);
|
|
464
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
465
|
+
printRunStatus(state);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function readFinalResult(runId) {
|
|
469
|
+
const { loadRun, readRunFile } = await import('../src/orchestrator.js');
|
|
470
|
+
const state = await loadRun(runId);
|
|
471
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
472
|
+
try { return await readRunFile(runId, 'judge', 'verdict.json'); }
|
|
473
|
+
catch {}
|
|
474
|
+
try { return await readRunFile(runId, 'judge', 'last_message.md'); }
|
|
475
|
+
catch {}
|
|
476
|
+
throw new Error(`最终结果尚未生成:当前状态 ${displayStatus(state.status)}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function clipboardCommands() {
|
|
480
|
+
if (process.platform === 'darwin') return [['pbcopy', []]];
|
|
481
|
+
if (process.platform === 'win32') return [['clip', []]];
|
|
482
|
+
return [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function copyToClipboard(text) {
|
|
486
|
+
let lastError = null;
|
|
487
|
+
for (const [command, args] of clipboardCommands()) {
|
|
488
|
+
try {
|
|
489
|
+
await new Promise((resolve, reject) => {
|
|
490
|
+
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
491
|
+
child.on('error', reject);
|
|
492
|
+
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${command} exited with ${code}`)));
|
|
493
|
+
child.stdin.end(text);
|
|
494
|
+
});
|
|
495
|
+
return;
|
|
496
|
+
} catch (error) {
|
|
497
|
+
lastError = error;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
throw new Error(`无法复制到剪贴板:${lastError?.message || '未找到可用剪贴板命令'}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function result(args) {
|
|
504
|
+
applyRuntimeEnv(args);
|
|
505
|
+
const runId = args.runId || await latestRunId();
|
|
506
|
+
const text = await readFinalResult(runId);
|
|
507
|
+
if (args.copy) {
|
|
508
|
+
await copyToClipboard(text);
|
|
509
|
+
console.log(`已复制最终结果: ${runId}`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
console.log(text);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function stop(args) {
|
|
516
|
+
applyRuntimeEnv(args);
|
|
517
|
+
if (!args.runId) throw new Error('stop requires a runId');
|
|
518
|
+
const { stopRun } = await import('../src/orchestrator.js');
|
|
519
|
+
const state = await stopRun(args.runId, { reason: args.reason });
|
|
520
|
+
console.log(`已停止任务批次: ${state.runId}`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function autoRun(args) {
|
|
524
|
+
applyRuntimeEnv(args);
|
|
525
|
+
if (!args.runId) throw new Error('auto requires a runId');
|
|
526
|
+
const { loadRun, startPlanner } = await import('../src/orchestrator.js');
|
|
527
|
+
const state = await loadRun(args.runId);
|
|
528
|
+
if (!state) throw new Error(`run not found: ${args.runId}`);
|
|
529
|
+
if (state.status === 'created') await startPlanner(args.runId);
|
|
530
|
+
const finalState = await watchRun(args.runId, { auto: true, pollMs: args.pollMs });
|
|
531
|
+
if (isFailureTerminal(finalState)) process.exitCode = 1;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function submit(args) {
|
|
535
|
+
if (args.detach && !args.auto) throw new Error('--detach requires auto mode; remove --no-auto');
|
|
536
|
+
applyRuntimeEnv(args);
|
|
537
|
+
const taskText = await readTaskText(args);
|
|
538
|
+
const { createRun, startPlanner } = await import('../src/orchestrator.js');
|
|
539
|
+
const state = await createRun({
|
|
540
|
+
label: args.label,
|
|
541
|
+
taskText,
|
|
542
|
+
repo: process.env.KANBAN_DEFAULT_REPO,
|
|
543
|
+
maxParallel: args.maxParallel,
|
|
544
|
+
workerSandbox: args.workerSandbox
|
|
545
|
+
});
|
|
546
|
+
console.log(`已创建任务批次: ${state.runId}`);
|
|
547
|
+
console.log(`看板地址: ${webUrl(args, state.runId)}`);
|
|
548
|
+
console.log(`终端查看: input-kanban status ${state.runId} --watch`);
|
|
549
|
+
if (args.detach) {
|
|
550
|
+
const pid = startDetachedAuto(state.runId, args);
|
|
551
|
+
console.log(`后台执行中: supervisor pid ${pid}`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
console.log('发起任务拆分...');
|
|
555
|
+
await startPlanner(state.runId);
|
|
556
|
+
if (!args.watch && !args.auto) return;
|
|
557
|
+
const finalState = await watchRun(state.runId, { auto: args.auto, pollMs: args.pollMs });
|
|
558
|
+
console.log(`最终状态: ${finalState.status}`);
|
|
559
|
+
if (isFailureTerminal(finalState)) process.exitCode = 1;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const { command, rest } = splitCommand(process.argv.slice(2));
|
|
564
|
+
if (command === 'serve') {
|
|
565
|
+
const args = parseServeArgs(rest);
|
|
566
|
+
if (args.help) { printHelp(); process.exit(0); }
|
|
567
|
+
await serve(args);
|
|
568
|
+
} else if (command === 'submit') {
|
|
569
|
+
const args = parseSubmitArgs(rest);
|
|
570
|
+
if (args.help) { printSubmitHelp(); process.exit(0); }
|
|
571
|
+
await submit(args);
|
|
572
|
+
} else if (command === 'status') {
|
|
573
|
+
const args = parseStatusArgs(rest);
|
|
574
|
+
if (args.help) { printStatusHelp(); process.exit(0); }
|
|
575
|
+
await status(args);
|
|
576
|
+
} else if (command === 'result') {
|
|
577
|
+
const args = parseResultArgs(rest);
|
|
578
|
+
if (args.help) { printResultHelp(); process.exit(0); }
|
|
579
|
+
await result(args);
|
|
580
|
+
} else if (command === 'stop') {
|
|
581
|
+
const args = parseStopArgs(rest);
|
|
582
|
+
if (args.help) { printStopHelp(); process.exit(0); }
|
|
583
|
+
await stop(args);
|
|
584
|
+
} else if (command === 'auto') {
|
|
585
|
+
const args = parseAutoArgs(rest);
|
|
586
|
+
if (args.help) { printAutoHelp(); process.exit(0); }
|
|
587
|
+
await autoRun(args);
|
|
588
|
+
}
|
|
79
589
|
} catch (error) {
|
|
80
590
|
console.error(error.message || String(error));
|
|
81
591
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "input-kanban",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"input-kanban": "bin/input-kanban.js"
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"start": "node bin/input-kanban.js",
|
|
10
10
|
"check": "node --check bin/input-kanban.js && node --check bin/input-kanban-format-events.js && node --check bin/input-kanban-timestamp-events.js && node --check bin/input-kanban-tmux-overview.js && node --check src/server.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js && node --check src/eventFormatter.js && node --check src/runners/index.js && node --check src/runners/headlessRunner.js && node --check src/runners/tmuxRunner.js && node --check src/runners/tmuxUtils.js && node --check src/tmux.js && node --test"
|
|
11
11
|
},
|
|
12
|
-
"dependencies": {},
|
|
13
12
|
"description": "A local Codex orchestration kanban dashboard",
|
|
14
13
|
"files": [
|
|
15
14
|
"bin",
|
package/public/index.html
CHANGED
|
@@ -84,11 +84,17 @@
|
|
|
84
84
|
.meta-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
85
85
|
.meta-chip.long .meta-value { max-width: min(680px, 72vw); }
|
|
86
86
|
.meta-chip .copy-btn { margin-left: 2px; }
|
|
87
|
+
.refresh-pulse-chip { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--line); border-radius: 999px; background: #020617; }
|
|
88
|
+
.refresh-pulse-dot { width: 12px; height: 12px; border: 2px solid #60a5fa; border-top-color: transparent; border-radius: 999px; opacity: .62; }
|
|
89
|
+
.refresh-pulse-chip.pulse .refresh-pulse-dot { animation: refresh-spin .8s ease-out; }
|
|
90
|
+
@keyframes refresh-spin { 0% { transform: rotate(0deg) scale(.75); opacity: 1; } 70% { transform: rotate(300deg) scale(1.18); opacity: 1; } 100% { transform: rotate(360deg) scale(1); opacity: .62; } }
|
|
87
91
|
.log-panel { margin-top: 16px; }
|
|
88
92
|
.file-tabs button { font-size: 13px; }
|
|
89
93
|
.copy-btn { padding: 2px 6px; margin: 0 0 0 6px; border-radius: 6px; font-size: 12px; line-height: 1.2; background: var(--gray); vertical-align: middle; }
|
|
90
94
|
.rename-btn { opacity: 0; pointer-events: none; transition: opacity .15s ease; }
|
|
91
95
|
.run-card:hover .rename-btn, .run-card:focus-within .rename-btn, .build-title:hover .rename-btn, .build-title:focus-within .rename-btn, .rename-btn:focus { opacity: 1; pointer-events: auto; }
|
|
96
|
+
.run-card-title-actions { display: inline-flex; align-items: center; gap: 3px; }
|
|
97
|
+
.archive-confirm-btn { min-width: 46px; padding: 4px 10px; border-color: rgba(248,113,113,.85); background: var(--red) !important; color: white; font-weight: 900; }
|
|
92
98
|
.icon-svg { width: 14px; height: 14px; display: block; }
|
|
93
99
|
.session-cell { word-break: break-all; }
|
|
94
100
|
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
@@ -198,6 +204,7 @@ let selectedRun = null;
|
|
|
198
204
|
let selectedTask = null;
|
|
199
205
|
let selectedFileName = null;
|
|
200
206
|
let manualCompleteTaskId = null;
|
|
207
|
+
let pendingArchiveRunId = null;
|
|
201
208
|
let currentState = null;
|
|
202
209
|
let lastAutoRefreshAt = null;
|
|
203
210
|
let runListVisibleCount = 10;
|
|
@@ -226,6 +233,7 @@ function userFacingErrorMessage(error) {
|
|
|
226
233
|
if (/planner already running/i.test(detail)) return '任务拆分正在进行中,请稍后查看结果。';
|
|
227
234
|
if (/judge already running/i.test(detail)) return '验收正在进行中,请稍后查看结果。';
|
|
228
235
|
if (/already running/i.test(detail)) return '任务正在进行中,请稍后查看结果。';
|
|
236
|
+
if (/cannot archive.*running/i.test(detail)) return '任务仍在执行中,请先停止后再归档。';
|
|
229
237
|
return error?.message || String(error);
|
|
230
238
|
}
|
|
231
239
|
const statusText = {
|
|
@@ -279,6 +287,9 @@ function metaChip(label, value, { title = value, danger = false, long = false, e
|
|
|
279
287
|
function editIcon() {
|
|
280
288
|
return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 16.5V20h3.5L18.1 9.4l-3.5-3.5L4 16.5Z" fill="currentColor"/><path d="m16 4.5 3.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
281
289
|
}
|
|
290
|
+
function archiveIcon() {
|
|
291
|
+
return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 5h16v4H4V5Z" fill="currentColor"/><path d="M6 10h12v9H6v-9Z" fill="currentColor" opacity=".72"/><path d="M9 13h6" stroke="#020617" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
292
|
+
}
|
|
282
293
|
function isTmuxMode() { return currentState?.runner === 'tmux'; }
|
|
283
294
|
function taskById(id) {
|
|
284
295
|
if (!currentState) return null;
|
|
@@ -350,8 +361,8 @@ async function refreshRuns() {
|
|
|
350
361
|
function renderRunList() {
|
|
351
362
|
const visibleRuns = latestRuns.slice(0, runListVisibleCount);
|
|
352
363
|
const cards = visibleRuns.map(r => `
|
|
353
|
-
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')">
|
|
354
|
-
<div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button></span><span>${pill(r.status)}</span></div>
|
|
364
|
+
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')" onmouseleave="clearArchiveConfirm('${r.runId}')">
|
|
365
|
+
<div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span class="run-card-title-actions"><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button><button class="secondary copy-btn rename-btn ${pendingArchiveRunId === r.runId ? 'archive-confirm-btn' : ''}" title="${pendingArchiveRunId === r.runId ? '再次点击确认归档' : '归档任务批次(运行中请先停止)'}" onclick="archiveRunFromCard(event, '${r.runId}')">${pendingArchiveRunId === r.runId ? '确认' : archiveIcon()}</button></span></span><span>${pill(r.status)}</span></div>
|
|
355
366
|
<div class="run-card-meta">
|
|
356
367
|
${metaChip('仓库', basenamePath(r.repo), { title: r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
|
|
357
368
|
${metaChip('创建', formatDateTime(r.createdAt))}
|
|
@@ -377,6 +388,7 @@ async function refreshSelected({auto=false} = {}) {
|
|
|
377
388
|
statusByRunId.set(selectedRun, currentState);
|
|
378
389
|
if (auto) lastAutoRefreshAt = new Date();
|
|
379
390
|
document.getElementById('selected').innerHTML = renderSelectedHeader();
|
|
391
|
+
if (auto) requestAnimationFrame(triggerRefreshPulse);
|
|
380
392
|
updateAutoRefreshHint();
|
|
381
393
|
updateRunNotice();
|
|
382
394
|
await loadTaskDescription();
|
|
@@ -407,14 +419,24 @@ function renderSelectedHeader() {
|
|
|
407
419
|
chips.push(metaChip('终端', 'tmux 现场尚未生成'));
|
|
408
420
|
}
|
|
409
421
|
}
|
|
410
|
-
chips.push(
|
|
411
|
-
chips.push(metaChip('上次', lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发'));
|
|
422
|
+
chips.push(refreshPulseChip());
|
|
412
423
|
return `<div class="build-title"><span>${esc(currentState.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, currentState.runId)">${editIcon()}</button>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
|
|
413
424
|
}
|
|
414
425
|
async function loadTaskDescription() {
|
|
415
426
|
if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
|
|
416
427
|
document.getElementById('taskDescription').textContent = await api(`/api/runs/${selectedRun}/task-text`);
|
|
417
428
|
}
|
|
429
|
+
function refreshPulseChip() {
|
|
430
|
+
const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
|
|
431
|
+
return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
|
|
432
|
+
}
|
|
433
|
+
function triggerRefreshPulse() {
|
|
434
|
+
const el = document.getElementById('refreshPulse');
|
|
435
|
+
if (!el) return;
|
|
436
|
+
el.classList.remove('pulse');
|
|
437
|
+
void el.offsetWidth;
|
|
438
|
+
el.classList.add('pulse');
|
|
439
|
+
}
|
|
418
440
|
function updateRunNotice() {
|
|
419
441
|
const el = document.getElementById('runNotice');
|
|
420
442
|
if (!el) return;
|
|
@@ -800,21 +822,46 @@ async function stopSelectedRun() {
|
|
|
800
822
|
});
|
|
801
823
|
await refreshSelected();
|
|
802
824
|
}
|
|
825
|
+
function clearArchiveConfirm(runId) {
|
|
826
|
+
if (pendingArchiveRunId !== runId) return;
|
|
827
|
+
pendingArchiveRunId = null;
|
|
828
|
+
renderRunList();
|
|
829
|
+
}
|
|
830
|
+
async function archiveRunFromCard(event, runId) {
|
|
831
|
+
event.stopPropagation();
|
|
832
|
+
if (pendingArchiveRunId !== runId) {
|
|
833
|
+
pendingArchiveRunId = runId;
|
|
834
|
+
renderRunList();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
pendingArchiveRunId = null;
|
|
838
|
+
await archiveRunById(runId, { confirmFirst: false });
|
|
839
|
+
}
|
|
840
|
+
async function archiveRunById(runId, { confirmFirst = true } = {}) {
|
|
841
|
+
if (!runId) return;
|
|
842
|
+
if (confirmFirst) {
|
|
843
|
+
const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
|
|
844
|
+
if (!ok) return;
|
|
845
|
+
}
|
|
846
|
+
await runAction(async () => {
|
|
847
|
+
await api(`/api/runs/${runId}/archive`, {
|
|
848
|
+
method: 'POST',
|
|
849
|
+
body: JSON.stringify({ reason: 'archived from dashboard' })
|
|
850
|
+
});
|
|
851
|
+
if (selectedRun === runId) {
|
|
852
|
+
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
853
|
+
document.getElementById('selected').textContent = '未选择任务批次';
|
|
854
|
+
document.getElementById('taskDescription').textContent = '未选择任务批次';
|
|
855
|
+
document.getElementById('tasks').innerHTML = '';
|
|
856
|
+
clearFileView();
|
|
857
|
+
updateAutoRefreshHint();
|
|
858
|
+
}
|
|
859
|
+
await refreshRuns();
|
|
860
|
+
});
|
|
861
|
+
}
|
|
803
862
|
async function archiveSelectedRun() {
|
|
804
863
|
if (!selectedRun) return;
|
|
805
|
-
|
|
806
|
-
if (!ok) return;
|
|
807
|
-
await api(`/api/runs/${selectedRun}/archive`, {
|
|
808
|
-
method: 'POST',
|
|
809
|
-
body: JSON.stringify({ reason: 'archived from dashboard' })
|
|
810
|
-
});
|
|
811
|
-
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
812
|
-
document.getElementById('selected').textContent = '未选择任务批次';
|
|
813
|
-
document.getElementById('taskDescription').textContent = '未选择任务批次';
|
|
814
|
-
document.getElementById('tasks').innerHTML = '';
|
|
815
|
-
clearFileView();
|
|
816
|
-
updateAutoRefreshHint();
|
|
817
|
-
await refreshRuns();
|
|
864
|
+
await archiveRunById(selectedRun);
|
|
818
865
|
}
|
|
819
866
|
async function markTaskCompleted(event, taskId) {
|
|
820
867
|
event.stopPropagation();
|
package/src/orchestrator.js
CHANGED
|
@@ -15,6 +15,8 @@ import { defaultRunner } from './runners/index.js';
|
|
|
15
15
|
const execFileAsync = promisify(execFile);
|
|
16
16
|
const runner = defaultRunner;
|
|
17
17
|
const VALID_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
18
|
+
const MISSING_RUNNER_GRACE_MS = 10000;
|
|
19
|
+
const MAX_DERIVED_LABEL_DISPLAY_WIDTH = 40;
|
|
18
20
|
|
|
19
21
|
function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
20
22
|
const sandbox = String(value || '').trim();
|
|
@@ -25,12 +27,81 @@ function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
|
25
27
|
function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
|
|
26
28
|
function planPath(runDir) { return path.join(runDir, 'plan.json'); }
|
|
27
29
|
|
|
30
|
+
function shouldMarkRunnerUnknown(target) {
|
|
31
|
+
const missingSince = Date.parse(target.missingRunnerAt || '');
|
|
32
|
+
if (!Number.isFinite(missingSince)) {
|
|
33
|
+
target.missingRunnerAt = nowIso();
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return Date.now() - missingSince >= MISSING_RUNNER_GRACE_MS;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clearMissingRunner(target) {
|
|
40
|
+
delete target.missingRunnerAt;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPidAlive(pid) {
|
|
44
|
+
const value = Number(pid);
|
|
45
|
+
if (!Number.isFinite(value) || value <= 0) return false;
|
|
46
|
+
try {
|
|
47
|
+
process.kill(value, 0);
|
|
48
|
+
return true;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return error?.code === 'EPERM';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hasLiveRunnerProcess(state, id, target) {
|
|
55
|
+
return runner.hasRunning(state.runId, id) || isPidAlive(target?.pid);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stopPid(pid, signal = 'SIGTERM') {
|
|
59
|
+
const value = Number(pid);
|
|
60
|
+
if (!Number.isFinite(value) || value <= 0) return false;
|
|
61
|
+
try {
|
|
62
|
+
process.kill(value, signal);
|
|
63
|
+
if (signal !== 'SIGKILL') setTimeout(() => { if (isPidAlive(value)) stopPid(value, 'SIGKILL'); }, 1000).unref?.();
|
|
64
|
+
return true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return error?.code === 'ESRCH';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
28
70
|
function userInputError(message) {
|
|
29
71
|
const error = new Error(message);
|
|
30
72
|
error.statusCode = 400;
|
|
31
73
|
return error;
|
|
32
74
|
}
|
|
33
75
|
|
|
76
|
+
function charDisplayWidth(char) {
|
|
77
|
+
return char.codePointAt(0) > 0x2e80 ? 2 : 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function truncateDisplayWidth(text, maxWidth) {
|
|
81
|
+
let width = 0;
|
|
82
|
+
let result = '';
|
|
83
|
+
for (const char of text) {
|
|
84
|
+
const nextWidth = width + charDisplayWidth(char);
|
|
85
|
+
if (nextWidth > maxWidth) return `${result.trimEnd()}…`;
|
|
86
|
+
result += char;
|
|
87
|
+
width = nextWidth;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function deriveRunLabel(label, taskText) {
|
|
93
|
+
const explicit = String(label || '').trim();
|
|
94
|
+
if (explicit) return explicit;
|
|
95
|
+
const firstLine = String(taskText || '').split(/\r?\n/).map(line => line.trim()).find(Boolean) || 'task';
|
|
96
|
+
const cleaned = firstLine
|
|
97
|
+
.replace(/^#{1,6}\s+/, '')
|
|
98
|
+
.replace(/^[-*+]\s+/, '')
|
|
99
|
+
.replace(/^\d+[.)、]\s*/, '')
|
|
100
|
+
.replace(/\s+/g, ' ')
|
|
101
|
+
.trim();
|
|
102
|
+
return truncateDisplayWidth(cleaned, MAX_DERIVED_LABEL_DISPLAY_WIDTH) || 'task';
|
|
103
|
+
}
|
|
104
|
+
|
|
34
105
|
async function assertGitWorkTree(repo) {
|
|
35
106
|
const resolvedRepo = path.resolve(repo || DEFAULT_REPO);
|
|
36
107
|
let stat;
|
|
@@ -44,14 +115,15 @@ async function assertGitWorkTree(repo) {
|
|
|
44
115
|
throw userInputError(`target repository is not a git work tree: ${resolvedRepo}`);
|
|
45
116
|
}
|
|
46
117
|
|
|
47
|
-
export async function createRun({ label = '
|
|
118
|
+
export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
48
119
|
const resolvedRepo = await assertGitWorkTree(repo);
|
|
49
|
-
const
|
|
120
|
+
const runLabel = deriveRunLabel(label, taskText);
|
|
121
|
+
const runId = makeRunId(runLabel);
|
|
50
122
|
const runDir = pathForRun(runId);
|
|
51
123
|
await ensureDir(runDir);
|
|
52
124
|
await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
|
|
53
125
|
const state = {
|
|
54
|
-
runId, label, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
|
|
126
|
+
runId, label: runLabel, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
|
|
55
127
|
runner: RUNNER,
|
|
56
128
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
57
129
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
@@ -200,7 +272,17 @@ export async function startPlanner(runId) {
|
|
|
200
272
|
return state;
|
|
201
273
|
}
|
|
202
274
|
|
|
203
|
-
function
|
|
275
|
+
function normalizeExpectedArtifacts(value, runId, taskId) {
|
|
276
|
+
const artifacts = Array.isArray(value) ? value : [];
|
|
277
|
+
return artifacts.map(item => String(item || '').trim()).filter(Boolean).map(item => {
|
|
278
|
+
if (path.isAbsolute(item)) return item;
|
|
279
|
+
const normalized = item.replace(/^\.\//, '');
|
|
280
|
+
if (normalized.includes(runId)) return normalized;
|
|
281
|
+
return path.posix.join('.orchestrator', runId, taskId, normalized);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write', runId = '') {
|
|
204
286
|
const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
|
|
205
287
|
return {
|
|
206
288
|
id,
|
|
@@ -208,7 +290,7 @@ function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
|
|
|
208
290
|
name: t.name || t.id || `Task ${i + 1}`,
|
|
209
291
|
prompt: t.prompt || t.instructions || '',
|
|
210
292
|
sandbox: normalizeSandbox(t.sandbox, defaultSandbox),
|
|
211
|
-
expectedArtifacts:
|
|
293
|
+
expectedArtifacts: normalizeExpectedArtifacts(t.expectedArtifacts, runId, id),
|
|
212
294
|
status: 'pending'
|
|
213
295
|
};
|
|
214
296
|
}
|
|
@@ -240,7 +322,7 @@ async function rotatePlannerAttempt(state, runDir) {
|
|
|
240
322
|
}];
|
|
241
323
|
}
|
|
242
324
|
|
|
243
|
-
function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write') {
|
|
325
|
+
function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write', runId = '') {
|
|
244
326
|
if (Array.isArray(plan.batches)) {
|
|
245
327
|
const batches = plan.batches.map((b, bi) => {
|
|
246
328
|
const batch = {
|
|
@@ -250,14 +332,14 @@ function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-wri
|
|
|
250
332
|
status: 'pending',
|
|
251
333
|
tasks: []
|
|
252
334
|
};
|
|
253
|
-
batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox));
|
|
335
|
+
batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox, runId));
|
|
254
336
|
return batch;
|
|
255
337
|
}).filter(b => b.tasks.length);
|
|
256
338
|
return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
|
|
257
339
|
}
|
|
258
340
|
if (Array.isArray(plan.tasks)) {
|
|
259
341
|
const batch = { id: 'batch-1', name: '默认批次', maxParallel: Math.max(1, Number(defaultMaxParallel) || 1), status: 'pending', tasks: [] };
|
|
260
|
-
batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox));
|
|
342
|
+
batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox, runId));
|
|
261
343
|
return { ...plan, batches: [batch], tasks: batch.tasks };
|
|
262
344
|
}
|
|
263
345
|
return null;
|
|
@@ -273,7 +355,7 @@ async function materializePlan(state) {
|
|
|
273
355
|
state.tasks = [];
|
|
274
356
|
return { ok: false, empty: false, error: state.planner.planParseError };
|
|
275
357
|
}
|
|
276
|
-
const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write');
|
|
358
|
+
const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write', state.runId);
|
|
277
359
|
if (!normalized || !Array.isArray(normalized.tasks)) {
|
|
278
360
|
state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
|
|
279
361
|
state.batches = [];
|
|
@@ -310,6 +392,28 @@ export async function dispatchRun(runId) {
|
|
|
310
392
|
return state;
|
|
311
393
|
}
|
|
312
394
|
|
|
395
|
+
function artifactPathForState(state, rel) {
|
|
396
|
+
return path.isAbsolute(rel) ? rel : path.join(state.repo, rel);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function workerArtifactInstructions(state, task) {
|
|
400
|
+
const artifacts = task.expectedArtifacts || [];
|
|
401
|
+
if (!artifacts.length) return '';
|
|
402
|
+
const lines = artifacts.map(rel => `- ${artifactPathForState(state, rel)}`);
|
|
403
|
+
return `\n\nRequired output artifacts:\nWrite the following artifact path(s) exactly. Create parent directories if needed.\n${lines.join('\n')}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function upstreamArtifactInstructions(state, task) {
|
|
407
|
+
const currentBatchIndex = (state.batches || []).findIndex(batch => batch.id === task.batchId);
|
|
408
|
+
if (currentBatchIndex <= 0) return '';
|
|
409
|
+
const previousTaskIds = new Set((state.batches || []).slice(0, currentBatchIndex).flatMap(batch => (batch.tasks || []).map(item => item.id)));
|
|
410
|
+
const lines = (state.tasks || [])
|
|
411
|
+
.filter(item => previousTaskIds.has(item.id))
|
|
412
|
+
.flatMap(item => (item.expectedArtifacts || []).map(rel => `- ${item.id}: ${artifactPathForState(state, rel)}`));
|
|
413
|
+
if (!lines.length) return '';
|
|
414
|
+
return `\n\nAvailable upstream artifacts from completed earlier batches:\n${lines.join('\n')}\nUse only artifacts from this run id: ${state.runId}.`;
|
|
415
|
+
}
|
|
416
|
+
|
|
313
417
|
async function startWorkerInState(state, task) {
|
|
314
418
|
const runDir = pathForRun(state.runId);
|
|
315
419
|
const outDir = roleDir(runDir, 'worker', task.id);
|
|
@@ -317,7 +421,7 @@ async function startWorkerInState(state, task) {
|
|
|
317
421
|
const fullPrompt = `${marker(state.runId, task.id, 'worker')}
|
|
318
422
|
ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
|
|
319
423
|
|
|
320
|
-
${task.prompt}
|
|
424
|
+
${task.prompt}${workerArtifactInstructions(state, task)}${upstreamArtifactInstructions(state, task)}
|
|
321
425
|
`;
|
|
322
426
|
const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, batchId: task.batchId || 'batch-1', runStatePath: statePath(runDir), prompt: fullPrompt, sandbox: task.sandbox || state.workerSandbox || 'workspace-write', cwd: state.repo, outDir });
|
|
323
427
|
Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
|
|
@@ -328,11 +432,28 @@ export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
|
|
|
328
432
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
329
433
|
const stoppedAt = nowIso();
|
|
330
434
|
await runner.stopRun(runId);
|
|
435
|
+
const stoppedPids = new Set();
|
|
436
|
+
const stopTargetPid = target => {
|
|
437
|
+
const pid = Number(target?.pid);
|
|
438
|
+
if (Number.isFinite(pid) && pid > 0 && !stoppedPids.has(pid)) {
|
|
439
|
+
stoppedPids.add(pid);
|
|
440
|
+
stopPid(pid);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
331
443
|
for (const roleState of [state.planner, state.judge]) {
|
|
332
|
-
if (roleState?.status === 'running')
|
|
444
|
+
if (roleState?.status === 'running') {
|
|
445
|
+
stopTargetPid(roleState);
|
|
446
|
+
Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
447
|
+
}
|
|
333
448
|
}
|
|
334
449
|
for (const task of state.tasks || []) {
|
|
335
|
-
if (task.status === 'running')
|
|
450
|
+
if (task.status === 'running') {
|
|
451
|
+
stopTargetPid(task);
|
|
452
|
+
Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const batch of state.batches || []) {
|
|
456
|
+
for (const task of batch.tasks || []) if (task.status === 'running') stopTargetPid(task);
|
|
336
457
|
}
|
|
337
458
|
for (const batch of state.batches || []) {
|
|
338
459
|
if ((batch.tasks || []).some(t => t.status === 'stopped')) batch.status = 'stopped';
|
|
@@ -495,9 +616,16 @@ async function refreshRole(state, roleState, dir) {
|
|
|
495
616
|
if (exit !== '') {
|
|
496
617
|
roleState.exitCode = Number(exit.trim());
|
|
497
618
|
if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
|
|
498
|
-
|
|
619
|
+
clearMissingRunner(roleState);
|
|
620
|
+
if (['running', 'unknown'].includes(roleState.status)) roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
|
|
621
|
+
}
|
|
622
|
+
else if (['running', 'unknown'].includes(roleState.status) && hasLiveRunnerProcess(state, key, roleState)) {
|
|
623
|
+
roleState.status = 'running';
|
|
624
|
+
clearMissingRunner(roleState);
|
|
499
625
|
}
|
|
500
|
-
else if (roleState.status === 'running' && !
|
|
626
|
+
else if (roleState.status === 'running' && !hasLiveRunnerProcess(state, key, roleState)) {
|
|
627
|
+
if (shouldMarkRunnerUnknown(roleState)) roleState.status = 'unknown';
|
|
628
|
+
} else if (roleState.status === 'running') clearMissingRunner(roleState);
|
|
501
629
|
roleState.files = await standardFiles(dir);
|
|
502
630
|
await attachTmuxMetadata(roleState, dir);
|
|
503
631
|
}
|
|
@@ -510,8 +638,14 @@ async function refreshTask(state, task) {
|
|
|
510
638
|
if (exit !== '') {
|
|
511
639
|
task.exitCode = Number(exit.trim());
|
|
512
640
|
if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
|
|
513
|
-
|
|
514
|
-
|
|
641
|
+
clearMissingRunner(task);
|
|
642
|
+
if (['pending', 'running', 'unknown'].includes(task.status)) task.status = task.exitCode === 0 ? 'completed' : 'failed';
|
|
643
|
+
} else if (['running', 'unknown'].includes(task.status) && hasLiveRunnerProcess(state, task.id, task)) {
|
|
644
|
+
task.status = 'running';
|
|
645
|
+
clearMissingRunner(task);
|
|
646
|
+
} else if (task.status === 'running' && !hasLiveRunnerProcess(state, task.id, task)) {
|
|
647
|
+
if (shouldMarkRunnerUnknown(task)) task.status = 'unknown';
|
|
648
|
+
} else if (task.status === 'running') clearMissingRunner(task);
|
|
515
649
|
task.files = await standardFiles(dir);
|
|
516
650
|
await attachTmuxMetadata(task, dir);
|
|
517
651
|
delete task.attentionHint;
|