@evomap/evolver-core 2.0.0-beta.6 → 2.0.0-beta.8
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/algo/conversationSniffer.js +6 -2
- package/dist/events/paths.d.ts +9 -9
- package/dist/events/paths.js +18 -18
- package/dist/exec/claudeBridge.d.ts +3 -0
- package/dist/exec/claudeBridge.js +146 -31
- package/dist/issueReporter/index.js +24 -4
- package/dist/util/fileLock.d.ts +5 -3
- package/dist/util/fileLock.js +131 -50
- package/dist/workflow/dsl.d.ts +24 -3
- package/dist/workflow/dsl.js +4 -0
- package/dist/workflow/engine.d.ts +5 -1
- package/dist/workflow/engine.js +3 -0
- package/dist/workflow/index.d.ts +3 -1
- package/dist/workflow/index.js +3 -1
- package/dist/workflow/runtime.d.ts +110 -0
- package/dist/workflow/runtime.js +1298 -0
- package/dist/workflow/stateStore.d.ts +172 -0
- package/dist/workflow/stateStore.js +1044 -0
- package/package.json +1 -1
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { redactString } from '../hub/sanitize.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// The Chinese branches require compound/intentful phrases on purpose: everyday words (能力/流程/成功/通过)
|
|
4
|
+
// occur in nearly every Chinese dev session, and matching them bare made this fallback the DOMINANT distill
|
|
5
|
+
// path — 105 of 118 drafts in one bulk ingest (#562). English gets specificity from \b word boundaries;
|
|
6
|
+
// Chinese has no \b, so specificity must come from the phrase itself.
|
|
7
|
+
const REUSABLE_RE = /\b(reusable|repeatable|workflow|playbook|runbook|capability|procedure|pattern|recipe|documented|future runs?|next time|can reuse|reuse this)\b|复用|可重用|工作流|方法论|沉淀/i;
|
|
8
|
+
const PROOF_RE = /\b(validated|verified|passed|green|success(?:ful|fully)?|succeeded|works?|completed|published|uploaded|recorded:true|exit code:?\s*0|all tests passed)\b|(?:验证|校验|测试|检查|构建|编译|运行|执行|部署|发布)(?:都|均|全部)?(?:通过|成功)|全部通过|跑通|已(?:验证|发布|上线)/i;
|
|
5
9
|
const FAILURE_RE = /\b(failed|failure|error|exception|traceback|exit code:?\s*[1-9]|not working|unable to)\b|失败|错误|报错/i;
|
|
6
10
|
const EXIT_ZERO_RE = /\bexit code:?\s*0\b/i;
|
|
7
11
|
const EXIT_NON_ZERO_RE = /\bexit code:?\s*[1-9]\d*\b/i;
|
package/dist/events/paths.d.ts
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
/** ~/.evomap, 可 EVOLVER_HOME/EVOMAP_HOME 覆盖. */
|
|
2
2
|
export declare function evomapHome(env?: Readonly<Record<string, string | undefined>>): string;
|
|
3
|
-
export declare function rootEventsPath(): string;
|
|
4
|
-
export declare function mvDir(): string;
|
|
3
|
+
export declare function rootEventsPath(env?: Readonly<Record<string, string | undefined>>): string;
|
|
4
|
+
export declare function mvDir(env?: Readonly<Record<string, string | undefined>>): string;
|
|
5
5
|
/** 可进化人格模型持久化文件 (五维向量 + 各键统计 + 变更历史). v1 personality_state.json 的 v2 落点. */
|
|
6
|
-
export declare function personalityStatePath(): string;
|
|
7
|
-
export declare function assetsDir(): string;
|
|
6
|
+
export declare function personalityStatePath(env?: Readonly<Record<string, string | undefined>>): string;
|
|
7
|
+
export declare function assetsDir(env?: Readonly<Record<string, string | undefined>>): string;
|
|
8
8
|
/** M1 raw-material substrate (append-only jsonl consumed by the cycle). */
|
|
9
|
-
export declare function materialDir(): string;
|
|
9
|
+
export declare function materialDir(env?: Readonly<Record<string, string | undefined>>): string;
|
|
10
10
|
/** MaterialStore backing file. */
|
|
11
|
-
export declare function materialStorePath(): string;
|
|
11
|
+
export declare function materialStorePath(env?: Readonly<Record<string, string | undefined>>): string;
|
|
12
12
|
/** Per-source watermark cursor used to make re-ingest idempotent (file-level dedup). */
|
|
13
|
-
export declare function materialWatermarkPath(): string;
|
|
13
|
+
export declare function materialWatermarkPath(env?: Readonly<Record<string, string | undefined>>): string;
|
|
14
14
|
/** Proxy LLM-trace day-files dir (`llm-trace-YYYYMMDD.jsonl`), the route-savings source for the value ledger.
|
|
15
15
|
* Mirrors the proxy's EVOLVER_LLM_TRACE_DIR default so the outreach layer reads what the proxy wrote. */
|
|
16
|
-
export declare function tracesDir(): string;
|
|
16
|
+
export declare function tracesDir(env?: Readonly<Record<string, string | undefined>>): string;
|
|
17
17
|
/** Hub-asset audit trail (`asset_call_log.jsonl`): one JSONL line per hub-asset interaction (search hit/miss,
|
|
18
18
|
* reuse/reference, publish, review). Written best-effort by AssetCallLog; the desktop's RecallHistory / 召回历程
|
|
19
19
|
* reads it. Defaults to the evomap home root, beside assets/ + evolution/.
|
|
20
20
|
* NOTE(reuse/#234): confirm this matches where the proxy/RecallHistory reads — best-effort append, so a path
|
|
21
21
|
* mismatch degrades the audit line, never the reuse write itself. */
|
|
22
|
-
export declare function assetCallLogPath(): string;
|
|
22
|
+
export declare function assetCallLogPath(env?: Readonly<Record<string, string | undefined>>): string;
|
package/dist/events/paths.js
CHANGED
|
@@ -4,41 +4,41 @@ import { join } from 'node:path';
|
|
|
4
4
|
export function evomapHome(env = process.env) {
|
|
5
5
|
return env['EVOLVER_HOME'] ?? env['EVOMAP_HOME'] ?? join(homedir(), '.evomap');
|
|
6
6
|
}
|
|
7
|
-
export function rootEventsPath() {
|
|
8
|
-
return join(evomapHome(), 'evolution', 'root_events.jsonl');
|
|
7
|
+
export function rootEventsPath(env = process.env) {
|
|
8
|
+
return join(evomapHome(env), 'evolution', 'root_events.jsonl');
|
|
9
9
|
}
|
|
10
|
-
export function mvDir() {
|
|
11
|
-
return join(evomapHome(), 'evolution', 'mv');
|
|
10
|
+
export function mvDir(env = process.env) {
|
|
11
|
+
return join(evomapHome(env), 'evolution', 'mv');
|
|
12
12
|
}
|
|
13
13
|
/** 可进化人格模型持久化文件 (五维向量 + 各键统计 + 变更历史). v1 personality_state.json 的 v2 落点. */
|
|
14
|
-
export function personalityStatePath() {
|
|
15
|
-
return join(evomapHome(), 'evolution', 'personality_state.json');
|
|
14
|
+
export function personalityStatePath(env = process.env) {
|
|
15
|
+
return join(evomapHome(env), 'evolution', 'personality_state.json');
|
|
16
16
|
}
|
|
17
|
-
export function assetsDir() {
|
|
18
|
-
return join(evomapHome(), 'assets');
|
|
17
|
+
export function assetsDir(env = process.env) {
|
|
18
|
+
return join(evomapHome(env), 'assets');
|
|
19
19
|
}
|
|
20
20
|
/** M1 raw-material substrate (append-only jsonl consumed by the cycle). */
|
|
21
|
-
export function materialDir() {
|
|
22
|
-
return join(evomapHome(), 'evolution', 'material');
|
|
21
|
+
export function materialDir(env = process.env) {
|
|
22
|
+
return join(evomapHome(env), 'evolution', 'material');
|
|
23
23
|
}
|
|
24
24
|
/** MaterialStore backing file. */
|
|
25
|
-
export function materialStorePath() {
|
|
26
|
-
return join(materialDir(), 'material.jsonl');
|
|
25
|
+
export function materialStorePath(env = process.env) {
|
|
26
|
+
return join(materialDir(env), 'material.jsonl');
|
|
27
27
|
}
|
|
28
28
|
/** Per-source watermark cursor used to make re-ingest idempotent (file-level dedup). */
|
|
29
|
-
export function materialWatermarkPath() {
|
|
30
|
-
return join(materialDir(), 'watermark.json');
|
|
29
|
+
export function materialWatermarkPath(env = process.env) {
|
|
30
|
+
return join(materialDir(env), 'watermark.json');
|
|
31
31
|
}
|
|
32
32
|
/** Proxy LLM-trace day-files dir (`llm-trace-YYYYMMDD.jsonl`), the route-savings source for the value ledger.
|
|
33
33
|
* Mirrors the proxy's EVOLVER_LLM_TRACE_DIR default so the outreach layer reads what the proxy wrote. */
|
|
34
|
-
export function tracesDir() {
|
|
35
|
-
return
|
|
34
|
+
export function tracesDir(env = process.env) {
|
|
35
|
+
return env['EVOLVER_LLM_TRACE_DIR'] ?? join(evomapHome(env), 'proxy', 'traces');
|
|
36
36
|
}
|
|
37
37
|
/** Hub-asset audit trail (`asset_call_log.jsonl`): one JSONL line per hub-asset interaction (search hit/miss,
|
|
38
38
|
* reuse/reference, publish, review). Written best-effort by AssetCallLog; the desktop's RecallHistory / 召回历程
|
|
39
39
|
* reads it. Defaults to the evomap home root, beside assets/ + evolution/.
|
|
40
40
|
* NOTE(reuse/#234): confirm this matches where the proxy/RecallHistory reads — best-effort append, so a path
|
|
41
41
|
* mismatch degrades the audit line, never the reuse write itself. */
|
|
42
|
-
export function assetCallLogPath() {
|
|
43
|
-
return join(evomapHome(), 'asset_call_log.jsonl');
|
|
42
|
+
export function assetCallLogPath(env = process.env) {
|
|
43
|
+
return join(evomapHome(env), 'asset_call_log.jsonl');
|
|
44
44
|
}
|
|
@@ -108,6 +108,9 @@ export declare class ExecBridgeForbiddenError extends Error {
|
|
|
108
108
|
export declare class UnsandboxedFullAccessRequiresIsolationError extends Error {
|
|
109
109
|
constructor();
|
|
110
110
|
}
|
|
111
|
+
export declare class UnsafeWorktreePathError extends Error {
|
|
112
|
+
constructor(reason: string);
|
|
113
|
+
}
|
|
111
114
|
/**
|
|
112
115
|
* Whitelist-filter `env` for a spawned agent/tool: keep ONLY the minimal runtime env + the caller-declared
|
|
113
116
|
* extras (the runner's own auth via allowPrefixes/allowKeys); drop everything else. Fail-safe by construction —
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
15
|
import { resolve as resolvePath, sep, join as joinPath } from 'node:path';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
|
-
import { closeSync,
|
|
17
|
+
import { closeSync, lstatSync, mkdtempSync, openSync, realpathSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
18
18
|
import { renderExecPrompt } from './prompt.js';
|
|
19
19
|
import { parseGitShortstat, gitDiffProof } from './proofOfWork.js';
|
|
20
20
|
// Policy enforcement core (#107): checkPolicy runs the always-on global guards (blast hard cap +
|
|
@@ -55,6 +55,112 @@ export class UnsandboxedFullAccessRequiresIsolationError extends Error {
|
|
|
55
55
|
this.name = 'UnsandboxedFullAccessRequiresIsolationError';
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
export class UnsafeWorktreePathError extends Error {
|
|
59
|
+
constructor(reason) {
|
|
60
|
+
super(`worktree isolation refused: ${reason}`);
|
|
61
|
+
this.name = 'UnsafeWorktreePathError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function errorCode(error) {
|
|
65
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
66
|
+
? String(error.code)
|
|
67
|
+
: undefined;
|
|
68
|
+
}
|
|
69
|
+
function assertPathAbsent(path) {
|
|
70
|
+
try {
|
|
71
|
+
lstatSync(path);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (errorCode(error) === 'ENOENT')
|
|
75
|
+
return;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
throw new UnsafeWorktreePathError('reserved destination already exists');
|
|
79
|
+
}
|
|
80
|
+
function reserveWorktreePath() {
|
|
81
|
+
// The random 0700 container is atomically created by the OS. The git destination remains absent inside it,
|
|
82
|
+
// so `git worktree add` cannot adopt an attacker-precreated path from the shared temp directory.
|
|
83
|
+
const container = mkdtempSync(joinPath(tmpdir(), 'evolver-wt-'));
|
|
84
|
+
const containerStat = lstatSync(container);
|
|
85
|
+
if (containerStat.isSymbolicLink() || !containerStat.isDirectory()) {
|
|
86
|
+
throw new UnsafeWorktreePathError('temporary reservation is not a real directory');
|
|
87
|
+
}
|
|
88
|
+
const workDir = joinPath(container, 'worktree');
|
|
89
|
+
assertPathAbsent(workDir);
|
|
90
|
+
return {
|
|
91
|
+
container,
|
|
92
|
+
containerDev: containerStat.dev,
|
|
93
|
+
containerIno: containerStat.ino,
|
|
94
|
+
workDir,
|
|
95
|
+
expectedRealPath: joinPath(realpathSync(container), 'worktree'),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function verifyWorktreePath(reservation) {
|
|
99
|
+
const stat = lstatSync(reservation.workDir);
|
|
100
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
101
|
+
throw new UnsafeWorktreePathError('git worktree destination is not a real directory');
|
|
102
|
+
}
|
|
103
|
+
if (realpathSync(reservation.workDir) !== reservation.expectedRealPath) {
|
|
104
|
+
throw new UnsafeWorktreePathError('git worktree destination resolves outside its reservation');
|
|
105
|
+
}
|
|
106
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
107
|
+
}
|
|
108
|
+
function worktreePathStillOwned(reservation, identity) {
|
|
109
|
+
try {
|
|
110
|
+
const stat = lstatSync(reservation.workDir);
|
|
111
|
+
return !stat.isSymbolicLink()
|
|
112
|
+
&& stat.isDirectory()
|
|
113
|
+
&& stat.dev === identity.dev
|
|
114
|
+
&& stat.ino === identity.ino
|
|
115
|
+
&& realpathSync(reservation.workDir) === reservation.expectedRealPath;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function removeEmptyReservation(reservation) {
|
|
122
|
+
try {
|
|
123
|
+
const stat = lstatSync(reservation.container);
|
|
124
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()
|
|
125
|
+
|| stat.dev !== reservation.containerDev || stat.ino !== reservation.containerIno)
|
|
126
|
+
return;
|
|
127
|
+
rmdirSync(reservation.container);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
// Never recurse through a path that may have been replaced. Empty, owned reservations are the only thing
|
|
131
|
+
// removed directly; non-empty or already-gone containers are intentionally left for safe diagnosis.
|
|
132
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(errorCode(error) ?? ''))
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function cleanupWorktreeReservation(reservation, identity, git, repoCwd) {
|
|
137
|
+
let cleanupIdentity = identity;
|
|
138
|
+
if (!cleanupIdentity) {
|
|
139
|
+
try {
|
|
140
|
+
cleanupIdentity = verifyWorktreePath(reservation);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (errorCode(error) !== 'ENOENT')
|
|
144
|
+
throw error;
|
|
145
|
+
// `git worktree add` may register metadata before failing without creating the destination. Give Git a
|
|
146
|
+
// signal-shielded chance to prune that partial registration, then reclaim only the still-empty container.
|
|
147
|
+
try {
|
|
148
|
+
await git(['worktree', 'remove', '--force', reservation.workDir], repoCwd, undefined, { processSignalMode: 'ignore' });
|
|
149
|
+
}
|
|
150
|
+
catch { /* no child exists, so an unregistered cleanup failure is harmless */ }
|
|
151
|
+
removeEmptyReservation(reservation);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (cleanupIdentity) {
|
|
156
|
+
if (!worktreePathStillOwned(reservation, cleanupIdentity)) {
|
|
157
|
+
throw new UnsafeWorktreePathError('verified worktree path changed before cleanup');
|
|
158
|
+
}
|
|
159
|
+
await git(['worktree', 'remove', '--force', reservation.workDir], repoCwd, undefined, { processSignalMode: 'ignore' });
|
|
160
|
+
assertPathAbsent(reservation.workDir);
|
|
161
|
+
}
|
|
162
|
+
removeEmptyReservation(reservation);
|
|
163
|
+
}
|
|
58
164
|
/** Whether `child` is the same as, or nested under, `root` (both resolved to absolute paths). */
|
|
59
165
|
function isWithinRoot(child, root) {
|
|
60
166
|
const c = resolvePath(child);
|
|
@@ -278,27 +384,26 @@ export function makeClaudeExecBridge(opts) {
|
|
|
278
384
|
// use-case ①: inject the personality style block from the state applySelectForRun just persisted.
|
|
279
385
|
...(opts.personality ? { personality: opts.personality.currentState() } : {}),
|
|
280
386
|
});
|
|
281
|
-
// Isolation:
|
|
387
|
+
// Isolation: atomically reserve an unpredictable private temp container, then let git create the absent
|
|
388
|
+
// worktree path inside it. Cleanup is armed only after git succeeds and the resulting directory is verified.
|
|
282
389
|
const isolate = opts.isolation === 'worktree';
|
|
283
390
|
if (opts.signal?.aborted)
|
|
284
391
|
return cancelledExecutionResult(undefined);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
let worktreeRegistered = false;
|
|
392
|
+
const reservation = isolate ? reserveWorktreePath() : undefined;
|
|
393
|
+
const workDir = reservation?.workDir ?? opts.cwd;
|
|
394
|
+
let worktreeIdentity;
|
|
395
|
+
let result;
|
|
290
396
|
let observedRun;
|
|
291
397
|
let patchRef;
|
|
292
398
|
let ownsPatchRef = false;
|
|
293
399
|
let preservePatchRef = false;
|
|
294
400
|
let failedProof;
|
|
295
|
-
const proofGit = async (args, cwd,
|
|
401
|
+
const proofGit = async (args, cwd, checkCancellationAfter = true) => {
|
|
296
402
|
if (opts.signal?.aborted)
|
|
297
403
|
throw new ExecBridgeRunCancelledError();
|
|
298
404
|
try {
|
|
299
405
|
const output = await git(args, cwd, opts.signal);
|
|
300
|
-
|
|
301
|
-
if (opts.signal?.aborted)
|
|
406
|
+
if (checkCancellationAfter && opts.signal?.aborted)
|
|
302
407
|
throw new ExecBridgeRunCancelledError();
|
|
303
408
|
return output;
|
|
304
409
|
}
|
|
@@ -311,8 +416,11 @@ export function makeClaudeExecBridge(opts) {
|
|
|
311
416
|
}
|
|
312
417
|
};
|
|
313
418
|
try {
|
|
314
|
-
if (
|
|
315
|
-
await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd,
|
|
419
|
+
if (reservation) {
|
|
420
|
+
await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd, false);
|
|
421
|
+
worktreeIdentity = verifyWorktreePath(reservation);
|
|
422
|
+
if (opts.signal?.aborted)
|
|
423
|
+
throw new ExecBridgeRunCancelledError();
|
|
316
424
|
}
|
|
317
425
|
const run = await agent(prompt, {
|
|
318
426
|
cwd: workDir,
|
|
@@ -443,7 +551,7 @@ export function makeClaudeExecBridge(opts) {
|
|
|
443
551
|
reason = summarizeViolations(violations);
|
|
444
552
|
}
|
|
445
553
|
preservePatchRef = patchRef !== undefined && ownsPatchRef;
|
|
446
|
-
|
|
554
|
+
result = {
|
|
447
555
|
outcome: { status: passed ? 'success' : 'failed', score, ...(reason ? { reason } : {}) },
|
|
448
556
|
proofOfWork: proof,
|
|
449
557
|
strongEvidence: passed && stat.files > 0,
|
|
@@ -457,12 +565,22 @@ export function makeClaudeExecBridge(opts) {
|
|
|
457
565
|
}
|
|
458
566
|
catch (error) {
|
|
459
567
|
if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
|
|
460
|
-
|
|
568
|
+
result = cancelledExecutionResult(observedRun);
|
|
461
569
|
}
|
|
462
|
-
if (error instanceof GitProofError && observedRun) {
|
|
463
|
-
|
|
570
|
+
else if (error instanceof GitProofError && observedRun) {
|
|
571
|
+
result = failedProofExecutionResult(observedRun, error, failedProof);
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
if (reservation) {
|
|
575
|
+
try {
|
|
576
|
+
await cleanupWorktreeReservation(reservation, worktreeIdentity, git, opts.cwd);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
// Preserve the primary execution error. The abandoned reservation remains private and diagnosable.
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
throw error;
|
|
464
583
|
}
|
|
465
|
-
throw error;
|
|
466
584
|
}
|
|
467
585
|
finally {
|
|
468
586
|
if (!preservePatchRef && ownsPatchRef && patchRef) {
|
|
@@ -471,21 +589,18 @@ export function makeClaudeExecBridge(opts) {
|
|
|
471
589
|
}
|
|
472
590
|
catch { /* best-effort cleanup must not replace the execution result or its primary failure */ }
|
|
473
591
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
rmSync(worktreeReservation, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
485
|
-
}
|
|
486
|
-
catch { /* best-effort cleanup of the owned reservation */ }
|
|
487
|
-
}
|
|
592
|
+
}
|
|
593
|
+
if (reservation) {
|
|
594
|
+
try {
|
|
595
|
+
await cleanupWorktreeReservation(reservation, worktreeIdentity, git, opts.cwd);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
// A successful run must not hide abandoned edits/resources. Failed and cancelled results retain their
|
|
599
|
+
// primary classification; the private reservation remains available for diagnosis.
|
|
600
|
+
if (result.outcome.status === 'success')
|
|
601
|
+
throw error;
|
|
488
602
|
}
|
|
489
603
|
}
|
|
604
|
+
return result;
|
|
490
605
|
};
|
|
491
606
|
}
|
|
@@ -10,6 +10,7 @@ const STATE_VERSION = 1;
|
|
|
10
10
|
const DEFAULT_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
11
11
|
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
|
12
12
|
const DEFAULT_MAX_SUBMISSIONS = 2;
|
|
13
|
+
const PENDING_RESOLUTION_GRACE_MS = 5 * 60 * 1000;
|
|
13
14
|
const MAX_IDS = 5;
|
|
14
15
|
const MAX_REMOTE_RECONCILE_PAGES = 10;
|
|
15
16
|
const REMOTE_RECONCILE_PAGE_SIZE = 100;
|
|
@@ -902,6 +903,11 @@ function safeIsoNow(now) {
|
|
|
902
903
|
}
|
|
903
904
|
return new Date().toISOString();
|
|
904
905
|
}
|
|
906
|
+
function pendingSubmissionIsLive(reservedAt, nowMs) {
|
|
907
|
+
const reservedAtMs = Date.parse(reservedAt);
|
|
908
|
+
return !Number.isFinite(reservedAtMs)
|
|
909
|
+
|| nowMs - reservedAtMs < PENDING_RESOLUTION_GRACE_MS;
|
|
910
|
+
}
|
|
905
911
|
function latestSubmission(state, fingerprint) {
|
|
906
912
|
for (let index = state.submissions.length - 1; index >= 0; index -= 1) {
|
|
907
913
|
const record = state.submissions[index];
|
|
@@ -1353,6 +1359,18 @@ export async function submitIssueDraft(draft, submit, options) {
|
|
|
1353
1359
|
status: 'rejected',
|
|
1354
1360
|
updatedAt: priorRejection.rejectedAt,
|
|
1355
1361
|
});
|
|
1362
|
+
const submittedGuard = {
|
|
1363
|
+
version: STATE_VERSION,
|
|
1364
|
+
fingerprint: prepared.draft.fingerprint,
|
|
1365
|
+
attemptId: prepared.attemptId,
|
|
1366
|
+
reservedAt: prepared.reservedAt,
|
|
1367
|
+
status: 'submitted',
|
|
1368
|
+
};
|
|
1369
|
+
writeSubmissionGuard(options.rootDir, submittedGuard);
|
|
1370
|
+
state.reservations = state.reservations.filter((record) => !(record.fingerprint === prepared.draft.fingerprint
|
|
1371
|
+
&& record.attemptId === prepared.attemptId
|
|
1372
|
+
&& record.reservedAt === prepared.reservedAt));
|
|
1373
|
+
writeState(options.rootDir, state);
|
|
1356
1374
|
throw new Error('issue_report_rejected_before_finalize');
|
|
1357
1375
|
}
|
|
1358
1376
|
const priorSubmission = latestSubmission(state, prepared.draft.fingerprint);
|
|
@@ -1498,18 +1516,21 @@ export function resolveIssueSubmission(fingerprint, resolution, options) {
|
|
|
1498
1516
|
const storedGuard = readSubmissionGuard(options.rootDir, fingerprint);
|
|
1499
1517
|
const guard = recoverPreparingReservation(options.rootDir, state, quotaIndex, storedGuard);
|
|
1500
1518
|
const reservations = state.reservations.filter((record) => record.fingerprint === fingerprint);
|
|
1501
|
-
|
|
1502
|
-
|
|
1519
|
+
const timestamp = safeIsoNow(options.now);
|
|
1520
|
+
const nowMs = Date.parse(timestamp);
|
|
1521
|
+
if ((guard?.status === 'pending' && pendingSubmissionIsLive(guard.reservedAt, nowMs))
|
|
1522
|
+
|| reservations.some((record) => (record.status === 'pending' && pendingSubmissionIsLive(record.reservedAt, nowMs)))) {
|
|
1503
1523
|
throw new IssueDraftConflictError('issue_report_submission_in_flight');
|
|
1504
1524
|
}
|
|
1505
1525
|
if (guard?.status === 'submitted' && resolution.outcome !== 'submitted') {
|
|
1506
1526
|
throw new IssueDraftConflictError('issue_report_submission_ambiguous');
|
|
1507
1527
|
}
|
|
1508
|
-
const reservation = reservations.filter((record) => record.status === 'ambiguous').at(-1);
|
|
1528
|
+
const reservation = reservations.filter((record) => record.status === 'ambiguous' || record.status === 'pending').at(-1);
|
|
1509
1529
|
const quotaEntry = quotaIndex.entries.filter((entry) => entry.fingerprint === fingerprint).at(-1);
|
|
1510
1530
|
const attempt = state.attempts.filter((record) => record.fingerprint === fingerprint).at(-1);
|
|
1511
1531
|
const guardEvidence = guard?.status === 'ambiguous'
|
|
1512
1532
|
|| (guard?.status === 'submitted' && resolution.outcome === 'submitted')
|
|
1533
|
+
|| guard?.status === 'pending'
|
|
1513
1534
|
? guard
|
|
1514
1535
|
: storedGuard?.status === 'preparing' ? storedGuard : undefined;
|
|
1515
1536
|
if (!guardEvidence && !reservation && !quotaEntry && !attempt) {
|
|
@@ -1523,7 +1544,6 @@ export function resolveIssueSubmission(fingerprint, resolution, options) {
|
|
|
1523
1544
|
?? reservation?.reservedAt
|
|
1524
1545
|
?? quotaEntry?.countedAt
|
|
1525
1546
|
?? attempt.attemptedAt;
|
|
1526
|
-
const timestamp = safeIsoNow(options.now);
|
|
1527
1547
|
if (resolution.outcome === 'submitted') {
|
|
1528
1548
|
const submitted = {
|
|
1529
1549
|
...draft,
|
package/dist/util/fileLock.d.ts
CHANGED
|
@@ -20,9 +20,11 @@ export interface AcquireLockOptions {
|
|
|
20
20
|
* The lock file records the owner pid and token. If a waiter finds the lock held by a pid that is no longer
|
|
21
21
|
* alive (the owner crashed without releaseLock), it reclaims the stale lock instead of spinning
|
|
22
22
|
* until timeout — otherwise one crashed process would deadlock every future writer until the file
|
|
23
|
-
* is removed by hand.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* is removed by hand. Empty or truncated locks are reclaimed only after the same inode and contents
|
|
24
|
+
* remain malformed for a grace period, so a live creator can finish publishing its owner payload.
|
|
25
|
+
* Acquisition stays atomic (O_EXCL), and stale reclaim moves the old lock aside under a mutation
|
|
26
|
+
* guard, then verifies the inode snapshot before deletion. A live owner's lock (including this
|
|
27
|
+
* process's own) is never stolen.
|
|
26
28
|
*
|
|
27
29
|
* NOTE: still synchronous (blocks the event loop while waiting) by design — it guards short
|
|
28
30
|
* synchronous critical sections (append-only writes).
|