dsh-plan-completion 0.0.0-stage → 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.
package/host/job.js ADDED
@@ -0,0 +1,905 @@
1
+ /**
2
+ * host/job.js — 计划作业队列、全局并发闸与 runJob 编排(零依赖,纯注入)。
3
+ *
4
+ * 设计依据:v1.2 §3.5.1(流水线)、§3.5.2(幂等/合并/并发)、§3.5.3(取消与失效)、
5
+ * §3.5.4(双截止线)、§3.5.7(rewrite 防抖)。
6
+ *
7
+ * 端口(全部由 index.js 注入,本模块不 import 任何 DSH 包):
8
+ * callModel({system, messages, maxTokens, temperature, signal}) → Promise<string>
9
+ * commit(plan, {cas}) → boolean
10
+ * publish({plan, suggestion, turn}) → void
11
+ * now() → number
12
+ * signal?: AbortSignal
13
+ *
14
+ * 双截止线(确定性、可测;测试用假 now,不需要真 sleep):
15
+ * - suggestionDeadlineMs(默认 2500ms):超过则本回合不发布建议,计划照常落盘;
16
+ * - commitDeadlineMs(默认 6000ms;强制重写 8000ms):超过则放弃本回合计划,保留 last-good。
17
+ */
18
+
19
+ import { DEFAULTS, numberOption } from './config.js';
20
+ import {
21
+ createSeedPlan,
22
+ isNextVersion,
23
+ mayRewrite,
24
+ patchPlan,
25
+ rewritePlan,
26
+ } from './plan.js';
27
+ import { buildPatchPrompt, buildRepairPrompt, buildRewritePrompt, parseModelOutput } from './prompt.js';
28
+ import { fallbackPlan, heuristicSuggestion } from './heuristics.js';
29
+ import { clampChars, clampSuggestion, oneLine, sanitizeSuggestion } from './text.js';
30
+
31
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+ const isFn = (v) => typeof v === 'function';
33
+
34
+ function errMsg(err) {
35
+ if (err && typeof err.message === 'string') return err.message;
36
+ return String(err);
37
+ }
38
+
39
+ function jobResult(patch) {
40
+ return {
41
+ ok: true,
42
+ committed: false,
43
+ published: false,
44
+ reason: 'error',
45
+ plan: null,
46
+ suggestion: '',
47
+ op: null,
48
+ fallback: false,
49
+ attempts: 0,
50
+ durationMs: 0,
51
+ error: '',
52
+ userMessage: undefined,
53
+ ...patch,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * 幂等键:`sessionId:turn`;强制重写追加 `:replan`,避免和同回合 turn/end 作业撞车。
59
+ * @param {object} job
60
+ * @returns {string}
61
+ */
62
+ export function jobKey(job) {
63
+ const sessionId = isObj(job) && typeof job.sessionId === 'string' && job.sessionId ? job.sessionId : 'unknown';
64
+ const turn = isObj(job) && Number.isInteger(job.turn) ? job.turn : 0;
65
+ const force = isObj(job) && (job.kind === 'force-rewrite' || job.forceRewrite === true);
66
+ return `${sessionId}:${turn}${force ? ':replan' : ''}`;
67
+ }
68
+
69
+ /**
70
+ * 合并两个待执行作业:新的覆盖旧的,强制重写优先,watermark 取最新,记录合并次数。
71
+ * @param {object} prev
72
+ * @param {object} next
73
+ * @returns {object}
74
+ */
75
+ export function mergeJobs(prev, next) {
76
+ if (!isObj(prev)) return next;
77
+ if (!isObj(next)) return prev;
78
+ const prevForce = prev.kind === 'force-rewrite' || prev.forceRewrite === true;
79
+ const nextForce = next.kind === 'force-rewrite' || next.forceRewrite === true;
80
+ const merged = { ...prev, ...next };
81
+ merged.kind = prevForce || nextForce ? 'force-rewrite' : next.kind ?? prev.kind ?? 'turn';
82
+ merged.forceRewrite = prevForce || nextForce || merged.kind === 'force-rewrite';
83
+ merged.watermark = mergeWatermark(prev.watermark, next.watermark);
84
+ merged.evidence = next.evidence ?? prev.evidence;
85
+ merged.plan = next.plan ?? prev.plan;
86
+ merged.turn = Math.max(Number.isInteger(prev.turn) ? prev.turn : 0, Number.isInteger(next.turn) ? next.turn : 0);
87
+ merged.instruction = next.instruction ?? prev.instruction;
88
+ const prevCount = Number.isInteger(prev.mergedFrom) ? prev.mergedFrom : 1;
89
+ const nextCount = Number.isInteger(next.mergedFrom) ? next.mergedFrom : 1;
90
+ merged.mergedFrom = prevCount + nextCount;
91
+ return merged;
92
+ }
93
+
94
+ function mergeWatermark(a, b) {
95
+ const wa = isObj(a) ? a : null;
96
+ const wb = isObj(b) ? b : null;
97
+ if (!wa) return wb ?? undefined;
98
+ if (!wb) return wa;
99
+ const turnA = Number.isInteger(wa.turn) ? wa.turn : 0;
100
+ const turnB = Number.isInteger(wb.turn) ? wb.turn : 0;
101
+ if (turnB > turnA) return { ...wb };
102
+ if (turnA > turnB) return { ...wa };
103
+ return { turn: turnA, seq: Math.max(Number.isInteger(wa.seq) ? wa.seq : 0, Number.isInteger(wb.seq) ? wb.seq : 0) };
104
+ }
105
+
106
+ /**
107
+ * 全局并发闸:跨会话 in-flight 上限(设计 §3.5.2:默认 ≤2)。
108
+ * @param {number} [limit]
109
+ * @returns {{limit:number, acquire:()=>Promise<()=>void>, run:Function, inFlight:()=>number, pendingCount:()=>number}}
110
+ */
111
+ export function globalGate(limit = DEFAULTS.globalConcurrency) {
112
+ const max = Math.max(1, Math.min(32, Math.floor(numberOption(limit, DEFAULTS.globalConcurrency, 1, 32))));
113
+ let active = 0;
114
+ const waiters = [];
115
+
116
+ function releaser() {
117
+ let released = false;
118
+ return () => {
119
+ if (released) return;
120
+ released = true;
121
+ if (active > 0) active -= 1;
122
+ dispatch();
123
+ };
124
+ }
125
+
126
+ function dispatch() {
127
+ while (active < max && waiters.length > 0) {
128
+ const resolve = waiters.shift();
129
+ active += 1;
130
+ resolve(releaser());
131
+ }
132
+ }
133
+
134
+ /** @returns {Promise<() => void>} 释放函数 */
135
+ function acquire() {
136
+ return new Promise((resolve) => {
137
+ waiters.push(resolve);
138
+ dispatch();
139
+ });
140
+ }
141
+
142
+ async function run(fn) {
143
+ const release = await acquire();
144
+ try {
145
+ return await fn();
146
+ } finally {
147
+ release();
148
+ }
149
+ }
150
+
151
+ return { limit: max, acquire, run, inFlight: () => active, pendingCount: () => waiters.length };
152
+ }
153
+
154
+ /**
155
+ * rewrite 防抖(设计 §3.5.7):两次自动 rewrite 需间隔 ≥3 回合且 ≥5 分钟。
156
+ * @param {{lastRewriteTurn?:number, lastRewriteAt?:number}|undefined} gate
157
+ * @param {{turn?:number, now?:number}} [context]
158
+ * @returns {boolean}
159
+ */
160
+ export function rewriteAllowed(gate, context = {}) {
161
+ if (!isObj(gate)) return true;
162
+ const { turn, now } = context;
163
+ if (Number.isInteger(gate.lastRewriteTurn) && Number.isInteger(turn)) {
164
+ if (turn - gate.lastRewriteTurn < DEFAULTS.rewriteMinTurns) return false;
165
+ }
166
+ if (Number.isFinite(gate.lastRewriteAt) && Number.isFinite(now)) {
167
+ if (now - gate.lastRewriteAt < DEFAULTS.rewriteMinIntervalMs) return false;
168
+ }
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * 计算两份步骤列表的差异,用于把「过小的 rewrite diff」降级成 patch。
174
+ *
175
+ * @param {Array} prevSteps
176
+ * @param {Array} nextSteps
177
+ * @returns {{patchable:boolean, changes:Array, added:string[], removed:string[]}}
178
+ */
179
+ export function diffToChanges(prevSteps, nextSteps) {
180
+ const result = { patchable: false, changes: [], added: [], removed: [] };
181
+ const prev = Array.isArray(prevSteps) ? prevSteps.filter(isObj) : [];
182
+ const next = Array.isArray(nextSteps) ? nextSteps.filter(isObj) : [];
183
+ const prevById = new Map(prev.map((step) => [step.id, step]));
184
+ const nextById = new Map(next.map((step) => [step.id, step]));
185
+ for (const step of next) if (!prevById.has(step.id)) result.added.push(step.id);
186
+ for (const step of prev) if (!nextById.has(step.id)) result.removed.push(step.id);
187
+ for (const nextStep of next) {
188
+ const prevStep = prevById.get(nextStep.id);
189
+ if (!prevStep) continue;
190
+ const change = { id: nextStep.id };
191
+ let fields = 0;
192
+ if (nextStep.status !== prevStep.status) {
193
+ change.status = nextStep.status;
194
+ fields += 1;
195
+ }
196
+ if (typeof nextStep.title === 'string' && nextStep.title !== prevStep.title) {
197
+ change.title = nextStep.title;
198
+ fields += 1;
199
+ }
200
+ const prevEvidence = JSON.stringify(prevStep.evidence ?? []);
201
+ const nextEvidence = JSON.stringify(nextStep.evidence ?? []);
202
+ if (prevEvidence !== nextEvidence) {
203
+ change.evidence = Array.isArray(nextStep.evidence) ? nextStep.evidence : [];
204
+ fields += 1;
205
+ }
206
+ if (fields > 0) result.changes.push(change);
207
+ }
208
+ result.patchable = result.added.length === 0 && result.removed.length === 0;
209
+ return result;
210
+ }
211
+
212
+ /**
213
+ * 创建作业队列:同会话串行、期间新作业合并(superseded)、全局并发闸、幂等键去重。
214
+ *
215
+ * @param {{execute: (job:object, runtime:{isSuperseded:()=>boolean})=>Promise<object>, limit?:number, dedupeTtlMs?:number, now?:()=>number, gate?:object}} options
216
+ * @returns {object}
217
+ */
218
+ export function createQueue(options = {}) {
219
+ const opts = isObj(options) ? options : {};
220
+ const execute = opts.execute;
221
+ const limit = numberOption(opts.limit, DEFAULTS.globalConcurrency, 1, 32);
222
+ const dedupeTtlMs = numberOption(opts.dedupeTtlMs, DEFAULTS.dedupeTtlMs, 0, 24 * 60 * 60 * 1000);
223
+ const queueNow = isFn(opts.now) ? opts.now : () => Date.now();
224
+ const gate = isObj(opts.gate) && isFn(opts.gate.acquire) ? opts.gate : globalGate(limit);
225
+
226
+ const sessions = new Map();
227
+ const seen = new Map();
228
+ let closed = false;
229
+ let started = 0;
230
+ let completed = 0;
231
+
232
+ function skip(reason, extra = {}) {
233
+ return { ok: false, skipped: true, reason, ...extra };
234
+ }
235
+
236
+ function pruneSeen() {
237
+ if (seen.size < 500) return;
238
+ const before = queueNow() - Math.max(dedupeTtlMs, 60 * 1000);
239
+ for (const [key, entry] of seen) {
240
+ if (entry.state === 'active') continue;
241
+ if (!Number.isFinite(entry.at) || entry.at < before) seen.delete(key);
242
+ }
243
+ }
244
+
245
+ function settle(record, value) {
246
+ const waiters = record.waiters ?? [];
247
+ record.waiters = [];
248
+ for (const waiter of waiters) {
249
+ try {
250
+ waiter.resolve(value);
251
+ } catch {
252
+ /* ignore */
253
+ }
254
+ }
255
+ }
256
+
257
+ async function runSession(sessionId) {
258
+ const session = sessions.get(sessionId);
259
+ if (!session) return;
260
+ while (session.current) {
261
+ const record = session.current;
262
+ started += 1;
263
+ let value;
264
+ const release = await gate.acquire();
265
+ try {
266
+ const runtime = {
267
+ sessionId,
268
+ isSuperseded: () => session.pending != null || closed,
269
+ };
270
+ value = await execute(record.job, runtime);
271
+ } catch (err) {
272
+ value = jobResult({ ok: false, reason: 'execute-error', error: errMsg(err) });
273
+ } finally {
274
+ release();
275
+ }
276
+ completed += 1;
277
+ const entry = seen.get(record.key);
278
+ if (entry && entry.state === 'active') seen.set(record.key, { state: 'done', at: queueNow() });
279
+ settle(record, value);
280
+ const nextRecord = session.pending;
281
+ session.pending = null;
282
+ session.current = nextRecord;
283
+ if (!nextRecord) break;
284
+ }
285
+ if (sessions.get(sessionId) === session && !session.current) sessions.delete(sessionId);
286
+ }
287
+
288
+ /**
289
+ * 入队一个作业。
290
+ * @param {object} job
291
+ * @returns {Promise<object>} 作业结果;重复/关闭时返回 `{skipped:true, reason}`
292
+ */
293
+ function enqueue(job) {
294
+ try {
295
+ if (!isObj(job)) return Promise.resolve(skip('bad-job'));
296
+ const sessionId = typeof job.sessionId === 'string' ? job.sessionId.trim() : '';
297
+ if (!sessionId) return Promise.resolve(skip('bad-session'));
298
+ if (closed) return Promise.resolve(skip('closed'));
299
+ if (!isFn(execute)) return Promise.resolve(skip('no-execute'));
300
+ const key = jobKey(job);
301
+ const existing = seen.get(key);
302
+ if (existing && existing.state === 'active' && existing.promise) return existing.promise;
303
+ if (
304
+ existing &&
305
+ existing.state === 'done' &&
306
+ dedupeTtlMs > 0 &&
307
+ Number.isFinite(existing.at) &&
308
+ queueNow() - existing.at < dedupeTtlMs
309
+ ) {
310
+ return Promise.resolve(skip('duplicate', { key }));
311
+ }
312
+ pruneSeen();
313
+
314
+ let resolveFn;
315
+ let rejectFn;
316
+ const promise = new Promise((resolve, reject) => {
317
+ resolveFn = resolve;
318
+ rejectFn = reject;
319
+ });
320
+ const record = { job, key, waiters: [{ resolve: resolveFn, reject: rejectFn }] };
321
+ seen.set(key, { state: 'active', promise, at: queueNow() });
322
+
323
+ let session = sessions.get(sessionId);
324
+ if (!session) {
325
+ session = { id: sessionId, current: null, pending: null };
326
+ sessions.set(sessionId, session);
327
+ }
328
+ if (session.current) {
329
+ if (session.pending) {
330
+ session.pending.job = mergeJobs(session.pending.job, job);
331
+ session.pending.waiters.push({ resolve: resolveFn, reject: rejectFn });
332
+ } else {
333
+ session.pending = record;
334
+ }
335
+ return promise;
336
+ }
337
+ session.current = record;
338
+ Promise.resolve()
339
+ .then(() => runSession(sessionId))
340
+ .catch(() => {});
341
+ return promise;
342
+ } catch (err) {
343
+ return Promise.resolve(skip('enqueue-error', { error: errMsg(err) }));
344
+ }
345
+ }
346
+
347
+ /** 关闭队列:不再接受新作业,未开始的合并作业静默取消。 */
348
+ function close() {
349
+ closed = true;
350
+ for (const session of sessions.values()) {
351
+ if (!session.pending) continue;
352
+ settle(session.pending, { ok: false, skipped: true, reason: 'closed' });
353
+ const entry = seen.get(session.pending.key);
354
+ if (entry && entry.state === 'active') seen.set(session.pending.key, { state: 'done', at: queueNow() });
355
+ session.pending = null;
356
+ }
357
+ }
358
+
359
+ /** 等待所有会话空闲。 */
360
+ function idle() {
361
+ return new Promise((resolve) => {
362
+ const check = () => {
363
+ if (sessions.size === 0) {
364
+ resolve();
365
+ return;
366
+ }
367
+ const timer = setTimeout(check, 10);
368
+ if (timer && typeof timer.unref === 'function') timer.unref();
369
+ };
370
+ check();
371
+ });
372
+ }
373
+
374
+ return {
375
+ enqueue,
376
+ close,
377
+ idle,
378
+ gate,
379
+ size: () => sessions.size,
380
+ stats: () => ({
381
+ sessions: sessions.size,
382
+ seen: seen.size,
383
+ started,
384
+ completed,
385
+ inFlight: isFn(gate.inFlight) ? gate.inFlight() : 0,
386
+ gatePending: isFn(gate.pendingCount) ? gate.pendingCount() : 0,
387
+ }),
388
+ };
389
+ }
390
+
391
+ /* ────────────────────────────────────────────────────────────────
392
+ * runJob 编排
393
+ * ──────────────────────────────────────────────────────────────── */
394
+
395
+ function extractModelText(value) {
396
+ if (typeof value === 'string') return value;
397
+ if (value == null) return '';
398
+ if (Array.isArray(value)) {
399
+ const parts = [];
400
+ for (const item of value) {
401
+ const text = extractModelText(item);
402
+ if (text) parts.push(text);
403
+ }
404
+ return parts.join('');
405
+ }
406
+ if (!isObj(value)) return '';
407
+ for (const key of ['text', 'content', 'message', 'output', 'raw', 'value', 'delta']) {
408
+ const nested = value[key];
409
+ if (typeof nested === 'string' && nested) return nested;
410
+ if (Array.isArray(nested)) {
411
+ const text = extractModelText(nested);
412
+ if (text) return text;
413
+ } else if (isObj(nested)) {
414
+ const text = extractModelText(nested);
415
+ if (text) return text;
416
+ }
417
+ }
418
+ return '';
419
+ }
420
+
421
+ function callModelOnce(prompt, ctx) {
422
+ return new Promise((resolve) => {
423
+ let done = false;
424
+ let timer = null;
425
+ const finish = (value) => {
426
+ if (done) return;
427
+ done = true;
428
+ if (timer != null && isFn(ctx.clearTimeoutFn)) {
429
+ try {
430
+ ctx.clearTimeoutFn(timer);
431
+ } catch {
432
+ /* ignore */
433
+ }
434
+ }
435
+ resolve(value);
436
+ };
437
+ const remaining = ctx.deadlineMs;
438
+ if (isFn(ctx.setTimeoutFn) && Number.isFinite(remaining) && remaining > 0) {
439
+ timer = ctx.setTimeoutFn(() => {
440
+ try {
441
+ if (ctx.signal && isFn(ctx.signal.abort)) ctx.signal.abort();
442
+ } catch {
443
+ /* ignore */
444
+ }
445
+ finish({ timedOut: true });
446
+ }, remaining);
447
+ if (timer && typeof timer.unref === 'function') timer.unref();
448
+ }
449
+ let call;
450
+ const arg = {
451
+ system: prompt.system,
452
+ messages: prompt.messages,
453
+ maxTokens: prompt.maxTokens,
454
+ temperature: prompt.temperature,
455
+ };
456
+ if (ctx.signal) arg.signal = ctx.signal;
457
+ try {
458
+ call = ctx.callModel(arg, { deadlineAt: ctx.deadlineAt, deadlineMs: remaining });
459
+ } catch (err) {
460
+ finish({ error: errMsg(err) });
461
+ return;
462
+ }
463
+ Promise.resolve(call).then(
464
+ (value) => finish({ value }),
465
+ (err) => finish({ error: errMsg(err) }),
466
+ );
467
+ });
468
+ }
469
+
470
+ function heuristicTextFor(evidence, plan) {
471
+ const res = heuristicSuggestion({ ...(isObj(evidence) ? evidence : {}), plan: plan ?? evidence?.plan ?? null });
472
+ return res && res.ok ? res.text : '';
473
+ }
474
+
475
+ function buildSeed({ job, evidence, watermark, nowMs }) {
476
+ const intent = oneLine(evidence?.intent ?? evidence?.firstUserMessage ?? job?.intent ?? '');
477
+ const fallback = fallbackPlan(intent);
478
+ if (!fallback.ok) return { ok: false, error: fallback.error };
479
+ const seed = createSeedPlan(
480
+ {
481
+ sessionId: job.sessionId,
482
+ intent,
483
+ steps: fallback.steps,
484
+ watermark,
485
+ reason: 'seed-fallback',
486
+ },
487
+ { now: nowMs },
488
+ );
489
+ if (!seed.ok) return { ok: false, error: seed.error };
490
+ return { ok: true, plan: seed.plan };
491
+ }
492
+
493
+ function applyOutput({ out, plan, job, force, evidence, watermark, nowMs }) {
494
+ const sessionId = job.sessionId ?? evidence?.sessionId ?? plan?.sessionId ?? '';
495
+ const seed = (baseReason) => {
496
+ const built = buildSeed({ job: { ...job, sessionId }, evidence, watermark, nowMs });
497
+ if (!built.ok) return { ok: false, baseReason: 'seed-invalid', error: built.error };
498
+ return { ok: true, plan: built.plan, changed: true, baseReason };
499
+ };
500
+
501
+ switch (out.op) {
502
+ case 'none':
503
+ return plan ? { ok: true, plan: null, changed: false, baseReason: 'no-op' } : seed('seed');
504
+ case 'patch': {
505
+ if (!plan) return seed('patch-without-plan');
506
+ const res = patchPlan(plan, out.changes, { reason: out.reason, watermark, now: nowMs });
507
+ if (!res.ok) return { ok: false, baseReason: 'plan-invalid', error: res.error };
508
+ return { ok: true, plan: res.plan, changed: res.changed !== false, baseReason: res.changed === false ? 'no-op' : 'ok' };
509
+ }
510
+ case 'rewrite': {
511
+ if (!plan) {
512
+ const created = createSeedPlan(
513
+ {
514
+ sessionId,
515
+ intent: out.intent || evidence?.intent || '',
516
+ steps: out.steps,
517
+ watermark,
518
+ reason: out.reason,
519
+ },
520
+ { now: nowMs },
521
+ );
522
+ if (!created.ok) return { ok: false, baseReason: 'plan-invalid', error: created.error };
523
+ return { ok: true, plan: created.plan, changed: true, baseReason: 'ok', seeded: true };
524
+ }
525
+ const turn = job.turn ?? watermark.turn;
526
+ const locked = !mayRewrite(plan, { force });
527
+ const gateOk = force || rewriteAllowed(job.rewriteGate, { turn, now: nowMs });
528
+ if (locked || !gateOk) {
529
+ // 用户版只允许 patch;防抖期也把「小 diff」降级成 patch(设计 §3.5.7)。
530
+ const diff = diffToChanges(plan.steps, out.steps);
531
+ if (diff.patchable && diff.changes.length > 0) {
532
+ const res = patchPlan(plan, diff.changes, { reason: out.reason, watermark, now: nowMs });
533
+ if (!res.ok) return { ok: false, baseReason: 'plan-invalid', error: res.error };
534
+ return { ok: true, plan: res.plan, changed: res.changed !== false, baseReason: res.changed === false ? 'no-op' : 'ok', downgraded: 'patch' };
535
+ }
536
+ return {
537
+ ok: false,
538
+ baseReason: locked ? 'rewrite-locked' : 'rewrite-debounced',
539
+ error: locked ? '用户已编辑计划,模型不能自动 rewrite 覆盖' : '两次 rewrite 间隔不足,本次不重写',
540
+ };
541
+ }
542
+ const res = rewritePlan(plan, out, { reason: out.reason, watermark, now: nowMs, force });
543
+ if (!res.ok) {
544
+ return { ok: false, baseReason: res.code === 'locked-by-user' ? 'rewrite-locked' : 'plan-invalid', error: res.error };
545
+ }
546
+ return { ok: true, plan: res.plan, changed: res.changed !== false, baseReason: res.changed === false ? 'no-op' : 'ok' };
547
+ }
548
+ default:
549
+ return { ok: false, baseReason: 'bad-op', error: `未知 op:${String(out.op)}` };
550
+ }
551
+ }
552
+
553
+ async function commitPlan(ports, plan, cas, meta = {}) {
554
+ if (!isFn(ports.commit)) return { ok: false, missing: true, error: '缺少 commit 端口' };
555
+ try {
556
+ const value = await ports.commit(plan, { ...meta, cas });
557
+ return { ok: value === true, error: '' };
558
+ } catch (err) {
559
+ return { ok: false, threw: true, error: errMsg(err) };
560
+ }
561
+ }
562
+
563
+ /**
564
+ * 执行一次计划作业(一条流水线:证据 → 模型 → 校验 → 落盘 → 发布建议)。
565
+ *
566
+ * @param {{job:object, ports:object, suggestionDeadlineMs?:number, commitDeadlineMs?:number}} params
567
+ * @returns {Promise<{ok:boolean, committed:boolean, published:boolean, reason:string, plan:object|null, suggestion:string, op:string|null, version:object|null, fallback:boolean, attempts:number, durationMs:number, error:string, userMessage?:string}>}
568
+ */
569
+ export async function runJob(params = {}) {
570
+ const input = isObj(params) ? params : {};
571
+ const job = isObj(input.job) ? input.job : null;
572
+ const ports = isObj(input.ports) ? input.ports : {};
573
+
574
+ if (!job) return jobResult({ ok: false, reason: 'bad-job', error: 'job 必须是对象' });
575
+ if (!isFn(ports.callModel)) return jobResult({ ok: false, reason: 'bad-ports', error: '缺少 callModel 端口' });
576
+
577
+ const now = isFn(ports.now) ? ports.now : () => Date.now();
578
+ const t0 = now();
579
+ const force = job.kind === 'force-rewrite' || job.forceRewrite === true;
580
+ const commitDeadlineMs = numberOption(
581
+ input.commitDeadlineMs ?? job.commitDeadlineMs ?? job.planDeadlineMs,
582
+ force ? DEFAULTS.forceRewriteDeadlineMs : DEFAULTS.planDeadlineMs,
583
+ 1,
584
+ 120000,
585
+ );
586
+ const suggestionDeadlineMs = numberOption(
587
+ input.suggestionDeadlineMs ?? job.suggestionDeadlineMs,
588
+ DEFAULTS.suggestionDeadlineMs,
589
+ 0,
590
+ 120000,
591
+ );
592
+ const setTimeoutFn = isFn(ports.setTimeout) ? ports.setTimeout : typeof setTimeout === 'function' ? setTimeout : null;
593
+ const clearTimeoutFn = isFn(ports.clearTimeout) ? ports.clearTimeout : typeof clearTimeout === 'function' ? clearTimeout : null;
594
+ const externalSignal = input.signal ?? ports.signal ?? job.signal ?? null;
595
+
596
+ let controller = null;
597
+ if (typeof AbortController === 'function') {
598
+ controller = new AbortController();
599
+ if (externalSignal && typeof externalSignal === 'object') {
600
+ if (externalSignal.aborted) controller.abort();
601
+ else if (isFn(externalSignal.addEventListener)) {
602
+ try {
603
+ externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
604
+ } catch {
605
+ /* ignore */
606
+ }
607
+ }
608
+ }
609
+ }
610
+ const signal = controller ? controller.signal : externalSignal;
611
+ const aborted = () => Boolean(signal && signal.aborted);
612
+ const isSuperseded = isFn(job.isSuperseded) ? job.isSuperseded : () => false;
613
+ const finish = (patch) => {
614
+ const result = jobResult({ ...patch, durationMs: Math.max(0, now() - t0) });
615
+ return result;
616
+ };
617
+
618
+ try {
619
+ if (aborted()) return finish({ ok: true, reason: 'aborted', userMessage: force ? '重写已取消' : undefined });
620
+ if (isSuperseded()) return finish({ ok: true, reason: 'superseded' });
621
+
622
+ const evidence = isObj(job.evidence) ? job.evidence : {};
623
+ let plan = isObj(job.plan) ? job.plan : isObj(evidence.plan) ? evidence.plan : null;
624
+ const watermark = isObj(job.watermark)
625
+ ? job.watermark
626
+ : isObj(evidence.watermark)
627
+ ? evidence.watermark
628
+ : { turn: Number.isInteger(job.turn) ? job.turn : Number.isInteger(evidence.turn) ? evidence.turn : 0, seq: Number.isInteger(evidence.seq) ? evidence.seq : 0 };
629
+ const turn = Number.isInteger(job.turn) ? job.turn : Number.isInteger(evidence.turn) ? evidence.turn : watermark.turn;
630
+ const sessionId = job.sessionId ?? evidence.sessionId ?? plan?.sessionId ?? '';
631
+
632
+ // ── 1. 构造提示(已有计划 → patch;冷启动/强制 → rewrite)──────────
633
+ const useRewrite = force || !plan;
634
+ let prompt;
635
+ try {
636
+ prompt = useRewrite ? buildRewritePrompt(evidence, { force }) : buildPatchPrompt(evidence);
637
+ } catch (err) {
638
+ return finish({ ok: false, reason: 'prompt-error', error: errMsg(err) });
639
+ }
640
+
641
+ // ── 2. 调模型(双截止线 + JSON 非法修复重发 1 次)────────────────
642
+ const callDeadlineAt = t0 + commitDeadlineMs;
643
+ const maxAttempts = 1 + DEFAULTS.repairRetryMax;
644
+ let attempts = 0;
645
+ let lastText = '';
646
+ let callError = '';
647
+ let timedOut = false;
648
+ let parsed = null;
649
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
650
+ attempts = attempt;
651
+ if (aborted()) return finish({ ok: true, reason: 'aborted', attempts: attempts, userMessage: force ? '重写已取消' : undefined });
652
+ const remaining = callDeadlineAt - now();
653
+ if (remaining <= 0) {
654
+ timedOut = true;
655
+ break;
656
+ }
657
+ const call = await callModelOnce(prompt, {
658
+ callModel: ports.callModel,
659
+ signal,
660
+ deadlineAt: callDeadlineAt,
661
+ deadlineMs: remaining,
662
+ setTimeoutFn,
663
+ clearTimeoutFn,
664
+ });
665
+ if (call.timedOut) {
666
+ timedOut = true;
667
+ break;
668
+ }
669
+ if (call.error) callError = call.error;
670
+ lastText = extractModelText(call.value);
671
+ parsed = parseModelOutput(lastText);
672
+ if (parsed.ok) break;
673
+ if (attempt >= maxAttempts) break;
674
+ prompt = buildRepairPrompt(prompt, lastText, parsed.error || callError || 'invalid');
675
+ }
676
+
677
+ // ── 3. 计划落盘截止线 ───────────────────────────────────────────
678
+ if (timedOut || now() - t0 > commitDeadlineMs) {
679
+ return finish({ ok: true, reason: 'plan-deadline', attempts, userMessage: force ? '重写失败,已保留原计划' : undefined });
680
+ }
681
+ if (aborted()) return finish({ ok: true, reason: 'aborted', attempts, userMessage: force ? '重写已取消' : undefined });
682
+ if (isSuperseded()) return finish({ ok: true, reason: 'superseded', attempts });
683
+
684
+ // ── 4. 模型输出非法 → 保留 last-good(无计划则种子骨架)+ 启发式建议 ──
685
+ if (!parsed || !parsed.ok) {
686
+ const result = finish({
687
+ ok: true,
688
+ reason: callError ? 'model-error' : 'model-invalid',
689
+ fallback: true,
690
+ attempts,
691
+ error: parsed?.error || callError || '',
692
+ });
693
+ if (!plan) {
694
+ const built = buildSeed({ job: { ...job, sessionId }, evidence, watermark, nowMs: now() });
695
+ if (built.ok) {
696
+ const committed = await commitPlan(ports, built.plan, null, { previous: null, now: now() });
697
+ if (committed.ok) {
698
+ result.committed = true;
699
+ result.plan = built.plan;
700
+ result.version = built.plan.version;
701
+ plan = built.plan;
702
+ } else {
703
+ result.reason = committed.threw ? 'commit-error' : 'cas-conflict';
704
+ result.error = committed.error;
705
+ }
706
+ }
707
+ }
708
+ const suggestion = heuristicTextFor(evidence, plan);
709
+ const published = await maybePublish({
710
+ ports,
711
+ job,
712
+ evidence,
713
+ plan,
714
+ out: null,
715
+ suggestion,
716
+ turn,
717
+ sessionId,
718
+ watermark,
719
+ elapsedMs: now() - t0,
720
+ suggestionDeadlineMs,
721
+ isSuperseded,
722
+ aborted,
723
+ result,
724
+ });
725
+ result.published = published;
726
+ if (!result.suggestion && published) result.suggestion = suggestion;
727
+ return result;
728
+ }
729
+
730
+ // ── 5. 应用 op → 版本 bump → CAS 落盘 ───────────────────────────
731
+ const out = parsed.output;
732
+ const result = finish({ ok: true, op: out.op, attempts });
733
+ let committed = false;
734
+ let nextPlan = null;
735
+ let baseReason = 'ok';
736
+
737
+ try {
738
+ const applied = applyOutput({ out, plan, job: { ...job, sessionId }, force, evidence, watermark, nowMs: now() });
739
+ if (!applied.ok) {
740
+ baseReason = applied.baseReason || 'plan-invalid';
741
+ result.error = applied.error || '';
742
+ } else {
743
+ if (applied.downgraded) result.downgraded = applied.downgraded;
744
+ if (applied.seeded) result.seeded = true;
745
+ if (applied.plan) {
746
+ if (plan && !isNextVersion(plan.version, applied.plan.version)) {
747
+ baseReason = 'version-monotonic';
748
+ result.error = '新版本不是当前版本的严格下一版';
749
+ } else if (applied.changed === false) {
750
+ baseReason = 'no-op';
751
+ } else {
752
+ const commitRes = await commitPlan(ports, applied.plan, plan ? plan.version : null, { previous: plan, now: now() });
753
+ if (commitRes.ok) {
754
+ committed = true;
755
+ nextPlan = applied.plan;
756
+ result.committed = true;
757
+ result.plan = applied.plan;
758
+ result.version = applied.plan.version;
759
+ } else {
760
+ baseReason = commitRes.threw ? 'commit-error' : 'cas-conflict';
761
+ result.error = commitRes.error || '';
762
+ }
763
+ }
764
+ } else {
765
+ baseReason = applied.baseReason || 'no-op';
766
+ }
767
+ }
768
+ } catch (err) {
769
+ baseReason = 'apply-error';
770
+ result.error = errMsg(err);
771
+ }
772
+
773
+ // ── 6. 建议:模型给了就用模型;没给则启发式兜底 ─────────────────
774
+ let suggestion = typeof out.suggestion === 'string' ? out.suggestion : '';
775
+ let suggestionSource = suggestion ? 'model' : '';
776
+ if (!suggestion) {
777
+ suggestion = heuristicTextFor(evidence, nextPlan ?? plan);
778
+ suggestionSource = suggestion ? 'heuristic' : '';
779
+ if (suggestion) result.fallback = true;
780
+ }
781
+ const published = await maybePublish({
782
+ ports,
783
+ job,
784
+ evidence,
785
+ plan: nextPlan ?? plan,
786
+ out,
787
+ suggestion,
788
+ turn,
789
+ sessionId,
790
+ watermark,
791
+ elapsedMs: now() - t0,
792
+ suggestionDeadlineMs,
793
+ isSuperseded,
794
+ aborted,
795
+ result,
796
+ });
797
+ result.published = published;
798
+ if (published) {
799
+ result.suggestion = suggestion;
800
+ result.suggestionSource = suggestionSource;
801
+ }
802
+
803
+ // ── 7. 汇总 reason ─────────────────────────────────────────────
804
+ if (!committed) {
805
+ result.reason = baseReason;
806
+ } else if (published) {
807
+ result.reason = 'ok';
808
+ } else {
809
+ result.reason = result.publishSkip || 'committed-no-suggestion';
810
+ }
811
+ return result;
812
+ } catch (err) {
813
+ return finish({ ok: false, reason: 'internal', error: errMsg(err) });
814
+ }
815
+ }
816
+
817
+ async function maybePublish(params) {
818
+ const {
819
+ ports,
820
+ job,
821
+ out,
822
+ suggestion,
823
+ turn,
824
+ sessionId,
825
+ watermark,
826
+ elapsedMs,
827
+ suggestionDeadlineMs,
828
+ isSuperseded,
829
+ aborted,
830
+ result,
831
+ plan,
832
+ } = params;
833
+ const sanitized = sanitizeSuggestion(suggestion);
834
+ if (!sanitized.ok) {
835
+ result.publishSkip = sanitized.filtered ? 'suggestion-filtered' : 'suggestion-invalid';
836
+ return false;
837
+ }
838
+ result.publishSkip = '';
839
+ if (aborted()) {
840
+ result.publishSkip = 'aborted';
841
+ return false;
842
+ }
843
+ if (elapsedMs > suggestionDeadlineMs) {
844
+ result.publishSkip = 'suggestion-deadline';
845
+ return false;
846
+ }
847
+ if (isSuperseded()) {
848
+ result.publishSkip = 'superseded';
849
+ return false;
850
+ }
851
+ const previousSuggestion = job.lastSuggestion ?? job.previousSuggestion;
852
+ if (typeof previousSuggestion === 'string' && previousSuggestion && oneLine(previousSuggestion) === sanitized.text) {
853
+ result.publishSkip = 'suggestion-duplicate';
854
+ return false;
855
+ }
856
+ if (!isFn(ports.publish)) {
857
+ result.publishSkip = 'no-publish-port';
858
+ return false;
859
+ }
860
+ try {
861
+ await ports.publish({
862
+ plan: plan ?? null,
863
+ suggestion: sanitized.text,
864
+ turn,
865
+ sessionId,
866
+ version: plan?.version ?? null,
867
+ watermark,
868
+ alternates: Array.isArray(out?.alternates) ? out.alternates : [],
869
+ op: out?.op ?? 'none',
870
+ fallback: result.fallback === true,
871
+ kind: job.kind ?? 'turn',
872
+ at: Date.now(),
873
+ });
874
+ return true;
875
+ } catch (err) {
876
+ result.publishSkip = 'publish-error';
877
+ result.error = errMsg(err);
878
+ return false;
879
+ }
880
+ }
881
+
882
+ /**
883
+ * 便捷 runner:把 createQueue 与 runJob 串起来(index.js 可直接用)。
884
+ *
885
+ * @param {{ports:object, limit?:number, dedupeTtlMs?:number, now?:()=>number, gate?:object, suggestionDeadlineMs?:number, commitDeadlineMs?:number}} options
886
+ * @returns {{queue:object, submit:(job:object)=>Promise<object>}}
887
+ */
888
+ export function createRunner(options = {}) {
889
+ const opts = isObj(options) ? options : {};
890
+ const ports = isObj(opts.ports) ? opts.ports : {};
891
+ const queue = createQueue({
892
+ limit: opts.limit,
893
+ dedupeTtlMs: opts.dedupeTtlMs,
894
+ now: opts.now,
895
+ gate: opts.gate,
896
+ execute: (job, runtime) =>
897
+ runJob({
898
+ job: { ...job, isSuperseded: () => runtime.isSuperseded() },
899
+ ports,
900
+ suggestionDeadlineMs: opts.suggestionDeadlineMs,
901
+ commitDeadlineMs: opts.commitDeadlineMs,
902
+ }),
903
+ });
904
+ return { queue, submit: (job) => queue.enqueue(job) };
905
+ }