dkitchen 0.2.1__tar.gz → 0.3.0__tar.gz

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
  Metadata-Version: 2.4
2
2
  Name: dkitchen
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions
5
5
  Author: 917Dhj
6
6
  License-Expression: MIT
@@ -30,11 +30,27 @@ Provides-Extra: dev
30
30
  Requires-Dist: pytest>=8; extra == "dev"
31
31
  Dynamic: license-file
32
32
 
33
- # dkitchen
33
+ <p align="center">
34
+ <img src="https://raw.githubusercontent.com/917Dhj/dkitchen/main/docs/assets/dkitchen.png" width="240" alt="Two pixel-art robot chefs: the big one rings a service bell, the small one answers with a pan">
35
+ </p>
34
36
 
35
- **English** | [简体中文](https://github.com/917Dhj/dkitchen/blob/main/README.zh-CN.md)
37
+ <h1 align="center">dkitchen</h1>
36
38
 
37
- **You decide what to cook. Your agents run the kitchen.** 🧑‍🍳
39
+ <p align="center">
40
+ <b>English</b> | <a href="https://github.com/917Dhj/dkitchen/blob/main/README.zh-CN.md">简体中文</a>
41
+ </p>
42
+
43
+ <p align="center">
44
+ <b>You decide what to cook. Your agents run the kitchen.</b> 🧑‍🍳
45
+ </p>
46
+
47
+ <p align="center">
48
+ <a href="https://pypi.org/project/dkitchen/"><img src="https://img.shields.io/pypi/v/dkitchen" alt="PyPI"></a>
49
+ <a href="https://pypi.org/project/dkitchen/"><img src="https://img.shields.io/pypi/pyversions/dkitchen" alt="Python"></a>
50
+ <a href="https://github.com/917Dhj/dkitchen/actions/workflows/tests.yml"><img src="https://github.com/917Dhj/dkitchen/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
51
+ <a href="https://github.com/917Dhj/dkitchen/blob/main/LICENSE"><img src="https://img.shields.io/github/license/917Dhj/dkitchen" alt="License"></a>
52
+ <a href="https://x.com/dahuoji09"><img src="https://img.shields.io/badge/follow-%40dahuoji09-000000?logo=x" alt="X: @dahuoji09"></a>
53
+ </p>
38
54
 
39
55
  dkitchen is the multi-agent workflow I use every day to run my research repo. It gives a set of Claude Code and Codex sessions fixed stations, a shared way to hand off work through GitHub issues, clear rules for when to stop and ask you, and the tools that tie it together. You keep the decisions only you can make: what to research, what to try, what to spend.
40
56
 
@@ -50,6 +66,10 @@ dkitchen sets up your agents the same way. You are the head chef.
50
66
 
51
67
  ## 🧑‍🍳 The kitchen
52
68
 
69
+ <p align="center">
70
+ <img src="https://raw.githubusercontent.com/917Dhj/dkitchen/main/docs/assets/kitchen.gif" width="100%" alt="A pixel-art kitchen: you talk a design through with the designer, the verifier checks the ticket, the orchestrator rings the bell to start a Codex executor on free GPUs, reviews and merges a PR, and brings you one decision to approve">
71
+ </p>
72
+
53
73
  | Station | In a kitchen | What it does | Runs on |
54
74
  | --- | --- | --- | --- |
55
75
  | 👑 **You** | Head chef | Choose the direction, approve designs and budgets, decide what the results mean for your paper. | You |
@@ -100,6 +120,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
100
120
  `ding` passes messages between agent sessions and wakes a session when its long job ends.
101
121
 
102
122
  ```bash
123
+ ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
103
124
  ding board # who is working on what, in this repo
104
125
  ding send '#42' --from orchestrator --message-file note.md
105
126
  ding launch train -- python train.py # run in tmux, wake me when it exits
@@ -1,8 +1,24 @@
1
- # dkitchen
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/917Dhj/dkitchen/main/docs/assets/dkitchen.png" width="240" alt="Two pixel-art robot chefs: the big one rings a service bell, the small one answers with a pan">
3
+ </p>
2
4
 
3
- **English** | [简体中文](https://github.com/917Dhj/dkitchen/blob/main/README.zh-CN.md)
5
+ <h1 align="center">dkitchen</h1>
4
6
 
5
- **You decide what to cook. Your agents run the kitchen.** 🧑‍🍳
7
+ <p align="center">
8
+ <b>English</b> | <a href="https://github.com/917Dhj/dkitchen/blob/main/README.zh-CN.md">简体中文</a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <b>You decide what to cook. Your agents run the kitchen.</b> 🧑‍🍳
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="https://pypi.org/project/dkitchen/"><img src="https://img.shields.io/pypi/v/dkitchen" alt="PyPI"></a>
17
+ <a href="https://pypi.org/project/dkitchen/"><img src="https://img.shields.io/pypi/pyversions/dkitchen" alt="Python"></a>
18
+ <a href="https://github.com/917Dhj/dkitchen/actions/workflows/tests.yml"><img src="https://github.com/917Dhj/dkitchen/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
19
+ <a href="https://github.com/917Dhj/dkitchen/blob/main/LICENSE"><img src="https://img.shields.io/github/license/917Dhj/dkitchen" alt="License"></a>
20
+ <a href="https://x.com/dahuoji09"><img src="https://img.shields.io/badge/follow-%40dahuoji09-000000?logo=x" alt="X: @dahuoji09"></a>
21
+ </p>
6
22
 
7
23
  dkitchen is the multi-agent workflow I use every day to run my research repo. It gives a set of Claude Code and Codex sessions fixed stations, a shared way to hand off work through GitHub issues, clear rules for when to stop and ask you, and the tools that tie it together. You keep the decisions only you can make: what to research, what to try, what to spend.
8
24
 
@@ -18,6 +34,10 @@ dkitchen sets up your agents the same way. You are the head chef.
18
34
 
19
35
  ## 🧑‍🍳 The kitchen
20
36
 
37
+ <p align="center">
38
+ <img src="https://raw.githubusercontent.com/917Dhj/dkitchen/main/docs/assets/kitchen.gif" width="100%" alt="A pixel-art kitchen: you talk a design through with the designer, the verifier checks the ticket, the orchestrator rings the bell to start a Codex executor on free GPUs, reviews and merges a PR, and brings you one decision to approve">
39
+ </p>
40
+
21
41
  | Station | In a kitchen | What it does | Runs on |
22
42
  | --- | --- | --- | --- |
23
43
  | 👑 **You** | Head chef | Choose the direction, approve designs and budgets, decide what the results mean for your paper. | You |
@@ -68,6 +88,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
68
88
  `ding` passes messages between agent sessions and wakes a session when its long job ends.
69
89
 
70
90
  ```bash
91
+ ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
71
92
  ding board # who is working on what, in this repo
72
93
  ding send '#42' --from orchestrator --message-file note.md
73
94
  ding launch train -- python train.py # run in tmux, wake me when it exits
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dkitchen"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -27,12 +27,16 @@ Every key is optional. The defaults match the English templates in kitchen/:
27
27
  [log]
28
28
  issue = 1 # the pinned orchestrator log ticket
29
29
  pending = "Waiting for the user"
30
+
31
+ [sessions]
32
+ name = "【#{issue}】{name}" # `ding new --issue N --name NAME`; also {repo}
30
33
  """
31
34
  from __future__ import annotations
32
35
 
33
36
  from dataclasses import dataclass, fields as dataclass_fields, replace
34
37
  from pathlib import Path
35
38
  import re
39
+ import string
36
40
  import subprocess
37
41
  import sys
38
42
 
@@ -81,6 +85,38 @@ class Log:
81
85
  pending: str = "Waiting for the user"
82
86
 
83
87
 
88
+ @dataclass(frozen=True)
89
+ class Sessions:
90
+ name: str = "【#{issue}】{name}"
91
+
92
+ def session_name(self, *, issue: int, name: str, repo: str) -> str:
93
+ return self.name.format(issue=issue, name=name, repo=repo)
94
+
95
+
96
+ SESSION_FIELDS = ("issue", "name", "repo")
97
+
98
+
99
+ def session_template(value: object) -> str:
100
+ """A session name template using only SESSION_FIELDS, checked when the config is read."""
101
+ if not isinstance(value, str) or not value.strip():
102
+ raise ValueError(f"{FILE}: [sessions] name must be a nonempty string")
103
+ allowed = ", ".join(f"{{{field}}}" for field in SESSION_FIELDS)
104
+ try:
105
+ for _, field, spec, conversion in string.Formatter().parse(value):
106
+ if field is None:
107
+ continue
108
+ if field not in SESSION_FIELDS:
109
+ raise ValueError(f"{FILE}: [sessions] name uses {{{field}}}; use only {allowed}")
110
+ if conversion or "{" in spec:
111
+ raise ValueError(f"{FILE}: [sessions] name: write {{{field}}} without a conversion or nested field")
112
+ Sessions(value).session_name(issue=1, name="name", repo="owner/name")
113
+ except ValueError as exc:
114
+ if str(exc).startswith(FILE):
115
+ raise
116
+ raise ValueError(f"{FILE}: [sessions] name is not a valid template: {exc}") from exc
117
+ return value
118
+
119
+
84
120
  @dataclass(frozen=True)
85
121
  class Config:
86
122
  root: Path
@@ -89,6 +125,7 @@ class Config:
89
125
  status: Status = Status()
90
126
  labels: Labels = Labels()
91
127
  log: Log = Log()
128
+ sessions: Sessions = Sessions()
92
129
 
93
130
  def repository(self) -> str:
94
131
  """The configured repository, or the one gh sees from the repository root."""
@@ -116,6 +153,8 @@ def section(cls, values: object, name: str):
116
153
  if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value):
117
154
  raise ValueError(f"{FILE}: [{name}] fields must be a nonempty list of field names")
118
155
  value = tuple(item.strip() for item in value)
156
+ elif key == "name" and cls is Sessions:
157
+ value = session_template(value)
119
158
  elif key == "issue":
120
159
  if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
121
160
  raise ValueError(f"{FILE}: [{name}] issue must be a positive issue number")
@@ -141,7 +180,7 @@ def load(cwd: Path | None = None) -> Config:
141
180
  data = tomllib.loads(path.read_text(encoding="utf-8"))
142
181
  except tomllib.TOMLDecodeError as exc:
143
182
  raise ValueError(f"{path}: {exc}") from exc
144
- sections = {"stations": Stations, "status": Status, "labels": Labels, "log": Log}
183
+ sections = {"stations": Stations, "status": Status, "labels": Labels, "log": Log, "sessions": Sessions}
145
184
  unknown = sorted(set(data) - {"repo", *sections})
146
185
  if unknown:
147
186
  raise ValueError(f"{FILE}: unknown keys: {', '.join(unknown)}")
@@ -324,6 +324,11 @@ def github_repository() -> str:
324
324
  return repo
325
325
 
326
326
 
327
+ def ownership_note(repo: str, number: int, thread_id: str) -> str:
328
+ return (f"You own ticket {repo}#{number}. Before anything else, post a comment on it "
329
+ f"with the line `Codex owner: {thread_id}`.\n\n")
330
+
331
+
327
332
  def session_references(body: str | None, label: str = "session") -> list[str]:
328
333
  if body is None:
329
334
  return []
@@ -1162,6 +1167,46 @@ def message_text(message: str | None, message_file: str | None, sender: str | No
1162
1167
  return message
1163
1168
 
1164
1169
 
1170
+ def new_task(args) -> int:
1171
+ thread_id = None
1172
+ stage = "validation"
1173
+ try:
1174
+ cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
1175
+ if cwd is None or not cwd.is_dir():
1176
+ raise ValueError("--cwd must name an existing directory.")
1177
+ message = message_text(args.message, args.message_file, None)
1178
+ if not message.strip():
1179
+ raise ValueError("The first message (--message or --message-file) must not be empty.")
1180
+ stage = "connection"
1181
+ with app_server(socket_path(args.socket)) as call:
1182
+ stage = "thread/start"
1183
+ thread = call(stage, {"cwd": str(cwd), "ephemeral": False}).get("thread")
1184
+ try:
1185
+ thread_id = str(UUID(thread["id"]))
1186
+ except (KeyError, ValueError, TypeError, AttributeError) as exc:
1187
+ raise ValueError("Malformed App Server thread ID.") from exc
1188
+ stage = "thread/name/set"
1189
+ call(stage, {"threadId": thread_id, "name": args.name})
1190
+ if args.issue is not None:
1191
+ message = ownership_note(args.repo, args.issue, thread_id) + message
1192
+ stage = "turn/start"
1193
+ turn = call(stage, {"threadId": thread_id,
1194
+ "input": [{"type": "text", "text": message}]}).get("turn")
1195
+ if (not isinstance(turn, dict) or not isinstance(turn.get("id"), str)
1196
+ or not turn["id"] or turn.get("status") not in ("inProgress", "completed")):
1197
+ raise ValueError("App Server did not return an accepted turn.")
1198
+ print(thread_id)
1199
+ return 0
1200
+ except (OSError, ValueError, RuntimeError, KeyboardInterrupt) as exc:
1201
+ recovery = ""
1202
+ if thread_id is not None:
1203
+ recovery = f"; session {thread_id}; inspect Desktop or ding board before retrying"
1204
+ elif stage == "thread/start":
1205
+ recovery = "; session creation is unconfirmed; inspect Desktop or ding board before retrying"
1206
+ print(f"ding: new {stage}: {str(exc) or type(exc).__name__}{recovery}", file=sys.stderr)
1207
+ return 130 if isinstance(exc, KeyboardInterrupt) else 1
1208
+
1209
+
1165
1210
  def send_task(args) -> int:
1166
1211
  state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state")
1167
1212
  record = state / "ding/sent.jsonl"
@@ -1324,6 +1369,16 @@ def main(argv=None) -> int:
1324
1369
  installed = "unknown"
1325
1370
  parser.add_argument("--version", action="version", version=f"ding {installed}")
1326
1371
  commands = parser.add_subparsers(dest="action", required=True)
1372
+ sub = commands.add_parser("new", help="Create a saved Codex session and start its first message")
1373
+ sub.add_argument("--cwd", required=True, help="Existing working directory for the session")
1374
+ sub.add_argument("--name", required=True, help="Session title; with --issue, filled into the "
1375
+ "[sessions] name template")
1376
+ sub.add_argument("--issue", type=int, help="Ticket this session owns: formats --name with the [sessions] "
1377
+ "name template and asks the session to post its Codex owner line")
1378
+ message = sub.add_mutually_exclusive_group(required=True)
1379
+ message.add_argument("--message", help="First message text, or '-' to read stdin")
1380
+ message.add_argument("--message-file", help="UTF-8 file holding the first message")
1381
+ sub.add_argument("--socket", help="Existing local App Server Unix socket path")
1327
1382
  board = commands.add_parser("board", help="List sessions, run states, and local unwrapped tasks")
1328
1383
  board.add_argument("--all", action="store_true", help="Include all non-archived sessions")
1329
1384
  board.add_argument("--status", action="store_true", help="Show the latest executor comment status block")
@@ -1337,6 +1392,7 @@ def main(argv=None) -> int:
1337
1392
  help="Minimum lifetime CPU percent per core outside tmux panes (default: 10)")
1338
1393
  for action, summary in (("read", "Print a session's latest final reply"),
1339
1394
  ("status", "Print a session's App Server status as JSON"),
1395
+ ("rename", "Change a session's name without starting a model turn"),
1340
1396
  ("send", "Send a message to a session"),
1341
1397
  ("run", "Run a command and message a session when it exits"),
1342
1398
  ("launch", "Run a command in a new tmux session and wake this Codex session when it exits")):
@@ -1350,6 +1406,8 @@ def main(argv=None) -> int:
1350
1406
  sub.add_argument("--socket", help="Existing local App Server Unix socket path")
1351
1407
  if action == "read":
1352
1408
  sub.add_argument("--head", action="store_true", help="Print only the first paragraph of the final reply")
1409
+ if action == "rename":
1410
+ sub.add_argument("--name", required=True, help="New session name, preserved exactly as supplied")
1353
1411
  if action in {"send", "run", "launch"}:
1354
1412
  message = sub.add_mutually_exclusive_group(required=action != "launch")
1355
1413
  message.add_argument("--message", help="Message text, or '-' to read stdin")
@@ -1402,11 +1460,22 @@ def main(argv=None) -> int:
1402
1460
  command = argv[separator + 1:]
1403
1461
  argv = argv[:separator]
1404
1462
  args = parser.parse_args(argv)
1463
+ if args.action == "rename" and not args.name.strip():
1464
+ parser.error("--name must contain non-whitespace text")
1405
1465
  if getattr(args, "issue", None) is not None and args.issue <= 0:
1406
1466
  parser.error("--issue must be a positive issue number")
1407
1467
  if args.action in {"run", "launch"} and not command:
1408
1468
  parser.error(f"{args.action} requires a task command after --")
1409
1469
  try:
1470
+ if args.action == "new":
1471
+ if not args.name.strip():
1472
+ parser.error("--name must contain non-whitespace text")
1473
+ if args.issue is not None and args.cwd and Path(args.cwd).expanduser().is_dir():
1474
+ from dkitchen import config
1475
+ settings = config.load(Path(args.cwd).expanduser().resolve())
1476
+ args.repo = settings.repository()
1477
+ args.name = settings.sessions.session_name(issue=args.issue, name=args.name, repo=args.repo)
1478
+ return new_task(args)
1410
1479
  if args.action == "check-refs":
1411
1480
  from dkitchen import refs
1412
1481
  return refs.check_refs(args)
@@ -1442,6 +1511,11 @@ def main(argv=None) -> int:
1442
1511
  if args.action == "run":
1443
1512
  return run_task(args, command)
1444
1513
  path = socket_path(args.socket)
1514
+ if args.action == "rename":
1515
+ with app_server(path) as call:
1516
+ call("thread/name/set", {"threadId": args.thread_id, "name": args.name})
1517
+ print(args.thread_id)
1518
+ return 0
1445
1519
  result = query(args.thread_id, path, status_only=args.action == "status")
1446
1520
  if args.action == "read" and args.head:
1447
1521
  result = reply_head(result)
@@ -6,7 +6,8 @@ Wakes on:
6
6
  - status labels being added, and newly opened PRs;
7
7
  - stale tickets: no status label, last comment an executor "in progress" older than --stale-hours,
8
8
  no running `ding run` for the ticket, and no live tmux job whose session name holds issue<N>.
9
- On tickets labelled awaiting-user, executor comments wake only for blocked, done, and waiting on design.
9
+ On tickets labelled awaiting-user, executor comments wake only for blocked, done, and waiting on design,
10
+ or when their "waiting on" names the orchestrator, designer, or verifier.
10
11
  Verifier comments never wake on their own, since the verifier also messages the orchestrator;
11
12
  they are listed in the next batch for reference.
12
13
  The state file keeps the time already reported, so rerunning the same command loses nothing.
@@ -87,10 +88,11 @@ def state_and_waiting(body: str, config: Config) -> tuple[str, str]:
87
88
  def executor_wakes(body: str, waiting_user: bool, config: Config) -> bool:
88
89
  state, waiting = state_and_waiting(body, config)
89
90
  status, stations = config.status, config.stations
91
+ names = lambda *who: not waiting.startswith(status.none) and any(name in waiting for name in who)
92
+ kitchen = (stations.orchestrator, stations.designer, stations.verifier)
90
93
  if waiting_user:
91
- return state in {status.blocked, status.done, status.waiting_on_design}
92
- names = (stations.orchestrator, stations.designer, stations.verifier, stations.user)
93
- return state != status.in_progress or (not waiting.startswith(status.none) and any(name in waiting for name in names))
94
+ return state in {status.blocked, status.done, status.waiting_on_design} or names(*kitchen)
95
+ return state != status.in_progress or names(*kitchen, stations.user)
94
96
 
95
97
 
96
98
  def summary(body: str, config: Config) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dkitchen
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions
5
5
  Author: 917Dhj
6
6
  License-Expression: MIT
@@ -30,11 +30,27 @@ Provides-Extra: dev
30
30
  Requires-Dist: pytest>=8; extra == "dev"
31
31
  Dynamic: license-file
32
32
 
33
- # dkitchen
33
+ <p align="center">
34
+ <img src="https://raw.githubusercontent.com/917Dhj/dkitchen/main/docs/assets/dkitchen.png" width="240" alt="Two pixel-art robot chefs: the big one rings a service bell, the small one answers with a pan">
35
+ </p>
34
36
 
35
- **English** | [简体中文](https://github.com/917Dhj/dkitchen/blob/main/README.zh-CN.md)
37
+ <h1 align="center">dkitchen</h1>
36
38
 
37
- **You decide what to cook. Your agents run the kitchen.** 🧑‍🍳
39
+ <p align="center">
40
+ <b>English</b> | <a href="https://github.com/917Dhj/dkitchen/blob/main/README.zh-CN.md">简体中文</a>
41
+ </p>
42
+
43
+ <p align="center">
44
+ <b>You decide what to cook. Your agents run the kitchen.</b> 🧑‍🍳
45
+ </p>
46
+
47
+ <p align="center">
48
+ <a href="https://pypi.org/project/dkitchen/"><img src="https://img.shields.io/pypi/v/dkitchen" alt="PyPI"></a>
49
+ <a href="https://pypi.org/project/dkitchen/"><img src="https://img.shields.io/pypi/pyversions/dkitchen" alt="Python"></a>
50
+ <a href="https://github.com/917Dhj/dkitchen/actions/workflows/tests.yml"><img src="https://github.com/917Dhj/dkitchen/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
51
+ <a href="https://github.com/917Dhj/dkitchen/blob/main/LICENSE"><img src="https://img.shields.io/github/license/917Dhj/dkitchen" alt="License"></a>
52
+ <a href="https://x.com/dahuoji09"><img src="https://img.shields.io/badge/follow-%40dahuoji09-000000?logo=x" alt="X: @dahuoji09"></a>
53
+ </p>
38
54
 
39
55
  dkitchen is the multi-agent workflow I use every day to run my research repo. It gives a set of Claude Code and Codex sessions fixed stations, a shared way to hand off work through GitHub issues, clear rules for when to stop and ask you, and the tools that tie it together. You keep the decisions only you can make: what to research, what to try, what to spend.
40
56
 
@@ -50,6 +66,10 @@ dkitchen sets up your agents the same way. You are the head chef.
50
66
 
51
67
  ## 🧑‍🍳 The kitchen
52
68
 
69
+ <p align="center">
70
+ <img src="https://raw.githubusercontent.com/917Dhj/dkitchen/main/docs/assets/kitchen.gif" width="100%" alt="A pixel-art kitchen: you talk a design through with the designer, the verifier checks the ticket, the orchestrator rings the bell to start a Codex executor on free GPUs, reviews and merges a PR, and brings you one decision to approve">
71
+ </p>
72
+
53
73
  | Station | In a kitchen | What it does | Runs on |
54
74
  | --- | --- | --- | --- |
55
75
  | 👑 **You** | Head chef | Choose the direction, approve designs and budgets, decide what the results mean for your paper. | You |
@@ -100,6 +120,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
100
120
  `ding` passes messages between agent sessions and wakes a session when its long job ends.
101
121
 
102
122
  ```bash
123
+ ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
103
124
  ding board # who is working on what, in this repo
104
125
  ding send '#42' --from orchestrator --message-file note.md
105
126
  ding launch train -- python train.py # run in tmux, wake me when it exits
@@ -252,6 +252,310 @@ def test_status_cli(capsys, monkeypatch):
252
252
  assert json.loads(capsys.readouterr().out) == {"type": "active", "activeFlags": []}
253
253
 
254
254
 
255
+ @pytest.mark.parametrize("missing", ["--cwd", "--name", "--message"])
256
+ def test_new_requires_options(tmp_path, monkeypatch, capsys, missing):
257
+ monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
258
+ options = {"--cwd": str(tmp_path), "--name": "Build", "--message": "Start"}
259
+ argv = ["new"]
260
+ for flag, value in options.items():
261
+ if flag != missing:
262
+ argv.extend([flag, value])
263
+ with pytest.raises(SystemExit) as exc:
264
+ ding.main(argv)
265
+ assert exc.value.code == 2
266
+ assert missing in capsys.readouterr().err
267
+
268
+
269
+ def test_new_rejects_message_and_message_file(tmp_path, monkeypatch, capsys):
270
+ monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
271
+ note = tmp_path / "note.md"
272
+ note.write_text("Start", encoding="utf-8")
273
+ with pytest.raises(SystemExit) as exc:
274
+ ding.main(["new", "--cwd", str(tmp_path), "--name", "Build",
275
+ "--message", "Start", "--message-file", str(note)])
276
+ assert exc.value.code == 2
277
+ assert "not allowed with argument" in capsys.readouterr().err
278
+
279
+
280
+ @pytest.mark.parametrize("name", ["", " \t\n", "\u2003"])
281
+ def test_new_rejects_blank_name_like_rename(tmp_path, monkeypatch, capsys, name):
282
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("blank name must not look up"))
283
+ with pytest.raises(SystemExit) as exc:
284
+ ding.main(["new", "--cwd", str(tmp_path), "--name", name, "--message", "Start"])
285
+ assert exc.value.code == 2
286
+ captured = capsys.readouterr()
287
+ assert captured.out == ""
288
+ assert "--name must contain non-whitespace text" in captured.err
289
+
290
+
291
+ @pytest.mark.parametrize("invalid", ["missing-dir", "file", "empty-cwd",
292
+ "blank-message", "empty-message", "blank-stdin", "empty-stdin",
293
+ "blank-file", "empty-file", "missing-file"])
294
+ def test_new_validates_before_socket_lookup(tmp_path, monkeypatch, capsys, invalid):
295
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must validate before socket lookup"))
296
+ monkeypatch.chdir(tmp_path)
297
+ cwd, message_option = str(tmp_path), ["--message", "Start"]
298
+ if invalid == "missing-dir":
299
+ cwd = str(tmp_path / "missing")
300
+ elif invalid == "file":
301
+ cwd = str(tmp_path / "file")
302
+ Path(cwd).touch()
303
+ elif invalid == "empty-cwd":
304
+ cwd = ""
305
+ else:
306
+ text = " \t\r\n" if invalid.startswith("blank") else ""
307
+ if invalid.endswith("stdin"):
308
+ monkeypatch.setattr(ding.sys, "stdin", io.StringIO(text))
309
+ message_option = ["--message", "-"]
310
+ elif invalid.endswith("file"):
311
+ note = tmp_path / "note.md"
312
+ if invalid != "missing-file":
313
+ note.write_text(text, encoding="utf-8")
314
+ message_option = ["--message-file", str(note)]
315
+ else:
316
+ message_option = ["--message", text]
317
+ assert ding.main(["new", "--cwd", cwd, "--name", "Build", *message_option]) == 1
318
+ captured = capsys.readouterr()
319
+ assert captured.out == ""
320
+ assert "new validation" in captured.err
321
+ assert ("--cwd" if invalid in {"missing-dir", "file", "empty-cwd"} else
322
+ "note.md" if invalid == "missing-file" else "--message") in captured.err
323
+
324
+
325
+ def ticket_repo(tmp_path, template=None):
326
+ text = 'repo = "example/project"\n'
327
+ if template is not None:
328
+ text += f"[sessions]\nname = {json.dumps(template)}\n"
329
+ (tmp_path / ".dkitchen.toml").write_text(text, encoding="utf-8")
330
+ return tmp_path
331
+
332
+
333
+ @pytest.mark.parametrize("template, expected", [
334
+ (None, "【#57】Ablation {seed}"), ("{repo}#{issue:04d} {name}", "example/project#0057 Ablation {seed}"),
335
+ ])
336
+ def test_new_issue_formats_name_and_asks_for_owner_line(tmp_path, monkeypatch, capsys, template, expected):
337
+ cwd = ticket_repo(tmp_path, template)
338
+ requests = []
339
+
340
+ def call(method, params):
341
+ requests.append((method, params))
342
+ return {"thread/start": {"thread": {"id": THREAD_ID}}, "thread/name/set": {},
343
+ "turn/start": {"turn": {"id": "turn-1", "status": "inProgress"}}}[method]
344
+
345
+ for function in ("gh_json", "gh_records", "github_repository"):
346
+ monkeypatch.setattr(ding, function, lambda *a: pytest.fail("must not read GitHub"))
347
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
348
+ assert ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", "Ablation {seed}",
349
+ "--message", "Brief", "--socket", "/test.sock"]) == 0
350
+ assert requests[1] == ("thread/name/set", {"threadId": THREAD_ID, "name": expected})
351
+ assert requests[2][1]["input"] == [{"type": "text", "text":
352
+ f"You own ticket example/project#57. Before anything else, post a comment on it "
353
+ f"with the line `Codex owner: {THREAD_ID}`.\n\nBrief"}]
354
+ assert capsys.readouterr().out == THREAD_ID + "\n"
355
+
356
+
357
+ def test_new_issue_still_needs_a_name(tmp_path, monkeypatch, capsys):
358
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
359
+ with pytest.raises(SystemExit) as exc:
360
+ ding.main(["new", "--cwd", str(ticket_repo(tmp_path)), "--issue", "57", "--message", "Start"])
361
+ assert exc.value.code == 2
362
+ assert "--name" in capsys.readouterr().err
363
+
364
+
365
+ @pytest.mark.parametrize("issue", ["0", "-3"])
366
+ def test_new_issue_must_be_positive(tmp_path, monkeypatch, capsys, issue):
367
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
368
+ with pytest.raises(SystemExit) as exc:
369
+ ding.main(["new", "--cwd", str(tmp_path), "--issue", issue, "--name", "Build", "--message", "Start"])
370
+ assert exc.value.code == 2
371
+ assert "positive issue number" in capsys.readouterr().err
372
+
373
+
374
+ def test_new_issue_with_blank_name_is_refused_before_the_template(tmp_path, monkeypatch, capsys):
375
+ cwd = ticket_repo(tmp_path, "#{issue} {name}")
376
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
377
+ with pytest.raises(SystemExit) as exc:
378
+ ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", " \t", "--message", "Start"])
379
+ assert exc.value.code == 2
380
+ assert "--name must contain non-whitespace text" in capsys.readouterr().err
381
+
382
+
383
+ @pytest.mark.parametrize("failure", ["template", "repository"])
384
+ def test_new_issue_config_failure_creates_nothing(tmp_path, monkeypatch, capsys, failure):
385
+ if failure == "template":
386
+ cwd = ticket_repo(tmp_path, "{title}")
387
+ else:
388
+ cwd = tmp_path
389
+ real_run = subprocess.run
390
+
391
+ def gh_repo_view(argv, *args, **kwargs):
392
+ if argv[:3] == ["gh", "repo", "view"]:
393
+ return subprocess.CompletedProcess(argv, 1, "", "no git remotes found")
394
+ return real_run(argv, *args, **kwargs)
395
+
396
+ monkeypatch.setattr(subprocess, "run", gh_repo_view)
397
+ monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
398
+ assert ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", "Build", "--message", "Start"]) == 1
399
+ captured = capsys.readouterr()
400
+ assert captured.out == ""
401
+ assert ("{title}" if failure == "template" else "no git remotes found") in captured.err
402
+
403
+
404
+ @pytest.mark.parametrize("source, status, explicit_socket", [
405
+ ("argument", "inProgress", True), ("stdin", "completed", False), ("file", "inProgress", False),
406
+ ])
407
+ def test_new_protocol_and_exact_text(tmp_path, monkeypatch, capsys, source, status, explicit_socket):
408
+ cwd = tmp_path / "project space" / "nested"
409
+ cwd.mkdir(parents=True)
410
+ (tmp_path / "work").symlink_to(cwd, target_is_directory=True)
411
+ monkeypatch.chdir(tmp_path)
412
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
413
+ message = ' Review "quoted"; $(not-shell)\r\n世界\n'
414
+ name = " 检查\n配置 "
415
+ thread_id = "00000000-0000-4000-8000-0000000000ab"
416
+ requests, opened, closed = [], [], []
417
+
418
+ def call(method, params):
419
+ requests.append((method, params))
420
+ return {"thread/start": {"thread": {"id": "{" + thread_id.upper() + "}"}},
421
+ "thread/name/set": {}, "turn/start": {"turn": {"id": "turn-1", "status": status}}}[method]
422
+
423
+ @contextmanager
424
+ def server(path):
425
+ opened.append(path)
426
+ try:
427
+ yield call
428
+ finally:
429
+ closed.append(path)
430
+
431
+ def discover(directory):
432
+ assert not explicit_socket
433
+ assert directory == Path(f"/tmp/codex-daemon-{ding.os.getuid()}")
434
+ return "/test.sock"
435
+
436
+ monkeypatch.setattr(ding, "app_server", server)
437
+ monkeypatch.setattr(ding, "discover_socket", discover)
438
+ monkeypatch.setattr(ding, "resolve_target", lambda *a: pytest.fail("new has no target"))
439
+ monkeypatch.setattr(ding, "deliver_message", lambda *a: pytest.fail("first message must not queue"))
440
+ monkeypatch.setattr(ding, "send_task", lambda *a: pytest.fail("first message must not use send"))
441
+ message_option = ["--message", message]
442
+ if source == "stdin":
443
+ monkeypatch.setattr(ding.sys, "stdin", io.StringIO(message))
444
+ message_option = ["--message", "-"]
445
+ elif source == "file":
446
+ (tmp_path / "note.md").write_bytes(message.encode("utf-8"))
447
+ message_option = ["--message-file", "note.md"]
448
+ options = ["--socket", "/test.sock"] if explicit_socket else []
449
+ assert ding.main(["new", "--cwd", "work", "--name", name, *message_option, *options]) == 0
450
+ assert requests == [
451
+ ("thread/start", {"cwd": str(cwd), "ephemeral": False}),
452
+ ("thread/name/set", {"threadId": thread_id, "name": name}),
453
+ ("turn/start", {"threadId": thread_id, "input": [{"type": "text", "text": message}]}),
454
+ ]
455
+ assert opened == closed == ["/test.sock"]
456
+ captured = capsys.readouterr()
457
+ assert captured.out == thread_id + "\n"
458
+ assert captured.err == ""
459
+ assert not (tmp_path / "state/ding").exists()
460
+
461
+
462
+ @pytest.mark.parametrize("point", ["socket lookup", "connection"])
463
+ @pytest.mark.parametrize("error", [OSError("unavailable"), KeyboardInterrupt()])
464
+ def test_new_connection_failure_never_creates(tmp_path, monkeypatch, capsys, point, error):
465
+ def fail(*args):
466
+ raise error
467
+
468
+ monkeypatch.setattr(ding, "socket_path", fail if point == "socket lookup" else lambda *a: "/test.sock")
469
+ monkeypatch.setattr(ding, "app_server", fail if point == "connection" else
470
+ lambda *a: pytest.fail("socket lookup failed"))
471
+ assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start"]) == (
472
+ 130 if isinstance(error, KeyboardInterrupt) else 1)
473
+ captured = capsys.readouterr()
474
+ assert captured.out == ""
475
+ assert "new connection" in captured.err
476
+ assert "creation is unconfirmed" not in captured.err
477
+ assert THREAD_ID not in captured.err
478
+
479
+
480
+ @pytest.mark.parametrize("stage", ["thread/start", "thread/name/set", "turn/start"])
481
+ @pytest.mark.parametrize("error", [ding.AppServerError("request rejected"), TimeoutError("response lost"),
482
+ ding.AppServerUnavailable("connection closed"), KeyboardInterrupt()])
483
+ def test_new_failure_preserves_session_without_retry(tmp_path, monkeypatch, capsys, stage, error):
484
+ requests, connections = [], []
485
+ stages = ["thread/start", "thread/name/set", "turn/start"]
486
+
487
+ def call(method, params):
488
+ requests.append(method)
489
+ if method == stage:
490
+ raise error
491
+ return {"thread": {"id": THREAD_ID}}
492
+
493
+ def server(path):
494
+ connections.append(path)
495
+ return server_calls(call)
496
+
497
+ monkeypatch.setattr(ding, "app_server", server)
498
+ assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
499
+ "--socket", "/test.sock"]) == (130 if isinstance(error, KeyboardInterrupt) else 1)
500
+ assert requests == stages[:stages.index(stage) + 1]
501
+ assert connections == ["/test.sock"]
502
+ captured = capsys.readouterr()
503
+ assert captured.out == ""
504
+ assert f"new {stage}" in captured.err
505
+ assert (str(error) or type(error).__name__) in captured.err
506
+ assert "inspect Desktop or ding board before retrying" in captured.err
507
+ if stage == "thread/start":
508
+ assert "session creation is unconfirmed" in captured.err
509
+ assert THREAD_ID not in captured.err
510
+ else:
511
+ assert f"session {THREAD_ID}" in captured.err
512
+
513
+
514
+ @pytest.mark.parametrize("reply", [{}, {"thread": None}, {"thread": []}, {"thread": {}},
515
+ {"thread": {"id": None}}, {"thread": {"id": 7}},
516
+ {"thread": {"id": "invalid"}}])
517
+ def test_new_rejects_invalid_thread_before_naming(tmp_path, monkeypatch, capsys, reply):
518
+ requests = []
519
+
520
+ def call(method, params):
521
+ requests.append(method)
522
+ return reply
523
+
524
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
525
+ assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
526
+ "--socket", "/test.sock"]) == 1
527
+ assert requests == ["thread/start"]
528
+ captured = capsys.readouterr()
529
+ assert captured.out == ""
530
+ assert "new thread/start: Malformed App Server thread ID" in captured.err
531
+ assert "session creation is unconfirmed" in captured.err
532
+
533
+
534
+ @pytest.mark.parametrize("turn", [None, [], {}, {"id": None, "status": "inProgress"},
535
+ {"id": "", "status": "inProgress"}, {"id": 7, "status": "inProgress"},
536
+ {"id": "turn-1"}, {"id": "turn-1", "status": "failed"},
537
+ {"id": "turn-1", "status": "interrupted"},
538
+ {"id": "turn-1", "status": "future-state"},
539
+ {"id": "turn-1", "status": {}}])
540
+ def test_new_rejects_unaccepted_turn_and_keeps_id(tmp_path, monkeypatch, capsys, turn):
541
+ requests = []
542
+
543
+ def call(method, params):
544
+ requests.append(method)
545
+ return {"thread/start": {"thread": {"id": THREAD_ID}},
546
+ "thread/name/set": {}, "turn/start": {"turn": turn}}[method]
547
+
548
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
549
+ assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
550
+ "--socket", "/test.sock"]) == 1
551
+ assert requests == ["thread/start", "thread/name/set", "turn/start"]
552
+ captured = capsys.readouterr()
553
+ assert captured.out == ""
554
+ assert "new turn/start: App Server did not return an accepted turn" in captured.err
555
+ assert f"session {THREAD_ID}" in captured.err
556
+ assert "inspect Desktop or ding board before retrying" in captured.err
557
+
558
+
255
559
  def test_send_persists_intent_before_delivery(monkeypatch, tmp_path):
256
560
  calls = []
257
561
  message = 'text "quoted"; $(not-shell)\nnext'
@@ -2829,3 +3133,98 @@ def test_pane_representative_skips_empty_ancestor_command_and_breaks_ties_by_pid
2829
3133
  panes = {10: {"pane_id": "%1", "session": "work", "window": "task"}}
2830
3134
  assert [row["pid"] for row in ding.unwrapped_processes(
2831
3135
  processes, panes, min_age=600, min_cpu=10, proc=tmp_path)] == [11]
3136
+
3137
+
3138
+ @pytest.mark.parametrize("arguments", [
3139
+ [], [THREAD_ID], ["--name", "New name"], [THREAD_ID, "--name"],
3140
+ [THREAD_ID, "--issue", "42", "--name", "New name"],
3141
+ ])
3142
+ def test_rename_requires_one_target_and_name(monkeypatch, capsys, arguments):
3143
+ monkeypatch.setattr(ding, "resolve_target", lambda *a: pytest.fail("invalid arguments must not resolve"))
3144
+ with pytest.raises(SystemExit) as exc:
3145
+ ding.main(["rename", *arguments])
3146
+ assert exc.value.code == 2
3147
+ assert capsys.readouterr().out == ""
3148
+
3149
+
3150
+ @pytest.mark.parametrize("name", ["", " \t\n", "\u2003"])
3151
+ @pytest.mark.parametrize("target", [["Build"], ["--issue", "42"]])
3152
+ def test_rename_rejects_blank_name_before_lookup(monkeypatch, capsys, name, target):
3153
+ for function in ("resolve_target", "socket_path", "app_server", "github_repository", "gh_json"):
3154
+ monkeypatch.setattr(ding, function, lambda *a: pytest.fail("blank name must not look up"))
3155
+ with pytest.raises(SystemExit) as exc:
3156
+ ding.main(["rename", *target, "--name", name])
3157
+ assert exc.value.code == 2
3158
+ captured = capsys.readouterr()
3159
+ assert captured.out == ""
3160
+ assert "--name must contain non-whitespace text" in captured.err
3161
+
3162
+
3163
+ @pytest.mark.parametrize("target,issue", [
3164
+ (THREAD_ID, None), ("Build", None), (None, 42), ("#42", None),
3165
+ ("example/project#42", None), ("https://github.com/example/project/issues/42", None),
3166
+ ])
3167
+ def test_rename_delegates_target_resolution(monkeypatch, capsys, target, issue):
3168
+ resolved = []
3169
+ calls = []
3170
+
3171
+ def resolve(value, number, path):
3172
+ resolved.append((value, number, path))
3173
+ return THREAD_ID
3174
+
3175
+ def call(method, params):
3176
+ calls.append((method, params))
3177
+ return {}
3178
+
3179
+ monkeypatch.setattr(ding, "resolve_target", resolve)
3180
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
3181
+ arguments = [target] if target is not None else ["--issue", str(issue)]
3182
+ assert ding.main(["rename", *arguments, "--name", "New name", "--socket", "/test.sock"]) == 0
3183
+ assert resolved == [(target, issue, "/test.sock")]
3184
+ assert calls == [("thread/name/set", {"threadId": THREAD_ID, "name": "New name"})]
3185
+ captured = capsys.readouterr()
3186
+ assert captured.out == THREAD_ID + "\n"
3187
+ assert captured.err == ""
3188
+
3189
+
3190
+ @pytest.mark.parametrize("reply,status,error", [
3191
+ ({"result": {}}, 0, None),
3192
+ ({"error": {"code": -32602, "message": "name refused"}}, 1, "name refused"),
3193
+ ("", 1, "closed the connection"),
3194
+ ({"result": None}, 1, "Malformed App Server result"),
3195
+ ])
3196
+ def test_rename_rpc_acknowledgement_and_failure(tmp_path, monkeypatch, capsys, reply, status, error):
3197
+ websocket = pytest.importorskip("websocket")
3198
+ ws = Connection([{"result": {}}, reply])
3199
+ monkeypatch.setattr(websocket, "create_connection", lambda *a, **kw: ws)
3200
+ name = ' 评测 "quoted" $(not-shell)\nnext '
3201
+ path = str(tmp_path / "server")
3202
+ with socket.socket(socket.AF_UNIX) as server:
3203
+ server.bind(path)
3204
+ server.listen(1)
3205
+ assert ding.main(["rename", "{" + THREAD_ID + "}", "--name", name, "--socket", path]) == status
3206
+ captured = capsys.readouterr()
3207
+ assert captured.out == (THREAD_ID + "\n" if status == 0 else "")
3208
+ assert (error in captured.err) if error else captured.err == ""
3209
+ assert [request["method"] for request in ws.requests] == ["initialize", "initialized", "thread/name/set"]
3210
+ assert ws.requests[-1]["params"] == {"threadId": THREAD_ID, "name": name}
3211
+ assert ws.closed
3212
+
3213
+
3214
+ @pytest.mark.parametrize("target", ["Build", "#42"])
3215
+ def test_rename_refuses_ambiguous_targets(issue_data, monkeypatch, capsys, target):
3216
+ issue, comments = issue_data
3217
+ comments.append({"body": f"Codex session: {OTHER_ID}"})
3218
+ calls = []
3219
+
3220
+ def call(method, params):
3221
+ calls.append(method)
3222
+ assert method == "thread/list"
3223
+ return {"data": [thread(), thread(OTHER_ID, "Build")]}
3224
+
3225
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
3226
+ assert ding.main(["rename", target, "--name", "New name", "--socket", "/test.sock"]) == 1
3227
+ captured = capsys.readouterr()
3228
+ assert captured.out == ""
3229
+ assert THREAD_ID in captured.err and OTHER_ID in captured.err
3230
+ assert calls == ["thread/list"]
@@ -70,6 +70,28 @@ def test_bad_config_is_refused_with_its_key(tmp_path, text, error):
70
70
  settings(tmp_path, text)
71
71
 
72
72
 
73
+ def test_session_name_template_defaults_and_fills_names_verbatim(tmp_path):
74
+ assert settings(tmp_path).sessions.session_name(issue=57, name="Fix {x} 100%", repo="o/r") == \
75
+ "【#57】Fix {x} 100%"
76
+ loaded = settings(tmp_path, '[sessions]\nname = "{repo}#{issue:04d} {name}"')
77
+ assert loaded.sessions.session_name(issue=7, name="Plot", repo="o/r") == "o/r#0007 Plot"
78
+
79
+
80
+ @pytest.mark.parametrize("template, error", [
81
+ ("", "nonempty string"),
82
+ ("{issue} {title}", r"uses \{title\}; use only \{issue\}, \{name\}, \{repo\}"),
83
+ ("{} {name}", r"uses \{\}"),
84
+ ("{name.upper}", r"uses \{name.upper\}"),
85
+ ("{name!r}", "without a conversion"),
86
+ ("{name", "not a valid template"),
87
+ ("{name:d}", "not a valid template"),
88
+ ("{issue:{name}}", "nested field"),
89
+ ])
90
+ def test_bad_session_name_template_is_refused_on_load(tmp_path, template, error):
91
+ with pytest.raises(ValueError, match=error):
92
+ settings(tmp_path, f"[sessions]\nname = {json.dumps(template)}")
93
+
94
+
73
95
  def test_board_status_falls_back_to_the_config_fields(tmp_path, monkeypatch, capsys):
74
96
  monkeypatch.chdir(tmp_path)
75
97
  (tmp_path / config.FILE).write_text(CHINESE, encoding="utf-8")
@@ -206,6 +228,8 @@ def executor(block):
206
228
  ("Status: in progress\nWaiting on: Designer, for the prompt\nKey numbers: 1\nNext: wait", False, True),
207
229
  ("Status: done\nWaiting on: none\nKey numbers: 1\nNext: merge", False, True),
208
230
  ("Status: waiting on user\nWaiting on: User\nKey numbers: 1\nNext: wait", True, False),
231
+ ("Status: waiting on user\nWaiting on: User to approve; Orchestrator to merge PR #3\nKey numbers: 1\nNext: merge",
232
+ True, True),
209
233
  ("Status: blocked\nWaiting on: none\nKey numbers: 1\nNext: fix", True, True),
210
234
  ])
211
235
  def test_executor_comments_wake_by_status_and_waiting_on(tmp_path, block, waiting_user, wakes):
@@ -222,6 +246,14 @@ def test_chinese_status_words_and_full_width_colons(tmp_path):
222
246
  assert not watch.executor_wakes(executor("状态: 进行中\n在等: 无\n关键数字: 1\n下一步: 跑"), False, loaded)
223
247
 
224
248
 
249
+ def test_an_awaiting_user_ticket_wakes_when_the_comment_also_waits_on_the_kitchen(tmp_path):
250
+ # #209 issuecomment-6179869282: still waiting on the user, but also asking the orchestrator to merge
251
+ loaded = settings(tmp_path, CHINESE)
252
+ body = executor("状态: 等用户\n在等: 用户批准说话人 1,329 次单次作答;总编排重审合入 PR #314\n关键数字: 0\n下一步: 合入")
253
+ assert watch.executor_wakes(body, True, loaded)
254
+ assert not watch.executor_wakes(executor("状态: 等用户\n在等: 用户批准\n关键数字: 0\n下一步: 等"), True, loaded)
255
+
256
+
225
257
  @pytest.fixture
226
258
  def tree(tmp_path):
227
259
  (tmp_path / "pkg").mkdir()
File without changes
File without changes
File without changes
File without changes