agent-trellis 0.19.0 → 0.21.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/dist/adapters/claude-code.js +2 -2
- package/dist/adapters/codex.js +3 -3
- package/dist/adapters/jsonMcp.d.ts +1 -1
- package/dist/adapters/jsonMcp.js +2 -2
- package/dist/adapters/kimi-code.js +2 -2
- package/dist/adapters/kiro.js +11 -9
- package/dist/adapters/mcpPlan.d.ts +1 -1
- package/dist/adapters/mcpPlan.js +35 -12
- package/dist/adapters/pi.js +5 -5
- package/dist/adapters/zcode.d.ts +1 -1
- package/dist/adapters/zcode.js +6 -6
- package/dist/cli.js +12 -1
- package/dist/commands/doctor.d.ts +9 -1
- package/dist/commands/doctor.js +92 -7
- package/dist/commands/evolve.d.ts +43 -0
- package/dist/commands/evolve.js +479 -0
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/mcpAuth.js +1 -1
- package/dist/commands/mcpGateway.d.ts +30 -2
- package/dist/commands/mcpGateway.js +44 -10
- package/dist/commands/mcpImport.js +2 -1
- package/dist/commands/secretsAudit.d.ts +1 -1
- package/dist/commands/secretsAudit.js +36 -13
- package/dist/evolve/agentView.d.ts +32 -0
- package/dist/evolve/agentView.js +34 -0
- package/dist/evolve/apply.d.ts +71 -0
- package/dist/evolve/apply.js +162 -0
- package/dist/evolve/archive.d.ts +23 -0
- package/dist/evolve/archive.js +58 -0
- package/dist/evolve/erase.d.ts +17 -0
- package/dist/evolve/erase.js +65 -0
- package/dist/evolve/extension.d.ts +37 -0
- package/dist/evolve/extension.js +63 -0
- package/dist/evolve/gate.d.ts +95 -0
- package/dist/evolve/gate.js +295 -0
- package/dist/evolve/importHistory.d.ts +91 -0
- package/dist/evolve/importHistory.js +239 -0
- package/dist/evolve/jobMain.d.ts +22 -0
- package/dist/evolve/jobMain.js +52 -0
- package/dist/evolve/jobRun.d.ts +94 -0
- package/dist/evolve/jobRun.js +273 -0
- package/dist/evolve/jobShell.d.ts +46 -0
- package/dist/evolve/jobShell.js +75 -0
- package/dist/evolve/l2.d.ts +93 -0
- package/dist/evolve/l2.js +81 -0
- package/dist/evolve/lesson.d.ts +57 -0
- package/dist/evolve/lesson.js +97 -0
- package/dist/evolve/pending.d.ts +69 -0
- package/dist/evolve/pending.js +116 -0
- package/dist/evolve/probeWorkspace.d.ts +57 -0
- package/dist/evolve/probeWorkspace.js +69 -0
- package/dist/evolve/propose.d.ts +73 -0
- package/dist/evolve/propose.js +136 -0
- package/dist/evolve/recorder.d.ts +68 -0
- package/dist/evolve/recorder.js +169 -0
- package/dist/evolve/report.d.ts +26 -0
- package/dist/evolve/report.js +37 -0
- package/dist/evolve/reportView.d.ts +96 -0
- package/dist/evolve/reportView.js +311 -0
- package/dist/evolve/risk.d.ts +106 -0
- package/dist/evolve/risk.js +224 -0
- package/dist/evolve/sources.d.ts +19 -0
- package/dist/evolve/sources.js +29 -0
- package/dist/evolve/stateDir.d.ts +21 -0
- package/dist/evolve/stateDir.js +56 -0
- package/dist/evolve/surface.d.ts +34 -0
- package/dist/evolve/surface.js +72 -0
- package/dist/evolve/trajectory.d.ts +25 -0
- package/dist/evolve/trajectory.js +88 -0
- package/dist/evolve/treeToCanonical.d.ts +28 -0
- package/dist/evolve/treeToCanonical.js +79 -0
- package/dist/evolve/trellisSubstrate.d.ts +65 -0
- package/dist/evolve/trellisSubstrate.js +154 -0
- package/dist/evolve/trigger.d.ts +177 -0
- package/dist/evolve/trigger.js +400 -0
- package/dist/evolve/types.d.ts +126 -0
- package/dist/evolve/types.js +15 -0
- package/dist/evolve/validate.d.ts +139 -0
- package/dist/evolve/validate.js +315 -0
- package/dist/host/checks.d.ts +57 -0
- package/dist/host/checks.js +126 -0
- package/dist/host/delivery.d.ts +42 -0
- package/dist/host/delivery.js +59 -0
- package/dist/host/edits.d.ts +87 -0
- package/dist/host/edits.js +102 -0
- package/dist/host/hostEvents.d.ts +74 -0
- package/dist/host/hostEvents.js +45 -0
- package/dist/host/index.d.ts +22 -0
- package/dist/host/index.js +17 -0
- package/dist/host/levels.d.ts +106 -0
- package/dist/host/levels.js +206 -0
- package/dist/host/staging.d.ts +56 -0
- package/dist/host/staging.js +106 -0
- package/dist/host/surface.d.ts +36 -0
- package/dist/host/surface.js +20 -0
- package/dist/lib/backup.js +12 -3
- package/dist/lib/envVarNames.js +7 -0
- package/dist/lib/fsAtomic.d.ts +7 -0
- package/dist/lib/fsAtomic.js +14 -0
- package/dist/lib/hostEventBus.d.ts +47 -0
- package/dist/lib/hostEventBus.js +94 -0
- package/dist/lib/hostExtensions.d.ts +75 -0
- package/dist/lib/hostExtensions.js +196 -0
- package/dist/lib/mcpConnect.d.ts +1 -1
- package/dist/lib/mcpConnect.js +19 -8
- package/dist/lib/mcpMigrateRead.js +14 -2
- package/dist/lib/mcpRuntime.d.ts +12 -1
- package/dist/lib/mcpRuntime.js +34 -2
- package/dist/lib/mcpToolRegistry.d.ts +18 -2
- package/dist/lib/mcpToolRegistry.js +29 -9
- package/dist/lib/secretEnv.d.ts +53 -8
- package/dist/lib/secretEnv.js +78 -15
- package/dist/lib/tomlSection.js +3 -2
- package/dist/lib/workspaceDoctor.d.ts +1 -1
- package/dist/lib/workspaceDoctor.js +8 -4
- package/dist/lib/workspaceMcp.d.ts +1 -1
- package/dist/lib/workspaceMcp.js +5 -5
- package/dist/pi-bridge/bundle.js +42236 -2701
- package/dist/pi-bridge/index.js +2 -2
- package/docs/magpie-gateway-research.md +422 -0
- package/docs/research.md +4 -1
- package/docs/roadmap.md +22 -12
- package/docs/runtime-infra.md +41 -22
- package/docs/self-evolution.md +138 -44
- package/package.json +8 -2
- package/schema/secrets.policy.example.yaml +5 -0
- package/schema/servers.example.yaml +5 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话触发器(trellis-evolve 任务 11.5/11.6)。
|
|
3
|
+
*
|
|
4
|
+
* 环境式接入的"重活拉起"侧:阈值/冷却/预算判断通过后,一次性 detached
|
|
5
|
+
* 作业(即移植引擎的运行)被拉起,作业结束即退出——系统不依赖常驻进程。
|
|
6
|
+
* 单实例靠作业锁文件 `evolve/job.lock`(作业自身持有,拉起前检查活锁);
|
|
7
|
+
* 预算(每日提案上限、委派 editor 预算)超限即跳过并把原因记入运行档案,
|
|
8
|
+
* 不排队。阈值/冷却/预算上限在 `evolve.yaml` 的 `trigger:` 段可配置
|
|
9
|
+
* (host/levels.ts 解析),缺省值见 `DEFAULT_TRIGGER_LIMITS`。
|
|
10
|
+
*
|
|
11
|
+
* 两个入口共用同一判定(11.6 的补偿评估):会话结束(`maybeLaunchOnSessionEnd`)
|
|
12
|
+
* 与会话启动(`maybeLaunchOnSessionStart`)——SIGKILL/崩溃不发结束事件,
|
|
13
|
+
* 启动路径对上次遗漏的触发做补偿,不会重复消费(累计信号以"上次作业的
|
|
14
|
+
* 信号合并时刻"为界,作业运行期间到达的信号不会被漏掉)。`enabled` 为 false(级别 off)时零副作用:不判定、不入档、不拉起。
|
|
15
|
+
*
|
|
16
|
+
* 判定纯函数是 `evaluateTrigger`;`tryLaunchJob` 负责收集状态(信号、
|
|
17
|
+
* 档案、锁)、判定、spawn、把判定写入档案。拉起的作业入口是
|
|
18
|
+
* `jobMain.ts`(作业体是 rule-based 治理流水的一次运行;引擎内核 golden
|
|
19
|
+
* 锚定但生产未消费,待 L2/探针任务集接入)。
|
|
20
|
+
*/
|
|
21
|
+
import { type ArchiveEntry } from "./archive.js";
|
|
22
|
+
import type { EvolveTriggerLimits } from "../host/levels.js";
|
|
23
|
+
/** 档案 kind:一次触发判定(含跳过原因)。 */
|
|
24
|
+
export declare const TRIGGER_DECISION_KIND = "trigger-decision";
|
|
25
|
+
/** 档案 kind:一次作业运行(jobMain 结束时落一条,含起止时间)。 */
|
|
26
|
+
export declare const JOB_RUN_KIND = "job-run";
|
|
27
|
+
/** 档案 kind:提案(12.x 的提案写入用同一 kind,预算判断据此计数)。 */
|
|
28
|
+
export declare const PROPOSAL_ARCHIVE_KIND = "proposal";
|
|
29
|
+
/** 档案 kind:一次委派 editor 调用(预算判断据此计数)。 */
|
|
30
|
+
export declare const EDITOR_CALL_ARCHIVE_KIND = "editor-call";
|
|
31
|
+
/** 解析后的触发/预算上限(全部字段已回退到缺省)。 */
|
|
32
|
+
export interface ResolvedTriggerLimits {
|
|
33
|
+
readonly signalThreshold: number;
|
|
34
|
+
readonly cooldownMinutes: number;
|
|
35
|
+
readonly dailyProposalCap: number;
|
|
36
|
+
readonly editorBudget: number;
|
|
37
|
+
}
|
|
38
|
+
/** 缺省触发/预算上限(用户可在 `evolve.yaml` 的 `trigger:` 段覆盖)。
|
|
39
|
+
* 保守取值:冷却 6 小时、每日提案 3 条、editor 预算 5 次。 */
|
|
40
|
+
export declare const DEFAULT_TRIGGER_LIMITS: ResolvedTriggerLimits;
|
|
41
|
+
export declare function resolveTriggerLimits(limits: EvolveTriggerLimits | undefined): ResolvedTriggerLimits;
|
|
42
|
+
export type TriggerSkipReason = "below-threshold" | "cooldown" | "already-running" | "daily-proposal-cap" | "editor-budget" | "spawn-failed";
|
|
43
|
+
/** 触发来源(run-backfill D1/D2):事件触发 / 手动触发 / 补跑链起。
|
|
44
|
+
* 进 trigger-decision 与 job-run 档案;jobMain 经 `--trigger` 透传。 */
|
|
45
|
+
export type JobTriggerKind = "event" | "manual" | "chain";
|
|
46
|
+
/**
|
|
47
|
+
* 门豁免(纯输入;run-backfill D2):手动/补跑路径绕过信号阈值与冷却
|
|
48
|
+
* (用户明确意图),预算护栏(日提案上限/editor 预算)不豁免。缺省全
|
|
49
|
+
* false —— 事件路径语义逐字不变。
|
|
50
|
+
*/
|
|
51
|
+
export interface TriggerBypass {
|
|
52
|
+
readonly threshold?: boolean;
|
|
53
|
+
readonly cooldown?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export type TriggerGateDecision = {
|
|
56
|
+
readonly decision: "launch";
|
|
57
|
+
readonly note: string;
|
|
58
|
+
} | {
|
|
59
|
+
readonly decision: "skip";
|
|
60
|
+
readonly reason: TriggerSkipReason;
|
|
61
|
+
readonly note: string;
|
|
62
|
+
};
|
|
63
|
+
export type TriggerDecision = {
|
|
64
|
+
readonly decision: "launch";
|
|
65
|
+
readonly note: string;
|
|
66
|
+
readonly pid: number;
|
|
67
|
+
} | {
|
|
68
|
+
readonly decision: "skip";
|
|
69
|
+
readonly reason: TriggerSkipReason;
|
|
70
|
+
readonly note: string;
|
|
71
|
+
};
|
|
72
|
+
/** 判定所需的全部状态(纯输入,便于单测)。 */
|
|
73
|
+
export interface TriggerState {
|
|
74
|
+
readonly now: number;
|
|
75
|
+
/** 累计未消费信号数(自上次作业结束以来)。 */
|
|
76
|
+
readonly pendingSignalCount: number;
|
|
77
|
+
/** 上一作业结束时刻(ms);从未跑过作业为 null(无冷却约束)。 */
|
|
78
|
+
readonly lastJobEndedAt: number | null;
|
|
79
|
+
/** 是否已有作业在运行(活锁)。 */
|
|
80
|
+
readonly jobRunning: boolean;
|
|
81
|
+
/** 当日提案数(按 UTC 日历日,下同)。 */
|
|
82
|
+
readonly todayProposalCount: number;
|
|
83
|
+
/** 当日委派 editor 调用数。 */
|
|
84
|
+
readonly todayEditorCallCount: number;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 阈值/冷却/预算判断(纯函数)。顺序:活锁 → 阈值 → 冷却 → 每日提案
|
|
88
|
+
* 上限 → editor 预算;全过才 launch。信号"严重度"加权留待提案阶段细化
|
|
89
|
+
* (信号暂无严重度字段,本期按计数)。`bypass` 缺省全 false:事件路径
|
|
90
|
+
* 语义逐字不变;手动/补跑路径豁免阈值与冷却(D2),预算不豁免。
|
|
91
|
+
*/
|
|
92
|
+
export declare function evaluateTrigger(state: TriggerState, limits: ResolvedTriggerLimits, bypass?: TriggerBypass): TriggerGateDecision;
|
|
93
|
+
export declare function jobLockPath(homeDir: string): string;
|
|
94
|
+
/** 是否有活作业在运行;死锁/坏锁顺手清除(下次拉起可进行)。 */
|
|
95
|
+
export declare function isJobRunning(homeDir: string): boolean;
|
|
96
|
+
/** 作业侧权威抢锁(wx 原建);活锁存在时返回 false,死锁清除后重试一次。 */
|
|
97
|
+
export declare function acquireJobLock(homeDir: string, pid: number, startedAtMs: number): boolean;
|
|
98
|
+
/** 释放自己持有的锁;不是自己的锁不动。 */
|
|
99
|
+
export declare function releaseJobLock(homeDir: string, pid: number): void;
|
|
100
|
+
/** 补跑标记记录(`evolve/run-request.json`):运行期间到达的触发请求
|
|
101
|
+
* 合并为至多一个标记;count 仅审计(多次请求塌缩为一次)。 */
|
|
102
|
+
export interface RunRequestRecord {
|
|
103
|
+
readonly reason: string;
|
|
104
|
+
readonly setAtMs: number;
|
|
105
|
+
readonly count: number;
|
|
106
|
+
}
|
|
107
|
+
export declare function runRequestPath(homeDir: string): string;
|
|
108
|
+
/** 置位幂等:已存在只增 count(审计),reason/setAtMs 保持首次(0600/0700
|
|
109
|
+
* 与信号文件同款;不引入常驻进程与队列)。 */
|
|
110
|
+
export declare function setRunRequest(homeDir: string, reason: string, nowMs: number): RunRequestRecord;
|
|
111
|
+
/** CAS 读后删消费(与 isJobRunning 的 compare-and-delete 同款):读后内容
|
|
112
|
+
* 变了视为并发置位的新请求,不误删(留待作业启动清标记/下次收尾消费);
|
|
113
|
+
* 无标记返回 null。 */
|
|
114
|
+
export declare function consumeRunRequest(homeDir: string): RunRequestRecord | null;
|
|
115
|
+
/** 自 sinceMs(含:null = 全部)以来的累计信号数(遍历 signals/ 全部进程文件)。 */
|
|
116
|
+
export declare function countPendingSignals(homeDir: string, sinceMs: number | null): number;
|
|
117
|
+
/**
|
|
118
|
+
* 信号消费水位线:优先作业落档的"信号合并时刻"(signalsMergedAtMs)
|
|
119
|
+
* ——用 endedAtMs 会把作业运行期间到达的信号永久漏掉(本次没读到、
|
|
120
|
+
* 下次又被 `collectedAt > endedAtMs` 过滤);旧档案无该字段时回退
|
|
121
|
+
* endedAtMs。冷却期语义仍用 lastJobEndedAtMs(距作业结束)。
|
|
122
|
+
*/
|
|
123
|
+
export declare function lastSignalWatermarkMs(entries: readonly ArchiveEntry[]): number | null;
|
|
124
|
+
export interface TryLaunchJobOptions {
|
|
125
|
+
homeDir: string;
|
|
126
|
+
/** 级别开关(off 返回 null 且零副作用)。缺省恒开。 */
|
|
127
|
+
enabled?: () => boolean;
|
|
128
|
+
/** 触发/预算上限;缺省走内置默认(调用方可从 evolve.yaml 读入)。 */
|
|
129
|
+
limits?: EvolveTriggerLimits;
|
|
130
|
+
/** 时间源(毫秒);缺省 Date.now。 */
|
|
131
|
+
now?: () => number;
|
|
132
|
+
/** 显式累计信号数;缺省统计自上次作业结束以来的信号。 */
|
|
133
|
+
pendingSignalCount?: number;
|
|
134
|
+
/** 门豁免(手动/补跑路径;缺省不豁免,事件路径语义逐字不变)。 */
|
|
135
|
+
bypass?: TriggerBypass;
|
|
136
|
+
/** 触发来源(入 trigger-decision 与 job-run 档案;缺省 "event")。 */
|
|
137
|
+
trigger?: JobTriggerKind;
|
|
138
|
+
/** 作业命令(argv 传参,不拼 shell);缺省本包 `jobMain.js` 入口。 */
|
|
139
|
+
jobCommand?: {
|
|
140
|
+
readonly bin: string;
|
|
141
|
+
readonly args: readonly string[];
|
|
142
|
+
};
|
|
143
|
+
/** 测试注入的 spawn;缺省 `node:child_process.spawn`。 */
|
|
144
|
+
spawnFn?: (bin: string, args: readonly string[], options: {
|
|
145
|
+
detached: true;
|
|
146
|
+
stdio: "ignore";
|
|
147
|
+
}) => {
|
|
148
|
+
pid?: number;
|
|
149
|
+
unref?: () => void;
|
|
150
|
+
on?: (event: string, listener: () => void) => void;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** 收集判定状态(tryLaunchJob 与 CLI 手动触发的护栏/dry-run 判断同源)。 */
|
|
154
|
+
export declare function collectTriggerState(homeDir: string, nowMs: number, pendingSignalCount?: number): TriggerState;
|
|
155
|
+
/**
|
|
156
|
+
* 完整判断与拉起:收集状态 → `evaluateTrigger` → 判定无论 launch/skip
|
|
157
|
+
* 都只追加入档(跳过必带原因);launch 时 spawn 一次性 detached 作业并
|
|
158
|
+
* 立即 unref(gateway 不等作业,作业崩溃不波及会话;exit 监听只为回收
|
|
159
|
+
* 子进程,不留僵尸)。`enabled` 为 false 时返回 null 且零副作用。
|
|
160
|
+
*/
|
|
161
|
+
export declare function tryLaunchJob(options: TryLaunchJobOptions): TriggerDecision | null;
|
|
162
|
+
/** 会话结束入口(11.5):阈值/冷却/预算判断 + 一次性 detached 作业拉起。 */
|
|
163
|
+
export declare function maybeLaunchOnSessionEnd(options: TryLaunchJobOptions): TriggerDecision | null;
|
|
164
|
+
/**
|
|
165
|
+
* 会话启动入口(11.6 补偿评估):SIGKILL/崩溃不发结束事件,启动路径对
|
|
166
|
+
* 上次遗漏的触发做同一套判定。与结束路径唯一的行为差异是调用时点——
|
|
167
|
+
* 累计信号以上次作业结束为界,不会把已消费的信号再触发一遍。
|
|
168
|
+
*/
|
|
169
|
+
export declare function maybeLaunchOnSessionStart(options: TryLaunchJobOptions): TriggerDecision | null;
|
|
170
|
+
/**
|
|
171
|
+
* 作业收尾(释锁后)消费补跑标记并链起至多一次补跑(D3):有标记则清
|
|
172
|
+
* 标记并按手动同款门拉起 `chain` 作业(豁免阈值/冷却——请求在置标记前
|
|
173
|
+
* 已过这两道门;预算护栏照旧,skip 照记 trigger-decision);无标记返回
|
|
174
|
+
* null 不链。崩溃语义:残留标记由下一次作业启动清掉(请求已被满足),
|
|
175
|
+
* 丢失至多一次补跑请求(下次事件/手动自然补)。
|
|
176
|
+
*/
|
|
177
|
+
export declare function chainAfterJob(options: Pick<TryLaunchJobOptions, "homeDir" | "limits" | "now" | "jobCommand" | "spawnFn">): TriggerDecision | null;
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话触发器(trellis-evolve 任务 11.5/11.6)。
|
|
3
|
+
*
|
|
4
|
+
* 环境式接入的"重活拉起"侧:阈值/冷却/预算判断通过后,一次性 detached
|
|
5
|
+
* 作业(即移植引擎的运行)被拉起,作业结束即退出——系统不依赖常驻进程。
|
|
6
|
+
* 单实例靠作业锁文件 `evolve/job.lock`(作业自身持有,拉起前检查活锁);
|
|
7
|
+
* 预算(每日提案上限、委派 editor 预算)超限即跳过并把原因记入运行档案,
|
|
8
|
+
* 不排队。阈值/冷却/预算上限在 `evolve.yaml` 的 `trigger:` 段可配置
|
|
9
|
+
* (host/levels.ts 解析),缺省值见 `DEFAULT_TRIGGER_LIMITS`。
|
|
10
|
+
*
|
|
11
|
+
* 两个入口共用同一判定(11.6 的补偿评估):会话结束(`maybeLaunchOnSessionEnd`)
|
|
12
|
+
* 与会话启动(`maybeLaunchOnSessionStart`)——SIGKILL/崩溃不发结束事件,
|
|
13
|
+
* 启动路径对上次遗漏的触发做补偿,不会重复消费(累计信号以"上次作业的
|
|
14
|
+
* 信号合并时刻"为界,作业运行期间到达的信号不会被漏掉)。`enabled` 为 false(级别 off)时零副作用:不判定、不入档、不拉起。
|
|
15
|
+
*
|
|
16
|
+
* 判定纯函数是 `evaluateTrigger`;`tryLaunchJob` 负责收集状态(信号、
|
|
17
|
+
* 档案、锁)、判定、spawn、把判定写入档案。拉起的作业入口是
|
|
18
|
+
* `jobMain.ts`(作业体是 rule-based 治理流水的一次运行;引擎内核 golden
|
|
19
|
+
* 锚定但生产未消费,待 L2/探针任务集接入)。
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { appendArchiveRecord, readArchiveRecords } from "./archive.js";
|
|
26
|
+
import { readSignals } from "./recorder.js";
|
|
27
|
+
import { evolveStateDir } from "./stateDir.js";
|
|
28
|
+
/** 档案 kind:一次触发判定(含跳过原因)。 */
|
|
29
|
+
export const TRIGGER_DECISION_KIND = "trigger-decision";
|
|
30
|
+
/** 档案 kind:一次作业运行(jobMain 结束时落一条,含起止时间)。 */
|
|
31
|
+
export const JOB_RUN_KIND = "job-run";
|
|
32
|
+
/** 档案 kind:提案(12.x 的提案写入用同一 kind,预算判断据此计数)。 */
|
|
33
|
+
export const PROPOSAL_ARCHIVE_KIND = "proposal";
|
|
34
|
+
/** 档案 kind:一次委派 editor 调用(预算判断据此计数)。 */
|
|
35
|
+
export const EDITOR_CALL_ARCHIVE_KIND = "editor-call";
|
|
36
|
+
/** 缺省触发/预算上限(用户可在 `evolve.yaml` 的 `trigger:` 段覆盖)。
|
|
37
|
+
* 保守取值:冷却 6 小时、每日提案 3 条、editor 预算 5 次。 */
|
|
38
|
+
export const DEFAULT_TRIGGER_LIMITS = {
|
|
39
|
+
signalThreshold: 10,
|
|
40
|
+
cooldownMinutes: 360,
|
|
41
|
+
dailyProposalCap: 3,
|
|
42
|
+
editorBudget: 5,
|
|
43
|
+
};
|
|
44
|
+
export function resolveTriggerLimits(limits) {
|
|
45
|
+
return {
|
|
46
|
+
signalThreshold: limits?.signalThreshold ?? DEFAULT_TRIGGER_LIMITS.signalThreshold,
|
|
47
|
+
cooldownMinutes: limits?.cooldownMinutes ?? DEFAULT_TRIGGER_LIMITS.cooldownMinutes,
|
|
48
|
+
dailyProposalCap: limits?.dailyProposalCap ?? DEFAULT_TRIGGER_LIMITS.dailyProposalCap,
|
|
49
|
+
editorBudget: limits?.editorBudget ?? DEFAULT_TRIGGER_LIMITS.editorBudget,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 阈值/冷却/预算判断(纯函数)。顺序:活锁 → 阈值 → 冷却 → 每日提案
|
|
54
|
+
* 上限 → editor 预算;全过才 launch。信号"严重度"加权留待提案阶段细化
|
|
55
|
+
* (信号暂无严重度字段,本期按计数)。`bypass` 缺省全 false:事件路径
|
|
56
|
+
* 语义逐字不变;手动/补跑路径豁免阈值与冷却(D2),预算不豁免。
|
|
57
|
+
*/
|
|
58
|
+
export function evaluateTrigger(state, limits, bypass = {}) {
|
|
59
|
+
if (state.jobRunning) {
|
|
60
|
+
return { decision: "skip", reason: "already-running", note: "已有作业在运行,不启动第二个" };
|
|
61
|
+
}
|
|
62
|
+
if (bypass.threshold !== true && (state.pendingSignalCount === 0 || state.pendingSignalCount < limits.signalThreshold)) {
|
|
63
|
+
return {
|
|
64
|
+
decision: "skip",
|
|
65
|
+
reason: "below-threshold",
|
|
66
|
+
note: `累计信号 ${state.pendingSignalCount} 未达阈值 ${limits.signalThreshold}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (bypass.cooldown !== true && state.lastJobEndedAt !== null) {
|
|
70
|
+
const elapsed = state.now - state.lastJobEndedAt;
|
|
71
|
+
const cooldownMs = limits.cooldownMinutes * 60_000;
|
|
72
|
+
if (elapsed < cooldownMs) {
|
|
73
|
+
return {
|
|
74
|
+
decision: "skip",
|
|
75
|
+
reason: "cooldown",
|
|
76
|
+
note: `距上一作业结束 ${Math.round(elapsed / 60_000)} 分钟,仍在冷却期 ${limits.cooldownMinutes} 分钟内`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (state.todayProposalCount >= limits.dailyProposalCap) {
|
|
81
|
+
return {
|
|
82
|
+
decision: "skip",
|
|
83
|
+
reason: "daily-proposal-cap",
|
|
84
|
+
note: `当日提案数 ${state.todayProposalCount} 已达上限 ${limits.dailyProposalCap},跳过不排队`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (state.todayEditorCallCount >= limits.editorBudget) {
|
|
88
|
+
return {
|
|
89
|
+
decision: "skip",
|
|
90
|
+
reason: "editor-budget",
|
|
91
|
+
note: `当日 editor 调用 ${state.todayEditorCallCount} 已达预算 ${limits.editorBudget},跳过不排队`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const waived = [bypass.threshold === true ? "阈值" : null, bypass.cooldown === true ? "冷却" : null].filter((part) => part !== null);
|
|
95
|
+
return {
|
|
96
|
+
decision: "launch",
|
|
97
|
+
note: waived.length > 0
|
|
98
|
+
? `累计信号 ${state.pendingSignalCount};${waived.join("与")}判断已豁免,预算未超限`
|
|
99
|
+
: `累计信号 ${state.pendingSignalCount} 达阈值 ${limits.signalThreshold},不在冷却期且预算未超限`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ---------- 作业锁(单实例) ----------
|
|
103
|
+
export function jobLockPath(homeDir) {
|
|
104
|
+
return join(evolveStateDir(homeDir), "job.lock");
|
|
105
|
+
}
|
|
106
|
+
function pidAlive(pid) {
|
|
107
|
+
try {
|
|
108
|
+
process.kill(pid, 0);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// EPERM:进程存在但不属本用户,视为活。
|
|
113
|
+
return err.code === "EPERM";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** 是否有活作业在运行;死锁/坏锁顺手清除(下次拉起可进行)。 */
|
|
117
|
+
export function isJobRunning(homeDir) {
|
|
118
|
+
const path = jobLockPath(homeDir);
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = readFileSync(path, "utf-8");
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
let pid;
|
|
127
|
+
try {
|
|
128
|
+
pid = JSON.parse(raw).pid;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
pid = undefined;
|
|
132
|
+
}
|
|
133
|
+
if (typeof pid === "number" && pid > 0 && pidAlive(pid))
|
|
134
|
+
return true;
|
|
135
|
+
// compare-and-delete:判定死锁到 unlink 之间,另一进程可能已完成清理
|
|
136
|
+
// 并用 wx 重建了活锁;重读比对,内容变了视为活锁,绝不误删(否则
|
|
137
|
+
// 随后自己的 wx 成功 → 双作业并发)。
|
|
138
|
+
try {
|
|
139
|
+
if (readFileSync(path, "utf-8") !== raw)
|
|
140
|
+
return true;
|
|
141
|
+
unlinkSync(path);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// 并发清锁,忽略。
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
/** 作业侧权威抢锁(wx 原建);活锁存在时返回 false,死锁清除后重试一次。 */
|
|
149
|
+
export function acquireJobLock(homeDir, pid, startedAtMs) {
|
|
150
|
+
const path = jobLockPath(homeDir);
|
|
151
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
152
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
153
|
+
try {
|
|
154
|
+
writeFileSync(path, JSON.stringify({ pid, startedAtMs }), { flag: "wx" });
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (err.code !== "EEXIST")
|
|
159
|
+
throw err;
|
|
160
|
+
if (isJobRunning(homeDir))
|
|
161
|
+
return false;
|
|
162
|
+
// isJobRunning 已清死锁,循环重试一次。
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
/** 释放自己持有的锁;不是自己的锁不动。 */
|
|
168
|
+
export function releaseJobLock(homeDir, pid) {
|
|
169
|
+
const path = jobLockPath(homeDir);
|
|
170
|
+
try {
|
|
171
|
+
const held = JSON.parse(readFileSync(path, "utf-8")).pid;
|
|
172
|
+
if (held !== pid)
|
|
173
|
+
return;
|
|
174
|
+
unlinkSync(path);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// 无锁可释(或并发释放),忽略。
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function runRequestPath(homeDir) {
|
|
181
|
+
return join(evolveStateDir(homeDir), "run-request.json");
|
|
182
|
+
}
|
|
183
|
+
/** 置位幂等:已存在只增 count(审计),reason/setAtMs 保持首次(0600/0700
|
|
184
|
+
* 与信号文件同款;不引入常驻进程与队列)。 */
|
|
185
|
+
export function setRunRequest(homeDir, reason, nowMs) {
|
|
186
|
+
const path = runRequestPath(homeDir);
|
|
187
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
188
|
+
const existing = readRunRequestRaw(path);
|
|
189
|
+
const record = existing === null
|
|
190
|
+
? { reason, setAtMs: nowMs, count: 1 }
|
|
191
|
+
: { reason: existing.reason, setAtMs: existing.setAtMs, count: existing.count + 1 };
|
|
192
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
193
|
+
return record;
|
|
194
|
+
}
|
|
195
|
+
/** CAS 读后删消费(与 isJobRunning 的 compare-and-delete 同款):读后内容
|
|
196
|
+
* 变了视为并发置位的新请求,不误删(留待作业启动清标记/下次收尾消费);
|
|
197
|
+
* 无标记返回 null。 */
|
|
198
|
+
export function consumeRunRequest(homeDir) {
|
|
199
|
+
const path = runRequestPath(homeDir);
|
|
200
|
+
let raw;
|
|
201
|
+
try {
|
|
202
|
+
raw = readFileSync(path, "utf-8");
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const record = parseRunRequestRaw(raw);
|
|
208
|
+
try {
|
|
209
|
+
if (readFileSync(path, "utf-8") !== raw)
|
|
210
|
+
return record;
|
|
211
|
+
unlinkSync(path);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// 并发消费/置位竞态,忽略。
|
|
215
|
+
}
|
|
216
|
+
return record;
|
|
217
|
+
}
|
|
218
|
+
function readRunRequestRaw(path) {
|
|
219
|
+
try {
|
|
220
|
+
return parseRunRequestRaw(readFileSync(path, "utf-8"));
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function parseRunRequestRaw(raw) {
|
|
227
|
+
try {
|
|
228
|
+
const parsed = JSON.parse(raw);
|
|
229
|
+
if (typeof parsed.reason === "string" &&
|
|
230
|
+
typeof parsed.setAtMs === "number" &&
|
|
231
|
+
typeof parsed.count === "number") {
|
|
232
|
+
return { reason: parsed.reason, setAtMs: parsed.setAtMs, count: parsed.count };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// 损坏标记按无标记处理(消费时被删除)。
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
// ---------- 状态收集 ----------
|
|
241
|
+
/** 自 sinceMs(含:null = 全部)以来的累计信号数(遍历 signals/ 全部进程文件)。 */
|
|
242
|
+
export function countPendingSignals(homeDir, sinceMs) {
|
|
243
|
+
const dir = join(evolveStateDir(homeDir), "signals");
|
|
244
|
+
if (!existsSync(dir))
|
|
245
|
+
return 0;
|
|
246
|
+
let count = 0;
|
|
247
|
+
for (const name of readdirSync(dir)) {
|
|
248
|
+
if (!name.endsWith(".jsonl"))
|
|
249
|
+
continue;
|
|
250
|
+
for (const signal of readSignals(join(dir, name))) {
|
|
251
|
+
if (sinceMs === null || signal.collectedAt > sinceMs)
|
|
252
|
+
count++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return count;
|
|
256
|
+
}
|
|
257
|
+
function lastJobEndedAtMs(entries) {
|
|
258
|
+
let max = null;
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
if (entry.kind !== JOB_RUN_KIND)
|
|
261
|
+
continue;
|
|
262
|
+
const ended = entry.payload?.endedAtMs;
|
|
263
|
+
if (typeof ended === "number" && (max === null || ended > max))
|
|
264
|
+
max = ended;
|
|
265
|
+
}
|
|
266
|
+
return max;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* 信号消费水位线:优先作业落档的"信号合并时刻"(signalsMergedAtMs)
|
|
270
|
+
* ——用 endedAtMs 会把作业运行期间到达的信号永久漏掉(本次没读到、
|
|
271
|
+
* 下次又被 `collectedAt > endedAtMs` 过滤);旧档案无该字段时回退
|
|
272
|
+
* endedAtMs。冷却期语义仍用 lastJobEndedAtMs(距作业结束)。
|
|
273
|
+
*/
|
|
274
|
+
export function lastSignalWatermarkMs(entries) {
|
|
275
|
+
let max = null;
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
if (entry.kind !== JOB_RUN_KIND)
|
|
278
|
+
continue;
|
|
279
|
+
const payload = entry.payload;
|
|
280
|
+
const watermark = typeof payload?.signalsMergedAtMs === "number"
|
|
281
|
+
? payload.signalsMergedAtMs
|
|
282
|
+
: typeof payload?.endedAtMs === "number"
|
|
283
|
+
? payload.endedAtMs
|
|
284
|
+
: null;
|
|
285
|
+
if (watermark !== null && (max === null || watermark > max))
|
|
286
|
+
max = watermark;
|
|
287
|
+
}
|
|
288
|
+
return max;
|
|
289
|
+
}
|
|
290
|
+
function countToday(entries, kind, nowMs) {
|
|
291
|
+
const day = new Date(nowMs).toISOString().slice(0, 10);
|
|
292
|
+
return entries.filter((entry) => entry.kind === kind && entry.recordedAt.slice(0, 10) === day).length;
|
|
293
|
+
}
|
|
294
|
+
/** 收集判定状态(tryLaunchJob 与 CLI 手动触发的护栏/dry-run 判断同源)。 */
|
|
295
|
+
export function collectTriggerState(homeDir, nowMs, pendingSignalCount) {
|
|
296
|
+
const entries = readArchiveRecords(homeDir);
|
|
297
|
+
const lastJobEndedAt = lastJobEndedAtMs(entries);
|
|
298
|
+
return {
|
|
299
|
+
now: nowMs,
|
|
300
|
+
pendingSignalCount: pendingSignalCount ?? countPendingSignals(homeDir, lastSignalWatermarkMs(entries)),
|
|
301
|
+
lastJobEndedAt,
|
|
302
|
+
jobRunning: isJobRunning(homeDir),
|
|
303
|
+
todayProposalCount: countToday(entries, PROPOSAL_ARCHIVE_KIND, nowMs),
|
|
304
|
+
todayEditorCallCount: countToday(entries, EDITOR_CALL_ARCHIVE_KIND, nowMs),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* 完整判断与拉起:收集状态 → `evaluateTrigger` → 判定无论 launch/skip
|
|
309
|
+
* 都只追加入档(跳过必带原因);launch 时 spawn 一次性 detached 作业并
|
|
310
|
+
* 立即 unref(gateway 不等作业,作业崩溃不波及会话;exit 监听只为回收
|
|
311
|
+
* 子进程,不留僵尸)。`enabled` 为 false 时返回 null 且零副作用。
|
|
312
|
+
*/
|
|
313
|
+
export function tryLaunchJob(options) {
|
|
314
|
+
if (options.enabled !== undefined && !options.enabled())
|
|
315
|
+
return null;
|
|
316
|
+
const now = options.now ?? Date.now;
|
|
317
|
+
const nowMs = now();
|
|
318
|
+
const limits = resolveTriggerLimits(options.limits);
|
|
319
|
+
const trigger = options.trigger ?? "event";
|
|
320
|
+
const state = collectTriggerState(options.homeDir, nowMs, options.pendingSignalCount);
|
|
321
|
+
const gate = evaluateTrigger(state, limits, options.bypass ?? {});
|
|
322
|
+
const stamp = () => new Date(nowMs);
|
|
323
|
+
if (gate.decision === "skip") {
|
|
324
|
+
// 运行中触发(手动或事件)→ 置补跑标记(D3:多次请求塌缩为一个);
|
|
325
|
+
// 其余跳过原因(预算等)只记因不置标记(超限不排队的语义不变)。
|
|
326
|
+
let note = gate.note;
|
|
327
|
+
if (gate.reason === "already-running") {
|
|
328
|
+
setRunRequest(options.homeDir, `trigger:${trigger}`, nowMs);
|
|
329
|
+
note += ";已置补跑标记(当前作业收尾后补跑至多一次)";
|
|
330
|
+
}
|
|
331
|
+
appendArchiveRecord(options.homeDir, { kind: TRIGGER_DECISION_KIND, payload: { decision: "skip", reason: gate.reason, note, trigger } }, stamp);
|
|
332
|
+
return { decision: "skip", reason: gate.reason, note };
|
|
333
|
+
}
|
|
334
|
+
const command = options.jobCommand ?? defaultJobCommand();
|
|
335
|
+
const spawnFn = options.spawnFn ??
|
|
336
|
+
((bin, args, opts) => spawn(bin, [...args], opts));
|
|
337
|
+
// 触发来源透传给 jobMain(--trigger 入 job-run 档案);event 为缺省不加参。
|
|
338
|
+
const triggerArgs = trigger === "event" ? [] : ["--trigger", trigger];
|
|
339
|
+
const child = spawnFn(command.bin, [...command.args, ...triggerArgs, options.homeDir], { detached: true, stdio: "ignore" });
|
|
340
|
+
// spawn 失败收容:ChildProcess 的 error 事件(EMFILE/ENOMEM 等)异步
|
|
341
|
+
// 触发且无监听者时被 Node 抛成 uncaughtException——本调用点在 bus.emit
|
|
342
|
+
// 的收容之外,会直接打垮 gateway 进程。监听即收容(失败作业不持锁,
|
|
343
|
+
// 下次触发按既有判定重试)。
|
|
344
|
+
child.on?.("error", () => { });
|
|
345
|
+
child.on?.("exit", () => {
|
|
346
|
+
// 仅回收子进程(unref 不阻止事件循环退出);作业退出后锁由 jobMain 自清。
|
|
347
|
+
});
|
|
348
|
+
child.unref?.();
|
|
349
|
+
// pid 缺失 = 拉起失败:不记 launch(否则档案对一次根本没发生的作业说谎)。
|
|
350
|
+
if (typeof child.pid !== "number") {
|
|
351
|
+
const note = `作业进程拉起失败(${command.bin} 未获得 pid),本次不排队`;
|
|
352
|
+
appendArchiveRecord(options.homeDir, { kind: TRIGGER_DECISION_KIND, payload: { decision: "skip", reason: "spawn-failed", note, trigger } }, stamp);
|
|
353
|
+
return { decision: "skip", reason: "spawn-failed", note };
|
|
354
|
+
}
|
|
355
|
+
const pid = child.pid;
|
|
356
|
+
appendArchiveRecord(options.homeDir, { kind: TRIGGER_DECISION_KIND, payload: { decision: "launch", note: gate.note, pid, trigger } }, stamp);
|
|
357
|
+
return { decision: "launch", note: gate.note, pid };
|
|
358
|
+
}
|
|
359
|
+
/** 会话结束入口(11.5):阈值/冷却/预算判断 + 一次性 detached 作业拉起。 */
|
|
360
|
+
export function maybeLaunchOnSessionEnd(options) {
|
|
361
|
+
return tryLaunchJob(options);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* 会话启动入口(11.6 补偿评估):SIGKILL/崩溃不发结束事件,启动路径对
|
|
365
|
+
* 上次遗漏的触发做同一套判定。与结束路径唯一的行为差异是调用时点——
|
|
366
|
+
* 累计信号以上次作业结束为界,不会把已消费的信号再触发一遍。
|
|
367
|
+
*/
|
|
368
|
+
export function maybeLaunchOnSessionStart(options) {
|
|
369
|
+
return tryLaunchJob(options);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* 作业收尾(释锁后)消费补跑标记并链起至多一次补跑(D3):有标记则清
|
|
373
|
+
* 标记并按手动同款门拉起 `chain` 作业(豁免阈值/冷却——请求在置标记前
|
|
374
|
+
* 已过这两道门;预算护栏照旧,skip 照记 trigger-decision);无标记返回
|
|
375
|
+
* null 不链。崩溃语义:残留标记由下一次作业启动清掉(请求已被满足),
|
|
376
|
+
* 丢失至多一次补跑请求(下次事件/手动自然补)。
|
|
377
|
+
*/
|
|
378
|
+
export function chainAfterJob(options) {
|
|
379
|
+
const request = consumeRunRequest(options.homeDir);
|
|
380
|
+
if (request === null)
|
|
381
|
+
return null;
|
|
382
|
+
return tryLaunchJob({
|
|
383
|
+
homeDir: options.homeDir,
|
|
384
|
+
bypass: { threshold: true, cooldown: true },
|
|
385
|
+
trigger: "chain",
|
|
386
|
+
...(options.limits !== undefined ? { limits: options.limits } : {}),
|
|
387
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
388
|
+
...(options.jobCommand !== undefined ? { jobCommand: options.jobCommand } : {}),
|
|
389
|
+
...(options.spawnFn !== undefined ? { spawnFn: options.spawnFn } : {}),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/** 默认作业命令:本包 jobMain 入口(dist 的 .js;经 tsx 直跑源码时同后缀
|
|
393
|
+
* 取 .ts,与 extension 的 load 说明符同款解析);argv 尾部由拉起方补 homeDir。 */
|
|
394
|
+
function defaultJobCommand() {
|
|
395
|
+
const self = import.meta.url;
|
|
396
|
+
return {
|
|
397
|
+
bin: process.execPath,
|
|
398
|
+
args: [fileURLToPath(new URL(self.endsWith(".ts") ? "./jobMain.ts" : "./jobMain.js", self))],
|
|
399
|
+
};
|
|
400
|
+
}
|