bothouse-dsh 0.1.0 → 0.1.40

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/collab/tasks.mjs CHANGED
@@ -8,6 +8,10 @@
8
8
  // waker can reach the owner (cloud-web bot offline, or not reconnected yet after
9
9
  // a restart) the reminder stays pending and is delivered once when it comes online.
10
10
  import { CollabError, MAX_TEXT } from './store.mjs';
11
+ import { mentions, nameCalled, split, withoutLeadingMentions } from './mentions.mjs';
12
+ import { NOTICE_PREFIXES } from './cloudweb.mjs';
13
+ import { exitMark } from './team-exit.mjs';
14
+ import { checkDispatch, HandoffRulesError } from './handoff-rules.mjs';
11
15
 
12
16
  /** Status codes and their labels (the eight names are a #139 B setting, not user-specified). */
13
17
  export const STATUS = {
@@ -21,23 +25,74 @@ const STANCE_WORDS = { accept: '接', ask: '问', decline: '退', escalate: '升
21
25
  export const WORKING = ['assigned', 'accepted', 'asked', 'rework'];
22
26
  /** Waiting for the lead: review may close or send back. */
23
27
  const REVIEWABLE = ['returned', 'declined', 'escalated'];
28
+ /** The owner delivered: handed back, or the lead accepted it (fromChat reads the next call as an answer). */
29
+ const DELIVERED = ['returned', 'done'];
30
+ // #597 r12: how long a bare-name call after a delivery waits for the owner's stance (Tasks.pendingCalls).
31
+ // Source: 聊天频道橘 10-26「超过 30 分钟没回也丢弃」.
32
+ const PENDING_CALL_S = 30 * 60;
24
33
  // Default check time after dispatch or rework. Source: user 2026-10-23「这10分钟之内搞不完就滚蛋」.
25
34
  export const CHECK_AFTER_S = 10 * 60;
35
+ // Workspace watch (#574 B2, same rule as contrib/lead/d10-fire MAX_RENEW, 0 = no cap): at the
36
+ // check time a workspace task with new role messages since its window opened gets another
37
+ // CHECK_AFTER_S, renewing while there is progress; otherwise it is parked (paused queue).
38
+ // Replaces the old one-hour park. Source: user 2026-10-26 agreed「有进展就一直续,没进展才停」(#429),
39
+ // replacing 橘 10-25 15:19「最多续 2 次」; same as Go watchMaxRenew.
40
+ export const MAX_RENEWALS = 0;
26
41
  // setTimeout's own maximum delay (Node clamps anything larger to 1 ms).
27
42
  const MAX_TIMER_MS = 2 ** 31 - 1;
28
43
  const STANCE_LINE = /^\s*【(接|问|退|升)】\s*(?:#(\d+))?\s*(.*)$/;
44
+ // The dispatcher's explicit review of a workspace task, a line of its chat-channel reply:
45
+ // 【验收】#id closes it, 【返工】#id 原因 sends it back (Tasks.verdictsOf).
46
+ const VERDICT_LINE = /^[ \t]*【(验收|返工)】[ \t]*#(\d+)[ \t]*(.*)$/gm;
29
47
  const HUMAN = 'owner';
48
+ // #597 G35 briefing sizes (chars): enough for the handoff's goal and acceptance lines and the
49
+ // report's summary, short of MAX_TEXT with the record around them. Not user-specified.
50
+ const BRIEF_HANDOFF = 1500;
51
+ const BRIEF_REPORT = 800;
30
52
  const PLACEHOLDERS = WORKING.map(() => '?').join(', ');
53
+ // A paused task (park: the bridge released its workspace with the task stopped at a clean
54
+ // point) keeps its status but is not in progress: no check timer, not counted. Any status
55
+ // change by its owner or the lead (stance, hand-back, review) resumes it.
56
+ const LIVE = `status IN (${PLACEHOLDERS}) AND paused_at IS NULL`;
57
+ // #574 dispatch fields: `ref` names the existing implementation to follow, `base` the branch to start
58
+ // from when several members edit the same files. The checks and their messages are in the shared
59
+ // handoff rules (handoff-rules.json, also read by ws-handoff); a dispatch runs the ones listed under
60
+ // its `dispatch` key. The rules file not being readable refuses the dispatch (HTTP 500), never skips it.
61
+ const MISSING_REWORK_NOTE = '返工要写原因:note 填为什么打回,示例:note: "没先读现有实现"';
62
+
63
+ /** Runs a handoff check; a missing or broken rules file becomes a CollabError instead of letting it through. */
64
+ export function withRules(fn) {
65
+ try {
66
+ return fn();
67
+ } catch (e) {
68
+ if (e instanceof HandoffRulesError) throw new CollabError('rules-missing', e.message);
69
+ throw e;
70
+ }
71
+ }
72
+
73
+ /** True when ref passes the shared reference-implementation check on its own. */
74
+ export function hasRef(ref) { return checkDispatch({ title: '', ref }) === null; }
31
75
 
32
76
  export class Tasks {
77
+ reassignForQuota(task, owner) {
78
+ const row = this.db.prepare(`UPDATE tasks SET owner = ?, status = 'assigned', next_check_at = ?,
79
+ renewals = 0, remind_msg_id = NULL, updated_at = ? WHERE id = ? AND owner = ? RETURNING *`)
80
+ .get(owner, this.now() + CHECK_AFTER_S, this.now(), task.id, task.owner);
81
+ if (row) {
82
+ this.record(row, 'quota', `#${row.id}「${row.title}」额度冷却转交 → ${this.nameOf(owner)}。原交接:${task.reason ?? ''}\n参照:${task.ref ?? ''}\n底分支:${task.base ?? ''}`);
83
+ this.changed(row.id);
84
+ }
85
+ return row;
86
+ }
33
87
  /**
34
88
  * @param hub - Hub (store, members, post, emit).
35
89
  * @param options - { now?(): seconds, setTimer?, clearTimer? } (tests drive the clock);
36
90
  * start: false defers the timer and pending deliveries until start() (the plugin
37
91
  * starts after DSH recovery, so a reminder job is never mistaken for a leftover).
38
92
  */
39
- constructor(hub, { now = () => Date.now() / 1000, setTimer = setTimeout, clearTimer = clearTimeout, start = true } = {}) {
93
+ constructor(hub, { now = () => Date.now() / 1000, setTimer = setTimeout, clearTimer = clearTimeout, start = true, maxRenewals = MAX_RENEWALS } = {}) {
40
94
  this.hub = hub;
95
+ this.maxRenewals = maxRenewals;
41
96
  this.store = hub.store;
42
97
  this.db = hub.store.db;
43
98
  this.now = now;
@@ -50,37 +105,66 @@ export class Tasks {
50
105
  this.wakers = [];
51
106
  /** released(roomId) -> a workspace finished its last task and went back to the pool. */
52
107
  this.releasers = [];
108
+ /**
109
+ * #597 r12: bare-name calls after a delivery, waiting for the owner's stance line, keyed
110
+ * `room\0owner`: { roomId, owner, lead, title, messageId, at }. In memory only: a restart drops them.
111
+ */
112
+ this.pendingCalls = new Map();
53
113
  const cols = new Set(this.db.prepare('PRAGMA table_info(rooms)').all().map(c => c.name));
54
114
  // rooms.kind/parent_id come with team mode A (#134); without them every room is its own chat channel.
55
115
  this.tree = cols.has('parent_id') && cols.has('kind');
56
116
  this.q = {
57
117
  get: this.db.prepare('SELECT * FROM tasks WHERE id = ?'),
58
- add: this.db.prepare(`INSERT INTO tasks (room_id, parent_task, title, owner, created_by, status, next_check_at, assign_msg_id, created_at, updated_at, reason)
59
- VALUES (?, ?, ?, ?, ?, 'assigned', ?, ?, ?, ?, ?) RETURNING *`),
118
+ add: this.db.prepare(`INSERT INTO tasks (room_id, parent_task, title, owner, created_by, status, next_check_at, assign_msg_id, created_at, updated_at, reason, ref, base)
119
+ VALUES (?, ?, ?, ?, ?, 'assigned', ?, ?, ?, ?, ?, ?, ?) RETURNING *`),
60
120
  // The last ? keeps (1) or drops (0) an undelivered reminder: only 接/问 stay the owner's turn.
61
- set: this.db.prepare('UPDATE tasks SET status = ?, next_check_at = ?, note = ?, updated_at = ?, remind_msg_id = CASE WHEN ? THEN remind_msg_id END WHERE id = ? AND status = ? RETURNING *'),
62
- handBack: this.db.prepare(`UPDATE tasks SET status = 'returned', next_check_at = NULL, remind_msg_id = NULL, result_msg_id = ?, returned_at = ?, updated_at = ?
121
+ // A resumed (was paused) task starts a fresh watch: renewals back to 0 (rework: resetRenewals).
122
+ set: this.db.prepare(`UPDATE tasks SET status = ?, paused_at = NULL, next_check_at = ?, note = ?, updated_at = ?, remind_msg_id = CASE WHEN ? THEN remind_msg_id END,
123
+ renewals = CASE WHEN paused_at IS NOT NULL THEN 0 ELSE renewals END WHERE id = ? AND status = ? RETURNING *`),
124
+ handBack: this.db.prepare(`UPDATE tasks SET status = 'returned', paused_at = NULL, next_check_at = NULL, remind_msg_id = NULL, result_msg_id = ?, returned_at = ?, updated_at = ?
63
125
  WHERE id = ? AND status = ? RETURNING *`),
64
126
  bindAssign: this.db.prepare('UPDATE tasks SET assign_msg_id = ? WHERE id = ? AND assign_msg_id IS NULL'),
65
127
  due: this.db.prepare(`SELECT * FROM tasks WHERE next_check_at IS NOT NULL AND next_check_at <= ?
66
- AND status IN (${PLACEHOLDERS}) ORDER BY next_check_at, id`),
128
+ AND ${LIVE} ORDER BY next_check_at, id`),
67
129
  // Posting the reminder and marking it undelivered is one step; the check time is spent.
68
130
  unremind: this.db.prepare('UPDATE tasks SET next_check_at = NULL, remind_msg_id = ? WHERE id = ? AND next_check_at IS NOT NULL RETURNING *'),
69
- undelivered: this.db.prepare(`SELECT * FROM tasks WHERE remind_msg_id IS NOT NULL AND status IN (${PLACEHOLDERS}) ORDER BY id`),
131
+ undelivered: this.db.prepare(`SELECT * FROM tasks WHERE remind_msg_id IS NOT NULL AND ${LIVE} ORDER BY id`),
70
132
  // Clear before waking (a success is never re-sent); restore only if nobody took it.
71
133
  takeRemind: this.db.prepare('UPDATE tasks SET remind_msg_id = NULL WHERE id = ? AND remind_msg_id = ? RETURNING *'),
72
134
  putRemind: this.db.prepare('UPDATE tasks SET remind_msg_id = ? WHERE id = ? AND remind_msg_id IS NULL'),
73
- next: this.db.prepare(`SELECT MIN(next_check_at) AS at FROM tasks WHERE next_check_at IS NOT NULL AND status IN (${PLACEHOLDERS})`),
74
- active: this.db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE room_id = ? AND status IN (${PLACEHOLDERS})`),
75
- busyOfOwner: this.db.prepare(`SELECT * FROM tasks WHERE owner = ? AND status IN (${PLACEHOLDERS}) ORDER BY updated_at DESC`),
76
- allBusy: this.db.prepare(`SELECT * FROM tasks WHERE status IN (${PLACEHOLDERS}) ORDER BY updated_at DESC`),
135
+ active: this.db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE room_id = ? AND ${LIVE}`),
136
+ paused: this.db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE room_id = ? AND status IN (${PLACEHOLDERS}) AND paused_at IS NOT NULL`),
137
+ // Rows the owner posted or changed in place since a time (changed_at: a streamed reply growing, a progress card edited).
138
+ ownerSaid: this.db.prepare(`SELECT text, card FROM messages WHERE room_id = ? AND author_kind = 'role' AND author = ? AND deleted = 0
139
+ AND COALESCE(changed_at, created_at) > ?`),
140
+ // paused_chat: the chat channel that owned the workspace when the task was paused (resumeClaim).
141
+ resetRenewals: this.db.prepare('UPDATE tasks SET renewals = 0 WHERE id = ?'),
142
+ renew: this.db.prepare(`UPDATE tasks SET next_check_at = ?, renewals = ? WHERE id = ? AND ${LIVE} RETURNING *`),
143
+ park: this.db.prepare(`UPDATE tasks SET paused_at = ?, paused_chat = ?, next_check_at = NULL WHERE room_id = ? AND ${LIVE}`),
144
+ open: this.db.prepare(`SELECT * FROM tasks WHERE status IN (${PLACEHOLDERS}) ORDER BY id`),
145
+ autoClose: this.db.prepare(`UPDATE tasks SET status = 'returned', next_check_at = NULL, remind_msg_id = NULL,
146
+ note = ?, returned_at = ?, updated_at = ? WHERE id = ? AND status IN (${PLACEHOLDERS}) RETURNING *`),
147
+ busyOfOwner: this.db.prepare(`SELECT * FROM tasks WHERE owner = ? AND ${LIVE} ORDER BY updated_at DESC`),
148
+ allBusy: this.db.prepare(`SELECT * FROM tasks WHERE ${LIVE} ORDER BY updated_at DESC`),
77
149
  // A stance line without #id only answers a dispatch the owner has not answered yet.
78
150
  latestAssigned: this.db.prepare("SELECT * FROM tasks WHERE room_id = ? AND owner = ? AND status = 'assigned' ORDER BY id DESC LIMIT 1"),
151
+ // The owner's newest task in a room, whatever its status (fromChat: did she just deliver?).
152
+ latestOf: this.db.prepare('SELECT * FROM tasks WHERE room_id = ? AND owner = ? ORDER BY id DESC LIMIT 1'),
153
+ // Chat-side tasks (source 'mention' / 'card'): opened from the lead's @ or the bridge task card.
154
+ setSource: this.db.prepare('UPDATE tasks SET source = ? WHERE id = ?'),
155
+ openChat: this.db.prepare(`SELECT * FROM tasks WHERE room_id = ? AND owner = ? AND created_by = ? AND source IS NOT NULL
156
+ AND status IN (${PLACEHOLDERS}) ORDER BY id DESC LIMIT 1`),
157
+ returnedChat: this.db.prepare("SELECT * FROM tasks WHERE room_id = ? AND created_by = ? AND source IN ('mention', 'card') AND status = 'returned' ORDER BY id"),
79
158
  children: this.tree ? this.db.prepare("SELECT id FROM rooms WHERE kind = 'workspace' AND parent_id = ? ORDER BY id") : undefined,
159
+ // #597 G24: a job the lead already handed to one of this chat channel's workspaces.
160
+ openHanded: this.tree ? this.db.prepare(`SELECT * FROM tasks WHERE owner = ? AND created_by = ? AND source = 'workspace'
161
+ AND status IN (${PLACEHOLDERS}) AND room_id IN (SELECT id FROM rooms WHERE kind = 'workspace' AND parent_id = ?) ORDER BY id DESC LIMIT 1`) : undefined,
80
162
  };
81
163
  hub.onMessage(message => this.fromMessage(message));
82
164
  // A member coming online (cloud-web register, DSH member published) gets its pending reminders.
83
- this.unsubscribe = hub.subscribe(event => { if (event.type === 'members') this.deliverPending(); });
165
+ this.unsubscribe = hub.subscribe(event => {
166
+ if (event.type === 'members') { this.deliverPending(); this.arm(); }
167
+ });
84
168
  if (start) this.start();
85
169
  }
86
170
 
@@ -119,20 +203,28 @@ export class Tasks {
119
203
  const chat = this.chatOf(roomId);
120
204
  if (chat === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
121
205
  const rooms = [chat, ...(this.tree ? this.q.children.all(chat).map(r => r.id) : [])];
122
- const rows = this.db.prepare(`SELECT * FROM tasks WHERE room_id IN (${rooms.map(() => '?').join(', ')}) ORDER BY id DESC`).all(...rooms);
206
+ // A paused task stays on the board of the chat channel it was paused under (paused_chat), even
207
+ // after its workspace went back to the pool and another chat channel claimed it: a to-do is never lost.
208
+ const rows = this.db.prepare(`SELECT * FROM tasks WHERE (room_id IN (${rooms.map(() => '?').join(', ')})
209
+ AND (paused_at IS NULL OR paused_chat IS NULL OR paused_chat = ?)) OR (paused_at IS NOT NULL AND paused_chat = ?)
210
+ ORDER BY id DESC`).all(...rooms, chat, chat);
123
211
  return rows.map(r => this.view(r));
124
212
  }
125
213
 
126
214
  /** In-progress tasks in one room (the "进行中 N 件" next to it in the channel list). */
127
215
  countActive(roomId) { return this.q.active.get(roomId, ...WORKING).n; }
128
216
 
217
+ /** Paused tasks in roomId (park): not counted by countActive. */
218
+ countPaused(roomId) { return this.q.paused.get(roomId, ...WORKING).n; }
219
+
129
220
  /** Public task row: codes plus display names. */
130
221
  view(t) {
131
222
  return {
132
223
  id: t.id, room_id: t.room_id, parent_task: t.parent_task, title: t.title,
133
224
  owner: t.owner, owner_name: this.nameOf(t.owner), created_by: t.created_by, created_by_name: this.nameOf(t.created_by),
134
225
  status: t.status, next_check_at: t.next_check_at, assign_msg_id: t.assign_msg_id, result_msg_id: t.result_msg_id,
135
- note: t.note, reason: t.reason, created_at: t.created_at, updated_at: t.updated_at, returned_at: t.returned_at,
226
+ note: t.note, reason: t.reason, ref: t.ref ?? null, base: t.base ?? null, created_at: t.created_at, updated_at: t.updated_at, returned_at: t.returned_at,
227
+ paused_at: t.paused_at ?? null,
136
228
  };
137
229
  }
138
230
 
@@ -196,16 +288,29 @@ export class Tasks {
196
288
  * Dispatch: the actor (the lead for this task) assigns `title` in `roomId` to member `owner`.
197
289
  * `reason` is the dispatcher's own one-line why-this-member, shown in the record (team mode C).
198
290
  * `force` allows assigning even if the member has an in-progress task in any room.
291
+ * `ref`/`base` (#574): an API dispatch passes ref (string, checked by the shared rules) and, for a
292
+ * multi-editor task, base; undefined ref (tasks opened from chat or bridge cards) skips the check.
199
293
  */
200
- assign({ roomId, title, owner, actor, parentTask, messageId, reason, force = false }) {
294
+ assign({ roomId, title, owner, actor, parentTask, reviewOf, messageId, reason, ref, base, force = false }) {
201
295
  const room = this.store.room(roomId);
202
296
  if (room === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
203
297
  const text = String(title ?? '').trim();
204
298
  if (!text || text.length > MAX_TEXT) throw new CollabError('invalid', 'task title is empty or too long');
205
299
  this.member(owner);
300
+ if (!this.hub.membersInRoom(roomId).some(m => m.id === owner)) throw new CollabError('member-missing', `member ${owner} does not belong to room ${roomId}`);
206
301
  this.member(actor);
302
+ if (reviewOf != null) {
303
+ const original = this.mustTask(reviewOf);
304
+ if (actor !== original.created_by && actor !== HUMAN) throw new CollabError('forbidden', 'only the dispatcher or owner can delegate a review');
305
+ if (owner === original.owner) throw new CollabError('forbidden', 'the author cannot review their own task');
306
+ if (!REVIEWABLE.includes(original.status)) throw new CollabError('conflict', 'only a returned task can be delegated for review');
307
+ if (this.chatOf(original.room_id) !== this.chatOf(roomId)) throw new CollabError('forbidden', 'a review must stay in the task chat channel');
308
+ parentTask = original.id;
309
+ }
207
310
  if (parentTask != null && this.task(parentTask) === undefined) throw new CollabError('task-missing', `task ${parentTask} does not exist`);
208
311
  if (messageId != null && this.store.message(messageId) === undefined) throw new CollabError('invalid', `message ${messageId} does not exist`);
312
+ const refError = ref === undefined ? null : withRules(() => checkDispatch({ title: text, ref, base }));
313
+ if (refError) throw new CollabError('invalid', refError);
209
314
  const excludeIds = parentTask != null ? this.ancestorsOf(parentTask) : new Set();
210
315
  const busy = this.busyInfo(owner, excludeIds);
211
316
  if (busy.busy && !force) {
@@ -214,9 +319,17 @@ export class Tasks {
214
319
  }
215
320
  const now = this.now();
216
321
  const why = clip(reason);
217
- const task = this.q.add.get(roomId, parentTask ?? null, text, owner, actor, now + CHECK_AFTER_S, messageId ?? null, now, now, why);
322
+ const refText = ref === undefined ? null : clip(ref);
323
+ const baseText = ref === undefined ? null : clip(base);
324
+ const task = this.q.add.get(roomId, parentTask ?? null, text, owner, actor, now + CHECK_AFTER_S, messageId ?? null, now, now, why, refText, baseText);
325
+ if (reviewOf != null) this.q.setSource.run('review', task.id);
326
+ const refNote = `${refText ? `,参照:${refText}` : ''}${baseText ? `,底分支:${baseText}` : ''}`;
218
327
  const busyNote = busy.busy ? `(注:该成员在「${busy.roomName}」已有进行中任务 #${busy.task.id})` : '';
219
- const record = this.record(task, 'assign', `派活 #${task.id}「${task.title}」→ ${this.nameOf(owner)}(${this.nameOf(actor)} 派)${why ? `,理由:${why}` : ''}${busyNote ? ` ${busyNote}` : ''}`);
328
+ // The task card shows the title, so the record does not repeat it. A task opened from a message
329
+ // (an @ dispatch, a handoff) gets its card under that message on the page (TaskCard by
330
+ // assign_msg_id), so no record goes to the message's own room: the dispatch text shows once.
331
+ const source = messageId == null ? undefined : this.store.message(messageId).room_id;
332
+ const record = this.record(task, 'assign', `派活 #${task.id} → ${this.nameOf(owner)}(${this.nameOf(actor)} 派)${why ? `,理由:${why}` : ''}${refNote}${busyNote ? ` ${busyNote}` : ''}`, source);
220
333
  if (messageId == null) this.q.bindAssign.run(record.id, task.id);
221
334
  return this.changed(task.id);
222
335
  }
@@ -229,8 +342,11 @@ export class Tasks {
229
342
  if (actor !== task.owner && actor !== HUMAN) throw new CollabError('forbidden', `only ${this.nameOf(task.owner)} can answer task ${id}`);
230
343
  if (!WORKING.includes(task.status)) throw new CollabError('conflict', `task ${id} is ${STATUS[task.status]}, not waiting for its owner`);
231
344
  const status = STANCES[mark];
345
+ this.resumeClaim(task, true);
232
346
  // 退/升 hand the turn to the lead: the reminder is cancelled.
233
- const check = status === 'declined' || status === 'escalated' ? null : task.next_check_at;
347
+ // A paused task answered again is back in work with a fresh check time.
348
+ const check = status === 'declined' || status === 'escalated' ? null
349
+ : task.paused_at != null ? this.now() + CHECK_AFTER_S : task.next_check_at;
234
350
  const row = this.q.set.get(status, check, clip(note), this.now(), check === null ? 0 : 1, id, task.status);
235
351
  if (row === undefined) throw new CollabError('conflict', `task ${id} changed meanwhile`);
236
352
  const tail = row.note ? `:${row.note}` : '';
@@ -244,32 +360,88 @@ export class Tasks {
244
360
  if (actor !== task.owner && actor !== HUMAN) throw new CollabError('forbidden', `only ${this.nameOf(task.owner)} can hand back task ${id}`);
245
361
  if (!WORKING.includes(task.status)) throw new CollabError('conflict', `task ${id} is ${STATUS[task.status]}, nothing to hand back`);
246
362
  if (messageId == null || this.store.message(messageId) === undefined) throw new CollabError('invalid', 'message_id must name the delivery message');
363
+ this.resumeClaim(task, false);
247
364
  const now = this.now();
248
365
  const row = this.q.handBack.get(messageId, now, now, id, task.status);
249
366
  if (row === undefined) throw new CollabError('conflict', `task ${id} changed meanwhile`);
250
367
  this.record(row, 'return', `#${id}「${row.title}」${this.nameOf(row.owner)} 交回,用时 ${duration(row.returned_at - row.created_at)}`);
368
+ this.returnToChat(row, messageId);
251
369
  this.releaseIfDone(row.room_id);
252
370
  return this.changed(id);
253
371
  }
254
372
 
373
+ /**
374
+ * A workspace task handed back: its result goes to the chat channel (TeamExit.forwardResult:
375
+ * the [report] body, or the delivery itself) and its dispatcher is woken there on it to review,
376
+ * as a chat-channel hand-back @-s the lead (lead_return.go). A bridge-claimed workspace is the
377
+ * bridge's (its lead session reads the workspace itself).
378
+ */
379
+ returnToChat(task, messageId) {
380
+ if (!this.tree || this.store.room(task.room_id)?.kind !== 'workspace' || this.store.bridgeClaimed(task.room_id)) return;
381
+ const msg = this.store.message(messageId);
382
+ let copy = msg === undefined ? undefined : this.hub.teamExit?.forwardResult(msg);
383
+ if (copy === undefined || task.created_by === HUMAN) return;
384
+ // The dispatcher is woken on this copy alone (a turn reads no other system row), so it says
385
+ // how to review, as the pause record says how to answer (watch).
386
+ const how = `\n\n验收 #${task.id}:回复里单独一行写【验收】#${task.id};要返工就单独一行写【返工】#${task.id} 原因。`;
387
+ if (!copy.text.includes(how)) copy = this.hub.edit(copy.id, 'system', 'system', copy.text + how, copy.card) ?? copy;
388
+ this.wake(task.created_by, copy);
389
+ }
390
+
255
391
  /** The lead (or the human owner) closes the task or sends it back for rework. */
256
- review(id, { actor, verdict, note }) {
392
+ review(id, { actor, verdict, note, messageId }) {
257
393
  const task = this.mustTask(id);
258
- if (actor !== task.created_by && actor !== HUMAN) throw new CollabError('forbidden', `only ${this.nameOf(task.created_by)} can review task ${id}`);
394
+ const reviews = this.db.prepare(`SELECT * FROM tasks WHERE parent_task = ? AND source = 'review'
395
+ AND status IN (${PLACEHOLDERS})`).all(id, ...WORKING);
396
+ const delegated = actor !== task.owner && reviews.some(r => r.owner === actor && r.paused_at == null);
397
+ if (actor !== task.created_by && actor !== HUMAN && !delegated) throw new CollabError('forbidden', `only ${this.nameOf(task.created_by)} or their delegated reviewer can review task ${id}`);
259
398
  if (!REVIEWABLE.includes(task.status)) throw new CollabError('conflict', `task ${id} is ${STATUS[task.status]}, nothing to review`);
260
399
  if (verdict !== 'done' && verdict !== 'rework') throw new CollabError('invalid', 'verdict must be done or rework');
400
+ // #574: a send-back always says why (same rule as board done: rework count > 0 needs a reason).
401
+ if (verdict === 'rework' && !clip(note)) throw new CollabError('invalid', MISSING_REWORK_NOTE);
261
402
  // Rework is a new round with the same check time as a dispatch (source above).
262
403
  const check = verdict === 'rework' ? this.now() + CHECK_AFTER_S : null;
404
+ const reclaim = verdict === 'rework' && this.reworkClaim(task);
263
405
  const row = this.q.set.get(verdict, check, clip(note) ?? task.note, this.now(), 0, id, task.status);
264
406
  if (row === undefined) throw new CollabError('conflict', `task ${id} changed meanwhile`);
407
+ for (const review of reviews) {
408
+ this.db.prepare(`UPDATE tasks SET status = 'done', next_check_at = NULL, remind_msg_id = NULL,
409
+ paused_at = NULL, result_msg_id = ?, returned_at = ?, updated_at = ? WHERE id = ?`)
410
+ .run(messageId ?? null, this.now(), this.now(), review.id);
411
+ this.record(this.task(review.id), 'done', `#${review.id} 验收 #${id} 已结束:${verdict === 'done' ? '通过' : '返工'}`);
412
+ this.changed(review.id);
413
+ this.releaseIfDone(review.room_id);
414
+ }
415
+ if (verdict === 'rework') this.q.resetRenewals.run(id);
265
416
  const tail = clip(note) ? `:${clip(note)}` : '';
266
- this.record(row, verdict, verdict === 'done'
417
+ // A workspace task sent back: its owner is woken there on the record (nobody routes it there
418
+ // otherwise), which says how to hand back as the pause record does (watch).
419
+ const how = reclaim ? `。做完了就交回:回复第一行写【接】#${id},下面写 [report] 和结果;没做完就第一行写【问】#${id},说明做到哪一步、卡在哪。${this.briefing(row)}` : '';
420
+ const record = this.record(row, verdict, verdict === 'done'
267
421
  ? `#${id}「${row.title}」验收完成(${this.nameOf(actor)})`
268
- : `#${id}「${row.title}」返工 → ${this.nameOf(row.owner)}(${this.nameOf(actor)})${tail}`);
422
+ : `${reclaim ? `@${this.nameOf(row.owner)} ` : ''}#${id}「${row.title}」返工 → ${this.nameOf(row.owner)}(${this.nameOf(actor)})${tail}${how}`);
269
423
  if (verdict === 'done') this.releaseIfDone(row.room_id);
424
+ if (reclaim || (delegated && verdict === 'rework')) this.wake(row.owner, record);
270
425
  return this.changed(id);
271
426
  }
272
427
 
428
+ /**
429
+ * A workspace task (not a bridge claim's) about to be sent back: its hand-back may have released
430
+ * the workspace, so it is claimed again for the chat channel above it with this task as the
431
+ * handoff (as resumeClaim does); one another chat channel claimed since refuses the rework with
432
+ * 'workspace-owned'. True when the task is such a workspace task.
433
+ */
434
+ reworkClaim(task) {
435
+ if (!this.tree || this.store.room(task.room_id)?.kind !== 'workspace' || this.store.bridgeClaimed(task.room_id)) return false;
436
+ const chat = this.chatOf(task.room_id);
437
+ const owner = this.store.workspaceOwner(task.room_id);
438
+ if (owner != null && owner !== chat) {
439
+ throw new CollabError('workspace-owned', `task ${task.id}'s workspace now belongs to room ${owner}; hand it off again to a free workspace`);
440
+ }
441
+ if (owner == null) this.store.claimWorkspace(chat, task.room_id, { handoff: task.id });
442
+ return true;
443
+ }
444
+
273
445
  /**
274
446
  * One API action (#139 B) for a sender. `verified` says the actor comes from
275
447
  * the connection or session (owner's browser, a cloud-web bot's socket, the
@@ -289,7 +461,9 @@ export class Tasks {
289
461
  }
290
462
  const optional = v => (v == null || v === '' ? undefined : Number(v));
291
463
  switch (action) {
292
- case 'assign': return this.assign({ roomId, title: body.title, owner: String(body.owner ?? ''), actor, parentTask: optional(body.parent_task), messageId: optional(body.message_id), reason: body.reason, force: Boolean(body.force) });
464
+ case 'assign':
465
+ if (body.review_of != null && !verified) throw new CollabError('forbidden', 'delegating review needs a verified sender');
466
+ return this.assign({ roomId, title: body.title, owner: String(body.owner ?? ''), actor, parentTask: optional(body.parent_task), reviewOf: optional(body.review_of), messageId: optional(body.message_id), reason: body.reason, ref: String(body.ref ?? ''), base: body.base, force: Boolean(body.force) });
293
467
  case 'stance': return this.stance(id, { actor, stance: String(body.stance ?? ''), note: body.note });
294
468
  case 'return': return this.handBack(id, { actor, messageId: optional(body.message_id) });
295
469
  case 'review': return this.review(id, { actor, verdict: String(body.verdict ?? ''), note: body.note });
@@ -300,21 +474,289 @@ export class Tasks {
300
474
  /** A member reply whose first line is 【接】【问】【退】【升】 (optionally #id) answers its task. */
301
475
  fromMessage(msg) {
302
476
  if (msg.author_kind !== 'role' || msg.card !== null) return undefined;
303
- const first = String(msg.text).split('\n', 1)[0];
477
+ if (this.verdictsOf(msg)) return undefined;
478
+ this.settleCall(msg);
479
+ const stance = this.stanceOf(msg);
480
+ const dsh = this.hub.members.get(msg.author)?.kind === 'dsh';
481
+ const dshWorkspace = dsh && this.inWorkspace(msg.room_id);
482
+ if (dshWorkspace) {
483
+ // #597 G35: a DSH member (local CLI members too, dsh-member.mjs) posts one reply per turn, but a
484
+ // turn ending is not a delivery. In a workspace only a reply marked [report] (team-exit exitMark,
485
+ // first line or the turn's last part) hands its task back; a 【接】 alone (accepted, waiting) and an
486
+ // [ask] (a question, forwarded by TeamExit) leave the task in work with its 10-minute watch.
487
+ // Only the hand-back changes here: the lead's dispatch below (fromChat) goes on as before.
488
+ const mark = exitMark(msg.text, { lastLine: true, team: this.hub.routable() });
489
+ // A stance line naming #id speaks for that task only (gone or done: nothing to hand back).
490
+ const named = STANCE_LINE.exec(withoutLeadingMentions(String(msg.text), this.hub.routable()).split('\n', 1)[0])?.[2] !== undefined;
491
+ const task = stance !== undefined ? this.task(stance.id) : (named ? undefined : this.openOf(msg.room_id, msg.author));
492
+ if (mark?.kind === 'report' && (stance === undefined || stance.status === 'accepted') && task !== undefined && task.source !== 'review' && WORKING.includes(task.status)) {
493
+ // #597 G37: the hand-back does not end it: a lead's [report] @-ing a teammate dispatches too.
494
+ const handed = this.handBack(task.id, { actor: msg.author, messageId: msg.id });
495
+ this.fromChat(msg, { handBack: false });
496
+ return handed;
497
+ }
498
+ } else if (stance?.status === 'accepted' && this.task(stance.id).source !== 'review' && dsh) {
499
+ // In a chat channel a DSH member's 【接】 is still its delivery (no [report] convention there).
500
+ // A bridge bot may keep working after its 【接】, so its stance stays a stance.
501
+ return this.handBack(stance.id, { actor: msg.author, messageId: msg.id });
502
+ }
503
+ if (stance !== undefined) return stance;
504
+ // A stance naming a task of another room speaks about that task: it hands back nothing here either.
505
+ if (this.foreignStance(msg)) return undefined;
506
+ // A 【问】【退】【升】 reply with no task to answer is a question or a pushback, never a hand-back.
507
+ if (/^【(问|退|升)】/.test(withoutLeadingMentions(String(msg.text), this.hub.routableIn(msg.room_id)).trimStart())) return undefined;
508
+ if (NOTICE_PREFIXES.some(p => String(msg.text).trimStart().startsWith(p))) return undefined;
509
+ return this.fromChat(msg, { handBack: !dshWorkspace });
510
+ }
511
+
512
+ /**
513
+ * Lines 【验收】#id / 【返工】#id 原因 in a chat-channel reply review the workspace tasks they
514
+ * name: only the task's dispatcher, only for a workspace under this chat channel. Anything
515
+ * else the dispatcher says (a reply to someone else, a send-back without the line) changes nothing.
516
+ */
517
+ verdictsOf(msg) {
518
+ let applied = false;
519
+ for (const [, word, id, note] of String(msg.text).matchAll(VERDICT_LINE)) {
520
+ const task = this.task(Number(id));
521
+ if (task === undefined || this.chatOf(task.room_id) !== this.chatOf(msg.room_id)) continue;
522
+ // A dispatcher teaching an independent reviewer the verdict syntax is delegating,
523
+ // even when those example commands occupy their own lines.
524
+ if (task.created_by === msg.author) {
525
+ const team = this.hub.routableIn(msg.room_id);
526
+ const explicit = mentions(String(msg.text), team);
527
+ if (team.some(m => m.id !== msg.author && m.id !== task.owner
528
+ && (explicit.has(m.id) || nameCalled(String(msg.text), m.name, team.map(t => t.name))))) continue;
529
+ }
530
+ // Dispatchers review in the chat channel; delegated reviewers may work in their review workspace.
531
+ if (this.inWorkspace(msg.room_id) && !this.db.prepare(`SELECT id FROM tasks WHERE parent_task = ?
532
+ AND source = 'review' AND room_id = ? AND owner = ? AND ${LIVE}`).get(task.id, msg.room_id, msg.author, ...WORKING)) continue;
533
+ try {
534
+ this.review(task.id, { actor: msg.author, verdict: word === '验收' ? 'done' : 'rework', note: word === '返工' ? note.trim() : undefined, messageId: msg.id });
535
+ applied = true;
536
+ } catch (error) {
537
+ this.hub.logger?.warn?.('collab: %s #%s from %s refused: %s', word, id, msg.author, error?.message ?? error);
538
+ }
539
+ }
540
+ return applied;
541
+ }
542
+
543
+ /**
544
+ * #597 G35: a DSH owner woken (rework, pause) with no session left in the task's workspace (a new
545
+ * claim cleared it, store.resetSessions) starts with nothing but the wake: it gets the original
546
+ * handoff and its last [report], so it never reports on work it cannot see. '' while its session
547
+ * is there (it goes on in it) and for other members.
548
+ */
549
+ briefing(task) {
550
+ if (this.hub.members.get(task.owner)?.kind !== 'dsh' || this.store.binding(task.owner, task.room_id) !== undefined) return '';
551
+ const handoff = task.assign_msg_id == null ? undefined : this.store.message(task.assign_msg_id);
552
+ const result = task.result_msg_id == null ? undefined : this.store.message(task.result_msg_id);
553
+ const report = result === undefined ? '' : (exitMark(result.text, { lastLine: true, team: this.hub.routable() })?.body ?? String(result.text));
554
+ const cut = (t, n) => { const v = String(t ?? '').trim(); return v.length > n ? `${v.slice(0, n)}…` : v; };
555
+ const parts = [];
556
+ if (handoff !== undefined) parts.push(`原交接:\n${cut(handoff.text, BRIEF_HANDOFF)}`);
557
+ if (report.trim()) parts.push(`上次 [report] 摘录:\n${cut(report, BRIEF_REPORT)}`);
558
+ return parts.length ? `\n\n这是新会话,接着做要的上下文:\n${parts.join('\n\n')}` : '';
559
+ }
560
+
561
+ /** True when roomId is a workspace (team mode A). */
562
+ inWorkspace(roomId) { return this.tree && this.store.room(roomId)?.kind === 'workspace'; }
563
+
564
+ /** owner's latest task in work in roomId (not paused-only: a paused one is resumed by handBack). */
565
+ openOf(roomId, owner) {
566
+ return this.db.prepare(`SELECT * FROM tasks WHERE room_id = ? AND owner = ? AND status IN (${PLACEHOLDERS}) ORDER BY id DESC LIMIT 1`)
567
+ .get(roomId, owner, ...WORKING);
568
+ }
569
+
570
+ /** The first line is a stance whose #id names a task in another room. */
571
+ foreignStance(msg) {
572
+ const m = STANCE_LINE.exec(withoutLeadingMentions(String(msg.text), this.hub.routable()).split('\n', 1)[0]);
573
+ const task = m?.[2] !== undefined ? this.task(Number(m[2])) : undefined;
574
+ return task !== undefined && task.room_id !== msg.room_id;
575
+ }
576
+
577
+ stanceOf(msg) {
578
+ // A bridge final reply opens with the lead's @ (lead_return.go), so skip leading mentions.
579
+ const first = withoutLeadingMentions(String(msg.text), this.hub.routableIn(msg.room_id)).split('\n', 1)[0];
304
580
  const m = STANCE_LINE.exec(first);
305
581
  if (m === null) return undefined;
306
582
  // Members open every reply with a stance, so without #id only a not-yet-answered
307
583
  // dispatch counts; once answered, changing that task needs its #id.
308
584
  const task = m[2] !== undefined ? this.task(Number(m[2]))
309
585
  : this.q.latestAssigned.get(msg.room_id, msg.author);
310
- if (task === undefined || task.owner !== msg.author || !WORKING.includes(task.status)) return undefined;
586
+ // A task is answered in its own room: a 【接】#id in the chat channel about a workspace task is
587
+ // talk about it (and for a DSH member would be a hand-back without the work), not its answer.
588
+ if (task === undefined || task.room_id !== msg.room_id || task.owner !== msg.author || !WORKING.includes(task.status)) return undefined;
311
589
  return this.stance(task.id, { actor: msg.author, stance: m[1], note: m[3] });
312
590
  }
313
591
 
314
- /** Post the reminder for every task past its check time, once each, and wake its owner. */
592
+ /**
593
+ * The board follows the channel the way Discord's task card follows a lead's dispatch,
594
+ * without the task API: the primary role @-ing (or calling by leading name) teammates
595
+ * opens one task per teammate that has none open from it in this room; a teammate's
596
+ * reply that @-s the dispatcher (cloud-web lead_return adds it to every final reply)
597
+ * hands its open task back; the dispatcher's next message accepts what was handed back here
598
+ * (a workspace task needs its 【验收】#id line, verdictsOf).
599
+ */
600
+ fromChat(msg, { handBack = true } = {}) {
601
+ const lead = this.store.leadId(msg.room_id) === null ? this.hub.primaryMember() : this.hub.primaryIn(msg.room_id);
602
+ const team = this.hub.routableIn(msg.room_id);
603
+ const text = String(msg.text);
604
+ if (lead !== undefined && msg.author === lead.id) {
605
+ const reviewIds = new Set([...text.matchAll(/【(?:验收|返工)】[ \t]*#(\d+)/g)].map(m => Number(m[1])));
606
+ const reviewOf = reviewIds.size === 1 ? this.task([...reviewIds][0]) : undefined;
607
+ const reviewing = reviewOf?.created_by === lead.id && REVIEWABLE.includes(reviewOf.status)
608
+ && this.chatOf(reviewOf.room_id) === this.chatOf(msg.room_id) ? reviewOf : undefined;
609
+ // Its next message after a chat-side hand-back accepts it. Workspace tasks need its explicit
610
+ // 【验收】#id / 【返工】#id line (verdictsOf): it may be answering something else meanwhile.
611
+ for (const t of this.q.returnedChat.all(msg.room_id, lead.id)) {
612
+ if (t.id === reviewing?.id || this.db.prepare(`SELECT id FROM tasks WHERE parent_task = ? AND source = 'review' AND status IN (${PLACEHOLDERS})`).get(t.id, ...WORKING)) continue;
613
+ try { this.review(t.id, { actor: lead.id, verdict: 'done' }); } catch { /* changed meanwhile */ }
614
+ }
615
+ const explicit = mentions(text, team);
616
+ const called = new Set(explicit);
617
+ const names = team.map(m => m.name);
618
+ for (const m of team) if (nameCalled(text, m.name, names)) called.add(m.id);
619
+ called.delete(lead.id);
620
+ const title = taskTitle(text, team);
621
+ if (!title) return undefined;
622
+ for (const owner of called) {
623
+ if (reviewing !== undefined) {
624
+ this.openFromChat({ roomId: msg.room_id, owner, lead: lead.id, title, messageId: msg.id, source: 'review', reviewOf: reviewing.id });
625
+ continue;
626
+ }
627
+ // #597 r12: structure, not wording. Once the owner's newest task here is handed back or
628
+ // accepted, a bare name may be talk about the delivery (praise) or a send-back: the task
629
+ // opens only when her next message here opens with a stance line (settleCall). An explicit
630
+ // @, a first call, or one while her task is in work dispatches at once, as before.
631
+ const key = `${msg.room_id}\0${owner}`;
632
+ this.pendingCalls.delete(key);
633
+ const latest = this.q.latestOf.get(msg.room_id, owner);
634
+ if (DELIVERED.includes(latest?.status) && !explicit.has(owner)) {
635
+ this.pendingCalls.set(key, { roomId: msg.room_id, owner, lead: lead.id, title, messageId: msg.id, at: this.now() });
636
+ continue;
637
+ }
638
+ this.openFromChat({ roomId: msg.room_id, owner, lead: lead.id, title, messageId: msg.id, source: 'mention' });
639
+ }
640
+ return undefined;
641
+ }
642
+ // handBack false: a DSH reply in a workspace, which hands back only with [report] (fromMessage).
643
+ if (!handBack) return undefined;
644
+ const callsLead = lead !== undefined && (mentions(text, team).has(lead.id) || nameCalled(text, lead.name, team.map(m => m.name)));
645
+ if (!callsLead) return undefined;
646
+ const open = this.q.openChat.get(msg.room_id, msg.author, lead.id, ...WORKING);
647
+ if (open === undefined || open.source === 'review') return undefined;
648
+ return this.handBack(open.id, { actor: msg.author, messageId: msg.id });
649
+ }
650
+
651
+ /**
652
+ * #597 r12: the owner's next message in a room settles the lead's bare-name call waiting there
653
+ * (fromChat). A first line 【接】【问】【退】【升】 without #id opens the task, which stanceOf then
654
+ * answers with that stance; anything else (thanks, chat), a stance naming #id, or a reply after
655
+ * PENDING_CALL_S drops it.
656
+ */
657
+ settleCall(msg) {
658
+ const key = `${msg.room_id}\0${msg.author}`;
659
+ const call = this.pendingCalls.get(key);
660
+ if (call === undefined) return;
661
+ this.pendingCalls.delete(key);
662
+ if (this.now() - call.at > PENDING_CALL_S) return;
663
+ const m = STANCE_LINE.exec(withoutLeadingMentions(String(msg.text), this.hub.routable()).split('\n', 1)[0]);
664
+ if (m === null || m[2] !== undefined) return;
665
+ try {
666
+ this.openFromChat({ ...call, source: 'mention' });
667
+ } catch (error) {
668
+ this.hub.logger?.warn?.('collab: pending call failed: %s', error?.message ?? error);
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Open a chat-side task unless the owner already has one open from this lead in this room, or one
674
+ * the lead handed to a workspace under it (the lead's later @ just tells the owner about it).
675
+ */
676
+ openFromChat({ roomId, owner, lead, title, messageId, source, reviewOf }) {
677
+ if (!this.hub.members.has(owner) || this.q.openChat.get(roomId, owner, lead, ...WORKING) !== undefined) return undefined;
678
+ if (this.q.openHanded?.get(owner, lead, ...WORKING, roomId) !== undefined) return undefined;
679
+ // force: the channel already dispatched it; the board records it even if the owner is busy elsewhere.
680
+ const task = this.assign({ roomId, title, owner, actor: lead, messageId, reviewOf, force: true });
681
+ this.q.setSource.run(source, task.id);
682
+ return task;
683
+ }
684
+
685
+ /**
686
+ * Bridge task card members (cloud-web public_progress.go task_members) in room roomId,
687
+ * posted by lead as card message messageId: a live top-level member opens its task, one
688
+ * that ended (core.TaskMember: completed/failed/cancelled/interrupted) hands it back bound
689
+ * to the card. Sub-rows (parent_id set) belong to their parent member and are skipped.
690
+ */
691
+ fromCard(roomId, lead, messageId, members) {
692
+ if (!Array.isArray(members) || !this.hub.members.has(lead)) return;
693
+ const team = this.hub.routableIn(roomId);
694
+ for (const m of members) {
695
+ if (m === null || typeof m !== 'object' || m.parent_id) continue;
696
+ const project = String(m.reporter_project ?? '');
697
+ const name = String(m.name ?? '').toLowerCase();
698
+ const owner = team.find(t => (project && t.id === project) || (name && t.name.toLowerCase() === name));
699
+ if (owner === undefined || owner.id === lead) continue;
700
+ const status = String(m.status ?? '');
701
+ try {
702
+ const mirrored = m.id ? this.db.prepare("SELECT * FROM tasks WHERE room_id = ? AND assign_msg_id = ? AND card_member_id = ? AND source = 'card'").get(roomId, messageId, m.id) : undefined;
703
+ if (mirrored && mirrored.owner !== owner.id && WORKING.includes(mirrored.status)) this.reassignForQuota(mirrored, owner.id);
704
+ if (['queued', 'running', 'waiting'].includes(status)) {
705
+ const title = String(m.title ?? '').trim() || String(m.name ?? '').trim();
706
+ if (title) {
707
+ const opened = this.openFromChat({ roomId, owner: owner.id, lead, title, messageId, source: 'card' });
708
+ if (opened && m.id) this.db.prepare('UPDATE tasks SET card_member_id = ? WHERE id = ?').run(m.id, opened.id);
709
+ }
710
+ // `result` is the public-progress report kind; newer bridge cards
711
+ // normalize it to `completed`, while older cards expose it directly.
712
+ // Keep `done` for cards produced by older task-card implementations.
713
+ } else if (['result', 'completed', 'done', 'failed', 'cancelled', 'interrupted'].includes(status)) {
714
+ const open = this.q.openChat.get(roomId, owner.id, lead, ...WORKING);
715
+ if (open !== undefined && open.source !== 'review') this.handBack(open.id, { actor: owner.id, messageId });
716
+ }
717
+ } catch (error) {
718
+ this.hub.logger?.warn?.('collab: task card sync failed: %s', error?.message ?? error);
719
+ }
720
+ }
721
+ }
722
+
723
+ /**
724
+ * At its check time a workspace task is renewed or parked (watch); a task in any other room
725
+ * gets one reminder @-ing the owner, which also wakes it. A workspace held by a bridge claim is
726
+ * the bridge's to watch (core/team_watch.go): its tasks get neither.
727
+ */
315
728
  tick() {
316
729
  if (this.closed || !this.started) return;
317
- for (const task of this.q.due.all(this.now(), ...WORKING)) {
730
+ const now = this.now();
731
+ for (const task of this.q.open.all(...WORKING)) {
732
+ // Only a member that was actually removed closes its work: a merely offline member, or a
733
+ // pause the user asked for, must be able to resume later.
734
+ if (task.owner === HUMAN) continue;
735
+ const removed = this.db.prepare('SELECT removed FROM members WHERE id = ?').get(task.owner)?.removed === 1;
736
+ if (!removed) continue;
737
+ const reason = '负责人已移除,自动收口';
738
+ const row = this.q.autoClose.get(reason, now, now, task.id, ...WORKING);
739
+ if (row === undefined) continue;
740
+ const text = `#${row.id}「${row.title}」${reason}。`;
741
+ if (row.paused_at != null && row.paused_chat != null && row.paused_chat !== this.chatOf(row.room_id)) {
742
+ const card = JSON.stringify({ kind: 'task', task_id: row.id, event: 'return' });
743
+ const message = { authorKind: 'system', author: 'system', name: '任务', text, card };
744
+ this.hub.post(row.room_id, message);
745
+ this.hub.post(row.paused_chat, message);
746
+ } else this.record(row, 'return', text);
747
+ this.releaseIfDone(row.room_id);
748
+ this.store.releaseIdleWorkspaces();
749
+ this.changed(row.id);
750
+ }
751
+ for (const task of this.q.allBusy.all(...WORKING)) {
752
+ if (this.watched(task) && watchDue(task) <= now) this.watch(task, now);
753
+ }
754
+ for (const task of this.q.due.all(now, ...WORKING)) {
755
+ if (this.store.room(task.room_id)?.kind === 'workspace') continue;
756
+ if (this.hub.quota?.cooling(task.owner)) {
757
+ this.q.renew.get(now + CHECK_AFTER_S, task.renewals ?? 0, task.id, ...WORKING);
758
+ continue;
759
+ }
318
760
  const name = this.nameOf(task.owner);
319
761
  const { message } = this.hub.post(task.room_id, {
320
762
  authorKind: 'system', author: 'system', name: '任务提醒',
@@ -328,6 +770,85 @@ export class Tasks {
328
770
  this.arm();
329
771
  }
330
772
 
773
+ /** A workspace task this gateway watches: not in a workspace the bridge claimed. */
774
+ watched(task) {
775
+ return this.store.room(task.room_id)?.kind === 'workspace' && !this.store.bridgeClaimed(task.room_id);
776
+ }
777
+
778
+ /**
779
+ * A workspace task at its check time: progress by its owner since the window opened
780
+ * (watchDue - CHECK_AFTER_S) and fewer than MAX_RENEWALS renewals → another CHECK_AFTER_S;
781
+ * otherwise it is parked through pauseTask, and the pause record @-s the owner and wakes it
782
+ * (the record goes first, while the workspace still names its chat channel).
783
+ */
784
+ watch(task, now) {
785
+ if (this.hub.quota?.cooling(task.owner)) {
786
+ this.q.renew.get(now + CHECK_AFTER_S, task.renewals ?? 0, task.id, ...WORKING);
787
+ return;
788
+ }
789
+ const renewals = task.renewals ?? 0;
790
+ // #597 G35: an owner waiting on its [ask] waits on the user, not stuck: no progress check, no
791
+ // reminder, no pause (which would release the workspace the answer comes back to). The watch
792
+ // only moves on, renewals untouched; the answer re-arms it (rewatch).
793
+ if (this.awaitingUser(task)) {
794
+ if (this.q.renew.get(now + CHECK_AFTER_S, renewals, task.id, ...WORKING) !== undefined) this.changed(task.id);
795
+ return;
796
+ }
797
+ if ((this.maxRenewals <= 0 || renewals < this.maxRenewals) && this.progressed(task, watchDue(task) - CHECK_AFTER_S)) {
798
+ if (this.q.renew.get(now + CHECK_AFTER_S, renewals + 1, task.id, ...WORKING) !== undefined) this.changed(task.id);
799
+ return;
800
+ }
801
+ const why = this.maxRenewals > 0 && renewals >= this.maxRenewals ? `已续期 ${this.maxRenewals} 次` : `${CHECK_AFTER_S / 60} 分钟内没有新进展`;
802
+ // The owner is woken on this record, often in a fresh session (the pause released the
803
+ // workspace): it says what to answer, as the bridge's watch reminder does (core/team_watch.go).
804
+ const message = this.record(task, 'pause', `@${this.nameOf(task.owner)} #${task.id}「${task.title}」${why},已暂停。`
805
+ + `做完了就交回:回复第一行写【接】#${task.id},下面写 [report] 和结果;没做完就第一行写【问】#${task.id},说明做到哪一步、卡在哪。${this.briefing(task)}`);
806
+ if (this.pauseTask(task.id)) this.wake(task.owner, message);
807
+ }
808
+
809
+ /**
810
+ * #597 G35: the task's owner is waiting on the user: its latest own text row in the task's
811
+ * workspace is an [ask] (team-exit team_exit_asks, posted after the task was handed over) the user
812
+ * has not answered yet. An older unanswered [ask] the owner moved on from (a later reply, a
813
+ * hand-back, a rework turn) does not hold the watch.
814
+ */
815
+ awaitingUser(task) {
816
+ if (!this.hub.teamExit?.enabled) return false;
817
+ return this.db.prepare(`SELECT 1 FROM team_exit_asks WHERE workspace_id = ? AND member_id = ? AND answered = 0
818
+ AND source_id > ? AND source_id = (SELECT MAX(id) FROM messages WHERE room_id = ? AND author = ?
819
+ AND deleted = 0 AND card IS NULL) LIMIT 1`)
820
+ .get(task.room_id, task.owner, task.assign_msg_id ?? 0, task.room_id, task.owner) !== undefined;
821
+ }
822
+
823
+ /**
824
+ * #597 G35: the user's answer to owner's [ask] reached workspace roomId (TeamExit): the owner's
825
+ * tasks in work there get a fresh CHECK_AFTER_S watch window from now.
826
+ */
827
+ rewatch(roomId, owner) {
828
+ const now = this.now();
829
+ for (const task of this.q.allBusy.all(...WORKING)) {
830
+ if (task.room_id !== roomId || task.owner !== owner || !this.watched(task) || this.awaitingUser(task)) continue;
831
+ if (this.q.renew.get(now + CHECK_AFTER_S, task.renewals ?? 0, task.id, ...WORKING) !== undefined) this.changed(task.id);
832
+ }
833
+ this.arm();
834
+ }
835
+
836
+ /**
837
+ * Progress = the owner's own rows posted or changed in place since `since`: its progress card
838
+ * (or any card it owns) moving, or text beyond a first stance line (【接】【问】【退】【升】) that is
839
+ * not a status notice. Same rule as contrib/lead/d10-fire chat_active, with the stance line taken
840
+ * off instead of the whole row: a DSH member's one reply opens with 【接】#id and grows in place.
841
+ */
842
+ progressed(task, since) {
843
+ return this.q.ownerSaid.all(task.room_id, task.owner, since).some(({ text, card }) => {
844
+ if (card != null) return true;
845
+ const lines = withoutLeadingMentions(String(text ?? ''), this.hub.routableIn(task.room_id)).trim().split('\n');
846
+ if (STANCE_LINE.test(lines[0])) lines.shift();
847
+ const body = lines.join('\n').trim();
848
+ return body !== '' && !NOTICE_PREFIXES.some(p => body.startsWith(p));
849
+ });
850
+ }
851
+
331
852
  /** Retry every undelivered reminder whose task is still the owner's turn. */
332
853
  deliverPending() {
333
854
  if (this.closed || !this.started) return;
@@ -341,22 +862,37 @@ export class Tasks {
341
862
  const task = this.q.takeRemind.get(taskId, msgId);
342
863
  if (task === undefined) return;
343
864
  if (message === undefined) return;
344
- let taken = false;
345
865
  this.delivering = true;
346
- try {
347
- for (const wake of this.wakers) {
348
- try { if (wake(task.owner, message)) { taken = true; break; } } catch (error) { this.hub.logger.warn('collab: task reminder wake failed: %s', error?.message ?? error); }
349
- }
350
- } finally { this.delivering = false; }
866
+ let taken;
867
+ try { taken = this.wake(task.owner, message); } finally { this.delivering = false; }
351
868
  if (!taken) this.q.putRemind.run(msgId, taskId);
352
869
  }
353
870
 
871
+ /** Start memberId on message through the first waker that takes it; true if one did. */
872
+ wake(memberId, message) {
873
+ if (this.hub.quota?.cooling(memberId)) return false;
874
+ for (const wake of this.wakers) {
875
+ try { if (wake(memberId, message)) return true; } catch (error) { this.hub.logger.warn('collab: task reminder wake failed: %s', error?.message ?? error); }
876
+ }
877
+ return false;
878
+ }
879
+
354
880
  /** Schedule tick() for the earliest pending check time. */
355
881
  arm() {
356
882
  if (this.timer !== undefined) this.clearTimer(this.timer);
357
883
  this.timer = undefined;
358
884
  if (this.closed) return;
359
- const at = this.q.next.get(...WORKING).at;
885
+ // Workspace tasks are due at watchDue (also those whose check time an older reminder spent),
886
+ // except in bridge-claimed workspaces; other tasks at their check time.
887
+ const at = this.q.open.all(...WORKING)
888
+ .map(t => {
889
+ if (t.owner === HUMAN) return null;
890
+ const removed = this.db.prepare('SELECT removed FROM members WHERE id = ?').get(t.owner)?.removed === 1;
891
+ if (removed) return this.now();
892
+ if (t.paused_at != null) return null;
893
+ return this.store.room(t.room_id)?.kind !== 'workspace' ? t.next_check_at : this.watched(t) ? watchDue(t) : null;
894
+ })
895
+ .filter(t => t != null).sort((a, b) => a - b)[0];
360
896
  if (at == null) return;
361
897
  const ms = Math.min(MAX_TIMER_MS, Math.max(0, (at - this.now()) * 1000));
362
898
  this.timer = this.setTimer(() => { this.timer = undefined; this.tick(); }, ms);
@@ -374,13 +910,17 @@ export class Tasks {
374
910
  return task;
375
911
  }
376
912
 
377
- /** One system record (task card) in the task room and, for a workspace, its chat channel. */
378
- record(task, event, text) {
913
+ /**
914
+ * One system record (task card) in the task room and, for a workspace, its chat channel; none in
915
+ * room skip (the room of the dispatch message, which shows the card itself). Returns the task
916
+ * room's record (undefined when skipped).
917
+ */
918
+ record(task, event, text, skip) {
379
919
  const card = JSON.stringify({ kind: 'task', task_id: task.id, event });
380
920
  const m = { authorKind: 'system', author: 'system', name: '任务', text, card };
381
- const { message } = this.hub.post(task.room_id, m);
921
+ const message = task.room_id === skip ? undefined : this.hub.post(task.room_id, m).message;
382
922
  const chat = this.chatOf(task.room_id);
383
- if (chat !== undefined && chat !== task.room_id) this.hub.post(chat, m);
923
+ if (chat !== undefined && chat !== task.room_id && chat !== skip) this.hub.post(chat, m);
384
924
  return message;
385
925
  }
386
926
 
@@ -411,6 +951,53 @@ export class Tasks {
411
951
  return true;
412
952
  }
413
953
 
954
+ /**
955
+ * Park workspace roomId (the bridge's op release with park, core ParkTeamWorkspace): its tasks
956
+ * in work are marked paused (still the owner's, resumed by any status change) and the workspace
957
+ * is released to the pool like release() (same claim check, 'stale-claim' before anything is
958
+ * paused). Returns whether it was released now.
959
+ */
960
+ park(roomId, claim = null) {
961
+ if (this.store.room(roomId)?.kind !== 'workspace' || this.store.workspaceOwner(roomId) == null) return false;
962
+ if (claim != null && String(claim) !== this.store.claimId(roomId)) {
963
+ throw new CollabError('stale-claim', `workspace ${roomId} was claimed again since claim ${claim}`);
964
+ }
965
+ this.q.park.run(this.now(), this.store.workspaceOwner(roomId), roomId, ...WORKING);
966
+ const released = this.release(roomId, claim);
967
+ this.arm();
968
+ return released;
969
+ }
970
+
971
+ /** Pause one task through the same queue/release path as park; other work stays busy. */
972
+ pauseTask(id) {
973
+ const task = this.task(id);
974
+ if (!task || task.paused_at != null || !WORKING.includes(task.status)
975
+ || this.store.room(task.room_id)?.kind !== 'workspace') return false;
976
+ this.db.prepare(`UPDATE tasks SET paused_at = ?, paused_chat = ?, next_check_at = NULL
977
+ WHERE id = ?`).run(this.now(), this.store.workspaceOwner(task.room_id), id);
978
+ if (this.countActive(task.room_id) === 0) this.release(task.room_id);
979
+ this.changed(id);
980
+ return true;
981
+ }
982
+
983
+ /**
984
+ * A paused task's workspace was released by park and may have been claimed by another chat
985
+ * channel since. Resuming it there would make the workspace serve two chat channels and send
986
+ * its asks/reports (team-exit, by rooms.parent_id) to the later one. So: owned by another chat
987
+ * channel → refused with 'workspace-owned' and nothing changes (the task stays paused, still
988
+ * listed; hand it off again to a free workspace). Free and reclaim → claimed back for the chat
989
+ * channel it was paused under. Tasks not paused, or paused before paused_chat existed, pass.
990
+ */
991
+ resumeClaim(task, reclaim) {
992
+ if (task.paused_at == null || task.paused_chat == null || !this.tree) return;
993
+ const owner = this.store.workspaceOwner(task.room_id);
994
+ if (owner != null && owner !== task.paused_chat) {
995
+ throw new CollabError('workspace-owned', `task ${task.id} is paused and its workspace now belongs to room ${owner}; hand it off again to a free workspace`);
996
+ }
997
+ // The resumed task is this claim's handoff: handing it back frees the workspace again (handoffDone).
998
+ if (owner == null && reclaim) this.store.claimWorkspace(task.paused_chat, task.room_id, { handoff: task.id });
999
+ }
1000
+
414
1001
  changed(id) {
415
1002
  const task = this.view(this.task(id));
416
1003
  this.hub.emit({ type: 'task', task, chat_id: this.chatOf(task.room_id), active: this.countActive(task.room_id) });
@@ -420,6 +1007,27 @@ export class Tasks {
420
1007
  }
421
1008
  }
422
1009
 
1010
+ /**
1011
+ * First non-empty line of a dispatch with the @-mentions taken out (the task title). A stance line
1012
+ * and an [ask]/[report] mark are not content (#597 G37: a lead's [report] opening 【接】#id dispatches too);
1013
+ * the line calling the teammate comes first.
1014
+ */
1015
+ function taskTitle(text, team) {
1016
+ const clean = l => l.replace(/^[\s,,::、]+/, '').replace(/^\[(?:ask|report)\]/i, '').replace(/^[\s,,::、]+/, '').trim();
1017
+ const lines = String(text).split('\n').map(l => {
1018
+ const segs = split(l, team);
1019
+ return { called: segs.some(([, id]) => id), text: clean(segs.map(([seg, id]) => (id ? '' : seg)).join('')) };
1020
+ }).filter(l => l.text !== '' && !STANCE_LINE.test(l.text));
1021
+ // The line that calls the teammate says what the task is; else the first line of content.
1022
+ const line = (lines.find(l => l.called) ?? lines[0])?.text ?? '';
1023
+ return line.slice(0, MAX_TEXT);
1024
+ }
1025
+
1026
+ /** When a workspace task's watch is due: its check time, or CHECK_AFTER_S after its last change. */
1027
+ function watchDue(task) {
1028
+ return task.next_check_at ?? task.updated_at + CHECK_AFTER_S;
1029
+ }
1030
+
423
1031
  function clip(note) {
424
1032
  const s = String(note ?? '').trim();
425
1033
  return s ? s.slice(0, MAX_TEXT) : null;