@mebular/fleet 0.0.0-stage → 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.
Files changed (178) hide show
  1. package/dist/agentdir.d.ts +43 -0
  2. package/dist/agentdir.d.ts.map +1 -0
  3. package/dist/agentdir.js +107 -0
  4. package/dist/agentdir.js.map +1 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +1135 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/collab/chatter.d.ts +54 -0
  10. package/dist/collab/chatter.d.ts.map +1 -0
  11. package/dist/collab/chatter.js +94 -0
  12. package/dist/collab/chatter.js.map +1 -0
  13. package/dist/collab/dag.d.ts +27 -0
  14. package/dist/collab/dag.d.ts.map +1 -0
  15. package/dist/collab/dag.js +107 -0
  16. package/dist/collab/dag.js.map +1 -0
  17. package/dist/collab/negotiation.d.ts +60 -0
  18. package/dist/collab/negotiation.d.ts.map +1 -0
  19. package/dist/collab/negotiation.js +90 -0
  20. package/dist/collab/negotiation.js.map +1 -0
  21. package/dist/collab/tree.d.ts +39 -0
  22. package/dist/collab/tree.d.ts.map +1 -0
  23. package/dist/collab/tree.js +177 -0
  24. package/dist/collab/tree.js.map +1 -0
  25. package/dist/config.d.ts +123 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +206 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +40 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/join.d.ts +50 -0
  34. package/dist/join.d.ts.map +1 -0
  35. package/dist/join.js +167 -0
  36. package/dist/join.js.map +1 -0
  37. package/dist/jointoken.d.ts +144 -0
  38. package/dist/jointoken.d.ts.map +1 -0
  39. package/dist/jointoken.js +472 -0
  40. package/dist/jointoken.js.map +1 -0
  41. package/dist/model.d.ts +64 -0
  42. package/dist/model.d.ts.map +1 -0
  43. package/dist/model.js +158 -0
  44. package/dist/model.js.map +1 -0
  45. package/dist/onboard.d.ts +90 -0
  46. package/dist/onboard.d.ts.map +1 -0
  47. package/dist/onboard.js +510 -0
  48. package/dist/onboard.js.map +1 -0
  49. package/dist/protocol/envelope.d.ts +94 -0
  50. package/dist/protocol/envelope.d.ts.map +1 -0
  51. package/dist/protocol/envelope.js +169 -0
  52. package/dist/protocol/envelope.js.map +1 -0
  53. package/dist/protocol/events.d.ts +51 -0
  54. package/dist/protocol/events.d.ts.map +1 -0
  55. package/dist/protocol/events.js +87 -0
  56. package/dist/protocol/events.js.map +1 -0
  57. package/dist/qr.d.ts +15 -0
  58. package/dist/qr.d.ts.map +1 -0
  59. package/dist/qr.js +52 -0
  60. package/dist/qr.js.map +1 -0
  61. package/dist/quickstart.d.ts +167 -0
  62. package/dist/quickstart.d.ts.map +1 -0
  63. package/dist/quickstart.js +482 -0
  64. package/dist/quickstart.js.map +1 -0
  65. package/dist/quota.d.ts +41 -0
  66. package/dist/quota.d.ts.map +1 -0
  67. package/dist/quota.js +84 -0
  68. package/dist/quota.js.map +1 -0
  69. package/dist/runtime/agent.d.ts +100 -0
  70. package/dist/runtime/agent.d.ts.map +1 -0
  71. package/dist/runtime/agent.js +260 -0
  72. package/dist/runtime/agent.js.map +1 -0
  73. package/dist/runtime/executor.d.ts +42 -0
  74. package/dist/runtime/executor.d.ts.map +1 -0
  75. package/dist/runtime/executor.js +87 -0
  76. package/dist/runtime/executor.js.map +1 -0
  77. package/dist/runtime/fairness.d.ts +25 -0
  78. package/dist/runtime/fairness.d.ts.map +1 -0
  79. package/dist/runtime/fairness.js +79 -0
  80. package/dist/runtime/fairness.js.map +1 -0
  81. package/dist/runtime/node.d.ts +91 -0
  82. package/dist/runtime/node.d.ts.map +1 -0
  83. package/dist/runtime/node.js +203 -0
  84. package/dist/runtime/node.js.map +1 -0
  85. package/dist/runtime/openchamber-http.d.ts +27 -0
  86. package/dist/runtime/openchamber-http.d.ts.map +1 -0
  87. package/dist/runtime/openchamber-http.js +118 -0
  88. package/dist/runtime/openchamber-http.js.map +1 -0
  89. package/dist/runtime/openchamber.d.ts +38 -0
  90. package/dist/runtime/openchamber.d.ts.map +1 -0
  91. package/dist/runtime/openchamber.js +43 -0
  92. package/dist/runtime/openchamber.js.map +1 -0
  93. package/dist/runtime/planner.d.ts +23 -0
  94. package/dist/runtime/planner.d.ts.map +1 -0
  95. package/dist/runtime/planner.js +17 -0
  96. package/dist/runtime/planner.js.map +1 -0
  97. package/dist/runtime/worker.d.ts +96 -0
  98. package/dist/runtime/worker.d.ts.map +1 -0
  99. package/dist/runtime/worker.js +296 -0
  100. package/dist/runtime/worker.js.map +1 -0
  101. package/dist/security.d.ts +462 -0
  102. package/dist/security.d.ts.map +1 -0
  103. package/dist/security.js +1197 -0
  104. package/dist/security.js.map +1 -0
  105. package/dist/store/daemon-store.d.ts +33 -0
  106. package/dist/store/daemon-store.d.ts.map +1 -0
  107. package/dist/store/daemon-store.js +115 -0
  108. package/dist/store/daemon-store.js.map +1 -0
  109. package/dist/store/file-store.d.ts +22 -0
  110. package/dist/store/file-store.d.ts.map +1 -0
  111. package/dist/store/file-store.js +63 -0
  112. package/dist/store/file-store.js.map +1 -0
  113. package/dist/store/mebular-store.d.ts +22 -0
  114. package/dist/store/mebular-store.d.ts.map +1 -0
  115. package/dist/store/mebular-store.js +53 -0
  116. package/dist/store/mebular-store.js.map +1 -0
  117. package/dist/store/message-store.d.ts +26 -0
  118. package/dist/store/message-store.d.ts.map +1 -0
  119. package/dist/store/message-store.js +51 -0
  120. package/dist/store/message-store.js.map +1 -0
  121. package/dist/surface.d.ts +43 -0
  122. package/dist/surface.d.ts.map +1 -0
  123. package/dist/surface.js +515 -0
  124. package/dist/surface.js.map +1 -0
  125. package/dist/transport/null.d.ts +7 -0
  126. package/dist/transport/null.d.ts.map +1 -0
  127. package/dist/transport/null.js +16 -0
  128. package/dist/transport/null.js.map +1 -0
  129. package/dist/transport/spool.d.ts +12 -0
  130. package/dist/transport/spool.d.ts.map +1 -0
  131. package/dist/transport/spool.js +54 -0
  132. package/dist/transport/spool.js.map +1 -0
  133. package/dist/transport/types.d.ts +21 -0
  134. package/dist/transport/types.d.ts.map +1 -0
  135. package/dist/transport/types.js +6 -0
  136. package/dist/transport/types.js.map +1 -0
  137. package/dist/version.json +6 -0
  138. package/package.json +37 -4
  139. package/protocol/collab.example.json +23 -0
  140. package/protocol/daemon-home.example.json +66 -0
  141. package/protocol/envelope.example.json +12 -0
  142. package/protocol/state-machine.json +14 -0
  143. package/protocol/task-tree.example.json +72 -0
  144. package/src/agentdir.ts +138 -0
  145. package/src/cli.ts +1192 -0
  146. package/src/collab/chatter.ts +116 -0
  147. package/src/collab/dag.ts +124 -0
  148. package/src/collab/negotiation.ts +130 -0
  149. package/src/collab/tree.ts +184 -0
  150. package/src/config.ts +286 -0
  151. package/src/index.ts +39 -0
  152. package/src/join.ts +227 -0
  153. package/src/jointoken.ts +609 -0
  154. package/src/model.ts +203 -0
  155. package/src/onboard.ts +601 -0
  156. package/src/protocol/envelope.ts +228 -0
  157. package/src/protocol/events.ts +135 -0
  158. package/src/qr.ts +68 -0
  159. package/src/quickstart.ts +632 -0
  160. package/src/quota.ts +112 -0
  161. package/src/runtime/agent.ts +313 -0
  162. package/src/runtime/executor.ts +114 -0
  163. package/src/runtime/fairness.ts +90 -0
  164. package/src/runtime/node.ts +250 -0
  165. package/src/runtime/openchamber-http.ts +137 -0
  166. package/src/runtime/openchamber.ts +66 -0
  167. package/src/runtime/planner.ts +36 -0
  168. package/src/runtime/worker.ts +354 -0
  169. package/src/security.ts +1660 -0
  170. package/src/store/daemon-store.ts +144 -0
  171. package/src/store/file-store.ts +75 -0
  172. package/src/store/mebular-store.ts +66 -0
  173. package/src/store/message-store.ts +62 -0
  174. package/src/surface.ts +535 -0
  175. package/src/transport/null.ts +20 -0
  176. package/src/transport/spool.ts +62 -0
  177. package/src/transport/types.ts +27 -0
  178. package/README.md +0 -3
