@weiping/wingline-core 0.1.0 → 0.1.1

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.
@@ -17,6 +17,8 @@ export interface CommandDeps {
17
17
  monotonic: () => number;
18
18
  }
19
19
  export type Step = 'create' | 'wait' | 'brief' | 'send' | 'close';
20
+ /** terminalSend 的提交等待秒数:launch / restore / send / notify 共用一份,别再各写一个 10。 */
21
+ export declare const WAIT_SUBMIT_SEC = 10;
20
22
  export declare const postAddress: (post: string, team: string) => string;
21
23
  /**
22
24
  * 本地请求账本的键 `<team>:<post>:<step>:<hash8>`(`terminal` 命令族的 --retry-request 只认 Orca 分配的 UUID;编排命令族见 commands/task/ledger.ts)。
@@ -1,5 +1,7 @@
1
1
  import { WinglineError } from '../errors.js';
2
2
  import { inRange } from './version-range.js';
3
+ /** terminalSend 的提交等待秒数:launch / restore / send / notify 共用一份,别再各写一个 10。 */
4
+ export const WAIT_SUBMIT_SEC = 10;
3
5
  export const postAddress = (post, team) => `${post}@${team}`;
4
6
  /**
5
7
  * 本地请求账本的键 `<team>:<post>:<step>:<hash8>`(`terminal` 命令族的 --retry-request 只认 Orca 分配的 UUID;编排命令族见 commands/task/ledger.ts)。
@@ -24,6 +24,8 @@ export interface AttentionReport {
24
24
  /** 需要人知道但不属于某个条目的提示:未应用的规格草稿、Orca 同步不可用等。 */
25
25
  warnings: string[];
26
26
  }
27
+ /** 待办数的唯一口径:CLI `wl dispatcher status`、控制台顶栏与 TUI 都用它,别再各写一遍。 */
28
+ export declare const pendingCount: (r: Pick<AttentionReport, "frozen" | "signals" | "questions" | "stale" | "warnings">) => number;
27
29
  /** spec 第 6.5 节:由规格文件、注册表与常量推导,条件成立期间一直显示。 */
28
30
  export declare function warningsOf(deps: CommandDeps, team: string, spec: TeamSpec | null): string[];
29
31
  /** spec 第 6.5 节:只读汇总;调度员没在跑也能用,取不到的来源显式列在 unavailable。 */
@@ -8,6 +8,8 @@ import { gatherStaleInput, staleReasons } from '../task/query.js';
8
8
  import { dispatcherHealth } from './lifecycle.js';
9
9
  import { signalSummary } from './signal-summary.js';
10
10
  import { unreadGaveUp } from './unread.js';
11
+ /** 待办数的唯一口径:CLI `wl dispatcher status`、控制台顶栏与 TUI 都用它,别再各写一遍。 */
12
+ export const pendingCount = (r) => r.frozen.length + r.signals.length + r.questions.length + r.stale.length + r.warnings.length;
11
13
  const DAY_MS = 24 * 3_600_000;
12
14
  /** spec 第 6.5 节:由规格文件、注册表与常量推导,条件成立期间一直显示。 */
13
15
  export function warningsOf(deps, team, spec) {
@@ -44,8 +46,7 @@ function specOf(deps, team) {
44
46
  }
45
47
  }
46
48
  function lastCommand(deps, team, s) {
47
- const events = deps.registry.listPatrolEvents(team, { limit: 500 });
48
- const hit = events.findLast((e) => e.action === 'notify' && e.post === s.post && e.signal === s.signal && (e.taskId ?? '') === s.taskId);
49
+ const hit = deps.registry.lastNotifyEventOf(team, s.post, s.signal, s.taskId);
49
50
  const command = hit?.detail?.command;
50
51
  return command ?? null;
51
52
  }
