nx-kv 0.8.4 → 0.8.5
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 +30 -0
- package/assets/kv-submit/SKILL.md +12 -4
- package/assets/kv-submit/references/submit-workflow.md +9 -2
- package/assets/kv-waittask/SKILL.md +23 -1
- package/assets/kv-waittask/references/kv-waittask-workflow.md +26 -4
- package/package.json +1 -1
- package/src/modules/localtask/index.js +44 -21
- package/src/modules/localtask/service.js +238 -46
- package/src/modules/localtask/view.jsx +72 -16
- package/src/web/public/assets/{CliHints-D70_R4vQ.js → CliHints-ZTpac1yV.js} +1 -1
- package/src/web/public/assets/{index-2OK9SG2y.js → index-gJ1xxwt4.js} +2 -2
- package/src/web/public/assets/{view-l_lnGEBC.js → view-C9k3lhCV.js} +1 -1
- package/src/web/public/assets/{view-gzBv_kjb.js → view-CYSIt3hy.js} +1 -1
- package/src/web/public/assets/{view-C5ictDJi.js → view-Cq4GElgD.js} +1 -1
- package/src/web/public/assets/view-mzUKgI5p.js +1 -0
- package/src/web/public/index.html +1 -1
- package/src/modules/localtask/popup.js +0 -285
- package/src/web/public/assets/view-DxwcTgBZ.js +0 -1
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
// 1. **工作目录即主题,主题拥有队列。** wait 传入工作目录(默认进程 cwd),归一化后
|
|
11
11
|
// 作为主题 key;同一目录的多次 wait(即便现状措辞不同)共用同一个任务队列,
|
|
12
12
|
// 不同目录 → 不同主题 → 队列互不串。任务可在一个主题队列里逐条堆积,agent 按序领取。
|
|
13
|
-
// 2.
|
|
13
|
+
// 2. **全局主题兜底(工作窃取)。** 除按目录划分的本地队列外,还有一个跨目录共享的
|
|
14
|
+
// 「全局主题」(key '__GLOBAL__'):本地队列取不到 pending 时,任何 wait 都自动去
|
|
15
|
+
// 全局领(显式传 --cwd 也不例外);全局也没有才阻塞。任务用 addTask({global:true})
|
|
16
|
+
// (CLI --global)发布进池子,谁闲谁领——类似 Go 调度器的 local runq + global runq。
|
|
17
|
+
// 3. **现状总结只作状态展示。** --summary 是 agent 当前状态(人读),每次 wait 刷新,
|
|
14
18
|
// 不再参与队列分桶;用户据此一眼看清 agent 做到哪、卡在哪。
|
|
15
|
-
//
|
|
19
|
+
// 4. **纯本地协调,不依赖 KV 后端登录态。** 数据落本机文件,弹窗由本进程临时起的
|
|
16
20
|
// http 服务器直出,未登录也能用。
|
|
17
|
-
//
|
|
21
|
+
// 5. **排队语义。** 用户可提前回填:主题已有 pending 时 wait 立即取走、不开窗;
|
|
18
22
|
// 没有 pending 才开窗阻塞。
|
|
19
|
-
//
|
|
23
|
+
// 6. **超时是业务结果不是错误。** 默认阻塞 3 分钟(页面可调,上限 10 分钟);超时返回 {status:'timeout'}、
|
|
20
24
|
// exit 0,agent 据此安静收尾结束本轮,绝不抛异常。
|
|
21
25
|
//
|
|
22
26
|
// 跨进程:弹窗与 wait 同进程时靠内存 waiter 即时唤醒;任务由别的进程写入(如常驻
|
|
@@ -57,6 +61,25 @@ export function clampTaskSuffix(v) {
|
|
|
57
61
|
return s;
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
// ─── 全局主题:跨目录共享队列(工作窃取)───────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// 参考 Go runtime scheduler 的两层队列:每个 P 有 local runq,空了去 global runq
|
|
67
|
+
// 取,还没有才 park。这里的对应物:每个工作目录一个本地主题队列,wait 在本地
|
|
68
|
+
// 取不到 pending 时自动去「全局主题」领;全局也没有才阻塞。
|
|
69
|
+
// 任务侧用 addTask({global:true})(CLI --global)把不限定接收方的任务发布进池子。
|
|
70
|
+
//
|
|
71
|
+
// 全局主题是真实存在的主题对象(复用 topics 存储),但有特殊行为:
|
|
72
|
+
// - key 固定 '__GLOBAL__',与目录名哈希出的 16 位 hex key 永不冲突;cwd 恒为空
|
|
73
|
+
// - 永不进入 waiting 状态(它是任务源,不是等待源)——countWaitingTopics 自然不计
|
|
74
|
+
// - prune/remove 默认跳过(受保护),--force 才能删;等待语义(setWaiting/cancel)不适用
|
|
75
|
+
export const GLOBAL_KEY = '__GLOBAL__';
|
|
76
|
+
export const GLOBAL_NAME = '全局任务';
|
|
77
|
+
export const GLOBAL_CWD = ''; // 全局主题永不指向真实目录
|
|
78
|
+
|
|
79
|
+
export function isGlobalKey(key) {
|
|
80
|
+
return key === GLOBAL_KEY;
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
// ─── 存储位置:可环境变量覆盖(测试隔离);旧名/旧文件自动兼容 ──────────
|
|
61
84
|
|
|
62
85
|
export function storePath() {
|
|
@@ -218,12 +241,33 @@ async function loadStore() {
|
|
|
218
241
|
};
|
|
219
242
|
}
|
|
220
243
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
244
|
+
// 落盘串行化:同进程内多个写操作可能重叠(如 poll 消费落盘与 finally 复位等待态、
|
|
245
|
+
// 全局 FIFO 唤醒与另一路 clearWaiting)——它们共用同一个 .tmp 中转文件,不排队的话
|
|
246
|
+
// A 的 rename 会把 tmp 消耗掉,B 再 rename 就 ENOENT。链式队列保证同进程顺序写。
|
|
247
|
+
// (跨进程仍靠 rename 原子性 last-writer-wins,与本模块既有语义一致。)
|
|
248
|
+
let saveQueue = Promise.resolve();
|
|
249
|
+
function saveStore(data) {
|
|
250
|
+
const run = saveQueue.then(async () => {
|
|
251
|
+
const p = storePath();
|
|
252
|
+
await fsp.mkdir(dirname(p), { recursive: true });
|
|
253
|
+
const tmp = p + '.tmp';
|
|
254
|
+
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
255
|
+
// Windows 上目标文件可能被并发读者(loadStore 的 readFile)或杀软短暂锁住,
|
|
256
|
+
// rename 偶发 EPERM/EACCES——小退避重试几次即可。跨进程并发写也靠原子 rename 兜底。
|
|
257
|
+
for (let i = 0; ; i++) {
|
|
258
|
+
try {
|
|
259
|
+
await fsp.rename(tmp, p);
|
|
260
|
+
return;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (i >= 4 || (err.code !== 'EPERM' && err.code !== 'EACCES')) throw err;
|
|
263
|
+
await new Promise((r) => setTimeout(r, 25 * (i + 1)));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
saveQueue = run.catch(() => {
|
|
268
|
+
/* 失败不阻断后续排队者;本次调用的 rejection 仍返回给调用方 */
|
|
269
|
+
});
|
|
270
|
+
return run;
|
|
227
271
|
}
|
|
228
272
|
|
|
229
273
|
// 取(必要时新建)某个主题
|
|
@@ -281,6 +325,7 @@ function topicView(t) {
|
|
|
281
325
|
waitingUntil: t.waitingUntil || '',
|
|
282
326
|
waitingSince: t.waitingSince || '',
|
|
283
327
|
cancelRequested: !!t.cancelRequested,
|
|
328
|
+
isGlobal: isGlobalKey(t.key),
|
|
284
329
|
createdAt: t.createdAt,
|
|
285
330
|
updatedAt: t.updatedAt,
|
|
286
331
|
pending: tasks.filter((x) => x.status !== 'consumed').length,
|
|
@@ -293,6 +338,38 @@ function topicView(t) {
|
|
|
293
338
|
|
|
294
339
|
const waiters = new Map();
|
|
295
340
|
|
|
341
|
+
// 进程内「愿意从全局领任务」的等待者(FIFO)。任何本地主题的 wait 在本地空、
|
|
342
|
+
// 全局也空而阻塞时,除了挂进 waiters(等本目录回填),还挂到这里——
|
|
343
|
+
// 同进程 addTask({global:true}) 投递时按先来后到投给最早阻塞的 waiter。
|
|
344
|
+
const globalWaiters = [];
|
|
345
|
+
|
|
346
|
+
// 注册全局兜底 waiter;返回注销函数(wait 在拿到任务/超时/被关闭时都要调用,
|
|
347
|
+
// 否则已结束的 wait 还留在队列里占位)。summary 随注册固化——全局投递领取时
|
|
348
|
+
// 记进 task.roundSummary 的是「领取方 agent 当时的现状」,而非全局池自己的现状。
|
|
349
|
+
function registerGlobalWaiter(onTask, summary) {
|
|
350
|
+
const entry = { onTask, summary: summary || '', settled: false };
|
|
351
|
+
globalWaiters.push(entry);
|
|
352
|
+
return () => {
|
|
353
|
+
if (entry.settled) return;
|
|
354
|
+
entry.settled = true;
|
|
355
|
+
const i = globalWaiters.indexOf(entry);
|
|
356
|
+
if (i >= 0) globalWaiters.splice(i, 1);
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 弹出第一个未注销的 waiter(FIFO);无人等返回 null。
|
|
361
|
+
// 取出即标记 settled,保证一条任务只唤醒一个 waiter。
|
|
362
|
+
function takeGlobalWaiter() {
|
|
363
|
+
while (globalWaiters.length) {
|
|
364
|
+
const entry = globalWaiters.shift();
|
|
365
|
+
if (!entry.settled) {
|
|
366
|
+
entry.settled = true;
|
|
367
|
+
return entry;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
296
373
|
function markConsumed(task, roundSummary) {
|
|
297
374
|
task.status = 'consumed';
|
|
298
375
|
task.consumedAt = stampNow();
|
|
@@ -318,7 +395,9 @@ export async function consumeFirstPending(key, roundSummary) {
|
|
|
318
395
|
// 在 store 里把某主题置为等待/结束,并刷新现状。
|
|
319
396
|
// waitingSince 是「等待起点」,面板据此显示「已等多久」——waitingUntil(截止时刻)
|
|
320
397
|
// 只能反推剩余,反推不出起点(超时值可被改写),所以两者都存。
|
|
398
|
+
// 全局主题是任务源不是等待源,永不置等待态(no-op)。
|
|
321
399
|
async function setWaiting(key, cwd, summary, waiting, deadline) {
|
|
400
|
+
if (isGlobalKey(key)) return;
|
|
322
401
|
const store = await loadStore();
|
|
323
402
|
const t = topicOf(store, key, cwd);
|
|
324
403
|
t.waiting = !!waiting;
|
|
@@ -334,6 +413,7 @@ async function setWaiting(key, cwd, summary, waiting, deadline) {
|
|
|
334
413
|
// wait 结束复位:**仅当主题仍存在**时清等待态,绝不重建——主题可能已被 force 删除,
|
|
335
414
|
// 重建会让「删除主题」失效(曾在超时清理路径上把删掉的主题又建回来)。
|
|
336
415
|
async function clearWaiting(key) {
|
|
416
|
+
if (isGlobalKey(key)) return;
|
|
337
417
|
const store = await loadStore();
|
|
338
418
|
const t = store.topics[key];
|
|
339
419
|
if (!t) return;
|
|
@@ -360,6 +440,10 @@ export async function cancelWait({ cwd: rawCwd, key: rawKey } = {}) {
|
|
|
360
440
|
throw badInput('需要 --cwd "<工作目录>" 或 --key "<主题key>"');
|
|
361
441
|
}
|
|
362
442
|
const key = resolveTopicKey({ key: rawKey, cwd: rawCwd });
|
|
443
|
+
// 全局主题不是等待源(永不 waiting),无从关闭
|
|
444
|
+
if (isGlobalKey(key)) {
|
|
445
|
+
return { cancelled: false, key, name: GLOBAL_NAME, cwd: '', reason: 'global_not_waitable' };
|
|
446
|
+
}
|
|
363
447
|
const store = await loadStore();
|
|
364
448
|
const t = store.topics[key];
|
|
365
449
|
if (!t || !t.waiting) {
|
|
@@ -396,12 +480,17 @@ function settingsView(s) {
|
|
|
396
480
|
};
|
|
397
481
|
}
|
|
398
482
|
|
|
399
|
-
// 总览:返回全部主题(面板据此分组展示与回填)+
|
|
483
|
+
// 总览:返回全部主题(面板据此分组展示与回填)+ 超时设置,按最近更新排序;
|
|
484
|
+
// 全局主题永远置顶(共享池是所有 worker 的候补来源,放最显眼处)。
|
|
400
485
|
export async function listTasks() {
|
|
401
486
|
const store = await loadStore();
|
|
402
487
|
const topics = Object.values(store.topics)
|
|
403
488
|
.map(topicView)
|
|
404
|
-
.sort(
|
|
489
|
+
.sort(
|
|
490
|
+
(a, b) =>
|
|
491
|
+
Number(!!b.isGlobal) - Number(!!a.isGlobal) ||
|
|
492
|
+
String(b.updatedAt).localeCompare(String(a.updatedAt))
|
|
493
|
+
);
|
|
405
494
|
return { topics, waiting: topics.filter((t) => t.waiting).length, settings: settingsView(store.settings) };
|
|
406
495
|
}
|
|
407
496
|
|
|
@@ -414,12 +503,12 @@ export async function getSettings() {
|
|
|
414
503
|
// 1) 旧模型迁移来的无 cwd 主题(“旧数据,无目录”);
|
|
415
504
|
// 2) 空主题(没有任何任务);
|
|
416
505
|
// 3) allFinished 时,连同「已全部领取、无待领」的主题一起清。
|
|
417
|
-
//
|
|
506
|
+
// 等待中的主题与全局主题一律保留;dryRun 只报告不改动。
|
|
418
507
|
export async function pruneTopics({ allFinished = false, dryRun = false } = {}) {
|
|
419
508
|
const store = await loadStore();
|
|
420
509
|
const removed = [];
|
|
421
510
|
for (const [key, t] of Object.entries(store.topics)) {
|
|
422
|
-
if (t.waiting) continue;
|
|
511
|
+
if (t.waiting || isGlobalKey(key)) continue;
|
|
423
512
|
const tasks = Array.isArray(t.tasks) ? t.tasks : [];
|
|
424
513
|
const hasPending = tasks.some((x) => x.status !== 'consumed');
|
|
425
514
|
const isLegacy = !t.cwd;
|
|
@@ -462,8 +551,15 @@ export async function updateSettings({ timeoutSec, taskSuffix } = {}) {
|
|
|
462
551
|
return settingsView(store.settings);
|
|
463
552
|
}
|
|
464
553
|
|
|
465
|
-
|
|
466
|
-
|
|
554
|
+
// 任务级寻址:显式 key 优先(全局主题只能按 key 定位——它没有 cwd),否则按工作目录
|
|
555
|
+
function resolveTaskKey({ key: rawKey, cwd: rawCwd }) {
|
|
556
|
+
if (rawKey && String(rawKey).trim()) return String(rawKey).trim();
|
|
557
|
+
return normalizeCwd(rawCwd).key;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function getTask({ cwd: rawCwd, key: rawKey, id } = {}) {
|
|
561
|
+
const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
|
|
562
|
+
const { cwd } = normalizeCwd(rawCwd);
|
|
467
563
|
const want = Number(id);
|
|
468
564
|
if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
|
|
469
565
|
const store = await loadStore();
|
|
@@ -477,32 +573,69 @@ export async function getTask({ cwd: rawCwd, id } = {}) {
|
|
|
477
573
|
}
|
|
478
574
|
|
|
479
575
|
/**
|
|
480
|
-
*
|
|
481
|
-
* 若本进程正有 wait
|
|
576
|
+
* 向某主题(工作目录)的队列回填任务;global=true 时投递到全局主题(共享池)。
|
|
577
|
+
* 若本进程正有 wait 在等该主题/全局池 → 直接投递并唤醒;否则入队堆积。
|
|
578
|
+
* 全局投递的唤醒链:显式 wait 全局 key 者(罕见)→ globalWaiters FIFO → 入队。
|
|
482
579
|
*/
|
|
483
|
-
export async function addTask({ cwd: rawCwd, summary: rawSummary, text } = {}) {
|
|
484
|
-
const { key, cwd } = normalizeCwd(rawCwd);
|
|
485
|
-
const summary = normalizeSummary(rawSummary, { optional: true });
|
|
580
|
+
export async function addTask({ cwd: rawCwd, summary: rawSummary, text, global: toGlobal = false } = {}) {
|
|
486
581
|
const body = normalizeText(text);
|
|
582
|
+
const summary = normalizeSummary(rawSummary, { optional: true });
|
|
487
583
|
|
|
488
584
|
const store = await loadStore();
|
|
489
|
-
|
|
585
|
+
let key;
|
|
586
|
+
let cwd;
|
|
587
|
+
let t;
|
|
588
|
+
if (toGlobal) {
|
|
589
|
+
// 全局主题:cwd 恒空、名字固定;--cwd 显式给出也忽略(--global 优先)
|
|
590
|
+
key = GLOBAL_KEY;
|
|
591
|
+
cwd = GLOBAL_CWD;
|
|
592
|
+
t = topicOf(store, key, cwd, GLOBAL_NAME);
|
|
593
|
+
} else {
|
|
594
|
+
({ key, cwd } = normalizeCwd(rawCwd));
|
|
595
|
+
t = topicOf(store, key, cwd);
|
|
596
|
+
}
|
|
597
|
+
|
|
490
598
|
const task = normalizeTask({ id: nextTaskId(t.tasks), text: body, status: 'pending', createdAt: stampNow() });
|
|
491
599
|
t.tasks.push(task);
|
|
492
600
|
t.updatedAt = stampNow();
|
|
493
601
|
if (summary) t.summary = summary;
|
|
494
602
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
603
|
+
// 唤醒分两步:先在 store 里标记消费,saveStore 之后才 resolve——保证 wait 返回时
|
|
604
|
+
// 数据必然一致(沿用旧本地路径的顺序)。
|
|
605
|
+
let delivered = false;
|
|
606
|
+
let wake = null; // (task) => void
|
|
607
|
+
|
|
608
|
+
if (toGlobal) {
|
|
609
|
+
const direct = waiters.get(GLOBAL_KEY); // 显式等全局 key 的(罕见路径)
|
|
610
|
+
if (direct) {
|
|
611
|
+
markConsumed(task, t.summary);
|
|
612
|
+
waiters.delete(GLOBAL_KEY);
|
|
613
|
+
t.waiting = false;
|
|
614
|
+
t.waitingUntil = '';
|
|
615
|
+
delivered = true;
|
|
616
|
+
wake = direct.resolve;
|
|
617
|
+
} else {
|
|
618
|
+
const gw = takeGlobalWaiter(); // FIFO:最早阻塞的先领
|
|
619
|
+
if (gw) {
|
|
620
|
+
markConsumed(task, gw.summary); // roundSummary 记领取方 wait 的现状
|
|
621
|
+
delivered = true;
|
|
622
|
+
wake = gw.onTask;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
} else {
|
|
626
|
+
const waiter = waiters.get(key);
|
|
627
|
+
if (waiter) {
|
|
628
|
+
markConsumed(task, t.summary);
|
|
629
|
+
waiters.delete(key);
|
|
630
|
+
t.waiting = false;
|
|
631
|
+
t.waitingUntil = '';
|
|
632
|
+
delivered = true;
|
|
633
|
+
wake = waiter.resolve;
|
|
634
|
+
}
|
|
502
635
|
}
|
|
503
636
|
|
|
504
637
|
await saveStore(store); // 先落盘再唤醒:wait 返回时数据必然一致
|
|
505
|
-
if (
|
|
638
|
+
if (wake) wake(normalizeTask(task));
|
|
506
639
|
|
|
507
640
|
return {
|
|
508
641
|
status: 'ok',
|
|
@@ -510,14 +643,16 @@ export async function addTask({ cwd: rawCwd, summary: rawSummary, text } = {}) {
|
|
|
510
643
|
cwd,
|
|
511
644
|
name: t.name,
|
|
512
645
|
summary: t.summary,
|
|
646
|
+
isGlobal: toGlobal,
|
|
513
647
|
task: normalizeTask(task),
|
|
514
648
|
delivered,
|
|
515
649
|
queued: !delivered,
|
|
516
650
|
};
|
|
517
651
|
}
|
|
518
652
|
|
|
519
|
-
export async function updateTask({ cwd: rawCwd, id, text } = {}) {
|
|
520
|
-
const { key, cwd }
|
|
653
|
+
export async function updateTask({ cwd: rawCwd, key: rawKey, id, text } = {}) {
|
|
654
|
+
const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
|
|
655
|
+
const { cwd } = normalizeCwd(rawCwd);
|
|
521
656
|
const want = Number(id);
|
|
522
657
|
if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
|
|
523
658
|
const body = String(text ?? '').trim();
|
|
@@ -534,11 +669,12 @@ export async function updateTask({ cwd: rawCwd, id, text } = {}) {
|
|
|
534
669
|
task.text = body;
|
|
535
670
|
t.updatedAt = stampNow();
|
|
536
671
|
await saveStore(store);
|
|
537
|
-
return { status: 'ok', key, cwd, name: t.name, task: normalizeTask(task) };
|
|
672
|
+
return { status: 'ok', key, cwd: t.cwd || cwd, name: t.name, task: normalizeTask(task) };
|
|
538
673
|
}
|
|
539
674
|
|
|
540
|
-
export async function removeTask({ cwd: rawCwd, id } = {}) {
|
|
541
|
-
const { key, cwd }
|
|
675
|
+
export async function removeTask({ cwd: rawCwd, key: rawKey, id } = {}) {
|
|
676
|
+
const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
|
|
677
|
+
const { cwd } = normalizeCwd(rawCwd);
|
|
542
678
|
const want = Number(id);
|
|
543
679
|
if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
|
|
544
680
|
|
|
@@ -552,7 +688,7 @@ export async function removeTask({ cwd: rawCwd, id } = {}) {
|
|
|
552
688
|
const [removed] = t.tasks.splice(index, 1);
|
|
553
689
|
t.updatedAt = stampNow();
|
|
554
690
|
await saveStore(store);
|
|
555
|
-
return { status: 'ok', key, cwd, name: t.name, removed: normalizeTask(removed) };
|
|
691
|
+
return { status: 'ok', key, cwd: t.cwd || cwd, name: t.name, removed: normalizeTask(removed) };
|
|
556
692
|
}
|
|
557
693
|
|
|
558
694
|
// ─── 主题删除(单个 / 批量) ───────────────────────────────────────
|
|
@@ -565,6 +701,8 @@ function resolveTopicKey({ key: rawKey, cwd: rawCwd }) {
|
|
|
565
701
|
|
|
566
702
|
// 删除整个主题(连同其全部任务)。默认拒绝删除「等待中」主题——那会让正阻塞的
|
|
567
703
|
// agent 只能等到超时;force=true 才强删(agent 随后超时收尾)。
|
|
704
|
+
// 全局主题同样默认拒绝(共享池,删掉会让所有 worker 的候补来源消失);
|
|
705
|
+
// force=true 清空全局任务池。
|
|
568
706
|
export async function removeTopic({ cwd: rawCwd, key: rawKey, force = false } = {}) {
|
|
569
707
|
if (!rawKey && !rawCwd) {
|
|
570
708
|
throw badInput('需要 --cwd "<工作目录>" 或 --key "<主题key>"');
|
|
@@ -576,6 +714,9 @@ export async function removeTopic({ cwd: rawCwd, key: rawKey, force = false } =
|
|
|
576
714
|
const { notFound } = await import('../../core/errors.js');
|
|
577
715
|
throw notFound(`主题不存在(key ${key})`);
|
|
578
716
|
}
|
|
717
|
+
if (isGlobalKey(key) && !force) {
|
|
718
|
+
throw badInput('全局主题是共享任务池,默认不能删除(确认要清空请加 --force)');
|
|
719
|
+
}
|
|
579
720
|
if (t.waiting && !force) {
|
|
580
721
|
throw badInput('主题正在等待中,不能删除(确认要删请加 --force,agent 将超时收尾)');
|
|
581
722
|
}
|
|
@@ -614,6 +755,10 @@ export async function removeTopics({ keys, cwds, all = false, force = false } =
|
|
|
614
755
|
skipped.push({ key, reason: 'not_found' });
|
|
615
756
|
continue;
|
|
616
757
|
}
|
|
758
|
+
if (isGlobalKey(key) && !force) {
|
|
759
|
+
skipped.push({ key, name: t.name, cwd: t.cwd, reason: 'protected' });
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
617
762
|
if (t.waiting && !force) {
|
|
618
763
|
skipped.push({ key, name: t.name, cwd: t.cwd, reason: 'waiting' });
|
|
619
764
|
continue;
|
|
@@ -628,8 +773,8 @@ export async function removeTopics({ keys, cwds, all = false, force = false } =
|
|
|
628
773
|
// 调整**待领取**任务的优先级:把 #id 移动到 #beforeId 之前或 #afterId 之后。
|
|
629
774
|
// 领取顺序 = tasks 数组里 pending 子序列的先后(consumeFirstPending 取第一条 pending),
|
|
630
775
|
// 故只在 pending 之间换位;已领取(历史)任务固定、不可移动。
|
|
631
|
-
export async function reorderTasks({ cwd: rawCwd, id, beforeId, afterId } = {}) {
|
|
632
|
-
const { key, cwd }
|
|
776
|
+
export async function reorderTasks({ cwd: rawCwd, key: rawKey, id, beforeId, afterId } = {}) {
|
|
777
|
+
const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
|
|
633
778
|
const dragId = Number(id);
|
|
634
779
|
const hasBefore = beforeId !== undefined && beforeId !== null;
|
|
635
780
|
const hasAfter = afterId !== undefined && afterId !== null;
|
|
@@ -650,7 +795,7 @@ export async function reorderTasks({ cwd: rawCwd, id, beforeId, afterId } = {})
|
|
|
650
795
|
const t = store.topics[key];
|
|
651
796
|
if (!t) {
|
|
652
797
|
const { notFound } = await import('../../core/errors.js');
|
|
653
|
-
throw notFound(
|
|
798
|
+
throw notFound(`主题(key ${key})不存在`);
|
|
654
799
|
}
|
|
655
800
|
const drag = t.tasks.find((x) => Number(x.id) === dragId);
|
|
656
801
|
const target = t.tasks.find((x) => Number(x.id) === targetId);
|
|
@@ -671,7 +816,7 @@ export async function reorderTasks({ cwd: rawCwd, id, beforeId, afterId } = {})
|
|
|
671
816
|
t.updatedAt = stampNow();
|
|
672
817
|
|
|
673
818
|
await saveStore(store);
|
|
674
|
-
return { status: 'ok', key, cwd, name: t.name, topic: topicView(t) };
|
|
819
|
+
return { status: 'ok', key, cwd: t.cwd || '', name: t.name, topic: topicView(t) };
|
|
675
820
|
}
|
|
676
821
|
|
|
677
822
|
// ─── 阻塞等待:核心入口 ─────────────────────────────────────────────
|
|
@@ -688,7 +833,10 @@ function withTaskSuffix(task, suffix) {
|
|
|
688
833
|
|
|
689
834
|
/**
|
|
690
835
|
* 针对某主题(工作目录)等待用户回填下一条任务。
|
|
691
|
-
*
|
|
836
|
+
* 领取顺序:本目录队列 → 全局主题(共享池)→ 阻塞(两个来源任一到达即唤醒)。
|
|
837
|
+
* 返回带 source: 'local' | 'global' 标明任务来自哪个队列。
|
|
838
|
+
* @param {string} [cwd] 工作目录(主题按目录名识别);缺省 → 进程当前 cwd。
|
|
839
|
+
* 注意:显式传 cwd **不会**关闭全局兜底——本地空了一定去全局领。
|
|
692
840
|
* @param {string} summary 当前现状(必填,状态展示)
|
|
693
841
|
* @param {number} [timeoutSec] 阻塞上限(秒);缺省 → 页面配置(推荐 180,最大 600)
|
|
694
842
|
* @param {number} [timeoutMs] 兼容旧参数(毫秒)
|
|
@@ -718,7 +866,24 @@ export async function waitForTask({
|
|
|
718
866
|
// 1. 主题队列已有 pending → 立即取走,不开窗、不阻塞;记录本轮完成总结
|
|
719
867
|
const queued = await consumeFirstPending(key, summary);
|
|
720
868
|
if (queued) {
|
|
721
|
-
return { status: 'ok', key, cwd, name, summary, task: withTaskSuffix(queued, suffix), queued: true };
|
|
869
|
+
return { status: 'ok', key, cwd, name, summary, task: withTaskSuffix(queued, suffix), queued: true, source: 'local' };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 1b. 本地空 → 去全局主题(共享池)领。这是所有 wait 的**无条件默认兜底**:
|
|
873
|
+
// 与是否显式传 --cwd 无关——本地取不到就一定来全局扫一眼,全局也没有才阻塞。
|
|
874
|
+
// (防止调用方习惯性传 cwd 导致共享池永远无人领取。)
|
|
875
|
+
const fromGlobal = await consumeFirstPending(GLOBAL_KEY, summary);
|
|
876
|
+
if (fromGlobal) {
|
|
877
|
+
return {
|
|
878
|
+
status: 'ok',
|
|
879
|
+
key,
|
|
880
|
+
cwd,
|
|
881
|
+
name,
|
|
882
|
+
summary,
|
|
883
|
+
task: withTaskSuffix(fromGlobal, suffix),
|
|
884
|
+
queued: true,
|
|
885
|
+
source: 'global',
|
|
886
|
+
};
|
|
722
887
|
}
|
|
723
888
|
|
|
724
889
|
// 2. 没有 pending → 复用常驻主面板(不再起随机端口临时弹窗):
|
|
@@ -732,7 +897,7 @@ export async function waitForTask({
|
|
|
732
897
|
const presence = panelInfo.running ? await probePresence() : 0;
|
|
733
898
|
const deepLink = deepLinkUrl(key, cwd);
|
|
734
899
|
|
|
735
|
-
// 3.
|
|
900
|
+
// 3. 结果承诺:三个生产者(同进程本地 waiter / 同进程全局 FIFO / 跨进程轮询)+ 超时
|
|
736
901
|
let resolveOutcome;
|
|
737
902
|
const outcome = new Promise((r) => {
|
|
738
903
|
resolveOutcome = r;
|
|
@@ -740,11 +905,24 @@ export async function waitForTask({
|
|
|
740
905
|
waiters.set(key, {
|
|
741
906
|
resolve: (task) => resolveOutcome({ kind: 'task', task, delivered: true }),
|
|
742
907
|
});
|
|
908
|
+
// 全局兜底:同进程有人 addTask({global:true}) 时按 FIFO 唤醒本 wait
|
|
909
|
+
const unregisterGlobal = registerGlobalWaiter(
|
|
910
|
+
(task) => resolveOutcome({ kind: 'task', task, delivered: true, source: 'global' }),
|
|
911
|
+
summary
|
|
912
|
+
);
|
|
743
913
|
|
|
744
914
|
const poll = setInterval(() => {
|
|
915
|
+
// 跨进程兜底轮询:先查本地,再查全局(本地优先;同轮至多 resolve 一次)
|
|
745
916
|
consumeFirstPending(key)
|
|
746
917
|
.then((task) => {
|
|
747
|
-
if (task)
|
|
918
|
+
if (task) {
|
|
919
|
+
resolveOutcome({ kind: 'task', task, delivered: false, source: 'local' });
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
return consumeFirstPending(GLOBAL_KEY);
|
|
923
|
+
})
|
|
924
|
+
.then((task) => {
|
|
925
|
+
if (task) resolveOutcome({ kind: 'task', task, delivered: false, source: 'global' });
|
|
748
926
|
})
|
|
749
927
|
.catch(() => {
|
|
750
928
|
/* 瞬时读写错误,下一轮再试 */
|
|
@@ -810,13 +988,24 @@ export async function waitForTask({
|
|
|
810
988
|
? { status: 'timeout', key, cwd, summary, timeoutMs: tmo }
|
|
811
989
|
: r.kind === 'cancelled'
|
|
812
990
|
? { status: 'timeout', key, cwd, summary, timeoutMs: tmo, cancelled: true }
|
|
813
|
-
: {
|
|
991
|
+
: {
|
|
992
|
+
status: 'ok',
|
|
993
|
+
key,
|
|
994
|
+
cwd,
|
|
995
|
+
name,
|
|
996
|
+
summary,
|
|
997
|
+
task: withTaskSuffix(r.task, suffix),
|
|
998
|
+
delivered: r.delivered,
|
|
999
|
+
queued: false,
|
|
1000
|
+
source: r.source || 'local',
|
|
1001
|
+
};
|
|
814
1002
|
} finally {
|
|
815
1003
|
clearInterval(poll);
|
|
816
1004
|
clearInterval(cancelPoll);
|
|
817
1005
|
clearInterval(review);
|
|
818
1006
|
clearTimeout(timer);
|
|
819
1007
|
waiters.delete(key);
|
|
1008
|
+
unregisterGlobal();
|
|
820
1009
|
await clearWaiting(key);
|
|
821
1010
|
}
|
|
822
1011
|
return result;
|
|
@@ -1075,6 +1264,7 @@ export async function* watchEvents({ cwd: rawCwd, key: rawKey, pollMs = EVENT_PO
|
|
|
1075
1264
|
cwd: t.cwd || '',
|
|
1076
1265
|
name: t.name || '',
|
|
1077
1266
|
summary: t.summary || '',
|
|
1267
|
+
isGlobal: isGlobalKey(t.key),
|
|
1078
1268
|
waitingUntil: t.waitingUntil,
|
|
1079
1269
|
remainMs: Math.max(0, deadline - now),
|
|
1080
1270
|
replay: first,
|
|
@@ -1089,6 +1279,7 @@ export async function* watchEvents({ cwd: rawCwd, key: rawKey, pollMs = EVENT_PO
|
|
|
1089
1279
|
cwd: t.cwd || '',
|
|
1090
1280
|
name: t.name || '',
|
|
1091
1281
|
summary: t.summary || '',
|
|
1282
|
+
isGlobal: isGlobalKey(t.key),
|
|
1092
1283
|
waitingUntil: t.waitingUntil,
|
|
1093
1284
|
remainSec: sec,
|
|
1094
1285
|
remainMs: Math.max(0, st.deadline - now),
|
|
@@ -1098,7 +1289,7 @@ export async function* watchEvents({ cwd: rawCwd, key: rawKey, pollMs = EVENT_PO
|
|
|
1098
1289
|
} else if (st && st.phase === 'waiting') {
|
|
1099
1290
|
const outcome = consumedCount(t) > st.consumed0 ? 'delivered' : 'timeout';
|
|
1100
1291
|
state.set(t.key, { phase: 'ended' });
|
|
1101
|
-
yield { type: 'resolved', key: t.key, cwd: t.cwd || '', name: t.name || '', outcome };
|
|
1292
|
+
yield { type: 'resolved', key: t.key, cwd: t.cwd || '', name: t.name || '', isGlobal: isGlobalKey(t.key), outcome };
|
|
1102
1293
|
} else if (!st) {
|
|
1103
1294
|
state.set(t.key, { phase: 'idle' });
|
|
1104
1295
|
}
|
|
@@ -1113,6 +1304,7 @@ export async function* watchEvents({ cwd: rawCwd, key: rawKey, pollMs = EVENT_PO
|
|
|
1113
1304
|
key: t.key,
|
|
1114
1305
|
cwd: t.cwd || '',
|
|
1115
1306
|
name: t.name || '',
|
|
1307
|
+
isGlobal: isGlobalKey(t.key),
|
|
1116
1308
|
outcome: 'timeout',
|
|
1117
1309
|
stale: true,
|
|
1118
1310
|
};
|