workflow-ai 1.0.64 → 1.0.65
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/README.md +239 -145
- package/configs/agent-health-rules.yaml +64 -0
- package/configs/pipeline.yaml +18 -1
- package/package.json +1 -1
- package/src/init.mjs +20 -3
- package/src/lib/agent-health-registry.mjs +245 -0
- package/src/lib/artifact-snapshot.mjs +233 -0
- package/src/lib/error-classifier.mjs +274 -0
- package/src/lib/test-error-classifier.mjs +60 -0
- package/src/lib/test-extends.mjs +58 -0
- package/src/lib/test-version.mjs +21 -0
- package/src/scripts/move-to-review.js +5 -7
- package/src/scripts/reset-agent-health.js +62 -0
- package/src/skills/coach/SKILL.md +1 -0
- package/src/skills/coach/tests/cases/TC-COACH-001/current/meta.json +2 -3
- package/src/skills/coach/tests/cases/TC-COACH-002/current/meta.json +2 -3
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/claude-sonnet/trial-1.md +23 -31
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/claude-sonnet/trial-2.md +20 -35
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/claude-sonnet/trial-3.md +36 -19
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/judge.json +1 -1
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-deepseek/trial-2.md +11 -5
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-deepseek/trial-3.md +12 -16
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-glm/trial-1.md +15 -9
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-glm/trial-3.md +15 -14
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-minimax/trial-1.md +22 -18
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-minimax/trial-2.md +24 -16
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/kilo-minimax/trial-3.md +13 -20
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-001/current/meta.json +2 -2
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/claude-sonnet/trial-1.md +14 -19
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/claude-sonnet/trial-2.md +24 -14
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/claude-sonnet/trial-3.md +20 -19
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/judge.json +16 -17
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-deepseek/trial-1.md +0 -7
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-deepseek/trial-2.md +9 -10
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-deepseek/trial-3.md +5 -5
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-glm/trial-1.md +20 -4
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-glm/trial-2.md +36 -9
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-glm/trial-3.md +9 -6
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-minimax/trial-1.md +4 -12
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-minimax/trial-2.md +6 -8
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/kilo-minimax/trial-3.md +8 -4
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-002/current/meta.json +10 -11
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/claude-sonnet/trial-1.md +30 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/claude-sonnet/trial-2.md +30 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/claude-sonnet/trial-3.md +30 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/judge.json +165 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-deepseek/trial-1.md +5 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-deepseek/trial-2.md +26 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-deepseek/trial-3.md +5 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-glm/trial-1.md +39 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-glm/trial-2.md +37 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-glm/trial-3.md +45 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-minimax/trial-1.md +26 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-minimax/trial-2.md +27 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/kilo-minimax/trial-3.md +7 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003/current/meta.json +117 -0
- package/src/skills/decompose-gaps/tests/cases/TC-DECOMPOSE-GAPS-003-parent-plan-mandatory.yaml +41 -0
- package/src/skills/decompose-gaps/tests/index.yaml +5 -0
- package/src/skills/decompose-gaps/tests/rubrics/parent-plan-mandatory.md +22 -0
- package/src/skills/decompose-gaps/workflows/decompose.md +5 -2
- package/src/skills/decompose-plan/knowledge/atomicity-checklist.md +31 -5
- package/src/skills/decompose-plan/knowledge/capabilities.md +29 -5
- package/src/skills/decompose-plan/knowledge/human-task-rules.md +15 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/claude-sonnet/trial-1.md +55 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/claude-sonnet/trial-2.md +49 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/claude-sonnet/trial-3.md +49 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/judge.json +163 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-deepseek/trial-1.md +104 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-deepseek/trial-2.md +45 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-deepseek/trial-3.md +58 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-glm/trial-1.md +193 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-glm/trial-2.md +202 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-glm/trial-3.md +155 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-minimax/trial-1.md +52 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-minimax/trial-2.md +17 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/kilo-minimax/trial-3.md +0 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004/current/meta.json +115 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-004-executor-atomicity.yaml +64 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/claude-sonnet/trial-1.md +59 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/claude-sonnet/trial-2.md +204 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/claude-sonnet/trial-3.md +213 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/judge.json +163 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-deepseek/trial-1.md +0 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-deepseek/trial-2.md +57 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-deepseek/trial-3.md +54 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-glm/trial-1.md +147 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-glm/trial-2.md +165 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-glm/trial-3.md +133 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-minimax/trial-1.md +81 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-minimax/trial-2.md +108 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/kilo-minimax/trial-3.md +3 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/meta.json +114 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005-capabilities-registry.yaml +78 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/claude-sonnet/trial-1.md +225 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/claude-sonnet/trial-2.md +66 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/claude-sonnet/trial-3.md +36 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/judge.json +163 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-deepseek/trial-1.md +42 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-deepseek/trial-2.md +67 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-deepseek/trial-3.md +40 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-glm/trial-1.md +122 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-glm/trial-2.md +131 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-glm/trial-3.md +138 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-minimax/trial-1.md +41 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-minimax/trial-2.md +88 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/kilo-minimax/trial-3.md +0 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006/current/meta.json +115 -0
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-006-dod-threshold.yaml +72 -0
- package/src/skills/decompose-plan/tests/index.yaml +15 -0
- package/src/skills/decompose-plan/tests/rubrics/capabilities-registry.md +21 -0
- package/src/skills/decompose-plan/tests/rubrics/dod-threshold.md +21 -0
- package/src/skills/decompose-plan/tests/rubrics/executor-atomicity.md +21 -0
- package/src/skills/decompose-plan/workflows/decompose.md +38 -5
- package/src/skills/execute-task/tests/cases/TC-EXECUTE-TASK-001/current/meta.json +3 -4
- package/src/skills/execute-task/tests/cases/TC-EXECUTE-TASK-005/current/meta.json +3 -4
- package/src/skills/manual-testing/SKILL.md +6 -4
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/claude-sonnet/trial-1.md +29 -16
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/claude-sonnet/trial-2.md +21 -54
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/claude-sonnet/trial-3.md +18 -23
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/judge.json +17 -17
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-001/current/meta.json +19 -19
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/claude-sonnet/trial-1.md +27 -30
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/claude-sonnet/trial-2.md +16 -23
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/claude-sonnet/trial-3.md +35 -28
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/judge.json +13 -13
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-002/current/meta.json +15 -15
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/claude-sonnet/trial-1.md +76 -0
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/claude-sonnet/trial-2.md +71 -0
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/claude-sonnet/trial-3.md +85 -0
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/judge.json +46 -0
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003/current/meta.json +36 -0
- package/src/skills/manual-testing/tests/cases/TC-MANUAL-TESTING-003-qa-non-ui-assertion.yaml +65 -0
- package/src/skills/manual-testing/tests/index.yaml +5 -0
- package/src/skills/manual-testing/tests/rubrics/qa-non-ui-assertion.md +31 -0
- package/src/skills/review-result/scripts/verify-artifacts.js +42 -12
package/src/init.mjs
CHANGED
|
@@ -357,7 +357,7 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
357
357
|
errors: []
|
|
358
358
|
};
|
|
359
359
|
|
|
360
|
-
// Step 1: Create .workflow/ structure (
|
|
360
|
+
// Step 1: Create .workflow/ structure (directories)
|
|
361
361
|
const directories = [
|
|
362
362
|
'tickets/backlog',
|
|
363
363
|
'tickets/ready',
|
|
@@ -371,13 +371,21 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
371
371
|
'logs',
|
|
372
372
|
'templates',
|
|
373
373
|
'src/skills',
|
|
374
|
-
'tests/skills'
|
|
374
|
+
'tests/skills',
|
|
375
|
+
'state'
|
|
375
376
|
];
|
|
376
377
|
|
|
377
378
|
for (const dir of directories) {
|
|
378
379
|
ensureDir(join(workflowRoot, dir));
|
|
379
380
|
}
|
|
380
|
-
result.steps.push(
|
|
381
|
+
result.steps.push(`Created .workflow/ directory structure (${directories.length} directories)`);
|
|
382
|
+
|
|
383
|
+
// Create .gitkeep in .workflow/tests/skills/
|
|
384
|
+
// FIX-9: Ensure .gitkeep exists for tests/skills directory
|
|
385
|
+
const testsSkillsGitkeep = join(workflowRoot, 'tests', 'skills', '.gitkeep');
|
|
386
|
+
if (!existsSync(testsSkillsGitkeep)) {
|
|
387
|
+
writeFileSync(testsSkillsGitkeep, '');
|
|
388
|
+
}
|
|
381
389
|
|
|
382
390
|
// Step 2: Ensure global dir and create skill junctions
|
|
383
391
|
const globalDir = getGlobalDir();
|
|
@@ -432,6 +440,15 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
432
440
|
updateGitignore(projectRoot);
|
|
433
441
|
result.steps.push('Updated .gitignore with .workflow/logs/');
|
|
434
442
|
|
|
443
|
+
// Step 10: Copy agent-health-rules.yaml to .workflow/config/
|
|
444
|
+
const agentHealthRulesSrc = join(packageRoot, 'configs', 'agent-health-rules.yaml');
|
|
445
|
+
const agentHealthRulesDest = join(workflowRoot, 'config', 'agent-health-rules.yaml');
|
|
446
|
+
if (existsSync(agentHealthRulesSrc)) {
|
|
447
|
+
ensureDir(dirname(agentHealthRulesDest));
|
|
448
|
+
copyFileSync(agentHealthRulesSrc, agentHealthRulesDest);
|
|
449
|
+
result.steps.push('Copied agent-health-rules.yaml → .workflow/config/');
|
|
450
|
+
}
|
|
451
|
+
|
|
435
452
|
return result;
|
|
436
453
|
}
|
|
437
454
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createLogger } from './logger.mjs';
|
|
4
|
+
import { parseTtl } from './error-classifier.mjs';
|
|
5
|
+
|
|
6
|
+
const HEALTH_FILE = '.workflow/state/agent-health.json';
|
|
7
|
+
const LOCK_FILE = '.workflow/state/agent-health.json.lock';
|
|
8
|
+
const SUPPORTED_VERSION = '1.0';
|
|
9
|
+
const MAX_LOCK_RETRIES = 5;
|
|
10
|
+
const LOCK_TIMEOUT_MS = 2000;
|
|
11
|
+
const LOCK_BACKOFFS = [100, 200, 400, 800, 1600];
|
|
12
|
+
|
|
13
|
+
const logger = createLogger();
|
|
14
|
+
|
|
15
|
+
export class AgentHealthLockError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'AgentHealthLockError';
|
|
19
|
+
if (Error.captureStackTrace) {
|
|
20
|
+
Error.captureStackTrace(this, this.constructor);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getHealthFilePath(projectRoot) {
|
|
26
|
+
return path.join(projectRoot, HEALTH_FILE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getLockFilePath(projectRoot) {
|
|
30
|
+
return path.join(projectRoot, LOCK_FILE);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureDir(dirPath) {
|
|
34
|
+
if (!fs.existsSync(dirPath)) {
|
|
35
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseTimestamp(isoString) {
|
|
40
|
+
if (!isoString) return null;
|
|
41
|
+
const date = new Date(isoString);
|
|
42
|
+
return isNaN(date.getTime()) ? null : date.getTime();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readHealthFile(filePath) {
|
|
46
|
+
if (!fs.existsSync(filePath)) {
|
|
47
|
+
return { version: SUPPORTED_VERSION, updated_at: null, agents: {} };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
51
|
+
const data = JSON.parse(content);
|
|
52
|
+
if (!data || typeof data !== 'object') {
|
|
53
|
+
logger.warn(`agent-health-registry: corrupted JSON in ${filePath}, returning empty state`);
|
|
54
|
+
return { version: SUPPORTED_VERSION, updated_at: null, agents: {} };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
version: data.version || SUPPORTED_VERSION,
|
|
58
|
+
updated_at: data.updated_at || null,
|
|
59
|
+
agents: data.agents || {}
|
|
60
|
+
};
|
|
61
|
+
} catch (e) {
|
|
62
|
+
logger.warn(`agent-health-registry: corrupted JSON in ${filePath}, returning empty state`);
|
|
63
|
+
return { version: SUPPORTED_VERSION, updated_at: null, agents: {} };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeHealthFileAtomic(filePath, data, lockFilePath, projectRoot) {
|
|
68
|
+
const tmpPath = filePath + '.tmp';
|
|
69
|
+
const dirPath = path.dirname(filePath);
|
|
70
|
+
ensureDir(dirPath);
|
|
71
|
+
|
|
72
|
+
const startTime = Date.now();
|
|
73
|
+
for (let attempt = 0; attempt < MAX_LOCK_RETRIES; attempt++) {
|
|
74
|
+
const elapsed = Date.now() - startTime;
|
|
75
|
+
if (elapsed >= LOCK_TIMEOUT_MS) {
|
|
76
|
+
throw new AgentHealthLockError(`Failed to acquire lock within ${LOCK_TIMEOUT_MS}ms`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const backoff = LOCK_BACKOFFS[attempt];
|
|
80
|
+
const remaining = LOCK_TIMEOUT_MS - elapsed;
|
|
81
|
+
const sleepTime = Math.min(backoff, remaining);
|
|
82
|
+
|
|
83
|
+
if (attempt > 0) {
|
|
84
|
+
if (sleepTime > 0) {
|
|
85
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
86
|
+
const start = Date.now();
|
|
87
|
+
while (Date.now() - start < sleepTime) {
|
|
88
|
+
// busy wait
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
fs.openSync(lockFilePath, 'wx');
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.code === 'EEXIST') {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const content = JSON.stringify(data, null, 2);
|
|
104
|
+
fs.writeFileSync(tmpPath, content, 'utf-8');
|
|
105
|
+
fs.renameSync(tmpPath, filePath);
|
|
106
|
+
return;
|
|
107
|
+
} finally {
|
|
108
|
+
try {
|
|
109
|
+
fs.unlinkSync(lockFilePath);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// ignore lock cleanup errors
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw new AgentHealthLockError(`Failed to acquire lock after ${MAX_LOCK_RETRIES} attempts`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function loadHealth(projectRoot, now = Date.now()) {
|
|
120
|
+
const filePath = getHealthFilePath(projectRoot);
|
|
121
|
+
const data = readHealthFile(filePath);
|
|
122
|
+
pruneExpired(projectRoot, now);
|
|
123
|
+
return { agents: data.agents };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function markUnhealthy(projectRoot, agentId, options) {
|
|
127
|
+
const { class: agentClass, rule_id, ttl, reason } = options;
|
|
128
|
+
const filePath = getHealthFilePath(projectRoot);
|
|
129
|
+
const lockPath = getLockFilePath(projectRoot);
|
|
130
|
+
|
|
131
|
+
const existing = readHealthFile(filePath);
|
|
132
|
+
const currentTime = new Date().toISOString();
|
|
133
|
+
|
|
134
|
+
let untilMs;
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
if (typeof ttl === 'number') {
|
|
137
|
+
// Legacy: numeric milliseconds offset
|
|
138
|
+
untilMs = now + ttl;
|
|
139
|
+
} else if (typeof ttl === 'string') {
|
|
140
|
+
// String TTL: 'until_utc_midnight', '1h', '5m', '1d', 'infinite', etc.
|
|
141
|
+
try {
|
|
142
|
+
untilMs = parseTtl(ttl, now);
|
|
143
|
+
} catch {
|
|
144
|
+
untilMs = now + 5 * 60 * 1000;
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
untilMs = now + 5 * 60 * 1000;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const untilIso = new Date(untilMs).toISOString();
|
|
151
|
+
|
|
152
|
+
existing.agents[agentId] = {
|
|
153
|
+
status: 'unhealthy',
|
|
154
|
+
class: agentClass,
|
|
155
|
+
rule_id: rule_id || null,
|
|
156
|
+
reason: reason || null,
|
|
157
|
+
marked_at: currentTime,
|
|
158
|
+
until: untilIso
|
|
159
|
+
};
|
|
160
|
+
existing.updated_at = currentTime;
|
|
161
|
+
|
|
162
|
+
writeHealthFileAtomic(filePath, existing, lockPath, projectRoot);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function markHealthy(projectRoot, agentId) {
|
|
166
|
+
const filePath = getHealthFilePath(projectRoot);
|
|
167
|
+
const lockPath = getLockFilePath(projectRoot);
|
|
168
|
+
|
|
169
|
+
const existing = readHealthFile(filePath);
|
|
170
|
+
|
|
171
|
+
if (existing.agents[agentId]) {
|
|
172
|
+
delete existing.agents[agentId];
|
|
173
|
+
existing.updated_at = new Date().toISOString();
|
|
174
|
+
writeHealthFileAtomic(filePath, existing, lockPath, projectRoot);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function isHealthy(projectRoot, agentId, now = Date.now()) {
|
|
179
|
+
const filePath = getHealthFilePath(projectRoot);
|
|
180
|
+
const data = readHealthFile(filePath);
|
|
181
|
+
const agent = data.agents[agentId];
|
|
182
|
+
|
|
183
|
+
if (!agent) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (agent.status !== 'unhealthy') {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const untilMs = parseTimestamp(agent.until);
|
|
192
|
+
if (untilMs === null) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return now >= untilMs;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function unhealthy(projectRoot, now = Date.now()) {
|
|
200
|
+
const filePath = getHealthFilePath(projectRoot);
|
|
201
|
+
const data = readHealthFile(filePath);
|
|
202
|
+
const result = [];
|
|
203
|
+
|
|
204
|
+
for (const [agentId, agent] of Object.entries(data.agents)) {
|
|
205
|
+
if (agent.status !== 'unhealthy') {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const untilMs = parseTimestamp(agent.until);
|
|
209
|
+
if (untilMs !== null && now < untilMs) {
|
|
210
|
+
result.push({
|
|
211
|
+
agentId,
|
|
212
|
+
class: agent.class,
|
|
213
|
+
rule_id: agent.rule_id,
|
|
214
|
+
reason: agent.reason,
|
|
215
|
+
until: agent.until
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function pruneExpired(projectRoot, now = Date.now()) {
|
|
224
|
+
const filePath = getHealthFilePath(projectRoot);
|
|
225
|
+
const lockPath = getLockFilePath(projectRoot);
|
|
226
|
+
|
|
227
|
+
const existing = readHealthFile(filePath);
|
|
228
|
+
let changed = false;
|
|
229
|
+
|
|
230
|
+
for (const [agentId, agent] of Object.entries(existing.agents)) {
|
|
231
|
+
if (agent.status !== 'unhealthy') {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const untilMs = parseTimestamp(agent.until);
|
|
235
|
+
if (untilMs !== null && now >= untilMs) {
|
|
236
|
+
delete existing.agents[agentId];
|
|
237
|
+
changed = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (changed) {
|
|
242
|
+
existing.updated_at = new Date().toISOString();
|
|
243
|
+
writeHealthFileAtomic(filePath, existing, lockPath, projectRoot);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { readdirSync, statSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative, isAbsolute } from 'path';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SNAPSHOT_MAX_FILE_SIZE = 524288;
|
|
7
|
+
const DEFAULT_EXCLUDE_PATTERNS = [
|
|
8
|
+
'.workflow/logs/**',
|
|
9
|
+
'.workflow/state/**',
|
|
10
|
+
'**/.git/**',
|
|
11
|
+
'**/node_modules/**',
|
|
12
|
+
'**/*.tmp'
|
|
13
|
+
];
|
|
14
|
+
const DEFAULT_INCLUDE_PATHS = [
|
|
15
|
+
'.workflow/tickets',
|
|
16
|
+
'.workflow/plans',
|
|
17
|
+
'.workflow/reports',
|
|
18
|
+
'src',
|
|
19
|
+
'configs'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function matchesGlob(filePath, pattern) {
|
|
23
|
+
// Normalize to forward slashes, strip leading ./
|
|
24
|
+
const normPath = filePath.replace(/\\/g, '/');
|
|
25
|
+
const p = pattern.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
26
|
+
|
|
27
|
+
// Build regex from glob pattern.
|
|
28
|
+
// Rules:
|
|
29
|
+
// **/ at the very start → optional prefix of zero-or-more directories
|
|
30
|
+
// /** at the end → any file/subdir below this directory
|
|
31
|
+
// **/{seg}/** → {seg} appears anywhere as a path component
|
|
32
|
+
// **/*.ext → any file with .ext in any subdirectory
|
|
33
|
+
// * → any characters except /
|
|
34
|
+
let result = '^';
|
|
35
|
+
let i = 0;
|
|
36
|
+
|
|
37
|
+
while (i < p.length) {
|
|
38
|
+
const ch = p[i];
|
|
39
|
+
|
|
40
|
+
if (ch === '*' && p[i + 1] === '*') {
|
|
41
|
+
// Double-star glob
|
|
42
|
+
if (i === 0 && p[i + 2] === '/') {
|
|
43
|
+
// **/ at the very start: zero-or-more leading directories
|
|
44
|
+
result += '(?:.+/)?';
|
|
45
|
+
i += 3;
|
|
46
|
+
} else if (p[i - 1] === '/' && i + 2 >= p.length) {
|
|
47
|
+
// /** at the end (preceded by /): any suffix including sub-paths
|
|
48
|
+
result += '.*';
|
|
49
|
+
i += 2;
|
|
50
|
+
} else if (p[i - 1] === '/' && p[i + 2] === '/') {
|
|
51
|
+
// /**/ in the middle: optional sub-path segment
|
|
52
|
+
result += '(?:.+/)?';
|
|
53
|
+
i += 3;
|
|
54
|
+
} else {
|
|
55
|
+
// ** in any other position: match anything
|
|
56
|
+
result += '.*';
|
|
57
|
+
i += 2;
|
|
58
|
+
}
|
|
59
|
+
} else if (ch === '*') {
|
|
60
|
+
// Single star: any characters except /
|
|
61
|
+
result += '[^/]*';
|
|
62
|
+
i++;
|
|
63
|
+
} else if (ch === '?') {
|
|
64
|
+
result += '[^/]';
|
|
65
|
+
i++;
|
|
66
|
+
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
|
67
|
+
result += '\\' + ch;
|
|
68
|
+
i++;
|
|
69
|
+
} else {
|
|
70
|
+
result += ch;
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
result += '$';
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
return new RegExp(result).test(normPath);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function shouldExclude(path, excludePatterns) {
|
|
85
|
+
for (const pattern of excludePatterns) {
|
|
86
|
+
if (matchesGlob(path, pattern)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function computeFileHash(filePath, maxSize) {
|
|
94
|
+
try {
|
|
95
|
+
const stats = statSync(filePath);
|
|
96
|
+
if (stats.size > maxSize) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const content = readFileSync(filePath);
|
|
100
|
+
return createHash('sha1').update(content).digest('hex');
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function walkDirectory(dirPath, basePath, excludePatterns, maxFileSize) {
|
|
107
|
+
const result = new Map();
|
|
108
|
+
|
|
109
|
+
if (!existsSync(dirPath)) {
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function walk(dir) {
|
|
114
|
+
try {
|
|
115
|
+
const entries = readdirSync(dir);
|
|
116
|
+
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
const fullPath = join(dir, entry);
|
|
119
|
+
const relativePath = relative(basePath, fullPath).replace(/\\/g, '/');
|
|
120
|
+
|
|
121
|
+
if (shouldExclude(relativePath, excludePatterns)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const stats = statSync(fullPath);
|
|
127
|
+
|
|
128
|
+
if (stats.isDirectory()) {
|
|
129
|
+
walk(fullPath);
|
|
130
|
+
} else if (stats.isFile()) {
|
|
131
|
+
const sha1 = computeFileHash(fullPath, maxFileSize);
|
|
132
|
+
result.set(relativePath, {
|
|
133
|
+
mtime: stats.mtimeMs,
|
|
134
|
+
size: stats.size,
|
|
135
|
+
sha1
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.warn(`[WARN] artifact-snapshot: skip ${relativePath}: ${e.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.warn(`[WARN] artifact-snapshot: walk ${dir}: ${e.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
walk(dirPath);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function snapshot(projectRoot, options = {}) {
|
|
152
|
+
const includePaths = options.includePaths || DEFAULT_INCLUDE_PATHS;
|
|
153
|
+
const excludePatterns = options.excludePatterns || DEFAULT_EXCLUDE_PATTERNS;
|
|
154
|
+
const snapshotMaxFileSize = options.snapshotMaxFileSize || DEFAULT_SNAPSHOT_MAX_FILE_SIZE;
|
|
155
|
+
|
|
156
|
+
let gitOutput = '';
|
|
157
|
+
const gitEnabled = existsSync(join(projectRoot, '.git'));
|
|
158
|
+
|
|
159
|
+
if (gitEnabled) {
|
|
160
|
+
try {
|
|
161
|
+
gitOutput = execFileSync('git', ['status', '--porcelain=v1', '-z'], {
|
|
162
|
+
cwd: projectRoot,
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
maxBuffer: 10 * 1024 * 1024
|
|
165
|
+
});
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.warn(`[WARN] artifact-snapshot: git status failed: ${e.message}`);
|
|
168
|
+
gitOutput = '';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const fsMap = new Map();
|
|
173
|
+
|
|
174
|
+
for (const includePath of includePaths) {
|
|
175
|
+
const fullPath = isAbsolute(includePath)
|
|
176
|
+
? includePath
|
|
177
|
+
: join(projectRoot, includePath);
|
|
178
|
+
|
|
179
|
+
if (existsSync(fullPath)) {
|
|
180
|
+
const dirMap = walkDirectory(fullPath, projectRoot, excludePatterns, snapshotMaxFileSize);
|
|
181
|
+
for (const [key, value] of dirMap) {
|
|
182
|
+
fsMap.set(key, value);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
git: gitOutput,
|
|
189
|
+
fs: fsMap,
|
|
190
|
+
timestamp: Date.now()
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function diff(before, after) {
|
|
195
|
+
const created = [];
|
|
196
|
+
const changed = [];
|
|
197
|
+
const deleted = [];
|
|
198
|
+
|
|
199
|
+
const beforeFs = before.fs;
|
|
200
|
+
const afterFs = after.fs;
|
|
201
|
+
|
|
202
|
+
const beforeFiles = new Set(beforeFs.keys());
|
|
203
|
+
const afterFiles = new Set(afterFs.keys());
|
|
204
|
+
|
|
205
|
+
for (const file of afterFiles) {
|
|
206
|
+
if (!beforeFiles.has(file)) {
|
|
207
|
+
created.push(file);
|
|
208
|
+
} else {
|
|
209
|
+
const beforeMeta = beforeFs.get(file);
|
|
210
|
+
const afterMeta = afterFs.get(file);
|
|
211
|
+
|
|
212
|
+
if (beforeMeta.mtime !== afterMeta.mtime ||
|
|
213
|
+
beforeMeta.size !== afterMeta.size ||
|
|
214
|
+
beforeMeta.sha1 !== afterMeta.sha1) {
|
|
215
|
+
changed.push(file);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const file of beforeFiles) {
|
|
221
|
+
if (!afterFiles.has(file)) {
|
|
222
|
+
deleted.push(file);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { changed, created, deleted };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isEmpty(diffResult) {
|
|
230
|
+
return diffResult.changed.length === 0 &&
|
|
231
|
+
diffResult.created.length === 0 &&
|
|
232
|
+
diffResult.deleted.length === 0;
|
|
233
|
+
}
|