@@ -94,9 +95,9 @@ function groupWarnings(events) {
94
95
  export async function dispatcherStatus(deps, team) {
95
96
  const { registry } = deps;
96
97
  const now = deps.now();
97
- const events = registry.listPatrolEvents(team, { limit: 500 });
98
- const last = (match) => {
99
- const e = events.findLast((x) => match(x.action));
98
+ // 定点查询而非读最新 500 条:巡查事件保留 30 天、每 5 分钟写若干条,500 条窗口会把更早的 check/scan 挤出去
99
+ const lastOf = (actions) => {
100
+ const e = registry.lastPatrolEventOf(team, actions);
100
101
  return e ? { at: e.at, outcome: e.outcome ?? 'ok' } : null;
101
102
  };
102
103
  const report = await attention(deps, team);
@@ -104,11 +105,11 @@ export async function dispatcherStatus(deps, team) {
104
105
  return {
105
106
  team,
106
107
  health: report.dispatcher,
107
- lastCheck: last((a) => a.startsWith('check_')),
108
- lastScan: last((a) => a === 'scan' || a === 'scan_skipped'),
108
+ lastCheck: lastOf(['check_ok', 'check_failed']),
109
+ lastScan: lastOf(['scan', 'scan_skipped']),
109
110
  frozen: report.frozen.length,
110
111
  hushed: registry.listHushes(team).filter((h) => Date.parse(h.until) > now.getTime()).length,
111
- attentionItems: report.frozen.length + report.signals.length + report.questions.length + report.stale.length,
112
- warnings: groupWarnings(events.filter((e) => e.at >= since && WARNING_ACTIONS.has(e.action))),
112
+ attentionItems: pendingCount(report),
113
+ warnings: groupWarnings(registry.listPatrolEvents(team, { since, limit: 10_000 }).filter((e) => WARNING_ACTIONS.has(e.action))),
113
114
  };
114
115
  }
@@ -5,7 +5,7 @@ export declare function freezePostCmd(deps: CommandDeps, a: {
5
5
  address: string;
6
6
  reason: string;
7
7
  }): Promise<void>;
8
- /** 解冻:先重置巡查行与游标,最后删冻结行(中途崩溃仍是冻结状态,可重跑)。 */
8
+ /** 解冻:先重置巡查行与游标,最后删冻结行(中途崩溃仍是冻结状态,可重跑)。团队以冻结行为准——巡查可能冻结的是没登记的终端(任务派去了陌生终端),knownPost 会把它判成 post_unknown,永远解不开。 */
9
9
  export declare function unfreezePostCmd(deps: CommandDeps, a: {
10
10
  address: string;
11
11
  }): Promise<{
@@ -34,12 +34,14 @@ async function stillReady(deps, team, ids) {
34
34
  return new Set(ids);
35
35
  }
36
36
  }
37
- /** 解冻:先重置巡查行与游标,最后删冻结行(中途崩溃仍是冻结状态,可重跑)。 */
37
+ /** 解冻:先重置巡查行与游标,最后删冻结行(中途崩溃仍是冻结状态,可重跑)。团队以冻结行为准——巡查可能冻结的是没登记的终端(任务派去了陌生终端),knownPost 会把它判成 post_unknown,永远解不开。 */
38
38
  export async function unfreezePostCmd(deps, a) {
39
- const team = knownPost(deps, a.address);
40
39
  const frozen = deps.registry.getFrozen(a.address);
41
- if (!frozen)
40
+ if (!frozen) {
41
+ knownPost(deps, a.address); // 没冻结:按原口径报 team_unknown / post_unknown
42
42
  throw new WinglineError('input', 'post_not_frozen', `岗位 ${a.address} 没有被冻结`);
43
+ }
44
+ const team = frozen.team;
43
45
  const at = deps.now().toISOString();
44
46
  deps.registry.resetPatrolForPost(a.address, at);
45
47
  deps.registry.setCursor(team, a.address, at);
@@ -150,6 +150,8 @@ function maintain(deps, st) {
150
150
  deps.registry.prunePatrolEvents(new Date(now.getTime() - EVENT_KEEP_MS).toISOString());
151
151
  // 审计事件同保留 30 天(3A spec §6.4);调度员循环 + wl log 各清理一次。
152
152
  pruneAuditBestEffort(deps, st.team.name, new Date(now.getTime() - EVENT_KEEP_MS).toISOString());
153
+ // 已结束状态同样只留 30 天:已清除的巡查行、已回答的提问、已定案的账本行,不清会一直涨
154
+ deps.registry.pruneFinishedState(new Date(now.getTime() - EVENT_KEEP_MS).toISOString());
153
155
  st.logDay = dayOf(now);
154
156
  }
155
157
  const target = mustTeamReady(deps, st.team.name);
@@ -7,7 +7,6 @@ export interface Launch {
7
7
  hash: string;
8
8
  }
9
9
  export declare const WAIT_TIMEOUT_MS = 60000;
10
- export declare const WAIT_SUBMIT_SEC = 10;
11
10
  /** 先记 pending,再调用,最后按结果记 done / uncertain / failed。ok 为 false 表示 Orca 回执未确认成功。 */
12
11
  export declare function recorded<T>(reg: Registry, id: string, fn: () => Promise<T>, ok?: (v: T) => boolean): Promise<{
13
12
  confirmed: boolean;
@@ -1,9 +1,8 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { WinglineError } from '../errors.js';
3
- import { postAddress, requestId } from './deps.js';
3
+ import { postAddress, requestId, WAIT_SUBMIT_SEC } from './deps.js';
4
4
  import { screenGuard } from './task/notify.js';
5
5
  export const WAIT_TIMEOUT_MS = 60_000;
6
- export const WAIT_SUBMIT_SEC = 10;
7
6
  /** 先记 pending,再调用,最后按结果记 done / uncertain / failed。ok 为 false 表示 Orca 回执未确认成功。 */
8
7
  export async function recorded(reg, id, fn, ok = () => true) {
9
8
  reg.beginRequest(id);
@@ -33,10 +33,16 @@ export async function restore(deps, a) {
33
33
  if (hash !== row.specHash) {
34
34
  throw new WinglineError('input', 'team_spec_changed', `团队 ${a.team} 的规格已变化`, { hint: `不支持在线修改编队,请先执行 wl down ${a.team}` });
35
35
  }
36
+ const seenSession = new Set();
36
37
  for (const [post, id] of Object.entries(a.sessions ?? {})) {
37
38
  if (!spec.posts.some((p) => p.id === post) || !SESSION_ID.test(id)) {
38
39
  throw new WinglineError('input', 'session_override_invalid', `--session ${post}=${id} 无效:岗位不在团队里,或会话 ID 格式不符`);
39
40
  }
41
+ // 同一会话指定给两个岗位:后者会在运行期撞上 reserved,这里提前拒绝
42
+ if (seenSession.has(id)) {
43
+ throw new WinglineError('input', 'session_override_invalid', `--session ${post}=${id} 无效:会话 ${id} 已指定给另一个岗位`);
44
+ }
45
+ seenSession.add(id);
40
46
  }
41
47
  // 第 2、3 步:按 team.yaml 顺序,牵头岗最先(lead 先就绪,Run 才有认领人)
42
48
  const l = { deps, spec, hash };
@@ -21,7 +21,7 @@ export interface RestorePost {
21
21
  /** restore 的请求键独立命名空间(复审 I-1):与 wl up 的键不冲突,代数各自计数。 */
22
22
  export declare const restoreKey: (team: string, post: string, gen: number, step: "create" | "wait" | "notice") => string;
23
23
  /** 已完成的 restore create 中最大代数加 1;本代 create 尚未完成(中断重跑沿用原代)时不前进。终端仍活着(located)时调用方走 kept 或补完上一代(pendingResume),不会拉起新代。 */
24
- export declare function restoreGen(reg: Registry, team: string, post: string, _located: boolean): number;
24
+ export declare function restoreGen(reg: Registry, team: string, post: string): number;
25
25
  /**
26
26
  * 远端岗位的接回提示带一个每次接回唯一的标识(`WLR-` 加 8 位十六进制,由该次接回的请求键哈希得到,重试时不变)。
27
27
  * 提示原文每次相同,原会话里可能早有旧的同样的提示;不带标识,丢了 `--resume` 的这一次会被旧提示误判为通过。
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { checkBriefingLimit } from '../../brief/render.js';
3
3
  import { WinglineError } from '../../errors.js';
4
- import { postAddress, requestId } from '../deps.js';
4
+ import { postAddress, requestId, WAIT_SUBMIT_SEC } from '../deps.js';
5
5
  import { briefText } from '../up.js';
6
- import { ensureMarker, lifetimeGen, recorded, startPost, stepDone, WAIT_SUBMIT_SEC, WAIT_TIMEOUT_MS, withMarker, } from '../launch.js';
6
+ import { ensureMarker, lifetimeGen, recorded, startPost, stepDone, WAIT_TIMEOUT_MS, withMarker, } from '../launch.js';
7
7
  import { locatePost } from '../locate.js';
8
8
  import { screenGuard } from '../task/notify.js';
9
9
  import { findSession, markerHighlighted } from './find-session.js';
@@ -11,7 +11,7 @@ import { resumeCommand, SESSION_ID } from './resume-command.js';
11
11
  /** restore 的请求键独立命名空间(复审 I-1):与 wl up 的键不冲突,代数各自计数。 */
12
12
  export const restoreKey = (team, post, gen, step) => `restore:${team}:${post}:${gen}:${step}`;
13
13
  /** 已完成的 restore create 中最大代数加 1;本代 create 尚未完成(中断重跑沿用原代)时不前进。终端仍活着(located)时调用方走 kept 或补完上一代(pendingResume),不会拉起新代。 */
14
- export function restoreGen(reg, team, post, _located) {
14
+ export function restoreGen(reg, team, post) {
15
15
  let g = 0;
16
16
  while (reg.getRequest(restoreKey(team, post, g, 'create'))?.state === 'done')
17
17
  g++;
@@ -81,7 +81,7 @@ async function landingResult(deps, address, sessionId, token, remote, retry) {
81
81
  * 返回要补完的会话 ID 与请求键;否则 null。会话 ID 没缓存、岗位已 live、句柄对不上都不算。
82
82
  */
83
83
  function pendingResume(reg, team, post, handle) {
84
- const gen = restoreGen(reg, team, post, true) - 1;
84
+ const gen = restoreGen(reg, team, post) - 1;
85
85
  if (gen < 0)
86
86
  return null;
87
87
  const rid = (step) => restoreKey(team, post, gen, step);
@@ -131,18 +131,20 @@ export async function restorePost(l, post, o) {
131
131
  const retry = `wl restore ${l.spec.name}`;
132
132
  try {
133
133
  const located = await locatePost(orca, address);
134
- if (located && located.connected) {
135
- // 上次接回没走完(终端是 restore 拉起的、空闲等待或接回提示未定案):补完它,而不是报 kept
134
+ if (located) {
135
+ // 上次接回没走完(终端是 restore 拉起或复用的、空闲等待或接回提示未定案):补完它,而不是报 kept 或再开新代
136
136
  const pending = pendingResume(registry, l.spec.name, post.id, located.handle);
137
137
  if (pending && o.dryRun)
138
138
  return { address, outcome: 'resumed', sessionId: pending.sessionId };
139
139
  if (pending)
140
140
  return await settleResume(l, address, located.handle, pending.sessionId, pending.rid, retry, remote);
141
- if (!o.dryRun) {
142
- registry.setPostHandle(address, located.handle);
143
- registry.setPostStatus(address, 'live');
141
+ if (located.connected) {
142
+ if (!o.dryRun) {
143
+ registry.setPostHandle(address, located.handle);
144
+ registry.setPostStatus(address, 'live');
145
+ }
146
+ return { address, outcome: 'kept' };
144
147
  }
145
- return { address, outcome: 'kept' };
146
148
  }
147
149
  const row = registry.getPost(address);
148
150
  // path: 固定值直接用;选择器(阶段 3 之前的行)现解析一次
@@ -175,14 +177,12 @@ export async function restorePost(l, post, o) {
175
177
  if (sessionId !== null && command !== null) {
176
178
  if (o.dryRun)
177
179
  return { address, outcome: 'resumed', sessionId };
178
- const gen = restoreGen(registry, l.spec.name, post.id, located !== null);
180
+ const gen = restoreGen(registry, l.spec.name, post.id);
179
181
  const rid = (step) => restoreKey(l.spec.name, post.id, gen, step);
180
- let handle = stepDone(registry, rid('create')) ? row.handle : null;
181
- if (!handle) {
182
- const created = await recorded(registry, rid('create'), () => orca.terminalCreate({ worktree: post.worktree, title: address, command }));
183
- handle = created.value.handle;
184
- registry.setPostHandle(address, handle);
185
- }
182
+ // 终端还在(哪怕暂时断连)就复用它并同样记入 create 账本,避免把同一会话接进两个终端或下一代重用旧代数
183
+ const created = await recorded(registry, rid('create'), async () => located ? { handle: located.handle } : orca.terminalCreate({ worktree: post.worktree, title: address, command }));
184
+ const handle = created.value.handle;
185
+ registry.setPostHandle(address, handle);
186
186
  registry.setSessionId(address, sessionId);
187
187
  return await settleResume(l, address, handle, sessionId, rid, retry, remote);
188
188
  }
@@ -1,5 +1,4 @@
1
1
  /** 会话 ID 形如 Orca 分配的 UUID / 内部 ID:字母、数字、连字符之外的字符一律拒绝。 */
2
2
  export declare const SESSION_ID: RegExp;
3
- export declare function supportsResume(agent: string): boolean;
4
3
  /** 拼出该 agent 的接回命令;不支持接回的 agent 返回 null(调用方走 fresh)。 */
5
4
  export declare function resumeCommand(agent: string, launch: string, sessionId: string): string | null;
@@ -5,9 +5,6 @@ export const SESSION_ID = /^[A-Za-z0-9-]+$/;
5
5
  const RESUME_TEMPLATES = {
6
6
  claude: (launch, sessionId) => `${launch} --resume ${sessionId}`,
7
7
  };
8
- export function supportsResume(agent) {
9
- return agent in RESUME_TEMPLATES;
10
- }
11
8
  /** 拼出该 agent 的接回命令;不支持接回的 agent 返回 null(调用方走 fresh)。 */
12
9
  export function resumeCommand(agent, launch, sessionId) {
13
10
  if (!SESSION_ID.test(sessionId)) {
@@ -1,9 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { WinglineError } from '../errors.js';
3
- import { nextRequestId, preflight } from './deps.js';
3
+ import { nextRequestId, preflight, WAIT_SUBMIT_SEC } from './deps.js';
4
4
  import { locatePost } from './locate.js';
5
5
  const MAX_MESSAGE = 4000;
6
- const WAIT_SUBMIT_SEC = 10;
7
6
  function checkMessage(deps, message) {
8
7
  if (message.length > MAX_MESSAGE) {
9
8
  throw new WinglineError('input', 'message_too_long', `消息 ${message.length} 字符,超过上限 ${MAX_MESSAGE}`);
@@ -1,12 +1,16 @@
1
1
  import type { CommandDeps } from '../deps.js';
2
2
  export interface GateExecResult {
3
3
  passed: boolean;
4
- /** 完整输出(不截断)。 */
4
+ /** 输出正文;超过 GATE_OUTPUT_LIMIT_BYTES 时只留尾部,并在末尾标明被截断。 */
5
5
  output: string;
6
6
  timedOut: boolean;
7
7
  /** 被 Ctrl-C / SIGTERM 中断(不计为 gate 失败)。 */
8
8
  interrupted?: boolean;
9
+ /** 输出超过上限,gate 被终止并计为失败。 */
10
+ overflowed?: boolean;
9
11
  }
12
+ /** 输出只保留尾部这么多字节:`yes`、`tail -f` 这类跑飞的 gate 不能把内存吃光。 */
13
+ export declare const GATE_OUTPUT_LIMIT_BYTES: number;
10
14
  export declare function runGate(command: string | null, opts: {
11
15
  cwd: string;
12
16
  timeoutMs: number;
@@ -2,9 +2,12 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { WinglineError } from '../../errors.js';
5
+ /** 输出只保留尾部这么多字节:`yes`、`tail -f` 这类跑飞的 gate 不能把内存吃光。 */
6
+ export const GATE_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
5
7
  const TIMEOUT_MARK = '\n(gate 超时,已终止)';
8
+ const OVERFLOW_MARK = `\n(gate 输出超过 ${GATE_OUTPUT_LIMIT_BYTES / 1024 / 1024} MB,已终止,只保留尾部)`;
6
9
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
7
- /** 超时要杀整棵进程树:POSIX 杀进程组,Windows 用 taskkill /T。 */
10
+ /** 超时、输出超限都要杀整棵进程树:POSIX 杀进程组,Windows 用 taskkill /T。 */
8
11
  function killTree(pid) {
9
12
  try {
10
13
  if (process.platform === 'win32') {
@@ -26,8 +29,10 @@ function spawnGate(command, opts) {
26
29
  // gate 是用户在 team.yaml 里写的完整 shell 命令行,必须经 shell 解释(2A spec 第 3 节约束 6,总纲 §2.4);全项目只此一处。
27
30
  const child = spawn(command, { shell: true, cwd: opts.cwd, detached: process.platform !== 'win32', windowsHide: true });
28
31
  const chunks = [];
32
+ let kept = 0;
29
33
  let timedOut = false;
30
34
  let interrupted = false;
35
+ let overflowed = false;
31
36
  // 运行期间 Ctrl-C / SIGTERM:把 gate 的整个进程组一起杀掉,不留孤儿
32
37
  const onSignal = () => {
33
38
  interrupted = true;
@@ -42,16 +47,35 @@ function spawnGate(command, opts) {
42
47
  SIGNALS.forEach((s) => process.off(s, onSignal));
43
48
  };
44
49
  SIGNALS.forEach((s) => process.on(s, onSignal));
45
- child.stdout.on('data', (d) => chunks.push(d));
46
- child.stderr.on('data', (d) => chunks.push(d));
50
+ /** 只留尾部:超过上限就丢最前面的块,并终止 gate(整份输出既没必要也存不住)。 */
51
+ const collect = (d) => {
52
+ chunks.push(d);
53
+ kept += d.length;
54
+ if (kept <= GATE_OUTPUT_LIMIT_BYTES)
55
+ return;
56
+ if (!overflowed) {
57
+ overflowed = true;
58
+ killTree(child.pid);
59
+ }
60
+ while (chunks.length > 1 && kept > GATE_OUTPUT_LIMIT_BYTES)
61
+ kept -= chunks.shift().length;
62
+ };
63
+ child.stdout.on('data', collect);
64
+ child.stderr.on('data', collect);
47
65
  child.on('error', (err) => {
48
66
  cleanup();
49
67
  resolve({ passed: false, output: `无法启动 gate:${err.message}`, timedOut: false });
50
68
  });
51
69
  child.on('close', (code) => {
52
70
  cleanup();
53
- const output = Buffer.concat(chunks).toString('utf8') + (timedOut ? TIMEOUT_MARK : '');
54
- resolve({ passed: code === 0 && !timedOut && !interrupted, output, timedOut, ...(interrupted ? { interrupted } : {}) });
71
+ const output = Buffer.concat(chunks).toString('utf8') + (timedOut ? TIMEOUT_MARK : '') + (overflowed ? OVERFLOW_MARK : '');
72
+ resolve({
73
+ passed: code === 0 && !timedOut && !interrupted && !overflowed,
74
+ output,
75
+ timedOut,
76
+ ...(interrupted ? { interrupted } : {}),
77
+ ...(overflowed ? { overflowed } : {}),
78
+ });
55
79
  });
56
80
  });
57
81
  }
@@ -1,4 +1,4 @@
1
- import type { CommandDeps } from '../deps.js';
1
+ import { type CommandDeps } from '../deps.js';
2
2
  export type ScreenState = 'clear' | 'at_prompt' | 'shell' | 'unknown';
3
3
  /** 逐条对应 spec 第 7.4 节:agent 或包装器正停在需要人确认的画面上。 */
4
4
  export declare const PROMPT_PATTERNS: readonly RegExp[];
@@ -1,4 +1,5 @@
1
1
  import { WinglineError } from '../../errors.js';
2
+ import { WAIT_SUBMIT_SEC } from '../deps.js';
2
3
  import { locatePost } from '../locate.js';
3
4
  /** 逐条对应 spec 第 7.4 节:agent 或包装器正停在需要人确认的画面上。 */
4
5
  export const PROMPT_PATTERNS = [
@@ -19,7 +20,6 @@ export const PROMPT_PATTERNS = [
19
20
  /** 终端已退回 shell:在那里打字会执行命令。只看最后一个非空行。 */
20
21
  const SHELL_PROMPT = /(?:(?:[$#]|(?<!\d)%)\s*$)|(?:^PS .*>\s*$)|(?:^[A-Za-z]:\\.*>\s*$)/;
21
22
  export const SCREEN_TAIL_LINES = 8;
22
- const WAIT_SUBMIT_SEC = 10;
23
23
  /** 纯函数:取 preview 最后 8 行,任一行命中 PROMPT_PATTERNS → at_prompt;否则最后一个非空行像 shell 提示符 → shell;空 → unknown;其余 clear。 */
24
24
  export function classifyPreview(preview) {
25
25
  const lines = preview.split('\n');
@@ -56,6 +56,8 @@ export declare function showTask(deps: CommandDeps, a: {
56
56
  history?: boolean;
57
57
  }): Promise<ShownTask>;
58
58
  export type StaleReason = 'done_unfinished' | 'call_uncertain' | 'dead_assignee' | 'owner_idle' | 'bypassed' | 'blocked_waiting';
59
+ /** 卡住原因的短标签:TUI 与文档共用(控制台前端只 import type,标签在 web/src/labels.ts 另放一份)。 */
60
+ export declare const STALE_REASON_LABEL: Record<StaleReason, string>;
59
61
  export interface StaleItem {
60
62
  taskId: string;
61
63
  title: string;
@@ -116,6 +116,15 @@ export async function showTask(deps, a) {
116
116
  ...(a.history ? { history: deps.registry.listTaskLog(a.id) } : {}),
117
117
  };
118
118
  }
119
+ /** 卡住原因的短标签:TUI 与文档共用(控制台前端只 import type,标签在 web/src/labels.ts 另放一份)。 */
120
+ export const STALE_REASON_LABEL = {
121
+ done_unfinished: '已完成但未收尾',
122
+ call_uncertain: '调用结果不确定',
123
+ dead_assignee: '派发失主',
124
+ owner_idle: '主人迟迟未开始',
125
+ bypassed: '绕过闸门上报',
126
+ blocked_waiting: '等待决策',
127
+ };
119
128
  export const DEFAULT_STALE = { ownerIdleMinutes: 30, uncertainMinutes: 1 };
120
129
  const MINUTE_MS = 60_000;
121
130
  const as = (owner) => owner ?? '<主人>';
@@ -130,7 +139,7 @@ function doneUnfinished(x) {
130
139
  });
131
140
  }
132
141
  /** 账本键 → 任务与重跑命令;oneshot:* 不归任何任务。 */
133
- function commandOfKey(x, key, t) {
142
+ function commandOfKey(key, t) {
134
143
  const [kind] = key.split(':');
135
144
  if (kind === 'dispatch')
136
145
  return `wl task start ${t.id} --as ${as(t.claimedBy)}`;
@@ -178,7 +187,7 @@ function callUncertain(x, now, th) {
178
187
  return [];
179
188
  if (task && epochOfKey(r.id) < task.epoch)
180
189
  return [];
181
- const command = task ? commandOfKey(x, r.id, task) : null;
190
+ const command = task ? commandOfKey(r.id, task) : null;
182
191
  return task && command ? [item(task, 'call_uncertain', r.updatedAt, command)] : [];
183
192
  });
184
193
  }
@@ -101,6 +101,8 @@ export interface PatrolStateRow {
101
101
  detail?: Record<string, unknown> | null;
102
102
  }
103
103
  export interface FrozenRow {
104
+ /** 冻结行自带的团队名:被冻结的可以是没登记的终端,所以别从地址里反推。 */
105
+ team: string;
104
106
  post: string;
105
107
  reason: string;
106
108
  signal: string | null;
@@ -283,6 +285,11 @@ export declare class Registry {
283
285
  id: number;
284
286
  at: string;
285
287
  }) | null;
288
+ /** 该信号主体最近一次 notify 事件(不受巡查事件保留窗口影响);task_id 为 NULL 的主体与空串比较。 */
289
+ lastNotifyEventOf(team: string, post: string, signal: string, taskId: string): (PatrolEvent & {
290
+ id: number;
291
+ at: string;
292
+ }) | null;
286
293
  appendAudit(r: Omit<AuditRow, 'id' | 'at' | 'detail'> & {
287
294
  at?: string;
288
295
  detail?: unknown;
@@ -322,6 +329,12 @@ export declare class Registry {
322
329
  /** team 是否写过 actions 之一的事件。 */
323
330
  hasPatrolEventOf(team: string, actions: readonly string[]): boolean;
324
331
  prunePatrolEvents(before: string): number;
332
+ /**
333
+ * 已结束状态的日常清理:已清除的巡查行、已回答的提问、已定案且过保留期的任务账本行。
334
+ * 不动:pending/uncertain 行(可能还在重放)、代数键(restore:*、<team>:<post>:*,删了会让代数从头数起)、
335
+ * done:*:delivered:* 交付行(解冻时按冻结时间点数被跳过的任务)。
336
+ */
337
+ pruneFinishedState(before: string): void;
325
338
  /** finished_at > after(null 表示不限)的已完成判定,按 finished_at 升序。 */
326
339
  listFinishedVerdictsAfter(post: string, after: string | null): DoneVerdictRow[];
327
340
  /** 2A done 写下的交付记录(requests 里 done:%:delivered:<target> 且 done 的行)。 */
@@ -74,7 +74,7 @@ const patrolRow = (r) => ({
74
74
  detail: r.detail === null || r.detail === undefined ? null : JSON.parse(r.detail),
75
75
  });
76
76
  const frozenRow = (r) => ({
77
- post: r.post, reason: r.reason, signal: r.signal, frozenAt: r.frozen_at,
77
+ team: r.team, post: r.post, reason: r.reason, signal: r.signal, frozenAt: r.frozen_at,
78
78
  });
79
79
  const questionRow = (r) => ({
80
80
  team: r.team,
@@ -466,7 +466,9 @@ export class Registry {
466
466
  }
467
467
  /** unfreeze 用:该岗位所有巡查行回到 level 0 并标记已清除(spec 第 7 节)。画面信号的行不动:解冻不该清掉用量暂停,也不该在提示仍留屏时让它重新通知一次。 */
468
468
  resetPatrolForPost(post, at) {
469
- this.db.prepare("UPDATE patrol_state SET level = 0, cleared_at = ?, last_nudged_at = NULL WHERE post = ? AND signal NOT IN ('context_low', 'usage_limit')").run(at, post);
469
+ this.db
470
+ .prepare("UPDATE patrol_state SET level = 0, cleared_at = ?, last_nudged_at = NULL, notify_count = 0, next_due_at = NULL WHERE post = ? AND signal NOT IN ('context_low', 'usage_limit')")
471
+ .run(at, post);
470
472
  }
471
473
  freezePost(a) {
472
474
  this.db
@@ -552,6 +554,13 @@ export class Registry {
552
554
  const r = this.db.prepare(`SELECT * FROM patrol_events WHERE team = ? AND action IN (${marks}) ORDER BY id DESC LIMIT 1`).get(team, ...actions);
553
555
  return r ? eventRow(r) : null;
554
556
  }
557
+ /** 该信号主体最近一次 notify 事件(不受巡查事件保留窗口影响);task_id 为 NULL 的主体与空串比较。 */
558
+ lastNotifyEventOf(team, post, signal, taskId) {
559
+ const r = this.db
560
+ .prepare("SELECT * FROM patrol_events WHERE team = ? AND action = 'notify' AND post = ? AND signal = ? AND COALESCE(task_id, '') = ? ORDER BY id DESC LIMIT 1")
561
+ .get(team, post, signal, taskId);
562
+ return r ? eventRow(r) : null;
563
+ }
555
564
  // —— 阶段 3A:审计事件、团队任务日志、时间线游标 ——
556
565
  // 三者共用 pageRows 的分页语义:id 升序;不带 afterId 取最新 limit 条;带 afterId 取游标之后最早的 limit 条。
557
566
  appendAudit(r) {
@@ -627,6 +636,18 @@ export class Registry {
627
636
  prunePatrolEvents(before) {
628
637
  return Number(this.db.prepare('DELETE FROM patrol_events WHERE at < ?').run(before).changes);
629
638
  }
639
+ /**
640
+ * 已结束状态的日常清理:已清除的巡查行、已回答的提问、已定案且过保留期的任务账本行。
641
+ * 不动:pending/uncertain 行(可能还在重放)、代数键(restore:*、<team>:<post>:*,删了会让代数从头数起)、
642
+ * done:*:delivered:* 交付行(解冻时按冻结时间点数被跳过的任务)。
643
+ */
644
+ pruneFinishedState(before) {
645
+ this.db.prepare('DELETE FROM patrol_state WHERE cleared_at IS NOT NULL AND cleared_at < ?').run(before);
646
+ this.db.prepare('DELETE FROM questions WHERE answered_at IS NOT NULL AND answered_at < ?').run(before);
647
+ this.db.prepare(`DELETE FROM requests WHERE state = 'done' AND updated_at < ?
648
+ AND (id LIKE 'dispatch:%' OR id LIKE 'recover:%' OR id LIKE 'resolve:%' OR id LIKE 'oneshot:%'
649
+ OR (id LIKE 'done:%' AND id NOT LIKE 'done:%:delivered:%'))`).run(before);
650
+ }
630
651
  /** finished_at > after(null 表示不限)的已完成判定,按 finished_at 升序。 */
631
652
  listFinishedVerdictsAfter(post, after) {
632
653
  const sql = 'SELECT * FROM task_done WHERE post = ? AND finished_at IS NOT NULL AND finished_at > ? ORDER BY finished_at, task_id';
@@ -1,7 +1,8 @@
1
1
  import { checkParam } from './http.js';
2
+ import { WinglineError } from '../errors.js';
2
3
  import { ps } from '../commands/ps.js';
3
4
  import { dispatcherHealth } from '../commands/dispatcher/lifecycle.js';
4
- import { attention, dispatcherStatus } from '../commands/dispatcher/attention.js';
5
+ import { attention, dispatcherStatus, pendingCount } from '../commands/dispatcher/attention.js';
5
6
  import { signalSummary } from '../commands/dispatcher/signal-summary.js';
6
7
  import { listTasks, showTask } from '../commands/task/query.js';
7
8
  import { listStale } from '../commands/task/query.js';
@@ -12,17 +13,11 @@ async function teamSummary(ctx, name) {
12
13
  const posts = await ps(ctx.deps, { team: name });
13
14
  const disp = dispatcherHealth(ctx.deps, name).state;
14
15
  const att = await attention(ctx.deps, name);
15
- const attentionCount = (disp === 'stale' ? 1 : 0) +
16
- att.frozen.length +
17
- att.signals.length +
18
- att.questions.length +
19
- att.stale.length +
20
- att.warnings.length;
21
16
  return {
22
17
  name,
23
18
  postsCount: posts.length,
24
19
  dispatcher: disp,
25
- attentionCount,
20
+ attentionCount: pendingCount(att),
26
21
  };
27
22
  }
28
23
  function registerTeamRoutes(r) {
@@ -140,7 +135,7 @@ function registerPostRoutes(r) {
140
135
  const posts = await ps(ctx.deps, { team });
141
136
  const pRow = posts.find((p) => p.address === address);
142
137
  if (!pRow) {
143
- throw new (await import('../errors.js')).WinglineError('input', 'invalid_param', `岗位 ${address} 不存在`);
138
+ throw new WinglineError('input', 'invalid_param', `岗位 ${address} 不存在`);
144
139
  }
145
140
  return await buildPostDetail(ctx, team, address, pRow);
146
141
  });
@@ -154,8 +149,15 @@ function registerLogRoute(r) {
154
149
  const post = ctx.query.get('post') ?? undefined;
155
150
  const since = ctx.query.get('since') ?? undefined;
156
151
  const source = ctx.query.get('source') ?? undefined;
152
+ // 与 wl log 的 --limit 同一口径:非正整数按用法错报,而不是 NaN 静默读空
157
153
  const limitStr = ctx.query.get('limit');
158
- const limit = limitStr ? parseInt(limitStr, 10) : undefined;
154
+ let limit;
155
+ if (limitStr !== null) {
156
+ limit = Number(limitStr);
157
+ if (!(Number.isInteger(limit) && limit >= 1)) {
158
+ throw new WinglineError('input', 'limit_invalid', `limit ${limitStr} 无效:应为正整数`);
159
+ }
160
+ }
159
161
  return await readTimeline(ctx.deps, { team, task, post, since, source, limit });
160
162
  });
161
163
  }
@@ -183,7 +183,15 @@ async function handleSse(req, res, team, sess, feeds, sseClients, warn) {
183
183
  sseClients.delete(res);
184
184
  });
185
185
  const lastEventId = req.headers['last-event-id'];
186
- unsub = await feeds.subscribe(team, (chunk) => res.write(chunk), lastEventId);
186
+ // 背压:对端不读时 res.write 返回 false,继续写会把事件无限堆在内存里——直接断开;
187
+ // EventSource 会带 Last-Event-ID 自动重连,从断点补回,不丢事件。
188
+ const write = (chunk) => {
189
+ if (res.write(chunk))
190
+ return;
191
+ unsub?.();
192
+ res.destroy();
193
+ };
194
+ unsub = await feeds.subscribe(team, write, lastEventId);
187
195
  if (closed || req.destroyed || res.destroyed) {
188
196
  unsub();
189
197
  sseClients.delete(res);
@@ -196,7 +204,7 @@ async function handleSse(req, res, team, sess, feeds, sseClients, warn) {
196
204
  });
197
205
  res.flushHeaders();
198
206
  sseClients.add(res);
199
- feeds.replaySnapshot(team, (chunk) => res.write(chunk));
207
+ feeds.replaySnapshot(team, write);
200
208
  }
201
209
  catch (err) {
202
210
  respondError(res, err, warn, `GET /api/teams/${team}/events`);
@@ -1,7 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { currentCursor, readTimelineAfter } from '../commands/log.js';
3
3
  import { ps } from '../commands/ps.js';
4
- import { attention } from '../commands/dispatcher/attention.js';
4
+ import { attention, pendingCount } from '../commands/dispatcher/attention.js';
5
5
  import { dispatcherHealth } from '../commands/dispatcher/lifecycle.js';
6
6
  export function cursorFrom(lastEventId, start) {
7
7
  if (!lastEventId)
@@ -49,6 +49,8 @@ export class TeamFeeds {
49
49
  pingTimer: null,
50
50
  lastSnapshotHash: null,
51
51
  lastSnapshot: null,
52
+ timelineBusy: false,
53
+ snapshotBusy: false,
52
54
  };
53
55
  this.loops.set(team, loop);
54
56
  this.startLoop(team, loop);
@@ -78,11 +80,27 @@ export class TeamFeeds {
78
80
  startLoop(team, loop) {
79
81
  // 1. timeline loop (2s)
80
82
  loop.timelineTimer = this.setTimer(async () => {
81
- await this.tickTimeline(team, loop);
83
+ if (loop.timelineBusy)
84
+ return;
85
+ loop.timelineBusy = true;
86
+ try {
87
+ await this.tickTimeline(team, loop);
88
+ }
89
+ finally {
90
+ loop.timelineBusy = false;
91
+ }
82
92
  }, this.timelineMs);
83
93
  // 2. snapshot loop (5s)
84
94
  loop.snapshotTimer = this.setTimer(async () => {
85
- await this.tickSnapshot(team, loop);
95
+ if (loop.snapshotBusy)
96
+ return;
97
+ loop.snapshotBusy = true;
98
+ try {
99
+ await this.tickSnapshot(team, loop);
100
+ }
101
+ finally {
102
+ loop.snapshotBusy = false;
103
+ }
86
104
  }, this.snapshotMs);
87
105
  // 3. ping loop (15s)
88
106
  loop.pingTimer = this.setTimer(() => {
@@ -118,13 +136,7 @@ export class TeamFeeds {
118
136
  team,
119
137
  posts,
120
138
  dispatcher: disp,
121
- attentionCounts: {
122
- frozen: att.frozen.length,
123
- signals: att.signals.length,
124
- questions: att.questions.length,
125
- stale: att.stale.length,
126
- warnings: att.warnings.length,
127
- },
139
+ attention: pendingCount(att),
128
140
  };
129
141
  const serialized = JSON.stringify(data);
130
142
  const hash = crypto.createHash('sha256').update(serialized).digest('hex');
@@ -18,10 +18,11 @@ function snakeKeys(v) {
18
18
  return v;
19
19
  return Object.fromEntries(Object.entries(v).map(([k, x]) => [snake(k), snakeKeys(x)]));
20
20
  }
21
- /** 把 TeamSpec 还原成 YAML 里的字段形态,供按字段比较。 */
21
+ /** 把 TeamSpec 还原成 YAML 里的字段形态,供按字段比较。岗位级字段(plan_first / self_claim / tags)也进哈希,预演必须同样列出。 */
22
22
  function fieldView(s) {
23
23
  const links = {};
24
24
  const gates = {};
25
+ const posts = {};
25
26
  for (const p of s.posts) {
26
27
  const l = { reviewed_by: p.reviewedBy, hands_to: p.handsTo, consults: p.consults ?? [] };
27
28
  const filled = Object.fromEntries(Object.entries(l).filter(([, v]) => v.length > 0));
@@ -29,8 +30,9 @@ function fieldView(s) {
29
30
  links[p.id] = filled;
30
31
  if (p.gate)
31
32
  gates[p.id] = p.gate;
33
+ posts[p.id] = { plan_first: p.planFirst, self_claim: p.selfClaim, tags: p.tags };
32
34
  }
33
- return { lead: s.lead, links, gates, budgets: snakeKeys(s.budgets), patrol: snakeKeys(s.patrol) };
35
+ return { lead: s.lead, links, gates, budgets: snakeKeys(s.budgets), patrol: snakeKeys(s.patrol), posts };
34
36
  }
35
37
  function flatten(v, prefix, out) {
36
38
  if (v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weiping/wingline-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Wingline 核心:在 Orca 之上运行常驻 agent 团队的引擎(注册表、编排、巡查、恢复)",
5
5
  "license": "MIT",
6
6
  "type": "module",