lark-remote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +162 -0
- package/README.md +161 -0
- package/dist/binder.d.ts +57 -0
- package/dist/binder.js +105 -0
- package/dist/bridge/index.d.ts +338 -0
- package/dist/bridge/index.js +1281 -0
- package/dist/bridge/queue-manager.d.ts +277 -0
- package/dist/bridge/queue-manager.js +729 -0
- package/dist/card/bash-card-session.d.ts +78 -0
- package/dist/card/bash-card-session.js +174 -0
- package/dist/card/bash-renderer.d.ts +24 -0
- package/dist/card/bash-renderer.js +187 -0
- package/dist/card/card-budget.d.ts +31 -0
- package/dist/card/card-budget.js +280 -0
- package/dist/card/card-session.d.ts +83 -0
- package/dist/card/card-session.js +185 -0
- package/dist/card/card-shared.d.ts +28 -0
- package/dist/card/card-shared.js +99 -0
- package/dist/card/collapsible.d.ts +26 -0
- package/dist/card/collapsible.js +87 -0
- package/dist/card/run-card-session.d.ts +78 -0
- package/dist/card/run-card-session.js +165 -0
- package/dist/card/run-renderer.d.ts +66 -0
- package/dist/card/run-renderer.js +841 -0
- package/dist/card/run-state.d.ts +103 -0
- package/dist/card/run-state.js +313 -0
- package/dist/card/text-truncate.d.ts +56 -0
- package/dist/card/text-truncate.js +184 -0
- package/dist/card/time.d.ts +1 -0
- package/dist/card/time.js +14 -0
- package/dist/card/tool-render.d.ts +25 -0
- package/dist/card/tool-render.js +176 -0
- package/dist/cli.d.ts +28 -0
- package/dist/cli.js +89 -0
- package/dist/common/guards.d.ts +37 -0
- package/dist/common/guards.js +65 -0
- package/dist/config/codex-config.d.ts +113 -0
- package/dist/config/codex-config.js +428 -0
- package/dist/config/dir.d.ts +17 -0
- package/dist/config/dir.js +81 -0
- package/dist/config/index.d.ts +219 -0
- package/dist/config/index.js +388 -0
- package/dist/config/kimi-config.d.ts +33 -0
- package/dist/config/kimi-config.js +111 -0
- package/dist/config/opencode-config.d.ts +20 -0
- package/dist/config/opencode-config.js +124 -0
- package/dist/config/pi-config.d.ts +49 -0
- package/dist/config/pi-config.js +151 -0
- package/dist/config/wizard.d.ts +12 -0
- package/dist/config/wizard.js +84 -0
- package/dist/connector/file-limits.d.ts +7 -0
- package/dist/connector/file-limits.js +8 -0
- package/dist/connector/index.d.ts +86 -0
- package/dist/connector/index.js +340 -0
- package/dist/error-classification.d.ts +15 -0
- package/dist/error-classification.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +580 -0
- package/dist/instance-lock.d.ts +30 -0
- package/dist/instance-lock.js +184 -0
- package/dist/logger/index.d.ts +57 -0
- package/dist/logger/index.js +178 -0
- package/dist/order/index.d.ts +27 -0
- package/dist/order/index.js +90 -0
- package/dist/persistence/atomic-write.d.ts +10 -0
- package/dist/persistence/atomic-write.js +65 -0
- package/dist/persistence/load-json-file.d.ts +9 -0
- package/dist/persistence/load-json-file.js +23 -0
- package/dist/restart.d.ts +24 -0
- package/dist/restart.js +73 -0
- package/dist/router/card-helpers.d.ts +28 -0
- package/dist/router/card-helpers.js +65 -0
- package/dist/router/config/claude.d.ts +14 -0
- package/dist/router/config/claude.js +72 -0
- package/dist/router/config/codex.d.ts +22 -0
- package/dist/router/config/codex.js +100 -0
- package/dist/router/config/common/render.d.ts +16 -0
- package/dist/router/config/common/render.js +158 -0
- package/dist/router/config/index.d.ts +18 -0
- package/dist/router/config/index.js +38 -0
- package/dist/router/config/kimi.d.ts +21 -0
- package/dist/router/config/kimi.js +63 -0
- package/dist/router/config/opencode.d.ts +12 -0
- package/dist/router/config/opencode.js +44 -0
- package/dist/router/config/pi.d.ts +12 -0
- package/dist/router/config/pi.js +50 -0
- package/dist/router/config/types.d.ts +43 -0
- package/dist/router/config/types.js +8 -0
- package/dist/router/index.d.ts +318 -0
- package/dist/router/index.js +2715 -0
- package/dist/router/order-exec-dispatch.d.ts +30 -0
- package/dist/router/order-exec-dispatch.js +48 -0
- package/dist/router/utils.d.ts +44 -0
- package/dist/router/utils.js +116 -0
- package/dist/runner/bash/index.d.ts +4 -0
- package/dist/runner/bash/index.js +5 -0
- package/dist/runner/bash/runner.d.ts +55 -0
- package/dist/runner/bash/runner.js +213 -0
- package/dist/runner/claude/index.d.ts +4 -0
- package/dist/runner/claude/index.js +5 -0
- package/dist/runner/claude/runner.d.ts +29 -0
- package/dist/runner/claude/runner.js +83 -0
- package/dist/runner/codex/argv.d.ts +33 -0
- package/dist/runner/codex/argv.js +40 -0
- package/dist/runner/codex/index.d.ts +1 -0
- package/dist/runner/codex/index.js +2 -0
- package/dist/runner/codex/jsonl.d.ts +37 -0
- package/dist/runner/codex/jsonl.js +221 -0
- package/dist/runner/codex/runner.d.ts +65 -0
- package/dist/runner/codex/runner.js +124 -0
- package/dist/runner/common/exec-translator.d.ts +73 -0
- package/dist/runner/common/exec-translator.js +105 -0
- package/dist/runner/common/jsonl-stream.d.ts +39 -0
- package/dist/runner/common/jsonl-stream.js +203 -0
- package/dist/runner/common/process-stopper.d.ts +28 -0
- package/dist/runner/common/process-stopper.js +79 -0
- package/dist/runner/common/runner-utils.d.ts +21 -0
- package/dist/runner/common/runner-utils.js +65 -0
- package/dist/runner/common/spawn-heartbeat.d.ts +33 -0
- package/dist/runner/common/spawn-heartbeat.js +51 -0
- package/dist/runner/common/spawning-runner.d.ts +321 -0
- package/dist/runner/common/spawning-runner.js +634 -0
- package/dist/runner/common/usage.d.ts +31 -0
- package/dist/runner/common/usage.js +29 -0
- package/dist/runner/index.d.ts +19 -0
- package/dist/runner/index.js +24 -0
- package/dist/runner/kimi/index.d.ts +1 -0
- package/dist/runner/kimi/index.js +2 -0
- package/dist/runner/kimi/jsonl.d.ts +28 -0
- package/dist/runner/kimi/jsonl.js +143 -0
- package/dist/runner/kimi/runner.d.ts +50 -0
- package/dist/runner/kimi/runner.js +105 -0
- package/dist/runner/opencode/argv.d.ts +32 -0
- package/dist/runner/opencode/argv.js +35 -0
- package/dist/runner/opencode/index.d.ts +1 -0
- package/dist/runner/opencode/index.js +2 -0
- package/dist/runner/opencode/jsonl.d.ts +68 -0
- package/dist/runner/opencode/jsonl.js +295 -0
- package/dist/runner/opencode/runner.d.ts +63 -0
- package/dist/runner/opencode/runner.js +93 -0
- package/dist/runner/pi/index.d.ts +1 -0
- package/dist/runner/pi/index.js +2 -0
- package/dist/runner/pi/jsonl.d.ts +128 -0
- package/dist/runner/pi/jsonl.js +249 -0
- package/dist/runner/pi/runner.d.ts +37 -0
- package/dist/runner/pi/runner.js +76 -0
- package/dist/runner/registry.d.ts +54 -0
- package/dist/runner/registry.js +75 -0
- package/dist/runner/resolve-agent-choices.d.ts +7 -0
- package/dist/runner/resolve-agent-choices.js +76 -0
- package/dist/runner/sync-agent-choices.d.ts +6 -0
- package/dist/runner/sync-agent-choices.js +73 -0
- package/dist/runner/types.d.ts +250 -0
- package/dist/runner/types.js +8 -0
- package/dist/session/claude/index.d.ts +1 -0
- package/dist/session/claude/index.js +2 -0
- package/dist/session/claude/session-reader.d.ts +34 -0
- package/dist/session/claude/session-reader.js +46 -0
- package/dist/session/claude/sessions.d.ts +53 -0
- package/dist/session/claude/sessions.js +528 -0
- package/dist/session/codex/index.d.ts +1 -0
- package/dist/session/codex/index.js +2 -0
- package/dist/session/codex/rollout-reader.d.ts +88 -0
- package/dist/session/codex/rollout-reader.js +473 -0
- package/dist/session/codex/sessions.d.ts +21 -0
- package/dist/session/codex/sessions.js +40 -0
- package/dist/session/common/catch-up.d.ts +12 -0
- package/dist/session/common/catch-up.js +31 -0
- package/dist/session/common/constants.d.ts +11 -0
- package/dist/session/common/constants.js +12 -0
- package/dist/session/common/content-blocks.d.ts +37 -0
- package/dist/session/common/content-blocks.js +68 -0
- package/dist/session/common/jsonl.d.ts +77 -0
- package/dist/session/common/jsonl.js +351 -0
- package/dist/session/common/usage-accumulator.d.ts +58 -0
- package/dist/session/common/usage-accumulator.js +72 -0
- package/dist/session/index.d.ts +7 -0
- package/dist/session/index.js +11 -0
- package/dist/session/kimi/index.d.ts +1 -0
- package/dist/session/kimi/index.js +2 -0
- package/dist/session/kimi/sessions.d.ts +81 -0
- package/dist/session/kimi/sessions.js +527 -0
- package/dist/session/opencode/index.d.ts +1 -0
- package/dist/session/opencode/index.js +2 -0
- package/dist/session/opencode/sessions.d.ts +81 -0
- package/dist/session/opencode/sessions.js +388 -0
- package/dist/session/pi/index.d.ts +1 -0
- package/dist/session/pi/index.js +2 -0
- package/dist/session/pi/sessions.d.ts +27 -0
- package/dist/session/pi/sessions.js +399 -0
- package/dist/session/registry.d.ts +12 -0
- package/dist/session/registry.js +20 -0
- package/dist/session/session-store.d.ts +86 -0
- package/dist/session/session-store.js +450 -0
- package/dist/startup-contact.d.ts +18 -0
- package/dist/startup-contact.js +51 -0
- package/dist/workspace/index.d.ts +19 -0
- package/dist/workspace/index.js +53 -0
- package/package.json +93 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { getLogger } from '../logger/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-workspace cap on the number of tasks WAITING to execute (review P2-5).
|
|
4
|
+
* Without a bound, a message flood grows `queuedTasks` / the promise chain /
|
|
5
|
+
* each task's captured `msg.content` closure without limit, and every enqueued
|
|
6
|
+
* task fires a queue card (amplifying outbound API calls). When the waiting
|
|
7
|
+
* queue is already at this depth, a new task is rejected with a visible card
|
|
8
|
+
* instead of being queued. The currently-executing task is NOT counted (it is
|
|
9
|
+
* tracked in `pendingOrExecutingCount`, not `queuedTasks`), so the cap is on
|
|
10
|
+
* backlog, not throughput. Owner is a single user, so 50 is ample headroom.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_WAITING_QUEUE = 50;
|
|
13
|
+
/**
|
|
14
|
+
* QueueManager handles the per-workspace serial processing queue.
|
|
15
|
+
* Manages task queuing, cancellation, and queue status card rendering.
|
|
16
|
+
*/
|
|
17
|
+
export class QueueManager {
|
|
18
|
+
/** Per-workspace (cwd) serial queues. Key = cwd, value = tail of promise chain. */
|
|
19
|
+
queues = new Map();
|
|
20
|
+
/** Per-workspace queued tasks for queue display cards. Key = cwd, value = task list. */
|
|
21
|
+
queuedTasks = new Map();
|
|
22
|
+
/**
|
|
23
|
+
* P3-5: per-workspace `messageId → QueuedTask` index mirroring `queuedTasks`.
|
|
24
|
+
* O(1) lookup for the task-start removal path (replacing the old double
|
|
25
|
+
* `find`+`findIndex` O(N) scan) and for `getQueuedTask`/`removeFromQueue`/
|
|
26
|
+
* `updateQueuedTaskMessage`. The ordered array remains the source of truth
|
|
27
|
+
* (position display depends on order); the index must be kept in sync on
|
|
28
|
+
* every mutation (push / splice / shift) via the `indexAdd`/`indexRemove`
|
|
29
|
+
* helpers. Same messageId is never enqueued twice in one workspace
|
|
30
|
+
* (Feishu dedup + internal-key dedup), so no overwrite concern.
|
|
31
|
+
*/
|
|
32
|
+
taskIndex = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* Map from user messageId to the queue card's Feishu send promise. Storing
|
|
35
|
+
* the promise (not the resolved id) closes the race where the card send is
|
|
36
|
+
* still in flight when the task begins: the begin path awaits the stored
|
|
37
|
+
* promise, so a late-arriving card id still gets reconciled to the
|
|
38
|
+
* executing/cancelled state. The promise never rejects (send failures
|
|
39
|
+
* resolve undefined), so awaiting it is always safe.
|
|
40
|
+
*/
|
|
41
|
+
queueCardMessages = new Map();
|
|
42
|
+
/** Track number of executing tasks per workspace (for queue card display). */
|
|
43
|
+
pendingOrExecutingCount = new Map();
|
|
44
|
+
/**
|
|
45
|
+
* Monotonic counter minting a unique slot id per enqueued task. A slot
|
|
46
|
+
* identifies one task's execution period for the interrupt bookkeeping in
|
|
47
|
+
* `executingSlot`/`interruptedSlots` — replacing the unowned skip-credit
|
|
48
|
+
* counter, which any settle could consume and which therefore leaked a
|
|
49
|
+
* re-armed count after repeated resets (see A4 anchor).
|
|
50
|
+
*/
|
|
51
|
+
slotCounter = 0;
|
|
52
|
+
/**
|
|
53
|
+
* Per-workspace slot id of the task currently executing. Set in the begin
|
|
54
|
+
* path right before the task runs; deleted when that task's settle fires.
|
|
55
|
+
* `resetExecutingCount` reads it to bind the interrupt credit to exactly
|
|
56
|
+
* the interrupted task's slot.
|
|
57
|
+
*/
|
|
58
|
+
executingSlot = new Map();
|
|
59
|
+
/**
|
|
60
|
+
* Per-workspace set of slot ids whose task was interrupted by
|
|
61
|
+
* `resetExecutingCount`. When such a slot settles, its decrement is skipped
|
|
62
|
+
* (the reset already zeroed the count). A slot is granted at most once —
|
|
63
|
+
* repeated resets of the same executing task do not mint extra credits —
|
|
64
|
+
* which prevents a normal task's settle from consuming a credit instead of
|
|
65
|
+
* decrementing, leaking the re-armed count to 1.
|
|
66
|
+
*/
|
|
67
|
+
interruptedSlots = new Map();
|
|
68
|
+
/** Per-workspace messageId → replacement closure, set by queue.edit + immediate. */
|
|
69
|
+
taskReplacements = new Map();
|
|
70
|
+
/**
|
|
71
|
+
* MessageIds of tasks that have actually begun executing (cancellation check
|
|
72
|
+
* passed, task removed from the queue). Entries are sticky: they remain
|
|
73
|
+
* after the task settles (A21). `handleQueueImmediate` step 6 uses this to
|
|
74
|
+
* distinguish "target was cancelled (never began)" from "target began while
|
|
75
|
+
* the interrupt was in flight" so the final feedback can report the true
|
|
76
|
+
* state instead of telling a running task's user that nothing was
|
|
77
|
+
* scheduled. A task that began and then settled quickly must still report
|
|
78
|
+
* "已开始执行", which requires the marker to survive settle. Message ids are
|
|
79
|
+
* globally unique and never reused, so a sticky marker cannot misclassify a
|
|
80
|
+
* later task; the set is bounded-pruned on insert to prevent unbounded
|
|
81
|
+
* growth. Removed/cancelled tasks never enter the set (cancellation happens
|
|
82
|
+
* before begin); `removeFromQueue` therefore needs no cleanup here.
|
|
83
|
+
*/
|
|
84
|
+
beganMessageIds = new Set();
|
|
85
|
+
/** Callback to check if workspace has an active run */
|
|
86
|
+
isWorkspaceRunning;
|
|
87
|
+
/** Callback to send card updates */
|
|
88
|
+
sendCard;
|
|
89
|
+
/** Callback to update existing card */
|
|
90
|
+
updateCard;
|
|
91
|
+
constructor(isWorkspaceRunning, sendCard, updateCard) {
|
|
92
|
+
this.isWorkspaceRunning = isWorkspaceRunning;
|
|
93
|
+
this.sendCard = sendCard;
|
|
94
|
+
this.updateCard = updateCard;
|
|
95
|
+
}
|
|
96
|
+
/** P3-5: register a queued task in the per-workspace `messageId → task` index. */
|
|
97
|
+
indexAdd(workspace, task) {
|
|
98
|
+
let idx = this.taskIndex.get(workspace);
|
|
99
|
+
if (!idx) {
|
|
100
|
+
idx = new Map();
|
|
101
|
+
this.taskIndex.set(workspace, idx);
|
|
102
|
+
}
|
|
103
|
+
idx.set(task.messageId, task);
|
|
104
|
+
}
|
|
105
|
+
/** P3-5: drop a task from the per-workspace index by messageId. */
|
|
106
|
+
indexRemove(workspace, messageId) {
|
|
107
|
+
this.taskIndex.get(workspace)?.delete(messageId);
|
|
108
|
+
}
|
|
109
|
+
/** P3-5: O(1) lookup of a queued task by messageId via the index. */
|
|
110
|
+
indexGet(workspace, messageId) {
|
|
111
|
+
return this.taskIndex.get(workspace)?.get(messageId);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Enqueue a task into the workspace-level serial queue.
|
|
115
|
+
* Each workspace has its own serial queue for parallel execution across workspaces.
|
|
116
|
+
*/
|
|
117
|
+
enqueue(workspace, task, opts) {
|
|
118
|
+
// Guard: reject non-function tasks that would poison the queue chain
|
|
119
|
+
if (typeof task !== 'function') {
|
|
120
|
+
getLogger().warn('[queue-manager] enqueue ignored task is not a function, workspace=', workspace);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Mint a unique slot id for this task's execution period (regardless of
|
|
124
|
+
// taskMeta). The slot binds begin/settle bookkeeping to this task, so an
|
|
125
|
+
// interrupt credit granted by `resetExecutingCount` can only be consumed
|
|
126
|
+
// by the interrupted task's own settle.
|
|
127
|
+
const slotId = ++this.slotCounter;
|
|
128
|
+
// Track this task in the queue for display card
|
|
129
|
+
const taskMeta = opts?.taskMeta;
|
|
130
|
+
let messagePreview;
|
|
131
|
+
if (taskMeta) {
|
|
132
|
+
messagePreview = taskMeta.messagePreview;
|
|
133
|
+
const queuedTask = {
|
|
134
|
+
userId: taskMeta.userId,
|
|
135
|
+
chatId: taskMeta.chatId,
|
|
136
|
+
messageId: taskMeta.messageId,
|
|
137
|
+
feishuReplyTo: taskMeta.feishuReplyTo,
|
|
138
|
+
workspace,
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
messagePreview,
|
|
141
|
+
binding: taskMeta.binding,
|
|
142
|
+
};
|
|
143
|
+
// Check if there are tasks waiting BEFORE adding this one
|
|
144
|
+
const currentExecutingCount = this.pendingOrExecutingCount.get(workspace) ?? 0;
|
|
145
|
+
const currentQueueLength = this.queuedTasks.get(workspace)?.length ?? 0;
|
|
146
|
+
const hasWaitingTasks = currentExecutingCount > 0 || currentQueueLength > 0;
|
|
147
|
+
// P2-5: bound the waiting backlog. When the queue is full, reject the
|
|
148
|
+
// task with a visible card instead of appending it (which would grow
|
|
149
|
+
// the promise chain + closures unboundedly under a message flood and
|
|
150
|
+
// fire another queue card). Do NOT increment pendingOrExecutingCount —
|
|
151
|
+
// a rejected task never executes, so it must not occupy a slot.
|
|
152
|
+
if (currentQueueLength >= MAX_WAITING_QUEUE) {
|
|
153
|
+
getLogger().warn(`[queue-manager] queue full workspace=${workspace} depth=${currentQueueLength} rejecting task messageId=${taskMeta.messageId}`);
|
|
154
|
+
void this.sendCard(taskMeta.chatId, {
|
|
155
|
+
schema: '2.0',
|
|
156
|
+
config: { wide_screen_mode: true },
|
|
157
|
+
header: {
|
|
158
|
+
template: 'red',
|
|
159
|
+
title: { tag: 'plain_text', content: '🚫 队列已满' },
|
|
160
|
+
},
|
|
161
|
+
body: {
|
|
162
|
+
elements: [
|
|
163
|
+
{
|
|
164
|
+
tag: 'div',
|
|
165
|
+
text: {
|
|
166
|
+
tag: 'lark_md',
|
|
167
|
+
content: `当前 workspace 已有 ${MAX_WAITING_QUEUE} 条消息排队等待,为防止积压已拒收本条消息。请等待队列消化后重发,或用 \`/stop\` 清空当前任务。`,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
}, { replyTo: taskMeta.feishuReplyTo ?? taskMeta.messageId }).catch((err) => {
|
|
173
|
+
getLogger().error('[queue-manager] failed to send queue-full rejection card:', err);
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Add to workspace queue list
|
|
178
|
+
let taskList = this.queuedTasks.get(workspace);
|
|
179
|
+
if (!taskList) {
|
|
180
|
+
taskList = [];
|
|
181
|
+
this.queuedTasks.set(workspace, taskList);
|
|
182
|
+
}
|
|
183
|
+
taskList.push(queuedTask);
|
|
184
|
+
this.indexAdd(workspace, queuedTask);
|
|
185
|
+
// Increment executing count SYNCHRONOUSLY so subsequent enqueues see it
|
|
186
|
+
this.pendingOrExecutingCount.set(workspace, currentExecutingCount + 1);
|
|
187
|
+
// Only send the queue card if the task actually has to wait.
|
|
188
|
+
if (hasWaitingTasks) {
|
|
189
|
+
void this.sendQueueStatusCard(workspace, taskMeta.chatId, taskMeta.messageId, messagePreview,
|
|
190
|
+
// Reply to the real Feishu message id when the queue key is an
|
|
191
|
+
// internal key (order.exec); otherwise messageId IS the Feishu id.
|
|
192
|
+
taskMeta.feishuReplyTo);
|
|
193
|
+
}
|
|
194
|
+
getLogger().debug(`[queue-manager] enqueue task queued workspace=${workspace} queueCard=${hasWaitingTasks} executing=${currentExecutingCount + 1} queueLen=${currentQueueLength + 1}`);
|
|
195
|
+
}
|
|
196
|
+
// Get or create the queue for this workspace
|
|
197
|
+
let queue = this.queues.get(workspace);
|
|
198
|
+
if (!queue) {
|
|
199
|
+
queue = Promise.resolve();
|
|
200
|
+
this.queues.set(workspace, queue);
|
|
201
|
+
}
|
|
202
|
+
getLogger().debug(`[queue-manager] enqueue workspace=${workspace}`);
|
|
203
|
+
// Capture messageId for cancellation guard
|
|
204
|
+
const messageId = taskMeta?.messageId;
|
|
205
|
+
// messagePreview already captured above (outside taskMeta block)
|
|
206
|
+
const newQueue = queue
|
|
207
|
+
.then(() => {
|
|
208
|
+
getLogger().debug(`[queue-manager] task begin workspace=${workspace} messageId=${messageId}`);
|
|
209
|
+
// Live preview for the executing card: read from the QueuedTask at
|
|
210
|
+
// begin time, not the enqueue-closure `taskMeta` (which is frozen).
|
|
211
|
+
// `updateQueuedTaskMessage` (queue.edit/queue.input) mutates the live
|
|
212
|
+
// task, so a task edited while queued must show the edited content
|
|
213
|
+
// when it naturally starts.
|
|
214
|
+
let livePreview = taskMeta?.messagePreview ?? '';
|
|
215
|
+
// Check if this task was cancelled before executing
|
|
216
|
+
if (messageId) {
|
|
217
|
+
// P3-5: O(1) index lookup replaces the old `find` + `findIndex`
|
|
218
|
+
// double O(N) scan. The ordered array still drives removal (splice
|
|
219
|
+
// preserves queue order for position display); the index stays in
|
|
220
|
+
// sync via indexRemove.
|
|
221
|
+
const task = this.indexGet(workspace, messageId);
|
|
222
|
+
if (!task) {
|
|
223
|
+
getLogger().debug(`[queue-manager] task skipped (cancelled) workspace=${workspace} messageId=${messageId}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
livePreview = task.messagePreview;
|
|
227
|
+
// Remove this task's metadata by messageId
|
|
228
|
+
const taskList = this.queuedTasks.get(workspace);
|
|
229
|
+
const idx = taskList?.findIndex((t) => t.messageId === messageId) ?? -1;
|
|
230
|
+
if (taskList && idx >= 0) {
|
|
231
|
+
taskList.splice(idx, 1);
|
|
232
|
+
}
|
|
233
|
+
this.indexRemove(workspace, messageId);
|
|
234
|
+
// Cancellation check passed and the task has been removed: it is
|
|
235
|
+
// about to run, so record it as began for queue.immediate feedback.
|
|
236
|
+
// Bounded: ids are only consulted for the current immediate target,
|
|
237
|
+
// so pruning the oldest entries is safe.
|
|
238
|
+
if (this.beganMessageIds.size >= 10_000) {
|
|
239
|
+
const oldest = this.beganMessageIds.values().next().value;
|
|
240
|
+
if (oldest !== undefined)
|
|
241
|
+
this.beganMessageIds.delete(oldest);
|
|
242
|
+
}
|
|
243
|
+
this.beganMessageIds.add(messageId);
|
|
244
|
+
}
|
|
245
|
+
// Consume any one-shot replacement closure registered by
|
|
246
|
+
// `setTaskReplacement` (queue.edit + queue.immediate). The replacement
|
|
247
|
+
// swaps in the edited content at this task's original queue slot, so
|
|
248
|
+
// tasks queued behind it still run after it. Deleted here (one-shot)
|
|
249
|
+
// so a stale closure can never run on a later task.
|
|
250
|
+
let replacement;
|
|
251
|
+
if (messageId) {
|
|
252
|
+
const workspaceReplacements = this.taskReplacements.get(workspace);
|
|
253
|
+
replacement = workspaceReplacements?.get(messageId);
|
|
254
|
+
if (replacement) {
|
|
255
|
+
workspaceReplacements?.delete(messageId);
|
|
256
|
+
if (workspaceReplacements && workspaceReplacements.size === 0) {
|
|
257
|
+
this.taskReplacements.delete(workspace);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Update queue card to "executing" status BEFORE running the task.
|
|
262
|
+
// This must happen after the cancellation check: a skipped/cancelled
|
|
263
|
+
// task must keep its "❌ 已撤销" card, not be flipped to executing.
|
|
264
|
+
if (messageId) {
|
|
265
|
+
void this.updateQueueCardToExecuting(workspace, messageId, livePreview, true);
|
|
266
|
+
}
|
|
267
|
+
// Re-arm the pending/executing count for every task that is about to
|
|
268
|
+
// run. `resetExecutingCount` (external interrupt) zeroes the count,
|
|
269
|
+
// and the interrupted task's settle consumes the skip-credit — so a
|
|
270
|
+
// task enqueued BEFORE the interrupt can begin with count 0. Without
|
|
271
|
+
// this, a running task is invisible to later enqueues and they
|
|
272
|
+
// silently skip the "⏳ 消息排队中" card. This applies regardless of
|
|
273
|
+
// taskMeta: resetExecutingCount can clear the count for any workspace,
|
|
274
|
+
// and a task that resumes after an interrupt must re-arm even when it
|
|
275
|
+
// carries no metadata.
|
|
276
|
+
const currentCount = this.pendingOrExecutingCount.get(workspace) ?? 0;
|
|
277
|
+
if (currentCount < 1) {
|
|
278
|
+
this.pendingOrExecutingCount.set(workspace, 1);
|
|
279
|
+
getLogger().debug(`[queue-manager] re-armed pendingOrExecutingCount workspace=${workspace} (interrupt resume)`);
|
|
280
|
+
}
|
|
281
|
+
// Mark this task's slot as the current execution period before running
|
|
282
|
+
// it. `resetExecutingCount` reads this marker to grant the interrupt
|
|
283
|
+
// credit to exactly this task; the settle removes the marker.
|
|
284
|
+
this.executingSlot.set(workspace, slotId);
|
|
285
|
+
return replacement ? replacement() : task();
|
|
286
|
+
})
|
|
287
|
+
.then(() => {
|
|
288
|
+
getLogger().debug(`[queue-manager] task end workspace=${workspace}`);
|
|
289
|
+
// beganMessageIds entry intentionally retained: sticky by design
|
|
290
|
+
// (A21) — the marker records "has ever begun", not "is currently
|
|
291
|
+
// running", so a task that began and settled quickly still reports
|
|
292
|
+
// "已开始执行" to queue.immediate.
|
|
293
|
+
this.decrementExecutingCount(workspace, slotId);
|
|
294
|
+
})
|
|
295
|
+
.catch((err) => {
|
|
296
|
+
getLogger().error('[queue-manager] queue task error:', err);
|
|
297
|
+
// Same sticky rationale as the success settle: the marker survives
|
|
298
|
+
// even when the task errors after beginning (A21).
|
|
299
|
+
this.decrementExecutingCount(workspace, slotId);
|
|
300
|
+
});
|
|
301
|
+
this.queues.set(workspace, newQueue);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Execute a task immediately without going through the queue.
|
|
305
|
+
* Used for / commands that should respond immediately.
|
|
306
|
+
*/
|
|
307
|
+
enqueueImmediate(workspace, task) {
|
|
308
|
+
getLogger().debug(`[queue-manager] enqueueImmediate workspace=${workspace}`);
|
|
309
|
+
void task().catch((err) => getLogger().error('[queue-manager] immediate task error:', err));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* One-shot: replace the execution closure of an existing queued task in
|
|
313
|
+
* place, preserving its queue position (tasks queued behind it still run
|
|
314
|
+
* after it). Consumed when the task's slot begins; removed if cancelled.
|
|
315
|
+
*/
|
|
316
|
+
setTaskReplacement(workspace, messageId, task) {
|
|
317
|
+
let workspaceReplacements = this.taskReplacements.get(workspace);
|
|
318
|
+
if (!workspaceReplacements) {
|
|
319
|
+
workspaceReplacements = new Map();
|
|
320
|
+
this.taskReplacements.set(workspace, workspaceReplacements);
|
|
321
|
+
}
|
|
322
|
+
workspaceReplacements.set(messageId, task);
|
|
323
|
+
getLogger().debug(`[queue-manager] set task replacement workspace=${workspace} messageId=${messageId}`);
|
|
324
|
+
}
|
|
325
|
+
/** Send a queue status card showing current queue position and actions. */
|
|
326
|
+
async sendQueueStatusCard(workspace, chatId, replyToMessageId, messagePreview,
|
|
327
|
+
/** Feishu reply target when `replyToMessageId` (the queue key) is an
|
|
328
|
+
* internal key, not a valid Feishu message id. Defaults to the queue key
|
|
329
|
+
* for hand-typed messages. */
|
|
330
|
+
feishuReplyTo) {
|
|
331
|
+
const taskList = this.queuedTasks.get(workspace) ?? [];
|
|
332
|
+
const positionInQueue = taskList.findIndex((t) => t.chatId === chatId && t.messageId === replyToMessageId);
|
|
333
|
+
const actualPosition = positionInQueue + 1;
|
|
334
|
+
const tasksAhead = actualPosition - 1;
|
|
335
|
+
const card = {
|
|
336
|
+
schema: '2.0',
|
|
337
|
+
config: { wide_screen_mode: true },
|
|
338
|
+
header: {
|
|
339
|
+
template: 'orange',
|
|
340
|
+
title: { tag: 'plain_text', content: '⏳ 消息排队中' },
|
|
341
|
+
},
|
|
342
|
+
body: {
|
|
343
|
+
elements: this.buildQueueStatusCardElements(workspace, actualPosition, tasksAhead, replyToMessageId, messagePreview),
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
// The Feishu reply target must be a real Feishu message id. The queue
|
|
347
|
+
// dedup key (`replyToMessageId`) is an internal key for card-action
|
|
348
|
+
// dispatches (order.exec), so reply to `feishuReplyTo` when provided.
|
|
349
|
+
const feishuReplyTarget = feishuReplyTo ?? replyToMessageId;
|
|
350
|
+
// Create the send promise and register it in the mapping BEFORE awaiting:
|
|
351
|
+
// the begin path reads this mapping synchronously, so a late-resolving
|
|
352
|
+
// send must still be reconcilable once its card id arrives.
|
|
353
|
+
const sendPromise = this.sendCard(chatId, card, { replyTo: feishuReplyTarget }).catch((err) => {
|
|
354
|
+
getLogger().error('[queue-manager] failed to send queue status card:', err);
|
|
355
|
+
return undefined;
|
|
356
|
+
});
|
|
357
|
+
this.queueCardMessages.set(replyToMessageId, sendPromise);
|
|
358
|
+
const messageId = await sendPromise;
|
|
359
|
+
if (messageId === undefined) {
|
|
360
|
+
// Send failed — the stored promise resolved undefined and no card
|
|
361
|
+
// exists; drop the mapping so it does not accumulate across retried
|
|
362
|
+
// failures.
|
|
363
|
+
this.queueCardMessages.delete(replyToMessageId);
|
|
364
|
+
}
|
|
365
|
+
return messageId;
|
|
366
|
+
}
|
|
367
|
+
/** Get queue info for a workspace. */
|
|
368
|
+
getQueueInfo(workspace) {
|
|
369
|
+
const taskList = this.queuedTasks.get(workspace) ?? [];
|
|
370
|
+
return {
|
|
371
|
+
position: taskList.length,
|
|
372
|
+
tasksAhead: Math.max(0, taskList.length - 1),
|
|
373
|
+
isRunning: this.isWorkspaceRunning(workspace),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/** Build queue status card elements. */
|
|
377
|
+
buildQueueStatusCardElements(workspace, actualPosition, tasksAhead, messageId, messagePreview) {
|
|
378
|
+
const workspaceName = workspace.split('/').pop() ?? workspace;
|
|
379
|
+
const isRunning = this.isWorkspaceRunning(workspace);
|
|
380
|
+
const elements = [
|
|
381
|
+
{
|
|
382
|
+
tag: 'div',
|
|
383
|
+
text: {
|
|
384
|
+
tag: 'lark_md',
|
|
385
|
+
content: `**当前 Workspace:** \`${workspaceName}\`\n**位置:** 第 ${actualPosition} 位\n**前面还有:** ${tasksAhead} 条消息在排队`,
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
{ tag: 'hr' },
|
|
389
|
+
{
|
|
390
|
+
tag: 'div',
|
|
391
|
+
text: {
|
|
392
|
+
tag: 'lark_md',
|
|
393
|
+
content: `💡 消息将按顺序执行…当前正在处理: ${isRunning ? '有任务运行中' : '空闲'}`,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
{ tag: 'hr' },
|
|
397
|
+
];
|
|
398
|
+
// Show message preview with edit button (only if not executing)
|
|
399
|
+
if (messagePreview) {
|
|
400
|
+
elements.push({
|
|
401
|
+
tag: 'div',
|
|
402
|
+
text: { tag: 'lark_md', content: `📝 \`${messagePreview}\`` },
|
|
403
|
+
});
|
|
404
|
+
// Edit button: a pending (queued) task is always editable. The
|
|
405
|
+
// executing/cancelled states render via dedicated card builders that
|
|
406
|
+
// hard-disable all buttons, so this builder only serves pending cards.
|
|
407
|
+
elements.push({
|
|
408
|
+
tag: 'button',
|
|
409
|
+
text: { tag: 'plain_text', content: '✏️ 编辑' },
|
|
410
|
+
type: 'default',
|
|
411
|
+
size: 'small',
|
|
412
|
+
behaviors: [{ type: 'callback', value: { cmd: 'queue.edit', workspace, messageId } }],
|
|
413
|
+
});
|
|
414
|
+
elements.push({ tag: 'hr' });
|
|
415
|
+
}
|
|
416
|
+
// Action buttons
|
|
417
|
+
elements.push(...this.buildQueueActionButtons(workspace, messageId, false));
|
|
418
|
+
return elements;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Build the 撤销/立即执行 action button pair for a queue card.
|
|
422
|
+
*
|
|
423
|
+
* Both buttons route to `queue.cancel` / `queue.immediate` callbacks with
|
|
424
|
+
* the same workspace/messageId; only the `disabled` flag varies by card
|
|
425
|
+
* state — pending cards enable both, executing/cancelled cards disable
|
|
426
|
+
* both. Centralizing the pair here eliminates the 3-way duplication
|
|
427
|
+
* between `buildQueueStatusCardElements`, `updateQueueCardToExecuting`,
|
|
428
|
+
* and `updateQueueCardToCancelled` (Clean Code P2-1).
|
|
429
|
+
*/
|
|
430
|
+
buildQueueActionButtons(workspace, messageId, disabled) {
|
|
431
|
+
return [
|
|
432
|
+
{
|
|
433
|
+
tag: 'button',
|
|
434
|
+
text: { tag: 'plain_text', content: '❌ 撤销' },
|
|
435
|
+
type: 'danger',
|
|
436
|
+
disabled,
|
|
437
|
+
behaviors: [{ type: 'callback', value: { cmd: 'queue.cancel', workspace, messageId } }],
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
tag: 'button',
|
|
441
|
+
text: { tag: 'plain_text', content: '⚡ 立即执行' },
|
|
442
|
+
type: 'primary',
|
|
443
|
+
disabled,
|
|
444
|
+
behaviors: [{ type: 'callback', value: { cmd: 'queue.immediate', workspace, messageId } }],
|
|
445
|
+
},
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Update queue card to "started executing" status when task begins.
|
|
450
|
+
*
|
|
451
|
+
* `started` tells the method whether the caller has already confirmed the
|
|
452
|
+
* task really began (begin path: cancellation check passed and the task has
|
|
453
|
+
* been removed from the queue). When `started` is false (queue.immediate
|
|
454
|
+
* mark path), a membership guard re-checks the task right before the card
|
|
455
|
+
* update: if the task was removed/cancelled while the send was in flight,
|
|
456
|
+
* the card must keep its "❌ 已撤销" state instead of being flipped back to
|
|
457
|
+
* executing. The mapping is deleted in finally in both cases.
|
|
458
|
+
*/
|
|
459
|
+
async updateQueueCardToExecuting(workspace, messageId, messagePreview, started = false) {
|
|
460
|
+
const cardMessageId = await this.queueCardMessages.get(messageId);
|
|
461
|
+
if (!cardMessageId) {
|
|
462
|
+
getLogger().debug(`[queue-manager] no queue card to update, messageId=${messageId}`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const workspaceName = workspace.split('/').pop() ?? workspace;
|
|
466
|
+
try {
|
|
467
|
+
// Prefer the live preview: an edited task's messagePreview is updated in
|
|
468
|
+
// place, and the passed-in snapshot may be stale if the card send was in
|
|
469
|
+
// flight while the user edited (A19). The begin path (started=true) already
|
|
470
|
+
// passes the live preview captured at begin (A12), so the fallback is only
|
|
471
|
+
// exercised when the task is no longer queued.
|
|
472
|
+
const liveTask = this.indexGet(workspace, messageId);
|
|
473
|
+
const stillQueued = liveTask !== undefined;
|
|
474
|
+
if (!stillQueued && !started) {
|
|
475
|
+
getLogger().debug(`[queue-manager] queue card update to executing skipped (task no longer queued and not started) messageId=${messageId}`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const previewForCard = liveTask?.messagePreview ?? messagePreview;
|
|
479
|
+
const card = {
|
|
480
|
+
schema: '2.0',
|
|
481
|
+
config: { wide_screen_mode: true },
|
|
482
|
+
header: {
|
|
483
|
+
template: 'green',
|
|
484
|
+
title: { tag: 'plain_text', content: '▶️ 已开始执行' },
|
|
485
|
+
},
|
|
486
|
+
body: {
|
|
487
|
+
elements: [
|
|
488
|
+
{
|
|
489
|
+
tag: 'div',
|
|
490
|
+
text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
|
|
491
|
+
},
|
|
492
|
+
{ tag: 'hr' },
|
|
493
|
+
{ tag: 'div', text: { tag: 'lark_md', content: `📝 \`${previewForCard}\`` } },
|
|
494
|
+
{ tag: 'hr' },
|
|
495
|
+
...this.buildQueueActionButtons(workspace, messageId, true),
|
|
496
|
+
],
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
await this.updateCard(cardMessageId, card);
|
|
500
|
+
getLogger().debug(`[queue-manager] queue card updated to executing messageId=${messageId}`);
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
getLogger().warn('[queue-manager] failed to update queue card to executing:', err);
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
this.queueCardMessages.delete(messageId);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** Remove a task from the queue by messageId. Returns true if found and removed. */
|
|
510
|
+
removeFromQueue(workspace, messageId) {
|
|
511
|
+
// Drop any one-shot replacement for this task: a cancelled/removed task
|
|
512
|
+
// must never leave a closure behind that could execute later.
|
|
513
|
+
const workspaceReplacements = this.taskReplacements.get(workspace);
|
|
514
|
+
if (workspaceReplacements?.delete(messageId) && workspaceReplacements.size === 0) {
|
|
515
|
+
this.taskReplacements.delete(workspace);
|
|
516
|
+
}
|
|
517
|
+
const taskList = this.queuedTasks.get(workspace);
|
|
518
|
+
if (!taskList)
|
|
519
|
+
return false;
|
|
520
|
+
// P3-5: index is the O(1) presence check; array splice keeps order.
|
|
521
|
+
if (!this.indexGet(workspace, messageId))
|
|
522
|
+
return false;
|
|
523
|
+
const index = taskList.findIndex((t) => t.messageId === messageId);
|
|
524
|
+
if (index >= 0) {
|
|
525
|
+
taskList.splice(index, 1);
|
|
526
|
+
}
|
|
527
|
+
this.indexRemove(workspace, messageId);
|
|
528
|
+
// Note: queueCardMessages mapping is NOT deleted here.
|
|
529
|
+
// The caller (handleQueueCancel) will call updateQueueCardToCancelled
|
|
530
|
+
// which will clean up the mapping after updating the card.
|
|
531
|
+
getLogger().info(`[queue-manager] removed from queue workspace=${workspace} messageId=${messageId}`);
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
/** Update queue card to "cancelled" status when user clicks 撤销. */
|
|
535
|
+
async updateQueueCardToCancelled(workspace, messageId) {
|
|
536
|
+
const cardMessageId = await this.queueCardMessages.get(messageId);
|
|
537
|
+
if (!cardMessageId) {
|
|
538
|
+
getLogger().debug(`[queue-manager] no queue card to update (cancelled), messageId=${messageId}`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const workspaceName = workspace.split('/').pop() ?? workspace;
|
|
542
|
+
const card = {
|
|
543
|
+
schema: '2.0',
|
|
544
|
+
config: { wide_screen_mode: true },
|
|
545
|
+
header: {
|
|
546
|
+
template: 'gray',
|
|
547
|
+
title: { tag: 'plain_text', content: '❌ 已撤销' },
|
|
548
|
+
},
|
|
549
|
+
body: {
|
|
550
|
+
elements: [
|
|
551
|
+
{
|
|
552
|
+
tag: 'div',
|
|
553
|
+
text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
|
|
554
|
+
},
|
|
555
|
+
{ tag: 'hr' },
|
|
556
|
+
{ tag: 'div', text: { tag: 'lark_md', content: `📝 该消息已从队列中撤销` } },
|
|
557
|
+
{ tag: 'hr' },
|
|
558
|
+
...this.buildQueueActionButtons(workspace, messageId, true),
|
|
559
|
+
],
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
try {
|
|
563
|
+
await this.updateCard(cardMessageId, card);
|
|
564
|
+
getLogger().debug(`[queue-manager] queue card updated to cancelled, messageId=${messageId}`);
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
getLogger().warn('[queue-manager] failed to update queue card to cancelled:', err);
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
this.queueCardMessages.delete(messageId);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/** Get task metadata from queue. */
|
|
574
|
+
getQueuedTask(workspace, messageId) {
|
|
575
|
+
// P3-5: O(1) index lookup instead of array `find`.
|
|
576
|
+
return this.indexGet(workspace, messageId);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Whether a task with the given messageId has begun executing (cancellation
|
|
580
|
+
* check passed, task removed from the queue). Sticky: true even after the
|
|
581
|
+
* task settles, so queue.immediate can distinguish "began (possibly already
|
|
582
|
+
* completed)" from "never began / cancelled".
|
|
583
|
+
*/
|
|
584
|
+
hasBegan(messageId) {
|
|
585
|
+
return this.beganMessageIds.has(messageId);
|
|
586
|
+
}
|
|
587
|
+
/** Get all queued tasks for a workspace. Returns a copy to prevent aliasing bugs. */
|
|
588
|
+
getQueuedTasks(workspace) {
|
|
589
|
+
return [...(this.queuedTasks.get(workspace) ?? [])];
|
|
590
|
+
}
|
|
591
|
+
/** Update the messagePreview for a queued task. Returns true if found and updated. */
|
|
592
|
+
updateQueuedTaskMessage(workspace, messageId, newMessage) {
|
|
593
|
+
// P3-5: O(1) index lookup instead of array `find`.
|
|
594
|
+
const task = this.indexGet(workspace, messageId);
|
|
595
|
+
if (!task)
|
|
596
|
+
return false;
|
|
597
|
+
task.messagePreview = newMessage;
|
|
598
|
+
task.editedMessage = newMessage;
|
|
599
|
+
getLogger().info(`[queue-manager] updated messagePreview workspace=${workspace} messageId=${messageId}`);
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Build the orange queue card reflecting the edited message content. Returns
|
|
604
|
+
* the card object (sent as the cardAction callback response `card.data`,
|
|
605
|
+
* which Feishu renders in place) or null if the task is no longer queued.
|
|
606
|
+
*
|
|
607
|
+
* Why a callback-response card instead of a PATCH updateCard API call:
|
|
608
|
+
* when handleQueueInput returns a { toast } callback response, Feishu uses
|
|
609
|
+
* that response to render the clicked card; a response without a `card`
|
|
610
|
+
* field leaves the card in its pre-click (edit) state, overriding the
|
|
611
|
+
* concurrent PATCH updateCard result. Returning { card } in the callback
|
|
612
|
+
* response updates the card synchronously with no API race.
|
|
613
|
+
*/
|
|
614
|
+
buildQueueCardForEdit(workspace, messageId, newMessagePreview) {
|
|
615
|
+
const taskList = this.queuedTasks.get(workspace) ?? [];
|
|
616
|
+
const positionInQueue = taskList.findIndex((t) => t.messageId === messageId);
|
|
617
|
+
if (positionInQueue < 0) {
|
|
618
|
+
getLogger().info(`[queue-manager] task not in queue (build edit card), messageId=${messageId}`);
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
const actualPosition = positionInQueue + 1;
|
|
622
|
+
const tasksAhead = positionInQueue;
|
|
623
|
+
return {
|
|
624
|
+
schema: '2.0',
|
|
625
|
+
config: { wide_screen_mode: true },
|
|
626
|
+
header: {
|
|
627
|
+
template: 'orange',
|
|
628
|
+
title: { tag: 'plain_text', content: '⏳ 消息排队中' },
|
|
629
|
+
},
|
|
630
|
+
body: {
|
|
631
|
+
elements: this.buildQueueStatusCardElements(workspace, actualPosition, tasksAhead, messageId, newMessagePreview),
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Decrement `pendingOrExecutingCount` for a workspace, honoring per-slot
|
|
637
|
+
* interrupt bookkeeping.
|
|
638
|
+
*
|
|
639
|
+
* Called from the queue chain's `.then()`/`.catch()` settle with the slot
|
|
640
|
+
* id minted at enqueue time. Three cases:
|
|
641
|
+
*
|
|
642
|
+
* 1. The slot is the workspace's current executing slot → its execution
|
|
643
|
+
* period ends (`executingSlot` marker removed).
|
|
644
|
+
* 2. The slot is in `interruptedSlots` (its task was interrupted by
|
|
645
|
+
* `resetExecutingCount`) → skip the decrement: the reset already zeroed
|
|
646
|
+
* the count, so a stale decrement could wrongly zero it again while a
|
|
647
|
+
* newer task (enqueued after the reset) is still running.
|
|
648
|
+
* 3. Otherwise → normal decrement.
|
|
649
|
+
*/
|
|
650
|
+
decrementExecutingCount(workspace, slotId) {
|
|
651
|
+
if (this.executingSlot.get(workspace) === slotId) {
|
|
652
|
+
this.executingSlot.delete(workspace);
|
|
653
|
+
}
|
|
654
|
+
const interrupted = this.interruptedSlots.get(workspace);
|
|
655
|
+
if (interrupted?.has(slotId)) {
|
|
656
|
+
interrupted.delete(slotId);
|
|
657
|
+
if (interrupted.size === 0) {
|
|
658
|
+
this.interruptedSlots.delete(workspace);
|
|
659
|
+
}
|
|
660
|
+
getLogger().debug(`[queue-manager] skip decrement (interrupted slot) workspace=${workspace} slot=${slotId}`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const count = this.pendingOrExecutingCount.get(workspace) ?? 1;
|
|
664
|
+
this.pendingOrExecutingCount.set(workspace, Math.max(0, count - 1));
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Reset the executing task count for a workspace.
|
|
668
|
+
*
|
|
669
|
+
* This should be called when a running task is interrupted externally
|
|
670
|
+
* (e.g., via /stop command or "立即执行" button) so that the queue
|
|
671
|
+
* correctly reflects that no tasks are currently executing.
|
|
672
|
+
*
|
|
673
|
+
* Without this reset, the pendingOrExecutingCount would remain > 0 even after
|
|
674
|
+
* the task process is killed, causing subsequent messages to incorrectly
|
|
675
|
+
* show as "排队中" because the queue thinks a task is still running.
|
|
676
|
+
*
|
|
677
|
+
* Also marks the currently executing task's slot as interrupted, so its
|
|
678
|
+
* eventual `.then()`/`.catch()` settle skips the decrement (which could
|
|
679
|
+
* otherwise zero the count while a newer task is still running). The mark
|
|
680
|
+
* is granted at most once per slot: repeated resets of the same executing
|
|
681
|
+
* task do not accumulate credits.
|
|
682
|
+
*
|
|
683
|
+
* `expectedSlot` binds the reset to the task that was actually interrupted:
|
|
684
|
+
* the caller captures the executing slot BEFORE stopping the runner, and
|
|
685
|
+
* the stop window may outlive the interrupted task's settle (the chain can
|
|
686
|
+
* advance to a NEW task, which then owns `executingSlot`). When the current
|
|
687
|
+
* slot no longer matches the interrupted task's slot, the interrupted task
|
|
688
|
+
* already decremented normally — resetting now would zero the count of the
|
|
689
|
+
* running successor and mark ITS slot interrupted, hiding it from the
|
|
690
|
+
* queue card (A22).
|
|
691
|
+
*/
|
|
692
|
+
resetExecutingCount(workspace, expectedSlot) {
|
|
693
|
+
const currentSlot = this.executingSlot.get(workspace);
|
|
694
|
+
if (currentSlot !== expectedSlot) {
|
|
695
|
+
getLogger().debug(`[queue-manager] resetExecutingCount skip (stopped task settled, slot advanced) workspace=${workspace} expectedSlot=${expectedSlot} currentSlot=${currentSlot ?? 'none'}`);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
// P3#9: only reset and grant an interrupt slot when there is actually a
|
|
699
|
+
// pending/executing task.
|
|
700
|
+
const currentCount = this.pendingOrExecutingCount.get(workspace) ?? 0;
|
|
701
|
+
if (currentCount === 0) {
|
|
702
|
+
getLogger().debug(`[queue-manager] resetExecutingCount no-op (count already 0) workspace=${workspace}`);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
// Bind the interrupt credit to the currently executing task's slot (if
|
|
706
|
+
// any; defensive against a count without an executing task). `Set.add`
|
|
707
|
+
// dedupes repeated resets of the same slot.
|
|
708
|
+
const slot = currentSlot;
|
|
709
|
+
this.pendingOrExecutingCount.set(workspace, 0);
|
|
710
|
+
if (slot !== undefined) {
|
|
711
|
+
let interrupted = this.interruptedSlots.get(workspace);
|
|
712
|
+
if (!interrupted) {
|
|
713
|
+
interrupted = new Set();
|
|
714
|
+
this.interruptedSlots.set(workspace, interrupted);
|
|
715
|
+
}
|
|
716
|
+
interrupted.add(slot);
|
|
717
|
+
getLogger().debug(`[queue-manager] reset pendingOrExecutingCount workspace=${workspace} interruptedSlot=${slot}`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
getLogger().debug(`[queue-manager] reset pendingOrExecutingCount workspace=${workspace} (no executing slot)`);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* The slot id of the task currently executing in this workspace (if any).
|
|
724
|
+
*/
|
|
725
|
+
getExecutingSlot(workspace) {
|
|
726
|
+
return this.executingSlot.get(workspace);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
//# sourceMappingURL=queue-manager.js.map
|