package/src/quota.ts ADDED
@@ -0,0 +1,112 @@
1
+ // Fleet 本地配额(M1):**每设备对自己发出的量本地记账**,超额本地拒绝或排队。
2
+ //
3
+ // 禁中心化:无注册服务、无全局账本、无跨端对账;不同设备的配额互不影响(见 DESIGN.md §4)。
4
+
5
+ /** 准入判定。 */
6
+ export type QuotaDecision = 'accepted' | 'queued' | 'rejected';
7
+
8
+ /** 每设备快照。 */
9
+ export interface QuotaSnapshot {
10
+ device: string;
11
+ used: number;
12
+ queued: number;
13
+ rejected: number;
14
+ }
15
+
16
+ /** 构造选项。 */
17
+ export interface LocalQuotaOptions {
18
+ /** 每设备(对**自己发出**)的上限 */
19
+ limitPerDevice: number;
20
+ /** 超额策略:`queue` 进本地队列;`reject` 直接拒绝。默认 `queue`。 */
21
+ onOverflow?: 'queue' | 'reject';
22
+ }
23
+
24
+ function assertAmount(amount: number): void {
25
+ if (!Number.isInteger(amount) || amount <= 0) {
26
+ throw new Error(`quota amount 必须为正整数:${amount}`);
27
+ }
28
+ }
29
+
30
+ /** 每设备本地配额账本(确定性、纯本地)。 */
31
+ export class LocalQuota {
32
+ private readonly limit: number;
33
+ private readonly overflow: 'queue' | 'reject';
34
+ private readonly usedMap = new Map<string, number>();
35
+ private readonly queuedMap = new Map<string, number>();
36
+ private readonly rejectedMap = new Map<string, number>();
37
+
38
+ constructor(options: LocalQuotaOptions) {
39
+ if (!Number.isInteger(options.limitPerDevice) || options.limitPerDevice <= 0) {
40
+ throw new Error(`limitPerDevice 必须为正整数:${options.limitPerDevice}`);
41
+ }
42
+ this.limit = options.limitPerDevice;
43
+ this.overflow = options.onOverflow ?? 'queue';
44
+ }
45
+
46
+ private static bump(map: Map<string, number>, key: string, amount: number): void {
47
+ map.set(key, (map.get(key) ?? 0) + amount);
48
+ }
49
+
50
+ /** 请求发出 `amount` 单位:返回本机判定(`accepted` / `queued` / `rejected`)。 */
51
+ decide(device: string, amount = 1): QuotaDecision {
52
+ assertAmount(amount);
53
+ const used = this.usedMap.get(device) ?? 0;
54
+ if (used + amount <= this.limit) {
55
+ LocalQuota.bump(this.usedMap, device, amount);
56
+ return 'accepted';
57
+ }
58
+ if (this.overflow === 'reject') {
59
+ LocalQuota.bump(this.rejectedMap, device, amount);
60
+ return 'rejected';
61
+ }
62
+ LocalQuota.bump(this.queuedMap, device, amount);
63
+ return 'queued';
64
+ }
65
+
66
+ /** 容量释放(如队列/在途被消费):从 `used` 扣减(不为负)。 */
67
+ release(device: string, amount = 1): void {
68
+ assertAmount(amount);
69
+ const used = this.usedMap.get(device) ?? 0;
70
+ this.usedMap.set(device, Math.max(0, used - amount));
71
+ }
72
+
73
+ /**
74
+ * 把本地队列里的量搬迁到 `used`(容量允许时);返回搬迁成功的量。
75
+ * 仅本地排队消化,无跨端协调。
76
+ */
77
+ drainQueue(device: string, capacity = this.limit): number {
78
+ if (!Number.isInteger(capacity) || capacity < 0) throw new Error(`capacity 非法:${capacity}`);
79
+ const queued = this.queuedMap.get(device) ?? 0;
80
+ const used = this.usedMap.get(device) ?? 0;
81
+ const room = Math.max(0, capacity - used);
82
+ const moved = Math.min(queued, room);
83
+ if (moved > 0) {
84
+ this.queuedMap.set(device, queued - moved);
85
+ this.usedMap.set(device, used + moved);
86
+ }
87
+ return moved;
88
+ }
89
+
90
+ used(device: string): number {
91
+ return this.usedMap.get(device) ?? 0;
92
+ }
93
+
94
+ queued(device: string): number {
95
+ return this.queuedMap.get(device) ?? 0;
96
+ }
97
+
98
+ rejected(device: string): number {
99
+ return this.rejectedMap.get(device) ?? 0;
100
+ }
101
+
102
+ /** 每设备快照(按 device 字典序,确定性)。 */
103
+ snapshot(): QuotaSnapshot[] {
104
+ const devices = new Set<string>([...this.usedMap.keys(), ...this.queuedMap.keys(), ...this.rejectedMap.keys()]);
105
+ return [...devices].sort().map((device) => ({
106
+ device,
107
+ used: this.usedMap.get(device) ?? 0,
108
+ queued: this.queuedMap.get(device) ?? 0,
109
+ rejected: this.rejectedMap.get(device) ?? 0,
110
+ }));
111
+ }
112
+ }
@@ -0,0 +1,313 @@
1
+ // Fleet Agent 适配器(M4):按 `to.agent` 名字路由到可插拔执行器。
2
+ //
3
+ // 安全与工程约束:
4
+ // - **参数数组传参**(绝不 shell 拼接,防注入);
5
+ // - 超时 / 非零退出 / 输出超限 各自语义明确(超时与非零 → 任务 `failed` 带原因);
6
+ // - **不继承 daemon 的全量 env**:只透传 `ENV_ALLOWLIST`(PATH/HOME/… 运行所需)+ 调用方显式
7
+ // 给出的 `options.env`;凭据/令牌不外流。同时不打印/不落盘任何 env 值;
8
+ // - 工作目录与并发上限可配。
9
+
10
+ import { spawn, type ChildProcess } from 'node:child_process';
11
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import type { TaskState } from '../model.js';
15
+ import type { ExecutionOutcome, TaskExecutor } from './executor.js';
16
+
17
+ /** 按 agent 名字选择执行器;**未知 agent 返回 null**(调用方须显式失败,不得静默回退)。 */
18
+ export class ExecutorRegistry {
19
+ private readonly exact = new Map<string, TaskExecutor>();
20
+ private wildcard: TaskExecutor | null = null;
21
+
22
+ /** 注册某 agent 名;`'*'` 为通配(仅匹配 `to.agent === '*'`)。 */
23
+ register(agent: string, executor: TaskExecutor): this {
24
+ if (agent === '*') this.wildcard = executor;
25
+ else this.exact.set(agent, executor);
26
+ return this;
27
+ }
28
+
29
+ resolve(agent: string): TaskExecutor | null {
30
+ if (agent === '*') return this.wildcard;
31
+ return this.exact.get(agent) ?? null; // 显式:不 fallback 到通配
32
+ }
33
+
34
+ has(agent: string): boolean {
35
+ return agent === '*' ? this.wildcard !== null : this.exact.has(agent);
36
+ }
37
+
38
+ names(): string[] {
39
+ return [...this.exact.keys()].sort();
40
+ }
41
+ }
42
+
43
+ /** UTF-8 安全截断(按字节),附截断标记。 */
44
+ export function truncateOutput(text: string, maxBytes: number): { text: string; truncated: boolean; totalBytes: number } {
45
+ const buf = Buffer.from(text, 'utf-8');
46
+ if (buf.byteLength <= maxBytes) return { text, truncated: false, totalBytes: buf.byteLength };
47
+ const head = buf.subarray(0, maxBytes).toString('utf-8');
48
+ return { text: `${head}\n…[truncated ${buf.byteLength - maxBytes} bytes]`, truncated: true, totalBytes: buf.byteLength };
49
+ }
50
+
51
+ /** 简单并发闸(确定性:FIFO)。 */
52
+ class Semaphore {
53
+ private active = 0;
54
+ private readonly waiters: Array<() => void> = [];
55
+ constructor(private readonly limit: number) {}
56
+ async acquire(): Promise<void> {
57
+ if (this.active < this.limit) {
58
+ this.active += 1;
59
+ return;
60
+ }
61
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
62
+ this.active += 1;
63
+ }
64
+ release(): void {
65
+ this.active -= 1;
66
+ const next = this.waiters.shift();
67
+ if (next) next();
68
+ }
69
+ }
70
+
71
+ export interface SpawnResult {
72
+ code: number | null;
73
+ /** 已捕获的 stdout(至多 `maxOutputBytes` 字节) */
74
+ stdout: string;
75
+ /** stdout 的**总字节数**(含未捕获部分) */
76
+ stdoutBytes: number;
77
+ stderrTail: string;
78
+ timedOut: boolean;
79
+ }
80
+
81
+ /**
82
+ * 允许透传给被派发 Agent 的环境变量**白名单**(非私密运行所需)。
83
+ * 其余(尤其凭据/令牌)**一律不继承**——daemon 的 env 不外流。
84
+ */
85
+ export const ENV_ALLOWLIST: readonly string[] = [
86
+ 'PATH',
87
+ 'HOME',
88
+ 'LANG',
89
+ 'LC_ALL',
90
+ 'LC_CTYPE',
91
+ 'TZ',
92
+ 'SHELL',
93
+ 'USER',
94
+ 'LOGNAME',
95
+ 'TMPDIR',
96
+ 'TEMP',
97
+ 'TMP',
98
+ 'HERMES_HOME', // Hermes profile 解析(非密)
99
+ // Windows 运行所需(存在才带)
100
+ 'SystemRoot',
101
+ 'PATHEXT',
102
+ 'COMSPEC',
103
+ 'USERPROFILE',
104
+ 'APPDATA',
105
+ 'LOCALAPPDATA',
106
+ 'ProgramData',
107
+ ];
108
+
109
+ /** 按白名单构建子进程 env;`extra`(调用方显式给出)覆盖白名单同名项。 */
110
+ export function buildChildEnv(
111
+ extra?: Readonly<Record<string, string>>,
112
+ source: NodeJS.ProcessEnv = process.env,
113
+ ): Record<string, string> {
114
+ const env: Record<string, string> = {};
115
+ for (const key of ENV_ALLOWLIST) {
116
+ const value = source[key];
117
+ if (value !== undefined) env[key] = value;
118
+ }
119
+ for (const [key, value] of Object.entries(extra ?? {})) env[key] = value;
120
+ return env;
121
+ }
122
+
123
+ /** 以参数数组执行命令;捕获 stdout/stderr(stderr 保留尾部);超时 kill。 */
124
+ export async function runOnce(options: {
125
+ command: string;
126
+ args: readonly string[];
127
+ cwd?: string;
128
+ env?: Readonly<Record<string, string>>;
129
+ timeoutMs: number;
130
+ maxOutputBytes: number;
131
+ }): Promise<SpawnResult> {
132
+ const limit = options.maxOutputBytes;
133
+ return new Promise<SpawnResult>((resolve) => {
134
+ let child: ChildProcess;
135
+ try {
136
+ child = spawn(options.command, [...options.args], {
137
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
138
+ // 只继承白名单 + 显式 env;不把 daemon 的全量环境(含凭据)传给被派发 Agent。
139
+ env: buildChildEnv(options.env),
140
+ stdio: ['ignore', 'pipe', 'pipe'],
141
+ });
142
+ } catch (error) {
143
+ resolve({ code: null, stdout: '', stdoutBytes: 0, stderrTail: (error as Error).message, timedOut: false });
144
+ return;
145
+ }
146
+ const outChunks: Buffer[] = [];
147
+ const errChunks: Buffer[] = [];
148
+ let outBytes = 0;
149
+ let totalOutBytes = 0;
150
+ let timedOut = false;
151
+ const timer = setTimeout(() => {
152
+ timedOut = true;
153
+ child.kill('SIGKILL');
154
+ }, Math.max(1, options.timeoutMs));
155
+
156
+ child.stdout?.on('data', (chunk: Buffer) => {
157
+ totalOutBytes += chunk.length;
158
+ if (outBytes < limit) {
159
+ // 按**字节**精确封顶:大 chunk 只取剩余额度,避免截断结果超过 maxOutputBytes。
160
+ const remaining = limit - outBytes;
161
+ const piece = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
162
+ outChunks.push(piece);
163
+ outBytes += piece.length;
164
+ }
165
+ });
166
+ child.stderr?.on('data', (chunk: Buffer) => {
167
+ errChunks.push(chunk);
168
+ while (errChunks.length > 0 && Buffer.concat(errChunks).length > 8192) errChunks.shift();
169
+ });
170
+ const finish = (code: number | null): void => {
171
+ clearTimeout(timer);
172
+ const stdout = Buffer.concat(outChunks).toString('utf-8');
173
+ const stderrTail = Buffer.concat(errChunks).toString('utf-8').trim().slice(-800);
174
+ resolve({ code, stdout, stdoutBytes: totalOutBytes, stderrTail, timedOut });
175
+ };
176
+ child.on('error', () => finish(null));
177
+ child.on('close', (code) => finish(code));
178
+ });
179
+ }
180
+
181
+ export interface CommandAgentOptions {
182
+ /** 可执行文件(不经 shell) */
183
+ command: string;
184
+ /** 命令后、prompt 参数前的固定参数 */
185
+ baseArgs?: readonly string[];
186
+ /** 由任务生成 prompt 相关参数(默认 `['-z', task.intent]`) */
187
+ promptArgs?: (task: TaskState) => string[];
188
+ cwd?: string;
189
+ /** 额外环境变量(值不会被打印/落盘) */
190
+ env?: Readonly<Record<string, string>>;
191
+ /** 超时(默认 60000ms) */
192
+ timeoutMs?: number;
193
+ /** stdout 截断上限字节(默认 65536) */
194
+ maxOutputBytes?: number;
195
+ /** 并发上限(默认 1) */
196
+ concurrency?: number;
197
+ /** 结果引用生成(默认:截断后的 stdout) */
198
+ resultRef?: (stdout: string, task: TaskState) => string;
199
+ }
200
+
201
+ /** 通用「命令行 Agent」执行器。 */
202
+ export class CommandAgent implements TaskExecutor {
203
+ private readonly sem: Semaphore;
204
+ constructor(private readonly options: CommandAgentOptions) {
205
+ this.sem = new Semaphore(Math.max(1, options.concurrency ?? 1));
206
+ }
207
+
208
+ async execute(task: TaskState): Promise<ExecutionOutcome> {
209
+ await this.sem.acquire();
210
+ try {
211
+ const args = [
212
+ ...(this.options.baseArgs ?? []),
213
+ ...(this.options.promptArgs ? this.options.promptArgs(task) : ['-z', task.intent]),
214
+ ];
215
+ const timeoutMs = this.options.timeoutMs ?? 60_000;
216
+ const maxOutputBytes = this.options.maxOutputBytes ?? 65_536;
217
+ const result = await runOnce({
218
+ command: this.options.command,
219
+ args,
220
+ ...(this.options.cwd !== undefined ? { cwd: this.options.cwd } : {}),
221
+ ...(this.options.env !== undefined ? { env: this.options.env } : {}),
222
+ timeoutMs,
223
+ maxOutputBytes,
224
+ });
225
+ if (result.timedOut) return { ok: false, reason: `TIMEOUT after ${timeoutMs}ms` };
226
+ if (result.code !== 0) {
227
+ return { ok: false, reason: `EXIT_${result.code}${result.stderrTail ? `: ${result.stderrTail}` : ''}` };
228
+ }
229
+ const stored = result.stdout.trim();
230
+ const text = result.stdoutBytes > maxOutputBytes ? `${stored}\n…[truncated ${result.stdoutBytes - maxOutputBytes} bytes]` : stored;
231
+ const resultRef = this.options.resultRef ? this.options.resultRef(text, task) : text;
232
+ return { ok: true, resultRef };
233
+ } finally {
234
+ this.sem.release();
235
+ }
236
+ }
237
+ }
238
+
239
+ export interface HermesAgentOptions {
240
+ /** hermes 可执行文件(默认 `hermes`) */
241
+ hermesPath?: string;
242
+ /** 命令后、hermes 参数前的固定参数(测试注入用;生产留空) */
243
+ commandArgs?: readonly string[];
244
+ /** Hermes profile(`-p`) */
245
+ profile?: string;
246
+ /** toolsets(`-t`) */
247
+ toolsets?: string;
248
+ /** 模型(`-m`) */
249
+ model?: string;
250
+ /** 工作目录(`--in`) */
251
+ cwd?: string;
252
+ timeoutMs?: number;
253
+ maxOutputBytes?: number;
254
+ concurrency?: number;
255
+ /** `--usage-file` 临时目录 */
256
+ usageDir?: string;
257
+ }
258
+
259
+ /**
260
+ * Hermes 一次性非交互适配器:`hermes [-p P] [-t T] [-m M] [--in DIR] -z <prompt> --usage-file <tmp>`。
261
+ * 成功 → 结果 = stdout(截断);超时/非零退出 → `failed` 带原因与 stderr 尾巴。
262
+ */
263
+ export class HermesAgent implements TaskExecutor {
264
+ private readonly sem: Semaphore;
265
+ constructor(private readonly options: HermesAgentOptions = {}) {
266
+ this.sem = new Semaphore(Math.max(1, options.concurrency ?? 2));
267
+ }
268
+
269
+ private argv(prompt: string, usagePath: string): string[] {
270
+ const args: string[] = [...(this.options.commandArgs ?? [])];
271
+ if (this.options.profile) args.push('-p', this.options.profile);
272
+ if (this.options.toolsets) args.push('-t', this.options.toolsets);
273
+ if (this.options.model) args.push('-m', this.options.model);
274
+ if (this.options.cwd) args.push('--in', this.options.cwd);
275
+ args.push('-z', prompt, '--usage-file', usagePath);
276
+ return args;
277
+ }
278
+
279
+ async execute(task: TaskState): Promise<ExecutionOutcome> {
280
+ await this.sem.acquire();
281
+ const dir = await mkdtemp(join(this.options.usageDir ?? tmpdir(), 'fleet-hermes-'));
282
+ const usagePath = join(dir, 'usage.json');
283
+ try {
284
+ const timeoutMs = this.options.timeoutMs ?? 600_000;
285
+ const maxOutputBytes = this.options.maxOutputBytes ?? 65_536;
286
+ const result = await runOnce({
287
+ command: this.options.hermesPath ?? 'hermes',
288
+ args: this.argv(task.intent, usagePath),
289
+ ...(this.options.cwd !== undefined ? { cwd: this.options.cwd } : {}),
290
+ timeoutMs,
291
+ maxOutputBytes,
292
+ });
293
+ if (result.timedOut) return { ok: false, reason: `TIMEOUT after ${timeoutMs}ms` };
294
+ if (result.code !== 0) {
295
+ return { ok: false, reason: `EXIT_${result.code}${result.stderrTail ? `: ${result.stderrTail}` : ''}` };
296
+ }
297
+ const stored = result.stdout.trim();
298
+ const text = result.stdoutBytes > maxOutputBytes ? `${stored}\n…[truncated ${result.stdoutBytes - maxOutputBytes} bytes]` : stored;
299
+ // usage 仅用于观测 sessionId;解析失败不影响结果。
300
+ let sessionId: string | undefined;
301
+ try {
302
+ const usage = JSON.parse(await readFile(usagePath, 'utf-8')) as { session_id?: string };
303
+ sessionId = usage.session_id;
304
+ } catch {
305
+ sessionId = undefined;
306
+ }
307
+ return { ok: true, ...(sessionId !== undefined ? { reason: `session:${sessionId}` } : {}), resultRef: text };
308
+ } finally {
309
+ await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 20 });
310
+ this.sem.release();
311
+ }
312
+ }
313
+ }
@@ -0,0 +1,114 @@
1
+ // 可插拔执行器(M2):内置 `echo`;执行记录(ExecutionLog)保证**按 taskId 至多执行一次**。
2
+
3
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+ import type { TaskState } from '../model.js';
6
+
7
+ export interface ExecutionOutcome {
8
+ ok: boolean;
9
+ resultRef?: string;
10
+ reason?: string;
11
+ }
12
+
13
+ /** 执行器契约:**按 taskId 幂等**(同一任务重复执行必须得到同一结果且无副作用)。 */
14
+ export interface TaskExecutor {
15
+ execute(task: TaskState): Promise<ExecutionOutcome>;
16
+ }
17
+
18
+ /** 内置 echo 执行器:结果与意图确定性绑定(幂等、无副作用)。 */
19
+ export class EchoExecutor implements TaskExecutor {
20
+ async execute(task: TaskState): Promise<ExecutionOutcome> {
21
+ return { ok: true, resultRef: `echo:${task.intent}` };
22
+ }
23
+ }
24
+
25
+ /** 期望结果(供校验):与 `EchoExecutor` 对齐。 */
26
+ export function echoResultFor(intent: string): string {
27
+ return `echo:${intent}`;
28
+ }
29
+
30
+ interface LogEntry {
31
+ taskId: string;
32
+ resultRef: string;
33
+ }
34
+
35
+ /** 执行记录:持久化「已执行的 taskId → 结果」,跨重启/重复投递去重执行。 */
36
+ export class ExecutionLog {
37
+ private readonly path: string;
38
+ private readonly entries: LogEntry[] = [];
39
+ private readonly index = new Map<string, string>();
40
+
41
+ private constructor(path: string) {
42
+ this.path = path;
43
+ }
44
+
45
+ static async open(path: string): Promise<ExecutionLog> {
46
+ const log = new ExecutionLog(path);
47
+ await mkdir(dirname(path), { recursive: true });
48
+ let raw = '';
49
+ try {
50
+ raw = await readFile(path, 'utf-8');
51
+ } catch {
52
+ raw = '';
53
+ }
54
+ for (const line of raw.split('\n')) {
55
+ const trimmed = line.trim();
56
+ if (trimmed === '') continue;
57
+ try {
58
+ const entry = JSON.parse(trimmed) as LogEntry;
59
+ if (!log.index.has(entry.taskId)) {
60
+ log.index.set(entry.taskId, entry.resultRef);
61
+ log.entries.push(entry);
62
+ }
63
+ } catch {
64
+ continue; // 半行/损坏行跳过
65
+ }
66
+ }
67
+ return log;
68
+ }
69
+
70
+ has(taskId: string): boolean {
71
+ return this.index.has(taskId);
72
+ }
73
+
74
+ resultOf(taskId: string): string | undefined {
75
+ return this.index.get(taskId);
76
+ }
77
+
78
+ /** 记录一次真实执行(若已存在则不重复追加)。 */
79
+ async record(taskId: string, resultRef: string): Promise<void> {
80
+ if (this.index.has(taskId)) return;
81
+ const entry: LogEntry = { taskId, resultRef };
82
+ await appendFile(this.path, `${JSON.stringify(entry)}\n`, 'utf-8');
83
+ this.index.set(taskId, resultRef);
84
+ this.entries.push(entry);
85
+ }
86
+
87
+ all(): LogEntry[] {
88
+ return [...this.entries];
89
+ }
90
+
91
+ size(): number {
92
+ return this.entries.length;
93
+ }
94
+
95
+ async close(): Promise<void> {
96
+ // 追加即时落盘;无长连接。
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 按 taskId 至多执行一次:已记录则直接返回记录结果(不再次调用执行器)。
102
+ * 这保证「重复投递不重复执行」与「重启不重复执行」。
103
+ */
104
+ export async function executeOnce(
105
+ task: TaskState,
106
+ executor: TaskExecutor,
107
+ log: ExecutionLog,
108
+ ): Promise<ExecutionOutcome> {
109
+ const existing = log.resultOf(task.taskId);
110
+ if (existing !== undefined) return { ok: true, resultRef: existing };
111
+ const outcome = await executor.execute(task);
112
+ if (outcome.ok) await log.record(task.taskId, outcome.resultRef ?? '');
113
+ return outcome;
114
+ }
@@ -0,0 +1,90 @@
1
+ // W1 公平准入(本地、确定性、无墙钟):收件按 `(from.device, 逻辑序)` 轮转;
2
+ // 有其它待处理发送方时,单一发送方**份额 ≤ 50%**;`(来源设备, 目标 Agent)` 准入配额;
3
+ // 每 Agent 并发默认 2。全部为纯函数,便于判别性测试与全端一致。
4
+
5
+ import type { TaskState } from '../model.js';
6
+
7
+ export interface FairOrderOptions {
8
+ /** 单一发送方份额上限(有其它待处理发送方时);默认 0.5 */
9
+ shareCap?: number;
10
+ }
11
+
12
+ /**
13
+ * 轮转序:每一步选「已服务次数最少」的发送方(tie:剩余最多 → device 字典序),
14
+ * 组内按 `taskId` 逻辑序。返回 taskId 的确定序。
15
+ */
16
+ export function fairOrder(pending: readonly TaskState[], options: FairOrderOptions = {}): string[] {
17
+ const shareCap = options.shareCap ?? 0.5;
18
+ const groups = new Map<string, TaskState[]>();
19
+ for (const state of pending) {
20
+ const list = groups.get(state.from.device);
21
+ if (list) list.push(state);
22
+ else groups.set(state.from.device, [state]);
23
+ }
24
+ for (const list of groups.values()) list.sort((a, b) => (a.taskId < b.taskId ? -1 : a.taskId > b.taskId ? 1 : 0));
25
+
26
+ const served = new Map<string, number>();
27
+ for (const device of groups.keys()) served.set(device, 0);
28
+ const order: string[] = [];
29
+ const total = pending.length;
30
+
31
+ while (order.length < total) {
32
+ const active = [...groups.keys()]
33
+ .filter((d) => (groups.get(d)?.length ?? 0) > 0)
34
+ .sort((a, b) => {
35
+ const sa = served.get(a)!;
36
+ const sb = served.get(b)!;
37
+ if (sa !== sb) return sa - sb;
38
+ const ra = groups.get(a)!.length;
39
+ const rb = groups.get(b)!.length;
40
+ if (ra !== rb) return rb - ra;
41
+ return a < b ? -1 : a > b ? 1 : 0;
42
+ });
43
+ if (active.length === 0) break;
44
+ let pick = active[0]!;
45
+ // 份额上限:若选它会让其份额 > cap 且还有尚未达到同等优先级的其它活跃发送方 → 换下一个
46
+ const cap = shareCap * (order.length + 1);
47
+ if (served.get(pick)! + 1 > cap) {
48
+ const alternative = active.find((d) => served.get(d)! + 1 <= cap);
49
+ if (alternative !== undefined) pick = alternative;
50
+ }
51
+ const list = groups.get(pick)!;
52
+ const next = list.shift()!;
53
+ order.push(next.taskId);
54
+ served.set(pick, served.get(pick)! + 1);
55
+ }
56
+ return order;
57
+ }
58
+
59
+ export interface AgentConcurrencyOptions {
60
+ /** 每个目标 Agent 的并发上限(默认 2;仅本地调度门,无全局账本) */
61
+ perAgent?: number;
62
+ /** `(来源设备, 目标 Agent)` 准入配额(默认 Infinity = 不限) */
63
+ perPair?: number;
64
+ }
65
+
66
+ /**
67
+ * 在某轮内按「目标 Agent 并发 + (来源设备,目标 Agent) 配额」过滤可执行 taskId(保序)。
68
+ * `inFlight` 为已在途/已执行的计数(本地记账;调用方维护)。
69
+ */
70
+ export function admitByConcurrency(
71
+ ordered: readonly TaskState[],
72
+ inFlight: { agent: ReadonlyMap<string, number>; pair: ReadonlyMap<string, number> },
73
+ options: AgentConcurrencyOptions = {},
74
+ ): string[] {
75
+ const perAgent = options.perAgent ?? 2;
76
+ const perPair = options.perPair ?? Number.POSITIVE_INFINITY;
77
+ const agentCount = new Map(inFlight.agent);
78
+ const pairCount = new Map(inFlight.pair);
79
+ const admitted: string[] = [];
80
+ for (const state of ordered) {
81
+ const agent = state.to.agent;
82
+ const pair = `${state.from.device}->${agent}`;
83
+ if ((agentCount.get(agent) ?? 0) >= perAgent) continue;
84
+ if ((pairCount.get(pair) ?? 0) >= perPair) continue;
85
+ agentCount.set(agent, (agentCount.get(agent) ?? 0) + 1);
86
+ pairCount.set(pair, (pairCount.get(pair) ?? 0) + 1);
87
+ admitted.push(state.taskId);
88
+ }
89
+ return admitted;
90
+ }